Files
JX-BPOS/apps/web/src/shared/gov/flow/flow-layout.ts
T
ChaiHaoandClaude Opus 5 6cff6ab34c 删掉节点的「说明」字段 —— 它填得进、存得下,却从来画不出来
`sub` 声称是「画布上的第二行小字」,但画笔里那支只在 `variant !== 'business'`
(步骤图)才走,而全仓的调用点一律是 business:这个字段写了、存了、
一次也没显示过。属性面上那句提示是句假话。

整条清:属性面的一行 · `ProcessNode.sub`(契约) · `FlowNode.sub` ·
图↔包两处映射 · 画笔里那支画不出来的分支(连同它带来的「两种画法」分叉,
现在节点框里就一行活动名) · 节点宽度算法里 `node.sub || node.ghost` 的宽框判据
(sub 没了,只剩 ghost 决定宽窄)。

存量数据不受影响:库里没有一条带 sub 的定义(查过),schema 非严格,
真有旧值也只是解析时被剥掉。

五闸绿;契约快照随本提交入库(去一个可选字段,加法的反面,无下游)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-29 17:48:12 +08:00

1107 lines
45 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.
/**
* 流程图确定性布局引擎:把服务器规范化后的结构化图数据(节点/行/挂靠/连线)
* 算成坐标与折线路径,喂给 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<FlowVariant, Metrics> = {
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<FlowNodeKind, { w: number; h: number }> = {
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<string, { row: number; col: number }>();
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<string>();
const foldTopIds = new Set<string>();
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<string, CaptionSide>();
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<string, ShapeLayout>();
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<string, number>(); // 节点 → 阶梯层号(0 = 槽基线)
const slotState = new Map<string, { dir: 1 | -1; nextLevel: number }>(); // `${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<number, "left" | "right">(); // edge index → 改从该侧顶点出
{
const sideTaken = new Map<string, { left: boolean; right: boolean }>();
// 网关同行的**出边与入边**都占用左右顶点。入边曾被漏记——蛇形镜像行主线从右顶点进,
// 右顶点被误判空闲,分流边的横段就沿行中线穿过网关的前驱活动;两侧都被占时
// 正确行为是回落到底顶点 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<string, number[]>();
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<string, Array<{ conn: ConnectionLayout; end: "from" | "to" }>>();
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<string, Port[]>();
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,
};
}