@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.
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  import { requiredRowsWidth } from "../kinds/row-align";
2
2
  import type { CdlDiagram, CdlTextMetrics, LaidLane, LaidNode } from "../types";
3
3
  import { 端の字に要る線の長さ } from "./head-label";
4
+ import { METRO_DECISION_HEIGHT, METRO_DECISION_WIDTH } from "./metro-path";
4
5
  import {
5
6
  hasRenderedLabel,
6
7
  labelPillH,
@@ -181,6 +182,16 @@ export function layoutNodes(
181
182
  textMetrics: CdlTextMetrics,
182
183
  ): LaidNode[] {
183
184
  const laneMap = new Map(lanes.map((l) => [l.id, l]));
185
+ const metroDecisionIds = new Set(
186
+ diag.edges
187
+ .filter((edge) => edge.routing === "metro")
188
+ .flatMap((edge) => [edge.from, edge.to])
189
+ .filter((id) => diag.nodes.some((node) => node.id === id && node.kind === "decision")),
190
+ );
191
+ const nodeSize = (node: CdlDiagram["nodes"][number]) =>
192
+ metroDecisionIds.has(node.id)
193
+ ? { w: METRO_DECISION_WIDTH, h: METRO_DECISION_HEIGHT }
194
+ : NODE_SIZE[node.kind];
184
195
 
185
196
  // 全 lane を跨いで stack 番号で row を共有する。
186
197
  // 同 stack 番号の node は全 lane で同じ y 中心になり、 lane 間 edge が水平で揃う。
@@ -189,7 +200,7 @@ export function layoutNodes(
189
200
  const rowH = new Map<number, number>();
190
201
  for (const stack of allStacks) {
191
202
  const nodesAtStack = diag.nodes.filter((n) => n.stack === stack);
192
- const h = Math.max(...nodesAtStack.map((n) => 箱の高さ(n, NODE_SIZE[n.kind].h)));
203
+ const h = Math.max(...nodesAtStack.map((n) => 箱の高さ(n, nodeSize(n).h)));
193
204
  rowH.set(stack, h);
194
205
  }
195
206
  const rowCy = new Map<number, number>();
@@ -238,7 +249,7 @@ export function layoutNodes(
238
249
  if (!lane) {
239
250
  throw new Error(`cdl layout: node "${n.id}" の lane "${n.lane}" が定義されていない`);
240
251
  }
241
- const size = NODE_SIZE[n.kind];
252
+ const size = nodeSize(n);
242
253
  if (n.posX !== undefined && n.posY !== undefined) {
243
254
  const w = n.posW ?? n.w ?? autoRowsWidth(n, size.w, textMetrics);
244
255
  const h = n.posH ?? n.h ?? rowH.get(n.stack) ?? size.h;
@@ -200,7 +200,7 @@ export function drawsTitleOutsideBox(kind: string | undefined): boolean {
200
200
  */
201
201
  const TITLE_FONT_SIZE_DEFAULT = 18;
202
202
  const TITLE_FONT_SIZE: ReadonlyMap<string, number> = new Map<string, number>([
203
- ...(["dyn-rect", "dyn-circle", "dyn-arc", "dyn-wave", "dyn-polygon", "station"] as const).map(
203
+ ...(["dyn-rect", "dyn-circle", "dyn-arc", "dyn-wave", "dyn-polygon"] as const).map(
204
204
  (k) => [k, 13] as const,
205
205
  ),
206
206
  ...(
@@ -251,8 +251,10 @@ const TITLE_FONT_SIZE: ReadonlyMap<string, number> = new Map<string, number>([
251
251
  ] as const
252
252
  ).map((k) => [k, 20] as const),
253
253
  ...(["card", "event", "function"] as const).map((k) => [k, 24] as const),
254
+ ["station", 25],
254
255
  ["actor", 32],
255
256
  ["storage", 34],
257
+ ["timeline-number", 18],
256
258
  ]);
257
259
 
258
260
  /**
@@ -286,6 +288,17 @@ export function skipsNodeTitleWidthCheck(kind: string | undefined): boolean {
286
288
  */
287
289
  export const TITLE_BOX_PADDING = 52;
288
290
 
291
+ /** 時間軸の番号と軸に共通する縁の太さ。active でも変えない。 */
292
+ export const TIMELINE_NUMBER_STROKE = 4;
293
+
294
+ /** 題を描ける幅。丸い番号は縁の内側、それ以外は既存の箱内外の余白を使う。 */
295
+ export function titleUsableWidth(kind: string | undefined, w: number, h: number): number {
296
+ if (kind === "timeline-number") {
297
+ return Math.min(w, h) - 2 * TIMELINE_NUMBER_STROKE;
298
+ }
299
+ return w - (drawsTitleOutsideBox(kind) ? 0 : TITLE_BOX_PADDING);
300
+ }
301
+
289
302
  /**
290
303
  * 低い箱の時だけ題を箱に収まるまで小さくする種別。
291
304
  *
@@ -303,6 +316,7 @@ const KINDS_SCALING_TITLE_WHEN_LOW: ReadonlySet<string> = new Set(["actor", "eve
303
316
  */
304
317
  const KINDS_SCALING_TITLE_ALWAYS: ReadonlySet<string> = new Set([
305
318
  "card",
319
+ "timeline-number",
306
320
  "shape-file",
307
321
  "shape-folder",
308
322
  "shape-cloud",
@@ -470,6 +484,7 @@ const KINDS_DRAWING_ONLY_SHAPE: readonly string[] = [
470
484
  const KINDS_WITHOUT_EYEBROW_TEXT: ReadonlySet<string> = new Set([
471
485
  ...KINDS_DRAWING_ONLY_SHAPE,
472
486
  ...KINDS_WITHOUT_TITLE_TEXT,
487
+ "timeline-number",
473
488
  ]);
474
489
 
475
490
  /**
@@ -499,6 +514,7 @@ export function rendersEyebrow(kind: string | undefined): boolean {
499
514
  const KINDS_WITHOUT_SUBTITLE_TEXT: ReadonlySet<string> = new Set([
500
515
  ...KINDS_DRAWING_OWN_PAYLOAD,
501
516
  ...KINDS_WITHOUT_TITLE_TEXT,
517
+ "timeline-number",
502
518
  ]);
503
519
 
504
520
  /** その node が `subtitle` を文字として描くか (SSOT)。 破綻の扱いは `rendersEyebrow` と同じ */
@@ -14,6 +14,8 @@ export const TOKENS = {
14
14
  lane_label_h: 40,
15
15
  lane_top_pad: 100,
16
16
  contain_pad: 28,
17
+ stage_column_gap: 48,
18
+ stage_card_text_inset: 18,
17
19
 
18
20
  // node sizes (kind ごと、 unified row h は engine が max() で算出)
19
21
  node: {
@@ -141,7 +143,10 @@ export const TOKENS = {
141
143
  "mark-start": { w: 96, h: 96 },
142
144
  "mark-end": { w: 96, h: 96 },
143
145
  // 路線図の駅 (#996) ... 正方形にして小さな丸が縦横どちらにも歪まないようにする。
144
- station: { w: 24, h: 24 },
146
+ // 半径 16 と縁 6 の外径を、線路との接点を含めて収める。
147
+ station: { w: 38, h: 38 },
148
+ // 時間軸の番号 (#1017) ... 半径 24 の丸と太さ 4 の縁を収める。
149
+ "timeline-number": { w: 52, h: 52 },
145
150
  // 時系列のやり取り (#578)。 実寸は中身から決まる (`sequenceBoardMetrics`) ので、
146
151
  // ここは書かなかった時の受け皿
147
152
  "sequence-board": { w: 960, h: 640 },
@@ -176,6 +181,8 @@ export const LANE_TOP_PAD = TOKENS.lane_top_pad;
176
181
  export const STACK_GAP = TOKENS.row_gap;
177
182
  export const NODE_SIZE = TOKENS.node;
178
183
  export const CONTAIN_PAD = TOKENS.contain_pad;
184
+ export const STAGE_COLUMN_GAP = TOKENS.stage_column_gap;
185
+ export const STAGE_CARD_TEXT_INSET = TOKENS.stage_card_text_inset;
179
186
  export const VIEW_PAD = TOKENS.view_pad;
180
187
  export const HEADER_RESERVE = TOKENS.header_reserve;
181
188
  export const LANE_AUTO_GAP = 80;
package/src/layout.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  slideLabelsAlongOwnPath,
26
26
  } from "./layout/edges";
27
27
  import { applyFooterShapeDrops } from "./layout/footer-shape";
28
+ import { layoutLegend } from "./layout/legend";
28
29
  import {
29
30
  expandLaneGapsForEdgeLabels,
30
31
  expandLanesForNodes,
@@ -181,7 +182,7 @@ function dropUnsafeLaneDx(
181
182
  */
182
183
  export function applyRowLevelNodeShifts<
183
184
  N extends { id: string; stack: number; lane: string; cx: number; cy: number },
184
- L extends { id: string; x: number },
185
+ L extends { id: string; x: number; stage?: unknown },
185
186
  >(
186
187
  nodes: readonly N[],
187
188
  lanes: readonly L[],
@@ -189,6 +190,11 @@ export function applyRowLevelNodeShifts<
189
190
  ): { nodes: N[]; lanes: L[] } | null {
190
191
  const dyByStack = aggregateRowDy(nodes, nodeShifts);
191
192
  const dxByLane = dropUnsafeLaneDx(aggregateLaneDx(nodes, nodeShifts), nodes);
193
+ // 段階列は隣り合う列面の間を線の回廊として使うため、札回避の横 shift で列だけを動かさない。
194
+ // 縦 shift は stack の整列に必要なので、従来どおり適用する。
195
+ for (const lane of lanes) {
196
+ if (lane.stage !== undefined) dxByLane.delete(lane.id);
197
+ }
192
198
  let moved = false;
193
199
  const outNodes = nodes.map((n) => {
194
200
  const dx = dxByLane.get(n.lane) ?? 0;
@@ -434,7 +440,18 @@ export function layout(diag: CdlDiagram): LaidDiagram {
434
440
  }
435
441
  }
436
442
 
437
- const viewBox = computeViewBox(containedLanesFinal, nodesFinal, edges, bboxes, diag.viewport);
443
+ const contentViewBox = computeViewBox(
444
+ containedLanesFinal,
445
+ nodesFinal,
446
+ edges,
447
+ bboxes,
448
+ diag.viewport,
449
+ );
450
+ // 凡例なしの経路では関数を呼ばず、既存の viewBox と配置結果の形を保つ。
451
+ const legendLayout = diag.legend?.length
452
+ ? layoutLegend(diag.legend, contentViewBox, textMetrics, nodesFinal)
453
+ : undefined;
454
+ const viewBox = legendLayout?.viewBox ?? contentViewBox;
438
455
 
439
456
  return {
440
457
  id: diag.id,
@@ -462,6 +479,7 @@ export function layout(diag: CdlDiagram): LaidDiagram {
462
479
  ...(textMetrics === "monospace" ? { textMetrics: "monospace" as const } : {}),
463
480
  // 種類も配色と同じく空の目印を出さない。 遮断をここに揃えると手書きの図にも効く
464
481
  ...(diag.type ? { type: diag.type } : {}),
482
+ ...(legendLayout ? { legend: legendLayout.legend } : {}),
465
483
  bboxes,
466
484
  collisions,
467
485
  nearCollisions,
@@ -7,6 +7,12 @@ import {
7
7
  import { hasTone } from "./tone";
8
8
 
9
9
  export const EDGE_HEAD_MUTED_MARKER_ID = "cdl-arrow-muted";
10
+ export const METRO_RETURN_ARROW_D = "M 21 0 L 4 9.5 L 4 -9.5 Z";
11
+
12
+ /** 戻る metro 線専用の矢印。通常の edge marker と寸法・線端からの距離が違う。 */
13
+ export function metroReturnArrowMarkerId(tone: string): string {
14
+ return `cdl-metro-return-arrow-${tone}`;
15
+ }
10
16
 
11
17
  /**
12
18
  * 関係の端の形の **描き方と marker の名前** (SSOT、 #560)。
@@ -2,6 +2,7 @@ import { 等幅の字 } from "./mono-text";
2
2
  import type { JSX } from "react";
3
3
  import { computeLabelBBoxWorld, hasRenderedLabel, LABEL_PILL_PAD_X } from "../layout/spec";
4
4
  import { 端の字の大きさ, 端の字を置く, type 線の端 } from "../layout/head-label";
5
+ import { METRO_LINE_WIDTH, METRO_LINE_WIDTH_CSS } from "../layout/metro-path";
5
6
  import {
6
7
  EDGE_HEAD_MARKER_SIZE,
7
8
  type CdlTextMetrics,
@@ -9,9 +10,12 @@ import {
9
10
  type LaidEdge,
10
11
  type Tone,
11
12
  } from "../types";
12
- import { edgeHeadMarkerName } from "./edge-head";
13
+ import { edgeHeadMarkerName, metroReturnArrowMarkerId } from "./edge-head";
13
14
  import { toneColor } from "./tone";
14
15
 
16
+ /** 段がまだ通っていない metro 区間を、意匠側が選ぶための属性名。 */
17
+ export const METRO_PENDING_ATTRIBUTE = "data-cdl-pending";
18
+
15
19
  /**
16
20
  * 辺の色 (#618)。 役目を書いた辺だけ「いま」 の色で引く。
17
21
  *
@@ -62,6 +66,7 @@ export function CdlEdgeView({
62
66
  progress,
63
67
  stateValues,
64
68
  flowing = false,
69
+ pending = false,
65
70
  }: {
66
71
  edge: LaidEdge;
67
72
  active: boolean;
@@ -81,6 +86,8 @@ export function CdlEdgeView({
81
86
  * 2 つの動きが同時に走って線がどちらに進んでいるか読めなくなる。
82
87
  */
83
88
  flowing?: boolean;
89
+ /** metro 線の初出より前の段だけに付く。非 metro 線では常に偽。 */
90
+ pending?: boolean;
84
91
  }): JSX.Element {
85
92
  const color = edgeColor(edge);
86
93
  // 光っていない edge も tone の色で描く。 以前は一律の灰色に落としていたため、 何も光っていない
@@ -96,7 +103,11 @@ export function CdlEdgeView({
96
103
  // その色なら 0.9 で 4.51-4.92 と 4.5:1 を満たす (0.8 では 3.69-4.04 で届かない)。
97
104
  // 実線と点線で同じ組を使い、 光らせた側が薄くなる逆転を作らない。
98
105
  // 端の形は線の種類と別の軸 (#560)。 書かなければ既定の塗った三角で、名前も従来のまま
99
- const marker = edgeHeadMarkerName(edge.tone, edge.head, !active, edge.headFill);
106
+ const style = edge.style ?? "solid";
107
+ const isMetroReturn = edge.metroReturn === true;
108
+ const marker = isMetroReturn
109
+ ? metroReturnArrowMarkerId(edge.tone)
110
+ : edgeHeadMarkerName(edge.tone, edge.head, !active, edge.headFill);
100
111
  // 出どころ側の印 (#578)。 marker は経路の向きに従って回るので、始点に置いた印は
101
112
  // 自動で外向きになる (`orient="auto-start-reverse"`)。 書かない図では端が無い
102
113
  const tailMarker =
@@ -104,7 +115,6 @@ export function CdlEdgeView({
104
115
  ? undefined
105
116
  : edgeHeadMarkerName(edge.tone, edge.tailHead, !active, edge.tailHeadFill);
106
117
 
107
- const style = edge.style ?? "solid";
108
118
  const isDotted = style === "dotted-flow";
109
119
  const isPassThrough = isDotted && Boolean(edge.dThrough);
110
120
 
@@ -124,6 +134,7 @@ export function CdlEdgeView({
124
134
 
125
135
  return (
126
136
  <g
137
+ {...(pending ? { [METRO_PENDING_ATTRIBUTE]: "true" } : {})}
127
138
  data-cdl-edge={edge.id}
128
139
  data-cdl-routing={edge.routing === "metro" ? "metro" : undefined}
129
140
  data-cdl-from={edge.from}
@@ -148,11 +159,12 @@ export function CdlEdgeView({
148
159
  fill="none"
149
160
  stroke={color}
150
161
  strokeOpacity={active ? 0.95 : 0.9}
151
- strokeWidth={active ? 5 : 4}
162
+ strokeWidth={edge.routing === "metro" ? METRO_LINE_WIDTH : active ? 5 : 4}
152
163
  strokeDasharray="0 18"
153
164
  strokeLinecap="round"
154
165
  style={{
155
166
  transition: "stroke 280ms ease, stroke-opacity 280ms ease, stroke-width 280ms ease",
167
+ strokeWidth: edge.routing === "metro" ? METRO_LINE_WIDTH_CSS : undefined,
156
168
  }}
157
169
  />
158
170
  {active && !isPassThrough && <EdgeGlowCircles color={color} edgeId={edge.id} />}
@@ -237,12 +249,20 @@ function SolidEdgePath({
237
249
  const boundDashOffset = edge.dashOffsetBind && stateValues
238
250
  ? resolveNumericAttr(edge.dashOffsetBind, stateValues, 0)
239
251
  : undefined;
252
+ const strokeWidth = edge.routing === "metro" && edge.widthBind === undefined
253
+ ? edge.metroReturn ? 6 : METRO_LINE_WIDTH
254
+ : boundWidth;
255
+ const strokeWidthCss = edge.routing === "metro" && !edge.metroReturn
256
+ ? edge.widthBind === undefined
257
+ ? METRO_LINE_WIDTH_CSS
258
+ : `var(--cdl-metro-line-width, ${strokeWidth}px)`
259
+ : undefined;
240
260
  // dashoffset は dasharray 前提、 signal 経由の流れる線を出したい時は "6 6" 等
241
261
  // 刻みは 2 つの役目を持つ。 光っていないことの合図 (`"solid"` の光っていない側) と、
242
262
  // 線の種類そのもの (`"dashed"`)。 **後者は光っても刻みのまま**にする = 意味を持つ刻みが
243
263
  // 光った瞬間に消えると、その関係だけ種類が変わって見える (#578)
244
264
  const dashArray = dashed
245
- ? "10 8"
265
+ ? edge.metroReturn ? "0 9.6" : "10 8"
246
266
  : active
247
267
  ? boundDashOffset !== undefined ? "8 6" : undefined
248
268
  : "4 5";
@@ -253,7 +273,7 @@ function SolidEdgePath({
253
273
  fill="none"
254
274
  stroke={boundStroke}
255
275
  strokeOpacity={active ? 0.95 : 0.9}
256
- strokeWidth={boundWidth}
276
+ strokeWidth={strokeWidth}
257
277
  strokeDasharray={dashArray}
258
278
  strokeDashoffset={boundDashOffset}
259
279
  strokeLinecap="round"
@@ -261,6 +281,7 @@ function SolidEdgePath({
261
281
  markerStart={tailMarker === undefined ? undefined : `url(#${tailMarker})`}
262
282
  style={{
263
283
  transition: "stroke 280ms ease, stroke-opacity 280ms ease, stroke-width 280ms ease",
284
+ strokeWidth: strokeWidthCss,
264
285
  }}
265
286
  />
266
287
  );
@@ -0,0 +1,120 @@
1
+ import type { JSX } from "react";
2
+
3
+ import {
4
+ LEGEND_FONT_SIZE,
5
+ LEGEND_MARK_REFERENCE_WIDTH,
6
+ LEGEND_MARK_SCALE,
7
+ LEGEND_MARK_TEXT_GAP,
8
+ LEGEND_MARK_WIDTH,
9
+ } from "../layout/legend";
10
+ import type { LaidLegendItem, LegendMark } from "../types";
11
+
12
+ const ink = "var(--cdl-text-accent, #143a52)";
13
+ const paper = "var(--cdl-stage-bg, #f8fafc)";
14
+
15
+ const LEGEND_MARK_RENDERERS = {
16
+ diamond: () => (
17
+ <path d="M 23 -26 L 46 -8 L 23 10 L 0 -8 Z" fill="none" stroke={ink} strokeWidth={2} />
18
+ ),
19
+ "filled-circle": () => <circle cx={23} cy={-8} r={11} fill={ink} />,
20
+ "double-circle": () => (
21
+ <>
22
+ <circle cx={23} cy={-8} r={12} fill="none" stroke={ink} strokeWidth={2.5} />
23
+ <circle cx={23} cy={-8} r={6} fill={ink} />
24
+ </>
25
+ ),
26
+ "dotted-line": () => (
27
+ <path
28
+ d={`M 0 -8 H ${LEGEND_MARK_REFERENCE_WIDTH}`}
29
+ fill="none"
30
+ stroke={ink}
31
+ strokeWidth={6}
32
+ strokeLinecap="round"
33
+ strokeDasharray="0 9.6"
34
+ />
35
+ ),
36
+ "solid-line": () => (
37
+ <path
38
+ d={`M 0 -8 H ${LEGEND_MARK_REFERENCE_WIDTH}`}
39
+ fill="none"
40
+ stroke={ink}
41
+ strokeWidth={4}
42
+ strokeLinecap="round"
43
+ />
44
+ ),
45
+ "curved-line": () => (
46
+ <path
47
+ d={`M 0 6 C 12 -22, 34 -22, ${LEGEND_MARK_REFERENCE_WIDTH} 6`}
48
+ fill="none"
49
+ stroke={ink}
50
+ strokeWidth={4}
51
+ strokeLinecap="round"
52
+ />
53
+ ),
54
+ "rounded-label": () => (
55
+ <rect
56
+ x={0}
57
+ y={-23}
58
+ width={LEGEND_MARK_REFERENCE_WIDTH}
59
+ height={30}
60
+ rx={9}
61
+ fill={paper}
62
+ stroke={ink}
63
+ strokeWidth={2}
64
+ />
65
+ ),
66
+ "numbered-circle": () => (
67
+ <>
68
+ <circle cx={23} cy={-8} r={13} fill={paper} stroke={ink} strokeWidth={2.5} />
69
+ <text x={23} y={-2} textAnchor="middle" fontSize={17} fontWeight={700} fill={ink}>
70
+ 1
71
+ </text>
72
+ </>
73
+ ),
74
+ station: () => <circle cx={23} cy={-8} r={11} fill={paper} stroke={ink} strokeWidth={4} />,
75
+ arrow: () => (
76
+ <path
77
+ d={`M 0 -8 H 42 M 32 -18 L ${LEGEND_MARK_REFERENCE_WIDTH} -8 L 32 2`}
78
+ fill="none"
79
+ stroke={ink}
80
+ strokeWidth={3}
81
+ strokeLinecap="round"
82
+ strokeLinejoin="round"
83
+ />
84
+ ),
85
+ } satisfies Record<LegendMark, () => JSX.Element>;
86
+
87
+ export function CdlLegend({ items }: { items: readonly LaidLegendItem[] }): JSX.Element {
88
+ return (
89
+ <g data-cdl-role="legend">
90
+ {items.map((item, index) => {
91
+ const Mark = LEGEND_MARK_RENDERERS[item.mark];
92
+ return (
93
+ <g
94
+ key={`${item.mark}-${index}`}
95
+ data-cdl-role="legend-item"
96
+ data-cdl-y={item.y}
97
+ transform={`translate(${item.x} ${item.y})`}
98
+ >
99
+ <g
100
+ data-cdl-role="legend-mark"
101
+ data-cdl-legend-mark={item.mark}
102
+ transform={`scale(${LEGEND_MARK_SCALE})`}
103
+ >
104
+ <Mark />
105
+ </g>
106
+ <text
107
+ data-cdl-role="legend-text"
108
+ x={LEGEND_MARK_WIDTH + LEGEND_MARK_TEXT_GAP}
109
+ y={0}
110
+ fontSize={LEGEND_FONT_SIZE}
111
+ fill="var(--cdl-text-mute, #4d7187)"
112
+ >
113
+ {item.text}
114
+ </text>
115
+ </g>
116
+ );
117
+ })}
118
+ </g>
119
+ );
120
+ }