185 lines
9.5 KiB
TypeScript
185 lines
9.5 KiB
TypeScript
import { mermaid, type MermaidConfig } from "@streamdown/mermaid";
|
||
import type { InvestigationDiagram } from "../api/investigation-report";
|
||
|
||
export const INVESTIGATION_DIAGRAM_FONT =
|
||
'"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif';
|
||
|
||
export const investigationDiagramConfig: MermaidConfig = {
|
||
securityLevel: "strict",
|
||
theme: "base",
|
||
htmlLabels: true,
|
||
fontFamily: INVESTIGATION_DIAGRAM_FONT,
|
||
themeVariables: {
|
||
fontFamily: INVESTIGATION_DIAGRAM_FONT,
|
||
fontSize: "18px",
|
||
background: "#FFFFFF",
|
||
primaryColor: "#FFFFFF",
|
||
primaryTextColor: "#26334D",
|
||
primaryBorderColor: "#A5B5CC",
|
||
secondaryColor: "#FFF6E8",
|
||
secondaryTextColor: "#78531B",
|
||
secondaryBorderColor: "#C3A575",
|
||
tertiaryColor: "#F6F8FC",
|
||
tertiaryBorderColor: "#CCD6E5",
|
||
tertiaryTextColor: "#475976",
|
||
lineColor: "#7689A6",
|
||
edgeLabelBackground: "#FFFFFF",
|
||
clusterBkg: "#F6F8FC",
|
||
clusterBorder: "#CCD6E5"
|
||
},
|
||
flowchart: {
|
||
useMaxWidth: false,
|
||
curve: "stepAfter",
|
||
nodeSpacing: 56,
|
||
rankSpacing: 64,
|
||
padding: 22,
|
||
diagramPadding: 24,
|
||
subGraphTitleMargin: { top: 12, bottom: 48 },
|
||
wrappingWidth: 260
|
||
},
|
||
themeCSS: `
|
||
.node rect { rx: 6px; ry: 6px; stroke-width: 1.4px; }
|
||
.node polygon { stroke-width: 1.6px; }
|
||
.nodeLabel { font-weight: 500; }
|
||
.nodeLabel p { text-wrap: balance; line-height: 1.6; }
|
||
.edgePath path, .flowchart-link { stroke-width: 1.35px; }
|
||
.edgeLabel { color: #536784; font-size: 15px; }
|
||
.edgeLabel p { padding: 3px 7px; border-radius: 4px; line-height: 1.5; }
|
||
.cluster rect { rx: 8px; ry: 8px; stroke-width: 1px; }
|
||
.cluster-label { font-weight: 600; font-size: 16px; }
|
||
.cluster-label .nodeLabel { background: #F6F8FC; padding: 0 4px; }
|
||
`
|
||
};
|
||
|
||
/** Keep group captions beside crossing links, without moving nodes or changing edges. */
|
||
export const investigationDiagramPlugins = {
|
||
mermaid: {
|
||
...mermaid,
|
||
getMermaid(config?: MermaidConfig) {
|
||
const renderer = mermaid.getMermaid(config);
|
||
return {
|
||
initialize: (nextConfig: MermaidConfig) => renderer.initialize(nextConfig),
|
||
async render(id: string, source: string) {
|
||
// Streamdown's two-argument renderer uses Mermaid's temporary #d{id}
|
||
// under body. It must remain measurable, but never enter page flow.
|
||
// Scope the rule to this render, including fullscreen and error paths.
|
||
const measurementStyle = document.createElement("style");
|
||
measurementStyle.textContent = `body > [id=${JSON.stringify(`d${id}`)}] {
|
||
position: fixed !important; inset: 0 auto auto 0;
|
||
width: 100%; visibility: hidden; pointer-events: none;
|
||
contain: layout paint;
|
||
}`;
|
||
document.head.append(measurementStyle);
|
||
const result = await renderer.render(id, source).finally(() => measurementStyle.remove());
|
||
// Mermaid's strict renderer emits HTML labels inside foreignObject,
|
||
// including HTML void tags such as <br>. Parse its sanitized output in
|
||
// an inert HTML fragment, not as a standalone XML document.
|
||
const template = document.createElement("template");
|
||
template.innerHTML = result.svg;
|
||
const svg = template.content.firstElementChild;
|
||
if (template.content.childElementCount !== 1 || svg?.localName !== "svg"
|
||
|| svg.namespaceURI !== "http://www.w3.org/2000/svg" || svg.querySelector("parsererror")) {
|
||
throw new Error("Mermaid did not return a valid SVG diagram");
|
||
}
|
||
try {
|
||
svg.querySelectorAll(".cluster").forEach((cluster) => {
|
||
const rect = Array.from(cluster.children).find((child) => child.tagName === "rect");
|
||
const caption = cluster.querySelector(".cluster-label");
|
||
if (!rect || !caption) return;
|
||
const x = Number(rect.getAttribute("x"));
|
||
const y = Number(rect.getAttribute("y"));
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||
caption.setAttribute(
|
||
"transform",
|
||
`translate(${x + 18}, ${y + (config?.flowchart?.subGraphTitleMargin?.top ?? 12)})`
|
||
);
|
||
// Mermaid paints crossing edges after clusters; keep captions above those edges.
|
||
const clusters = cluster.parentElement;
|
||
if (clusters?.classList.contains("clusters")) clusters.parentElement?.append(caption);
|
||
});
|
||
// Streamdown embeds this as HTML, just like Mermaid's original output.
|
||
return { ...result, svg: svg.outerHTML };
|
||
} catch {
|
||
// Caption placement is cosmetic; never discard a usable original graph.
|
||
return result;
|
||
}
|
||
}
|
||
};
|
||
}
|
||
}
|
||
};
|
||
|
||
export const investigationDiagramStates = {
|
||
pending: { className: "tjPending", label: "尚未开始", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#475976", width: 1.5, dash: "0" },
|
||
running: { className: "tjRunning", label: "正在执行", fill: "#E8F0FF", stroke: "#4263C7", color: "#284574", width: 2.5, dash: "0" },
|
||
completed: { className: "tjCompleted", label: "已完成", fill: "#EDF7F4", stroke: "#6B9E8B", color: "#2D5E4E", width: 1.8, dash: "0" },
|
||
waiting: { className: "tjWaiting", label: "等待依赖 / 外部结果", fill: "#FFFBEA", stroke: "#C8AC60", color: "#78531B", width: 1.8, dash: "6 3" },
|
||
waiting_human: { className: "tjWaitingHuman", label: "等待人工处理", fill: "#FFF6E8", stroke: "#C4A168", color: "#735018", width: 2, dash: "0" },
|
||
failed: { className: "tjFailed", label: "执行失败", fill: "#FEF2F2", stroke: "#D96C75", color: "#922C3E", width: 2, dash: "0" },
|
||
skipped: { className: "tjSkipped", label: "已跳过", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#64748B", width: 1.5, dash: "8 4" },
|
||
cancelled: { className: "tjCancelled", label: "已取消", fill: "#F1F5F9", stroke: "#94A3B8", color: "#64748B", width: 1.5, dash: "2 3" },
|
||
recorded_running: { className: "tjRecordedRunning", label: "记录时执行中", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#475976", width: 1.5, dash: "6 3" },
|
||
unknown: { className: "tjUnknown", label: "未标注状态", fill: "#FFFFFF", stroke: "#CCD6E5", color: "#536784", width: 1.4, dash: "0" }
|
||
} as const;
|
||
|
||
export function investigationNodeState(
|
||
node: InvestigationDiagram["node_refs"][number],
|
||
running = false
|
||
): keyof typeof investigationDiagramStates {
|
||
if (node.status === "running" && !running) return "recorded_running";
|
||
return node.status ?? "unknown";
|
||
}
|
||
|
||
function nodeStyle(state: typeof investigationDiagramStates[keyof typeof investigationDiagramStates]) {
|
||
return `fill:${state.fill},stroke:${state.stroke},color:${state.color},stroke-width:${state.width}px,stroke-dasharray:${state.dash}`;
|
||
}
|
||
|
||
export const investigationDiagramNodeId = (node: Element | null | undefined) =>
|
||
node?.getAttribute("data-id") ?? node?.id.match(/(?:^|-)flowchart-(.+)-\d+$/)?.[1];
|
||
|
||
/** Update state-only changes in place, preserving the reader's pan and zoom. */
|
||
export function paintInvestigationDiagramStates(root: Element, diagram: InvestigationDiagram, running: boolean) {
|
||
const refs = new Map(diagram.node_refs.map((node) => [node.node_id, node]));
|
||
const classes = Object.values(investigationDiagramStates).map((state) => state.className);
|
||
root.querySelectorAll<SVGGElement>("svg g.node").forEach((node) => {
|
||
const ref = refs.get(investigationDiagramNodeId(node) ?? "");
|
||
const state = investigationDiagramStates[ref ? investigationNodeState(ref, running) : "unknown"];
|
||
node.classList.remove(...classes);
|
||
node.classList.add(state.className);
|
||
node.setAttribute("aria-label", `${ref?.label ?? node.textContent?.trim() ?? "节点"}:${state.label}`);
|
||
for (const shape of Array.from(node.children)) {
|
||
if (!shape.matches("rect, polygon, path, circle, ellipse")) continue;
|
||
const style = (shape as SVGElement).style;
|
||
style.setProperty("fill", state.fill, "important");
|
||
style.setProperty("stroke", state.stroke, "important");
|
||
style.setProperty("stroke-width", `${state.width}px`, "important");
|
||
style.setProperty("stroke-dasharray", state.dash, "important");
|
||
}
|
||
node.querySelectorAll<SVGElement | HTMLElement>(".nodeLabel, .nodeLabel p").forEach((label) => {
|
||
label.style.setProperty("color", state.color, "important");
|
||
label.style.setProperty("fill", state.color, "important");
|
||
});
|
||
});
|
||
}
|
||
|
||
/** Color explicit recorded states; relationships and evidence never imply completion. */
|
||
export function styleInvestigationDiagram(diagram: InvestigationDiagram, running = false) {
|
||
const assignments = diagram.node_refs.flatMap((node) => {
|
||
const escaped = node.node_id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
const inSource = new RegExp(`\\b${escaped}(?=\\s*(?:[\\[({@;]|--|==|-\\.|$))`, "m").test(
|
||
diagram.mermaid
|
||
);
|
||
if (!inSource) return [];
|
||
const state = investigationDiagramStates[investigationNodeState(node, running)];
|
||
return [`class ${node.node_id} ${state.className};`, `style ${node.node_id} ${nodeStyle(state)};`];
|
||
});
|
||
// Keep role/reference classes neutral. Their presence conveys association,
|
||
// while only node_refs.status supplies an execution fact.
|
||
const roles = ["default", "tjAgent", "tjHuman", "tjDecision", "tjEvidence", "tjAction"]
|
||
.map((name) => `classDef ${name} ${nodeStyle(investigationDiagramStates.unknown)};`);
|
||
const states = Object.entries(investigationDiagramStates).map(([key, state]) =>
|
||
`classDef ${state.className} ${nodeStyle(key === "running" && !running ? investigationDiagramStates.recorded_running : state)};`
|
||
);
|
||
return [diagram.mermaid, ...roles, ...states, ...assignments].join("\n");
|
||
}
|