@cardenelabs/cdl 0.6.1 → 0.9.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +401 -0
  2. package/SPEC.md +32 -17
  3. package/dist/index.cjs +820 -364
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +68 -14
  6. package/dist/index.d.ts +68 -14
  7. package/dist/index.js +820 -364
  8. package/dist/index.js.map +1 -1
  9. package/dist/react.cjs +692 -254
  10. package/dist/react.cjs.map +1 -1
  11. package/dist/react.d.cts +1 -1
  12. package/dist/react.d.ts +1 -1
  13. package/dist/react.js +692 -254
  14. package/dist/react.js.map +1 -1
  15. package/dist/{render-DquCvgOB.d.cts → render-hmFRAabt.d.cts} +33 -2
  16. package/dist/{render-DquCvgOB.d.ts → render-hmFRAabt.d.ts} +33 -2
  17. package/package.json +6 -3
  18. package/src/kinds/box-lines.ts +120 -0
  19. package/src/kinds/gantt.tsx +152 -24
  20. package/src/kinds/mind-map.tsx +77 -16
  21. package/src/kinds/quadrant.tsx +28 -2
  22. package/src/kinds/shape-blockchain.tsx +65 -12
  23. package/src/kinds/shape-region.tsx +152 -34
  24. package/src/kinds/tree.tsx +121 -24
  25. package/src/layout/collisions.ts +10 -2
  26. package/src/layout/footer-shape.ts +86 -24
  27. package/src/layout/lifeline.ts +80 -0
  28. package/src/layout/spec.ts +21 -2
  29. package/src/layout/text-width.ts +92 -4
  30. package/src/layout/viewbox.ts +0 -8
  31. package/src/layout.ts +8 -1
  32. package/src/presets.ts +120 -22
  33. package/src/render/header.tsx +3 -1
  34. package/src/render/nodes.tsx +6 -5
  35. package/src/render/payload-binding.ts +88 -3
  36. package/src/render/stage.tsx +17 -34
  37. package/src/render/template-fields.ts +212 -0
  38. package/src/render/utils.ts +32 -2
  39. package/src/render.tsx +2 -9
  40. package/src/types.ts +33 -2
  41. package/src/validate.ts +27 -9
  42. package/src/visual-validate.ts +34 -1
  43. package/src/layout/label-shift.ts +0 -28
package/src/presets.ts CHANGED
@@ -2,7 +2,40 @@ import { diagram } from "./builder";
2
2
  import type { DiagramBuilder } from "./builder";
3
3
  import { LANE_PAD_MIN, NODE_SIZE } from "./layout/tokens";
4
4
  import { requiredRowsWidth } from "./kinds/row-align";
5
- import type { NodeKind, Tone, EdgeStyle, JourneyEmotion, QuadrantKey } from "./types";
5
+ import type { NodeKind, Tone, EdgeStyle, JourneyEmotion, QuadrantKey, BoundNumber } from "./types";
6
+
7
+ /**
8
+ * 状態を読む書き方 (`{名前}`)。
9
+ *
10
+ * 中身の型 (`BoundNumber` / `BoundEnum`) は任意の文字列を受けるが、 **入口はこの形だけに
11
+ * 限る** (#468 Round 1)。 広いままだと `value: "abc"` や `quadrant: "topLfet"` のような
12
+ * 書き間違いが、 これまで組み立ての時点で止まっていたのに実行時まで進み、 解決側の既定値
13
+ * (0 / 左下 / 普通) に落ちた図が出る。
14
+ */
15
+ type 名前の頭 =
16
+ | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m"
17
+ | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z"
18
+ | "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J" | "K" | "L" | "M"
19
+ | "N" | "O" | "P" | "Q" | "R" | "S" | "T" | "U" | "V" | "W" | "X" | "Y" | "Z"
20
+ | "_";
21
+
22
+ export type BindingRef = `{${名前の頭}${string}}`;
23
+
24
+ /**
25
+ * その文字列が **全体で** 状態を読む書き方かどうか (#468 Round 5)。
26
+ *
27
+ * `hasUnresolvedRef` は「参照らしい部分を含むか」 を見るため、 `Phase {A}` のような通常の
28
+ * 名前まで参照とみなす。 工程表の入口は名前と参照を同じ欄で受けるので、 部分一致で判定すると
29
+ * **正当な名前が黙って消える** (実測 = `start: "Phase {A}"` が位置 `"Phase {A}"` になり、
30
+ * 名前の欄が空になった)。
31
+ *
32
+ * 型 (`BindingRef`) が「文字列全体が `{名前}`」 を要求しているので、 判定もそれに揃える。
33
+ *
34
+ * **中身に中括弧を許す**。 型は `{${名前の頭}${string}}` で、 `string` が中括弧を含む形
35
+ * (`{a}tail}` / `{a{b}`) も受ける。 実装だけ狭めると、 **型では通るのに名前として扱われる**
36
+ * 食い違いができる (Round 6 の指摘)。 型で中括弧を除く条件は書けないため、 実装を型に合わせる。
37
+ */
38
+ const 参照そのもの = (v: string): boolean => /^\{[A-Za-z_][\s\S]*\}$/.test(v);
6
39
 
