@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.
@@ -7,7 +7,7 @@ import { FunctionNode } from "../kinds/function";
7
7
  import { StorageNode } from "../kinds/storage";
8
8
  import { GenericNode } from "../kinds/generic";
9
9
  import { MarkStartNode, MarkEndNode } from "../kinds/terminal-mark";
10
- import { StationNode } from "../kinds/station";
10
+ import { StationNode, TimelineNumberNode } from "../kinds/station";
11
11
  import { DynShapeNode } from "../kinds/dyn-shape";
12
12
  import { ChartLineNode, type ChartLineScale } from "../kinds/chart-line";
13
13
  import { ChartPieNode } from "../kinds/chart-pie";
@@ -119,10 +119,14 @@ export function CdlNodeView({
119
119
  stateValues,
120
120
  currentPhaseId,
121
121
  chartLineScale,
122
+ compactTitleInset,
122
123
  関係の受け口,
123
124
  lang,
124
125
  textMetrics,
125
126
  値の名前,
127
+ metroDiagram = false,
128
+ metroDecision = false,
129
+ stationNameRight = false,
126
130
  }: {
127
131
  node: LaidNode;
128
132
  active: boolean;
@@ -144,8 +148,16 @@ export function CdlNodeView({
144
148
  stateValues: Record<string, string>;
145
149
  currentPhaseId: string | undefined;
146
150
  chartLineScale?: ChartLineScale;
151
+ /** 小型 card の名前を左寄せにする時の左端からの距離。段階列の札だけが渡す。 */
152
+ compactTitleInset?: number;
147
153
  /** 触れた箱の関係を光らせる時の受け口 (#806)。 書かない図では `undefined` */
148
154
  関係の受け口?: 関係の受け口;
155
+ /** metro 線と駅を持つ図だけが担当の名札と端の印を路線図向けに描く。 */
156
+ metroDiagram?: boolean;
157
+ /** `routing: "metro"` の線が出入りする分かれ道だけを路線図向けに描く。 */
158
+ metroDecision?: boolean;
159
+ /** 真上から線路が入る駅の名前を右へ置く。 */
160
+ stationNameRight?: boolean;
149
161
  }): JSX.Element {
150
162
  const value = node.value ? interpolate(node.value, stateValues, 値の名前) : undefined;
151
163
  const rows = (node.rows ?? []).map((r) => interpolate(r, stateValues, 値の名前));
@@ -201,7 +213,7 @@ export function CdlNodeView({
201
213
  case "event":
202
214
  return <EventNode node={resolvedNode} active={active} progress={progress} />;
203
215
  case "card":
204
- return <CardNode node={resolvedNode} active={active} />;
216
+ return <CardNode node={resolvedNode} active={active} compactTitleInset={compactTitleInset} />;
205
217
  // 時系列のやり取り (#578)。 1 つの箱が図を丸ごと描く = 縦が時間で、参加者は箱を持たない
206
218
  case "sequence-board":
207
219
  return (
@@ -479,11 +491,13 @@ export function CdlNodeView({
479
491
  case "shape-customer-service":
480
492
  return <ShapeCustomerServiceNode node={resolvedNode} active={active} />;
481
493
  case "mark-start":
482
- return <MarkStartNode node={resolvedNode} active={active} />;
494
+ return <MarkStartNode node={resolvedNode} active={active} metro={metroDiagram} />;
483
495
  case "mark-end":
484
- return <MarkEndNode node={resolvedNode} active={active} />;
496
+ return <MarkEndNode node={resolvedNode} active={active} metro={metroDiagram} />;
485
497
  case "station":
486
- return <StationNode node={resolvedNode} active={active} />;
498
+ return <StationNode node={resolvedNode} active={active} nameRight={stationNameRight} />;
499
+ case "timeline-number":
500
+ return <TimelineNumberNode node={resolvedNode} />;
487
501
  default:
488
502
  // 新 NodeKind 25 種は GenericNode で汎用描画 (kind 別 icon / color / shape)
489
503
  return (
@@ -494,6 +508,8 @@ export function CdlNodeView({
494
508
  stateValues={stateValues}
495
509
  値の名前={値の名前}
496
510
  textMetrics={textMetrics}
511
+ showKindIcon={!(metroDiagram && node.kind === "decision")}
512
+ metroDecision={metroDecision}
497
513
  />
498
514
  );
499
515
  }
@@ -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"