@cardenelabs/cdl 0.124.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 +43 -1
- package/README.md +8 -0
- package/dist/index.cjs +873 -142
- 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 +873 -143
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +858 -142
- 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 +858 -142
- package/dist/react.js.map +1 -1
- package/dist/{render-U6omSNH2.d.cts → render-7yEHIUK1.d.cts} +57 -3
- package/dist/{render-U6omSNH2.d.ts → render-7yEHIUK1.d.ts} +57 -3
- 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 +77 -24
- 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 +106 -5
- 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);
|
|
@@ -1551,6 +1570,7 @@ function makeBackDetourCandidate(
|
|
|
1551
1570
|
obstacles: readonly LaidNode[],
|
|
1552
1571
|
detourY: number,
|
|
1553
1572
|
side: DetourSide,
|
|
1573
|
+
columnXOverride?: number,
|
|
1554
1574
|
): BackDetourCandidate {
|
|
1555
1575
|
const lowIsA = A.y >= B.y;
|
|
1556
1576
|
const low = lowIsA ? A : B;
|
|
@@ -1560,16 +1580,18 @@ function makeBackDetourCandidate(
|
|
|
1560
1580
|
const bandObstacles = obstacles
|
|
1561
1581
|
.map(nodeRect)
|
|
1562
1582
|
.filter((rect) => rect.y + rect.h >= bandTop && rect.y <= bandBottom);
|
|
1563
|
-
let columnX = detourColumnFor([from, to], side);
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
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
|
+
}
|
|
1573
1595
|
}
|
|
1574
1596
|
const bendAY = lowIsA ? lowBendY : detourY;
|
|
1575
1597
|
const bendBY = lowIsA ? detourY : lowBendY;
|
|
@@ -1732,8 +1754,8 @@ function routePath(
|
|
|
1732
1754
|
obstacles: LaidNode[] = [],
|
|
1733
1755
|
/** 逆向きの edge と通り道を共有する時に割り当てられる側。 未指定なら右。 */
|
|
1734
1756
|
detourSide?: DetourSide,
|
|
1735
|
-
|
|
1736
|
-
|
|
1757
|
+
fromLane?: LaidLane,
|
|
1758
|
+
toLane?: LaidLane,
|
|
1737
1759
|
edge?: CdlEdge,
|
|
1738
1760
|
labelObstacles: LaidNode[] = obstacles,
|
|
1739
1761
|
// CAR-541 SSOT ... 同 node 対称対 lower edge の両 endpoint 平行 shift 量 (world unit)。
|
|
@@ -2459,17 +2481,34 @@ function routePath(
|
|
|
2459
2481
|
// routing v7 = detour 高さを 200 → 100 に縮小。 200px 上に飛ぶと state から遠く label が「浮いて」 見える。
|
|
2460
2482
|
// 100px = state h/2 (75) + margin 25、 forward path (state 中心線) からは h/2 + 100 = 175px 離れて overlap 回避。
|
|
2461
2483
|
if (topTopPair) {
|
|
2462
|
-
|
|
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
|
+
);
|
|
2463
2502
|
const corner = 14;
|
|
2464
|
-
const xDir =
|
|
2503
|
+
const xDir = backB.x > backA.x ? 1 : -1;
|
|
2465
2504
|
const existing = {
|
|
2466
|
-
d: `M ${
|
|
2467
|
-
L ${
|
|
2468
|
-
Q ${
|
|
2469
|
-
L ${
|
|
2470
|
-
Q ${
|
|
2471
|
-
L ${
|
|
2472
|
-
labelX: (
|
|
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,
|
|
2473
2512
|
// CAR-468 SSOT ... 旧 detourY - 30 magic number を LABEL_INIT_CLEARANCE (26、 CAR-570 で 34 → 26 縮小) に置換。
|
|
2474
2513
|
// CAR-518 SSOT ... dotted-flow は 62 world (marker glow radius 分拡張) を labelInit で参照。
|
|
2475
2514
|
// top-top detour の水平 segment 上方 labelInit world = spec 準拠距離。
|
|
@@ -2482,7 +2521,21 @@ function routePath(
|
|
|
2482
2521
|
// 帯に掛かる箱を避けた最も近い外側の列なら、経路に関わらない遠い箱を足しても列を動かさない。
|
|
2483
2522
|
// 遠回りは交差を実際に減らす時だけ採り、減らない時は短く読める従来経路を保って、
|
|
2484
2523
|
// 残る交差を `edge-node-cross` に報告させる。
|
|
2485
|
-
|
|
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);
|
|
2486
2539
|
if (candidate.crossings >= existingCrossings) return existing;
|
|
2487
2540
|
return {
|
|
2488
2541
|
d: candidate.d,
|
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
|
+
}
|
package/src/layout/metro-path.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { CdlEdge, CdlTextMetrics, LaidNode, Side } from "../types";
|
|
2
2
|
import { SAME_HEIGHT_THRESHOLD } from "./curve-path";
|
|
3
|
-
import {
|
|
3
|
+
import { EDGE_STUB_OUT } from "./spec";
|
|
4
4
|
|
|
5
|
-
type 箱 = Pick<LaidNode, "cx" | "cy" | "w" | "h"
|
|
5
|
+
type 箱 = Pick<LaidNode, "cx" | "cy" | "w" | "h"> & Partial<Pick<LaidNode, "kind">>;
|
|
6
6
|
type 点 = { x: number; y: number };
|
|
7
7
|
|
|
8
8
|
type 路線図の線の結果 = {
|
|
@@ -12,9 +12,19 @@ type 路線図の線の結果 = {
|
|
|
12
12
|
labelX: number;
|
|
13
13
|
labelY: number;
|
|
14
14
|
labelAnchor: "middle";
|
|
15
|
+
metroReturn?: true;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
export const METRO_CORNER_RADIUS = 24;
|
|
19
|
+
export const METRO_BRANCH_CORNER_RADIUS = 22;
|
|
20
|
+
export const METRO_BRANCH_STUB = 30;
|
|
21
|
+
export const METRO_DECISION_HEIGHT = 88;
|
|
22
|
+
export const METRO_DECISION_WIDTH = 116;
|
|
23
|
+
export const METRO_RETURN_END_OFFSET = 43;
|
|
24
|
+
export const METRO_RETURN_START_OFFSET = 24;
|
|
25
|
+
/** 見本の線路幅。数値の fallback は CSS を読まない SVG 書き出しでも残る。 */
|
|
26
|
+
export const METRO_LINE_WIDTH = 10;
|
|
27
|
+
export const METRO_LINE_WIDTH_CSS = `var(--cdl-metro-line-width, ${METRO_LINE_WIDTH}px)`;
|
|
18
28
|
|
|
19
29
|
const T = Math.SQRT2 - 1;
|
|
20
30
|
|
|
@@ -38,18 +48,80 @@ function polyline(points: readonly 点[]): string {
|
|
|
38
48
|
return distinct.map((p, i) => `${i === 0 ? "M" : "L"} ${point(p)}`).join(" ");
|
|
39
49
|
}
|
|
40
50
|
|
|
51
|
+
/** 路線図の見本と同じ二次曲線で、横線から縦線へ 1 回だけ曲がる。 */
|
|
52
|
+
function quadraticCornerPath(start: 点, corner: 点, end: 点, radius: number): string {
|
|
53
|
+
const horizontalDir = Math.sign(corner.x - start.x) as 1 | -1;
|
|
54
|
+
const verticalDir = Math.sign(end.y - corner.y) as 1 | -1;
|
|
55
|
+
const r = Math.min(
|
|
56
|
+
radius,
|
|
57
|
+
Math.abs(corner.x - start.x),
|
|
58
|
+
Math.abs(end.y - corner.y),
|
|
59
|
+
);
|
|
60
|
+
const before = { x: corner.x - horizontalDir * r, y: corner.y };
|
|
61
|
+
const after = { x: corner.x, y: corner.y + verticalDir * r };
|
|
62
|
+
return [
|
|
63
|
+
`M ${point(start)}`,
|
|
64
|
+
`L ${point(before)}`,
|
|
65
|
+
`Q ${point(corner)} ${point(after)}`,
|
|
66
|
+
`L ${point(end)}`,
|
|
67
|
+
].join(" ");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 分かれ道の横角から出て、行き先の段へ上がってから駅へ入る。 */
|
|
71
|
+
function decisionBranchPath(start: 点, end: 点): { d: string; turnX: number } {
|
|
72
|
+
const horizontalDir = Math.sign(end.x - start.x) as 1 | -1;
|
|
73
|
+
const verticalDir = Math.sign(end.y - start.y) as 1 | -1;
|
|
74
|
+
const turnX = start.x + horizontalDir * (METRO_BRANCH_STUB + METRO_BRANCH_CORNER_RADIUS);
|
|
75
|
+
const radius = Math.min(
|
|
76
|
+
METRO_BRANCH_CORNER_RADIUS,
|
|
77
|
+
Math.abs(end.y - start.y) / 2,
|
|
78
|
+
Math.abs(end.x - turnX),
|
|
79
|
+
);
|
|
80
|
+
const firstCorner = { x: turnX, y: start.y };
|
|
81
|
+
const secondCorner = { x: turnX, y: end.y };
|
|
82
|
+
const beforeFirst = {
|
|
83
|
+
x: turnX - horizontalDir * radius,
|
|
84
|
+
y: start.y,
|
|
85
|
+
};
|
|
86
|
+
const afterFirst = {
|
|
87
|
+
x: turnX,
|
|
88
|
+
y: start.y + verticalDir * radius,
|
|
89
|
+
};
|
|
90
|
+
const beforeSecond = {
|
|
91
|
+
x: turnX,
|
|
92
|
+
y: end.y - verticalDir * radius,
|
|
93
|
+
};
|
|
94
|
+
const afterSecond = {
|
|
95
|
+
x: turnX + horizontalDir * radius,
|
|
96
|
+
y: end.y,
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
d: [
|
|
100
|
+
`M ${point(start)}`,
|
|
101
|
+
`L ${point(beforeFirst)}`,
|
|
102
|
+
`Q ${point(firstCorner)} ${point(afterFirst)}`,
|
|
103
|
+
`L ${point(beforeSecond)}`,
|
|
104
|
+
`Q ${point(secondCorner)} ${point(afterSecond)}`,
|
|
105
|
+
`L ${point(end)}`,
|
|
106
|
+
].join(" "),
|
|
107
|
+
turnX,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
41
111
|
/**
|
|
42
|
-
* 2
|
|
112
|
+
* 2 つの箱を路線図の線で結ぶ。
|
|
43
113
|
*
|
|
44
114
|
* 向かい合う辺の間に高さの差以上の横幅があれば斜線を中央へ置く。幅が足りない時は、
|
|
45
115
|
* 斜線の傾きを崩さず両方の右辺を結ぶため、右へ張り出すくの字にする。途中の箱は避けない。
|
|
116
|
+
* `decision` から横位置の違う駅へは左右の角から出し、同じ横位置なら行き先側の角から真っ直ぐ出す。
|
|
117
|
+
* 左へ戻る刻み線は駅の丸の外から出し、行き先の矢印より 4 下で止める。
|
|
46
118
|
*/
|
|
47
119
|
export function metroPath(
|
|
48
120
|
from: 箱,
|
|
49
121
|
to: 箱,
|
|
50
122
|
edge?: Pick<CdlEdge, "label" | "sub" | "style" | "overlay">,
|
|
51
|
-
|
|
52
|
-
|
|
123
|
+
_labelMarginExtra = 0,
|
|
124
|
+
_textMetrics: CdlTextMetrics = "proportional",
|
|
53
125
|
): 路線図の線の結果 {
|
|
54
126
|
const fromLeft = from.cx - from.w / 2;
|
|
55
127
|
const fromRight = from.cx + from.w / 2;
|
|
@@ -88,12 +160,52 @@ export function metroPath(
|
|
|
88
160
|
const sgn = Math.sign(dy);
|
|
89
161
|
let d: string;
|
|
90
162
|
let mid: 点;
|
|
91
|
-
let
|
|
163
|
+
let metroReturn = false;
|
|
92
164
|
|
|
93
|
-
if (Math.abs(dy)
|
|
165
|
+
if (from.kind === "decision" && Math.abs(dy) >= SAME_HEIGHT_THRESHOLD) {
|
|
166
|
+
const verticalDir = Math.sign(dy) as 1 | -1;
|
|
167
|
+
if (Math.abs(to.cx - from.cx) < SAME_HEIGHT_THRESHOLD) {
|
|
168
|
+
const start = { x: from.cx, y: from.cy + verticalDir * from.h / 2 };
|
|
169
|
+
const end = { x: to.cx, y: to.cy };
|
|
170
|
+
d = polyline([start, end]);
|
|
171
|
+
fromSide = verticalDir < 0 ? "top" : "bottom";
|
|
172
|
+
toSide = verticalDir < 0 ? "bottom" : "top";
|
|
173
|
+
mid = { x: start.x, y: (start.y + end.y) / 2 };
|
|
174
|
+
} else {
|
|
175
|
+
const horizontalDir = Math.sign(to.cx - from.cx) as 1 | -1;
|
|
176
|
+
const start = {
|
|
177
|
+
x: from.cx + horizontalDir * from.w / 2,
|
|
178
|
+
y: from.cy,
|
|
179
|
+
};
|
|
180
|
+
const end = {
|
|
181
|
+
x: to.cx - horizontalDir * to.w / 2,
|
|
182
|
+
y: to.cy,
|
|
183
|
+
};
|
|
184
|
+
const branch = decisionBranchPath(start, end);
|
|
185
|
+
d = branch.d;
|
|
186
|
+
fromSide = horizontalDir > 0 ? "right" : "left";
|
|
187
|
+
toSide = horizontalDir > 0 ? "left" : "right";
|
|
188
|
+
mid = { x: branch.turnX, y: (start.y + end.y) / 2 };
|
|
189
|
+
}
|
|
190
|
+
} else if (
|
|
191
|
+
edge?.style === "dashed" &&
|
|
192
|
+
from.kind === "station" &&
|
|
193
|
+
to.kind === "station" &&
|
|
194
|
+
to.cx < from.cx &&
|
|
195
|
+
Math.abs(dy) >= SAME_HEIGHT_THRESHOLD
|
|
196
|
+
) {
|
|
197
|
+
const verticalDir = Math.sign(to.cy - from.cy) as 1 | -1;
|
|
198
|
+
const start = { x: from.cx - METRO_RETURN_START_OFFSET, y: from.cy };
|
|
199
|
+
const end = { x: to.cx, y: to.cy - verticalDir * METRO_RETURN_END_OFFSET };
|
|
200
|
+
const corner = { x: end.x, y: start.y };
|
|
201
|
+
fromSide = "left";
|
|
202
|
+
toSide = verticalDir < 0 ? "bottom" : "top";
|
|
203
|
+
d = quadraticCornerPath(start, corner, end, METRO_BRANCH_CORNER_RADIUS);
|
|
204
|
+
mid = { x: (start.x + corner.x) / 2, y: start.y };
|
|
205
|
+
metroReturn = true;
|
|
206
|
+
} else if (Math.abs(dy) < SAME_HEIGHT_THRESHOLD) {
|
|
94
207
|
d = `M ${point({ x: x1, y: y1 })} L ${point({ x: x2, y: y2 })}`;
|
|
95
208
|
mid = { x: (x1 + x2) / 2, y: (y1 + y2) / 2 };
|
|
96
|
-
tangent = { x: x2 - x1, y: y2 - y1 };
|
|
97
209
|
} else if (fromSide !== toSide && Math.abs(x2 - x1) >= Math.abs(dy)) {
|
|
98
210
|
const horizontal = Math.abs(x2 - x1);
|
|
99
211
|
const s = (horizontal - Math.abs(dy)) / 2;
|
|
@@ -123,7 +235,6 @@ export function metroPath(
|
|
|
123
235
|
d = polyline([p0, p1, p2, p3]);
|
|
124
236
|
}
|
|
125
237
|
mid = { x: (x1 + x2) / 2, y: (y1 + y2) / 2 };
|
|
126
|
-
tangent = w;
|
|
127
238
|
} else {
|
|
128
239
|
fromSide = "right";
|
|
129
240
|
toSide = "right";
|
|
@@ -153,33 +264,15 @@ export function metroPath(
|
|
|
153
264
|
`L ${point(p4)}`,
|
|
154
265
|
].join(" ");
|
|
155
266
|
mid = { x: p2.x - T * r, y: p2.y };
|
|
156
|
-
tangent = { x: 0, y: sgn };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (edge?.overlay) {
|
|
160
|
-
return { d, fromSide, toSide, labelX: mid.x, labelY: mid.y, labelAnchor: "middle" };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const tangentLength = Math.hypot(tangent.x, tangent.y);
|
|
164
|
-
let normalX = tangent.y / tangentLength;
|
|
165
|
-
let normalY = -tangent.x / tangentLength;
|
|
166
|
-
if (normalY > Number.EPSILON || (Math.abs(normalY) <= Number.EPSILON && normalX < 0)) {
|
|
167
|
-
normalX *= -1;
|
|
168
|
-
normalY *= -1;
|
|
169
267
|
}
|
|
170
|
-
const labelW = computeLabelBoxW(edge?.label, edge?.sub, textMetrics);
|
|
171
|
-
const labelH = labelPillH(edge?.sub);
|
|
172
|
-
const distance =
|
|
173
|
-
labelPathClearanceHorizontal(edge) +
|
|
174
|
-
(Math.abs(normalX) * labelW + Math.abs(normalY) * labelH) / 2 +
|
|
175
|
-
labelMarginExtra;
|
|
176
268
|
|
|
177
269
|
return {
|
|
178
270
|
d,
|
|
179
271
|
fromSide,
|
|
180
272
|
toSide,
|
|
181
|
-
labelX: mid.x
|
|
182
|
-
labelY: mid.y
|
|
273
|
+
labelX: mid.x,
|
|
274
|
+
labelY: mid.y,
|
|
183
275
|
labelAnchor: "middle",
|
|
276
|
+
...(metroReturn ? { metroReturn: true as const } : {}),
|
|
184
277
|
};
|
|
185
278
|
}
|