7
40
  /**
8
41
  * 図の格子の目 (world unit)。 `grid-alignment` (軸 21) が見る単位と同じ。
@@ -213,11 +246,32 @@ export type FlowPreset = {
213
246
  /** edge tone / style の default (各 step → next step の edge) */
214
247
  defaultTone?: Tone;
215
248
  defaultStyle?: EdgeStyle;
249
+ /**
250
+ * step 間の edge を自動生成するか (既定 true)。
251
+ *
252
+ * `false` にすると `step()` は node だけを置き、矢印は呼び手が `edge()` で宣言する。
253
+ * **並び順と違う流れ** (`alpha` から `gamma` へ直接繋いで `beta` を経由しない等) を
254
+ * 書きたい時に使う。 自動生成しか無かった間、その形は本 preset では作れなかった (#474)。
255
+ *
256
+ * 既定を `true` にしているので、既存の呼び出しは 1 行も変えずに同じ図になる。
257
+ */
258
+ autoChain?: boolean;
216
259
  };
217
260
 
218
261
  export type FlowBuilder = {
219
- /** step を順番に追加、 自動で前 step から edge 接続 */
262
+ /** step を順番に追加、 自動で前 step から edge 接続 (`autoChain: false` なら node だけ) */
220
263
  step: (node: FlowStepInput, edgeLabel?: string) => FlowBuilder;
264
+ /**
265
+ * 矢印を宣言する (#474)。 tone / style は preset の既定を引き継ぐ。
266
+ *
267
+ * ここで作った矢印も `build()` の phase の `activate` に載る (自動生成分と同じ扱い)。
268
+ * 載せないと animation で光らず、自動生成と見た目が揃わない。
269
+ */
270
+ edge: (
271
+ from: string,
272
+ to: string,
273
+ opts?: { id?: string; label?: string; tone?: Tone; style?: EdgeStyle },
274
+ ) => FlowBuilder;
221
275
  build: () => ReturnType<DiagramBuilder["build"]>;
222
276
  };
223
277
 
@@ -238,6 +292,8 @@ export function flow(preset: FlowPreset): FlowBuilder {
238
292
  b.lane("flow", { width: w, ...(preset.laneLabel ? { label: preset.laneLabel } : {}) });
239
293
  const tone: Tone = preset.defaultTone ?? "accent";
240
294
  const style: EdgeStyle = preset.defaultStyle ?? "dotted-flow";
295
+ // 既定は自動生成。 省略した既存の呼び出しの挙動を変えない (#474)
296
+ const autoChain = preset.autoChain ?? true;
241
297
  let prevId: string | null = null;
242
298
  let stackIdx = 0;
243
299
  const phaseActivate: string[] = [];
@@ -253,7 +309,7 @@ export function flow(preset: FlowPreset): FlowBuilder {
253
309
  ...(node.subtitle ? { subtitle: node.subtitle } : {}),
254
310
  });
255
311
  phaseActivate.push(node.id);
