import { mermaid, type MermaidConfig } from "@streamdown/mermaid"; import type { InvestigationDiagram } from "../api/investigation-report"; import { normalizeInvestigationNodeAnnotation } from "../api/investigation-node-state"; 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; } ` }; type DiagramResult = { svg: string }; type CachedDiagram = { id: string; result: Promise }; const diagramCaches = new WeakMap>(); /** 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); let effectiveConfig = config; const cache = diagramCaches.get(renderer) ?? new Map(); diagramCaches.set(renderer, cache); return { initialize: (nextConfig: MermaidConfig) => { effectiveConfig = nextConfig; renderer.initialize(nextConfig); }, async render(id: string, source: string) { const key = JSON.stringify([effectiveConfig, source]); const cached = cache.get(key); if (cached) { cache.delete(key); cache.set(key, cached); const result = await cached.result; // SVG markers and CSS must belong to this mounted instance. return { ...result, svg: result.svg.replaceAll(cached.id, id) }; } const result = renderSvg(id, source, effectiveConfig); const entry = { id, result }; cache.set(key, entry); if (cache.size > 32) cache.delete(cache.keys().next().value!); try { return await result; } catch (error) { if (cache.get(key) === entry) cache.delete(key); throw error; } } }; async function renderSvg(id: string, source: string, renderConfig?: MermaidConfig) { // 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 + (renderConfig?.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 = { reference: { className: "tjReference", label: "参考步骤", fill: "#FFFFFF", stroke: "#CCD6E5", color: "#536784", width: 1.4, dash: "0" }, recorded: { className: "tjRecorded", label: "已有结果", fill: "#EDF7F4", stroke: "#6B9E8B", color: "#2D5E4E", width: 1.8, dash: "0" }, needs_input: { className: "tjNeedsInput", label: "待补充条件", fill: "#FFFBEA", stroke: "#C8AC60", color: "#78531B", width: 1.8, dash: "6 3" }, failed: { className: "tjFailed", label: "尝试失败", fill: "#FEF2F2", stroke: "#D96C75", color: "#922C3E", width: 2, dash: "0" }, not_taken: { className: "tjNotTaken", label: "未采用路线", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#64748B", width: 1.5, dash: "8 4" } } as const; const legacyClasses = { tjPending: "reference", tjRunning: "reference", tjRecordedRunning: "reference", tjUnknown: "reference", tjCompleted: "recorded", tjWaiting: "needs_input", tjWaitingHuman: "needs_input", tjSkipped: "not_taken", tjCancelled: "not_taken" } as const; export function investigationNodeState(node: InvestigationDiagram["node_refs"][number]): keyof typeof investigationDiagramStates { return normalizeInvestigationNodeAnnotation(node.status); } 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) { const refs = new Map(diagram.node_refs.map((node) => [node.node_id, node])); const classes = [...Object.values(investigationDiagramStates).map((state) => state.className), ...Object.keys(legacyClasses)]; root.querySelectorAll("svg g.node").forEach((node) => { const ref = refs.get(investigationDiagramNodeId(node) ?? ""); const state = investigationDiagramStates[ref ? investigationNodeState(ref) : "reference"]; 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 saved reference annotations without deriving state from an execution run. */ export function styleInvestigationDiagram(diagram: InvestigationDiagram) { const assignments = diagram.node_refs.flatMap((node) => { const escaped = node.node_id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const inSource = new RegExp(`(?:^|[^A-Za-z0-9_-])${escaped}(?=\\s*(?:[\\[({@;]|:::|--|==|-\\.|$))`, "m").test( diagram.mermaid ); if (!inSource) return []; const state = investigationDiagramStates[investigationNodeState(node)]; return [`class ${node.node_id} ${state.className};`, `style ${node.node_id} ${nodeStyle(state)};`]; }); // Keep role/reference classes neutral. Their presence conveys association, // and only explicit node annotations describe saved findings. const roles = ["default", "tjAgent", "tjHuman", "tjDecision", "tjEvidence", "tjAction"] .map((name) => `classDef ${name} ${nodeStyle(investigationDiagramStates.reference)};`); const states = Object.values(investigationDiagramStates).map((state) => `classDef ${state.className} ${nodeStyle(state)};`); const legacy = Object.entries(legacyClasses).map(([name, annotation]) => `classDef ${name} ${nodeStyle(investigationDiagramStates[annotation])};`); return [diagram.mermaid, ...roles, ...states, ...legacy, ...assignments].join("\n"); }