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