Files
next-tjwater-frontend/src/features/workbench/utils/investigation-diagram-style.ts
T
jiang c0e4231b83
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s
feat: expand intelligent investigation workspace
2026-10-30 17:30:19 +08:00

185 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}