Keep investigation graphs stable during updates, defer expensive chart rendering, and prevent stale results when switching tasks. Adopt reference annotations and consolidate frontend documentation.
207 lines
10 KiB
TypeScript
207 lines
10 KiB
TypeScript
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<DiagramResult> };
|
||
const diagramCaches = new WeakMap<object, Map<string, CachedDiagram>>();
|
||
|
||
/** 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<string, CachedDiagram>();
|
||
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 <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 + (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<SVGGElement>("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<SVGElement | HTMLElement>(".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");
|
||
}
|