@cardenelabs/cdl 0.5.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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +343 -0
  3. package/SPEC.md +374 -0
  4. package/dist/index.cjs +28085 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +3415 -0
  7. package/dist/index.d.ts +3415 -0
  8. package/dist/index.js +27962 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/react.cjs +23043 -0
  11. package/dist/react.cjs.map +1 -0
  12. package/dist/react.d.cts +2 -0
  13. package/dist/react.d.ts +2 -0
  14. package/dist/react.js +23041 -0
  15. package/dist/react.js.map +1 -0
  16. package/dist/render-C78lIXeC.d.cts +2449 -0
  17. package/dist/render-C78lIXeC.d.ts +2449 -0
  18. package/examples/quick-start.md +88 -0
  19. package/package.json +79 -0
  20. package/src/anim/core/easing.ts +77 -0
  21. package/src/anim/core/timeline.ts +335 -0
  22. package/src/anim/core/types.ts +42 -0
  23. package/src/anim/index.ts +20 -0
  24. package/src/anim/react/index.ts +3 -0
  25. package/src/anim/react/useReducedMotion.ts +27 -0
  26. package/src/anim/react/useTimeline.ts +48 -0
  27. package/src/assert-never.ts +16 -0
  28. package/src/author-intent-verify.ts +587 -0
  29. package/src/builder.ts +3740 -0
  30. package/src/compile.ts +12 -0
  31. package/src/dom-verify-core.ts +121 -0
  32. package/src/dom-verify-types.ts +21 -0
  33. package/src/dom-verify.ts +948 -0
  34. package/src/event-handler/index.ts +184 -0
  35. package/src/formula/ast.ts +46 -0
  36. package/src/formula/evaluator.ts +163 -0
  37. package/src/formula/formula-computeds.ts +83 -0
  38. package/src/formula/index.ts +5 -0
  39. package/src/formula/parser.ts +399 -0
  40. package/src/index.ts +284 -0
  41. package/src/input-signals.ts +74 -0
  42. package/src/kinds/actor.tsx +79 -0
  43. package/src/kinds/card.tsx +61 -0
  44. package/src/kinds/chart-bar.tsx +121 -0
  45. package/src/kinds/chart-line.tsx +163 -0
  46. package/src/kinds/chart-pie.tsx +107 -0
  47. package/src/kinds/compact-title.ts +164 -0
  48. package/src/kinds/dyn-shape.tsx +394 -0
  49. package/src/kinds/event.tsx +70 -0
  50. package/src/kinds/function.tsx +53 -0
  51. package/src/kinds/funnel.tsx +122 -0
  52. package/src/kinds/gantt.tsx +193 -0
  53. package/src/kinds/generic.tsx +368 -0
  54. package/src/kinds/mind-map.tsx +367 -0
  55. package/src/kinds/mind-radial.tsx +209 -0
  56. package/src/kinds/node-tone.ts +18 -0
  57. package/src/kinds/quadrant.tsx +164 -0
  58. package/src/kinds/row-align.ts +179 -0
  59. package/src/kinds/shape-basement.tsx +683 -0
  60. package/src/kinds/shape-blockchain.tsx +750 -0
  61. package/src/kinds/shape-commerce.tsx +553 -0
  62. package/src/kinds/shape-finance.tsx +706 -0
  63. package/src/kinds/shape-hardware.tsx +862 -0
  64. package/src/kinds/shape-people.tsx +439 -0
  65. package/src/kinds/shape-region.tsx +383 -0
  66. package/src/kinds/shape-software.tsx +859 -0
  67. package/src/kinds/storage.tsx +180 -0
  68. package/src/kinds/text-width.ts +73 -0
  69. package/src/kinds/tree.tsx +275 -0
  70. package/src/kinds/user-journey.tsx +240 -0
  71. package/src/label-text.ts +71 -0
  72. package/src/layout/clearance-constants.ts +47 -0
  73. package/src/layout/collisions.ts +2078 -0
  74. package/src/layout/edges.ts +2498 -0
  75. package/src/layout/footer-shape.ts +97 -0
  76. package/src/layout/geometry.ts +135 -0
  77. package/src/layout/label-shift.ts +28 -0
  78. package/src/layout/lanes.ts +328 -0
  79. package/src/layout/nodes.ts +190 -0
  80. package/src/layout/predict-bbox.ts +76 -0
  81. package/src/layout/px-projection.ts +176 -0
  82. package/src/layout/spec.ts +872 -0
  83. package/src/layout/text-width.ts +133 -0
  84. package/src/layout/tokens.ts +181 -0
  85. package/src/layout/viewbox.ts +47 -0
  86. package/src/layout-with-validation.ts +175 -0
  87. package/src/layout.ts +381 -0
  88. package/src/presets.ts +2108 -0
  89. package/src/reactive/batch.ts +48 -0
  90. package/src/reactive/computed.ts +85 -0
  91. package/src/reactive/effect.ts +145 -0
  92. package/src/reactive/index.ts +6 -0
  93. package/src/reactive/internal.ts +109 -0
  94. package/src/reactive/signal.ts +113 -0
  95. package/src/render/edges.tsx +318 -0
  96. package/src/render/header.tsx +146 -0
  97. package/src/render/interactive-panel.tsx +9620 -0
  98. package/src/render/nodes.tsx +319 -0
  99. package/src/render/stage.tsx +377 -0
  100. package/src/render/tone.ts +64 -0
  101. package/src/render/utils.ts +238 -0
  102. package/src/render.tsx +221 -0
  103. package/src/scroll-trigger/index.ts +109 -0
  104. package/src/scroll-trigger/progress.ts +49 -0
  105. package/src/thumbnail.tsx +114 -0
  106. package/src/types.ts +2371 -0
  107. package/src/validate.ts +268 -0
  108. package/src/visual-validate.ts +4381 -0
