forked from ChaiHao/BPOS-MVP
`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
1107 lines
45 KiB
TypeScript
1107 lines
45 KiB
TypeScript
/**
|
||
* 流程图确定性布局引擎:把服务器规范化后的结构化图数据(节点/行/挂靠/连线)
|
||
* 算成坐标与折线路径,喂给 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,
|
||
};
|
||
}
|