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
. 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("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(".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"); }