@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.
@@ -3,7 +3,8 @@ import { 折れ線の目盛, type ChartLineScale } from "../kinds/chart-line";
3
3
  import { 描く進みに直す } from "../kinds/draw-ratio";
4
4
  import { useReducedMotion } from "../anim/react/useReducedMotion";
5
5
  import { lifelineStartYs, uniformLifelineEndY } from "../layout/lifeline";
6
- import { buildDiagramAltText, isRenderedNode } from "../layout/spec";
6
+ import { buildDiagramAltText, isRenderedNode, TIMELINE_NUMBER_STROKE } from "../layout/spec";
7
+ import { STAGE_CARD_TEXT_INSET, STAGE_COLUMN_GAP } from "../layout/tokens";
7
8
  import {
8
9
  EDGE_HEADS,
9
10
  EDGE_HEAD_FILLS,
@@ -13,6 +14,7 @@ import {
13
14
  RELATION_FOCUS_DEFAULT,
14
15
  type CdlPhase,
15
16
  type LaidDiagram,
17
+ type LaidNode,
16
18
  } from "../types";
17
19
  import {
18
20
  EDGE_HEAD_MUTED_MARKER_ID,
@@ -20,15 +22,23 @@ import {
20
22
  edgeHeadMarkerId,
21
23
  edgeHeadMarkerName,
22
24
  edgeHeadPaint,
25
+ METRO_RETURN_ARROW_D,
26
+ metroReturnArrowMarkerId,
23
27
  } from "./edge-head";
24
28
  import { CdlEdgeView, CdlEdgeGlowView, CdlEdgeEndLabelView, CdlEdgeLabelView } from "./edges";
25
29
  import { CdlNodeView } from "./nodes";
30
+ import { CdlLegend } from "./legend";
26
31
  import { resolveChartData } from "./payload-binding";
27
32
  import { relatedFocusSet } from "./relation-focus";
28
33
  import { TONE, TONE_HEX } from "./tone";
29
- import { computeStateValues } from "./utils";
34
+ import { computeStateValues, interpolate } from "./utils";
30
35
  import type { 値の名前の表 } from "../input-option";
31
36
 
37
+ const METRO_LANE_BADGE_WIDTH = 200;
38
+ const METRO_LANE_BADGE_HEIGHT = 88;
39
+ const METRO_LANE_BADGE_INSET = 24;
40
+ const METRO_LANE_GUIDE_GAP = 24;
41
+
32
42
  /**
33
43
  * その図に描くものがあるか (#923)。
34
44
  *
@@ -42,9 +52,17 @@ import type { 値の名前の表 } from "../input-option";
42
52
  * 描かれない。 `{signal}` を含む条件は段しだいで出るので、出る側に数える。
43
53
  */
44
54
  function 描くものがある(laid: LaidDiagram): boolean {
55
+ if (laid.legend && laid.legend.length > 0) return true;
45
56
  if (laid.edges.length > 0) return true;
46
57
  if (laid.nodes.some((n) => isRenderedNode(n))) return true;
47
- return laid.lanes.some((l) => l.contain === true || l.lifeline === true || Boolean(l.label));
58
+ return laid.lanes.some(
59
+ (l) =>
60
+ l.contain === true ||
61
+ l.lifeline === true ||
62
+ Boolean(l.label) ||
63
+ l.stage !== undefined ||
64
+ l.metro !== undefined,
65
+ );
48
66
  }
49
67
 
50
68
  export function CdlStage({
@@ -121,6 +139,8 @@ export function CdlStage({
121
139
  [関係を光らせる, 焦点, laid.edges],
122
140
  );
123
141
  const activeSet = 焦点の集合 ?? new Set(currentPhase?.activate ?? []);
142
+ const 路線図 = laid.edges.some((edge) => edge.routing === "metro") &&
143
+ laid.nodes.some((node) => node.kind === "station");
124
144
  /** 箱に付ける受け口。 指定を書かない図では `undefined` = DOM も挙動も変わらない */
125
145
  const 箱の受け口 = 関係を光らせる
126
146
  ? { 触れた, 離れた, 押した, 焦点: 焦点?.id ?? null }
@@ -167,8 +187,15 @@ export function CdlStage({
167
187
  if (first === undefined) return false;
168
188
  return 今 === first;
169
189
  },
190
+ /** 初出の段より前にある区間だけを、通過前として選べるようにする。 */
191
+ まだか: (edgeId: string): boolean => {
192
+ const first = 初出.get(edgeId);
193
+ return first !== undefined && 今 >= 0 && 今 < first;
194
+ },
170
195
  };
171
196
  }, [laid.phases, currentPhase, laid.edgeReveal]);
197
+ const 線を描く = (edge: (typeof laid.edges)[number]): boolean =>
198
+ edge.routing === "metro" || 矢印の出番.出るか(edge.id);
172
199
  // この phase で左の起点から描き始める node (cdl#512)。 `activate` とは別集合で持つ =
173
200
  // 焦点が当たり続ける図で毎 phase 引き直すことになるため、 光っているかを引き金にしない
174
201
  const drawSet = new Set(currentPhase?.draw ?? []);
@@ -280,7 +307,56 @@ export function CdlStage({
280
307
  ),
281
308
  );
282
309
  }, [laid]);
310
+ const metroReturnArrowMarkers = useMemo(() => {
311
+ const tones = new Set(
312
+ laid.edges
313
+ .filter((edge) => edge.metroReturn === true)
314
+ .map((edge) => edge.tone),
315
+ );
316
+ return [...tones].map((tone) => (
317
+ <marker
318
+ key={tone}
319
+ id={metroReturnArrowMarkerId(tone)}
320
+ viewBox="0 -9.5 21 19"
321
+ refX="0"
322
+ refY="0"
323
+ markerWidth="21"
324
+ markerHeight="19"
325
+ markerUnits="userSpaceOnUse"
326
+ orient="auto"
327
+ >
328
+ <path
329
+ data-cdl-role="edge-arrowhead"
330
+ data-cdl-mark="metro-return-arrow"
331
+ d={METRO_RETURN_ARROW_D}
332
+ fill={TONE[tone]}
333
+ />
334
+ </marker>
335
+ ));
336
+ }, [laid.edges]);
283
337
  const diagramScale = laid.diagramScale ?? 1;
338
+ const stageLaneIds = new Set(
339
+ laid.lanes.filter((lane) => lane.stage !== undefined).map((lane) => lane.id),
340
+ );
341
+ const stageNoteNodes = laid.nodes.filter(
342
+ (node): node is LaidNode & { subtitle: string } =>
343
+ node.kind === "card" &&
344
+ (stageLaneIds.has(node.lane) || node.leaderTo !== undefined) &&
345
+ isRenderedNode(node) &&
346
+ Boolean(node.subtitle),
347
+ );
348
+ const stageNoteNodeIds = new Set(stageNoteNodes.map((node) => node.id));
349
+ const nodesById = new Map(laid.nodes.map((node) => [node.id, node]));
350
+ const metroDecisionIds = new Set<string>();
351
+ const stationNamesRight = new Set<string>();
352
+ for (const edge of laid.edges) {
353
+ if (edge.routing !== "metro") continue;
354
+ if (nodesById.get(edge.from)?.kind === "decision") metroDecisionIds.add(edge.from);
355
+ if (nodesById.get(edge.to)?.kind === "decision") metroDecisionIds.add(edge.to);
356
+ if (edge.toSide === "top" && nodesById.get(edge.to)?.kind === "station") {
357
+ stationNamesRight.add(edge.to);
358
+ }
359
+ }
284
360
  // 描くものが 1 つも無い図は面を取らない (#923)。
285
361
  // 札 (`interactive-panel`) は `render.tsx` が別に出すので、題と札は残る。
286
362
  // hook は全て上で呼び終えているので、ここで返しても呼ぶ順は変わらない。
@@ -343,6 +419,7 @@ export function CdlStage({
343
419
  に適用する。 marker (cdl-arrow-*) は edge routing の SSOT なので cdl 内に維持。 */}
344
420
  {/* 大小は phase ごとに入れ替わるため、配置が参照する両方を保持する。 */}
345
421
  {edgeHeadMarkers}
422
+ {metroReturnArrowMarkers}
346
423
  {/* tone が TONE 定義に無い edge の受け皿。 visual-validate の tone-marker-ref 軸が
347
424
  dead ref を検知するが、 検知するまでの間に矢頭が消えないようにする。 */}
348
425
  <marker
@@ -378,7 +455,53 @@ export function CdlStage({
378
455
  stroke="none"
379
456
  pointerEvents="none"
380
457
  />
381
- {lane.contain && (
458
+ {lane.stage && (
459
+ <>
460
+ <rect
461
+ data-cdl-role="stage-column"
462
+ x={lane.x + STAGE_COLUMN_GAP / 2}
463
+ y={lane.y}
464
+ width={lane.width - STAGE_COLUMN_GAP}
465
+ height={lane.height}
466
+ rx={16}
467
+ fill="var(--cdl-stage-bg, #fafafa)"
468
+ fillOpacity={0.5}
469
+ stroke="var(--cdl-text-mute, #8a8678)"
470
+ strokeWidth={1.5}
471
+ />
472
+ <text
473
+ data-cdl-role="stage-number"
474
+ x={lane.x + STAGE_COLUMN_GAP / 2 + 20}
475
+ y={lane.y + 24}
476
+ fontSize={14}
477
+ fontWeight={500}
478
+ letterSpacing={1.5}
479
+ fill="var(--cdl-text-mute, #8a8678)"
480
+ >
481
+ {`段階 ${lane.stage.number}`}
482
+ </text>
483
+ <text
484
+ data-cdl-role="stage-name"
485
+ x={lane.x + STAGE_COLUMN_GAP / 2 + 20}
486
+ y={lane.y + 55}
487
+ fontSize={22}
488
+ fontWeight={700}
489
+ fill="var(--cdl-text, #1a1f2a)"
490
+ >
491
+ {lane.stage.name}
492
+ </text>
493
+ <line
494
+ data-cdl-role="stage-divider"
495
+ x1={lane.x + STAGE_COLUMN_GAP / 2}
496
+ y1={lane.y + 72}
497
+ x2={lane.x + lane.width - STAGE_COLUMN_GAP / 2}
498
+ y2={lane.y + 72}
499
+ stroke="var(--cdl-divider, #d4d4d8)"
500
+ strokeWidth={1.5}
501
+ />
502
+ </>
503
+ )}
504
+ {lane.contain && lane.stage === undefined && (
382
505
  <rect
383
506
  data-cdl-role="lane-container"
384
507
  x={lane.x}
@@ -414,7 +537,76 @@ export function CdlStage({
414
537
  />
415
538
  );
416
539
  })()}
417
- {lane.label && (
540
+ {lane.timelineAxis && (() => {
541
+ const ys = laid.nodes
542
+ .filter((node) => node.lane === lane.id && isRenderedNode(node))
543
+ .map((node) => node.cy);
544
+ if (ys.length < 2) return null;
545
+ const y1 = Math.min(...ys);
546
+ const y2 = Math.max(...ys);
547
+ if (y2 <= y1) return null;
548
+ return (
549
+ <line
550
+ data-cdl-role="timeline-axis"
551
+ x1={lane.x + lane.width / 2}
552
+ y1={y1}
553
+ x2={lane.x + lane.width / 2}
554
+ y2={y2}
555
+ stroke="var(--cdl-tone-accent, #2d6a8f)"
556
+ strokeWidth={TIMELINE_NUMBER_STROKE}
557
+ />
558
+ );
559
+ })()}
560
+ {lane.metro !== undefined && (
561
+ <>
562
+ <line
563
+ data-cdl-role="metro-lane-guide"
564
+ x1={lane.x + METRO_LANE_BADGE_INSET + METRO_LANE_BADGE_WIDTH + METRO_LANE_GUIDE_GAP}
565
+ y1={lane.y + lane.height / 2}
566
+ x2={lane.x + lane.width - METRO_LANE_BADGE_INSET}
567
+ y2={lane.y + lane.height / 2}
568
+ stroke="var(--cdl-line-mute, #a89d8f)"
569
+ strokeWidth={2}
570
+ strokeDasharray="22 7 5 7"
571
+ opacity={0.45}
572
+ />
573
+ <g
574
+ data-cdl-role="metro-lane-badge"
575
+ transform={`translate(${lane.x + METRO_LANE_BADGE_INSET} ${lane.y + lane.height / 2 - METRO_LANE_BADGE_HEIGHT / 2})`}
576
+ >
577
+ <rect
578
+ width={METRO_LANE_BADGE_WIDTH}
579
+ height={METRO_LANE_BADGE_HEIGHT}
580
+ rx={12}
581
+ fill="var(--cdl-node-fill, #ffffff)"
582
+ stroke="var(--cdl-tone-accent, #2d6a8f)"
583
+ strokeWidth={1.25}
584
+ />
585
+ <text
586
+ x={METRO_LANE_BADGE_WIDTH / 2}
587
+ y={lane.metro.subtitle ? 37 : 53}
588
+ fontSize={26}
589
+ fontWeight={700}
590
+ fill="var(--cdl-text, #1a1f2a)"
591
+ textAnchor="middle"
592
+ >
593
+ {lane.label}
594
+ </text>
595
+ {lane.metro.subtitle && (
596
+ <text
597
+ x={METRO_LANE_BADGE_WIDTH / 2}
598
+ y={67}
599
+ fontSize={18}
600
+ fill="var(--cdl-text-dim, #5a6270)"
601
+ textAnchor="middle"
602
+ >
603
+ {lane.metro.subtitle}
604
+ </text>
605
+ )}
606
+ </g>
607
+ </>
608
+ )}
609
+ {lane.label && lane.metro === undefined && (
418
610
  <text
419
611
  data-cdl-role="lane-label"
420
612
  x={lane.x + 24}
@@ -430,8 +622,35 @@ export function CdlStage({
430
622
  </g>
431
623
  ))}
432
624
 
625
+ {/* 補助線は札と番号の下に敷き、edge の矢頭や配置には参加させない。 */}
626
+ {laid.nodes.map((node) => {
627
+ if (node.leaderTo === undefined) return null;
628
+ const target = nodesById.get(node.leaderTo);
629
+ if (target === undefined) return null;
630
+ if (!isRenderedNode(node) || !isRenderedNode(target)) return null;
631
+ const targetIsRight = target.cx >= node.cx;
632
+ const x1 = node.cx + (targetIsRight ? node.w / 2 : -node.w / 2);
633
+ const x2 = target.cx + (targetIsRight ? -target.w / 2 : target.w / 2);
634
+ if (targetIsRight ? x2 <= x1 : x1 <= x2) return null;
635
+ return (
636
+ <line
637
+ key={`timeline-leader-${node.id}`}
638
+ data-cdl-role="timeline-leader"
639
+ data-cdl-leader-from={node.id}
640
+ data-cdl-leader-to={node.leaderTo}
641
+ x1={x1}
642
+ y1={node.cy}
643
+ x2={x2}
644
+ y2={target.cy}
645
+ stroke="var(--cdl-line-mute, #a89d8f)"
646
+ strokeWidth={1.5}
647
+ strokeDasharray="2 4"
648
+ strokeLinecap="round"
649
+ />
650
+ );
651
+ })}
433
652
  {/* edges (1st pass) */}
434
- {laid.edges.filter((edge) => 矢印の出番.出るか(edge.id)).map((edge) => (
653
+ {laid.edges.filter(線を描く).map((edge) => (
435
654
  <CdlEdgeView
436
655
  key={edge.id}
437
656
  edge={edge}
@@ -440,6 +659,7 @@ export function CdlStage({
440
659
  drawing={矢印の出番.伸ばすか(edge.id)}
441
660
  progress={矢印の出番.伸ばすか(edge.id) ? 描く進み : progress}
442
661
  stateValues={stateValues}
662
+ pending={edge.routing === "metro" && 矢印の出番.まだか(edge.id)}
443
663
  />
444
664
  ))}
445
665
 
@@ -449,9 +669,11 @@ export function CdlStage({
449
669
  // および `tweens` は元の進みのまま = 「線はゆっくり、 値は短く」 が 1 つの phase で
450
670
  // 両立する。 対象外にも掛けると値の移りまで速くなる
451
671
  const 描く = drawSet.has(node.id);
672
+ const 段階の補足 = stageNoteNodeIds.has(node.id);
452
673
  const view = (
453
674
  <CdlNodeView
454
- node={node}
675
+ node={段階の補足 ? { ...node, subtitle: undefined } : node}
676
+ compactTitleInset={段階の補足 ? STAGE_CARD_TEXT_INSET : undefined}
455
677
  active={activeSet.has(node.id)}
456
678
  // 描画側の固定の字の言語 (#847)。 `layout` が既定以外の時だけ `lang` を持たせる
457
679
  lang={laid.lang}
@@ -463,20 +685,41 @@ export function CdlStage({
463
685
  値の名前={値の名前}
464
686
  currentPhaseId={currentPhase?.id}
465
687
  chartLineScale={chartLineScales.get(node.id)}
688
+ metroDiagram={路線図}
689
+ metroDecision={metroDecisionIds.has(node.id)}
690
+ stationNameRight={stationNamesRight.has(node.id)}
466
691
  />
467
692
  );
468
693
  return <g key={node.id}>{view}</g>;
469
694
  })}
470
695
 
696
+ {/* 段階列か補助線を持つ札は、既存の subtitle 入力を名前と同じ行の右端へ置く。 */}
697
+ {stageNoteNodes.map((node) => (
698
+ <text
699
+ key={`stage-note-${node.id}`}
700
+ data-cdl-role="stage-note"
701
+ data-cdl-stage-node={node.id}
702
+ x={node.cx + node.w / 2 - STAGE_CARD_TEXT_INSET}
703
+ y={node.cy + 1}
704
+ fontSize={14}
705
+ fontWeight={500}
706
+ textAnchor="end"
707
+ dominantBaseline="middle"
708
+ fill="var(--cdl-text-mute, #8a8678)"
709
+ >
710
+ {interpolate(node.subtitle, stateValues, 値の名前)}
711
+ </text>
712
+ ))}
713
+
471
714
  {/* 経由 node 貫通する dotted-flow edge の進行点 glow (3rd pass) */}
472
715
  {laid.edges
473
- .filter((edge) => Boolean(edge.dThrough) && 矢印の出番.出るか(edge.id))
716
+ .filter((edge) => Boolean(edge.dThrough) && 線を描く(edge))
474
717
  .map((edge) => (
475
718
  <CdlEdgeGlowView key={`glow-${edge.id}`} edge={edge} active={activeSet.has(edge.id)} />
476
719
  ))}
477
720
 
478
721
  {/* edge labels (3rd pass) ... 全 edge の label を最前面に描画 */}
479
- {laid.edges.filter((edge) => 矢印の出番.出るか(edge.id)).map((edge) => (
722
+ {laid.edges.filter(線を描く).map((edge) => (
480
723
  <CdlEdgeLabelView
481
724
  key={`label-${edge.id}`}
482
725
  edge={edge}
@@ -487,7 +730,7 @@ export function CdlStage({
487
730
 
488
731
  {/* 線の端に添える字 (#821 / #825)。 札と同じ最前面の pass */}
489
732
  {laid.edges
490
- .filter((edge) => (edge.headLabel || edge.tailLabel) && 矢印の出番.出るか(edge.id))
733
+ .filter((edge) => (edge.headLabel || edge.tailLabel) && 線を描く(edge))
491
734
  .flatMap((edge) =>
492
735
  (["head", "tail"] as const).map((端) => (
493
736
  <CdlEdgeEndLabelView
@@ -500,6 +743,8 @@ export function CdlStage({
500
743
  )),
501
744
  )}
502
745
 
746
+ {laid.legend && <CdlLegend items={laid.legend} />}
747
+
503
748
  {/* debug overlay = engine が把握している bbox + collision を半透明で重ね描き */}
504
749
  {debug && (
505
750
  <g pointerEvents="none">
@@ -51,6 +51,8 @@ type 読まない欄 =
51
51
  | "posH"
52
52
  | "role"
53
53
  | "tone"
54
+ // 補助線の行き先は node id で、描画側が字の差し込みに掛けない。
55
+ | "leaderTo"
54
56
  | "shape"
55
57
  | "chartData"
56
58
  | "chartFillUnder"
package/src/types.ts CHANGED
@@ -30,7 +30,7 @@ export type Tone = (typeof TONES)[number];
30
30
  export type CdlTextMetrics = "proportional" | "monospace";
31
31
 
32
32
  /**
33
- * cdl SVG に付与される data-cdl-role attribute の SSOT 型 (現在 12 role)。
33
+ * cdl SVG に付与される data-cdl-role attribute の SSOT 型。
34
34
  * consumer app (dragon 等) は `[data-cdl-role="<role>"]` selector で CSS を宛てる。 role を追加する時は本 union と該当 render 箇所 (kinds/*.tsx、
35
35
  * render/edges.tsx、 render/stage.tsx) を同時に更新する。
36
36
  *
@@ -54,6 +54,21 @@ export type CdlTextMetrics = "proportional" | "monospace";
54
54
  * - "lane-container" ... lane.contain=true の boundary rect
55
55
  * - "lane-lifeline" ... lane.lifeline=true の縦点線 line
56
56
  * - "lane-label" ... lane 見出し text
57
+ * - "metro-lane-badge" ... 路線用 lane の担当を示す名札
58
+ * - "metro-lane-guide" ... 路線用 lane の名札から右へ伸びる案内線
59
+ * - "mark-start" ... 路線図の始まりの印
60
+ * - "mark-end" ... 路線図の終わりの印
61
+ * - "legend" ... 図の下へ横一列に置く凡例全体
62
+ * - "legend-item" ... 凡例の 1 項目
63
+ * - "legend-mark" ... 凡例項目の印
64
+ * - "legend-text" ... 凡例項目の文字
65
+ * - "stage-column" ... lane.stage を持つ段階列の面
66
+ * - "stage-number" ... 段階列の見出しに出す番号
67
+ * - "stage-name" ... 段階列の見出しに出す名前
68
+ * - "stage-divider" ... 段階列の見出しと中身を分ける線
69
+ * - "stage-note" ... 段階列にある札か、補助線を持つ札の右側へ置く補足
70
+ * - "timeline-axis" ... lane.timelineAxis=true の縦実線
71
+ * - "timeline-leader" ... node.leaderTo で札から時間軸の番号へ結ぶ矢印なしの補助線
57
72
  */
58
73
  export type CdlRole =
59
74
  | "node-body"
@@ -67,7 +82,44 @@ export type CdlRole =
67
82
  | "edge-label"
68
83
  | "lane-container"
69
84
  | "lane-lifeline"
70
- | "lane-label";
85
+ | "lane-label"
86
+ | "metro-lane-badge"
87
+ | "metro-lane-guide"
88
+ | "mark-start"
89
+ | "mark-end"
90
+ | "legend"
91
+ | "legend-item"
92
+ | "legend-mark"
93
+ | "legend-text"
94
+ | "stage-column"
95
+ | "stage-number"
96
+ | "stage-name"
97
+ | "stage-divider"
98
+ | "stage-note"
99
+ | "timeline-axis"
100
+ | "timeline-leader";
101
+
102
+ /** 凡例に描ける印。 配置・描画・検査はこの 1 つの列を出所にする。 */
103
+ export const LEGEND_MARKS = [
104
+ "diamond",
105
+ "filled-circle",
106
+ "double-circle",
107
+ "dotted-line",
108
+ "solid-line",
109
+ "curved-line",
110
+ "rounded-label",
111
+ "numbered-circle",
112
+ "station",
113
+ "arrow",
114
+ ] as const;
115
+
116
+ export type LegendMark = (typeof LEGEND_MARKS)[number];
117
+
118
+ /** 図の下へ置く凡例の 1 項目。 */
119
+ export type CdlLegendItem = {
120
+ mark: LegendMark;
121
+ text: string;
122
+ };
71
123
 
72
124
  /** 描画できる箱の種類。 検査・記法一覧・型はこの 1 つの列を出所にする。 */
73
125
  export const NODE_KINDS = [
@@ -191,12 +243,14 @@ export const NODE_KINDS = [
191
243
  "shape-lawyer",
192
244
  "shape-trader",
193
245
  "shape-customer-service",
194
- // 流れの始まりと終わりの印 2 種 (#560) ... 塗った丸と輪で囲んだ丸。
195
- // 状態遷移図が使う。 印そのものが意味を持つので文字は描かない
246
+ // 流れの始まりと終わりの印 2 種 (#560) ... 始点と終点は線の端から推測せず、著者が
247
+ // `kind: "mark-start"` / `kind: "mark-end"` の node を明示する。印は文字を描かない
196
248
  "mark-start",
197
249
  "mark-end",
198
250
  // 路線図の駅 (#996) ... 線路上の点を小さな丸で示し、名前と説明は丸の外に置く
199
251
  "station",
252
+ // 時間軸の番号 (#1017) ... 実線の軸上に番号を入れた丸を置く。路線図の駅とは別の意味を持つ。
253
+ "timeline-number",
200
254
  // 時系列のやり取り (#578) ... 1 つの箱が図を丸ごと描く。 縦が時間で、参加者は箱を持たない
201
255
  "sequence-board",
202
256
  ] as const;
@@ -225,7 +279,9 @@ export type EdgeStyle = "solid" | "dotted-flow" | "dashed";
225
279
  * - `"back-detour"` ... 箱の上を回って前の箱へ戻り、途中の箱と交わる時は外側を回る
226
280
  * - `"curve"` ... 始点から水平に出て終点へ水平に入る 3 次曲線。 途中の箱は避けず、
227
281
  * 2 点が同じ高さなら直線になる。 終点の矢印は水平を向く
228
- * - `"metro"` ... 横線と傾き ±1 の斜線だけで結び、角を半径 r の円弧で丸める。
282
+ * - `"metro"` ... 通常は横線と傾き ±1 の斜線で結び、角を円弧で丸める。
283
+ * `decision` からの枝は、行き先が同じ横位置なら上下の角から真っ直ぐ、右にあれば右の角から出て曲がる。
284
+ * 左へ戻る `dashed` は行き先側へ横に出して駅の上下へ入る。
229
285
  * 途中の箱は避けず、2 点が同じ高さなら横 1 本になる
230
286
  */
231
287
  export type EdgeRouting = "default" | "back-detour" | "curve" | "metro";
@@ -407,7 +463,29 @@ export type CdlLane = {
407
463
  */
408
464
  width: number;
409
465
  label?: string;
466
+ /**
467
+ * 路線図の担当として描く lane。 `label` を名札の名前、`subtitle` をその補足に使う。
468
+ *
469
+ * 書いた lane だけが名札と案内線を出す。書かない lane の出力は変わらない。
470
+ */
471
+ metro?: {
472
+ subtitle?: string;
473
+ };
410
474
  contain?: boolean;
475
+ /**
476
+ * lane を同じ上端・高さの段階列として描く時の見出し。
477
+ *
478
+ * `number` と `name` は別の SVG text に出るため、利用側は `stage-number` と `stage-name` を
479
+ * 個別に選べる。この lane にある `card` node の `subtitle` は、札の右側へ `stage-note` として
480
+ * 描き、通常の card の補足位置には出さない。
481
+ * `leaderTo` を持つ `card` も、段階列の内外にかかわらず同じ `stage-note` を使う。
482
+ *
483
+ * 書かない lane は既存の縦列のままで、`lane-container` / `lane-label` の入力と出力は変わらない。
484
+ */
485
+ stage?: {
486
+ number: string;
487
+ name: string;
488
+ };
411
489
  /**
412
490
  * この lane を何のために置いているか (既定 = `"column"`)。
413
491
  *
@@ -427,6 +505,12 @@ export type CdlLane = {
427
505
  role?: "column" | "overlay";
428
506
  /** UML sequence diagram の lifeline (= lane の中央 x に縦の点線を描画) */
429
507
  lifeline?: boolean;
508
+ /**
509
+ * lane の中央 x に時間軸の実線を描く。線はこの lane にある描画対象 node の中心間を結ぶ。
510
+ *
511
+ * `lifeline` の点線とは独立しており、両方を書いた lane では両方を描く。
512
+ */
513
+ timelineAxis?: boolean;
430
514
  /**
431
515
  * 明示指定した座標。 `posX` と `posY` の **両方**が揃った lane は「置いた位置に置く」
432
516
  * 扱いになる。 片方だけでは何も変わらない。
@@ -453,6 +537,11 @@ export type CdlNode = {
453
537
  title: string;
454
538
  eyebrow?: string;
455
539
  subtitle?: string;
540
+ /**
541
+ * この箱から指定した node へ矢印なしの補助線を引く。edge ではないため `laid.edges` には入らない。
542
+ * `card` の `subtitle` は札の右側へ `stage-note` として描き、通常の補足位置には出さない。
543
+ */
544
+ leaderTo?: string;
456
545
  /** mono 数値表示用、 `{stateId}` を含めれば state 値で動的置換 */
457
546
  value?: string;
458
547
  /** kind=storage 時の行 (各 mono、 state 参照可) */
@@ -2783,6 +2872,8 @@ export type CdlDiagram = {
2783
2872
  * 種類を書いていない図にも当たる。
2784
2873
  */
2785
2874
  type?: string;
2875
+ /** SVG 内の図の下へ、指定順の横一列で置く凡例。 */
2876
+ legend?: readonly CdlLegendItem[];
2786
2877
  /**
2787
2878
  * 矢印をいつ出すか (既定 = `"phase"`、 #582)。
2788
2879
  *
@@ -2973,6 +3064,14 @@ export type LaidEdge = CdlEdge & {
2973
3064
  labelX: number;
2974
3065
  labelY: number;
2975
3066
  labelAnchor: "start" | "middle" | "end";
3067
+ /** 前の station へ戻る metro 線だけが持つ、専用の点線と矢印を描く目印。 */
3068
+ metroReturn?: true;
3069
+ };
3070
+
3071
+ export type LaidLegendItem = CdlLegendItem & {
3072
+ x: number;
3073
+ /** 文字の baseline。 印はこの値を基準に同じ行へ揃える。 */
3074
+ y: number;
2976
3075
  };
2977
3076
 
2978
3077
  /**
@@ -3008,6 +3107,8 @@ export type LaidDiagram = {
3008
3107
  textMetrics?: "monospace";
3009
3108
  /** どの種類の図か (#645)。 `CdlDiagram.type` をそのまま持ち越す */
3010
3109
  type?: string;
3110
+ /** 指定がある図だけが持つ、配置済みの凡例。 */
3111
+ legend?: readonly LaidLegendItem[];
3011
3112
  viewBox: { x: number; y: number; w: number; h: number };
3012
3113
  /**
3013
3114
  * 図全体の倍率 (`CdlViewport.scale` を正規化した値)。
package/src/validate.ts CHANGED
@@ -191,6 +191,11 @@ export function validate(diag: CdlDiagram): void {
191
191
  `[cdl] unknown-ref: node "${n.id}" の lane "${n.lane}" が未定義です${suggest(n.lane, laneIds)}\n → 修正例: .lane("${n.lane}", { x: 0, width: 400 }) を node 宣言前に追加`,
192
192
  );
193
193
  }
194
+ if (n.leaderTo !== undefined && !nodeIds.has(n.leaderTo)) {
195
+ errors.push(
196
+ `[cdl] unknown-ref: node "${n.id}" の leaderTo "${n.leaderTo}" が node に存在しません${suggest(n.leaderTo, nodeIds, "node")}`,
197
+ );
198
+ }
194
199
  }
195
200
  for (const e of diag.edges) {
196
201
  if (!nodeIds.has(e.from)) {
@@ -151,11 +151,10 @@ import {
151
151
  rendersTitleText,
152
152
  fitsOwnTitleWidth,
153
153
  skipsNodeTitleWidthCheck,
154
- drawsTitleOutsideBox,
155
154
  titleScalesToFit,
156
155
  MIN_TITLE_FONT_SIZE,
157
156
  titleFontSize,
158
- TITLE_BOX_PADDING,
157
+ titleUsableWidth,
159
158
  rowBaselineY,
160
159
  rowGlyphDepth,
161
160
  laneVerticalRange,
@@ -2007,8 +2006,10 @@ function runAxes(
2007
2006
  const LIFELINE_W_THRESHOLD = 8;
2008
2007
  for (const n of laid.nodes) {
2009
2008
  if (n.w < LIFELINE_W_THRESHOLD) continue;
2010
- // 駅は文字を収める箱ではなく線路上の印なので、箱向けの最小寸法を要求しない。
2011
- if (n.kind === "station") continue;
2009
+ // 駅と丸い番号は文字を収める箱ではなく軸上の印なので、箱向けの最小寸法を要求しない。
2010
+ // 丸い番号は見本の半径 24 と縁 4 から大きさが決まり、80×40 を求めると軸より太くなる。
2011
+ // 番号の字が読めるかは軸 3 (text-readability) が引き続き見る。
2012
+ if (n.kind === "station" || n.kind === "timeline-number") continue;
2012
2013
  if (n.w < MIN_NODE_W || n.h < MIN_NODE_H) {
2013
2014
  push(
2014
2015
  "node-visibility",
@@ -2065,8 +2066,7 @@ function runAxes(
2065
2066
  //
2066
2067
  // 箱の内側の余白 (52) は、題を箱の中に描く種別にだけ足す。 箱の外に描く種別 (`dyn-*` の
2067
2068
  // 名前と図表の題) は内側の余白に縛られないので、足すと収まっている題まで挙がる。
2068
- const 余白 = drawsTitleOutsideBox(n.kind) ? 0 : TITLE_BOX_PADDING;
2069
- const 使える幅 = n.w - 余白;
2069
+ const 使える幅 = titleUsableWidth(n.kind, n.w, n.h);
2070
2070
  const 実幅 = measureTextWidth(titleTrimmed, {
2071
2071
  fontSize: titleFontSize(n.kind),
2072
2072
  bound: "drawn",
@@ -2089,7 +2089,7 @@ function runAxes(
2089
2089
  push(
2090
2090
  "text-readability",
2091
2091
  // 字ごとの実幅は小数になるので、読む人に出す時だけ丸める (判定は丸めない)
2092
- `node "${n.id}" title "${n.title}" が node 幅 ${n.w}px を超過する可能性 (期待 ${Math.round(実幅 + 余白)}px)`,
2092
+ `node "${n.id}" title "${n.title}" が node 幅 ${n.w}px を超過する可能性 (期待 ${Math.round(実幅 + (n.w - 使える幅))}px)`,
2093
2093
  "warn",
2094
2094
  );
2095
2095
  }