@cardenelabs/cdl 0.123.0 → 0.125.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,12 @@ export const MARK_INNER_RATIO = 0.56;
20
20
  const RING_WIDTH_ACTIVE = 6;
21
21
  const RING_WIDTH_IDLE = 4;
22
22
 
23
+ // 路線図は線路幅 10 と釣り合う見本の寸法を保ち、状態遷移図の大きな印とは混ぜない。
24
+ const METRO_START_RADIUS = 17;
25
+ const METRO_END_RADIUS = 19;
26
+ const METRO_END_RING_WIDTH = 3.5;
27
+ const METRO_END_INNER_RADIUS = 10;
28
+
23
29
  /** 印の既定の色。 `tone` を書けばそちらが勝つ */
24
30
  const MARK_DEFAULT = "var(--cdl-tone-accent, #2d6a8f)";
25
31
 
@@ -44,16 +50,26 @@ function markRadius(node: LaidNode): number {
44
50
  * 無い。 枠を足すと線の中心が半径に乗るぶん丸が箱の外へ出て、線との接点がずれる (実測で
45
51
  * 太さ 4 の枠を足すと外径が 96 から 100 になった)。 色は `tone` で変えられる。
46
52
  */
47
- export function MarkStartNode({ node }: { node: LaidNode; active: boolean }): JSX.Element {
53
+ export function MarkStartNode({
54
+ node,
55
+ metro = false,
56
+ }: {
57
+ node: LaidNode;
58
+ active: boolean;
59
+ metro?: boolean;
60
+ }): JSX.Element {
48
61
  const 色 = nodeAccent(node, MARK_DEFAULT);
49
62
  return (
50
- <g transform={`translate(${node.cx - node.w / 2} ${node.cy - node.h / 2})`}>
63
+ <g
64
+ data-cdl-role={metro ? "mark-start" : undefined}
65
+ transform={`translate(${node.cx - node.w / 2} ${node.cy - node.h / 2})`}
66
+ >
51
67
  <circle
52
68
  data-cdl-role="node-body"
53
69
  data-cdl-mark="start"
54
70
  cx={node.w / 2}
55
71
  cy={node.h / 2}
56
- r={markRadius(node)}
72
+ r={metro ? METRO_START_RADIUS : markRadius(node)}
57
73
  fill={色}
58
74
  />
59
75
  </g>
@@ -66,12 +82,23 @@ export function MarkStartNode({ node }: { node: LaidNode; active: boolean }): JS
66
82
  * 輪と中身は同じ色で描く。 別の色にすると 2 つの意味を持つ印に見えるが、表しているのは
67
83
  * 「ここで終わる」 の 1 つだけ。
68
84
  */
69
- export function MarkEndNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
85
+ export function MarkEndNode({
86
+ node,
87
+ active,
88
+ metro = false,
89
+ }: {
90
+ node: LaidNode;
91
+ active: boolean;
92
+ metro?: boolean;
93
+ }): JSX.Element {
70
94
  const 色 = nodeAccent(node, MARK_DEFAULT);
71
- const r = markRadius(node);
72
- const 輪の太さ = active ? RING_WIDTH_ACTIVE : RING_WIDTH_IDLE;
95
+ const r = metro ? METRO_END_RADIUS : markRadius(node);
96
+ const 輪の太さ = metro ? METRO_END_RING_WIDTH : active ? RING_WIDTH_ACTIVE : RING_WIDTH_IDLE;
73
97
  return (
74
- <g transform={`translate(${node.cx - node.w / 2} ${node.cy - node.h / 2})`}>
98
+ <g
99
+ data-cdl-role={metro ? "mark-end" : undefined}
100
+ transform={`translate(${node.cx - node.w / 2} ${node.cy - node.h / 2})`}
101
+ >
75
102
  <circle
76
103
  data-cdl-role="node-body"
77
104
  data-cdl-mark="end"
@@ -79,7 +106,7 @@ export function MarkEndNode({ node, active }: { node: LaidNode; active: boolean
79
106
  cy={node.h / 2}
80
107
  // 輪は線の中心を半径に置くので、外側へ太さの半分だけ出る。 印が箱の短い方の辺に
81
108
  // 収まるよう、その分だけ縮める
82
- r={Math.max(0, r - 輪の太さ / 2)}
109
+ r={metro ? r : Math.max(0, r - 輪の太さ / 2)}
83
110
  fill="var(--cdl-node-fill, #ffffff)"
84
111
  stroke={色}
85
112
  strokeWidth={輪の太さ}
@@ -88,7 +115,7 @@ export function MarkEndNode({ node, active }: { node: LaidNode; active: boolean
88
115
  data-cdl-role="node-inner"
89
116
  cx={node.w / 2}
90
117
  cy={node.h / 2}
91
- r={Math.max(0, r * MARK_INNER_RATIO)}
118
+ r={metro ? METRO_END_INNER_RADIUS : Math.max(0, r * MARK_INNER_RATIO)}
92
119
  fill={色}
93
120
  />
94
121
  </g>
@@ -42,7 +42,7 @@ import {
42
42
  ARROW_ENDPOINT_CENTER_TOL,
43
43
  } from "./spec";
44
44
  import { isSelfLoop, layoutSelfLoops } from "./self-loop";
45
- import { DEFAULT_LABEL_MARGIN } from "./tokens";
45
+ import { DEFAULT_LABEL_MARGIN, STAGE_COLUMN_GAP } from "./tokens";
46
46
 
47
47
  /** 縦 detour が obstacle の外側を通る時の余白 (world unit)。 */
48
48
  const VERTICAL_DETOUR_GAP = 50;
@@ -273,12 +273,30 @@ export function layoutEdges(
273
273
  if (!from) throw new Error(`cdl layout: edge "${e.id}" の from "${e.from}" が node に無い`);
274
274
  if (!to) throw new Error(`cdl layout: edge "${e.id}" の to "${e.to}" が node に無い`);
275
275
  const c = curvePath(from, to, e, labelMarginExtra, textMetrics);
276
+ const fromLane = lmap.get(from.lane);
277
+ const toLane = lmap.get(to.lane);
278
+ let labelX = c.labelX;
279
+ if (
280
+ fromLane?.stage !== undefined &&
281
+ toLane?.stage !== undefined &&
282
+ fromLane.id !== toLane.id &&
283
+ (e.label || e.sub)
284
+ ) {
285
+ const [leftLane, rightLane] =
286
+ fromLane.x <= toLane.x ? [fromLane, toLane] : [toLane, fromLane];
287
+ const gapLeft = leftLane.x + leftLane.width - STAGE_COLUMN_GAP / 2;
288
+ const gapRight = rightLane.x + STAGE_COLUMN_GAP / 2;
289
+ const labelHalfWidth = computeLabelBoxW(e.label, e.sub, textMetrics) / 2;
290
+ const minX = gapLeft + labelHalfWidth;
291
+ const maxX = gapRight - labelHalfWidth;
292
+ if (minX <= maxX) labelX = Math.min(maxX, Math.max(minX, labelX));
293
+ }
276
294
  positionedEdges.push({
277
295
  ...e,
278
296
  d: c.d,
279
297
  fromSide: c.fromSide,
280
298
  toSide: c.toSide,
281
- labelX: c.labelX + (e.labelOffsetX ?? 0),
299
+ labelX: labelX + (e.labelOffsetX ?? 0),
282
300
  labelY: c.labelY + (e.labelOffsetY ?? 0),
283
301
  labelAnchor: c.labelAnchor,
284
302
  });
@@ -296,6 +314,7 @@ export function layoutEdges(
296
314
  labelX: m.labelX + (e.labelOffsetX ?? 0),
297
315
  labelY: m.labelY + (e.labelOffsetY ?? 0),
298
316
  labelAnchor: m.labelAnchor,
317
+ ...(m.metroReturn ? { metroReturn: true as const } : {}),
299
318
  });
300
319
  } else {
301
320
  autoEdges.push(e);
@@ -1522,10 +1541,101 @@ interface Rect {
1522
1541
  h: number;
1523
1542
  }
1524
1543
 
1544
+ /** 経路の角の点。 */
1545
+ type Vertex = { x: number; y: number };
1546
+
1525
1547
  function nodeRect(n: LaidNode): Rect {
1526
1548
  return { x: n.cx - n.w / 2, y: n.cy - n.h / 2, w: n.w, h: n.h };
1527
1549
  }
1528
1550
 
1551
+ /** 絵の `edge-node-cross` と同じ判定で、経路が貫く箱を 1 箱 1 回で数える。 */
1552
+ function countPathObstacleCrossings(d: string, obstacles: readonly LaidNode[]): number {
1553
+ const segments = flattenPathSegments(extractPathSegments(d));
1554
+ return obstacles.filter((obstacle) => segmentsCrossRect(segments, nodeRect(obstacle))).length;
1555
+ }
1556
+
1557
+ type BackDetourCandidate = {
1558
+ d: string;
1559
+ labelX: number;
1560
+ crossings: number;
1561
+ length: number;
1562
+ };
1563
+
1564
+ /** top-top の戻り線を、帯に掛かる箱を避けた最も近い外側の列へ回す候補を作る。 */
1565
+ function makeBackDetourCandidate(
1566
+ A: Vertex,
1567
+ B: Vertex,
1568
+ from: LaidNode,
1569
+ to: LaidNode,
1570
+ obstacles: readonly LaidNode[],
1571
+ detourY: number,
1572
+ side: DetourSide,
1573
+ columnXOverride?: number,
1574
+ ): BackDetourCandidate {
1575
+ const lowIsA = A.y >= B.y;
1576
+ const low = lowIsA ? A : B;
1577
+ const lowBendY = low.y - VERTICAL_DETOUR_EXIT;
1578
+ const bandTop = Math.min(detourY, lowBendY);
1579
+ const bandBottom = Math.max(detourY, lowBendY);
1580
+ const bandObstacles = obstacles
1581
+ .map(nodeRect)
1582
+ .filter((rect) => rect.y + rect.h >= bandTop && rect.y <= bandBottom);
1583
+ let columnX = columnXOverride ?? detourColumnFor([from, to], side);
1584
+ if (columnXOverride === undefined) {
1585
+ while (true) {
1586
+ const blockers = bandObstacles.filter(
1587
+ (rect) => rect.x - VERTICAL_DETOUR_GAP < columnX
1588
+ && columnX < rect.x + rect.w + VERTICAL_DETOUR_GAP,
1589
+ );
1590
+ if (blockers.length === 0) break;
1591
+ columnX = side === "left"
1592
+ ? Math.min(...blockers.map((rect) => rect.x - VERTICAL_DETOUR_GAP))
1593
+ : Math.max(...blockers.map((rect) => rect.x + rect.w + VERTICAL_DETOUR_GAP));
1594
+ }
1595
+ }
1596
+ const bendAY = lowIsA ? lowBendY : detourY;
1597
+ const bendBY = lowIsA ? detourY : lowBendY;
1598
+ const vertices: Vertex[] = [
1599
+ A,
1600
+ { x: A.x, y: bendAY },
1601
+ { x: columnX, y: bendAY },
1602
+ { x: columnX, y: bendBY },
1603
+ { x: B.x, y: bendBY },
1604
+ B,
1605
+ ];
1606
+ const d = verticesToPath(vertices);
1607
+ const high = lowIsA ? B : A;
1608
+ return {
1609
+ d,
1610
+ labelX: (high.x + columnX) / 2,
1611
+ crossings: countPathObstacleCrossings(d, obstacles),
1612
+ length: vertices.slice(1).reduce((sum, vertex, index) => {
1613
+ const previous = vertices[index]!;
1614
+ return sum + Math.hypot(vertex.x - previous.x, vertex.y - previous.y);
1615
+ }, 0),
1616
+ };
1617
+ }
1618
+
1619
+ /** 交差数、長さ、終点へ入る線の並びを保つ側の順で、左右候補を 1 つに決める。 */
1620
+ function chooseBackDetourCandidate(
1621
+ A: Vertex,
1622
+ B: Vertex,
1623
+ from: LaidNode,
1624
+ to: LaidNode,
1625
+ obstacles: readonly LaidNode[],
1626
+ detourY: number,
1627
+ ): BackDetourCandidate {
1628
+ const left = makeBackDetourCandidate(A, B, from, to, obstacles, detourY, "left");
1629
+ const right = makeBackDetourCandidate(A, B, from, to, obstacles, detourY, "right");
1630
+ if (left.crossings !== right.crossings) return left.crossings < right.crossings ? left : right;
1631
+ if (Math.abs(left.length - right.length) >= 0.5) return left.length < right.length ? left : right;
1632
+
1633
+ // 同じ長さなら始点側へ回すと、同じ上辺へ入る線の左右の並びを従来から入れ替えずに済む。
1634
+ // 同じ列は左右の基準を持たないため、縦 detour と同じ右を既定にする。
1635
+ const preferred: DetourSide = Math.abs(A.x - B.x) < 0.5 ? "right" : A.x < B.x ? "left" : "right";
1636
+ return preferred === "left" ? left : right;
1637
+ }
1638
+
1529
1639
  /** 2 rect の最短距離 (world unit)。 重なっていれば 0。 */
1530
1640
  function rectGap(a: Rect, b: Rect): number {
1531
1641
  const dx = Math.max(0, a.x - (b.x + b.w), b.x - (a.x + a.w));
@@ -1644,8 +1754,8 @@ function routePath(
1644
1754
  obstacles: LaidNode[] = [],
1645
1755
  /** 逆向きの edge と通り道を共有する時に割り当てられる側。 未指定なら右。 */
1646
1756
  detourSide?: DetourSide,
1647
- _fromLane?: LaidLane,
1648
- _toLane?: LaidLane,
1757
+ fromLane?: LaidLane,
1758
+ toLane?: LaidLane,
1649
1759
  edge?: CdlEdge,
1650
1760
  labelObstacles: LaidNode[] = obstacles,
1651
1761
  // CAR-541 SSOT ... 同 node 対称対 lower edge の両 endpoint 平行 shift 量 (world unit)。
@@ -2371,21 +2481,66 @@ function routePath(
2371
2481
  // routing v7 = detour 高さを 200 → 100 に縮小。 200px 上に飛ぶと state から遠く label が「浮いて」 見える。
2372
2482
  // 100px = state h/2 (75) + margin 25、 forward path (state 中心線) からは h/2 + 100 = 175px 離れて overlap 回避。
2373
2483
  if (topTopPair) {
2374
- const detourY = Math.min(A.y, B.y) - 100;
2484
+ // 段階列では、見出しの左側を縦に貫かないよう node 上辺の右寄りから出入りし、
2485
+ // 水平の戻り回廊を全ての段階列より上へ持ち上げる。
2486
+ const stagePoint = (point: Vertex, node: LaidNode, lane: LaidLane | undefined): Vertex =>
2487
+ lane?.stage === undefined
2488
+ ? point
2489
+ : {
2490
+ x: Math.min(node.cx + node.w / 2 - 24, lane.x + lane.width - 24),
2491
+ y: point.y,
2492
+ };
2493
+ const backA = stagePoint(A, from, fromLane);
2494
+ const backB = stagePoint(B, to, toLane);
2495
+ const stageTops = [fromLane, toLane]
2496
+ .filter((lane): lane is LaidLane => lane?.stage !== undefined)
2497
+ .map((lane) => lane.y);
2498
+ const detourY = Math.min(
2499
+ Math.min(backA.y, backB.y) - 100,
2500
+ ...stageTops.map((top) => top - VERTICAL_DETOUR_GAP),
2501
+ );
2375
2502
  const corner = 14;
2376
- const xDir = B.x > A.x ? 1 : -1;
2377
- return {
2378
- d: `M ${A.x} ${A.y}
2379
- L ${A.x} ${detourY + corner}
2380
- Q ${A.x} ${detourY}, ${A.x + xDir * corner} ${detourY}
2381
- L ${B.x - xDir * corner} ${detourY}
2382
- Q ${B.x} ${detourY}, ${B.x} ${detourY + corner}
2383
- L ${B.x} ${B.y}`,
2384
- labelX: (A.x + B.x) / 2,
2503
+ const xDir = backB.x > backA.x ? 1 : -1;
2504
+ const existing = {
2505
+ d: `M ${backA.x} ${backA.y}
2506
+ L ${backA.x} ${detourY + corner}
2507
+ Q ${backA.x} ${detourY}, ${backA.x + xDir * corner} ${detourY}
2508
+ L ${backB.x - xDir * corner} ${detourY}
2509
+ Q ${backB.x} ${detourY}, ${backB.x} ${detourY + corner}
2510
+ L ${backB.x} ${backB.y}`,
2511
+ labelX: (backA.x + backB.x) / 2,
2385
2512
  // CAR-468 SSOT ... 旧 detourY - 30 magic number を LABEL_INIT_CLEARANCE (26、 CAR-570 で 34 → 26 縮小) に置換。
2386
2513
  // CAR-518 SSOT ... dotted-flow は 62 world (marker glow radius 分拡張) を labelInit で参照。
2387
2514
  // top-top detour の水平 segment 上方 labelInit world = spec 準拠距離。
2388
2515
  labelY: detourY - labelInit,
2516
+ labelAnchor: "middle" as const,
2517
+ };
2518
+ const existingCrossings = countPathObstacleCrossings(existing.d, obstacles);
2519
+ if (existingCrossings === 0) return existing;
2520
+
2521
+ // 帯に掛かる箱を避けた最も近い外側の列なら、経路に関わらない遠い箱を足しても列を動かさない。
2522
+ // 遠回りは交差を実際に減らす時だけ採り、減らない時は短く読める従来経路を保って、
2523
+ // 残る交差を `edge-node-cross` に報告させる。
2524
+ // 同じ段階列の中を戻る線は、列面どうしの 48 world の隙間の中央を縦回廊に使う。
2525
+ // 汎用の 50 world clearance で外へ押すと、隣の段階列の面へ入り込むため。
2526
+ const candidate =
2527
+ from.lane === to.lane && fromLane?.stage !== undefined
2528
+ ? makeBackDetourCandidate(
2529
+ backA,
2530
+ backB,
2531
+ from,
2532
+ to,
2533
+ obstacles,
2534
+ detourY,
2535
+ "right",
2536
+ fromLane.x + fromLane.width,
2537
+ )
2538
+ : chooseBackDetourCandidate(backA, backB, from, to, obstacles, detourY);
2539
+ if (candidate.crossings >= existingCrossings) return existing;
2540
+ return {
2541
+ d: candidate.d,
2542
+ labelX: candidate.labelX,
2543
+ labelY: detourY - labelInit,
2389
2544
  labelAnchor: "middle",
2390
2545
  };
2391
2546
  }
@@ -3062,9 +3217,6 @@ const SHARED_RUN_CORNER = VERTICAL_DETOUR_CORNER;
3062
3217
  /** 同じ直線上とみなす座標の差 (world unit)。 */
3063
3218
  const SHARED_RUN_EPS = 0.5;
3064
3219
 
3065
- /** 経路の角の点。 */
3066
- type Vertex = { x: number; y: number };
3067
-
3068
3220
  /**
3069
3221
  * 経路を「角の点の並び」 に開く。 開けない形なら `null` を返す。
3070
3222
  *
@@ -1,7 +1,14 @@
1
1
  import type { CdlDiagram, CdlEdge, CdlTextMetrics, LaidLane, LaidNode } from "../types";
2
2
  import { CLEARANCE_NODE_LABEL } from "./clearance-constants";
3
3
  import { computeLabelBoxW, EDGE_STUB_OUT, targetNodeNodeClearance } from "./spec";
4
- import { CONTAIN_PAD, DEFAULT_LANE_GAP, HEADER_RESERVE, LANE_LABEL_H, LANE_PAD_MIN } from "./tokens";
4
+ import {
5
+ CONTAIN_PAD,
6
+ DEFAULT_LANE_GAP,
7
+ HEADER_RESERVE,
8
+ LANE_LABEL_H,
9
+ LANE_PAD_MIN,
10
+ STAGE_COLUMN_GAP,
11
+ } from "./tokens";
5
12
 
6
13
  /**
7
14
  * lane.x を「動いた」 と見なす最小差分 (world unit)。
@@ -12,6 +19,22 @@ import { CONTAIN_PAD, DEFAULT_LANE_GAP, HEADER_RESERVE, LANE_LABEL_H, LANE_PAD_M
12
19
  */
13
20
  const LANE_POSITION_EPSILON = 1e-9;
14
21
 
22
+ /** 段階列だけを、全列を含む共通の上端と下端へ揃える。 */
23
+ function equalizeStageColumns(lanes: LaidLane[], nodes: readonly LaidNode[] = []): LaidLane[] {
24
+ const stages = lanes.filter((lane) => lane.stage !== undefined);
25
+ if (stages.length === 0) return lanes;
26
+
27
+ const top = Math.min(...stages.map((lane) => lane.y));
28
+ const stageIds = new Set(stages.map((lane) => lane.id));
29
+ const nodeBottoms = nodes
30
+ .filter((node) => stageIds.has(node.lane))
31
+ .map((node) => node.cy + node.h / 2 + CONTAIN_PAD);
32
+ const bottom = Math.max(...stages.map((lane) => lane.y + lane.height), ...nodeBottoms);
33
+ return lanes.map((lane) =>
34
+ lane.stage === undefined ? lane : { ...lane, y: top, height: bottom - top },
35
+ );
36
+ }
37
+
15
38
  export function layoutLanes(diag: CdlDiagram): LaidLane[] {
16
39
  // contain なし lane = label 高だけ確保、 contain あり lane は後で recompute で高さを node から決める。
17
40
  // lane.x が未指定 (undefined) なら 前 lane の右端 + laneGap で auto 計算 (mermaid 風 auto-layout)。
@@ -28,7 +51,7 @@ export function layoutLanes(diag: CdlDiagram): LaidLane[] {
28
51
  // 与えず、 完全に独立した free-form 座標として扱う。 未指定 lane は従来通り。
29
52
  const laneGap = diag.viewport?.laneGap ?? diag.viewport?.gap ?? DEFAULT_LANE_GAP;
30
53
  let cursorX = 0;
31
- return diag.lanes.map((lane) => {
54
+ const lanes = diag.lanes.map((lane, index) => {
32
55
  if (lane.posX !== undefined && lane.posY !== undefined) {
33
56
  return {
34
57
  ...lane,
@@ -39,7 +62,9 @@ export function layoutLanes(diag: CdlDiagram): LaidLane[] {
39
62
  };
40
63
  }
41
64
  const resolvedX = lane.x ?? cursorX;
42
- cursorX = resolvedX + lane.width + laneGap;
65
+ const nextLane = diag.lanes[index + 1];
66
+ const gapAfter = lane.stage !== undefined && nextLane?.stage !== undefined ? 0 : laneGap;
67
+ cursorX = resolvedX + lane.width + gapAfter;
43
68
  return {
44
69
  ...lane,
45
70
  x: resolvedX,
@@ -47,6 +72,7 @@ export function layoutLanes(diag: CdlDiagram): LaidLane[] {
47
72
  height: LANE_LABEL_H,
48
73
  };
49
74
  });
75
+ return equalizeStageColumns(lanes);
50
76
  }
51
77
 
52
78
  /**
@@ -128,7 +154,9 @@ export function expandLanesForNodes(lanes: LaidLane[], nodes: LaidNode[]): LaidL
128
154
  if (lane.posX !== undefined && lane.posY !== undefined) continue;
129
155
  const inside = nodes.filter((n) => n.lane === lane.id);
130
156
  const maxNodeW = inside.length > 0 ? Math.max(...inside.map((n) => n.w)) : 0;
131
- const newWidth = Math.max(lane.width, maxNodeW + padFor(lane) * 2);
157
+ const stageMinWidth =
158
+ lane.stage === undefined ? 0 : maxNodeW + CONTAIN_PAD + STAGE_COLUMN_GAP;
159
+ const newWidth = Math.max(lane.width, maxNodeW + padFor(lane) * 2, stageMinWidth);
132
160
  widthOf.set(lane.id, newWidth);
133
161
  deltaOf.set(lane.id, newWidth - lane.width);
134
162
  }
@@ -203,6 +231,41 @@ export function expandLaneGapsForEdgeLabels(
203
231
  // 未指定 lane のみ従来の pitch 均一化 logic に参加、 catalog 100+ backward compat 保証。
204
232
  const layoutedLanes = lanes.filter((l) => !(l.posX !== undefined && l.posY !== undefined));
205
233
  if (layoutedLanes.length < 2) return lanes;
234
+ // 段階列は汎用 lane の均一 pitch にせず、curve の札がある境目だけを広げる。
235
+ // 札が無い境目は列面どうしの 48 world を保つため、stage を持たない図には影響しない。
236
+ if (
237
+ layoutedLanes.length === lanes.length &&
238
+ layoutedLanes.every((lane) => lane.stage !== undefined)
239
+ ) {
240
+ const sortedStages = [...layoutedLanes].sort((a, b) => a.x - b.x);
241
+ const stageIndexById = new Map(sortedStages.map((lane, index) => [lane.id, index]));
242
+ const nodeLaneById = new Map(nodes.map((node) => [node.id, node.lane]));
243
+ const requiredSurfaceGaps = new Array<number>(sortedStages.length - 1).fill(STAGE_COLUMN_GAP);
244
+
245
+ for (const edge of edges) {
246
+ if (edge.routing !== "curve" || (!edge.label && !edge.sub)) continue;
247
+ const fromIndex = stageIndexById.get(nodeLaneById.get(edge.from) ?? "");
248
+ const toIndex = stageIndexById.get(nodeLaneById.get(edge.to) ?? "");
249
+ if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue;
250
+ const lo = Math.min(fromIndex, toIndex);
251
+ const hi = Math.max(fromIndex, toIndex);
252
+ const labelWidth = computeLabelBoxW(edge.label, edge.sub, textMetrics);
253
+ for (let pair = lo; pair < hi; pair++) {
254
+ requiredSurfaceGaps[pair] = Math.max(requiredSurfaceGaps[pair]!, labelWidth);
255
+ }
256
+ }
257
+
258
+ const shiftedXById = new Map<string, number>([[sortedStages[0]!.id, sortedStages[0]!.x]]);
259
+ for (let index = 1; index < sortedStages.length; index++) {
260
+ const previous = sortedStages[index - 1]!;
261
+ const lane = sortedStages[index]!;
262
+ const previousX = shiftedXById.get(previous.id)!;
263
+ const authoredGap = Math.max(0, lane.x - (previous.x + previous.width));
264
+ const laneGap = Math.max(authoredGap, requiredSurfaceGaps[index - 1]! - STAGE_COLUMN_GAP);
265
+ shiftedXById.set(lane.id, previousX + previous.width + laneGap);
266
+ }
267
+ return lanes.map((lane) => ({ ...lane, x: shiftedXById.get(lane.id) ?? lane.x }));
268
+ }
206
269
  // lane を x 昇順に並べる (author が非順序 index / x 手動指定した場合の safe fallback)。
207
270
  // shifted x を lane.id で lookup、 最後に元 lane 配列の順序で返却する。
208
271
  const nodeById = new Map(nodes.map((n) => [n.id, n]));
@@ -345,7 +408,7 @@ export function recomputeContainedLanes(lanes: LaidLane[], nodes: LaidNode[]): L
345
408
  }
346
409
  }
347
410
 
348
- return lanes.map((lane) => {
411
+ const contained = lanes.map((lane) => {
349
412
  if (lane.lifeline) {
350
413
  return {
351
414
  ...lane,
@@ -363,4 +426,5 @@ export function recomputeContainedLanes(lanes: LaidLane[], nodes: LaidNode[]): L
363
426
  height: bottom - top + CONTAIN_PAD * 2 + LANE_LABEL_H,
364
427
  };
365
428
  });
429
+ return equalizeStageColumns(contained, nodes);
366
430
  }
@@ -0,0 +1,68 @@
1
+ import { textWidth } from "../kinds/text-width";
2
+ import type { CdlLegendItem, CdlTextMetrics, LaidLegendItem, LaidNode } from "../types";
3
+ import { isRenderedNode } from "./spec";
4
+ import { VIEW_PAD } from "./tokens";
5
+
6
+ /** 小型の箱の題 (20) に対する見本の比率。 */
7
+ export const LEGEND_FONT_SIZE = 17;
8
+ /** 見本の印 (font-size 21) を凡例の字に合わせる倍率。 */
9
+ export const LEGEND_MARK_SCALE = LEGEND_FONT_SIZE / 21;
10
+ /** 印を描く座標系で、見本 SVG が使う横幅。 */
11
+ export const LEGEND_MARK_REFERENCE_WIDTH = 46;
12
+ /** 見本 SVG の印が使う横幅を、凡例の字に合わせた値。 */
13
+ export const LEGEND_MARK_WIDTH = LEGEND_MARK_REFERENCE_WIDTH * LEGEND_MARK_SCALE;
14
+ /** 印の右端から文字までの間隔。 */
15
+ export const LEGEND_MARK_TEXT_GAP = 12 * LEGEND_MARK_SCALE;
16
+ /** 隣り合う項目の間隔を、図の箱群の幅に対する比で決める。 */
17
+ export const LEGEND_ITEM_GAP_RATIO = 0.07;
18
+
19
+ const LEGEND_BASELINE_GAP = 32;
20
+ const LEGEND_BOTTOM_EXTENT = 10;
21
+
22
+ type ViewBox = { x: number; y: number; w: number; h: number };
23
+
24
+ /**
25
+ * 凡例を元の図の下へ置き、横一列が切れない viewBox を返す。
26
+ *
27
+ * 元の viewBox の末尾には `VIEW_PAD` があるため、その内側を図の下端として使う。
28
+ * 指定が無い時は呼び出されず、既存の座標と viewBox をそのまま保つ。
29
+ */
30
+ export function layoutLegend(
31
+ items: readonly CdlLegendItem[],
32
+ viewBox: ViewBox,
33
+ textMetrics: CdlTextMetrics,
34
+ nodes: readonly LaidNode[],
35
+ ): { legend: LaidLegendItem[]; viewBox: ViewBox } {
36
+ const renderedNodes = nodes.filter(isRenderedNode);
37
+ const startX =
38
+ renderedNodes.length > 0
39
+ ? Math.min(...renderedNodes.map((node) => node.cx - node.w / 2))
40
+ : viewBox.x + VIEW_PAD;
41
+ const diagramRight =
42
+ renderedNodes.length > 0
43
+ ? Math.max(...renderedNodes.map((node) => node.cx + node.w / 2))
44
+ : viewBox.x + viewBox.w - VIEW_PAD;
45
+ const itemGap = (diagramRight - startX) * LEGEND_ITEM_GAP_RATIO;
46
+ const y = viewBox.y + viewBox.h - VIEW_PAD + LEGEND_BASELINE_GAP;
47
+ let x = startX;
48
+ const legend = items.map((item) => {
49
+ const laid = { ...item, x, y };
50
+ x +=
51
+ LEGEND_MARK_WIDTH +
52
+ LEGEND_MARK_TEXT_GAP +
53
+ textWidth(item.text, LEGEND_FONT_SIZE, 0, textMetrics) +
54
+ itemGap;
55
+ return laid;
56
+ });
57
+ const right = x - itemGap;
58
+ const bottom = y + LEGEND_BOTTOM_EXTENT + VIEW_PAD;
59
+
60
+ return {
61
+ legend,
62
+ viewBox: {
63
+ ...viewBox,
64
+ w: Math.max(viewBox.w, right + VIEW_PAD - viewBox.x),
65
+ h: Math.max(viewBox.h, bottom - viewBox.y),
66
+ },
67
+ };
68
+ }