/** * 流程图确定性布局引擎:把服务器规范化后的结构化图数据(节点/行/挂靠/连线) * 算成坐标与折线路径,喂给 diagram-js 绘制。SVG 几何不经模型之手。 * * 两组度量预设对应两份参考文档: * business = docs/业务流示例.html FIG 2.1(L2 操作流程图,viewBox 1000 宽) * steps = docs/操作流示例.html 步骤流程(L3,单行为主) * 常量取自参考 SVG 的实测几何。 * * 折行采用**蛇形(boustrophedon)**:偶数行 L→R、奇数行 R→L,行与行由一小段竖落相接。 * (参考图原本用右侧折返车道 x=966 绕回行首——那个大回环已弃用,`lane` 仅作跨多行的兜底; * 故此处已刻意偏离参考图的折返画法,改为蛇形。) */ export type FlowNodeKind = "start" | "end" | "end-bad" | "activity" | "gateway" | "callout"; export type FlowTone = "main" | "ok" | "bad"; export interface FlowNode { id: string; kind: FlowNodeKind; /** 仅活动:case=Case 节点(绿,一事一议,下钻 CMMN 信封);缺省=SOP 节点(蓝)。 */ /** * 节点变体:case = 案例活动(绿) / approval = 审批点(琥珀);缺省 = 标准活动(蓝)。 * ⚠️ approval **不是一种活动**——按规范,审批点是流程层的系统内置人裁节点,与活动并列为 * 编排层五要素之一,零开发、不进活动库。它挂在 kind:"activity" 下**仅因共用同一套方框几何** * (蛇形折行与尺寸计算),不表示它在模型上属于活动。 */ flavor?: "case" | "approval" | undefined; /** Case 节点引用的库案例 id(cases[].id):L2 只是引用,下钻编辑共享库案例。 */ caseRef?: string; label: string; code?: string; ghost?: boolean; note?: string; /** (遗留)仅卡点:旧编译器按方向着色的产物——卡点现统一红,渲染忽略。 */ tone?: "ok" | "bad"; /** 仅卡点:去向节点 id(回跳返工/前跳直达)——渲染时框内标 →id。 */ to?: string | undefined; /* * (已退役 2026-07-30)owner / in / out / use —— 责任人角标与「进/出/查」数据线。 * 编排**不带岗位**(谁来干是运营时分配的),**也不声明数据**(契约归活动自己)。 * 属性卡里那四行早已写了不生效(owner/use 连 ProcessNode 都没有),一并撤掉。 */ /** 判据下沉:仅网关——判定标准(节点数据;画布不再弹窗,导出派生表可见)。 */ basis?: string | undefined; /** 仅网关:x=判断(×,默认)/ and=并行(+,所有出口同时走)。 */ mode?: "x" | "and" | undefined; /** 执行基数(exec-spec):item=逐条办;缺省=跟单办。 */ per?: "item"; /** 人机分工:auto 自动 / assist 帮我做 / human 我来做(缺省)。 */ assign?: "auto" | "assist" | "human"; /** Wait/等待(BPMN 调用活动):被调流程规范名(软引用);画双边框+等待角词,点击跳被调流程。 */ wait?: string; /** 仅活动点:**链接的活动实现 id**(装载面)。属性卡选定,保存时合并为语义 activityId。 */ act?: string | undefined; /** 手绘图(graph.manual)专用:节点左上角坐标(px,MANUAL_GRID 的倍数)。 */ x?: number; y?: number; /** 手绘图专用:旁注文字(起止/网关名)中心点——注记自由摆放,不吸附网格。 */ lx?: number; ly?: number; } export interface FlowAnchor { node: string; at: "above" | "below" | "right"; of: string; } export interface FlowEdge { from: string; to: string; /** 边号(E1、E2…)—— 语义边的编号身份,画在线标前;拣选与选中按它认 */ code?: string | undefined; label?: string; tone?: FlowTone; /** 边属性(传递契约)的工作副本:S-FEEL 条件。属性卡填写,保存时合并为语义边的 condition。 */ condition?: string | undefined; /** 边属性(传递契约)的工作副本:字段映射(形状同语义边 mapping)。保存时合并。 */ mapping?: unknown[] | undefined; /** 手绘图专用:整条折线路径(px,含两端;每段水平或垂直)。 */ points?: Array<{ x: number; y: number }>; /** 手绘图专用:线标签文字中心点(注记,自由摆放)。 */ lx?: number; ly?: number; } export interface FlowGraph { nodes: FlowNode[]; rows: string[][]; anchors: FlowAnchor[]; edges: FlowEdge[]; /** true=手绘图:几何即真相(节点 x/y + 连线 points),不走自动布局。 */ manual?: boolean; } export type FlowVariant = "business" | "steps"; export interface ShapeLayout { node: FlowNode; x: number; y: number; w: number; h: number; } export interface CaptionLayout { text: string; x: number; y: number; anchor: "start" | "middle" | "end"; role: "gateway" | "start" | "end" | "end-bad" | "note"; } export interface ConnectionLayout { id: string; edge: FlowEdge; tone: FlowTone; waypoints: Array<{ x: number; y: number }>; label?: { text: string; x: number; y: number; anchor: "start" | "middle" | "end" }; } export interface FlowLayoutResult { shapes: ShapeLayout[]; captions: CaptionLayout[]; connections: ConnectionLayout[]; width: number; height: number; /** 空图:渲染为虚线 ghost 骨架。 */ ghost: boolean; } /* ---- SVG 文字估宽与适配(与服务器画布渲染同一套估算) ---- */ /** 估算文本宽度:CJK ≈ fontSize,其余 ≈ 0.58×。 */ export function estTextWidth(text: string, fontSize: number): number { let width = 0; for (const ch of text) { width += /[⺀-鿿豈-﫿＀-￯]/.test(ch) ? fontSize : fontSize * 0.58; } return width; } /** 先缩字号(至 minSize)再截断加省略号,保证一行放得下。 */ export function fitLine( text: string, maxWidth: number, fontSize: number, minSize: number, ): { text: string; size: number } { let size = fontSize; while (size > minSize && estTextWidth(text, size) > maxWidth) size -= 0.5; if (estTextWidth(text, size) <= maxWidth) return { text, size }; let out = text; while (out.length > 1 && estTextWidth(`${out}…`, size) > maxWidth) out = out.slice(0, -1); return { text: `${out}…`, size }; } interface Metrics { actW: number; actWide: number; actH: number; gwHd: number; r: number; calloutW: number; calloutH: number; xLeft: number; xRight: number; lane: number; rowGap: number; midBase: number; midWithAbove: number; aboveCalloutTop: number; aboveActivityTop: number; belowActivityTop: number; belowCalloutTop: number; belowEndBadMid: number; rightGap: number; corridorPlain: number; corridorWithAbove: number; } const METRICS: Record = { business: { actW: 100, actWide: 130, actH: 38, gwHd: 18, r: 11, calloutW: 104, calloutH: 26, xLeft: 35, xRight: 930, lane: 966, rowGap: 198, midBase: 66, midWithAbove: 100, aboveCalloutTop: -69, aboveActivityTop: -94, belowActivityTop: 61, belowCalloutTop: 51, belowEndBadMid: 64, rightGap: 70, corridorPlain: 58, corridorWithAbove: 88, }, steps: { actW: 150, actWide: 150, actH: 40, gwHd: 20, r: 10, calloutW: 120, calloutH: 34, xLeft: 35, xRight: 930, lane: 966, rowGap: 198, midBase: 66, midWithAbove: 100, aboveCalloutTop: -69, aboveActivityTop: -94, belowActivityTop: 61, belowCalloutTop: 42, belowEndBadMid: 59, rightGap: 70, corridorPlain: 58, corridorWithAbove: 88, }, }; /** 行首为左置标注网关时的内容缩进(参考图 G3:左顶点 112 = 35+77)。 */ const GW_INDENT = 77; const GAP_MIN = 40; /** 网关出线离开顶点后先沿顶点法向延伸一小段再拐弯(不贴着顶点横拉)。 */ const GW_STUB = 20; const GHOST_GRAPH: FlowGraph = { nodes: [ { id: "S", kind: "start", label: "起点" }, { id: "A", kind: "activity", label: "活动 A", ghost: true }, { id: "G", kind: "gateway", label: "岔口" }, { id: "B", kind: "activity", label: "活动 B", ghost: true }, { id: "E", kind: "end", label: "终点" }, ], rows: [["S", "A", "G", "B", "E"]], anchors: [], edges: [ { from: "S", to: "A" }, { from: "A", to: "G" }, { from: "G", to: "B" }, { from: "B", to: "E" }, ], }; function nodeWidth(node: FlowNode, m: Metrics, shrink: number): number { switch (node.kind) { case "start": case "end": case "end-bad": return m.r * 2; case "gateway": return m.gwHd * 2; case "callout": return m.calloutW; case "activity": return Math.max(80, (node.ghost ? m.actWide : m.actW) - shrink); } } function nodeHeight(node: FlowNode, m: Metrics): number { switch (node.kind) { case "start": case "end": case "end-bad": return m.r * 2; case "gateway": return m.gwHd * 2; case "callout": return m.calloutH; case "activity": return m.actH; } } type CaptionSide = "above" | "below" | "left"; /* ---- 手绘图(manual):几何即真相,布局只做直排 ---- */ /** 手绘珊格(px):节点坐标、连线拐点都吸附到它的倍数。 */ export const MANUAL_GRID = 10; /** 手绘节点尺寸:取网格友好值(边中点/顶点都落在珊格上),与自动布局的参考几何相近。 */ export const MANUAL_SIZES: Record = { activity: { w: 100, h: 40 }, gateway: { w: 40, h: 40 }, start: { w: 20, h: 20 }, end: { w: 20, h: 20 }, "end-bad": { w: 20, h: 20 }, callout: { w: 100, h: 30 }, }; export function manualNodeSize(node: FlowNode): { w: number; h: number } { return MANUAL_SIZES[node.kind]; } /** 手绘连线的标签位:最长一段的中点(横段标在线上方居中,竖段标在线右侧)。 */ export function manualEdgeLabel( edge: FlowEdge, points: Array<{ x: number; y: number }>, ): ConnectionLayout["label"] { // 边号进线标:`E3 需要选型`;没有注记的边也标出编号 —— 图上能认出这是哪条边 const text = [edge.code, edge.label].filter((t) => t !== undefined && t !== '').join(' '); if (text === '') return undefined; // 手工摆放过的标签位(中心点)优先。 if (edge.lx !== undefined && edge.ly !== undefined) { return { text, x: edge.lx, y: edge.ly + 3.5, anchor: "middle" }; } let best = 0; let bestLen = -1; for (let i = 1; i < points.length; i++) { const len = Math.abs(points[i]!.x - points[i - 1]!.x) + Math.abs(points[i]!.y - points[i - 1]!.y); if (len > bestLen) { bestLen = len; best = i; } } const a = points[best - 1]; const b = points[best]; if (a === undefined || b === undefined) return undefined; if (a.y === b.y) { return { text, x: (a.x + b.x) / 2, y: a.y - 5, anchor: "middle" }; } return { text, x: a.x + 6, y: (a.y + b.y) / 2 + 3, anchor: "start" }; } /** 手绘图直排:节点按存储坐标放置,连线按存储折线画,起止/网关的名字作旁注。 */ function layoutManualFlow(graph: FlowGraph): FlowLayoutResult { const shapes: ShapeLayout[] = graph.nodes.map((node) => { const size = manualNodeSize(node); return { node, x: node.x ?? 0, y: node.y ?? 0, w: size.w, h: size.h }; }); const connections: ConnectionLayout[] = []; graph.edges.forEach((edge, i) => { const points = edge.points; if (!points || points.length < 2) return; const label = manualEdgeLabel(edge, points); connections.push({ id: `manual-${i}`, edge, tone: edge.tone ?? "main", waypoints: points, ...(label === undefined ? {} : { label }), }); }); const captions: CaptionLayout[] = []; for (const shape of shapes) { const { node } = shape; if (node.kind === "start" || node.kind === "end" || node.kind === "end-bad" || node.kind === "gateway") { // 手工摆放过的旁注位(中心点)优先;缺省在节点正下方。 const placed = node.lx !== undefined && node.ly !== undefined; captions.push({ text: node.label, x: placed ? node.lx! : shape.x + shape.w / 2, y: placed ? node.ly! + 3.5 : shape.y + shape.h + 13, anchor: "middle", role: node.kind === "gateway" ? "gateway" : node.kind, }); } } // 内容整体收顶/收左:手绘坐标可能从很深处开始,把最上/最左留白压到 PAD, // 否则展示态(viewBox 从 0 起)顶着一大块空白。只向原点收,不外推。 const PAD = 20; let minX = Infinity; let minY = Infinity; for (const shape of shapes) { minX = Math.min(minX, shape.x); minY = Math.min(minY, shape.y); } for (const connection of connections) { for (const p of connection.waypoints) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); } } for (const caption of captions) { minX = Math.min(minX, caption.x - estTextWidth(caption.text, 10) / 2); minY = Math.min(minY, caption.y - 10); } const dx = Number.isFinite(minX) && minX > PAD ? minX - PAD : 0; const dy = Number.isFinite(minY) && minY > PAD ? minY - PAD : 0; if (dx || dy) { for (const shape of shapes) { shape.x -= dx; shape.y -= dy; } for (const caption of captions) { caption.x -= dx; caption.y -= dy; } for (const connection of connections) { connection.waypoints = connection.waypoints.map((p) => ({ x: p.x - dx, y: p.y - dy })); if (connection.label) { connection.label = { ...connection.label, x: connection.label.x - dx, y: connection.label.y - dy }; } } } let maxX = 0; let maxY = 0; for (const shape of shapes) { maxX = Math.max(maxX, shape.x + shape.w); maxY = Math.max(maxY, shape.y + shape.h); } for (const connection of connections) { for (const p of connection.waypoints) { maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); } } for (const caption of captions) { maxX = Math.max(maxX, caption.x + estTextWidth(caption.text, 10) / 2); maxY = Math.max(maxY, caption.y + 4); } /* * 水平居中:画幅有 1000 的下限,内容却总从左缘 PAD 起 —— 画得比 1000 窄时 * 余白全堆在右边,图看着左贴(保存后经归一化尤其明显)。 * 定完画幅宽度后把内容平移到正中,两侧余白对称;纵向保持收顶,不居中。 */ let cMinX = Infinity; for (const shape of shapes) cMinX = Math.min(cMinX, shape.x); for (const connection of connections) for (const p of connection.waypoints) cMinX = Math.min(cMinX, p.x); for (const caption of captions) cMinX = Math.min(cMinX, caption.x - estTextWidth(caption.text, 10) / 2); if (!Number.isFinite(cMinX)) cMinX = 0; const contentW = maxX - cMinX; const width = Math.max(1000, contentW + 40); const dxCenter = (width - contentW) / 2 - cMinX; if (dxCenter !== 0) { for (const shape of shapes) shape.x += dxCenter; for (const caption of captions) caption.x += dxCenter; for (const connection of connections) { connection.waypoints = connection.waypoints.map((p) => ({ x: p.x + dxCenter, y: p.y })); if (connection.label) connection.label = { ...connection.label, x: connection.label.x + dxCenter }; } } return { shapes, captions, connections, width, height: maxY + 44, ghost: false, }; } export function layoutFlow(input: FlowGraph, variant: FlowVariant): FlowLayoutResult { if (input.manual && input.nodes.length > 0) return layoutManualFlow(input); const m = METRICS[variant]; const ghost = input.nodes.length === 0; const graph = ghost ? GHOST_GRAPH : input; const byId = new Map(graph.nodes.map((node) => [node.id, node])); const rows = graph.rows.filter((row) => row.length > 0); const rowIndex = new Map(); rows.forEach((row, r) => row.forEach((id, c) => rowIndex.set(id, { row: r, col: c }))); const anchorByNode = new Map(graph.anchors.map((anchor) => [anchor.node, anchor])); /** * 网关的**前跳/汇回**边:从网关(非行尾)直连到更后一行的某步(mergeTo 分支,如「A类」直接汇到 A4)。 * 这类边要从网关**下端顶点**发出(不再挤进右侧折返车道),故认定它占用了网关的下端。 */ const gatewaySkipsDown = (id: string): boolean => { const node = byId.get(id); const pos = rowIndex.get(id); if (node?.kind !== "gateway" || pos === undefined) return false; if (pos.col >= (rows[pos.row]?.length ?? 0) - 1) return false; // 行尾网关→那是主折返,不算前跳 return graph.edges.some((e) => { if (e.from !== id) return false; const to = rowIndex.get(e.to); return to !== undefined && to.row > pos.row; }); }; /* ---- 蛇形折行占用的中心口:行尾底部中心(出)与次行首顶部中心(入)。 * 支线汇入这些口时让位到靠源侧的三分位竖轨——否则汇入线与折行线对向共轨, * 视觉上"进线出线叠成一条"(2026-07 A3→A5 vs A5→A6 实例)。 ---- */ const foldBottomIds = new Set(); const foldTopIds = new Set(); for (const e of graph.edges) { const ap = rowIndex.get(e.from); const bp = rowIndex.get(e.to); if (ap !== undefined && bp !== undefined && bp.row === ap.row + 1 && !anchorByNode.has(e.from) && !anchorByNode.has(e.to)) { foldBottomIds.add(e.from); foldTopIds.add(e.to); } } /** 支线汇入目标口的竖轨 x:中心口被折行占用时让位到靠源侧三分位。 */ const mergeInX = (targetId: string, b: { x: number; w: number }, bCenterX: number, side: "top" | "bottom", srcX: number): number => { const contested = side === "bottom" ? foldBottomIds.has(targetId) : foldTopIds.has(targetId); if (!contested) return bCenterX; return srcX <= bCenterX ? b.x + b.w * 0.3 : b.x + b.w * 0.7; }; /* ---- 网关标注方位:下方空→下;上方空→上;都占→左置 ---- */ const captionSides = new Map(); for (const [id, node] of byId) { if (node.kind !== "gateway") continue; const pos = rowIndex.get(id); const bottomTaken = graph.anchors.some((a) => a.of === id && a.at === "below") || gatewaySkipsDown(id); const topTaken = graph.anchors.some((a) => a.of === id && a.at === "above") || // 跨行折返进它的顶点(它是次行行首且有来自上一行行尾的边) (pos !== undefined && pos.row > 0 && pos.col === 0 && graph.edges.some((e) => { const from = rowIndex.get(e.from); return e.to === id && from !== undefined && from.row < pos.row; })); captionSides.set(id, !bottomTaken ? "below" : !topTaken ? "above" : "left"); } /* ---- 行内水平排布 ---- */ const rowHasAbove = rows.map((row) => row.some((id) => graph.anchors.some((a) => a.of === id && a.at === "above")), ); // 挂靠占用层数:锚点顺次外挂(下挂探向下一行、上挂探出画布顶)。**同宿主同侧的多个挂靠 // 竖直阶梯堆叠**(每个兄弟连同其链占一段),所以层数 = Σ(1 + 各兄弟子树层数)——单链与旧 // chainDepth 等价;多分支时把兄弟也计入,行距/画布高才腾得开。 const stackLevels = (rootId: string, at: "above" | "below"): number => { const kids = graph.anchors.filter((an) => an.at === at && an.of === rootId); return kids.reduce((sum, k) => sum + 1 + stackLevels(k.node, at), 0); }; const rowBelowDepth = rows.map((row) => Math.max(0, ...row.map((id) => stackLevels(id, "below")))); const row0AboveDepth = rows.length ? Math.max(0, ...(rows[0] ?? []).map((id) => stackLevels(id, "above"))) : 0; const belowStep = m.belowActivityTop + m.actH / 2; // 每多一层下挂,链底再下移这么多 const aboveStep = -m.aboveActivityTop - m.actH / 2; // 每多一层上挂,链顶再上移这么多 const rowMids: number[] = []; for (let r = 0; r < rows.length; r++) { if (r === 0) { rowMids[r] = row0AboveDepth >= 1 ? m.midWithAbove + (row0AboveDepth - 1) * aboveStep : m.midBase; } else { const d = rowBelowDepth[r - 1] ?? 0; // depth-d 下挂链底距行中线 = belowActivityTop+actH +(d-1)·belowStep;再留 GAP_MIN + 下一行半高。 const need = d >= 1 ? m.belowActivityTop + m.actH + GAP_MIN + m.actH / 2 + (d - 1) * belowStep : m.rowGap; rowMids[r] = rowMids[r - 1]! + Math.max(m.rowGap, need); } } const shapes = new Map(); rows.forEach((row, r) => { const mid = rowMids[r] ?? 0; const first = row[0] === undefined ? undefined : byId.get(row[0]); const indent = first?.kind === "gateway" && captionSides.get(row[0] ?? "") === "left" ? GW_INDENT : 0; const xl = m.xLeft + indent; // 求间距 U:Σw + Σ(weight·U) = xRight − xl;圆后的间距权重 0.6。 let shrink = 0; let widths: number[] = []; let unit = 0; let weights: number[] = []; for (;;) { widths = row.map((id) => nodeWidth(byId.get(id)!, m, shrink)); weights = row.slice(1).map((_, i) => { const prev = byId.get(row[i] ?? "")!; return prev.kind === "start" || prev.kind === "end" || prev.kind === "end-bad" ? 0.6 : 1; }); const totalW = widths.reduce((sum, w) => sum + w, 0); const totalWeight = weights.reduce((sum, w) => sum + w, 0); unit = totalWeight > 0 ? (m.xRight - xl - totalW) / totalWeight : 0; if (unit >= GAP_MIN || shrink >= 20 || row.length === 1) break; shrink += 10; } // 横向铺满:节点始终从 xLeft 排到 xRight(unit 即填满整行的间距),不做稀疏居中留白。 let x = xl; row.forEach((id, i) => { const node = byId.get(id)!; const w = widths[i]!; const h = nodeHeight(node, m); if (i > 0) x += weights[i - 1]! * unit; shapes.set(id, { node, x, y: mid - h / 2, w, h }); x += w; }); // 蛇形折行:奇数行沿行带中线镜像 → 该行第一个节点(接上一行末尾)落到**右侧正下方**, // 整行右→左读,行间连接由此变成一小段竖落(而非绕到最右折返车道)。 if (r % 2 === 1) { for (const id of row) { const s = shapes.get(id)!; s.x = m.xLeft + m.xRight - (s.x + s.w); } } }); /* ---- 挂靠节点(相对宿主;挂靠链按依赖顺序解) ---- */ // 同侧多个挂靠:第一个居中于宿主(参考几何不变);第 2+ 个**竖直阶梯堆叠**——每个兄弟 // 沿 belowStep/aboveStep 网格再下(上)一层(前面兄弟的链也占层),横向朝行流向的**反侧** // 错开 STAIR_X(网关→深层挂靠的竖落线要能避开浅层框;流向侧留给汇回线)。 // 曾经横向右堆:宿主靠右时直接堆出画布,且汇回线沿框中线横穿同排兄弟框——已废弃。 const STAIR_X = Math.max(m.actW, m.actWide, m.calloutW) / 2 + 12; const anchorLevel = new Map(); // 节点 → 阶梯层号(0 = 槽基线) const slotState = new Map(); // `${of}:${at}` // 出口列(指导书尾网关多出口,2026-07 用户规则:出口并成一列贴右缘): // 仅当宿主行**最后一个主带节点是结束圆**(尾网关形)时生效,返回该圆的中线 x; // 其他形状(折行网关的 stop 分支等)保持原挂靠几何。绿 end 全变体;红 end-bad 仅步骤图。 const exitColumnX = (hostId: string, targetKind: FlowNodeKind): number | undefined => { if (!(targetKind === "end" || (targetKind === "end-bad" && variant === "steps"))) return undefined; const hostRow = rowIndex.get(hostId)?.row; const rowIds = hostRow !== undefined ? rows[hostRow] : undefined; if (!rowIds || rowIds.length < 2) return undefined; // 尾网关形 = 宿主网关**紧邻**行尾的主结束圆(中间隔着主线节点的普通网关不进列)。 const lastId = rowIds[rowIds.length - 1]; if (lastId === undefined || byId.get(lastId)?.kind !== "end" || rowIds[rowIds.length - 2] !== hostId) return undefined; const lastMain = shapes.get(lastId); return lastMain ? lastMain.x + lastMain.w / 2 : undefined; }; let pass = 0; let remaining = [...graph.anchors]; while (remaining.length > 0 && pass < 4) { const next: FlowAnchor[] = []; for (const anchor of remaining) { const host = shapes.get(anchor.of); const node = byId.get(anchor.node); if (!node) continue; if (!host) { next.push(anchor); continue; } const hostMid = host.y + host.h / 2; const hostCx = host.x + host.w / 2; const w = nodeWidth(node, m, 0); const h = nodeHeight(node, m); const slotKey = `${anchor.of}:${anchor.at}`; const centered = anchor.at === "right" ? host.x + host.w + m.rightGap - w / 2 : hostCx - w / 2; let level = 0; let x = centered; if (anchor.at !== "right") { let slot = slotState.get(slotKey); if (!slot) { // 错开方向 = 行流向的反侧(偶数行 L→R 流,汇回线去右边 → 错去左;镜像行反之); // 反侧越出左缘则回落右侧(右侧画布可增宽,左侧是硬边)。 const hostRow = rowIndex.get(anchor.of)?.row ?? 0; let dir: 1 | -1 = hostRow % 2 === 1 ? 1 : -1; const maxDrift = STAIR_X * Math.max(0, stackLevels(anchor.of, anchor.at) - 1); if (dir === -1 && hostCx - maxDrift - Math.max(m.actW, m.calloutW) / 2 < m.xLeft - 20) dir = 1; slot = { dir, nextLevel: 0 }; slotState.set(slotKey, slot); } level = slot.nextLevel; // 本兄弟连同其名下挂靠链占的层数,下一个兄弟从其后接着排。 slot.nextLevel += 1 + stackLevels(anchor.node, anchor.at); x = centered + slot.dir * STAIR_X * level; } // 出口圆(指导书尾网关多出口):并成一列**贴右缘对齐**(判定见 exitColumnX)。 if (anchor.at !== "right") { const colX = exitColumnX(anchor.of, node.kind); if (colX !== undefined) x = colX - w / 2; } anchorLevel.set(anchor.node, level); let y: number; if (anchor.at === "right") { y = hostMid - h / 2; } else if (anchor.at === "above") { // 活动比小圆注高,要抬得更高才不贴着网关(否则上探连线只有十几像素)。 y = hostMid + (node.kind === "activity" ? m.aboveActivityTop : m.aboveCalloutTop) - level * aboveStep; } else { y = hostMid + // end(绿色出口圆,指导书尾网关多出口)与 end-bad 同为 20×20 圆,共用居中式偏移。 (node.kind === "end-bad" || node.kind === "end" ? m.belowEndBadMid - h / 2 : node.kind === "activity" ? m.belowActivityTop : m.belowCalloutTop) + level * belowStep; } shapes.set(anchor.node, { node, x, y, w, h }); } remaining = next; pass++; } /* ---- 标注(网关 G# · 名 / 圆旁小注 / 网关交叉引用) ---- */ const captions: CaptionLayout[] = []; for (const { node, x, y, w, h } of shapes.values()) { const cx = x + w / 2; const cy = y + h / 2; if (node.kind === "gateway") { const side = captionSides.get(node.id) ?? "below"; const text = `${node.id} · ${node.label}`; if (side === "below") captions.push({ text, x: cx, y: cy + 40, anchor: "middle", role: "gateway" }); else if (side === "above") captions.push({ text, x: cx, y: cy - 30, anchor: "middle", role: "gateway" }); // 四顶点都有线时(上下都挂分支):标注放到网关**左上方**——避开正中的进/出主线(在 cy 高度) // 与上/下探连线(在 cx 竖列),落在左上空档里。 else captions.push({ text, x: x - 6, y: cy - m.gwHd - 8, anchor: "end", role: "gateway" }); if (node.note) { captions.push({ text: node.note, x: cx + m.gwHd * 2, y: cy + m.gwHd + 20, anchor: "start", role: "note", }); } } else if (node.kind === "start") { captions.push({ text: node.label, x: cx, y: cy - m.r - 15, anchor: "middle", role: "start" }); } else if (node.kind === "end" || node.kind === "end-bad") { captions.push({ text: node.label, x: cx, y: cy + m.r + 19, anchor: "middle", role: node.kind }); } } /* ---- 网关四顶点端口分配 ---- */ // 多条下挂分支挤在网关**底顶点**时(如 G3 的「无·免费 / 无·收费」),把明显偏侧、且该侧顶点空闲 //(无同行边占用)的分支挪到左/右顶点,让进/出四条线尽量各占一顶点。仅 ≥2 条下挂分支才触发,单分支流程不变。 const gwPortOverride = new Map(); // edge index → 改从该侧顶点出 { const sideTaken = new Map(); // 网关同行的**出边与入边**都占用左右顶点。入边曾被漏记——蛇形镜像行主线从右顶点进, // 右顶点被误判空闲,分流边的横段就沿行中线穿过网关的前驱活动;两侧都被占时 // 正确行为是回落到底顶点 stub 路由(那条走廊在行带下方,天然避框)。 const markSide = (gwId: string, otherId: string) => { const gw = byId.get(gwId); const ap = rowIndex.get(gwId); const bp = rowIndex.get(otherId); if (gw?.kind !== "gateway" || !ap || !bp || ap.row !== bp.row) return; const g = shapes.get(gwId); const t = shapes.get(otherId); if (!g || !t) return; const s = sideTaken.get(gwId) ?? { left: false, right: false }; if (t.x + t.w / 2 >= g.x + g.w / 2) s.right = true; else s.left = true; sideTaken.set(gwId, s); }; graph.edges.forEach((e) => { markSide(e.from, e.to); markSide(e.to, e.from); }); const belowByGw = new Map(); graph.edges.forEach((e, i) => { const an = anchorByNode.get(e.to); if (an?.of === e.from && an.at === "below") { const l = belowByGw.get(e.from) ?? []; l.push(i); belowByGw.set(e.from, l); } }); for (const [gw, idxs] of belowByGw) { if (idxs.length < 2) continue; // 只有多条下挂挤在底顶点才分流 const g = shapes.get(gw); if (!g) continue; const gcx = g.x + g.w / 2; const taken = sideTaken.get(gw) ?? { left: false, right: false }; // 偏得最远的优先占侧顶点(离网关越远,越该走侧顶点绕开) const sorted = [...idxs].sort((i, j) => { const ti = shapes.get(graph.edges[i]!.to)!; const tj = shapes.get(graph.edges[j]!.to)!; return Math.abs(tj.x + tj.w / 2 - gcx) - Math.abs(ti.x + ti.w / 2 - gcx); }); for (const i of sorted) { // 阶梯层(≥1)的挂靠一律走底顶点 stub 路由:左右顶点的横段沿行中线走, // 阶梯框横向错位后该横段可能扫过同行主带框。 if ((anchorLevel.get(graph.edges[i]!.to) ?? 0) > 0) continue; const t = shapes.get(graph.edges[i]!.to)!; const tcx = t.x + t.w / 2; if (tcx > gcx + m.gwHd && !taken.right) { gwPortOverride.set(i, "right"); taken.right = true; } else if (tcx < gcx - m.gwHd && !taken.left) { gwPortOverride.set(i, "left"); taken.left = true; } } } } /* ---- 连线路由 R1–R7 ---- */ const connections: ConnectionLayout[] = []; graph.edges.forEach((edge, index) => { const a = shapes.get(edge.from); const b = shapes.get(edge.to); if (!a || !b) return; const tone: FlowTone = edge.tone ?? "main"; const aMid = { x: a.x + a.w / 2, y: a.y + a.h / 2 }; const bMid = { x: b.x + b.w / 2, y: b.y + b.h / 2 }; const aPos = rowIndex.get(edge.from); const bPos = rowIndex.get(edge.to); const aAnchor = anchorByNode.get(edge.from); const bAnchor = anchorByNode.get(edge.to); let waypoints: Array<{ x: number; y: number }>; let label: ConnectionLayout["label"]; if ( bAnchor?.of === edge.from && bAnchor.at !== "right" && exitColumnX(edge.from, b.node.kind) !== undefined ) { // R3c 出口圆(右缘一列):网关底/顶顶点竖出到圆心高度,横到右缘入圆**左口**—— // 多条出口共用顶点竖干(同向共轨=合流干线),在各自高度分层向右。判定见 exitColumnX。 const down = bAnchor.at === "below"; waypoints = [ { x: aMid.x, y: down ? a.y + a.h : a.y }, { x: aMid.x, y: bMid.y }, { x: b.x, y: bMid.y }, ]; if (edge.label) label = { text: edge.label, x: aMid.x + 10, y: bMid.y - 7, anchor: "start" }; } else if (bAnchor?.of === edge.from && bAnchor.at === "above") { // R2 上探:正上方直上;被错开(同侧第 2 个挂靠)则先横到目标 x 再上,箭头准落到目标。 waypoints = Math.abs(bMid.x - aMid.x) < 1 ? [ { x: aMid.x, y: a.y }, { x: aMid.x, y: b.y + b.h }, ] : [ { x: aMid.x, y: a.y }, { x: bMid.x, y: a.y }, { x: bMid.x, y: b.y + b.h }, ]; if (edge.label) label = { text: edge.label, x: bMid.x + 10, y: (a.y + b.y + b.h) / 2 + 4, anchor: "start" }; } else if (bAnchor?.of === edge.from && bAnchor.at === "below") { // R3 下探:网关只从上/下/左/右四顶点进出(不从菱形斜边出)。默认走**底顶点**——正下方直下,错开的 // 先向下延伸一段 stub 再横到目标列(不贴着顶点横拉)。被端口分配挪走的分支改从左/右顶点出: // 先沿该向延伸到目标列,再竖入目标顶,让进/出四条线各占一个顶点(如 G3 的无·免费=底、无·收费=右)。 const port = gwPortOverride.get(index); if (port === "right" || port === "left") { const exitX = port === "right" ? a.x + a.w : a.x; waypoints = [ { x: exitX, y: aMid.y }, { x: bMid.x, y: aMid.y }, { x: bMid.x, y: b.y }, ]; if (edge.label) label = { text: edge.label, x: bMid.x + 10, y: (aMid.y + b.y) / 2, anchor: "start" }; } else { const stubY = a.y + a.h + GW_STUB; waypoints = Math.abs(bMid.x - aMid.x) < 1 ? [ { x: aMid.x, y: a.y + a.h }, { x: aMid.x, y: b.y }, ] : [ { x: aMid.x, y: a.y + a.h }, { x: aMid.x, y: stubY }, { x: bMid.x, y: stubY }, { x: bMid.x, y: b.y }, ]; if (edge.label) label = { text: edge.label, x: bMid.x + 10, y: (a.y + a.h + b.y) / 2 + 4, anchor: "start" }; } } else if (bAnchor?.of === edge.from && bAnchor.at === "right") { // R6 挂靠横连 waypoints = [ { x: a.x + a.w, y: aMid.y }, { x: b.x, y: aMid.y }, ]; if (edge.label) label = { text: edge.label, x: (a.x + a.w + b.x) / 2, y: aMid.y - 7, anchor: "middle" }; } else if (aAnchor && bPos !== undefined) { // R4 支线汇回主带。按**挂靠边**分三种走法,核心是不穿过任何活动/网关。 if (aAnchor.at === "above") { // ① 上挂分支:正下方就是它挂靠的网关——**绝不能向下出线**。从朝目标的一侧**水平**出 // (仍在网关上方那条高度),横到目标列,再竖入目标(目标更低入其顶、更高入其底)。 const exitX = bMid.x >= aMid.x ? a.x + a.w : a.x; const intoTop = bMid.y >= aMid.y; const inX = mergeInX(edge.to, b, bMid.x, intoTop ? "top" : "bottom", aMid.x); waypoints = [ { x: exitX, y: aMid.y }, { x: inX, y: aMid.y }, { x: inX, y: intoTop ? b.y : b.y + b.h }, ]; if (edge.label) label = { text: edge.label, x: (exitX + inX) / 2, y: aMid.y - 7, anchor: "middle" }; } else if (bMid.y > aMid.y) { // ② 下挂分支 → 更低的目标:走分支行**下方**的走廊(不横穿同排分支盒),横到目标列 // 再竖入其顶。多条支线共用走廊、竖段重叠没关系(合流本就该汇到一处)。 const corridorY = a.y + a.h + 14; const inX = mergeInX(edge.to, b, bMid.x, "top", aMid.x); waypoints = [ { x: aMid.x, y: a.y + a.h }, { x: aMid.x, y: corridorY }, { x: inX, y: corridorY }, { x: inX, y: b.y }, ]; if (edge.label) label = { text: edge.label, x: aMid.x + 8, y: (a.y + a.h + corridorY) / 2, anchor: "start" }; } else { // ③ 下挂分支 → 上方/同行的目标:横到目标列,竖入其底。 const exitX = bMid.x >= aMid.x ? a.x + a.w : a.x; const inX = mergeInX(edge.to, b, bMid.x, "bottom", aMid.x); waypoints = [ { x: exitX, y: aMid.y }, { x: inX, y: aMid.y }, { x: inX, y: b.y + b.h }, ]; if (edge.label) label = { text: edge.label, x: (exitX + inX) / 2, y: aMid.y - 7, anchor: "middle" }; } } else if (aPos !== undefined && bPos !== undefined && aPos.row === bPos.row && Math.abs(bPos.col - aPos.col) > 1) { // R1b 同行前跳(网关分支的无链 mergeTo):直连会横穿中间主线框——改走行上走廊: // 网关**顶端顶点**上到走廊(行上标注深度之上再抬 12),横到目标列,竖入目标顶。 // (跨行的网关前跳走 R5b 支线走廊;这里补同行缺口——指导书推导的判断分支首次触达。) // 行 0 的中线−标注深度会算到画布外(该公式此前只服务下方行),夹到 ≥6 保持在框顶与画布顶之间。 const corridor = Math.max( 6, rowMids[aPos.row]! - (rowHasAbove[aPos.row] ? m.corridorWithAbove : m.corridorPlain) - 12, ); const goRight = bMid.x >= aMid.x; waypoints = [ { x: aMid.x, y: a.y }, { x: aMid.x, y: corridor }, { x: bMid.x, y: corridor }, { x: bMid.x, y: b.y }, ]; if (edge.label) label = { text: edge.label, x: aMid.x + (goRight ? 10 : -10), y: (a.y + corridor) / 2 + 3, anchor: goRight ? "start" : "end", }; } else if (aPos !== undefined && bPos !== undefined && aPos.row === bPos.row) { // R1 同行横连(方向自适应:偶数行 L→R 从右侧出,奇数行 R→L 从左侧出,箭头随之反向)。 const aRightOfB = aMid.x > bMid.x; const fromX = aRightOfB ? a.x : a.x + a.w; const toX = aRightOfB ? b.x + b.w : b.x; waypoints = [ { x: fromX, y: aMid.y }, { x: toX, y: bMid.y }, ]; if (edge.label) label = { text: edge.label, x: (fromX + toX) / 2, y: aMid.y - 7, anchor: "middle" }; } else if (aPos !== undefined && bPos !== undefined && bPos.row > aPos.row && gatewaySkipsDown(edge.from)) { // R5b 网关前跳/汇回:从网关**下端顶点**落到一条略高于主折返走廊的支线走廊,横到目标列, // 再竖入目标顶。让「分流」这类分支边真正从网关底部发出,不再挤进右侧折返车道盖住主折返边。 const mainCorridor = rowMids[bPos.row]! - (rowHasAbove[bPos.row] ? m.corridorWithAbove : m.corridorPlain); const skipCorridor = mainCorridor - 12; const goRight = bMid.x >= aMid.x; waypoints = [ { x: aMid.x, y: a.y + a.h }, { x: aMid.x, y: skipCorridor }, { x: bMid.x, y: skipCorridor }, { x: bMid.x, y: b.y }, ]; if (edge.label) label = { text: edge.label, x: aMid.x + (goRight ? 10 : -10), y: (a.y + a.h + skipCorridor) / 2 + 4, anchor: goRight ? "start" : "end", }; } else if (aPos !== undefined && bPos !== undefined && bPos.row === aPos.row + 1) { // R5 蛇形行间:相邻行由蛇形折行首尾同侧对齐——源底部竖落到行间走廊,横一小段(通常很短), // 再竖入目标顶。取代原先绕到最右折返车道(x=966)的大回环。 const corridor = rowMids[bPos.row]! - (rowHasAbove[bPos.row] ? m.corridorWithAbove : m.corridorPlain); const goRight = bMid.x >= aMid.x; waypoints = [ { x: aMid.x, y: a.y + a.h }, { x: aMid.x, y: corridor }, { x: bMid.x, y: corridor }, { x: bMid.x, y: b.y }, ]; if (edge.label) label = { text: edge.label, x: aMid.x + (goRight ? 10 : -10), y: (a.y + a.h + corridor) / 2, anchor: goRight ? "start" : "end", }; } else if (aPos !== undefined && bPos !== undefined && bPos.row > aPos.row) { // R5c 跨多行兜底:仍走右侧车道折返(避免竖穿中间行)。蛇形下相邻行已由 R5 处理,此处极少命中。 const corridor = rowMids[bPos.row]! - (rowHasAbove[bPos.row] ? m.corridorWithAbove : m.corridorPlain); waypoints = [ { x: a.x + a.w, y: aMid.y }, { x: m.lane, y: aMid.y }, { x: m.lane, y: corridor }, { x: bMid.x, y: corridor }, { x: bMid.x, y: b.y }, ]; if (edge.label) label = { text: edge.label, x: (a.x + a.w + m.lane) / 2, y: aMid.y - 7, anchor: "middle" }; } else { // R7 兜底:横到目标中线再竖入(方向自适应:目标在左则从左侧出,箭头反向)。 const intoTop = bMid.y > aMid.y; const exitRight = bMid.x >= aMid.x; const exitX = exitRight ? a.x + a.w : a.x; waypoints = [ { x: exitX, y: aMid.y }, { x: bMid.x, y: aMid.y }, { x: bMid.x, y: intoTop ? b.y : b.y + b.h }, ]; if (waypoints[1]!.x === waypoints[0]!.x) waypoints.splice(1, 1); if (edge.label) label = { text: edge.label, x: exitX + (exitRight ? 12 : -12), y: aMid.y - 7, anchor: exitRight ? "start" : "end", }; } // 去掉相邻重复点(蛇形竖落里源与目标同列时会产生零长段),保持折线干净。 waypoints = waypoints.filter( (p, i) => i === 0 || p.x !== waypoints[i - 1]!.x || p.y !== waypoints[i - 1]!.y, ); connections.push({ id: `e${index}`, edge, tone, waypoints, ...(label ? { label } : {}) }); }); /* ---- 端口分配:同一活动的多条线不要挤在一个点上 ---- */ // 活动是矩形:每条线落在某条边上。同一条边落了多条线时,沿边把落点铺开(边中点起铺), // 让四条线尽量各占一条边的中点;直线主干(如 A4→A5 折返)保持不动,让带拐弯的支线/合流线让位。 type Port = { conn: ConnectionLayout; ptIdx: number; adjIdx: number; far: number; movable: boolean }; const portsByNode = new Map>(); for (const conn of connections) { for (const end of ["from", "to"] as const) { const list = portsByNode.get(conn.edge[end]) ?? []; list.push({ conn, end }); portsByNode.set(conn.edge[end], list); } } for (const [id, ports] of portsByNode) { const shape = shapes.get(id); if (!shape || shape.node.kind !== "activity") continue; const { x, y, w, h } = shape; const sideOf = (p: { x: number; y: number }): "top" | "bottom" | "left" | "right" | null => { if (Math.abs(p.y - y) < 2) return "top"; if (Math.abs(p.y - (y + h)) < 2) return "bottom"; if (Math.abs(p.x - x) < 2) return "left"; if (Math.abs(p.x - (x + w)) < 2) return "right"; return null; }; const bySide = new Map(); for (const { conn, end } of ports) { const wp = conn.waypoints; const ptIdx = end === "from" ? 0 : wp.length - 1; const adjIdx = end === "from" ? 1 : wp.length - 2; if (adjIdx < 0 || adjIdx >= wp.length) continue; const side = sideOf(wp[ptIdx]!); if (!side) continue; const horiz = side === "top" || side === "bottom"; const along = (p: { x: number; y: number }) => (horiz ? p.x : p.y); // 可移动 = 相邻段垂直入边(与落点同坐标),且整条线**不是笔直**——有拐弯能吸收平移。 // 笔直主干(如 A4→A5 折返,全程同一 x)一律不动,免得被拉出斜段。 const movable = Math.abs(along(wp[adjIdx]!) - along(wp[ptIdx]!)) < 0.5 && wp.some((p) => Math.abs(along(p) - along(wp[ptIdx]!)) > 0.5); const other = wp[end === "from" ? wp.length - 1 : 0]; // 挂靠分支的**汇入端**不参与铺开:多条支线合流本该共用一根竖干(T 形汇入是允许的 // 设计语言)。铺开会把合流拆成并排的竖升,深层分支的竖升就会十字穿过浅层分支的横走 //(阶梯堆叠后各分支横走高度不同)。 const mergeIn = end === "to" && anchorByNode.has(conn.edge.from); const list = bySide.get(side) ?? []; list.push({ conn, ptIdx, adjIdx, far: along(other!), movable: movable && !mergeIn }); bySide.set(side, list); } for (const [side, list] of bySide) { if (list.length < 2) continue; const horiz = side === "top" || side === "bottom"; list.sort((a, b) => a.far - b.far); const n = list.length; list.forEach((port, i) => { if (!port.movable) return; const slot = horiz ? x + (w * (i + 1)) / (n + 1) : y + (h * (i + 1)) / (n + 1); const wp = port.conn.waypoints; if (horiz) { wp[port.ptIdx]!.x = slot; wp[port.adjIdx]!.x = slot; } else { wp[port.ptIdx]!.y = slot; wp[port.adjIdx]!.y = slot; } }); } } /* ---- 画布尺寸 ---- */ let maxBottom = 0; let maxRight = 0; for (const { node, x, y, w, h } of shapes.values()) { const bottom = node.kind === "end" || node.kind === "end-bad" || node.kind === "start" ? y + h + 23 // 圆下标注基线 cy+r+19,再留 4 : y + h; maxBottom = Math.max(maxBottom, bottom); maxRight = Math.max(maxRight, x + w); } for (const caption of captions) maxBottom = Math.max(maxBottom, caption.y + 4); return { shapes: [...shapes.values()], captions, connections, // 宽度基准 1000(参考几何);阶梯挂靠向右溢出时增宽,绝不让内容画出画布。 width: Math.max(1000, Math.ceil(maxRight + 20)), height: Math.ceil(maxBottom + 18), ghost, }; }