import { fitLine, type FlowNode, type FlowTone, type FlowVariant } from "./flow-layout"; /** * 流程图节点/连线的共用画笔:FlowDiagram(diagram-js 渲染)与 FlowEditor(手绘编辑) * 都从这里取形——同一套配色/字级/几何,编辑态与展示态不许有视觉漂移。 */ const svgNS = "http://www.w3.org/2000/svg"; export function svgEl( tag: K, attrs: Record, text?: string, ): SVGElementTagNameMap[K] { const el = document.createElementNS(svgNS, tag); for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, String(value)); if (text !== undefined) el.textContent = text; return el; } export const FLOW_SANS = '"PingFang SC","Noto Sans SC","Hiragino Sans GB","Microsoft YaHei",sans-serif'; export const FLOW_MONO = '"SF Mono","JetBrains Mono",Menlo,ui-monospace,monospace'; export const TONE_COLOR: Record = { main: "#46586A", ok: "#3F7A5C", bad: "#B0413E" }; /** 节点画形(往 group 里追加图元;调用方自己定位 group)。 */ export function paintNode(group: SVGElement, node: FlowNode, w: number, h: number, variant: FlowVariant): void { if (node.kind === "start" || node.kind === "end" || node.kind === "end-bad") { const bad = node.kind === "end-bad"; group.append( svgEl("circle", { cx: w / 2, cy: h / 2, r: w / 2, fill: bad ? "#F6E7E6" : "#E6F1EA", stroke: bad ? "#B0413E" : "#3F7A5C", "stroke-width": node.kind === "start" ? 1.8 : bad ? 2.4 : 2.6, }), ); } else if (node.kind === "gateway") { const hd = w / 2; // 2026-07 精简:网关无弹窗、无铜点——判据存于节点数据,导出派生表与 L3 upstreamGates 承接。 group.append( svgEl("polygon", { points: `${hd},0 ${w},${hd} ${hd},${h} 0,${hd}`, fill: "#EEF1F5", stroke: "#46586A", "stroke-width": 1.3, }), ); group.append( svgEl( "text", { x: hd, y: hd + 4, "text-anchor": "middle", "font-family": FLOW_MONO, "font-size": variant === "business" ? 13 : 12, "font-weight": 700, fill: "#46586A", }, node.mode === "and" ? "+" : "×", ), ); } else if (node.kind === "callout") { // 卡点统一红(绿色让位给 Case 活动节点);去向以框内 →id 表达。 group.append( svgEl("rect", { x: 0, y: 0, width: w, height: h, rx: variant === "business" ? 5 : 6, fill: "#F6E7E6", stroke: "#B0413E", "stroke-width": variant === "business" ? 1.2 : 1.3, }), ); const fitted = fitLine( node.to ? `${node.label}→${node.to}` : node.label, w - 12, variant === "business" ? 10 : 11.5, 9, ); group.append( svgEl( "text", { x: w / 2, y: h / 2 + 3.5, "text-anchor": "middle", "font-family": FLOW_SANS, "font-size": fitted.size, "font-weight": 600, fill: "#8F3330", }, fitted.text, ), ); } else { // 方框三色:标准活动 浅蓝(缺省)/ 案例活动 绿(一事一议)/ 审批点 琥珀(人裁节点)。 // 审批点非活动,只是共用方框几何——模型上它是系统内置的编排要素(见 flow-layout 的 flavor 注释)。 // 活动调用已整体退役(2026-07 终拍:所有节点都是内置节点;schema 容忍遗留 wait 但不再有专属画法)。 const ghost = node.ghost === true; const isCase = node.flavor === "case"; const isApproval = node.flavor === "approval"; const skin = isApproval ? { fill: "#FBF0DC", stroke: "#B98432", ink: "#6E4B12" } : isCase ? { fill: "#E6F1EA", stroke: "#3F7A5C", ink: "#2F5C45" } : { fill: "#E8F1FB", stroke: "#4E7DAF", ink: "#161B22" }; group.append( svgEl("rect", { x: 0, y: 0, width: w, height: h, rx: 6, fill: ghost ? "#F4F6F9" : skin.fill, stroke: ghost ? "#9AA6B4" : skin.stroke, "stroke-width": ghost ? 1.2 : 1.3, ...(ghost ? { "stroke-dasharray": "4 3" } : {}), }), ); const titleSize = variant === "business" ? 12 : 11.5; const inkColor = ghost ? "#6A7686" : skin.ink; const title = fitLine(node.label, w - 12, titleSize, 10); // L2 主图只留 标号/活动名/责任角标(2026-07 精简)——sub 小字仅步骤图(steps)渲染。 if (node.sub && variant !== "business") { const sub = fitLine(node.sub, w - 12, 8.6, 8); group.append( svgEl( "text", { x: w / 2, y: 18, "text-anchor": "middle", "font-family": FLOW_SANS, "font-size": title.size, "font-weight": 600, fill: inkColor }, title.text, ), ); group.append( svgEl( "text", { x: w / 2, y: 32, "text-anchor": "middle", "font-family": FLOW_SANS, "font-size": sub.size, fill: ghost ? "#9AA6B4" : "#6A7686" }, sub.text, ), ); } else { group.append( svgEl( "text", { x: w / 2, y: h / 2 + 4, "text-anchor": "middle", "font-family": FLOW_SANS, "font-size": title.size, "font-weight": 600, fill: inkColor }, title.text, ), ); } // 标号角标:business 一贯有;步骤图(steps)的真实节点也要 S#/B# 编号与指导书条目对齐 // (2026-07 用户纠正)——ghost 骨架节点不标。 if (variant === "business" || !ghost) { group.append( svgEl("text", { x: 6, y: 11, "font-family": FLOW_MONO, "font-size": 8.5, fill: "#8A93A0" }, node.code ?? node.id), ); } /* 责任人角标已退役(2026-07-30):编排不带岗位 —— 谁来干是运营时分配的,不画在图上。 */ // 分工角标已随词签退役(2026-07 user「删除 AI/系统/人 选项配置」):步骤图不再画 assign 角标。 } } /** 箭头三角(按折线最后一段方向)。 */ export function arrowHeadPoints(waypoints: Array<{ x: number; y: number }>): string { const tip = waypoints[waypoints.length - 1]; const prev = waypoints[waypoints.length - 2]; // 严格索引:两点以下画不出箭头,直接退空(布局引擎不会产出这种连线,此处只为类型闭合) if (tip === undefined || prev === undefined) return ""; const dx = Math.sign(tip.x - prev.x); const dy = Math.sign(tip.y - prev.y); return dx !== 0 ? `${tip.x},${tip.y} ${tip.x - dx * 9},${tip.y - 4.5} ${tip.x - dx * 9},${tip.y + 4.5}` : `${tip.x},${tip.y} ${tip.x - 4.5},${tip.y - dy * 9} ${tip.x + 4.5},${tip.y - dy * 9}`; } export const CAPTION_STYLE: Record number; font: string; weight?: number }> = { gateway: { fill: "#3A4452", size: (v) => (v === "business" ? 10 : 9), font: FLOW_SANS, weight: 600 }, start: { fill: "#3F7A5C", size: (v) => (v === "business" ? 10 : 8.6), font: FLOW_SANS, weight: 600 }, end: { fill: "#2F5C45", size: (v) => (v === "business" ? 10 : 8.6), font: FLOW_SANS, weight: 600 }, "end-bad": { fill: "#8F3330", size: (v) => (v === "business" ? 10 : 8.6), font: FLOW_SANS, weight: 600 }, note: { fill: "#8A5F1C", size: (v) => (v === "business" ? 9.8 : 8.6), font: FLOW_MONO }, };