256
- if (prevId !== null) {
312
+ if (autoChain && prevId !== null) {
257
313
  const edgeId = `e-${prevId}-${node.id}`;
258
314
  b.edge(prevId, node.id, {
259
315
  id: edgeId,
@@ -266,6 +322,17 @@ export function flow(preset: FlowPreset): FlowBuilder {
266
322
  prevId = node.id;
267
323
  return api;
268
324
  },
325
+ edge(from, to, opts) {
326
+ const edgeId = opts?.id ?? `e-${from}-${to}`;
327
+ b.edge(from, to, {
328
+ id: edgeId,
329
+ label: opts?.label ?? "→",
330
+ tone: opts?.tone ?? tone,
331
+ style: opts?.style ?? style,
332
+ });
333
+ phaseActivate.push(edgeId);
334
+ return api;
335
+ },
269
336
  build() {
270
337
  b.phase(
271
338
  "flow",
@@ -398,7 +465,8 @@ const actorW = Math.max(140, label.length * 22 + 52);
398
465
  // warning suppress、 ASCII long name (AuthenticationService 21 char) は 514 になり
399
466
  // 過大化との trade-off だが 「警告出るのに対応できない」 の user feedback 優先。
400
467
  const actorW = Math.max(140, label.length * 22 + 52);
401
- b.node(footerId, { lane: laneId, stack: footerStack, kind: "card", title: label, w: actorW, h: 72 });
468
+ // `role` を付けるのは、 描画側が「どれが下端の名札か」 を位置で推測しないため (#458)
469
+ b.node(footerId, { lane: laneId, stack: footerStack, kind: "card", title: label, w: actorW, h: 72, role: "lifeline-footer" });
402
470
  phaseActivate.push(footerId);
403
471
  });
404
472
  b.phase(
@@ -1052,8 +1120,8 @@ export type TreeNode = {
1052
1120
  parent?: string;
1053
1121
  /** 表示 kind (default "card") */
1054
1122
  kind?: NodeKind;
1123
+ /** 箱の 2 行目に出す補足。 上の小ラベルは木では受けない (#471、 `TreeNodePayload` 参照) */
1055
1124
  subtitle?: string;
1056
- eyebrow?: string;
1057
1125
  };
1058
1126
 
1059
1127
  export type TreePreset = {
@@ -1109,7 +1177,6 @@ export function tree(preset: TreePreset): TreeBuilder {
1109
1177
  title: n.title,
1110
1178
  parent: n.parent,
1111
1179
  subtitle: n.subtitle,
1112
- eyebrow: n.eyebrow,
1113
1180
  })),
1114
1181
  });
1115
1182
  b.phase(
@@ -1133,7 +1200,8 @@ export type { JourneyEmotion } from "./types";
1133
1200
  export type JourneyStep = {
1134
1201
  id: string;
1135
1202
  title: string;
1136
- emotion: JourneyEmotion;
1203
+ /** 起伏の段階。 `{名前}` を書くと状態から読む (#468) */
1204
+ emotion: JourneyEmotion | BindingRef;
1137
1205
  /** どの touchpoint (例 "Website", "Email", "Support") */
1138
1206
  touchpoint?: string;
1139
1207
  /** opportunity (改善余地のメモ) */
@@ -1296,8 +1364,8 @@ export function mindMap(preset: MindMapPreset): MindMapBuilder {
1296
1364
  export type FunnelStage = {
1297
1365
  id: string;
1298
1366
  title: string;
1299
- /** この stage の人数 / 件数 */
1300
- count: number;
1367
+ /** この stage の人数 / 件数。 `{名前}` を書くと状態から読む (#468) */
1368
+ count: number | BindingRef;
1301
1369
  /** stage 詳細 */
1302
1370
  subtitle?: string;
1303
1371
  };
@@ -1376,8 +1444,8 @@ export type QuadrantQuadrantLabel = QuadrantKey;
1376
1444
  export type QuadrantItem = {
1377
1445
  id: string;
1378
1446
  title: string;
1379
- /** どの象限 (4 象限) */
1380
- quadrant: QuadrantQuadrantLabel;
1447
+ /** どの象限 (4 象限) `{名前}` を書くと状態から読む (#468) */
1448
+ quadrant: QuadrantQuadrantLabel | BindingRef;
1381
1449
  subtitle?: string;
1382
1450
  };
1383
1451
 
@@ -1472,7 +1540,8 @@ export type ChartType = "pie" | "bar" | "line";
1472
1540
  export type ChartDatum = {
1473
1541
  id: string;
1474
1542
  label: string;
1475
- value: number;
1543
+ /** 棒 / 折れ線 / 扇の大きさ。 `{名前}` を書くと状態から読む (#468) */
1544
+ value: number | BindingRef;
1476
1545
  /** 個別 tone (default は preset の defaultTone) */
1477
1546
  tone?: Tone;
1478
1547
  };
@@ -1553,9 +1622,15 @@ export function chart(preset: ChartPreset): ChartBuilder {
1553
1622
  export type GanttTask = {
1554
1623
  id: string;
1555
1624
  title: string;
1556
- /** 開始 (例 "2026-Q1" / "Day 1") */
1625
+ /**
1626
+ * 開始の目盛りの名前 (例 "2026-Q1" / "Day 1")。
1627
+ *
1628
+ * `{名前}` を書くと **位置そのもの** を状態から読む (#468)。 目盛りの名前の対応付けは
1629
+ * 飛ばす = 名前の並びに `{名前}` が混ざると、 位置が状態で動くたびに目盛りの並びが
1630
+ * 変わってしまう。
1631
+ */
1557
1632
  start: string;
1558
- /** 終了 (例 "2026-Q2") */
1633
+ /** 終了の目盛りの名前。 `{名前}` の扱いは `start` と同じ (#468) */
1559
1634
  end: string;
1560
1635
  /** owner / assignee */
1561
1636
  owner?: string;
@@ -1568,6 +1643,17 @@ export type GanttPreset = {
1568
1643
  topic: string;
1569
1644
  defaultTone?: Tone;
1570
1645
  laneWidth?: number;
1646
+ /**
1647
+ * 横軸を何目盛り分にするか (`#483`)。
1648
+ *
1649
+ * 書かないと今までどおり全 task の最大値から自動で決まるため、 `{名前}` で動く task が
1650
+ * 1 つあると **動かしていない task の帯まで伸び縮みする** (実測 = 端を 3 から 9 に動かすと
1651
+ * 静的な task の帯が 252 から 97.2 になった)。
1652
+ *
1653
+ * 書くと尺がその値で固定され、 他の task の帯の位置と幅が変わらない。 範囲から外れる task の
1654
+ * 帯は右端の目盛りに寄せて描く (`kinds/gantt.tsx` SSOT)。
1655
+ */
1656
+ axisMax?: number;
1571
1657
  };
1572
1658
 
1573
1659
  export type GanttBuilder = {
@@ -1579,8 +1665,11 @@ export type GanttBuilder = {
1579
1665
  * gantt preset ... Gantt chart / sprint planning / release timeline。
1580
1666
  * task ごとに 1 lane を作り、 start - end + owner を subtitle、 dependsOn で前 task → 次 task の edge を auto 生成。
1581
1667
  *
1668
+ * 横軸の範囲は既定では全 task の最大値から自動で決まる。 `axisMax` を書くと尺がその値で
1669
+ * 固定され、 `{名前}` で動く task があっても他の task の帯が動かない (`#483`)。
1670
+ *
1582
1671
  * @example
1583
- * gantt({ id: "release", topic: "Release timeline" })
1672
+ * gantt({ id: "release", topic: "Release timeline", axisMax: 4 })
1584
1673
  * .task({ id: "design", title: "Design", start: "Q1", end: "Q1" })
1585
1674
  * .task({ id: "build", title: "Build", start: "Q2", end: "Q2", dependsOn: "design" })
1586
1675
  * .task({ id: "test", title: "Test", start: "Q3", end: "Q3", dependsOn: "build" })
@@ -1599,12 +1688,19 @@ export function gantt(preset: GanttPreset): GanttBuilder {
1599
1688
  return api;
1600
1689
  },
1601
1690
  build() {
1602
- // start / end label を index に変換 (unique label を順に並べる)
1691
+ // start / end label を index に変換 (unique label を順に並べる)
1692
+ //
1693
+ // `{名前}` を書いた分は **並びに入れない** (#468)。 入れると位置が状態で動くたびに
1694
+ // 目盛りの並びが変わり、 動かしていない task の位置まで動く。
1603
1695
  const labelSeq: string[] = [];
1604
1696
  for (const t of tasks) {
1605
- if (!labelSeq.includes(t.start)) labelSeq.push(t.start);
1606
- if (!labelSeq.includes(t.end)) labelSeq.push(t.end);
1697
+ if (!参照そのもの(t.start) && !labelSeq.includes(t.start)) labelSeq.push(t.start);
1698
+ if (!参照そのもの(t.end) && !labelSeq.includes(t.end)) labelSeq.push(t.end);
1607
1699
  }
1700
+ // `{名前}` は位置そのものとして通す。 名前の欄は空にする = 目盛りは位置にしか無く、
1701
+ // 状態から来る位置に付ける名前が無い (空の目盛りは描かれない)
1702
+ const 位置 = (v: string): BoundNumber => (参照そのもの(v) ? v : labelSeq.indexOf(v));
1703
+ const 名札 = (v: string): string => (参照そのもの(v) ? "" : v);
1608
1704
  b.lane("gantt", { width: canvasW });
1609
1705
  const nodeId = `${preset.id}-gantt`;
1610
1706
  b.node(nodeId, {
@@ -1615,13 +1711,15 @@ export function gantt(preset: GanttPreset): GanttBuilder {
1615
1711
  eyebrow: "gantt",
1616
1712
  w: canvasW,
1617
1713
  h: canvasH,
1714
+ // 宣言していない図には欄自体を載せない = 自動で決める経路をそのまま通す
1715
+ ...(preset.axisMax !== undefined ? { ganttAxisMax: preset.axisMax } : {}),
1618
1716
  ganttData: tasks.map((t) => ({
1619
1717
  id: t.id,
1620
1718
  title: t.title,
1621
- startIdx: labelSeq.indexOf(t.start),
1622
- endIdx: labelSeq.indexOf(t.end),
1623
- startLabel: t.start,
1624
- endLabel: t.end,
1719
+ startIdx: 位置(t.start),
1720
+ endIdx: 位置(t.end),
1721
+ startLabel: 名札(t.start),
1722
+ endLabel: 名札(t.end),
1625
1723
  owner: t.owner,
1626
1724
  dependsOn: t.dependsOn,
1627
1725
  tone,
@@ -124,7 +124,9 @@ function StateDiffCard({
124
124
  <tbody style={{ color: "var(--cdl-text, #1a1f2a)" }}>
125
125
  {states.map((s) => {
126
126
  const initial = String(s.initial);
127
- const current = stateValues[s.id] ?? initial;
127
+ // 自分が持つ名前だけを見る (#456)。 通常の object を渡された場合、 `toString` の
128
+ // ような名前の状態で prototype の関数がそのまま表に出る
129
+ const current = Object.hasOwn(stateValues, s.id) ? stateValues[s.id]! : initial;
128
130
  const changed = initial !== current;
129
131
  return (
130
132
  <tr key={s.id}>
@@ -149,9 +149,10 @@ export function CdlNodeView({
149
149
  return <EventNode node={resolvedNode} active={active} progress={progress} />;
150
150
  case "card":
151
151
  return <CardNode node={resolvedNode} active={active} />;
152
- // 図表 7 種は payload の数と語が `{signal}` を取れるので stateValues を渡す
153
- // (`render/payload-binding.ts` が描画直前に解決する)。 動かさない 2 種
154
- // (mind-map / tree-hierarchy) は構造だけを持つため渡さない。
152
+ // 図表 9 種は payload `{signal}` を取れるので stateValues を渡す
153
+ // (`render/payload-binding.ts` が描画直前に解決する)。 取れる欄は種別で違い、
154
+ // 数と語を取る 7 種に対し、mind-map / tree-hierarchy は **名前だけ** を取る (#467)
155
+ // 親子の繋がりと、描かれない欄 (補足) は解決しない。
155
156
  case "chart-line":
156
157
  return <ChartLineNode node={resolvedNode} active={active} stateValues={stateValues} />;
157
158
  case "chart-pie":
@@ -161,13 +162,13 @@ export function CdlNodeView({
161
162
  case "gantt-timeline":
162
163
  return <GanttNode node={resolvedNode} active={active} stateValues={stateValues} />;
163
164
  case "mind-map":
164
- return <MindMapNode node={resolvedNode} active={active} />;
165
+ return <MindMapNode node={resolvedNode} active={active} stateValues={stateValues} />;
165
166
  case "funnel-stages":
166
167
  return <FunnelNode node={resolvedNode} active={active} stateValues={stateValues} />;
167
168
  case "quadrant-matrix":
168
169
  return <QuadrantNode node={resolvedNode} active={active} stateValues={stateValues} />;
169
170
  case "tree-hierarchy":
170
- return <TreeHierarchyNode node={resolvedNode} active={active} />;
171
+ return <TreeHierarchyNode node={resolvedNode} active={active} stateValues={stateValues} />;
171
172
  case "journey-map":
172
173
  return <UserJourneyNode node={resolvedNode} active={active} stateValues={stateValues} />;
173
174
  case "shape-file":
@@ -6,11 +6,14 @@ import type {
6
6
  GanttTaskPayload,
7
7
  JourneyEmotion,
8
8
  JourneyStepPayload,
9
+ MindBranchNode,
10
+ MindBranchPayload,
9
11
  QuadrantItemPayload,
10
12
  QuadrantKey,
11
13
  QuadrantPayload,
14
+ TreeNodePayload,
12
15
  } from "../types";
13
- import { interpolate } from "./utils";
16
+ import { hasUnresolvedRef, interpolate } from "./utils";
14
17
 
15
18
  /**
16
19
  * 図表 payload が状態を読むための解決層 (dragon#1161 段 3)。
@@ -24,8 +27,15 @@ import { interpolate } from "./utils";
24
27
  * (spec § 4.2 が輪について同じ判断をしている = 1 箇所の壊れで図が消えると原因が分からない)。
25
28
  * 印は各 kind が `data-cdl-unresolved="true"` として DOM に出す。
26
29
  *
27
- * 動かさない 2 種 (`mind-map` / `tree-hierarchy`) はここに resolver を持たない。
28
- * 構造だけを持ち動かせる数が無く、 動かすとしたら項目の増減になるため (Issue の実装しない条件)。
30
+ * `mind-map` / `tree-hierarchy` の 2 種は **名前だけ** を解決する (#467) この 2 種が持つのは
31
+ * 数ではなく構造と名前で、動かせるのは名前の側だけ。
32
+ *
33
+ * 解決するのは **描かれる欄だけ** にする。 この 2 種は補足 (`subtitle`) を描かないため
34
+ * (実測で確認)、解決しても画面には出ず、解決できない時だけ印が付く = 見た目は何も変わらない
35
+ * のに印だけ出る状態になる。 描く方を足すかは別 Issue (#471)。
36
+ *
37
+ * 親子の繋がり (`parent`) も解決しない = 状態で繋ぎ替えると木の形そのものが変わり、
38
+ * 配置の計算と印の付け方が別問題になる。
29
39
  */
30
40
 
31
41
  /** 解決の結果。 `ok` が false なら既定値に落ちたことを表す */
@@ -194,3 +204,78 @@ export function resolveJourneyData(
194
204
  return e.ok ? { ...s, emotion: e.value } : { ...s, emotion: e.value, unresolved: true as const };
195
205
  });
196
206
  }
207
+
208
+ /** 名前の欄を解決した結果。 解決できなければ書いたまま残して印を付ける */
209
+ export type ResolvedTreeNode = TreeNodePayload & UnresolvedMark;
210
+ export type ResolvedMindBranch = MindBranchNode & UnresolvedMark;
211
+ export type ResolvedMindData = Omit<MindBranchPayload, "branches"> & {
212
+ branches: ResolvedMindBranch[];
213
+ } & UnresolvedMark;
214
+
215
+ /**
216
+ * 名前の欄を状態で埋める (#467)。
217
+ *
218
+ * **解決できなかった時も文字を消さない**。 数の欄は既定値 (0 等) に落とすが、名前は落とす先が
219
+ * 無く、空にすると図から項目の存在まで消えてしまう。 書いた `{名前}` をそのまま残せば、
220
+ * どの名前が解決できなかったかが図の上で読める。 印は他の欄と同じ `data-cdl-unresolved`。
221
+ */
222
+ function resolveBoundText(raw: string, values: Record<string, string>): BindingResult<string> {
223
+ // 状態を読まない名前は組み直さない (毎 frame の割当てを避ける近道、 他の resolver と同じ)
224
+ if (!raw.includes("{")) return { value: raw, ok: true };
225
+ const resolved = interpolate(raw, values);
226
+ return hasUnresolvedRef(resolved) ? { value: resolved, ok: false } : { value: resolved, ok: true };
227
+ }
228
+
229
+ /**
230
+ * 木の名前と補足を解決する。 親子の繋がりは触らない。
231
+ *
232
+ * 補足を解決対象に入れたのは #471 で **補足を描くようにしたため**。 描かない欄を解決すると、
233
+ * 画面は何も変わらないのに解決できない時だけ印が付く状態になる = 印の意味が読めない。
234
+ * 逆に描くようになった欄を解決しないと、 図に `{名前}` が素のまま出る。
235
+ */
236
+ export function resolveTreeData(
237
+ nodes: TreeNodePayload[],
238
+ values: Record<string, string>,
239
+ ): ResolvedTreeNode[] {
240
+ const 読む = nodes.some((n) => n.title.includes("{") || (n.subtitle?.includes("{") ?? false));
241
+ if (!読む) return nodes as ResolvedTreeNode[];
242
+ return nodes.map((n) => {
243
+ const title = resolveBoundText(n.title, values);
244
+ const subtitle = n.subtitle === undefined ? undefined : resolveBoundText(n.subtitle, values);
245
+ const 解決後: ResolvedTreeNode = {
246
+ ...n,
247
+ title: title.value,
248
+ ...(subtitle ? { subtitle: subtitle.value } : {}),
249
+ };
250
+ return title.ok && (subtitle?.ok ?? true) ? 解決後 : { ...解決後, unresolved: true as const };
251
+ });
252
+ }
253
+
254
+ /**
255
+ * 放射の名前と補足を解決する。 中心の名前と枝の名前 / 補足を見る。
256
+ *
257
+ * 補足を入れた理由は `resolveTreeData` と同じ (#471)。 中心は補足の欄を持たない。
258
+ */
259
+ export function resolveMindData(
260
+ data: MindBranchPayload,
261
+ values: Record<string, string>,
262
+ ): ResolvedMindData {
263
+ const 読む =
264
+ data.rootTitle.includes("{") ||
265
+ data.branches.some((b) => b.title.includes("{") || (b.subtitle?.includes("{") ?? false));
266
+ if (!読む) return data as ResolvedMindData;
267
+
268
+ const root = resolveBoundText(data.rootTitle, values);
269
+ const branches = data.branches.map((b) => {
270
+ const title = resolveBoundText(b.title, values);
271
+ const subtitle = b.subtitle === undefined ? undefined : resolveBoundText(b.subtitle, values);
272
+ const 解決後: ResolvedMindBranch = {
273
+ ...b,
274
+ title: title.value,
275
+ ...(subtitle ? { subtitle: subtitle.value } : {}),
276
+ };
277
+ return title.ok && (subtitle?.ok ?? true) ? 解決後 : { ...解決後, unresolved: true as const };
278
+ });
279
+ const 解決後: ResolvedMindData = { ...data, rootTitle: root.value, branches };
280
+ return root.ok ? 解決後 : { ...解決後, unresolved: true as const };
281
+ }
@@ -4,7 +4,7 @@ import { CdlEdgeView, CdlEdgeGlowView, CdlEdgeLabelView } from "./edges";
4
4
  import { CdlNodeView } from "./nodes";
5
5
  import { TONE, TONE_HEX } from "./tone";
6
6
  import { buildDiagramAltText } from "../layout/spec";
7
- import { footerShapeDropsOf, lifelineFooterNodes } from "../layout/footer-shape";
7
+ import { lifelineStartYs, uniformLifelineEndY } from "../layout/lifeline";
8
8
 
9
9
  export function CdlStage({
10
10
  laid,
@@ -25,28 +25,22 @@ export function CdlStage({
25
25
  svgRef?: React.RefObject<SVGSVGElement | null>;
26
26
  }): JSX.Element {
27
27
  const activeSet = new Set(currentPhase?.activate ?? []);
28
- // lifeline lane 横断で uniform y2 (footer.top min) を事前計算して等長保証。
29
- // 単一 node lane も同じ y2 を使う (mixed 構成での分岐回避、 Round 2 major fix)。
30
- // footer.top を集めた配列を作り、 min を uniformFooterTop とする。 配列空 (全 lane が
31
- // 1 node 以下 or 0 node) の場合、 最初の lifeline lane の (lane.y + lane.height) を fallback。
32
- const lifelineLanes = laid.lanes.filter((l) => l.lifeline);
33
- // 下端の名札は 2 つの用途に使う。 縦線の終点 (uniformFooterTop) と、 絵を下げる量の表。
28
+ // 縦線の終点は全 lane 1 つに揃える (等長保証)。 値の決め方と、 1 lane の異常が図全体から
29
+ // 縦線を消していた形の対処は `uniformLifelineEndY` の説明。
34
30
  //
35
- // `shape-` の一部は中身を箱の下端から固定の距離で組み立てるため、 箱が低いと絵が上へ出る
36
- // (`SHAPE_DRAW_UP_EXTENT` の説明)。 縦線は箱の上端で止まるので、 出た分だけ縦線が絵の中を通る。
31
+ // 下端の名札で絵が箱の上へ出る種別を下げるのは **組み立て側の責務** (`applyFooterShapeDrops`)。
32
+ // ここで `<g transform>` を掛ける形だった頃は、 描画だけが動いて座標が動かず、 座標を読む側
33
+ // (縦線の終点 / 図の下端 / 外接矩形) が実際の位置を知らなかった (#478)。
37
34
  //
38
- // 名札を集める処理は 1 度だけ行い、 配置が変わらない限り作り直さない。 この component は
39
- // 動きの 1 コマごとに描き直されるため、 毎コマ集め直すと lane 数 × node 数を毎コマ払う
40
- const { footerTops, footerDropById } = useMemo(() => {
41
- const footers = lifelineFooterNodes(laid.lanes, laid.nodes);
35
+ // 始点と終点は 1 度だけ求め、 配置が変わらない限り作り直さない。 この component は
36
+ // 動きの 1 コマごとに描き直されるため、 毎コマ求め直すと lane 数 × node 数を毎コマ払う
37
+ const { lifelineStarts, uniformFooterTop } = useMemo(() => {
38
+ const starts = lifelineStartYs(laid.nodes);
42
39
  return {
43
- footerTops: footers.map((f) => f.cy - f.h / 2),
44
- footerDropById: footerShapeDropsOf(footers),
40
+ lifelineStarts: starts,
41
+ uniformFooterTop: uniformLifelineEndY(laid.lanes, laid.nodes, starts),
45
42
  };
46
43
  }, [laid]);
47
- const uniformFooterTop = footerTops.length > 0
48
- ? Math.min(...footerTops)
49
- : (lifelineLanes[0] ? lifelineLanes[0].y + lifelineLanes[0].height : 0);
50
44
  const diagramScale = laid.diagramScale ?? 1;
51
45
  return (
52
46
  <div className={compact ? "px-3 py-3" : "px-6 py-6"} style={compact ? undefined : { minHeight: 460 }}>
@@ -167,14 +161,10 @@ export function CdlStage({
167
161
  />
168
162
  )}
169
163
  {lane.lifeline && (() => {
170
- // lifeline header (topmost node) bottom edge から uniformFooterTop
171
- // (全 lifeline lane 共通、 CdlStage 冒頭で事前計算) までを結ぶ。 単一 node lane
172
- // multi-node lane も同じ y2 を使うことで mixed 構成の等長保証を守る (Round 2 major fix)
173
- const laneNodes = laid.nodes.filter((n) => n.lane === lane.id);
174
- if (laneNodes.length === 0) return null;
175
- const sortedByCy = laneNodes.slice().sort((a, b) => a.cy - b.cy);
176
- const header = sortedByCy[0]!;
177
- const y1 = header.cy + header.h / 2;
164
+ // 縦線は lane の一番上の node の下端から、 lane 共通の終点までを結ぶ。
165
+ // node 1 つの lane 2 つ以上の lane も同じ終点を使うことで高さが揃う。
166
+ const y1 = lifelineStarts.get(lane.id);
167
+ if (y1 === undefined) return null;
178
168
  const y2 = uniformFooterTop;
179
169
  if (y2 <= y1) return null;
180
170
  return (
@@ -223,14 +213,7 @@ export function CdlStage({
223
213
  currentPhaseId={currentPhase?.id}
224
214
  />
225
215
  );
226
- const dy = footerDropById.get(node.id);
227
- // 下端の名札で絵が上へ出る種別だけ、 絵の上端が箱の上端に来るまで下げる。
228
- // node の座標そのものは動かさない = 矢印の接続先や当たり判定は箱のままにする
229
- return dy === undefined ? (
230
- <g key={node.id}>{view}</g>
231
- ) : (
232
- <g key={node.id} transform={`translate(0, ${dy})`}>{view}</g>
233
- );
216
+ return <g key={node.id}>{view}</g>;
234
217
  })}
235
218
 
236
219
  {/* 経由 node 貫通する dotted-flow edge の進行点 glow (3rd pass) */}