@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.
@@ -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);
@@ -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
- while (true) {
1565
- const blockers = bandObstacles.filter(
1566
- (rect) => rect.x - VERTICAL_DETOUR_GAP < columnX
1567
- && columnX < rect.x + rect.w + VERTICAL_DETOUR_GAP,
1568
- );
1569
- if (blockers.length === 0) break;
1570
- columnX = side === "left"
1571
- ? Math.min(...blockers.map((rect) => rect.x - VERTICAL_DETOUR_GAP))
1572
- : Math.max(...blockers.map((rect) => rect.x + rect.w + VERTICAL_DETOUR_GAP));
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
- _fromLane?: LaidLane,
1736
- _toLane?: LaidLane,
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
- 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
+ );
2463
2502
  const corner = 14;
2464
- const xDir = B.x > A.x ? 1 : -1;
2503
+ const xDir = backB.x > backA.x ? 1 : -1;
2465
2504
  const existing = {
2466
- d: `M ${A.x} ${A.y}
2467
- L ${A.x} ${detourY + corner}
2468
- Q ${A.x} ${detourY}, ${A.x + xDir * corner} ${detourY}
2469
- L ${B.x - xDir * corner} ${detourY}
2470
- Q ${B.x} ${detourY}, ${B.x} ${detourY + corner}
2471
- L ${B.x} ${B.y}`,
2472
- labelX: (A.x + B.x) / 2,
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
- const candidate = chooseBackDetourCandidate(A, B, from, to, obstacles, detourY);
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,
@@ -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
+ }
@@ -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 { computeLabelBoxW, EDGE_STUB_OUT, labelPathClearanceHorizontal, labelPillH } from "./spec";
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 つの箱を、水平線と傾き ±1 の斜線だけで結ぶ。
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
- labelMarginExtra = 0,
52
- textMetrics: CdlTextMetrics = "proportional",
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 tangent: 点;
163
+ let metroReturn = false;
92
164
 
93
- if (Math.abs(dy) < SAME_HEIGHT_THRESHOLD) {
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 + normalX * distance,
182
- labelY: mid.y + normalY * distance,
273
+ labelX: mid.x,
274
+ labelY: mid.y,
183
275
  labelAnchor: "middle",
276
+ ...(metroReturn ? { metroReturn: true as const } : {}),
184
277
  };
185
278
  }