package/src/presets.ts ADDED
@@ -0,0 +1,2108 @@
1
+ import { diagram } from "./builder";
2
+ import type { DiagramBuilder } from "./builder";
3
+ import { LANE_PAD_MIN, NODE_SIZE } from "./layout/tokens";
4
+ import { requiredRowsWidth } from "./kinds/row-align";
5
+ import type { NodeKind, Tone, EdgeStyle } from "./types";
6
+
7
+ /**
8
+ * 図の格子の目 (world unit)。 `grid-alignment` (軸 21) が見る単位と同じ。
9
+ */
10
+ const GRID_UNIT = 16;
11
+
12
+ /**
13
+ * 箱を格子 (16 world) に載せるための帯の幅。
14
+ *
15
+ * engine は帯を「箱の幅 + 余白 25 × 2」 まで広げ、 箱を帯の中心に置く。 箱の左端は
16
+ * `(帯の幅 - 箱の幅) / 2` = 片側の余白そのものなので、 **片側の余白を 16 の倍数に切り上げる**
17
+ * と左端が格子に載る。 帯の幅を 32 の倍数にするだけでは足りない (箱の幅が 32 の倍数とは
18
+ * 限らないため、 例 560 では左端が 40 になる)。
19
+ *
20
+ * engine の再拡張値 (`箱の幅 + 25 × 2`) より広くなるので、 engine は帯を広げ直さない。
21
+ * 片側の増分は 32 - 25 = 7 world。
22
+ */
23
+ function gridAlignedLaneW(canvasW: number): number {
24
+ const padPerSide = Math.ceil(LANE_PAD_MIN / GRID_UNIT) * GRID_UNIT;
25
+ return canvasW + padPerSide * 2;
26
+ }
27
+
28
+ /**
29
+ * 箱の寸法を格子 (16 world) に合わせて切り上げる。
30
+ *
31
+ * `grid-alignment` は箱の 4 辺が格子に載ることを見る。 左上を格子に置いても、 幅や高さが
32
+ * 16 の倍数でなければ右下が外れる。 著者が `itemWidth` / `stageWidth` に任意の値を渡せる
33
+ * ため、 組み立て器の側で丸める。
34
+ *
35
+ * 切り上げるのは、 縮めると中身の描画領域が指定より狭くなるため。 増分は 16 world 未満。
36
+ */
37
+ function toGridSize(v: number): number {
38
+ return Math.ceil(v / GRID_UNIT) * GRID_UNIT;
39
+ }
40
+
41
+ /**
42
+ * 状態遷移図の状態 1 個の既定幅 (world unit)。
43
+ *
44
+ * `kind: "card"` の既定幅そのものを使う。 `stateWidth` を指定しなければ、
45
+ * 状態の箱は card の既定と同じ大きさで描かれる。 token 側が変われば追従する。
46
+ */
47
+ const DEFAULT_STATE_W = NODE_SIZE.card.w;
48
+
49
+ /**
50
+ * 状態の箱として受け付ける最小幅 (world unit)。
51
+ *
52
+ * これを下回ると描画側が壊れる。 幅 10 未満の箱は `CardNode` が中身を描かずに抜け、
53
+ * 幅 80 未満は `node-visibility` 軸が「小さすぎて見えない」 と判定する。
54
+ * 左右の余白 26 × 2 を引いた残りが文字の置き場所になるので、 80 でも題名は数文字しか入らない。
55
+ */
56
+ const MIN_NODE_BOX_W = 80;
57
+
58
+ /**
59
+ * `stateWidth` を検証して返す。 未指定なら既定幅。
60
+ *
61
+ * 旧実装では不正な値を渡しても帯の幅にしか渡らず、 engine が箱の幅から帯を広げ直すため
62
+ * 実害が出なかった。 箱の幅として使うようになった以上、 描画が壊れる値は入口で弾く。
63
+ *
64
+ * @throws 有限でない値、 または最小幅を下回る値
65
+ */
66
+ function resolveStateW(stateWidth: number | undefined, presetName: string): number {
67
+ if (stateWidth === undefined) return DEFAULT_STATE_W;
68
+ if (!Number.isFinite(stateWidth)) {
69
+ throw new Error(`cdl ${presetName}: stateWidth は有限の数値である必要があります (受け取った値 = ${stateWidth})`);
70
+ }
71
+ if (stateWidth < MIN_NODE_BOX_W) {
72
+ throw new Error(
73
+ `cdl ${presetName}: stateWidth は ${MIN_NODE_BOX_W} 以上である必要があります (受け取った値 = ${stateWidth})`,
74
+ );
75
+ }
76
+ return stateWidth;
77
+ }
78
+
79
+ /**
80
+ * 状態の箱を収める帯の幅を返す。
81
+ *
82
+ * engine は帯を「箱の幅 + 余白 × 2」 まで広げるので、 宣言も同じ式にしておく。 狭く宣言すると
83
+ * engine が広げて後続の帯を右へずらし、 宣言座標と実座標がずれる。
84
+ *
85
+ * 余白は `LANE_PAD_MIN` を下限として、 実際には隣の帯との間隔から逆算される
86
+ * (`layout/lanes.ts` の `padFor`)。 preset が作る帯は x を明示しないので間隔は既定の 80 になり、
87
+ * 余白は下限の 25 に落ち着く。 著者が `viewport.laneGap` を 80 より小さく指定した図では
88
+ * 余白がこれを超え、 engine が帯を追加で広げる。 その場合も配置の安全性は engine が保つが、
89
+ * 宣言幅と実幅は一致しなくなる = 本 helper が返すのは「既定の間隔での幅」。
90
+ */
91
+ function boxLaneW(stateW: number): number {
92
+ return stateW + LANE_PAD_MIN * 2;
93
+ }
94
+
95
+ /**
96
+ * 表形式の箱 (`kind: "storage"`) 1 個の既定幅 (world unit)。
97
+ *
98
+ * ER 図の実体とクラス図のクラスが使う。 `storage` の既定幅そのものを取るので、 token 側が
99
+ * 変われば追従する。
100
+ */
101
+ const DEFAULT_STORAGE_BOX_W = NODE_SIZE.storage.w;
102
+
103
+
104
+ /**
105
+ * 箱の幅を指す option を検証して返す。 未指定なら既定幅。
106
+ *
107
+ * 旧実装では不正な値を渡しても帯の幅にしか渡らず、 engine が箱の幅から帯を広げ直すため
108
+ * 実害が出なかった。 箱の幅として使うようになった以上、 描画が壊れる値は入口で弾く。
109
+ *
110
+ * @throws 有限でない値、 または最小幅を下回る値
111
+ */
112
+ function resolveStorageBoxW(width: number | undefined, presetName: string, optionName: string): number {
113
+ if (width === undefined) return DEFAULT_STORAGE_BOX_W;
114
+ if (!Number.isFinite(width)) {
115
+ throw new Error(`cdl ${presetName}: ${optionName} は有限の数値である必要があります (受け取った値 = ${width})`);
116
+ }
117
+ if (width < MIN_NODE_BOX_W) {
118
+ throw new Error(
119
+ `cdl ${presetName}: ${optionName} は ${MIN_NODE_BOX_W} 以上である必要があります (受け取った値 = ${width})`,
120
+ );
121
+ }
122
+ return width;
123
+ }
124
+
125
+ /**
126
+ * 行 (`rows`) が収まるところまで箱の幅を広げる。
127
+ *
128
+ * 箱の幅を明示しない node は `autoRowsWidth` (`layout/nodes.ts`) が行の実寸まで広げてくれるが、
129
+ * **`w` を明示した時点でその経路は止まる** (`if (n.w != null) return n.w;`)。 preset が幅を
130
+ * 指定するようになった以上、 同じ拡張を preset 側で行わないと長い行が箱からはみ出す。
131
+ *
132
+ * 指定値は下限として扱う。 「指定した幅より狭くならない」 は守り、 中身が入らない時だけ広げる。
133
+ * 例外にしない理由は、 著者が行の実寸を事前に計算できないため (font の送り幅に依存する)。
134
+ */
135
+ function fitRowsWidth(boxW: number, rows: readonly string[] | undefined): number {
136
+ // 呼出は `er` の実体と `classDiagram` のクラスの 2 箇所で、 どちらも `kind: "storage"` を作る。
137
+ // 送り幅と列間は種別で違うため、 どの種別として測るかを渡す
138
+ return Math.max(boxW, requiredRowsWidth(rows ? [...rows] : undefined, "storage"));
139
+ }
140
+
141
+ /**
142
+ * 高位 API ... 著者が「lane / node / edge を 1 件ずつ宣言」 する低位 API を簡略化。
143
+ *
144
+ * - swimlane({ id, topic, lanes: ["送信元", "コントラクト", "出力"] }) で 3 lane 自動配置
145
+ * - flow().step("A", "func").step("B", "storage").build() で sequence diagram 風
146
+ */
147
+
148
+ // ─── swimlane ──────────────────────────────────────────────────
149
+
150
+ export type SwimlanePreset = {
151
+ id: string;
152
+ topic: string;
153
+ /** lane label の配列。 各 lane width は固定 (default 400)、 x は auto-layout */
154
+ lanes: string[];
155
+ /** lane width (default 400) */
156
+ laneWidth?: number;
157
+ /** 各 lane が枠囲み contain か (default false、 true で全 lane contain) */
158
+ contain?: boolean;
159
+ };
160
+
161
+ /**
162
+ * 横並び 複数 lane を 1 行で生成する preset。
163
+ * 著者は lane label のみ指定、 x / width は engine が自動配置。
164
+ *
165
+ * @example
166
+ * swimlane({ id: "tx", topic: "Tx Flow", lanes: ["送信元", "Contract", "出力"] })
167
+ * .node("client", { lane: "send-mototo", stack: 0, kind: "actor", title: "Client" }) // lane id は label を slug 化
168
+ * ...
169
+ */
170
+ /** swimlane の戻り値 ... DiagramBuilder + 各 lane の slug id を取得する getter */
171
+ export type SwimlaneResult = DiagramBuilder & {
172
+ /** lane label から自動生成された slug id を返す (label 順 0-indexed) */
173
+ laneId: (indexOrLabel: number | string) => string;
174
+ };
175
+
176
+ export function swimlane(preset: SwimlanePreset): SwimlaneResult {
177
+ const w = preset.laneWidth ?? 400;
178
+ const b = diagram(preset.id, { topic: preset.topic });
179
+ const labelToId = new Map<string, string>();
180
+ const idsInOrder: string[] = [];
181
+ for (const label of preset.lanes) {
182
+ const id = slugify(label) || `lane-${idsInOrder.length}`;
183
+ labelToId.set(label, id);
184
+ idsInOrder.push(id);
185
+ b.lane(id, { width: w, label, ...(preset.contain ? { contain: true } : {}) });
186
+ }
187
+ const result = b as SwimlaneResult;
188
+ result.laneId = (indexOrLabel) => {
189
+ if (typeof indexOrLabel === "number") {
190
+ return idsInOrder[indexOrLabel] ?? "";
191
+ }
192
+ return labelToId.get(indexOrLabel) ?? indexOrLabel;
193
+ };
194
+ return result;
195
+ }
196
+
197
+ // ─── flow (sequence diagram 風) ────────────────────────────────
198
+
199
+ export type FlowStepInput = {
200
+ id: string;
201
+ kind: NodeKind;
202
+ title: string;
203
+ eyebrow?: string;
204
+ subtitle?: string;
205
+ };
206
+
207
+ export type FlowPreset = {
208
+ id: string;
209
+ topic: string;
210
+ /** lane label (1 本のみ、 全 step が縦 stack で並ぶ) */
211
+ laneLabel?: string;
212
+ laneWidth?: number;
213
+ /** edge tone / style の default (各 step → next step の edge) */
214
+ defaultTone?: Tone;
215
+ defaultStyle?: EdgeStyle;
216
+ };
217
+
218
+ export type FlowBuilder = {
219
+ /** step を順番に追加、 自動で前 step から edge 接続 */
220
+ step: (node: FlowStepInput, edgeLabel?: string) => FlowBuilder;
221
+ build: () => ReturnType<DiagramBuilder["build"]>;
222
+ };
223
+
224
+ /**
225
+ * flow preset ... 1 lane に縦 stack で node を並べ、 前 step → 次 step の edge を auto 接続。
226
+ * sequence diagram 風 (mermaid `sequenceDiagram` の cdl 版)。
227
+ *
228
+ * @example
229
+ * flow({ id: "auth", topic: "Auth Flow" })
230
+ * .step({ id: "user", kind: "person", title: "User" })
231
+ * .step({ id: "api", kind: "api", title: "POST /login" }, "ログイン要求")
232
+ * .step({ id: "db", kind: "database", title: "users 表" }, "credential 検証")
233
+ * .build();
234
+ */
235
+ export function flow(preset: FlowPreset): FlowBuilder {
236
+ const w = preset.laneWidth ?? 400;
237
+ const b = diagram(preset.id, { topic: preset.topic });
238
+ b.lane("flow", { width: w, ...(preset.laneLabel ? { label: preset.laneLabel } : {}) });
239
+ const tone: Tone = preset.defaultTone ?? "accent";
240
+ const style: EdgeStyle = preset.defaultStyle ?? "dotted-flow";
241
+ let prevId: string | null = null;
242
+ let stackIdx = 0;
243
+ const phaseActivate: string[] = [];
244
+
245
+ const api: FlowBuilder = {
246
+ step(node, edgeLabel) {
247
+ b.node(node.id, {
248
+ lane: "flow",
249
+ stack: stackIdx++,
250
+ kind: node.kind,
251
+ title: node.title,
252
+ ...(node.eyebrow ? { eyebrow: node.eyebrow } : {}),
253
+ ...(node.subtitle ? { subtitle: node.subtitle } : {}),
254
+ });
255
+ phaseActivate.push(node.id);
256
+ if (prevId !== null) {
257
+ const edgeId = `e-${prevId}-${node.id}`;
258
+ b.edge(prevId, node.id, {
259
+ id: edgeId,
260
+ label: edgeLabel ?? "→",
261
+ tone,
262
+ style,
263
+ });
264
+ phaseActivate.push(edgeId);
265
+ }
266
+ prevId = node.id;
267
+ return api;
268
+ },
269
+ build() {
270
+ b.phase(
271
+ "flow",
272
+ { duration: 2400, title: preset.topic, body: "全 step 順次実行。" },
273
+ (p) => p.activate(...phaseActivate).badge("flow"),
274
+ );
275
+ return b.build();
276
+ },
277
+ };
278
+
279
+ return api;
280
+ }
281
+
282
+ // ─── sequence (UML sequence diagram 風) ────────────────────────
283
+
284
+ export type SequenceStep = {
285
+ /** from actor id (or label slug) */
286
+ from: string;
287
+ /** to actor id (or label slug) */
288
+ to: string;
289
+ /** message label */
290
+ label: string;
291
+ /** 補足 (2 行目、 例 ... POST /login の body 仕様等) */
292
+ sub?: string;
293
+ tone?: Tone;
294
+ style?: EdgeStyle;
295
+ };
296
+
297
+ export type SequencePreset = {
298
+ id: string;
299
+ topic: string;
300
+ /** actor label の配列 (lifeline、 column 単位) */
301
+ actors: string[];
302
+ /** actor の lane width (default 220) */
303
+ laneWidth?: number;
304
+ /** lane gap (default 60) */
305
+ laneGap?: number;
306
+ /** default edge tone (default accent) */
307
+ defaultTone?: Tone;
308
+ /** default edge style (default solid) */
309
+ defaultStyle?: EdgeStyle;
310
+ };
311
+
312
+ export type SequenceBuilder = {
313
+ /** message step を時系列順に追加、 from → to の水平 edge を引く */
314
+ step: (s: SequenceStep) => SequenceBuilder;
315
+ build: () => ReturnType<DiagramBuilder["build"]>;
316
+ };
317
+
318
+ /**
319
+ * sequence preset ... UML sequence diagram 風。 actors を column 化 (lifeline)、
320
+ * step({ from, to, label }) で時系列順に message edge を生成。
321
+ *
322
+ * @example
323
+ * sequence({ id: "auth-seq", topic: "Auth Sequence", actors: ["User", "API", "DB"] })
324
+ * .step({ from: "User", to: "API", label: "POST /login" })
325
+ * .step({ from: "API", to: "DB", label: "SELECT credentials" })
326
+ * .step({ from: "DB", to: "API", label: "rows", tone: "success", style: "dotted-flow" })
327
+ * .step({ from: "API", to: "User", label: "200 OK", tone: "success" })
328
+ * .build();
329
+ */
330
+ export function sequence(preset: SequencePreset): SequenceBuilder {
331
+ const laneW = preset.laneWidth ?? 340;
332
+ const tone: Tone = preset.defaultTone ?? "accent";
333
+ const style: EdgeStyle = preset.defaultStyle ?? "solid";
334
+ const b = diagram(preset.id, { topic: preset.topic });
335
+
336
+ // actor 名 → slug id の map
337
+ const actorIds = new Map<string, string>();
338
+ const headerNodeIds: string[] = [];
339
+ preset.actors.forEach((label, i) => {
340
+ const id = slugify(label) || `actor-${i}`;
341
+ actorIds.set(label, id);
342
+ actorIds.set(id, id);
343
+ b.lane(id, { width: laneW, label, lifeline: true });
344
+ // 各 lane 先頭に actor header (kind=card) を stack=0 で配置
345
+ // header/footer 幅を title 長に応じて auto-size (text-readability warning 解消)。
346
+ // formula = 22px/char + 52px padding (visualValidate text-readability と完全一致)、 min 140 で従来 sample 互換維持。
347
+ // visualValidate text-readability formula (titleLen * 22 + 52) と完全一致で
348
+ // warning suppress、 ASCII long name (AuthenticationService 21 char) は 514 になり
349
+ // 過大化との trade-off だが 「警告出るのに対応できない」 の user feedback 優先。
350
+ const actorW = Math.max(140, label.length * 22 + 52);
351
+ const headerId = `${id}-header`;
352
+ b.node(headerId, { lane: id, stack: 0, kind: "card", title: label, w: actorW, h: 72 });
353
+ headerNodeIds.push(headerId);
354
+ // header と最初の step の間に dummy spacer node を挟む (stack=1、 step は stack=2 から)
355
+ const spacerId = `${id}-spacer`;
356
+ b.node(spacerId, { lane: id, stack: 1, kind: "card", title: "", w: 2, h: 40 });
357
+ });
358
+
359
+ const phaseActivate: string[] = [...headerNodeIds];
360
+ let stepIdx = 0;
361
+ const stepBoxIds: Array<{ from: string; to: string; stack: number }> = [];
362
+
363
+ const api: SequenceBuilder = {
364
+ step(s) {
365
+ const fromLaneId = actorIds.get(s.from) ?? s.from;
366
+ const toLaneId = actorIds.get(s.to) ?? s.to;
367
+ const stack = stepIdx + 2; // stack=0 は header、 stack=1 は spacer、 step は 2 から
368
+ // from / to lane に それぞれ invisible activation box (kind=card、 title="") を配置
369
+ const fromBoxId = `s${stepIdx}-${fromLaneId}`;
370
+ const toBoxId = `s${stepIdx}-${toLaneId}`;
371
+ // activation marker (lifeline 上に置く小点)、 edge anchor 用。 w=h=2 で視覚上ほぼ非表示
372
+ b.node(fromBoxId, { lane: fromLaneId, stack, kind: "card", title: "", w: 2, h: 2 });
373
+ if (fromLaneId !== toLaneId) {
374
+ b.node(toBoxId, { lane: toLaneId, stack, kind: "card", title: "", w: 2, h: 2 });
375
+ }
376
+ const edgeId = `e${stepIdx}-${fromLaneId}-${toLaneId}`;
377
+ b.edge(fromBoxId, fromLaneId === toLaneId ? fromBoxId : toBoxId, {
378
+ id: edgeId,
379
+ label: s.label,
380
+ ...(s.sub ? { sub: s.sub } : {}),
381
+ tone: s.tone ?? tone,
382
+ style: s.style ?? style,
383
+ });
384
+ stepBoxIds.push({ from: fromBoxId, to: toBoxId, stack });
385
+ phaseActivate.push(fromBoxId);
386
+ if (fromLaneId !== toLaneId) phaseActivate.push(toBoxId);
387
+ phaseActivate.push(edgeId);
388
+ stepIdx += 1;
389
+ return api;
390
+ },
391
+ build() {
392
+ // 下端 footer ... UML sequence 標準どおり、 各 lifeline 末尾に header と同 box を再描画
393
+ const footerStack = stepIdx + 2; // 最終 step (stepIdx + 1 の stack) の次 stack
394
+ preset.actors.forEach((label) => {
395
+ const laneId = actorIds.get(label) ?? slugify(label);
396
+ const footerId = `${laneId}-footer`;
397
+ // visualValidate text-readability formula (titleLen * 22 + 52) と完全一致で
398
+ // warning suppress、 ASCII long name (AuthenticationService 21 char) は 514 になり
399
+ // 過大化との trade-off だが 「警告出るのに対応できない」 の user feedback 優先。
400
+ 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 });
402
+ phaseActivate.push(footerId);
403
+ });
404
+ b.phase(
405
+ "seq",
406
+ { duration: 2400, title: preset.topic, body: "sequence の全 message を時系列展開。" },
407
+ (p) => p.activate(...phaseActivate).badge("sequence"),
408
+ );
409
+ return b.build();
410
+ },
411
+ };
412
+
413
+ return api;
414
+ }
415
+
416
+ // ─── topology (構成図 / deployment) ───────────────────────────
417
+
418
+ export type TopologyContainer = {
419
+ id: string;
420
+ kind: NodeKind;
421
+ title: string;
422
+ eyebrow?: string;
423
+ };
424
+
425
+ export type TopologyConnection = {
426
+ from: string;
427
+ to: string;
428
+ label: string;
429
+ sub?: string;
430
+ tone?: Tone;
431
+ style?: EdgeStyle;
432
+ labelOffsetX?: number;
433
+ labelOffsetY?: number;
434
+ };
435
+
436
+ export type TopologyPreset = {
437
+ id: string;
438
+ topic: string;
439
+ /** group の lane width (default 360) */
440
+ groupWidth?: number;
441
+ defaultTone?: Tone;
442
+ defaultStyle?: EdgeStyle;
443
+ };
444
+
445
+ export type TopologyGroupBuilder = {
446
+ add: (container: TopologyContainer) => TopologyGroupBuilder;
447
+ };
448
+
449
+ export type TopologyBuilder = {
450
+ group: (id: string, opts: { label: string }) => TopologyGroupBuilder;
451
+ connect: (
452
+ from: string,
453
+ to: string,
454
+ opts: { label: string; sub?: string; tone?: Tone; style?: EdgeStyle; labelOffsetX?: number; labelOffsetY?: number },
455
+ ) => TopologyBuilder;
456
+ build: () => ReturnType<DiagramBuilder["build"]>;
457
+ };
458
+
459
+ /**
460
+ * topology preset ... 構成図 / deployment diagram 風。
461
+ * group で container を囲む、 add で内部 element を配置、 connect で element 間 connection。
462
+ *
463
+ * @example
464
+ * topology({ id: "aws", topic: "AWS Deployment" })
465
+ * .group("aws", { label: "AWS" })
466
+ * .add({ id: "alb", kind: "service", title: "ALB" })
467
+ * .add({ id: "ecs", kind: "service", title: "ECS Task" })
468
+ * .add({ id: "rds", kind: "database", title: "RDS" })
469
+ * .group("client", { label: "Client" })
470
+ * .add({ id: "browser", kind: "frontend", title: "Browser" })
471
+ * .connect("browser", "alb", { label: "HTTPS" })
472
+ * .connect("alb", "ecs", { label: "round-robin" })
473
+ * .connect("ecs", "rds", { label: "TCP 5432" })
474
+ * .build();
475
+ */
476
+ export function topology(preset: TopologyPreset): TopologyBuilder {
477
+ const groupW = preset.groupWidth ?? 460;
478
+ const tone: Tone = preset.defaultTone ?? "accent";
479
+ const style: EdgeStyle = preset.defaultStyle ?? "solid";
480
+ const b = diagram(preset.id, { topic: preset.topic });
481
+
482
+ const groupOrder: string[] = [];
483
+ const groupStackCount = new Map<string, number>();
484
+ const phaseActivate: string[] = [];
485
+ let connIdx = 0;
486
+
487
+ const api: TopologyBuilder = {
488
+ group(groupId, opts) {
489
+ if (!groupOrder.includes(groupId)) {
490
+ groupOrder.push(groupId);
491
+ groupStackCount.set(groupId, 0);
492
+ b.lane(groupId, { width: groupW, label: opts.label, contain: true });
493
+ }
494
+ const groupApi: TopologyGroupBuilder = {
495
+ add(container) {
496
+ const stack = groupStackCount.get(groupId) ?? 0;
497
+ b.node(container.id, {
498
+ lane: groupId,
499
+ stack,
500
+ kind: container.kind,
501
+ title: container.title,
502
+ ...(container.eyebrow ? { eyebrow: container.eyebrow } : {}),
503
+ });
504
+ groupStackCount.set(groupId, stack + 1);
505
+ phaseActivate.push(container.id);
506
+ return groupApi;
507
+ },
508
+ };
509
+ return groupApi;
510
+ },
511
+ connect(from, to, opts) {
512
+ const currentIdx = connIdx++;
513
+ const edgeId = `c${currentIdx}-${from}-${to}`;
514
+ // CAR-559 SSOT ... 旧実装は `labelOffsetY: sameLane ? 0 : currentIdx % 2 === 0 ? 90 : -52`
515
+ // で lane 越え horizontal edge の label を強制的に上下シフトして overlap 解消していた。
516
+ // CAR-430 で engine 側 fan / parallel offset (PARALLEL_STEP_Y=52 / FAN_STEP=40 in edges.ts) +
517
+ // chain 伝搬 shift (resolveEdgeLabelOverlapsWithChainAndPropagate) が自動分散する現在、
518
+ // author 指定 ±90/-52 は engine 分散と二重発火して SSOT LABEL_INIT_CLEARANCE (26 + sub 分岐で
519
+ // 50) を大幅に上書き、 topology HTTPS edge (sub="TLS 1.3") で pill と edge の距離が
520
+ // LABEL_TO_PATH_CLEARANCE を変えても SSOT 通り動かず逆挙動する bug (label が edge より下に
521
+ // 落ちる + CLEARANCE 増加で gap 減少) の直接原因になっていた。
522
+ // CAR-531 (classDiagram の ±60 除去) と同じ方針で author 指定 offset を削除し、
523
+ // engine 側 SSOT (LABEL_INIT_CLEARANCE + fan offset + chain shift) に一任する。
524
+ // 個別 connection で offset を再導入したい author は opts.labelOffsetX/Y で明示的に指定する。
525
+ const labelOffsetX = opts.labelOffsetX ?? 0;
526
+ const labelOffsetY = opts.labelOffsetY ?? 0;
527
+ b.edge(from, to, {
528
+ id: edgeId,
529
+ label: opts.label,
530
+ ...(opts.sub ? { sub: opts.sub } : {}),
531
+ tone: opts.tone ?? tone,
532
+ style: opts.style ?? style,
533
+ labelOffsetX,
534
+ labelOffsetY,
535
+ });
536
+ phaseActivate.push(edgeId);
537
+ return api;
538
+ },
539
+ build() {
540
+ b.phase(
541
+ "topo",
542
+ { duration: 2400, title: preset.topic, body: "topology の全 container + connection を visible 化。" },
543
+ (p) => p.activate(...phaseActivate).badge("topology"),
544
+ );
545
+ return b.build();
546
+ },
547
+ };
548
+
549
+ return api;
550
+ }
551
+
552
+ // ─── er (ER 図 / Entity Relationship) ─────────────────────────
553
+
554
+ export type ErEntity = {
555
+ id: string;
556
+ /** entity 名 (例 "User") */
557
+ title: string;
558
+ /** 列定義 (例 "id: PK", "email: string", "userId: FK") */
559
+ rows: string[];
560
+ };
561
+
562
+ export type ErRelationCardinality = "1:1" | "1:N" | "N:1" | "N:M" | "0..1" | "1..*";
563
+
564
+ export type ErRelation = {
565
+ from: string;
566
+ to: string;
567
+ cardinality: ErRelationCardinality;
568
+ /** 関係名 (例 "places", "belongs_to") */
569
+ label?: string;
570
+ tone?: Tone;
571
+ };
572
+
573
+ export type ErPreset = {
574
+ id: string;
575
+ topic: string;
576
+ defaultTone?: Tone;
577
+ /**
578
+ * 実体 1 個の箱の幅 (world unit、 既定 = `storage` の既定幅)。
579
+ *
580
+ * 帯ではなく **箱** の幅。 帯は `箱の幅 + 余白 × 2` で宣言する (engine が帯を同じ式まで
581
+ * 広げるため、 狭く宣言すると宣言座標と実座標がずれる)。
582
+ */
583
+ entityWidth?: number;
584
+ };
585
+
586
+ export type ErBuilder = {
587
+ entity: (e: ErEntity) => ErBuilder;
588
+ relation: (r: ErRelation) => ErBuilder;
589
+ build: () => ReturnType<DiagramBuilder["build"]>;
590
+ };
591
+
592
+ /**
593
+ * er preset ... ER 図 (Entity Relationship)。 entity を table 風 node (rows 構造) で配置、
594
+ * relation を edge で接続、 cardinality は edge label に記法 (1:1 / 1:N / N:M / 0..1 / 1..*) で表記。
595
+ * mermaid ER 図の cdl 版。
596
+ *
597
+ * @example
598
+ * er({ id: "user-order", topic: "User-Order ER" })
599
+ * .entity({ id: "user", title: "User", rows: ["id: PK", "email: string", "createdAt: timestamp"] })
600
+ * .entity({ id: "order", title: "Order", rows: ["id: PK", "userId: FK", "total: number"] })
601
+ * .relation({ from: "user", to: "order", cardinality: "1:N", label: "places" })
602
+ * .build();
603
+ */
604
+ export function er(preset: ErPreset): ErBuilder {
605
+ const entityW = resolveStorageBoxW(preset.entityWidth, "er", "entityWidth");
606
+ const tone: Tone = preset.defaultTone ?? "info";
607
+ const b = diagram(preset.id, { topic: preset.topic });
608
+
609
+ const entities: string[] = [];
610
+ const phaseActivate: string[] = [];
611
+ let relIdx = 0;
612
+
613
+ const api: ErBuilder = {
614
+ entity(e) {
615
+ const laneId = `lane-${e.id}`;
616
+ // 帯は実体ごとに決める。 行の長さで箱が広がる実体があるため、 全実体を同じ帯幅で
617
+ // 宣言すると宣言座標と実座標がずれる。
618
+ const boxW = fitRowsWidth(entityW, e.rows);
619
+ b.lane(laneId, { width: boxLaneW(boxW) });
620
+ b.node(e.id, {
621
+ lane: laneId,
622
+ stack: 0,
623
+ kind: "storage",
624
+ w: boxW,
625
+ title: e.title,
626
+ eyebrow: "エンティティ",
627
+ rows: e.rows,
628
+ });
629
+ entities.push(e.id);
630
+ phaseActivate.push(e.id);
631
+ return api;
632
+ },
633
+ relation(r) {
634
+ const currentIdx = relIdx++;
635
+ const edgeId = `rel-${currentIdx}-${r.from}-${r.to}`;
636
+ const label = r.label ?? r.cardinality;
637
+ const sub = r.label ? r.cardinality : undefined;
638
+ // ER 図の relation label は entity 上に配置 (path 短く entity 隣接なので矢印との視覚関連が強い)。
639
+ // CAR-430 で label-shift.ts の shift 探索を削除、 edges.ts routePath() init 位置が SSOT。
640
+ // overlap 発生時は post-pass 2 の resolveEdgeLabelOverlapsWithChain が chain 伝搬で解消する。
641
+ b.edge(r.from, r.to, {
642
+ id: edgeId,
643
+ label,
644
+ ...(sub ? { sub } : {}),
645
+ tone: r.tone ?? tone,
646
+ style: "solid",
647
+ });
648
+ phaseActivate.push(edgeId);
649
+ return api;
650
+ },
651
+ build() {
652
+ b.phase(
653
+ "er",
654
+ { duration: 2400, title: preset.topic, body: "ER 図の全 entity + relation を visible 化。" },
655
+ (p) => p.activate(...phaseActivate).badge("er"),
656
+ );
657
+ return b.build();
658
+ },
659
+ };
660
+
661
+ return api;
662
+ }
663
+
664
+ // ─── stateMachine (FSM / Workflow) ────────────────────────────
665
+
666
+ export type FsmState = {
667
+ id: string;
668
+ title: string;
669
+ /** initial state (1 つだけ) */
670
+ initial?: boolean;
671
+ /** final state (複数可) */
672
+ final?: boolean;
673
+ };
674
+
675
+ export type FsmTransition = {
676
+ from: string;
677
+ to: string;
678
+ /** 遷移 trigger (例 "submit" / "success" / "fail") */
679
+ trigger: string;
680
+ /** guard 条件 (例 "if validated") */
681
+ guard?: string;
682
+ tone?: Tone;
683
+ };
684
+
685
+ export type StateMachinePreset = {
686
+ id: string;
687
+ topic: string;
688
+ defaultTone?: Tone;
689
+ /**
690
+ * 状態 1 個の箱の幅 (world unit、 既定 320 = card の既定幅)。
691
+ *
692
+ * 帯の幅ではなく **箱の幅** を指す。 帯は engine が「箱の幅 + 余白 × 2」 まで広げるので、
693
+ * 帯だけを狭く指定しても図は縮まない (#357 以前はここが帯にしか渡っておらず、
694
+ * 370 未満の指定が全て無視されていた)。
695
+ *
696
+ * 80 未満と有限でない値は例外を投げる。 箱の幅として使う以上、 描画が壊れる値を
697
+ * 黙って受け取らない。
698
+ */
699
+ stateWidth?: number;
700
+ };
701
+
702
+ export type StateMachineBuilder = {
703
+ state: (s: FsmState) => StateMachineBuilder;
704
+ transition: (t: FsmTransition) => StateMachineBuilder;
705
+ build: () => ReturnType<DiagramBuilder["build"]>;
706
+ };
707
+
708
+ /**
709
+ * stateMachine preset ... FSM (Finite State Machine) / workflow。
710
+ * state を card kind で配置、 transition を edge で接続、 trigger は edge label、 guard は sub。
711
+ * initial / final state は eyebrow + tone で識別表現。
712
+ *
713
+ * @example
714
+ * stateMachine({ id: "auth-fsm", topic: "Auth FSM" })
715
+ * .state({ id: "idle", title: "Idle", initial: true })
716
+ * .state({ id: "loading", title: "Loading" })
717
+ * .state({ id: "done", title: "Done", final: true })
718
+ * .state({ id: "error", title: "Error" })
719
+ * .transition({ from: "idle", to: "loading", trigger: "submit" })
720
+ * .transition({ from: "loading", to: "done", trigger: "success", tone: "success" })
721
+ * .transition({ from: "loading", to: "error", trigger: "fail", tone: "error" })
722
+ * .transition({ from: "error", to: "idle", trigger: "retry", guard: "if attempts < 3" })
723
+ * .build();
724
+ */
725
+ export function stateMachine(preset: StateMachinePreset): StateMachineBuilder {
726
+ const stateW = resolveStateW(preset.stateWidth, "stateMachine");
727
+ const laneW = boxLaneW(stateW);
728
+ const tone: Tone = preset.defaultTone ?? "accent";
729
+ const b = diagram(preset.id, { topic: preset.topic });
730
+
731
+ const phaseActivate: string[] = [];
732
+ let transIdx = 0;
733
+ const stateOrder = new Map<string, number>();
734
+
735
+ const api: StateMachineBuilder = {
736
+ state(s) {
737
+ const laneId = `lane-${s.id}`;
738
+ b.lane(laneId, { width: laneW });
739
+ const eyebrow = s.initial ? "初期" : s.final ? "最終" : "状態";
740
+ b.node(s.id, {
741
+ lane: laneId,
742
+ stack: 0,
743
+ kind: "card",
744
+ w: stateW,
745
+ title: s.title,
746
+ eyebrow,
747
+ });
748
+ stateOrder.set(s.id, stateOrder.size);
749
+ phaseActivate.push(s.id);
750
+ return api;
751
+ },
752
+ transition(t) {
753
+ const currentIdx = transIdx++;
754
+ const edgeId = `t${currentIdx}-${t.from}-${t.to}`;
755
+ // back transition (state idx 後ろ → 前) は side=top で node 上端から出させ、
756
+ // 上方経路で detour させて forward path と重ならないようにする。
757
+ // CAR-468 SSOT ... 旧 labelOffsetY = isBack ? -200 : ±170 (label を強制的に上下シフト) を廃止、
758
+ // 全 transition で labelOffsetY = 0 とし routePath() の SSOT init 位置 (LABEL_INIT_CLEARANCE=34、 CAR-630 で 26 → 34 復帰) を
759
+ // そのまま採用する。 複数 transition の label 分散は engine 側 parallel / fan-in / fan-out logic
760
+ // (PARALLEL_STEP_Y=52 / FAN_STEP=40 in edges.ts) と post-pass 2 chain shift が担当する。
761
+ const fromIdx = stateOrder.get(t.from) ?? 0;
762
+ const toIdx = stateOrder.get(t.to) ?? 0;
763
+ const isBack = toIdx < fromIdx;
764
+ b.edge(t.from, t.to, {
765
+ id: edgeId,
766
+ label: t.trigger,
767
+ ...(t.guard ? { sub: t.guard } : {}),
768
+ tone: t.tone ?? tone,
769
+ style: "solid",
770
+ ...(isBack ? { routing: "back-detour" as const } : {}),
771
+ });
772
+ phaseActivate.push(edgeId);
773
+ return api;
774
+ },
775
+ build() {
776
+ b.phase(
777
+ "fsm",
778
+ { duration: 2400, title: preset.topic, body: "FSM の全 state + transition を visible 化。" },
779
+ (p) => p.activate(...phaseActivate).badge("fsm"),
780
+ );
781
+ return b.build();
782
+ },
783
+ };
784
+
785
+ return api;
786
+ }
787
+
788
+ // ─── infrastructure (cloud icon grid + connection) ────────────
789
+
790
+ export type InfraNode = {
791
+ id: string;
792
+ kind: NodeKind;
793
+ title: string;
794
+ /** 表示位置 (col, row)、 0-indexed。 grid 上の配置を author が制御 */
795
+ col: number;
796
+ row: number;
797
+ subtitle?: string;
798
+ eyebrow?: string;
799
+ };
800
+
801
+ export type InfraConnection = {
802
+ from: string;
803
+ to: string;
804
+ label: string;
805
+ sub?: string;
806
+ tone?: Tone;
807
+ style?: EdgeStyle;
808
+ /** label の水平方向 fine offset (default 0)、 preset 交互 ±60 を override */
809
+ labelOffsetX?: number;
810
+ /** label の垂直方向 fine offset (default 0、 preset 交互 ±60 を override) */
811
+ labelOffsetY?: number;
812
+ };
813
+
814
+ export type InfrastructurePreset = {
815
+ id: string;
816
+ topic: string;
817
+ /** col あたりの lane width (default 380) */
818
+ laneWidth?: number;
819
+ defaultTone?: Tone;
820
+ defaultStyle?: EdgeStyle;
821
+ };
822
+
823
+ export type InfrastructureBuilder = {
824
+ node: (n: InfraNode) => InfrastructureBuilder;
825
+ connect: (c: InfraConnection) => InfrastructureBuilder;
826
+ build: () => ReturnType<DiagramBuilder["build"]>;
827
+ };
828
+
829
+ /**
830
+ * infrastructure preset ... AWS / GCP / Azure 等の cloud / system 構成図。
831
+ * node を col + row の grid で配置、 connect で接続線 + edge label。
832
+ * topology preset (group + container 包含) と違って flat grid 配置 + 各 node が
833
+ * 独立 lane を持つので、 BFF / Lambda / CDN 等を散在配置するシステム概要図に適する。
834
+ *
835
+ * @example
836
+ * infrastructure({ id: "saas-infra", topic: "SaaS Architecture" })
837
+ * .node({ id: "user", kind: "person", title: "User", col: 0, row: 0 })
838
+ * .node({ id: "cdn", kind: "cdn", title: "CloudFront", col: 1, row: 0 })
839
+ * .node({ id: "alb", kind: "service", title: "ALB", col: 2, row: 0 })
840
+ * .node({ id: "app", kind: "service", title: "App", col: 2, row: 1 })
841
+ * .node({ id: "db", kind: "database", title: "RDS", col: 3, row: 1 })
842
+ * .connect({ from: "user", to: "cdn", label: "HTTPS" })
843
+ * .connect({ from: "cdn", to: "alb", label: "origin" })
844
+ * .connect({ from: "alb", to: "app", label: "route" })
845
+ * .connect({ from: "app", to: "db", label: "TCP 5432" })
846
+ * .build();
847
+ */
848
+ export function infrastructure(preset: InfrastructurePreset): InfrastructureBuilder {
849
+ const laneW = preset.laneWidth ?? 380;
850
+ const tone: Tone = preset.defaultTone ?? "accent";
851
+ const style: EdgeStyle = preset.defaultStyle ?? "solid";
852
+ const b = diagram(preset.id, { topic: preset.topic });
853
+
854
+ // col → laneId、 col の数で lane を auto 作成
855
+ const colLaneIds = new Map<number, string>();
856
+ const phaseActivate: string[] = [];
857
+ let connIdx = 0;
858
+
859
+ const api: InfrastructureBuilder = {
860
+ node(n) {
861
+ let laneId = colLaneIds.get(n.col);
862
+ if (!laneId) {
863
+ laneId = `col-${n.col}`;
864
+ b.lane(laneId, { width: laneW });
865
+ colLaneIds.set(n.col, laneId);
866
+ }
867
+ b.node(n.id, {
868
+ lane: laneId,
869
+ stack: n.row,
870
+ kind: n.kind,
871
+ title: n.title,
872
+ ...(n.eyebrow ? { eyebrow: n.eyebrow } : {}),
873
+ ...(n.subtitle ? { subtitle: n.subtitle } : {}),
874
+ });
875
+ phaseActivate.push(n.id);
876
+ return api;
877
+ },
878
+ connect(c) {
879
+ const currentIdx = connIdx++;
880
+ const edgeId = `i${currentIdx}-${c.from}-${c.to}`;
881
+ // Issue #213 SSOT ... 旧実装は `labelOffsetY = currentIdx % 2 === 0 ? -60 : 60` で
882
+ // label を縦交互に強制 shift して overlap 解消していた。 基準位置は path の上
883
+ // LABEL_INIT_CLEARANCE (34) なので、 +60 すると path の下 26 に落ちる。
884
+ // pill 端 と path の距離は 26 - 18 = 8 world となり LABEL_TO_PATH_CLEARANCE (16) を割り込む。
885
+ //
886
+ // topology (CAR-559) / stateMachine (CAR-468) / classDiagram (CAR-531) は同じ理由で
887
+ // author 指定 offset を撤去し engine 側 SSOT に一任済み。 infrastructure だけが残っていた。
888
+ // label の分散は engine 側 (parallel / fan offset + chain 伝搬 shift) が担当する。
889
+ // 個別 connection で offset を再導入したい author は c.labelOffsetX/Y で明示指定する。
890
+ b.edge(c.from, c.to, {
891
+ id: edgeId,
892
+ label: c.label,
893
+ ...(c.sub ? { sub: c.sub } : {}),
894
+ tone: c.tone ?? tone,
895
+ style: c.style ?? style,
896
+ labelOffsetX: c.labelOffsetX ?? 0,
897
+ labelOffsetY: c.labelOffsetY ?? 0,
898
+ });
899
+ phaseActivate.push(edgeId);
900
+ return api;
901
+ },
902
+ build() {
903
+ b.phase(
904
+ "infra",
905
+ { duration: 2400, title: preset.topic, body: "infrastructure 全 node + connection を visible 化。" },
906
+ (p) => p.activate(...phaseActivate).badge("infrastructure"),
907
+ );
908
+ return b.build();
909
+ },
910
+ };
911
+
912
+ return api;
913
+ }
914
+
915
+ // ─── classDiagram (UML class + attribute / method / relation) ─
916
+
917
+ export type ClassRelationType = "extends" | "implements" | "uses" | "aggregates" | "composes";
918
+
919
+ export type UmlClass = {
920
+ id: string;
921
+ title: string;
922
+ /** 属性 (例 "+name: string" / "-id: number") */
923
+ attributes?: string[];
924
+ /** メソッド (例 "+login(): void") */
925
+ methods?: string[];
926
+ /** stereotype (例 "abstract" / "interface" / "trait") */
927
+ stereotype?: string;
928
+ };
929
+
930
+ export type UmlRelation = {
931
+ from: string;
932
+ to: string;
933
+ type: ClassRelationType;
934
+ /** 関係 label (default は type 名) */
935
+ label?: string;
936
+ /** 多重度 (例 "1..*" / "0..1") */
937
+ cardinality?: string;
938
+ tone?: Tone;
939
+ };
940
+
941
+ export type ClassDiagramPreset = {
942
+ id: string;
943
+ topic: string;
944
+ defaultTone?: Tone;
945
+ /**
946
+ * クラス 1 個の箱の幅 (world unit、 既定 = `storage` の既定幅)。
947
+ *
948
+ * 帯ではなく **箱** の幅。 帯は `箱の幅 + 余白 × 2` で宣言する。
949
+ */
950
+ classWidth?: number;
951
+ };
952
+
953
+ export type ClassDiagramBuilder = {
954
+ class: (c: UmlClass) => ClassDiagramBuilder;
955
+ relation: (r: UmlRelation) => ClassDiagramBuilder;
956
+ build: () => ReturnType<DiagramBuilder["build"]>;
957
+ };
958
+
959
+ /**
960
+ * classDiagram preset ... UML クラス図。 attributes + methods を rows で表現、
961
+ * relation を edge で接続。 extends / implements / uses / aggregates / composes 5 種対応。
962
+ *
963
+ * @example
964
+ * classDiagram({ id: "user-class", topic: "User domain model" })
965
+ * .class({ id: "User", title: "User", attributes: ["+name: string", "+email: string"], methods: ["+login(): void"] })
966
+ * .class({ id: "Admin", title: "Admin", attributes: ["+permissions: string[]"], methods: ["+banUser(): void"] })
967
+ * .relation({ from: "Admin", to: "User", type: "extends" })
968
+ * .build();
969
+ */
970
+ export function classDiagram(preset: ClassDiagramPreset): ClassDiagramBuilder {
971
+ const classW = resolveStorageBoxW(preset.classWidth, "classDiagram", "classWidth");
972
+ const tone: Tone = preset.defaultTone ?? "info";
973
+ const b = diagram(preset.id, { topic: preset.topic });
974
+
975
+ const classes: string[] = [];
976
+ const phaseActivate: string[] = [];
977
+ let relIdx = 0;
978
+
979
+ const api: ClassDiagramBuilder = {
980
+ class(c) {
981
+ const laneId = `lane-${c.id}`;
982
+ // UML クラスは attributes と methods を区切って表示。
983
+ // v10.5 = 旧 separator "─".repeat(20) は 20 chars × 14px = 280 world で node w 強制拡張、
984
+ // class-demo の viewBox scale 5.2 倍化で label 過小化の root cause。 separator を短い
985
+ // "───" (3 chars) に短縮、 視覚区切りは維持しつつ node w 拡張を抑制する。
986
+ const rows: string[] = [];
987
+ if (c.attributes && c.attributes.length > 0) {
988
+ rows.push(...c.attributes);
989
+ }
990
+ if (c.methods && c.methods.length > 0) {
991
+ if (rows.length > 0) rows.push("───");
992
+ rows.push(...c.methods);
993
+ }
994
+ // 帯はクラスごとに決める。 行の長さで箱が広がるクラスがあるため、 全クラスを同じ帯幅で
995
+ // 宣言すると宣言座標と実座標がずれる。
996
+ const boxW = fitRowsWidth(classW, rows);
997
+ b.lane(laneId, { width: boxLaneW(boxW) });
998
+ b.node(c.id, {
999
+ lane: laneId,
1000
+ stack: 0,
1001
+ kind: "storage",
1002
+ w: boxW,
1003
+ title: c.title,
1004
+ eyebrow: c.stereotype ?? "クラス",
1005
+ ...(rows.length > 0 ? { rows } : {}),
1006
+ });
1007
+ classes.push(c.id);
1008
+ phaseActivate.push(c.id);
1009
+ return api;
1010
+ },
1011
+ relation(r) {
1012
+ const currentIdx = relIdx++;
1013
+ const edgeId = `cr-${currentIdx}-${r.from}-${r.to}`;
1014
+ const label = r.label ?? r.type;
1015
+ const sub = r.cardinality;
1016
+ b.edge(r.from, r.to, {
1017
+ id: edgeId,
1018
+ label,
1019
+ ...(sub ? { sub } : {}),
1020
+ tone: r.tone ?? tone,
1021
+ style: "solid",
1022
+ // CAR-531 SSOT ... 旧実装は `labelOffsetY: currentIdx % 2 === 0 ? -60 : 60` で
1023
+ // 複数 relation label の path 沿い集中を強制分散していた。 CAR-430 で導入した
1024
+ // engine 側 fan offset (FAN_STEP 40 world) が同 from / to 群を自動分散する現在、
1025
+ // author 指定 ±60 は engine 分散と二重発火して extends label が SSOT
1026
+ // LABEL_INIT_CLEARANCE (34、 CAR-630 で 26 → 34 復帰) を大幅に超える 114 world 離れる bug の直接原因になっていた。
1027
+ // author 指定 offset を削除、 engine 側の LABEL_INIT_CLEARANCE + fan offset に一任する。
1028
+ // 個別 relation で offset を再導入したい author は future extension (UmlRelation に
1029
+ // labelOffsetY? を追加) で明示的に指定する経路を残す。
1030
+ });
1031
+ phaseActivate.push(edgeId);
1032
+ return api;
1033
+ },
1034
+ build() {
1035
+ b.phase(
1036
+ "class",
1037
+ { duration: 2400, title: preset.topic, body: "UML class 全 class + relation を visible 化。" },
1038
+ (p) => p.activate(...phaseActivate).badge("class"),
1039
+ );
1040
+ return b.build();
1041
+ },
1042
+ };
1043
+
1044
+ return api;
1045
+ }
1046
+
1047
+ // ─── tree (parent-child + hierarchical) ───────────────────────
1048
+
1049
+ export type TreeNode = {
1050
+ id: string;
1051
+ title: string;
1052
+ parent?: string;
1053
+ /** 表示 kind (default "card") */
1054
+ kind?: NodeKind;
1055
+ subtitle?: string;
1056
+ eyebrow?: string;
1057
+ };
1058
+
1059
+ export type TreePreset = {
1060
+ id: string;
1061
+ topic: string;
1062
+ defaultTone?: Tone;
1063
+ /** depth ごとの lane width (default 320) */
1064
+ nodeWidth?: number;
1065
+ };
1066
+
1067
+ export type TreeBuilder = {
1068
+ node: (n: TreeNode) => TreeBuilder;
1069
+ build: () => ReturnType<DiagramBuilder["build"]>;
1070
+ };
1071
+
1072
+ /**
1073
+ * tree preset ... parent-child の階層構造を縦列の lane stack で表現。
1074
+ * 各 node は parent を指定、 親が同 lane stack の上に配置される lane (depth 別) を auto 計算。
1075
+ * 組織図 / file tree / class 階層 / 製品カテゴリ等の階層図に使う。
1076
+ *
1077
+ * @example
1078
+ * tree({ id: "org", topic: "組織図" })
1079
+ * .node({ id: "ceo", title: "CEO" })
1080
+ * .node({ id: "cto", title: "CTO", parent: "ceo" })
1081
+ * .node({ id: "cfo", title: "CFO", parent: "ceo" })
1082
+ * .node({ id: "eng-mgr", title: "Eng Manager", parent: "cto" })
1083
+ * .build();
1084
+ */
1085
+ export function tree(preset: TreePreset): TreeBuilder {
1086
+ const canvasW = preset.nodeWidth ? preset.nodeWidth * 2 : 720;
1087
+ const canvasH = 480;
1088
+ const b = diagram(preset.id, { topic: preset.topic });
1089
+ const treeNodes: TreeNode[] = [];
1090
+
1091
+ const api: TreeBuilder = {
1092
+ node(n) {
1093
+ treeNodes.push(n);
1094
+ return api;
1095
+ },
1096
+ build() {
1097
+ b.lane("tree", { width: canvasW });
1098
+ const nodeId = `${preset.id}-tree`;
1099
+ b.node(nodeId, {
1100
+ lane: "tree",
1101
+ stack: 0,
1102
+ kind: "tree-hierarchy",
1103
+ title: preset.topic,
1104
+ eyebrow: "tree",
1105
+ w: canvasW,
1106
+ h: canvasH,
1107
+ treeData: treeNodes.map((n) => ({
1108
+ id: n.id,
1109
+ title: n.title,
1110
+ parent: n.parent,
1111
+ subtitle: n.subtitle,
1112
+ eyebrow: n.eyebrow,
1113
+ })),
1114
+ });
1115
+ b.phase(
1116
+ "tree",
1117
+ { duration: 2400, title: preset.topic, body: "tree の全 node + parent-child edge を hierarchical layout で描画。" },
1118
+ (p) => p.activate(nodeId).badge("tree"),
1119
+ );
1120
+ return b.build();
1121
+ },
1122
+ };
1123
+
1124
+ return api;
1125
+ }
1126
+
1127
+ // ─── userJourney (step + emotion + touchpoint) ────────────────
1128
+
1129
+ export type JourneyEmotion = "delighted" | "happy" | "neutral" | "frustrated" | "angry";
1130
+
1131
+ export type JourneyStep = {
1132
+ id: string;
1133
+ title: string;
1134
+ emotion: JourneyEmotion;
1135
+ /** どの touchpoint (例 "Website", "Email", "Support") */
1136
+ touchpoint?: string;
1137
+ /** opportunity (改善余地のメモ) */
1138
+ opportunity?: string;
1139
+ };
1140
+
1141
+ export type UserJourneyPreset = {
1142
+ id: string;
1143
+ topic: string;
1144
+ defaultTone?: Tone;
1145
+ stepWidth?: number;
1146
+ };
1147
+
1148
+ export type UserJourneyBuilder = {
1149
+ step: (s: JourneyStep) => UserJourneyBuilder;
1150
+ build: () => ReturnType<DiagramBuilder["build"]>;
1151
+ };
1152
+
1153
+ /**
1154
+ * userJourney preset ... User research / UX research の journey map。
1155
+ * step を時系列に横並びで並べ、 emotion を tone で色分け、 touchpoint / opportunity を subtitle で示す。
1156
+ *
1157
+ * @example
1158
+ * userJourney({ id: "signup", topic: "Signup journey" })
1159
+ * .step({ id: "land", title: "Land on /", emotion: "neutral", touchpoint: "Website" })
1160
+ * .step({ id: "form", title: "Fill form", emotion: "frustrated", touchpoint: "Form" })
1161
+ * .step({ id: "verify", title: "Email verify", emotion: "happy", touchpoint: "Email" })
1162
+ * .step({ id: "done", title: "Done", emotion: "delighted", touchpoint: "Dashboard" })
1163
+ * .build();
1164
+ */
1165
+ export function userJourney(preset: UserJourneyPreset): UserJourneyBuilder {
1166
+ const canvasW = preset.stepWidth ? preset.stepWidth * 2 : 720;
1167
+ const canvasH = 480;
1168
+ const b = diagram(preset.id, { topic: preset.topic });
1169
+ const steps: JourneyStep[] = [];
1170
+
1171
+ const api: UserJourneyBuilder = {
1172
+ step(s) {
1173
+ steps.push(s);
1174
+ return api;
1175
+ },
1176
+ build() {
1177
+ b.lane("journey", { width: canvasW });
1178
+ const nodeId = `${preset.id}-journey`;
1179
+ b.node(nodeId, {
1180
+ lane: "journey",
1181
+ stack: 0,
1182
+ kind: "journey-map",
1183
+ title: preset.topic,
1184
+ eyebrow: "userJourney",
1185
+ w: canvasW,
1186
+ h: canvasH,
1187
+ journeyData: steps.map((s) => ({
1188
+ id: s.id,
1189
+ title: s.title,
1190
+ emotion: s.emotion,
1191
+ touchpoint: s.touchpoint,
1192
+ opportunity: s.opportunity,
1193
+ })),
1194
+ });
1195
+ b.phase(
1196
+ "journey",
1197
+ { duration: 2400, title: preset.topic, body: "userJourney 全 step を感情曲線 + touchpoint で描画。" },
1198
+ (p) => p.activate(nodeId).badge("journey"),
1199
+ );
1200
+ return b.build();
1201
+ },
1202
+ };
1203
+
1204
+ return api;
1205
+ }
1206
+
1207
+ // ─── mindMap (中心 topic + 放射 branch) ────────────────────────
1208
+
1209
+ export type MindBranch = {
1210
+ id: string;
1211
+ title: string;
1212
+ /** 親 branch (root の子は parent=root id) */
1213
+ parent: string;
1214
+ /** 色 tone (default は parent の tone を継承) */
1215
+ tone?: Tone;
1216
+ subtitle?: string;
1217
+ };
1218
+
1219
+ export type MindMapPreset = {
1220
+ id: string;
1221
+ topic: string;
1222
+ /** 中心 topic ID + label */
1223
+ rootId: string;
1224
+ rootTitle: string;
1225
+ branchWidth?: number;
1226
+ defaultTone?: Tone;
1227
+ };
1228
+
1229
+ export type MindMapBuilder = {
1230
+ branch: (b: MindBranch) => MindMapBuilder;
1231
+ build: () => ReturnType<DiagramBuilder["build"]>;
1232
+ };
1233
+
1234
+
1235
+ /**
1236
+ * mindMap preset ... 中心 root topic から放射状に branch を伸ばす mind map。
1237
+ * tree との違いは「root は 1 つ固定 + 第 1 階層の branch ごとに tone を auto rotate」。
1238
+ *
1239
+ * @example
1240
+ * mindMap({ id: "ideas", topic: "Project ideas", rootId: "root", rootTitle: "Project" })
1241
+ * .branch({ id: "feat", title: "Features", parent: "root" })
1242
+ * .branch({ id: "ui", title: "UI design", parent: "root" })
1243
+ * .branch({ id: "auth", title: "Auth", parent: "feat" })
1244
+ * .build();
1245
+ */
1246
+ export function mindMap(preset: MindMapPreset): MindMapBuilder {
1247
+ const canvasW = preset.branchWidth ? preset.branchWidth * 2 : 720;
1248
+ const canvasH = 480;
1249
+ const b = diagram(preset.id, { topic: preset.topic });
1250
+ const branches: MindBranch[] = [];
1251
+
1252
+ const api: MindMapBuilder = {
1253
+ branch(br) {
1254
+ branches.push(br);
1255
+ return api;
1256
+ },
1257
+ build() {
1258
+ b.lane("mind", { width: canvasW });
1259
+ const nodeId = `${preset.id}-mind`;
1260
+ b.node(nodeId, {
1261
+ lane: "mind",
1262
+ stack: 0,
1263
+ kind: "mind-map",
1264
+ title: preset.topic,
1265
+ eyebrow: "mindMap",
1266
+ w: canvasW,
1267
+ h: canvasH,
1268
+ mindData: {
1269
+ rootId: preset.rootId,
1270
+ rootTitle: preset.rootTitle,
1271
+ branches: branches.map((br) => ({
1272
+ id: br.id,
1273
+ title: br.title,
1274
+ parent: br.parent,
1275
+ tone: br.tone,
1276
+ subtitle: br.subtitle,
1277
+ })),
1278
+ },
1279
+ });
1280
+ b.phase(
1281
+ "mindmap",
1282
+ { duration: 2400, title: preset.topic, body: "mindMap 全 branch を visible 化、 中心 + 放射で描画。" },
1283
+ (p) => p.activate(nodeId).badge("mindmap"),
1284
+ );
1285
+ return b.build();
1286
+ },
1287
+ };
1288
+
1289
+ return api;
1290
+ }
1291
+
1292
+ // ─── funnel (Awareness → Conversion 階層) ──────────────────────
1293
+
1294
+ export type FunnelStage = {
1295
+ id: string;
1296
+ title: string;
1297
+ /** この stage の人数 / 件数 */
1298
+ count: number;
1299
+ /** stage 詳細 */
1300
+ subtitle?: string;
1301
+ };
1302
+
1303
+ export type FunnelPreset = {
1304
+ id: string;
1305
+ topic: string;
1306
+ defaultTone?: Tone;
1307
+ stageWidth?: number;
1308
+ };
1309
+
1310
+ export type FunnelBuilder = {
1311
+ stage: (s: FunnelStage) => FunnelBuilder;
1312
+ build: () => ReturnType<DiagramBuilder["build"]>;
1313
+ };
1314
+
1315
+ /**
1316
+ * funnel preset ... Sales / marketing funnel。 Awareness → Conversion の階層を縦に並べ、
1317
+ * 各 stage の count と drop rate (前 stage 比) を可視化。
1318
+ *
1319
+ * @example
1320
+ * funnel({ id: "sales", topic: "Sales funnel" })
1321
+ * .stage({ id: "visit", title: "Visit", count: 10000 })
1322
+ * .stage({ id: "signup", title: "Sign up", count: 1500 })
1323
+ * .stage({ id: "trial", title: "Trial", count: 800 })
1324
+ * .stage({ id: "paid", title: "Paid", count: 200 })
1325
+ * .build();
1326
+ */
1327
+ export function funnel(preset: FunnelPreset): FunnelBuilder {
1328
+ const canvasW = toGridSize(preset.stageWidth ?? 560);
1329
+ const canvasH = 480;
1330
+ const b = diagram(preset.id, { topic: preset.topic });
1331
+ const stages: FunnelStage[] = [];
1332
+
1333
+ const api: FunnelBuilder = {
1334
+ stage(s) {
1335
+ stages.push(s);
1336
+ return api;
1337
+ },
1338
+ build() {
1339
+ b.lane("funnel", { width: gridAlignedLaneW(canvasW) });
1340
+ const nodeId = `${preset.id}-funnel`;
1341
+ b.node(nodeId, {
1342
+ lane: "funnel",
1343
+ stack: 0,
1344
+ kind: "funnel-stages",
1345
+ title: preset.topic,
1346
+ eyebrow: "funnel",
1347
+ w: canvasW,
1348
+ h: canvasH,
1349
+ funnelData: stages.map((s) => ({
1350
+ id: s.id,
1351
+ title: s.title,
1352
+ count: s.count,
1353
+ subtitle: s.subtitle,
1354
+ })),
1355
+ });
1356
+ b.phase(
1357
+ "funnel",
1358
+ { duration: 2400, title: preset.topic, body: "funnel 全 stage を逆三角形 + count で描画。" },
1359
+ (p) => p.activate(nodeId).badge("funnel"),
1360
+ );
1361
+ return b.build();
1362
+ },
1363
+ };
1364
+
1365
+ return api;
1366
+ }
1367
+
1368
+ // ─── quadrant (2 軸 マトリクス) ────────────────────────────────
1369
+
1370
+ export type QuadrantQuadrantLabel = "topLeft" | "topRight" | "bottomLeft" | "bottomRight";
1371
+
1372
+ export type QuadrantItem = {
1373
+ id: string;
1374
+ title: string;
1375
+ /** どの象限 (4 象限) */
1376
+ quadrant: QuadrantQuadrantLabel;
1377
+ subtitle?: string;
1378
+ };
1379
+
1380
+ export type QuadrantPreset = {
1381
+ id: string;
1382
+ topic: string;
1383
+ /** x 軸 label (left / right) */
1384
+ xAxis: { left: string; right: string };
1385
+ /** y 軸 label (bottom / top) */
1386
+ yAxis: { bottom: string; top: string };
1387
+ /** 各象限の label */
1388
+ quadrantLabels?: Record<QuadrantQuadrantLabel, string>;
1389
+ defaultTone?: Tone;
1390
+ };
1391
+
1392
+ export type QuadrantBuilder = {
1393
+ item: (i: QuadrantItem) => QuadrantBuilder;
1394
+ build: () => ReturnType<DiagramBuilder["build"]>;
1395
+ };
1396
+
1397
+ /**
1398
+ * quadrant preset ... 2 軸 マトリクス (Priority matrix / SWOT / Eisenhower 等)。
1399
+ * 4 象限 (topLeft / topRight / bottomLeft / bottomRight) に item を配置。
1400
+ * lane = 2 col (left / right)、 stack = 2 row (top / bottom)。
1401
+ *
1402
+ * @example
1403
+ * quadrant({ id: "priority", topic: "Priority matrix",
1404
+ * xAxis: { left: "Low effort", right: "High effort" },
1405
+ * yAxis: { bottom: "Low value", top: "High value" } })
1406
+ * .item({ id: "a", title: "Quick win", quadrant: "topLeft" })
1407
+ * .item({ id: "b", title: "Major project", quadrant: "topRight" })
1408
+ * .item({ id: "c", title: "Fill in", quadrant: "bottomLeft" })
1409
+ * .item({ id: "d", title: "Thankless", quadrant: "bottomRight" })
1410
+ * .build();
1411
+ */
1412
+ export function quadrant(preset: QuadrantPreset): QuadrantBuilder {
1413
+ const canvasW = 640;
1414
+ const canvasH = 480;
1415
+ const b = diagram(preset.id, { topic: preset.topic });
1416
+ const items: QuadrantItem[] = [];
1417
+
1418
+ const api: QuadrantBuilder = {
1419
+ item(it) {
1420
+ items.push(it);
1421
+ return api;
1422
+ },
1423
+ build() {
1424
+ const labels = preset.quadrantLabels ?? {
1425
+ topLeft: `${preset.yAxis.top} × ${preset.xAxis.left}`,
1426
+ topRight: `${preset.yAxis.top} × ${preset.xAxis.right}`,
1427
+ bottomLeft: `${preset.yAxis.bottom} × ${preset.xAxis.left}`,
1428
+ bottomRight: `${preset.yAxis.bottom} × ${preset.xAxis.right}`,
1429
+ };
1430
+ b.lane("quadrant", { width: gridAlignedLaneW(canvasW) });
1431
+ const nodeId = `${preset.id}-quadrant`;
1432
+ b.node(nodeId, {
1433
+ lane: "quadrant",
1434
+ stack: 0,
1435
+ kind: "quadrant-matrix",
1436
+ title: preset.topic,
1437
+ eyebrow: "quadrant",
1438
+ w: canvasW,
1439
+ h: canvasH,
1440
+ quadrantData: {
1441
+ xAxis: preset.xAxis,
1442
+ yAxis: preset.yAxis,
1443
+ quadrantLabels: labels,
1444
+ items: items.map((it) => ({
1445
+ id: it.id,
1446
+ title: it.title,
1447
+ quadrant: it.quadrant,
1448
+ subtitle: it.subtitle,
1449
+ })),
1450
+ },
1451
+ });
1452
+ b.phase(
1453
+ "quadrant",
1454
+ { duration: 2400, title: preset.topic, body: "quadrant 十字軸 + 4 象限 + item を描画。" },
1455
+ (p) => p.activate(nodeId).badge("quadrant"),
1456
+ );
1457
+ return b.build();
1458
+ },
1459
+ };
1460
+
1461
+ return api;
1462
+ }
1463
+
1464
+ // ─── chart (Pie / Bar / Line 統合) ─────────────────────────────
1465
+
1466
+ export type ChartType = "pie" | "bar" | "line";
1467
+
1468
+ export type ChartDatum = {
1469
+ id: string;
1470
+ label: string;
1471
+ value: number;
1472
+ /** 個別 tone (default は preset の defaultTone) */
1473
+ tone?: Tone;
1474
+ };
1475
+
1476
+ export type ChartPreset = {
1477
+ id: string;
1478
+ topic: string;
1479
+ type: ChartType;
1480
+ defaultTone?: Tone;
1481
+ itemWidth?: number;
1482
+ };
1483
+
1484
+ export type ChartBuilder = {
1485
+ datum: (d: ChartDatum) => ChartBuilder;
1486
+ build: () => ReturnType<DiagramBuilder["build"]>;
1487
+ };
1488
+
1489
+ /**
1490
+ * chart preset ... 統計チャート (Pie / Bar / Line)、 CAR-994 Phase A で SVG geometry render 導入。
1491
+ *
1492
+ * datum 配列を 1 大 node (kind=chart-line / chart-pie / chart-bar) の chartData payload に
1493
+ * 格納し、 kinds/chart-*.tsx が自己完結で折れ線 / 円 / 棒グラフを SVG 描画する。
1494
+ * layout は 1 lane + 1 node (chart canvas サイズ) で完結、 lane / edge を datum 数だけ増やさない。
1495
+ *
1496
+ * @example
1497
+ * chart({ id: "share", topic: "Market share", type: "pie" })
1498
+ * .datum({ id: "a", label: "A", value: 45 })
1499
+ * .datum({ id: "b", label: "B", value: 30 })
1500
+ * .datum({ id: "c", label: "C", value: 25 })
1501
+ * .build();
1502
+ */
1503
+ export function chart(preset: ChartPreset): ChartBuilder {
1504
+ const canvasW = toGridSize(preset.itemWidth ?? 640);
1505
+ // 高さも格子の倍数にする。 line / bar は 360 だったが 360 % 16 = 8 で下端が格子から外れ、
1506
+ // 箱の 4 辺が載る条件を満たさなかった。
1507
+ const canvasH = toGridSize(preset.type === "pie" ? 320 : 360);
1508
+ const b = diagram(preset.id, { topic: preset.topic });
1509
+ const data: ChartDatum[] = [];
1510
+
1511
+ const kindMap = {
1512
+ line: "chart-line" as const,
1513
+ pie: "chart-pie" as const,
1514
+ bar: "chart-bar" as const,
1515
+ };
1516
+
1517
+ const api: ChartBuilder = {
1518
+ datum(d) {
1519
+ data.push(d);
1520
+ return api;
1521
+ },
1522
+ build() {
1523
+ b.lane("chart", { width: gridAlignedLaneW(canvasW) });
1524
+ const nodeId = `${preset.id}-chart`;
1525
+ b.node(nodeId, {
1526
+ lane: "chart",
1527
+ stack: 0,
1528
+ kind: kindMap[preset.type],
1529
+ title: preset.topic,
1530
+ eyebrow: preset.type,
1531
+ w: canvasW,
1532
+ h: canvasH,
1533
+ chartData: data.map((d) => ({ label: d.label, value: d.value, tone: d.tone })),
1534
+ });
1535
+ b.phase(
1536
+ "chart",
1537
+ { duration: 2400, title: preset.topic, body: `${preset.type} chart の全 datum を visible 化。` },
1538
+ (p) => p.activate(nodeId).badge(preset.type),
1539
+ );
1540
+ return b.build();
1541
+ },
1542
+ };
1543
+
1544
+ return api;
1545
+ }
1546
+
1547
+ // ─── gantt (sprint planning / release timeline) ───────────────
1548
+
1549
+ export type GanttTask = {
1550
+ id: string;
1551
+ title: string;
1552
+ /** 開始 (例 "2026-Q1" / "Day 1") */
1553
+ start: string;
1554
+ /** 終了 (例 "2026-Q2") */
1555
+ end: string;
1556
+ /** owner / assignee */
1557
+ owner?: string;
1558
+ /** 依存 task id */
1559
+ dependsOn?: string;
1560
+ };
1561
+
1562
+ export type GanttPreset = {
1563
+ id: string;
1564
+ topic: string;
1565
+ defaultTone?: Tone;
1566
+ laneWidth?: number;
1567
+ };
1568
+
1569
+ export type GanttBuilder = {
1570
+ task: (t: GanttTask) => GanttBuilder;
1571
+ build: () => ReturnType<DiagramBuilder["build"]>;
1572
+ };
1573
+
1574
+ /**
1575
+ * gantt preset ... Gantt chart / sprint planning / release timeline。
1576
+ * task ごとに 1 lane を作り、 start - end + owner を subtitle、 dependsOn で前 task → 次 task の edge を auto 生成。
1577
+ *
1578
+ * @example
1579
+ * gantt({ id: "release", topic: "Release timeline" })
1580
+ * .task({ id: "design", title: "Design", start: "Q1", end: "Q1" })
1581
+ * .task({ id: "build", title: "Build", start: "Q2", end: "Q2", dependsOn: "design" })
1582
+ * .task({ id: "test", title: "Test", start: "Q3", end: "Q3", dependsOn: "build" })
1583
+ * .build();
1584
+ */
1585
+ export function gantt(preset: GanttPreset): GanttBuilder {
1586
+ const canvasW = preset.laneWidth ? preset.laneWidth * 2 : 720;
1587
+ const canvasH = 360;
1588
+ const tone: Tone = preset.defaultTone ?? "teal";
1589
+ const b = diagram(preset.id, { topic: preset.topic });
1590
+ const tasks: GanttTask[] = [];
1591
+
1592
+ const api: GanttBuilder = {
1593
+ task(t) {
1594
+ tasks.push(t);
1595
+ return api;
1596
+ },
1597
+ build() {
1598
+ // start / end label を index に変換 (unique label を順に並べる)
1599
+ const labelSeq: string[] = [];
1600
+ for (const t of tasks) {
1601
+ if (!labelSeq.includes(t.start)) labelSeq.push(t.start);
1602
+ if (!labelSeq.includes(t.end)) labelSeq.push(t.end);
1603
+ }
1604
+ b.lane("gantt", { width: canvasW });
1605
+ const nodeId = `${preset.id}-gantt`;
1606
+ b.node(nodeId, {
1607
+ lane: "gantt",
1608
+ stack: 0,
1609
+ kind: "gantt-timeline",
1610
+ title: preset.topic,
1611
+ eyebrow: "gantt",
1612
+ w: canvasW,
1613
+ h: canvasH,
1614
+ ganttData: tasks.map((t) => ({
1615
+ id: t.id,
1616
+ title: t.title,
1617
+ startIdx: labelSeq.indexOf(t.start),
1618
+ endIdx: labelSeq.indexOf(t.end),
1619
+ startLabel: t.start,
1620
+ endLabel: t.end,
1621
+ owner: t.owner,
1622
+ dependsOn: t.dependsOn,
1623
+ tone,
1624
+ })),
1625
+ });
1626
+ b.phase(
1627
+ "gantt",
1628
+ { duration: 2400, title: preset.topic, body: "gantt 全 task を visible 化、 帯状 timeline + 依存 arrow で描画。" },
1629
+ (p) => p.activate(nodeId).badge("gantt"),
1630
+ );
1631
+ return b.build();
1632
+ },
1633
+ };
1634
+
1635
+ return api;
1636
+ }
1637
+
1638
+ // ─── flowchart (swimlane + decision + loop) ────────────────────
1639
+
1640
+ export type FlowchartNodeShape = "process" | "decision" | "start" | "end" | "loop";
1641
+
1642
+ export type FlowchartNode = {
1643
+ id: string;
1644
+ title: string;
1645
+ shape: FlowchartNodeShape;
1646
+ /** swimlane (role) */
1647
+ lane: string;
1648
+ };
1649
+
1650
+ export type FlowchartEdge = {
1651
+ from: string;
1652
+ to: string;
1653
+ /** decision shape の場合 "true" / "false" / null */
1654
+ label?: string;
1655
+ tone?: Tone;
1656
+ };
1657
+
1658
+ export type FlowchartPreset = {
1659
+ id: string;
1660
+ topic: string;
1661
+ /** swimlane の lane (role) ラベル */
1662
+ lanes: string[];
1663
+ defaultTone?: Tone;
1664
+ laneWidth?: number;
1665
+ };
1666
+
1667
+ export type FlowchartBuilder = {
1668
+ node: (n: FlowchartNode) => FlowchartBuilder;
1669
+ edge: (e: FlowchartEdge) => FlowchartBuilder;
1670
+ build: () => ReturnType<DiagramBuilder["build"]>;
1671
+ };
1672
+
1673
+ const SHAPE_KIND: Record<FlowchartNodeShape, NodeKind> = {
1674
+ process: "function",
1675
+ decision: "card",
1676
+ start: "event",
1677
+ end: "event",
1678
+ loop: "card",
1679
+ };
1680
+
1681
+ const SHAPE_EYEBROW: Record<FlowchartNodeShape, string> = {
1682
+ process: "process",
1683
+ decision: "decision",
1684
+ start: "start",
1685
+ end: "end",
1686
+ loop: "loop",
1687
+ };
1688
+
1689
+ /**
1690
+ * flowchart preset ... swimlane + decision diamond + loop 枠を扱うビジネス process diagram。
1691
+ * mermaid flowchart より「lane 別の役割分担」 と「decision / loop」 を明示できる。
1692
+ *
1693
+ * @example
1694
+ * flowchart({ id: "approve", topic: "Approval flow", lanes: ["User", "Manager"] })
1695
+ * .node({ id: "submit", title: "Submit", shape: "start", lane: "User" })
1696
+ * .node({ id: "review", title: "Review", shape: "decision", lane: "Manager" })
1697
+ * .node({ id: "approve", title: "Approve", shape: "end", lane: "Manager" })
1698
+ * .node({ id: "revise", title: "Revise", shape: "process", lane: "User" })
1699
+ * .edge({ from: "submit", to: "review" })
1700
+ * .edge({ from: "review", to: "approve", label: "true" })
1701
+ * .edge({ from: "review", to: "revise", label: "false" })
1702
+ * .build();
1703
+ */
1704
+ export function flowchart(preset: FlowchartPreset): FlowchartBuilder {
1705
+ const laneW = preset.laneWidth ?? 380;
1706
+ const tone: Tone = preset.defaultTone ?? "accent";
1707
+ const b = diagram(preset.id, { topic: preset.topic });
1708
+
1709
+ const laneToId = new Map<string, string>();
1710
+ const laneStackCount = new Map<string, number>();
1711
+ for (const label of preset.lanes) {
1712
+ const id = slugify(label) || `lane-${laneToId.size}`;
1713
+ laneToId.set(label, id);
1714
+ laneStackCount.set(id, 0);
1715
+ b.lane(id, { width: laneW, label });
1716
+ }
1717
+ const phaseActivate: string[] = [];
1718
+ let edgeIdx = 0;
1719
+
1720
+ const api: FlowchartBuilder = {
1721
+ node(n) {
1722
+ const laneId = laneToId.get(n.lane) ?? n.lane;
1723
+ const stack = laneStackCount.get(laneId) ?? 0;
1724
+ b.node(n.id, {
1725
+ lane: laneId,
1726
+ stack,
1727
+ kind: SHAPE_KIND[n.shape],
1728
+ title: n.title,
1729
+ eyebrow: SHAPE_EYEBROW[n.shape],
1730
+ });
1731
+ laneStackCount.set(laneId, stack + 1);
1732
+ phaseActivate.push(n.id);
1733
+ return api;
1734
+ },
1735
+ edge(e) {
1736
+ const edgeId = `fc-${edgeIdx++}-${e.from}-${e.to}`;
1737
+ // CAR-549 SSOT ... flowchart の decision condition label (true / false / yes / no) は
1738
+ // edge の semantic (どちらの分岐か) を強調するため path 上に重ねて表示する。
1739
+ // 他 label (通常の process 遷移名) は default (34 world 離す) で読みやすさ優先。
1740
+ const label = e.label ?? "";
1741
+ const isConditionLabel = /^(true|false|yes|no)$/i.test(label.trim());
1742
+ b.edge(e.from, e.to, {
1743
+ id: edgeId,
1744
+ label,
1745
+ tone: e.tone ?? tone,
1746
+ style: "solid",
1747
+ overlay: isConditionLabel,
1748
+ });
1749
+ phaseActivate.push(edgeId);
1750
+ return api;
1751
+ },
1752
+ build() {
1753
+ b.phase(
1754
+ "flowchart",
1755
+ { duration: 2400, title: preset.topic, body: "flowchart 全 node + edge を visible 化、 swimlane + decision を表現。" },
1756
+ (p) => p.activate(...phaseActivate).badge("flowchart"),
1757
+ );
1758
+ return b.build();
1759
+ },
1760
+ };
1761
+
1762
+ return api;
1763
+ }
1764
+
1765
+ // ─── network (router / switch / firewall) ──────────────────────
1766
+
1767
+ export type NetworkDeviceKind = "router" | "switch" | "firewall" | "server" | "client";
1768
+
1769
+ export type NetworkDevice = {
1770
+ id: string;
1771
+ title: string;
1772
+ kind: NetworkDeviceKind;
1773
+ segment?: string;
1774
+ /** 表示 col + row */
1775
+ col: number;
1776
+ row: number;
1777
+ };
1778
+
1779
+ export type NetworkLink = {
1780
+ from: string;
1781
+ to: string;
1782
+ /** プロトコル / VLAN 等 */
1783
+ protocol?: string;
1784
+ tone?: Tone;
1785
+ };
1786
+
1787
+ export type NetworkPreset = {
1788
+ id: string;
1789
+ topic: string;
1790
+ defaultTone?: Tone;
1791
+ laneWidth?: number;
1792
+ };
1793
+
1794
+ export type NetworkBuilder = {
1795
+ device: (d: NetworkDevice) => NetworkBuilder;
1796
+ link: (l: NetworkLink) => NetworkBuilder;
1797
+ build: () => ReturnType<DiagramBuilder["build"]>;
1798
+ };
1799
+
1800
+ const NET_KIND_MAP: Record<NetworkDeviceKind, NodeKind> = {
1801
+ router: "service",
1802
+ switch: "service",
1803
+ firewall: "service",
1804
+ server: "backend",
1805
+ client: "actor",
1806
+ };
1807
+
1808
+ /**
1809
+ * network preset ... NW topology (router / switch / firewall / server / client) を col × row 配置、
1810
+ * link で接続 (protocol / VLAN を label)。 On-prem / hybrid NW 構成図向け。
1811
+ *
1812
+ * @example
1813
+ * network({ id: "office-nw", topic: "Office NW" })
1814
+ * .device({ id: "fw", title: "Firewall", kind: "firewall", col: 0, row: 0 })
1815
+ * .device({ id: "sw1", title: "Switch A", kind: "switch", col: 1, row: 0 })
1816
+ * .device({ id: "srv", title: "Server", kind: "server", col: 2, row: 0 })
1817
+ * .link({ from: "fw", to: "sw1", protocol: "VLAN 10" })
1818
+ * .link({ from: "sw1", to: "srv", protocol: "TCP 22" })
1819
+ * .build();
1820
+ */
1821
+ export function network(preset: NetworkPreset): NetworkBuilder {
1822
+ const laneW = preset.laneWidth ?? 320;
1823
+ const tone: Tone = preset.defaultTone ?? "info";
1824
+ const b = diagram(preset.id, { topic: preset.topic });
1825
+
1826
+ const colLaneIds = new Map<number, string>();
1827
+ const phaseActivate: string[] = [];
1828
+ let edgeIdx = 0;
1829
+
1830
+ const api: NetworkBuilder = {
1831
+ device(d) {
1832
+ let laneId = colLaneIds.get(d.col);
1833
+ if (!laneId) {
1834
+ laneId = `n-col-${d.col}`;
1835
+ b.lane(laneId, { width: laneW });
1836
+ colLaneIds.set(d.col, laneId);
1837
+ }
1838
+ const eyebrowParts: string[] = [d.kind];
1839
+ if (d.segment) eyebrowParts.push(d.segment);
1840
+ b.node(d.id, {
1841
+ lane: laneId,
1842
+ stack: d.row,
1843
+ kind: NET_KIND_MAP[d.kind],
1844
+ title: d.title,
1845
+ eyebrow: eyebrowParts.join(" / "),
1846
+ });
1847
+ phaseActivate.push(d.id);
1848
+ return api;
1849
+ },
1850
+ link(l) {
1851
+ const edgeId = `nl-${edgeIdx++}-${l.from}-${l.to}`;
1852
+ b.edge(l.from, l.to, {
1853
+ id: edgeId,
1854
+ label: l.protocol ?? "",
1855
+ tone: l.tone ?? tone,
1856
+ style: "solid",
1857
+ });
1858
+ phaseActivate.push(edgeId);
1859
+ return api;
1860
+ },
1861
+ build() {
1862
+ b.phase(
1863
+ "network",
1864
+ { duration: 2400, title: preset.topic, body: "network 全 device + link を visible 化。" },
1865
+ (p) => p.activate(...phaseActivate).badge("network"),
1866
+ );
1867
+ return b.build();
1868
+ },
1869
+ };
1870
+
1871
+ return api;
1872
+ }
1873
+
1874
+ // ─── stateMachine2 (nested state + guard / action) ─────────────
1875
+
1876
+ export type FsmStateNested = {
1877
+ id: string;
1878
+ title: string;
1879
+ initial?: boolean;
1880
+ final?: boolean;
1881
+ /** 親 state (nested state、 例 "loading" の親 "active") */
1882
+ parent?: string;
1883
+ /** entry action (例 "onEnter: startTimer") */
1884
+ entry?: string;
1885
+ /** exit action */
1886
+ exit?: string;
1887
+ };
1888
+
1889
+ export type FsmTransitionExt = {
1890
+ from: string;
1891
+ to: string;
1892
+ trigger: string;
1893
+ guard?: string;
1894
+ /** action (例 "onTransition: incrementCounter") */
1895
+ action?: string;
1896
+ tone?: Tone;
1897
+ };
1898
+
1899
+ export type StateMachine2Preset = {
1900
+ id: string;
1901
+ topic: string;
1902
+ defaultTone?: Tone;
1903
+ /**
1904
+ * 状態 1 個の箱の幅 (world unit、 既定 320 = card の既定幅)。
1905
+ *
1906
+ * 帯の幅ではなく **箱の幅** を指す。 帯は engine が「箱の幅 + 余白 × 2」 まで広げるので、
1907
+ * 帯だけを狭く指定しても図は縮まない (#357 以前はここが帯にしか渡っておらず、
1908
+ * 370 未満の指定が全て無視されていた)。
1909
+ *
1910
+ * 80 未満と有限でない値は例外を投げる。 箱の幅として使う以上、 描画が壊れる値を
1911
+ * 黙って受け取らない。
1912
+ */
1913
+ stateWidth?: number;
1914
+ };
1915
+
1916
+ export type StateMachine2Builder = {
1917
+ state: (s: FsmStateNested) => StateMachine2Builder;
1918
+ transition: (t: FsmTransitionExt) => StateMachine2Builder;
1919
+ build: () => ReturnType<DiagramBuilder["build"]>;
1920
+ };
1921
+
1922
+ /**
1923
+ * stateMachine2 preset ... 拡張 FSM (UML statechart)。
1924
+ * stateMachine と違って (a) nested state (parent 指定) (b) entry / exit action (c) transition action サポート。
1925
+ * subtitle / sub で action / guard を表示。
1926
+ *
1927
+ * @example
1928
+ * stateMachine2({ id: "auth", topic: "Auth FSM" })
1929
+ * .state({ id: "idle", title: "Idle", initial: true, entry: "clearForm" })
1930
+ * .state({ id: "active", title: "Active" })
1931
+ * .state({ id: "loading", title: "Loading", parent: "active" })
1932
+ * .state({ id: "done", title: "Done", final: true })
1933
+ * .transition({ from: "idle", to: "loading", trigger: "submit", action: "startSpinner" })
1934
+ * .transition({ from: "loading", to: "done", trigger: "success" })
1935
+ * .build();
1936
+ */
1937
+ export function stateMachine2(preset: StateMachine2Preset): StateMachine2Builder {
1938
+ const stateW = resolveStateW(preset.stateWidth, "stateMachine2");
1939
+ const laneW = boxLaneW(stateW);
1940
+ const tone: Tone = preset.defaultTone ?? "accent";
1941
+ const b = diagram(preset.id, { topic: preset.topic });
1942
+
1943
+ const stateOrder = new Map<string, number>();
1944
+ const phaseActivate: string[] = [];
1945
+ let transIdx = 0;
1946
+
1947
+ const api: StateMachine2Builder = {
1948
+ state(s) {
1949
+ const laneId = `lane-${s.id}`;
1950
+ b.lane(laneId, { width: laneW });
1951
+ const eyebrowParts: string[] = [];
1952
+ if (s.initial) eyebrowParts.push("初期");
1953
+ else if (s.final) eyebrowParts.push("最終");
1954
+ else eyebrowParts.push("状態");
1955
+ if (s.parent) eyebrowParts.push(`nested in ${s.parent}`);
1956
+ const subtitleParts: string[] = [];
1957
+ if (s.entry) subtitleParts.push(`entry: ${s.entry}`);
1958
+ if (s.exit) subtitleParts.push(`exit: ${s.exit}`);
1959
+ b.node(s.id, {
1960
+ lane: laneId,
1961
+ stack: 0,
1962
+ kind: "card",
1963
+ w: stateW,
1964
+ title: s.title,
1965
+ eyebrow: eyebrowParts.join(" / "),
1966
+ ...(subtitleParts.length ? { subtitle: subtitleParts.join(" / ") } : {}),
1967
+ });
1968
+ stateOrder.set(s.id, stateOrder.size);
1969
+ phaseActivate.push(s.id);
1970
+ return api;
1971
+ },
1972
+ transition(t) {
1973
+ const currentIdx = transIdx++;
1974
+ const edgeId = `sm2-${currentIdx}-${t.from}-${t.to}`;
1975
+ const subParts: string[] = [];
1976
+ if (t.guard) subParts.push(`[${t.guard}]`);
1977
+ if (t.action) subParts.push(`/${t.action}`);
1978
+ // CAR-468 SSOT ... 旧 labelOffsetY = ±170 の強制上下シフトを廃止、 routePath() SSOT init 位置
1979
+ // (LABEL_INIT_CLEARANCE=34、 CAR-630 で 26 → 34 復帰) をそのまま採用。 分散は engine 側 parallel / fan-in / fan-out logic
1980
+ // (PARALLEL_STEP_Y=52 / FAN_STEP=40) + chain shift が担当。
1981
+ b.edge(t.from, t.to, {
1982
+ id: edgeId,
1983
+ label: t.trigger,
1984
+ ...(subParts.length ? { sub: subParts.join(" ") } : {}),
1985
+ tone: t.tone ?? tone,
1986
+ style: "solid",
1987
+ });
1988
+ phaseActivate.push(edgeId);
1989
+ return api;
1990
+ },
1991
+ build() {
1992
+ b.phase(
1993
+ "sm2",
1994
+ { duration: 2400, title: preset.topic, body: "拡張 FSM (nested + action) を visible 化。" },
1995
+ (p) => p.activate(...phaseActivate).badge("statemachine2"),
1996
+ );
1997
+ return b.build();
1998
+ },
1999
+ };
2000
+
2001
+ return api;
2002
+ }
2003
+
2004
+ // ─── mindMapRadial (中心 topic + 8 方向 45 度間隔 放射) ─────────
2005
+
2006
+ export type MindMapRadialBranch = {
2007
+ id: string;
2008
+ title: string;
2009
+ /** 色 tone (default は defaultTone)。 8 方向で auto rotate も可 */
2010
+ tone?: Tone;
2011
+ subtitle?: string;
2012
+ };
2013
+
2014
+ export type MindMapRadialPreset = {
2015
+ id: string;
2016
+ topic: string;
2017
+ /** 中心 node の title (id は builder が "center" で内部生成) */
2018
+ centerTitle: string;
2019
+ /** 中心 node の id override (default "center") */
2020
+ centerId?: string;
2021
+ /** 中心から branch までの距離 (world coord、 default 350) */
2022
+ radius?: number;
2023
+ /** branch node の width (default 260) */
2024
+ branchWidth?: number;
2025
+ /** 中心 node の width (default 260) */
2026
+ centerWidth?: number;
2027
+ defaultTone?: Tone;
2028
+ };
2029
+
2030
+ export type MindMapRadialBuilder = {
2031
+ branch: (b: MindMapRadialBranch) => MindMapRadialBuilder;
2032
+ build: () => ReturnType<DiagramBuilder["build"]>;
2033
+ };
2034
+
2035
+ /**
2036
+ * mindMapRadial preset ... 中心 node を軸に 8 方向 (0°/45°/90°/135°/180°/225°/270°/315°)
2037
+ * へ branch を放射状に配置する。 現行 mindMap (左から右へ水平 tree) と使い分ける用途。
2038
+ *
2039
+ * 実装戦略 ... engine の lane × stack grid を活用し、 3 lane (left / center / right) を
2040
+ * radius に基づき explicit x で配置、 3 stack row (top / middle / bottom) を stack index 0/1/2 で
2041
+ * 割当てる。 branch 順は position 順 (E/SE/S/SW/W/NW/N/NE) に固定し、 index → grid cell mapping。
2042
+ *
2043
+ * @example
2044
+ * mindMapRadial({ id: "mr", topic: "Product Radial", centerTitle: "Product" })
2045
+ * .branch({ id: "users", title: "Users" }) // 0° (right)
2046
+ * .branch({ id: "road", title: "Roadmap" }) // 45° (bottom-right)
2047
+ * .branch({ id: "metrics", title: "Metrics" }) // 90° (bottom)
2048
+ * ...
2049
+ * .build();
2050
+ */
2051
+ export function mindMapRadial(preset: MindMapRadialPreset): MindMapRadialBuilder {
2052
+ const centerId = preset.centerId ?? "center";
2053
+ const canvasW = 640;
2054
+ const canvasH = 640;
2055
+ const b = diagram(preset.id, { topic: preset.topic });
2056
+ const branches: MindMapRadialBranch[] = [];
2057
+
2058
+ const api: MindMapRadialBuilder = {
2059
+ branch(br) {
2060
+ if (branches.length >= 8) {
2061
+ throw new Error(`cdl mindMapRadial: 8 方向配置のため branch は最大 8 件 (preset id "${preset.id}")`);
2062
+ }
2063
+ branches.push(br);
2064
+ return api;
2065
+ },
2066
+ build() {
2067
+ b.lane("radial", { width: canvasW });
2068
+ const nodeId = `${preset.id}-radial`;
2069
+ b.node(nodeId, {
2070
+ lane: "radial",
2071
+ stack: 0,
2072
+ kind: "mind-radial",
2073
+ title: preset.topic,
2074
+ eyebrow: "mindMapRadial",
2075
+ w: canvasW,
2076
+ h: canvasH,
2077
+ mindData: {
2078
+ rootId: centerId,
2079
+ rootTitle: preset.centerTitle,
2080
+ branches: branches.map((br) => ({
2081
+ id: br.id,
2082
+ title: br.title,
2083
+ parent: centerId,
2084
+ tone: br.tone,
2085
+ subtitle: br.subtitle,
2086
+ })),
2087
+ },
2088
+ });
2089
+ b.phase(
2090
+ "mindmap-radial",
2091
+ { duration: 2400, title: preset.topic, body: "mindMapRadial 全 branch を visible 化、 中心 + 8 方向 45 度で描画。" },
2092
+ (p) => p.activate(nodeId).badge("mindmap-radial"),
2093
+ );
2094
+ return b.build();
2095
+ },
2096
+ };
2097
+
2098
+ return api;
2099
+ }
2100
+
2101
+ // ─── helper ────────────────────────────────────────────────────
2102
+
2103
+ function slugify(s: string): string {
2104
+ return s
2105
+ .toLowerCase()
2106
+ .replace(/[^a-z0-9぀-ゟ゠-ヿ一-龯]+/g, "-")
2107
+ .replace(/^-|-$/g, "");
2108
+ }