@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.
- package/CHANGELOG.md +55 -1
- package/README.md +14 -1
- package/dist/index.cjs +924 -134
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +924 -135
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +909 -134
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +909 -134
- package/dist/react.js.map +1 -1
- package/dist/{render-CVczwfRb.d.cts → render-7yEHIUK1.d.cts} +60 -5
- package/dist/{render-CVczwfRb.d.ts → render-7yEHIUK1.d.ts} +60 -5
- package/package.json +12 -9
- package/src/builder.ts +6 -0
- package/src/index.ts +4 -0
- package/src/kinds/card.tsx +12 -4
- package/src/kinds/generic.tsx +19 -9
- package/src/kinds/station.tsx +105 -19
- package/src/kinds/terminal-mark.tsx +36 -9
- package/src/layout/edges.ts +169 -17
- package/src/layout/lanes.ts +69 -5
- package/src/layout/legend.ts +68 -0
- package/src/layout/metro-path.ts +123 -30
- package/src/layout/nodes.ts +13 -2
- package/src/layout/spec.ts +17 -1
- package/src/layout/tokens.ts +8 -1
- package/src/layout.ts +20 -2
- package/src/render/edge-head.ts +6 -0
- package/src/render/edges.tsx +27 -6
- package/src/render/legend.tsx +120 -0
- package/src/render/nodes.tsx +21 -5
- package/src/render/stage.tsx +255 -10
- package/src/render/template-fields.ts +2 -0
- package/src/types.ts +109 -7
- package/src/validate.ts +5 -0
- package/src/visual-validate.ts +7 -7
|
@@ -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({
|
|
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
|
|
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({
|
|
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
|
|
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>
|
package/src/layout/edges.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
1648
|
-
|
|
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
|
-
|
|
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 =
|
|
2377
|
-
|
|
2378
|
-
d: `M ${
|
|
2379
|
-
L ${
|
|
2380
|
-
Q ${
|
|
2381
|
-
L ${
|
|
2382
|
-
Q ${
|
|
2383
|
-
L ${
|
|
2384
|
-
labelX: (
|
|
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
|
*
|
package/src/layout/lanes.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|