@cardenelabs/dragon 0.7.0 → 0.8.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.
package/src/compile.ts CHANGED
@@ -9,11 +9,12 @@
9
9
  * v0.2 ... 6 preset 全対応 (sequence / flow / swimlane / er / state / topology)
10
10
  */
11
11
 
12
- import type { DslDocument, DslPhase } from "./types";
13
- import type { CdlDiagram, ErRelationCardinality, LaidDiagram } from "@cardenelabs/cdl";
12
+ import type { DslActor, DslDocument, DslLane, DslPhase, DslStep, DslValue, PresetType } from "./types";
13
+ import type { CdlDiagram, CdlEdge, ErRelationCardinality, FormulaAst, LaidDiagram } from "@cardenelabs/cdl";
14
14
  import {
15
15
  sequence, flow, swimlane, er, stateMachine, topology, diagram, layout,
16
16
  rendersRows, requiredRowsHeight, requiredRowsWidth, NODE_KINDS,
17
+ applyDerivedValues, parseFormula,
17
18
  } from "@cardenelabs/cdl";
18
19
  import { parseFocusEntry } from "./focus";
19
20
  import { isColorValue, stripExternalPaint } from "./color";
@@ -66,7 +67,31 @@ export type CompileNotice = {
66
67
  // 図の中に描く部品を持たない見本を重ねた (#1017)
67
68
  | "part-not-drawn"
68
69
  // `倍率:` を書いた見本が、同じ名前の状態も持っていた (#1026)
69
- | "scale-reserved";
70
+ | "scale-reserved"
71
+ // 値で描く図 (`pie` / `bar` / `line`) で値を読めなかった (#1154)
72
+ | "chart-value-unreadable"
73
+ // 同上で矢印を書いた。 これらの図は関係を描けない (#1154)
74
+ | "chart-edge-dropped"
75
+ // 同じ名前を `states` と `values` の両方に書いた (#1162)
76
+ | "value-shadows-state"
77
+ // 式を解けず、その値を止めた (輪 / 無い名前 / 読めない式 / 数として読めない値、 #1162)
78
+ | "value-unresolved"
79
+ // 同じ名前を `values` に 2 度書いた。 先に書いた式を使う (#1162)
80
+ | "value-duplicate"
81
+ // 矢印が `actors` に無い名前を指した (#1209)
82
+ | "flow-actor-missing"
83
+ // きっかけ形の値を段に畳めなかった (段が無い / 相手が境目を通らない / 段からはみ出す、 #1161)
84
+ | "value-trigger-unresolved"
85
+ // 矢印の両端が同じ登場人物だった (#1227)
86
+ | "flow-self-loop"
87
+ // 箱に `lane:` を書いたが、 縦列は図種が決めるため効かなかった (#1246)
88
+ | "lane-not-honored"
89
+ // `lanes:` に書いた縦列に箱が 1 つも入らなかった (#1241)
90
+ | "lane-declared-empty"
91
+ // 最上位に `eyebrow:` を書いたが、 箱ごとに分かれる図種で相手が決まらなかった (#1247)
92
+ | "eyebrow-not-honored"
93
+ // 静止した `type: flow` で、書いた矢印の端が使われなかった (#1269)
94
+ | "flow-endpoint-not-honored";
70
95
  /** 対象の名前。 光らせる相手なら書かれた指定そのまま */
71
96
  actor: string;
72
97
  /** 書かれていた行 */
@@ -82,6 +107,28 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
82
107
  const oversize = describeOversize({ elements: countDocElements(doc), bytes: 0 });
83
108
  if (oversize) throw new Error(oversize);
84
109
 
110
+ // 矢印の指す先を `actors` に書いた名前へ揃える (#1209)。 **図種ごとの組み立てより前**。
111
+ //
112
+ // 動きを書いた図は slug に落として引き、 書いていない図は名前の完全一致で引く。 揃えないと
113
+ // 同じ本文が図種ごとに別の相手を指す = 知らせは出ないのに label が消える / 題が slug に
114
+ // 化ける / 依存が切れる (Round 1 で実測)。
115
+ doc = canonicalizeFlowActors(doc);
116
+
117
+ // 知らせは **落とす前の矢印** を見る (#1219)。 落とした後を渡すと、 図が壊れないように
118
+ // 外した矢印が書いた人に届かなくなる
119
+ const 書いたまま = doc;
120
+ // 解決できない矢印を組み立てから外す (#1219)。 残すと、 存在しない箱や枠を指す図ができて
121
+ // 描画の直前で落ちる (実測 = 8 図種)
122
+ doc = dropUnresolvedFlow(doc);
123
+ // 自分へ戻る矢印を組み立てから外す (#1227)。 残すと描画側の検査が図ごと落とし、
124
+ // 本文のどの行が原因かも出ない
125
+ doc = dropSelfLoopFlow(doc);
126
+
127
+ // 名前から作る id が重なる分を解く (#1220)。 **矢印を落とした後**に見る = 落とした矢印の
128
+ // 端にしか出てこない名前で id を分けても、 その箱は作られない
129
+ const 分けた = disambiguateActorIds(doc, opts?.onNotice);
130
+ doc = 分けた.doc;
131
+
85
132
  let diagram: CdlDiagram;
86
133
  switch (doc.type) {
87
134
  case "sequence":
@@ -112,37 +159,92 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
112
159
  diagram = compileClass(doc);
113
160
  break;
114
161
  case "pie":
115
- diagram = compilePie(doc);
162
+ diagram = compileValueChart(doc, "pie", "chart-pie", opts?.onNotice);
163
+ break;
164
+ case "bar":
165
+ diagram = compileValueChart(doc, "bar", "chart-bar", opts?.onNotice);
166
+ break;
167
+ case "line":
168
+ diagram = compileValueChart(doc, "line", "chart-line", opts?.onNotice);
169
+ break;
170
+ case "funnel":
171
+ diagram = compileFunnel(doc, opts?.onNotice);
172
+ break;
173
+ case "tree":
174
+ diagram = compileTree(doc, opts?.onNotice);
175
+ break;
176
+ case "journey":
177
+ diagram = compileJourney(doc, opts?.onNotice);
178
+ break;
179
+ case "quadrant":
180
+ diagram = compileQuadrant(doc, opts?.onNotice);
116
181
  break;
117
182
  case "c4":
118
183
  diagram = compileC4(doc);
119
184
  break;
120
185
  case "mind":
121
- diagram = compileMind(doc);
186
+ diagram = compileMind(doc, opts?.onNotice);
122
187
  break;
123
188
  default:
124
189
  // switch case で全 type を網羅済のため default は unreachable、 template expression で
125
190
  // never 型を直接埋込めないので String() で明示 (defensive runtime error message 用)。
126
191
  throw new Error(`unknown type: ${String(doc.type)}`);
127
192
  }
193
+ // 作り替えた名前を持つ箱と枠を、 **組み立て直後に** 控える (#1220)。 出口で題の文字だけを
194
+ // 見て戻すと、 後から足された見本の中の箱がたまたま同じ題を持っていた時に書き換えてしまう
195
+ const 作り替えた対象 = collectRenamedTargets(diagram, 分けた.元の名前);
196
+
128
197
  // edge と本文の行の対応は表に集めてから 1 edge = 1 回で知らせる (#998)。 経路ごとに
129
198
  // その場で呼ぶと、 同じ edge に別の行を 2 度知らせることになる。
130
199
  const edgeSourceLines = opts?.onEdgeSource ? new Map<string, number>() : undefined;
131
200
  applyEdgeInlineOptions(diagram, doc, edgeSourceLines);
132
- // `type: flow` は actor を鎖状に繋ぐため上の (from, to) 一致では取れない。 preset の規則で埋める。
133
- if (edgeSourceLines) fillFlowEdgeSources(diagram, doc, edgeSourceLines);
134
201
  applyGroupContainers(diagram, doc);
135
202
  applyNodeTones(diagram, doc);
136
203
  // 光らせる相手が実在するかを確かめる。 id への解決は図種ごとに違うが、 名前が居るか
137
204
  // 居ないかは記述だけで決まるので 1 か所で見る
138
- reportMissingFocusTargets(doc, opts?.onNotice);
205
+ reportMissingFocusTargets(書いたまま, opts?.onNotice);
206
+ // 矢印が指す名前が actors に居るかを確かめる。 図種ごとの解決より前に、 記述だけで決まる
207
+ reportMissingFlowActors(書いたまま, opts?.onNotice);
208
+ // 両端が同じ矢印を伝える (#1227)。 落とす前の `flow` を見る
209
+ reportSelfLoopFlow(書いたまま, opts?.onNotice);
210
+ // 静止した `type: flow` で書いた矢印の端が使われないことを伝える (#1269)。
211
+ // 自分へ戻る形と居ない名前を指す形は既に落ちた後の `doc` を見る = 上の 2 件と重ねない
212
+ reportFlowEndpointNotHonored(doc, 分けた.元の名前, opts?.onNotice);
213
+ reportLaneNotHonored(書いたまま, opts?.onNotice);
214
+ reportDocEyebrowNotHonored(書いたまま, opts?.onNotice);
215
+ reportChartFieldsNotHonored(書いたまま, opts?.onNotice);
216
+ reportAxesNotHonored(書いたまま, opts?.onNotice);
139
217
  // `位置: Web の右` を実際の配置から絶対座標に直す。 以降は座標を直接書いた時と同じ経路
140
218
  const placed = resolveRelativeDoc(diagram, doc, opts?.onNotice, opts?.partsCatalog);
141
219
  // canvas pivot 新 spec = 全 preset 共通の post-process で actor.posX/Y を CDL lane / node に伝播
142
220
  applyCanvasPivotPositions(diagram, placed);
143
221
  // CAR-1657 = parts kind actor を merge (opts.partsCatalog 経由)、 applyV05Extensions 後段で実行
144
- const extended = applyV05Extensions(diagram, placed);
145
- const merged = mergePartsFromActors(extended, placed, opts?.partsCatalog, opts?.onNotice);
222
+ const 追加した縦列: DslLane[] = [];
223
+ const extended = applyV05Extensions(diagram, placed, 追加した縦列);
224
+ // 値の知らせは、本文なら値を書いた行、見本なら見本を置いた行を指す。 `derived` 自体には
225
+ // source position が無いため、見本を重ねる間だけ別表で宣言元を持ち回る (#1180)。
226
+ const inheritedDerivedSourceLines = opts?.onNotice ? new Map<string, number[]>() : undefined;
227
+ for (const value of extended.derived ?? []) {
228
+ recordDerivedSourceLine(inheritedDerivedSourceLines, value.id, 0);
229
+ }
230
+ const merged = mergePartsFromActors(
231
+ extended,
232
+ placed,
233
+ opts?.partsCatalog,
234
+ opts?.onNotice,
235
+ inheritedDerivedSourceLines,
236
+ );
237
+ // 箱が 1 つも入らなかった縦列を伝える (#1241)。
238
+ //
239
+ // **見本 (parts) を重ねた後に見る**。 見本の箱は `lane` で行き先を選べるため、
240
+ // `lanes:` で作った縦列に後から入る (実測 = 重ねる前に見ると、箱が入っている縦列にまで
241
+ // 知らせが出た)。
242
+ //
243
+ // 字の集合では防げない = 全角 (`lane-A` に対し生成は `lane-a`) でも打ち間違い
244
+ // (`lane-idl`) でも結果は同じで、新しい縦列が増えるだけで書いた幅は元の縦列に届かない。
245
+ // 受け付けを字で絞るのではなく、合わなかったこと自体を伝える
246
+ reportEmptyDeclaredLanes(merged, 追加した縦列, opts?.onNotice);
247
+
146
248
  // 表が揃ってから 1 edge = 1 回で知らせる。 merge 後に残っている edge だけを対象にする =
147
249
  // 途中で消えた edge の行を知らせても呼出側が使えない。
148
250
  if (edgeSourceLines && opts?.onEdgeSource) {
@@ -161,11 +263,21 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
161
263
  // **出口で 1 度だけ見る**。 種類ごとに塞ぐと 12 経路のどれかを見落とす。 図は必ずここを
162
264
  // 通るので、 ここで段が無ければ入れる。
163
265
  injectStaticPhase(merged);
266
+ // 書いた状態を図に載せる (#1162)。 段を書かない図でも値が届くようにする。
267
+ // **値を載せるより先に呼ぶ**。 状態が空のまま式を解くと、参照が全て「無い名前」 になる。
268
+ materializeStates(merged, doc);
269
+ // 語の欄が状態を読むとき、その状態には記法の語が入っている。 図の語へ直す (#1201)
270
+ 語の状態を図の語へ直す(merged);
271
+ // きっかけ形の値を段の時計を読む式へ畳む (#1161 段 2)。 **値を載せるより先に呼ぶ** =
272
+ // 畳んだ式を `attachDerivedValues` が他の値と同じ経路で載せるため、 順序 / 重なり / 知らせの
273
+ // 扱いが式形と揃う。 時計の状態と段の補間もここで足す
274
+ const 畳んだきっかけ = foldValueTriggers(merged, doc, opts?.onNotice);
275
+ attachDerivedValues(merged, doc, opts?.onNotice, inheritedDerivedSourceLines, 畳んだきっかけ);
164
276
  // 図の外を指す値を、 色を塗る位置から落とす (#1004)。
165
277
  //
166
278
  // 入口ごとに塞ぐ形は採らない。 状態の上書き / phase が入れる値 / 画面が直接書く背景色 /
167
- // 埋め込んだ JSON と入口が 4 つ以上あり、 1 つ見落とすと穴が残る。 描画へ渡る図は必ず
168
- // ここを通るので、 出口で 1 度だけ見る。
279
+ // 埋め込んだ JSON / states / values と入口が複数あり、 1 つ見落とすと穴が残る。
280
+ // **図への追加を全て終えた後**、 出口で 1 度だけ見る。 この後に状態を足すと検査を迂回する。
169
281
  for (const dropped of stripExternalPaint(merged)) {
170
282
  opts?.onNotice?.({
171
283
  kind: "external-paint-dropped",
@@ -175,9 +287,35 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
175
287
  hint: "色は `#ff0000` のような色番号か、 `red` のような色名で書く",
176
288
  });
177
289
  }
290
+ // 作り替えた名前を表示だけ戻す (#1220)。 **図への追加を全て終えた後**に戻す = 途中で戻すと、
291
+ // 後続の処理が名前で引く時に作り替え前と後が混ざる
292
+ restoreActorNames(merged, 分けた.元の名前, 作り替えた対象);
178
293
  return merged;
179
294
  }
180
295
 
296
+ /**
297
+ * 書いた状態 (`states:`) を図に載せる (#1162)。
298
+ *
299
+ * 状態を図に登録するのは `animation:` を書いた経路だけだった (`compileGenericWithAnimate` と
300
+ * `injectPhasesFallback` のどちらも段がある時しか走らない)。 このため `states:` と `values:`
301
+ * だけを書いた図では図の状態が 0 件になり、描画側が組み立てる値が空になる。 書いた値は
302
+ * 1 つも届かず、箱には `{waiting}` の生の形が出ていた (実測)。
303
+ *
304
+ * **出口で 1 度だけ載せる**。 段を作る経路は図の種類ごとにばらけており、経路ごとに書くと
305
+ * どれかを見落とす (`injectStaticPhase` と同じ理由)。
306
+ *
307
+ * 既に載っている名前は触らない。 段の経路が登録した初期値と、見本から引き継いだ状態
308
+ * (`alias__id` の形) の両方を保つ。
309
+ */
310
+ function materializeStates(diagram: CdlDiagram, doc: DslDocument): void {
311
+ const 載っている = new Set(diagram.states.map((s) => s.id));
312
+ for (const st of doc.animate?.states ?? []) {
313
+ if (載っている.has(st.name)) continue;
314
+ diagram.states.push({ id: st.name, initial: st.initial });
315
+ 載っている.add(st.name);
316
+ }
317
+ }
318
+
181
319
  /**
182
320
  * 動かない図に段を 1 つ入れる (#1086)。
183
321
  *
@@ -227,6 +365,631 @@ function truncateForMessage(v: string): string {
227
365
  * あるか」 は書かれた内容だけで決まる。 図種ごとの解決経路に検査を分けると、 経路が増える
228
366
  * たびに検査が取り残される。
229
367
  */
368
+ /**
369
+ * 矢印の指す先を `actors` に書いた **正規の名前** へ解決する表 (#1209)。
370
+ *
371
+ * 名前そのものに加えて **一意な slug も受ける**。 動きを書いた図の組み立ては `slugify` に
372
+ * 落として引くため、 `API Gateway` を `api-gateway` と書いた形が届く。 2 つ以上の名前が
373
+ * 同じ slug になる時は受けない = どちらを指したか決められない。
374
+ *
375
+ * **返すのは正規の名前で、 slug ではない**。 slug を返すと、 動きを書いていない図の組み立て
376
+ * (名前の完全一致で引く) と食い違う = 知らせは出ないのに label が消える / 題が slug に化ける /
377
+ * 依存が切れる、 という形になる (Round 1 で実測)。 中央で名前へ揃えれば全経路が同じ相手を指す。
378
+ */
379
+ function actorRefTable(doc: DslDocument): Map<string, string> {
380
+ const 表 = new Map<string, string>();
381
+ const slug数 = new Map<string, number>();
382
+ for (const a of doc.actors) {
383
+ const sl = slugify(a.name);
384
+ slug数.set(sl, (slug数.get(sl) ?? 0) + 1);
385
+ }
386
+ for (const a of doc.actors) {
387
+ 表.set(a.name, a.name);
388
+ const sl = slugify(a.name);
389
+ if ((slug数.get(sl) ?? 0) === 1) 表.set(sl, a.name);
390
+ }
391
+ return 表;
392
+ }
393
+
394
+ /**
395
+ * 矢印の指す先を正規の名前へ揃えた `flow` を返す (#1209)。
396
+ *
397
+ * 解決できない矢印はそのまま残す = 図種ごとに扱いが違う (木は独自の知らせを出し、 値で描く図は
398
+ * 落とす)。 中央で消すとその扱いが効かなくなる。 残した分は `reportMissingFlowActors` が
399
+ * 知らせ、 動きを書いた図の組み立てが落とす。
400
+ */
401
+ function canonicalizeFlowActors(doc: DslDocument): DslDocument {
402
+ const 表 = actorRefTable(doc);
403
+ let 変えた = false;
404
+ const flow = doc.flow.map((s) => {
405
+ const from = 表.get(s.from) ?? s.from;
406
+ const to = 表.get(s.to) ?? s.to;
407
+ if (from === s.from && to === s.to) return s;
408
+ 変えた = true;
409
+ return { ...s, from, to };
410
+ });
411
+ return 変えた ? { ...doc, flow } : doc;
412
+ }
413
+
414
+ /**
415
+ * 図種ごとの図の作り (#1219 / #1220)。
416
+ *
417
+ * 2 つの判断がここから決まる。 解決できない矢印を中央で落とすか (#1219) と、 名前が同じ id に
418
+ * 潰れる登場人物を作り替えるか (#1220)。 どちらも **登場人物の名前が箱や枠の id になる図種**
419
+ * でだけ要る。
420
+ *
421
+ * `#1209` は動きを書いた 2 経路だけを塞いだ。 残る経路では **知らせは出るのに図まで壊れる**
422
+ * 状態だった (実測 = 8 図種が `compile` の `unknown-ref` で落ちる)。
423
+ *
424
+ * | 作り | 解決できない矢印 | 同じ id に潰れる名前 |
425
+ * |---|---|---|
426
+ * | 登場人物ごとに箱 | 中央で落とす | 名前を作り替えて id を分ける |
427
+ * | 図全体を 1 箱 | 図種に任せる | 触らない |
428
+ *
429
+ * **1 箱で描く図種を中央で落とさない**。 これらは矢印そのものを描かず、 書かれた本数を数えて
430
+ * 独自の知らせを出す (`chart-edge-dropped` / 木の親子の知らせ)。 中央で外すと本数が変わり、
431
+ * 全部が解決できない図では知らせごと消える。 同じ id に潰れる名前も、 中身を payload が
432
+ * 持つため箱の id にならず、 木と放射は独自の知らせを出す。
433
+ *
434
+ * `Record<PresetType, ...>` にしてあるので、 図種を足した時にどちらかを決めないと型検査が
435
+ * 落ちる (`rules/quality.md § 多層 SSOT 経路の全 registration 保証` と同じ形)。
436
+ */
437
+ const 図種の作り: Record<PresetType, "登場人物ごとに箱" | "図全体を 1 箱"> = {
438
+ // 矢印の端と登場人物の名前が、 そのまま箱や枠の id になる
439
+ sequence: "登場人物ごとに箱",
440
+ flow: "登場人物ごとに箱",
441
+ swimlane: "登場人物ごとに箱",
442
+ er: "登場人物ごとに箱",
443
+ state: "登場人物ごとに箱",
444
+ topology: "登場人物ごとに箱",
445
+ solidity: "登場人物ごとに箱",
446
+ class: "登場人物ごとに箱",
447
+ c4: "登場人物ごとに箱",
448
+ // 中身は payload が持ち、 図そのものは 1 箱。 矢印は描かず本数を数えて知らせる
449
+ gantt: "図全体を 1 箱",
450
+ pie: "図全体を 1 箱",
451
+ bar: "図全体を 1 箱",
452
+ line: "図全体を 1 箱",
453
+ funnel: "図全体を 1 箱",
454
+ tree: "図全体を 1 箱",
455
+ journey: "図全体を 1 箱",
456
+ quadrant: "図全体を 1 箱",
457
+ mind: "図全体を 1 箱",
458
+ };
459
+
460
+ /**
461
+ * 解決できない矢印を落とした `flow` を返す (#1219)。
462
+ *
463
+ * 落とすのは **組み立てに渡す分だけ**。 知らせ (`reportMissingFlowActors`) は元の `flow` を
464
+ * 見るので、 落とした矢印も書いた人に届く。
465
+ */
466
+ function dropUnresolvedFlow(doc: DslDocument): DslDocument {
467
+ if (図種の作り[doc.type] === "図全体を 1 箱") return doc;
468
+ const 表 = actorRefTable(doc);
469
+ const flow = doc.flow.filter((s) => 表.has(s.from) && 表.has(s.to));
470
+ return flow.length === doc.flow.length ? doc : { ...doc, flow };
471
+ }
472
+
473
+ /**
474
+ * 自分へ戻る矢印を落とした `flow` を返す (#1227)。
475
+ *
476
+ * 描画側は両端が同じ矢印を受けない (`validate` が `self-loop` で落とす)。 記法の側は通すため、
477
+ * 書けてしまって描画の直前で図ごと落ちていた。 本文のどの行が原因かも出ない。
478
+ *
479
+ * `#1219` が「解決できない矢印は組み立てから外し、 知らせは元の `flow` を見る」 という形を
480
+ * 決めているので、 それに揃える。 落とすのは **組み立てに渡す分だけ** で、 書いた人には
481
+ * `reportSelfLoopFlow` が行番号付きで伝える。
482
+ *
483
+ * ## `type: state` の自己遷移をどう扱うか
484
+ *
485
+ * 矢印としては描けない。 描画側に自分へ戻る矢印を足すのは別 repo の判断で、 本 repo の
486
+ * 記法から決められない。 代わりに 2 通りの書き方が残る = 途中の箱を 1 つ足して 2 本の矢印に
487
+ * 分けるか、 段 (`animation`) で状態が変わる様子として見せる。 知らせの `hint` がこの 2 つを
488
+ * 案内する。
489
+ *
490
+ * 図全体を 1 箱で描く種別は矢印を作らないため対象にしない (本数を数えて別に伝えている)。
491
+ */
492
+ function dropSelfLoopFlow(doc: DslDocument): DslDocument {
493
+ if (図種の作り[doc.type] === "図全体を 1 箱") return doc;
494
+ const flow = doc.flow.filter((s) => s.from !== s.to);
495
+ return flow.length === doc.flow.length ? doc : { ...doc, flow };
496
+ }
497
+
498
+ /**
499
+ * 自分へ戻る矢印を書いた人に伝える (#1227)。
500
+ *
501
+ * **落とす前の `flow` を見る**。 落とした後を渡すと、 図が壊れないように外した矢印が
502
+ * 書いた人に届かない (`reportMissingFlowActors` と同じ理由)。
503
+ *
504
+ * 同じ登場人物に何本書いても 1 件にまとめる = 本数だけ知らせが並んでも直し方は変わらない。
505
+ */
506
+ function reportSelfLoopFlow(doc: DslDocument, onNotice?: (n: CompileNotice) => void): void {
507
+ if (!onNotice) return;
508
+ if (図種の作り[doc.type] === "図全体を 1 箱") return;
509
+ const 知らせた = new Set<string>();
510
+ for (const s of doc.flow) {
511
+ if (s.from !== s.to || 知らせた.has(s.from)) continue;
512
+ 知らせた.add(s.from);
513
+ onNotice({
514
+ kind: "flow-self-loop",
515
+ actor: s.from,
516
+ line: s.pos.line,
517
+ message: `"${truncateForMessage(s.from)}" から自分へ戻る矢印は描けません (組み立てから外しました)`,
518
+ hint: "途中の箱を 1 つ足して 2 本に分けるか、 段 (animation) で状態が変わる様子として見せる",
519
+ });
520
+ }
521
+ }
522
+
523
+ /**
524
+ * 図全体を 1 箱にする図種で、 その箱の上に出す小見出しを渡す (#1247)。
525
+ *
526
+ * 書かなければ何も渡さない = 従来どおり小見出しは付かない。 `undefined` を明示して渡すと、
527
+ * 組立て側が「空の小見出しを書いた」 と区別できなくなるので、 項目ごと落とす。
528
+ */
529
+ function 図の小見出し(doc: DslDocument): { eyebrow?: string } {
530
+ return doc.eyebrow === undefined ? {} : { eyebrow: doc.eyebrow };
531
+ }
532
+
533
+ /**
534
+ * 箱ごとに分かれる図種で最上位の小見出しを書いた時に伝える (#1247)。
535
+ *
536
+ * 小見出しは **箱 1 つに対して 1 つ**。 図全体を 1 箱にする図種 (`pie` / `bar` 等) では
537
+ * 相手が決まるが、 箱ごとに分かれる図種では「どの箱の小見出しか」 が決まらない。
538
+ *
539
+ * 黙って捨てると「書いたのに出ない」 が手掛かりなしで起きる。 箱ごとに書く形
540
+ * (`- A: { eyebrow: "..." }`) を案内する = そちらは従来どおり効く。
541
+ */
542
+ function reportDocEyebrowNotHonored(doc: DslDocument, onNotice?: (n: CompileNotice) => void): void {
543
+ if (!onNotice) return;
544
+ if (doc.eyebrow === undefined) return;
545
+ if (図種の作り[doc.type] === "図全体を 1 箱") return;
546
+ onNotice({
547
+ kind: "eyebrow-not-honored",
548
+ actor: doc.title,
549
+ // 図の `pos` は常に 1 行目を指す。 書いた行に辿り着けるよう `eyebrowPos` を優先する
550
+ line: doc.eyebrowPos?.line ?? doc.pos?.line ?? 0,
551
+ message: `最上位に書いた eyebrow は効きません (type: ${doc.type} は箱ごとに分かれるため相手が決まりません)`,
552
+ hint: '箱ごとに書いてください (`- A: { eyebrow: "..." }`)',
553
+ });
554
+ }
555
+
556
+ /**
557
+ * 縦列を選べる図種で、一部の箱だけが縦列を書いた時に伝える (#1263)。
558
+ *
559
+ * 書かなかった箱の行き先を決める規則が要るが、既定の縦列に集めても自分の縦列を作っても
560
+ * 書いた人の意図と一致する保証が無い。 **全部書くか 1 つも書かないか** を求める。
561
+ *
562
+ * 1 つも書いていない形は従来どおりの並びになるだけなので知らせない。
563
+ */
564
+ function reportLaneMixed(doc: DslDocument, onNotice: (n: CompileNotice) => void): void {
565
+ const 対象 = doc.actors.filter((a) => a.partId === undefined);
566
+ const 書いた = 対象.filter((a) => a.lane !== undefined);
567
+ if (書いた.length === 0 || 書いた.length === 対象.length) return;
568
+ const 書いていない = 対象.filter((a) => a.lane === undefined);
569
+ onNotice({
570
+ kind: "lane-not-honored",
571
+ actor: 書いていない[0]?.name ?? "",
572
+ line: 書いていない[0]?.pos?.line ?? 0,
573
+ message: `type: ${doc.type} では縦列を書くなら全ての箱に書きます (書いていない箱: ${書いていない.map((a) => truncateForMessage(a.name)).join(" / ")})`,
574
+ hint: "書かなかった箱をどの縦列に置くかを決められないため、全部書くか 1 つも書かないかにしてください",
575
+ });
576
+ }
577
+
578
+ /**
579
+ * 静止した `type: flow` で、書いた矢印の端が使われないことを伝える (#1269)。
580
+ *
581
+ * この図種は **登場人物を書いた順に鎖状に繋ぐ**。 矢印の説明文は「その箱を to に持つ行」
582
+ * から拾い、書いた側の端 (from) は使わない (`compileFlow`)。
583
+ *
584
+ * そのため `A -> C` と書いても出来るのは `A -> B` で、書いた形と違う図になる。
585
+ * 実測 = `A -> C` / `C -> B` と書くと `A -> B` に `"y"`、`B -> C` に `"x"` が載った。
586
+ * 知らせは 1 件も出ていなかった。
587
+ *
588
+ * ## 鎖にすること自体は変えない
589
+ *
590
+ * 線形の流れを描く図種なので、鎖にするのは仕様。 書いた端どおりに繋ぎたい形は
591
+ * 箱に `lane:` を書けば別の経路へ回る (#1266)。 知らせの hint でそれを案内する。
592
+ *
593
+ * ## 偶然一致する形では知らせない
594
+ *
595
+ * `A -> B` / `B -> C` のように書いた端がそのまま鎖になる形は、書いたとおりの図になる。
596
+ * ここで知らせると、正しく書いた人にまで出る。
597
+ *
598
+ * ## 既に落ちた行は見ない
599
+ *
600
+ * 自分へ戻る形 (`flow-self-loop`) と居ない名前を指す形 (`flow-actor-missing`) は
601
+ * 別の知らせが担う。 落とした後の `doc` を見ることで、同じ 1 行に知らせが 2 件並ぶのを避ける。
602
+ */
603
+ function reportFlowEndpointNotHonored(
604
+ doc: DslDocument,
605
+ 元の名前: Map<string, string>,
606
+ onNotice?: (n: CompileNotice) => void,
607
+ ): void {
608
+ if (!onNotice) return;
609
+ if (!鎖でつなぐ形か(doc)) return;
610
+ // 名前が重なった登場人物は組み立ての間だけ尾を付けて分けている (`disambiguateActorIds`)。
611
+ // **判定は分けた後の名前で行い、伝える時は書いた名前に戻す** = 判定は `compileFlow` の
612
+ // 繋ぎ方と揃える必要があり、伝える先は本文なので書いていない名前を指すと直せない
613
+ // (実測 = `"A B 546d26" の端は使われません` と出て、本文にその名前は無い)
614
+ const 書いた名前 = (name: string): string => 元の名前.get(name) ?? name;
615
+ // 鎖が作る組を集める。 登場人物が 1 人以下なら矢印が 1 本も出来ないので、
616
+ // 書いた矢印は全て使われない扱いになる
617
+ const 鎖の組 = new Set<string>();
618
+ doc.actors.forEach((a, i) => {
619
+ const 次 = doc.actors[i + 1];
620
+ if (次 === undefined) return;
621
+ 鎖の組.add(`${a.name}\u0000${次.name}`);
622
+ });
623
+ for (const s of doc.flow) {
624
+ if (鎖の組.has(`${s.from}\u0000${s.to}`)) continue;
625
+ onNotice({
626
+ kind: "flow-endpoint-not-honored",
627
+ actor: 書いた名前(s.from),
628
+ line: s.pos.line,
629
+ message: `"${truncateForMessage(書いた名前(s.from))} -> ${truncateForMessage(書いた名前(s.to))}" の端は使われません (type: flow は登場人物を書いた順に繋ぎます)`,
630
+ hint: "書いた端どおりに繋ぐには、箱に lane: を書いてください (縦列を書いた形は書いた端がそのまま矢印になります)",
631
+ });
632
+ }
633
+ }
634
+
635
+ /**
636
+ * 箱に書いた縦列が効かないことを伝える (#1246)。
637
+ *
638
+ * 縦列は **図種が決める**。 `flow` / `topology` は 1 本にまとめ、 `swimlane` / `er` / `state`
639
+ * は箱ごとに 1 本作り、 `sequence` はそれがそのまま生命線になる。 図全体を 1 箱にする図種
640
+ * (`pie` / `bar` 等) では箱が 1 つしかない。 **どの図種も箱の `lane` を読まない**。
641
+ *
642
+ * 黙って捨てると、 書いた縦列は消え、 `lanes:` で宣言した縦列だけが中身のないまま残る。
643
+ * 実測 = `type: flow` で `lane: ui` / `lane: api` を書くと箱は両方 `flow` に入り、
644
+ * 宣言した `ui` / `api` は空のまま増えた。 知らせは 1 件も出なかった。
645
+ *
646
+ * ## なぜ組み立ての側で伝えるのか
647
+ *
648
+ * 記法の解析は図種を見ずに 1 行ずつ読む。 そこで弾くと **見本 (parts) の張替え先** まで
649
+ * 巻き添えになる = 見本では `lane` が実際に読まれる (`mergePartsFromActors` が唯一の読み手)。
650
+ * 図種を知っているのは組み立ての側なので、 効くかどうかの判断もここに置く。
651
+ *
652
+ * ## 見本と `type: mind` では伝えない
653
+ *
654
+ * 見本は上のとおり実際に効く。 `type: mind` は描けない欄をまとめて 1 件で伝えており
655
+ * (`compileMind` の「名前と副題 / 値、 枝の色しか描けません」)、 そこに `枠の指定` が既に
656
+ * 入っている。 二重に伝えると同じ 1 行について知らせが 2 件並ぶ。
657
+ */
658
+ function reportLaneNotHonored(doc: DslDocument, onNotice?: (n: CompileNotice) => void): void {
659
+ if (!onNotice) return;
660
+ if (doc.type === "mind") return;
661
+ // 縦列を選べる図種では、全ての箱が書いていれば効く (#1263)。 効く形では知らせない
662
+ const 効く図種 = 縦列を選べる図種.has(doc.type as GenericKind);
663
+ if (効く図種 && 書いた縦列に置く(doc.type as GenericKind, doc)) return;
664
+ if (効く図種) {
665
+ reportLaneMixed(doc, onNotice);
666
+ return;
667
+ }
668
+ for (const a of doc.actors) {
669
+ if (a.partId !== undefined) continue;
670
+ if (a.lane === undefined) continue;
671
+ onNotice({
672
+ kind: "lane-not-honored",
673
+ actor: a.name,
674
+ line: a.pos?.line ?? 0,
675
+ message: `"${truncateForMessage(a.name)}" に書いた lane は効きません (縦列は type: ${doc.type} が決めます)`,
676
+ hint: "縦列は図種が決めるため箱からは選べません。 lane を消してください (見本では張替え先として使えます)",
677
+ });
678
+ }
679
+ }
680
+
681
+ /**
682
+ * 名前から作る id の重なりを解く (#1220)。
683
+ *
684
+ * 箱と枠の id は登場人物の名前から作る (`slugify`)。 **違う名前が同じ id に潰れる** と、
685
+ * どちらも正しく書いているのに図が組み立たない (実測 = `foo-bar` と `Foo Bar` を書くと
686
+ * 9 図種が `duplicate-id` で落ちる)。 知らせも出ない = どちらの名前も `actors` に在るため。
687
+ *
688
+ * ## なぜ名前を作り替えるのか
689
+ *
690
+ * id を作る所は 90 箇所を超え、 さらに **cdl 側の組み立てが名前から id を作る経路** がある
691
+ * (`swimlane()` / `er()` は渡した名札から lane id を作る)。 dragon 側だけを直しても届かない。
692
+ *
693
+ * そこで **渡す名前を変え、 最後に表示だけ戻す**。 作り替えた名前は組み立ての間だけ使い、
694
+ * 出口で `nodes[].title` と `lanes[].label` を元に戻す (`restoreActorNames`)。
695
+ *
696
+ * ## 尾は名前から作る
697
+ *
698
+ * 書き順で決めると、 登場人物を並べ替えただけで id が入れ替わる。 元の名前だけから決まる
699
+ * 短い値を尾に付ければ、 並べ替えても同じ id になる。
700
+ *
701
+ * ## まったく同じ名前は畳む
702
+ *
703
+ * 名前が 1 文字も違わない登場人物は区別できない。 2 つの箱に同じ題が付くだけなので、
704
+ * 先に書いた方を残して知らせる。
705
+ */
706
+ function 名前の尾(name: string): string {
707
+ // FNV-1a。 短くて名前だけから決まればよく、 衝突しても下の検査が拾う
708
+ let h = 0x811c9dc5;
709
+ for (const c of name) {
710
+ h ^= c.codePointAt(0) ?? 0;
711
+ h = Math.imul(h, 0x01000193) >>> 0;
712
+ }
713
+ return h.toString(16).padStart(8, "0").slice(0, 6);
714
+ }
715
+
716
+ /**
717
+ * cdl 側が名札から id を作る時の規則 (`presets.ts` の `slugify`)。
718
+ *
719
+ * **dragon の規則と違う**。 dragon は `-` と `_` を残し `NFKC` で揃え 64 字で切るが、 cdl は
720
+ * どちらも `-` に潰し、 長さも切らない。 このため `a_b` と `a-b` は **dragon では別 id、 cdl では
721
+ * 同じ id** になる (実測 = 動きを書かない `sequence` / `solidity` が `duplicate-id` で落ちる)。
722
+ *
723
+ * ここに写している = cdl は `slugify` を公開していない。 **ずれると衝突を見落とす** ので、
724
+ * 下の検査が既知の組で対応を固定する。
725
+ */
726
+ function cdl側のslug(s: string): string {
727
+ return s
728
+ .toLowerCase()
729
+ .replace(/[^a-z0-9぀-ゟ゠-ヿ一-龯]+/g, "-")
730
+ .replace(/^-|-$/g, "");
731
+ }
732
+
733
+ /**
734
+ * cdl 側が「形が空になった名前」 に付ける id の頭 (#1220 Round 2 / 3)。
735
+ *
736
+ * cdl は形が空になった時に並びの位置へ逃げる。 逃げ先を鍵に入れないと、 逃げ先と同じ名前の
737
+ * 登場人物が居る図で重なる (実測 = `sequence` の `😀` と `actor-0`)。
738
+ *
739
+ * **図種ごとに違う** (Round 3 の指摘)。 両方を入れると、 その図種では使われない逃げ先まで
740
+ * 衝突とみなして **重なっていない図の id を変える**。 実測した対応は次のとおり。
741
+ *
742
+ * | 図種 | `😀` だけを書いた時に付く id |
743
+ * |---|---|
744
+ * | `sequence` / `solidity` | 枠 `actor-0` (cdl の `sequence()`) |
745
+ * | `swimlane` | 枠 `lane-0` (cdl の `swimlane()`) |
746
+ * | 残る 6 図種 | 箱 `n` (dragon 側の逃げ先。 cdl の逃げ道を通らない) |
747
+ *
748
+ * 空配列は「cdl の逃げ道を通らない」 を表す。 1 箱で描く図種はここに来ない (作り替え自体を
749
+ * しない) が、 図種を足した時に決め忘れないよう全種を並べる。
750
+ *
751
+ * **この表が効くのは動きを書いていない図だけ** (Round 4 の指摘)。 動きを書くと組み立てが別経路に
752
+ * 切り替わり、 id は dragon 側の規則で作られる = cdl の逃げ道を通らない。 使う側で条件を見る。
753
+ */
754
+ const 空の形の逃げ先: Record<PresetType, string[]> = {
755
+ sequence: ["actor-"],
756
+ solidity: ["actor-"],
757
+ swimlane: ["lane-"],
758
+ flow: [],
759
+ er: [],
760
+ state: [],
761
+ topology: [],
762
+ class: [],
763
+ c4: [],
764
+ // 図全体を 1 箱で描く群 (作り替えないのでここは使わない)
765
+ gantt: [],
766
+ pie: [],
767
+ bar: [],
768
+ line: [],
769
+ funnel: [],
770
+ tree: [],
771
+ journey: [],
772
+ quadrant: [],
773
+ mind: [],
774
+ };
775
+
776
+ /**
777
+ * その名前が下流で id になりうる形。 どれか 1 つでも重なれば衝突する。
778
+ *
779
+ * 位置と逃げ先が分からない時 (作り替えた候補を確かめる時) は逃げ先を数えない。 作り替えた
780
+ * 名前は尾が付いて形が空にならないので、 そもそも逃げ道を通らない。
781
+ */
782
+ function idになる形(name: string, 位置?: number, 逃げ先の頭: string[] = []): string[] {
783
+ const cdl = cdl側のslug(name);
784
+ const out = [slugify(name), cdl];
785
+ if (cdl === "" && 位置 !== undefined) {
786
+ for (const 頭 of 逃げ先の頭) out.push(`${頭}${位置}`);
787
+ }
788
+ return out;
789
+ }
790
+
791
+ function disambiguateActorIds(
792
+ doc: DslDocument,
793
+ onNotice?: (notice: CompileNotice) => void,
794
+ ): { doc: DslDocument; 元の名前: Map<string, string> } {
795
+ const 元の名前 = new Map<string, string>();
796
+ if (図種の作り[doc.type] !== "登場人物ごとに箱") return { doc, 元の名前 };
797
+
798
+ // 1. まったく同じ名前を畳む
799
+ const 見た = new Set<string>();
800
+ const 残す: DslActor[] = [];
801
+ for (const a of doc.actors) {
802
+ if (見た.has(a.name)) {
803
+ onNotice?.({
804
+ kind: "chart-value-unreadable",
805
+ actor: a.name,
806
+ line: a.pos?.line ?? 0,
807
+ message: `"${truncateForMessage(a.name)}" を 2 度書いています (先に書いた方だけ描きます)`,
808
+ hint: "違う名前にするか、 1 つにまとめる",
809
+ });
810
+ continue;
811
+ }
812
+ 見た.add(a.name);
813
+ 残す.push(a);
814
+ }
815
+
816
+ // 2. どの名前が重なるかを見る。 **両方の規則で見る** = 片方だけだと cdl 側の経路で落ちる
817
+ const 形ごとの名前 = new Map<string, Set<string>>();
818
+ const 位置 = new Map<string, number>();
819
+ 残す.forEach((a, i) => 位置.set(a.name, i));
820
+ // **動きを書いた図では cdl の逃げ道を通らない** (Round 4 の指摘)。 動きがあると組み立てが
821
+ // 別経路 (`compileGenericWithAnimate` / `compileSequenceWithAnimate`) に切り替わり、 id は
822
+ // dragon 側の規則で作られる (実測 = `😀` は 枠 `n` / `lane-n` になり、 位置を使わない)。
823
+ //
824
+ // 逃げ先を鍵に入れたままにすると、 その経路で **重なっていない図の id を変える**。
825
+ // dragon 側の規則で作る分は、 1 つ目の鍵 (`slugify`) が既に見ている。
826
+ const 動きを書いた = (doc.animate?.phases.length ?? 0) > 0;
827
+ const 逃げ先の頭 = 動きを書いた ? [] : 空の形の逃げ先[doc.type];
828
+ for (const a of 残す) {
829
+ for (const 形 of idになる形(a.name, 位置.get(a.name), 逃げ先の頭)) {
830
+ const 群 = 形ごとの名前.get(形) ?? new Set<string>();
831
+ 群.add(a.name);
832
+ 形ごとの名前.set(形, 群);
833
+ }
834
+ }
835
+ const 重なる = (name: string): boolean =>
836
+ idになる形(name, 位置.get(name), 逃げ先の頭).some(
837
+ (形) => (形ごとの名前.get(形)?.size ?? 0) > 1,
838
+ );
839
+
840
+ // **見本 (`parts`) を重ねた登場人物は作り替えない**。 見本の中身は `別名__元の id` の形で
841
+ // 名前空間を持ち、 別名は名前から作るため、 作り替えると見本の id が総入れ替えになる。
842
+ //
843
+ // ただし **重なりの判定には数える** (Round 1 の指摘)。 数えないと、 素の登場人物と見本が
844
+ // 同じ id の仮置きを共有し、 見本を片付ける時に素の登場人物の箱まで消える。
845
+ const 作り替える = 残す.filter((a) => a.partId === undefined && 重なる(a.name));
846
+
847
+ // 3. 名前から決まる尾を付ける。 **できあがる id が一意になるまで見る**
848
+ //
849
+ // 尾は元の名前だけから決まる = 並べ替えても同じ id になる。 それでも重なる時 (尾そのものが
850
+ // 重なる形) は、 名前を並べ替えた順で番号を足す = ここも書き順に依らない
851
+ const 使う形 = new Set<string>();
852
+ for (const a of 残す) {
853
+ if (作り替える.some((b) => b.name === a.name)) continue;
854
+ for (const 形 of idになる形(a.name)) 使う形.add(形);
855
+ }
856
+ const 新しい名前 = new Map<string, string>();
857
+ // 名前で並べてから配る = 書いた順に依らない
858
+ for (const a of [...作り替える].sort((x, y) => (x.name < y.name ? -1 : x.name > y.name ? 1 : 0))) {
859
+ const 尾 = 名前の尾(a.name);
860
+ // **id の長さの上限のぶん、 元の名前を先に切る** (Round 1 の指摘)。 切らないと尾が
861
+ // 64 字で落ちて、 同じ頭を持つ長い名前どうしが元のまま重なる
862
+ const 余地 = ID_MAX - (尾.length + 1);
863
+ const 基底 = a.name.slice(0, 余地);
864
+ let 候補 = `${基底} ${尾}`;
865
+ let n = 0;
866
+ while (idになる形(候補).some((形) => 使う形.has(形))) {
867
+ n += 1;
868
+ 候補 = `${基底.slice(0, 余地 - String(n).length)} ${尾}${n}`;
869
+ }
870
+ for (const 形 of idになる形(候補)) 使う形.add(形);
871
+ 新しい名前.set(a.name, 候補);
872
+ 元の名前.set(候補, a.name);
873
+ }
874
+
875
+ if (新しい名前.size === 0 && 残す.length === doc.actors.length) return { doc, 元の名前 };
876
+
877
+ const 直す = (名: string): string => 新しい名前.get(名) ?? 名;
878
+ const 次: DslDocument = {
879
+ ...doc,
880
+ actors: 残す.map((a) => (新しい名前.has(a.name) ? { ...a, name: 直す(a.name) } : a)),
881
+ flow: doc.flow.map((s) => {
882
+ const from = 直す(s.from);
883
+ const to = 直す(s.to);
884
+ return from === s.from && to === s.to ? s : { ...s, from, to };
885
+ }),
886
+ };
887
+ // 光らせる指定と位置の基準も名前で書くので、 同じ表で直す
888
+ if (次.animate) {
889
+ 次.animate = {
890
+ ...次.animate,
891
+ phases: 次.animate.phases.map((ph) =>
892
+ ph.highlight ? { ...ph, highlight: ph.highlight.map((h) => 直す(h)) } : ph,
893
+ ),
894
+ };
895
+ }
896
+ 次.actors = 次.actors.map((a) =>
897
+ a.posRel ? { ...a, posRel: { ...a.posRel, anchor: 直す(a.posRel.anchor) } } : a,
898
+ );
899
+ return { doc: 次, 元の名前 };
900
+ }
901
+
902
+ /**
903
+ * 作り替えた名前を持つ箱と枠を控える (#1220)。
904
+ *
905
+ * **組み立て直後に控える** (Round 1 の指摘)。 出口で題の文字だけを見て戻すと、 後から足された
906
+ * 見本の中の箱がたまたま同じ題を持っていた時に、 その表示まで書き換えてしまう。
907
+ */
908
+ function collectRenamedTargets(
909
+ diagram: CdlDiagram,
910
+ 元の名前: Map<string, string>,
911
+ ): { 箱: Set<string>; 枠: Set<string> } {
912
+ const 箱 = new Set<string>();
913
+ const 枠 = new Set<string>();
914
+ if (元の名前.size === 0) return { 箱, 枠 };
915
+ for (const n of diagram.nodes) if (元の名前.has(n.title)) 箱.add(n.id);
916
+ for (const l of diagram.lanes) if (l.label !== undefined && 元の名前.has(l.label)) 枠.add(l.id);
917
+ return { 箱, 枠 };
918
+ }
919
+
920
+ /**
921
+ * 作り替えた名前を、 図の表示だけ元に戻す (#1220)。
922
+ *
923
+ * 戻すのは題と名札だけ。 id は作り替えたまま = 分けるために作り替えたので、 戻すと元の
924
+ * 重なりに帰る。
925
+ */
926
+ function restoreActorNames(
927
+ diagram: CdlDiagram,
928
+ 元の名前: Map<string, string>,
929
+ 対象: { 箱: Set<string>; 枠: Set<string> },
930
+ ): void {
931
+ if (元の名前.size === 0) return;
932
+ for (const n of diagram.nodes) {
933
+ if (!対象.箱.has(n.id)) continue;
934
+ const 元 = 元の名前.get(n.title);
935
+ if (元 !== undefined) n.title = 元;
936
+ }
937
+ for (const l of diagram.lanes) {
938
+ if (!対象.枠.has(l.id) || l.label === undefined) continue;
939
+ const 元 = 元の名前.get(l.label);
940
+ if (元 !== undefined) l.label = 元;
941
+ }
942
+ }
943
+
944
+ /**
945
+ * 矢印が `actors` に無い名前を指したことを知らせる (#1209)。
946
+ *
947
+ * 知らせずに通すと、 **どちらに転んでも書いた人の意図が消える**。 動きを書いていない図では
948
+ * 種類ごとの組み立てが actors を順に繋ぐため、 書いた矢印そのものが捨てられて label も
949
+ * 消える (実測 = "変換" が "→" になった)。 動きを書いた図では名前がそのまま下流へ渡り、
950
+ * 存在しない node を指す図ができて描画の直前で落ちる
951
+ * (実測 = `unknown-ref: edge "e0-v-c" の from "v" が node に存在しません`)。
952
+ *
953
+ * 落ちる場所も消える場所も本文から遠いので、 書いた行で知らせる。
954
+ */
955
+ function reportMissingFlowActors(
956
+ doc: DslDocument,
957
+ onNotice?: (notice: CompileNotice) => void,
958
+ ): void {
959
+ if (!onNotice) return;
960
+ const 表 = actorRefTable(doc);
961
+ // hint は **1 度だけ作る**。 知らせごとに全 actor 名を並べ直すと、 名前も矢印も上限
962
+ // (各 1,000) まで書いた図で数百 MB になる (Round 1 の指摘)。 並べる数にも上限を置く
963
+ //
964
+ // **1 件ずつの長さも切る**。 件数だけを絞っても、 名前 1 つが 2 万字なら知らせも 2 万字に
965
+ // なる (Round 4 の実測)。 名前も矢印の指定も外から来る文字列なので、 表示に使う所は
966
+ // すべて `truncateForMessage` を通す (光らせる相手の知らせと同じ扱い)。
967
+ const 見せる数 = 8;
968
+ const 名前一覧 = doc.actors
969
+ .slice(0, 見せる数)
970
+ .map((a) => truncateForMessage(a.name))
971
+ .join(" / ");
972
+ const 残り = doc.actors.length - 見せる数;
973
+ const hint =
974
+ doc.actors.length > 0
975
+ ? `actors に書いた名前で指す (${名前一覧}${残り > 0 ? ` ほか ${残り} 件` : ""})`
976
+ : "actors に登場人物を書く";
977
+ const 知らせた = new Set<string>();
978
+ for (const s of doc.flow) {
979
+ for (const ref of [s.from, s.to]) {
980
+ if (表.has(ref) || 知らせた.has(ref)) continue;
981
+ 知らせた.add(ref);
982
+ onNotice({
983
+ kind: "flow-actor-missing",
984
+ actor: ref,
985
+ line: s.pos.line,
986
+ message: `矢印が "${truncateForMessage(ref)}" を指していますが、 actors に書かれていません`,
987
+ hint,
988
+ });
989
+ }
990
+ }
991
+ }
992
+
230
993
  function reportMissingFocusTargets(
231
994
  doc: DslDocument,
232
995
  onNotice?: (notice: CompileNotice) => void,
@@ -258,12 +1021,20 @@ function reportMissingFocusTargets(
258
1021
  steps.set(st.from, tos);
259
1022
  }
260
1023
 
1024
+ // 矢印の両端は **流れと同じ表で名前へ揃えてから** 照合する (#1209 Round 2)。
1025
+ //
1026
+ // 流れは入口で名前へ揃えている (`canonicalizeFlowActors`) 一方、 光らせる指定は生のまま
1027
+ // 来る。 揃えずに比べると、 slug で書いた矢印 (`api-gateway -> db`) が実際は光るのに
1028
+ // 「見つかりません」 と誤報する (実測)
1029
+ const 名前へ = actorRefTable(doc);
1030
+ const 揃える = (ref: string): string => 名前へ.get(ref) ?? ref;
1031
+
261
1032
  for (const phase of doc.animate.phases) {
262
1033
  for (const raw of phase.highlight ?? []) {
263
1034
  const entry = parseFocusEntry(raw, names);
264
1035
  const found =
265
1036
  entry.kind === "edge"
266
- ? (steps.get(entry.from)?.has(entry.to) ?? false)
1037
+ ? (steps.get(揃える(entry.from))?.has(揃える(entry.to)) ?? false)
267
1038
  : accepted.has(entry.name);
268
1039
  if (found) continue;
269
1040
  onNotice({
@@ -1485,9 +2256,13 @@ function mergePartsFromActors(
1485
2256
  doc: DslDocument,
1486
2257
  partsCatalog?: Record<string, CdlDiagram>,
1487
2258
  onNotice?: (notice: CompileNotice) => void,
2259
+ derivedSourceLines?: Map<string, number[]>,
1488
2260
  ): CdlDiagram {
1489
2261
  const partsActors = doc.actors.filter((a) => a.partId !== undefined);
1490
2262
  if (partsActors.length === 0) return target;
2263
+ // 値と状態の名前に使う前置きを **書いた順に 1 度だけ** 決める (#1189)。 見本ごとに作ると
2264
+ // 同じ形に潰れた時の番号が揃わず、後から重ねた見本が先の名前空間を踏む
2265
+ const 値の前置き = 値の前置きを作る(partsActors.map((a) => a.name));
1491
2266
  if (!partsCatalog) {
1492
2267
  if (typeof console !== "undefined" && console.warn) {
1493
2268
  const names = partsActors.map((a) => `${a.name} (kind: ${a.partId ?? "?"})`).join(", ");
@@ -1585,7 +2360,21 @@ function mergePartsFromActors(
1585
2360
  }
1586
2361
  }
1587
2362
  const t = partTargetSize(part, actor.posW, actor.posH, actor.scale);
1588
- mergePartIntoDiagram(target, part, actor.name, merged, actor.lane, placeX, placeY, t.w, t.h, onNotice, actor.pos?.line ?? 0);
2363
+ mergePartIntoDiagram(
2364
+ target,
2365
+ part,
2366
+ actor.name,
2367
+ merged,
2368
+ actor.lane,
2369
+ placeX,
2370
+ placeY,
2371
+ t.w,
2372
+ t.h,
2373
+ onNotice,
2374
+ actor.pos?.line ?? 0,
2375
+ derivedSourceLines,
2376
+ 値の前置き.get(actor.name),
2377
+ );
1589
2378
  }
1590
2379
  return target;
1591
2380
  }
@@ -1644,6 +2433,56 @@ function applyColorHex(
1644
2433
  * state initial は stateOverride で上書き可、 shape / subtitle / value 内の '{stateName}' template も
1645
2434
  * '{alias__stateName}' に rewrite する。 phase parallel merge (activate / tweens / sets の id 参照 rename)。
1646
2435
  */
2436
+ /**
2437
+ * 値と状態の名前に使う前置きを、登場人物の名前から作る (#1189)。
2438
+ *
2439
+ * `{名前}` に書ける字種は engine が 1 箇所で決めており (`template-name.ts`)、英数字と `_` に
2440
+ * 限る。 **読む側 (置き換え) と書ける側 (式) の両方がその定義を使う** ため、記法の側だけ
2441
+ * 広げることはできない。
2442
+ *
2443
+ * この記法では日本語の名前が普通なので、名前をそのまま前置きにすると値が 1 つも届かない。
2444
+ * 実測 = `受付 1` に見本を重ねると、状態は表に載るのに箱の `{受付 1__v}` が置き換わらず、
2445
+ * 見本の中の式は識別子として読めずに止まる (`value-unresolved`)。
2446
+ *
2447
+ * **箱 / 縦列 / 矢印の id は変えない**。 これらは `{名前}` の対象ではなく、画面側が id から
2448
+ * 登場人物の名前を取り出す経路があるため、変えると別の場所が壊れる。
2449
+ *
2450
+ * ## 同じ形に潰れる名前
2451
+ *
2452
+ * `受付 1` と `受付-1` はどちらも英数字だけにすると同じ形になる。 潰れたまま使うと 2 つの
2453
+ * 見本が同じ名前空間を共有し、片方の値がもう片方を上書きする。
2454
+ *
2455
+ * そこで **書いた順に番号を足して分ける**。 先に書いた方が番号なしを取り、後から同じ形に
2456
+ * なった方が `_2` / `_3` と続く。 英数字の名前しか無い図では 1 つも番号が付かないため、
2457
+ * 既存の図の名前は変わらない。
2458
+ */
2459
+ function 値の前置きを作る(名前たち: readonly string[]): Map<string, string> {
2460
+ const 出力 = new Map<string, string>();
2461
+ const 使用中 = new Set<string>();
2462
+ const 次の番号 = new Map<string, number>();
2463
+ for (const 名前 of 名前たち) {
2464
+ if (出力.has(名前)) continue;
2465
+ let 素 = 名前
2466
+ .normalize("NFKC")
2467
+ .replace(/[^A-Za-z0-9_]+/g, "_")
2468
+ .replace(/^_+|_+$/g, "");
2469
+ // 空になる形 (記号だけの名前) と数字始まりは、そのままでは名前として使えない
2470
+ if (素 === "" || /^[0-9]/.test(素)) 素 = `p${素}`;
2471
+ // 接尾辞で分けた名前も使用済みとして扱う。 `p1` / `p1!` / `p1_2` の順では、
2472
+ // base ごとの回数だけを見ると後ろ 2 つがどちらも `p1_2` になって再衝突する
2473
+ let 候補 = 素;
2474
+ let 番号 = 次の番号.get(素) ?? 2;
2475
+ while (使用中.has(候補)) {
2476
+ 候補 = `${素}_${番号}`;
2477
+ 番号 += 1;
2478
+ }
2479
+ 次の番号.set(素, 番号);
2480
+ 使用中.add(候補);
2481
+ 出力.set(名前, 候補);
2482
+ }
2483
+ return 出力;
2484
+ }
2485
+
1647
2486
  function mergePartIntoDiagram(
1648
2487
  target: CdlDiagram,
1649
2488
  part: CdlDiagram,
@@ -1669,15 +2508,48 @@ function mergePartIntoDiagram(
1669
2508
  onNotice?: (notice: CompileNotice) => void,
1670
2509
  /** 知らせに載せる行。 パーツを書いた行を指す。 行が取れない経路 (JSON) では 0 */
1671
2510
  noticeLine = 0,
2511
+ /** 見本から引き継いだ値の宣言元。 notice を見本を書いた行へ戻すために使う */
2512
+ derivedSourceLines?: Map<string, number[]>,
2513
+ /**
2514
+ * 値と状態の名前に使う前置き (#1189)。 `{名前}` は英数字と `_` しか読めないため、
2515
+ * 登場人物の名前をそのまま使えない。 渡されない経路では従来どおり名前をそのまま使う
2516
+ */
2517
+ valueAlias?: string,
1672
2518
  ): void {
1673
2519
  const prefix = (id: string): string => `${alias}__${id}`;
1674
- const stateIdSet = new Set(part.states.map((s) => s.id));
2520
+ // 値と状態だけ別の前置きを使う (#1189) / 縦列 / 矢印の id は `prefix` のまま
2521
+ const 値前置き = valueAlias ?? alias;
2522
+ const valuePrefix = (id: string): string => `${値前置き}__${id}`;
2523
+ // 見本が自分で持つ名前。 **状態と、他の値から決まる値の両方** (#1180)。
2524
+ //
2525
+ // 値を含めないと、見本の中の `{決まる値}` が名前を付け替えられずに残り、重ねた先の同名の
2526
+ // 値を指してしまう (見本どうしが互いの値を読む形になる)。
2527
+ const ownIdSet = new Set([
2528
+ ...part.states.map((s) => s.id),
2529
+ ...(part.derived ?? []).map((d) => d.id),
2530
+ ]);
1675
2531
  const rewriteTemplate = (s: string | undefined): string | undefined => {
1676
2532
  if (!s) return s;
1677
- return s.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (m, name: string) => {
1678
- return stateIdSet.has(name) ? `{${prefix(name)}}` : m;
2533
+ return s.replace(/\{(\w+)\}/g, (m, name: string) => {
2534
+ return ownIdSet.has(name) ? `{${valuePrefix(name)}}` : m;
1679
2535
  });
1680
2536
  };
2537
+ // 値の式は見本の名前空間の中で閉じる。 見本が持つ名前だけを書き換えると、綴り違いの参照が
2538
+ // 取り込み先の同名の値に偶然つながり、単体では止まる見本の意味が置いた場所で変わる。
2539
+ //
2540
+ // **どれが参照かは engine に決めさせる**。 engine は `{v}` と裸の `v` の両方を参照として
2541
+ // 読み、関数名 (`min` / `Math.max` 等) は参照に数えない (実測)。 ここで関数の一覧を持つと
2542
+ // 記法側 (`value-syntax.ts`) と engine に続く 3 つ目の写しになり、engine が関数を足した時に
2543
+ // 静かにずれる。
2544
+ const rewriteDerivedExpression = (expression: string): string => {
2545
+ try {
2546
+ return writeFormula(renameFormulaIdentifiers(parseFormula(expression), valuePrefix));
2547
+ } catch {
2548
+ // 読めない式は engine が止めて伝える (`value-unresolved`)。 書き換えられないので
2549
+ // そのまま載せる = 名前は前置き無しのままだが、式自体が解けないため値は出ない
2550
+ return expression;
2551
+ }
2552
+ };
1681
2553
 
1682
2554
  // 決定的 lane 参照 = user が書いた lane 指定を優先、 なければ parts 内部 lane を prefix 付きで作る
1683
2555
  const targetLaneId = laneMapping;
@@ -1886,7 +2758,22 @@ function mergePartIntoDiagram(
1886
2758
  hint: "色は `#ff0000` のような色番号か、 `red` のような色名で書く",
1887
2759
  });
1888
2760
  }
1889
- target.states.push({ id: prefix(stateOrig.id), initial });
2761
+ target.states.push({ id: valuePrefix(stateOrig.id), initial });
2762
+ }
2763
+
2764
+ // 見本が持つ「他の値から決まる値」 を引き継ぐ (#1180)。
2765
+ //
2766
+ // 引き継がないと、見本の中で書いた関係が重ねた先で解かれず、その値を読む箱に `{名前}` の
2767
+ // 生の形が出る。 名前は状態と同じ規則で前置きを付ける = 見本を 2 つ重ねても互いの値を
2768
+ // 読まない。 式の中の参照は、未定義の名前も含めて見本の名前空間へ閉じ込める。
2769
+ for (const derivedOrig of part.derived ?? []) {
2770
+ if (!target.derived) target.derived = [];
2771
+ const id = valuePrefix(derivedOrig.id);
2772
+ target.derived.push({
2773
+ id,
2774
+ expression: rewriteDerivedExpression(derivedOrig.expression),
2775
+ });
2776
+ recordDerivedSourceLine(derivedSourceLines, id, noticeLine);
1890
2777
  }
1891
2778
 
1892
2779
  // edge merge = id / from / to prefix (parts 内 edge は稀だが対応)
@@ -1928,8 +2815,8 @@ function mergePartIntoDiagram(
1928
2815
  ...phaseOrig,
1929
2816
  id: prefix(phaseOrig.id),
1930
2817
  activate: phaseOrig.activate.map(prefix),
1931
- tweens: phaseOrig.tweens.map((t) => ({ ...t, stateId: prefix(t.stateId) })),
1932
- sets: phaseOrig.sets.map((s) => ({ ...s, stateId: prefix(s.stateId) })),
2818
+ tweens: phaseOrig.tweens.map((t) => ({ ...t, stateId: valuePrefix(t.stateId) })),
2819
+ sets: phaseOrig.sets.map((s) => ({ ...s, stateId: valuePrefix(s.stateId) })),
1933
2820
  });
1934
2821
  }
1935
2822
  } else {
@@ -1943,8 +2830,8 @@ function mergePartIntoDiagram(
1943
2830
  const partPhase = part.phases[i]!;
1944
2831
  targetPhase.duration = Math.max(targetPhase.duration, partPhase.duration);
1945
2832
  targetPhase.activate = [...targetPhase.activate, ...partPhase.activate.map(prefix)];
1946
- targetPhase.tweens = [...targetPhase.tweens, ...partPhase.tweens.map((t) => ({ ...t, stateId: prefix(t.stateId) }))];
1947
- targetPhase.sets = [...targetPhase.sets, ...partPhase.sets.map((s) => ({ ...s, stateId: prefix(s.stateId) }))];
2833
+ targetPhase.tweens = [...targetPhase.tweens, ...partPhase.tweens.map((t) => ({ ...t, stateId: valuePrefix(t.stateId) }))];
2834
+ targetPhase.sets = [...targetPhase.sets, ...partPhase.sets.map((s) => ({ ...s, stateId: valuePrefix(s.stateId) }))];
1948
2835
  }
1949
2836
  // parts phase 余剰は append (target より parts が長い場合)
1950
2837
  for (let i = commonLen; i < partsLen; i++) {
@@ -1953,8 +2840,8 @@ function mergePartIntoDiagram(
1953
2840
  ...phaseOrig,
1954
2841
  id: prefix(phaseOrig.id),
1955
2842
  activate: phaseOrig.activate.map(prefix),
1956
- tweens: phaseOrig.tweens.map((t) => ({ ...t, stateId: prefix(t.stateId) })),
1957
- sets: phaseOrig.sets.map((s) => ({ ...s, stateId: prefix(s.stateId) })),
2843
+ tweens: phaseOrig.tweens.map((t) => ({ ...t, stateId: valuePrefix(t.stateId) })),
2844
+ sets: phaseOrig.sets.map((s) => ({ ...s, stateId: valuePrefix(s.stateId) })),
1958
2845
  });
1959
2846
  }
1960
2847
  }
@@ -1985,7 +2872,7 @@ function deepRewriteStrings(
1985
2872
  }
1986
2873
 
1987
2874
  /**
1988
- * v0.5+ flow inline option (guard / cardinality / labelOffsetX / labelOffsetY) を
2875
+ * v0.5+ flow inline option (guard / cardinality / labelOffsetX / labelOffsetY / overlay) を
1989
2876
  * 既存 preset 経由で生成された CdlEdge に対し、 doc.flow の (from, to) 一致順マッチングで反映する。
1990
2877
  *
1991
2878
  * 設計:
@@ -2002,6 +2889,16 @@ function applyEdgeInlineOptions(
2002
2889
  /** 対応が取れた edge を記録する表。 callback は呼ばない (1 edge = 1 回にするため)。 */
2003
2890
  sourceLines?: Map<string, number>,
2004
2891
  ): void {
2892
+ // **静止した `type: flow` は書いた端で対応が取れない** (#1267)。 鎖の規則で先に埋める
2893
+ if (鎖でつなぐ形か(doc)) {
2894
+ diagram.edges.forEach((e, idx) => {
2895
+ const s = 鎖のどの行から来たか(doc, idx);
2896
+ if (s === undefined) return;
2897
+ sourceLines?.set(e.id, s.pos.line);
2898
+ 矢印へ書き写す(e, s, doc);
2899
+ });
2900
+ return;
2901
+ }
2005
2902
  const used = new Set<string>();
2006
2903
  // sequence preset では actor 名 が lane id、 edge.from は `s{stepIdx}-{laneId}` 形式。
2007
2904
  // solidity は sorted-actor を sequence preset 経由するため sequence と同形。
@@ -2025,28 +2922,65 @@ function applyEdgeInlineOptions(
2025
2922
  if (!target) return;
2026
2923
  used.add(target.id);
2027
2924
  sourceLines?.set(target.id, s.pos.line);
2028
- if (s.guard !== undefined) {
2029
- target.guard = s.guard;
2030
- // FSM preset では sub が guard 同期、 author 明示 guard を sub に反映 (sub 既存なら上書きしない)
2031
- if (doc.type === "state" && target.sub === undefined) target.sub = s.guard;
2032
- }
2033
- if (s.cardinality !== undefined) {
2034
- target.cardinality = s.cardinality;
2035
- // ER preset の場合 label に "(1:N)" 形式で併記 (既に含まれていればスキップ)
2036
- if (doc.type === "er" && !target.label.includes(s.cardinality)) {
2037
- target.label = target.label
2038
- ? `${target.label} (${s.cardinality})`
2039
- : `(${s.cardinality})`;
2040
- }
2041
- }
2042
- if (s.labelOffsetX !== undefined) target.labelOffsetX = s.labelOffsetX;
2043
- if (s.labelOffsetY !== undefined) target.labelOffsetY = s.labelOffsetY;
2925
+ 矢印へ書き写す(target, s, doc);
2044
2926
  });
2045
2927
  }
2046
2928
 
2047
- /**
2048
- * 描画側が大きさを持つ種別。
2049
- *
2929
+ /** 本文に書いた矢印の指定を、対応が取れた矢印へ書き写す。 対応の取り方は呼出側が決める。 */
2930
+ function 矢印へ書き写す(target: CdlEdge, s: DslStep, doc: DslDocument): void {
2931
+ // **書いた補足が勝つ** (#1275)。 ここで写さないと 2 つ落ちる。 静止した `type: flow` は
2932
+ // 鎖を作る時に説明文しか渡さないため補足が消え、`er` は見本が多重度から作った補足が
2933
+ // 残って書いた値が無視される (どちらも実測)
2934
+ if (s.sub !== undefined) target.sub = s.sub;
2935
+ if (s.guard !== undefined) {
2936
+ target.guard = s.guard;
2937
+ // FSM preset では sub が guard 同期、 author 明示 guard を sub に反映 (sub 既存なら上書きしない)
2938
+ if (doc.type === "state" && target.sub === undefined) target.sub = s.guard;
2939
+ }
2940
+ if (s.cardinality !== undefined) {
2941
+ target.cardinality = s.cardinality;
2942
+ // ER preset の場合 label に "(1:N)" 形式で併記 (既に含まれていればスキップ)
2943
+ if (doc.type === "er" && !target.label.includes(s.cardinality)) {
2944
+ target.label = target.label
2945
+ ? `${target.label} (${s.cardinality})`
2946
+ : `(${s.cardinality})`;
2947
+ }
2948
+ }
2949
+ if (s.labelOffsetX !== undefined) target.labelOffsetX = s.labelOffsetX;
2950
+ if (s.labelOffsetY !== undefined) target.labelOffsetY = s.labelOffsetY;
2951
+ if (s.overlay !== undefined) target.overlay = s.overlay;
2952
+ }
2953
+
2954
+ /**
2955
+ * 静止した `type: flow` かどうか。 この形だけ矢印を鎖状に作る (`compileFlow`)。
2956
+ *
2957
+ * 段を持つ形と縦列を書いた形は generic 経路へ回るため鎖にならない。 判定を 1 か所に
2958
+ * 集めるのは、`compileFlow` の分岐と食い違うと対応の取り方だけがずれるため。
2959
+ */
2960
+ function 鎖でつなぐ形か(doc: DslDocument): boolean {
2961
+ if (doc.type !== "flow") return false;
2962
+ if (doc.animate && doc.animate.phases.length > 0) return false;
2963
+ return !書いた縦列に置く("flow", doc);
2964
+ }
2965
+
2966
+ /**
2967
+ * 鎖の N 本目の矢印が、本文のどの行から来たかを返す (#1267)。
2968
+ *
2969
+ * `compileFlow` は登場人物を書いた順に繋ぎ、説明文は **その箱を to に持つ行** から拾う。
2970
+ * 書いた側の端 (from) は使わない。 そのため `A -> C` と書いても矢印は `A -> B` になり、
2971
+ * (from, to) の一致では対応が取れない (実測 = 説明文だけが載り、指定が黙って落ちていた)。
2972
+ *
2973
+ * 説明文を決めた規則と同じ規則で指定も決める = 説明文と指定が必ず同じ行から来る。
2974
+ */
2975
+ function 鎖のどの行から来たか(doc: DslDocument, edgeIndex: number): DslStep | undefined {
2976
+ const to = doc.actors[edgeIndex + 1];
2977
+ if (to === undefined) return undefined;
2978
+ return doc.flow.find((s) => s.to === to.name);
2979
+ }
2980
+
2981
+ /**
2982
+ * 描画側が大きさを持つ種別。
2983
+ *
2050
2984
  * 記法の `kind` は描画の種別より広い。 そのまま渡すと大きさを引けずに描画が落ちる
2051
2985
  * (実測 = solidity の golden 4 件が `Cannot read properties of undefined`)。
2052
2986
  */
@@ -2072,16 +3006,505 @@ const KIND_ALIAS: Readonly<Record<string, string>> = {
2072
3006
  interface: "shape-code-block",
2073
3007
  };
2074
3008
 
3009
+ /**
3010
+ * 記法の `values:` を図に載せる (#1162)。
3011
+ *
3012
+ * `values` は「他の値から自動で決まる値」 で、 時間を持たない。 参照した値が動けば常に
3013
+ * 追随する。 解くのは描画側 (`@cardenelabs/cdl` の `applyDerivedValues`) で、 段の値を出した
3014
+ * 後に参照順で解いて `stateValues` に載せる。 **毎 frame ここを通る**ので、 掛け算や比較の
3015
+ * ように端点 2 点では表せない関係も段の補間の途中で正しい値になる。
3016
+ *
3017
+ * ここは載せるだけで、 式は評価しない。 評価を compile 時に畳むと段の補間中に決まり直せない。
3018
+ *
3019
+ * **出口で 1 度だけ載せる**。 図の種類は 18 あり、 経路ごとに書くとどれかを見落とす
3020
+ * (`injectStaticPhase` と同じ理由)。
3021
+ *
3022
+ * 名前が `states` と重なった場合は `values` を優先し、 重なったことを伝える。 spec の
3023
+ * 4 節で決めた挙動で、 黙って一方を捨てると「書いたのに効かない」 が残る。
3024
+ */
3025
+ /** 数を式に埋める。 指数表記 (`1e-7`) は engine の式が読めないため十進で書く */
3026
+ function 式に書く数(n: number): string {
3027
+ if (!Number.isFinite(n)) return "0";
3028
+ const text = String(n);
3029
+ if (!/[eE]/.test(text)) return text;
3030
+
3031
+ // Number の有効桁を丸めず、指数表記だけを通常の十進表記へ展開する。 `toFixed(6)` では
3032
+ // 1e-7 が 0 になり、正しく読める `to` の値が動かなくなる。
3033
+ const [coefficient = "0", exponentText = "0"] = text.toLowerCase().split("e");
3034
+ const negative = coefficient.startsWith("-");
3035
+ const unsigned = negative ? coefficient.slice(1) : coefficient;
3036
+ const [whole = "0", fraction = ""] = unsigned.split(".");
3037
+ const digits = `${whole}${fraction}`;
3038
+ const decimalAt = whole.length + Number(exponentText);
3039
+ let expanded: string;
3040
+ if (decimalAt <= 0) expanded = `0.${"0".repeat(-decimalAt)}${digits}`;
3041
+ else if (decimalAt >= digits.length) expanded = `${digits}${"0".repeat(decimalAt - digits.length)}`;
3042
+ else expanded = `${digits.slice(0, decimalAt)}.${digits.slice(decimalAt)}`;
3043
+ return negative ? `-${expanded}` : expanded;
3044
+ }
3045
+
3046
+ /** 段の中で 1 本の値が動く区間。 `from` から `to` へ `[start, end]` の間で線形に動く */
3047
+ type 動く区間 = { 段: number; start: number; end: number; dur: number; from: number; to: number };
3048
+
3049
+ /**
3050
+ * 相手の値が境目を通る時刻を求める。
3051
+ *
3052
+ * 相手は段の中で線形に動くので、 境目を通る時刻は逆算できる。 始めから成り立っているなら
3053
+ * 相手が動き始めた時刻、 終わりまで成り立たないなら「通らない」 とする。
3054
+ *
3055
+ * `>` と `!=` の厳密な瞬間は境目の直後だが、 1 frame 未満の差なので境目そのものを返す。
3056
+ */
3057
+ function 境目を通る時刻(区間: 動く区間, op: string, 境目: number): number | null {
3058
+ const 満たす = (x: number): boolean => {
3059
+ switch (op) {
3060
+ case ">=": return x >= 境目;
3061
+ case ">": return x > 境目;
3062
+ case "<=": return x <= 境目;
3063
+ case "<": return x < 境目;
3064
+ case "==": return x === 境目;
3065
+ case "!=": return x !== 境目;
3066
+ default: return false;
3067
+ }
3068
+ };
3069
+ if (満たす(区間.from)) return 区間.start;
3070
+ // `==` は終点が一致しなくても、線形補間の途中で境目を通る。 終点だけを見ると
3071
+ // 0 → 100 に対する `== 50` を「満たさない」と誤判定する。
3072
+ if (op === "==") {
3073
+ const min = Math.min(区間.from, 区間.to);
3074
+ const max = Math.max(区間.from, 区間.to);
3075
+ if (境目 < min || 境目 > max) return null;
3076
+ } else if (!満たす(区間.to)) {
3077
+ return null;
3078
+ }
3079
+ if (区間.to === 区間.from) return null;
3080
+ const t = 区間.start + (区間.dur * (境目 - 区間.from)) / (区間.to - 区間.from);
3081
+ if (!Number.isFinite(t)) return null;
3082
+ return Math.min(Math.max(t, 区間.start), 区間.end);
3083
+ }
3084
+
3085
+ /**
3086
+ * きっかけ形の値 (`trigger` / `to` / `dur`) を、段の時計を読む式へ畳む (#1161 段 2)。
3087
+ *
3088
+ * ## なぜ式へ畳むのか
3089
+ *
3090
+ * 描画側の動きの模型は段と、 段の中の線形補間しか持たない。 条件で動き出す仕組みも、 値ごとの
3091
+ * 長さも無い (実測)。 そのままでは `trigger` も `dur` も渡せない。
3092
+ *
3093
+ * 一方で描画側は `derived` の式を **毎 frame** 解く。 そこで段に時計を 1 本引き
3094
+ * (`0` から段の長さまでの補間)、 各値を「時計を読む傾斜」 として書けば、 段を割らずに
3095
+ * 値ごとの長さを守れる。
3096
+ *
3097
+ * ```
3098
+ * 値 = from + (to - from) * min(max((時計 - 開始) / 長さ, 0), 1)
3099
+ * ```
3100
+ *
3101
+ * `min` / `max` で挟むのは、 開始前は `from` のまま、 終了後は `to` のまま止めるため。
3102
+ *
3103
+ * ## 段を割らない
3104
+ *
3105
+ * 段は見出し / 本文 / 印を持つ表示物なので、 割ると段送りの見え方と件数が変わる。 時計を使えば
3106
+ * 段は 1 つのまま値だけが順に動く。 収まらない形 (開始 + 長さ > 段の長さ) は畳まずに知らせる。
3107
+ *
3108
+ * ## 連鎖の解き方
3109
+ *
3110
+ * `trigger: <相手> >= <境目>` は、 相手も段の中で線形に動くため境目を通る時刻を逆算できる。
3111
+ * 相手が動く値でない (式だけ、 または初期値のまま) 場合は時刻が決まらないので畳まない。
3112
+ *
3113
+ * 返すのは名前から式への表で、 `attachDerivedValues` がこれを `derived` に載せる。 畳めなかった
3114
+ * 値は表に入らないため図に載らない = 半端に止まった値を黙って置かない。
3115
+ */
3116
+ function foldValueTriggers(
3117
+ diagram: CdlDiagram,
3118
+ doc: DslDocument,
3119
+ onNotice?: (n: CompileNotice) => void,
3120
+ ): Map<string, string> {
3121
+ const 出力 = new Map<string, string>();
3122
+ const きっかけ付き = (doc.values ?? []).filter((v) => v.trigger !== undefined);
3123
+ if (きっかけ付き.length === 0) return 出力;
3124
+
3125
+ // 同じ名前を 2 度書いた時は先に書いた方を使う (`values` の既存の扱いと揃える)
3126
+ const 宣言 = new Map<string, DslValue>();
3127
+ for (const v of きっかけ付き) if (!宣言.has(v.name)) 宣言.set(v.name, v);
3128
+
3129
+ const 初期値 = new Map<string, number>();
3130
+ for (const s of diagram.states) {
3131
+ const n = Number(s.initial);
3132
+ if (Number.isFinite(n)) 初期値.set(s.id, n);
3133
+ }
3134
+
3135
+ // 段は書いた名前でも slug でも指せる。 `focus:` が名前で指せるのと揃える
3136
+ const 段の番号 = new Map<string, number>();
3137
+ diagram.phases.forEach((p, i) => {
3138
+ if (!段の番号.has(p.title)) 段の番号.set(p.title, i);
3139
+ if (!段の番号.has(p.id)) 段の番号.set(p.id, i);
3140
+ });
3141
+
3142
+ const 解けた = new Map<string, 動く区間>();
3143
+ const 解けない = new Set<string>();
3144
+ const 解決中 = new Set<string>();
3145
+
3146
+ const 知らせる = (v: DslValue, message: string, hint: string): void => {
3147
+ onNotice?.({ kind: "value-trigger-unresolved", actor: v.name, line: v.pos?.line ?? 0, message, hint });
3148
+ };
3149
+
3150
+ const 解く = (name: string): 動く区間 | null => {
3151
+ const 既出 = 解けた.get(name);
3152
+ if (既出) return 既出;
3153
+ if (解けない.has(name)) return null;
3154
+ const v = 宣言.get(name);
3155
+ if (!v || !v.trigger) return null;
3156
+ if (解決中.has(name)) {
3157
+ 解けない.add(name);
3158
+ 知らせる(v, `"${name}" のきっかけが一周しています`, "どれか 1 つを `trigger: step ...` に変える");
3159
+ return null;
3160
+ }
3161
+ 解決中.add(name);
3162
+ const 区間 = 組み立てる(v);
3163
+ 解決中.delete(name);
3164
+ if (!区間) {
3165
+ 解けない.add(name);
3166
+ return null;
3167
+ }
3168
+ 解けた.set(name, 区間);
3169
+ return 区間;
3170
+ };
3171
+
3172
+ const 組み立てる = (v: DslValue): 動く区間 | null => {
3173
+ const trigger = v.trigger!;
3174
+ const from = 初期値.get(v.name) ?? 0;
3175
+ const to = v.to ?? 0;
3176
+ const dur = v.durationMs ?? 0;
3177
+ let 段 = 0;
3178
+ let start = 0;
3179
+ if (trigger.kind === "step") {
3180
+ const idx = 段の番号.get(trigger.step);
3181
+ if (idx === undefined) {
3182
+ 知らせる(v, `"${trigger.step}" という段がありません`, "`animation:` にその名前の段を書くか、 段の名前に合わせる");
3183
+ return null;
3184
+ }
3185
+ 段 = idx;
3186
+ start = 0;
3187
+ } else {
3188
+ const 相手 = 解く(trigger.source);
3189
+ if (!相手) {
3190
+ 知らせる(
3191
+ v,
3192
+ `"${trigger.source}" が動く値でないため、 きっかけの時刻を決められません`,
3193
+ "見張る相手も `trigger:` を持つ値にする",
3194
+ );
3195
+ return null;
3196
+ }
3197
+ const at = 境目を通る時刻(相手, trigger.op, trigger.threshold);
3198
+ if (at === null) {
3199
+ 知らせる(
3200
+ v,
3201
+ `"${trigger.source}" は ${trigger.op} ${trigger.threshold} を満たしません`,
3202
+ "相手が通る値を境目にするか、 相手の `to` を見直す",
3203
+ );
3204
+ return null;
3205
+ }
3206
+ 段 = 相手.段;
3207
+ start = at;
3208
+ }
3209
+ const 段の長さ = diagram.phases[段]?.duration ?? 0;
3210
+ if (start + dur > 段の長さ) {
3211
+ 知らせる(
3212
+ v,
3213
+ `段 "${diagram.phases[段]?.title ?? ""}" (${段の長さ}ms) に収まりません (${Math.round(start + dur)}ms 必要)`,
3214
+ "段を長くするか `dur` を短くする",
3215
+ );
3216
+ return null;
3217
+ }
3218
+ return { 段, start, end: start + dur, dur, from, to };
3219
+ };
3220
+
3221
+ for (const v of 宣言.values()) 解く(v.name);
3222
+ if (解けた.size === 0) return 出力;
3223
+
3224
+ // 時計は段ごとに 1 本。 名前が既にある時は末尾に数を足してずらす = 書いた値を上書きしない
3225
+ const 使用中 = new Set(diagram.states.map((s) => s.id));
3226
+ for (const v of doc.values ?? []) 使用中.add(v.name);
3227
+ const 段ごとの時計 = new Map<number, string>();
3228
+ const 時計を用意する = (段: number): string => {
3229
+ const 既出 = 段ごとの時計.get(段);
3230
+ if (既出) return 既出;
3231
+ let 名前 = `__step_clock_${段}`;
3232
+ let 連番 = 2;
3233
+ while (使用中.has(名前)) 名前 = `__step_clock_${段}_${連番++}`;
3234
+ 使用中.add(名前);
3235
+ 段ごとの時計.set(段, 名前);
3236
+ diagram.states.push({ id: 名前, initial: 0 });
3237
+ const 段の中身 = diagram.phases[段];
3238
+ if (段の中身) 段の中身.tweens = [...段の中身.tweens, { stateId: 名前, from: 0, to: 段の中身.duration }];
3239
+ return 名前;
3240
+ };
3241
+
3242
+ for (const [name, 区間] of 解けた) {
3243
+ const 時計 = 時計を用意する(区間.段);
3244
+ const 進み = `min(max(({${時計}} - ${式に書く数(区間.start)}) / ${式に書く数(区間.dur)}, 0), 1)`;
3245
+ 出力.set(name, `((${進み} * ${式に書く数(区間.to - 区間.from)}) + ${式に書く数(区間.from)})`);
3246
+ }
3247
+ return 出力;
3248
+ }
3249
+
3250
+ function attachDerivedValues(
3251
+ diagram: CdlDiagram,
3252
+ doc: DslDocument,
3253
+ onNotice?: (n: CompileNotice) => void,
3254
+ inheritedSourceLines?: ReadonlyMap<string, readonly number[]>,
3255
+ foldedTriggers?: ReadonlyMap<string, string>,
3256
+ ): void {
3257
+ const values = doc.values ?? [];
3258
+ // 本文に値を書いていなくても、重ねた見本が値を持つことがある (#1180)。 その場合も
3259
+ // 解けなかった分は伝える = 見本の中で止まった値も、画面には `{名前}` の生の形で出る
3260
+ if (values.length === 0) {
3261
+ if ((diagram.derived?.length ?? 0) > 0) {
3262
+ reportUnresolvedValues(diagram, doc, onNotice, inheritedSourceLines);
3263
+ }
3264
+ return;
3265
+ }
3266
+
3267
+ // 名前が重なったかは **図に載った状態** で見る。 書いた `states:` だけを見ると、見本から
3268
+ // 引き継いだ状態 (`alias__id`) との重なりを見落とす
3269
+ const 状態の名前 = new Set(diagram.states.map((s) => s.id));
3270
+ for (const v of values) {
3271
+ if (!状態の名前.has(v.name)) continue;
3272
+ // きっかけ形は `states:` の値を **動き始めの値として使う**。 両方書くのが正しい形なので
3273
+ // 重なりとして知らせない (#1161)。 知らせると、 仕様どおりに書いた図が毎回警告を出す
3274
+ if (v.trigger !== undefined) continue;
3275
+ onNotice?.({
3276
+ kind: "value-shadows-state",
3277
+ actor: v.name,
3278
+ line: v.pos?.line ?? 0,
3279
+ message: `"${v.name}" を states と values の両方に書いています。 values を使います`,
3280
+ hint: "states から外すか、 values の名前を変える",
3281
+ });
3282
+ }
3283
+
3284
+ // **見本から引き継いだ分に足す** (#1180)。 代入で書くと、重ねた見本が持つ値が消える。
3285
+ //
3286
+ // 本文に書いた分を先に置く = engine は同じ名前では先に書いた式を使うため、名前が重なった
3287
+ // 時に本文が勝つ。 重なったことは engine の知らせ (`duplicate-id`) がそのまま伝える
3288
+ // きっかけ形は `foldValueTriggers` が畳んだ式を使う。 畳めなかった値はここに現れないため
3289
+ // 図に載らない = 半端に止まった値を黙って置かない (知らせは畳む時点で出している)
3290
+ const 載せる: Array<{ id: string; expression: string }> = [];
3291
+ for (const v of values) {
3292
+ const expression = v.expression ?? foldedTriggers?.get(v.name);
3293
+ if (expression === undefined) continue;
3294
+ 載せる.push({ id: v.name, expression });
3295
+ }
3296
+ diagram.derived = [...載せる, ...(diagram.derived ?? [])];
3297
+ reportUnresolvedValues(diagram, doc, onNotice, inheritedSourceLines);
3298
+ }
3299
+
3300
+ /**
3301
+ * 式の中の名前を付け替える (#1180)。
3302
+ *
3303
+ * **字句ではなく木を経由する**。 engine の式は `{v}` / 裸の `v` / 数字始まり / `$` 入りと
3304
+ * 参照の書き方が複数あり、正規表現で追うと書き方が 1 つ増えるたびに漏れる (review が 3 round
3305
+ * 続けて別の漏れを見つけた)。 木は識別子をそのまま持つので、字句を網羅しなくてよい。
3306
+ *
3307
+ * 関数呼び出し (`min` / `Math.max`) は木の上で別の種類なので、名前と取り違えない。
3308
+ */
3309
+ function renameFormulaIdentifiers(ast: FormulaAst, rename: (name: string) => string): FormulaAst {
3310
+ switch (ast.type) {
3311
+ case "number":
3312
+ return ast;
3313
+ case "identifier":
3314
+ return { type: "identifier", name: rename(ast.name) };
3315
+ case "unaryOp":
3316
+ return { ...ast, operand: renameFormulaIdentifiers(ast.operand, rename) };
3317
+ case "binaryOp":
3318
+ return {
3319
+ ...ast,
3320
+ left: renameFormulaIdentifiers(ast.left, rename),
3321
+ right: renameFormulaIdentifiers(ast.right, rename),
3322
+ };
3323
+ case "ternary":
3324
+ return {
3325
+ type: "ternary",
3326
+ condition: renameFormulaIdentifiers(ast.condition, rename),
3327
+ whenTrue: renameFormulaIdentifiers(ast.whenTrue, rename),
3328
+ whenFalse: renameFormulaIdentifiers(ast.whenFalse, rename),
3329
+ };
3330
+ case "call":
3331
+ return { ...ast, args: ast.args.map((a) => renameFormulaIdentifiers(a, rename)) };
3332
+ }
3333
+ }
3334
+
3335
+ /**
3336
+ * 数を、engine の読み手が受け付ける形で書く (#1180)。
3337
+ *
3338
+ * **指数表記を出さない**。 `0.0000001` は JavaScript の既定では `"1e-7"` になるが、engine の
3339
+ * 読み手は指数表記を読めない (実測 = `unexpected token after expression`)。 そのまま書くと、
3340
+ * 元は解けていた式が書き換えた後だけ止まる。
3341
+ *
3342
+ * 展開は桁をずらすだけで、丸めない。 `String` が返す最短の形をそのまま使うため、値は変わらない。
3343
+ * 有限でない数は書けないので投げる (呼出側が元の式のまま載せる)。
3344
+ */
3345
+ function writeNumber(value: number): string {
3346
+ if (!Number.isFinite(value)) throw new Error(`cannot write non-finite number: ${String(value)}`);
3347
+ const s = String(value);
3348
+ if (!/[eE]/.test(s)) return s;
3349
+ const m = /^(-?)(\d+)(?:\.(\d+))?[eE]([+-]?\d+)$/.exec(s);
3350
+ if (!m) throw new Error(`cannot write number: ${s}`);
3351
+ const sign = m[1] ?? "";
3352
+ const int = m[2] ?? "";
3353
+ const frac = m[3] ?? "";
3354
+ const digits = int + frac;
3355
+ // 小数点の位置。 元の整数部の桁数を指数のぶんだけずらす
3356
+ const point = int.length + Number(m[4] ?? "0");
3357
+ if (point <= 0) return `${sign}0.${"0".repeat(-point)}${digits}`;
3358
+ if (point >= digits.length) return `${sign}${digits}${"0".repeat(point - digits.length)}`;
3359
+ return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`;
3360
+ }
3361
+
3362
+ /**
3363
+ * 式の木を文字列へ戻す (#1180)。
3364
+ *
3365
+ * **括弧を全て付ける**。 演算子の優先順位を再現しようとすると engine の表を写すことになり、
3366
+ * 表がずれた時に式の意味が静かに変わる。 括弧が増えても解いた結果は変わらない。
3367
+ */
3368
+ function writeFormula(ast: FormulaAst): string {
3369
+ switch (ast.type) {
3370
+ case "number":
3371
+ return writeNumber(ast.value);
3372
+ case "identifier":
3373
+ // **裸で書ける形とそうでない形がある**。 engine は裸の名前を `[A-Za-z_$][\w$]*` で読む
3374
+ // 一方、波括弧の中は `\w+` なので数字始まりの名前は波括弧付きでしか書けない。
3375
+ // 名前は前置きで変わる (`1p__v` のように数字始まりになりうる) ため、書ける方を選ぶ
3376
+ return /^[A-Za-z_$][\w$]*$/.test(ast.name) ? ast.name : `{${ast.name}}`;
3377
+ case "unaryOp":
3378
+ // 空白は挟まない。 読み手は負の数を字面として持たず (`-5` は単項 `-` と `5` の木になる)、
3379
+ // `--5` も単項の 2 段として読む (実測)。 挟んでも挟まなくても意味が同じなので足さない
3380
+ return `(${ast.op}${writeFormula(ast.operand)})`;
3381
+ case "binaryOp":
3382
+ return `(${writeFormula(ast.left)} ${ast.op} ${writeFormula(ast.right)})`;
3383
+ case "ternary":
3384
+ return `(${writeFormula(ast.condition)} ? ${writeFormula(ast.whenTrue)} : ${writeFormula(ast.whenFalse)})`;
3385
+ case "call":
3386
+ return `${ast.fn}(${ast.args.map(writeFormula).join(", ")})`;
3387
+ }
3388
+ }
3389
+
3390
+ /** `derived` の同名宣言を、engine が読む順のまま行番号の列として残す。 */
3391
+ function recordDerivedSourceLine(
3392
+ sourceLines: Map<string, number[]> | undefined,
3393
+ id: string,
3394
+ line: number,
3395
+ ): void {
3396
+ if (!sourceLines) return;
3397
+ const lines = sourceLines.get(id) ?? [];
3398
+ lines.push(line);
3399
+ sourceLines.set(id, lines);
3400
+ }
3401
+
3402
+ /**
3403
+ * 解けなかった値を書いた人に伝える (#1162)。
3404
+ *
3405
+ * 描画側は解けない値を黙って飛ばす (`computeStateValues` が engine の知らせを捨てている)。
3406
+ * 書き間違えても図は描かれ、箱に `{waiting}` の生の形が出るだけになる。 綴りを疑う以外に
3407
+ * 手掛かりが無いので、組み立ての時点で分かる分をここで伝える。
3408
+ *
3409
+ * **判定は engine にさせる**。 解く順序と、止める条件 (輪 / 無い名前 / 読めない式 / 数として
3410
+ * 読めない値) は engine が持つ。 同じ判定を書き直すと、描画は動くのに知らせだけ出る
3411
+ * (またはその逆) 状態を作る。
3412
+ *
3413
+ * 見るのは初期値 1 組だけ。 輪 / 無い名前 / 読めない式 / 二重宣言は値に依らないのでこれで
3414
+ * 全て取れる。 段の途中でだけ起きる形 (割る数が段の途中で 0 になる等) は取れない =
3415
+ * 毎 frame の知らせは engine 側が返し口を持たないため、ここでは扱わない。
3416
+ */
3417
+ function reportUnresolvedValues(
3418
+ diagram: CdlDiagram,
3419
+ doc: DslDocument,
3420
+ onNotice?: (n: CompileNotice) => void,
3421
+ inheritedSourceLines?: ReadonlyMap<string, readonly number[]>,
3422
+ ): void {
3423
+ if (!onNotice) return;
3424
+ // 描画側 (`computeStateValues`) が段を進める前に組み立てるのと同じ形。 値を解く手順は
3425
+ // engine に渡すので、ここで組み立てるのは初期値の表だけにする
3426
+ //
3427
+ // **継承を持たない入れ物で作る**。 engine 側 (`computeStateValues` /
3428
+ // `applyDerivedValues`) が同じ形で組むため、ここを通常の object にすると
3429
+ // `__proto__` のような名前で **組み立てだけが「値が無い」 と知らせる** 状態ができる
3430
+ // (実測 = 描画は `a = 5` を出すのに、知らせは `value-unresolved` を出していた)。
3431
+ //
3432
+ // engine が `{}` で組んでいた頃はここも `{}` で揃えていた。 cdl 側が継承なしに
3433
+ // 揃えた (cdl#456 / cdl#490) ので、こちらも合わせる。 **揃っていることが要点**で、
3434
+ // どちらの形にするかは engine が決める
3435
+ const 初期値: Record<string, string> = Object.create(null) as Record<string, string>;
3436
+ for (const s of diagram.states) 初期値[s.id] = String(s.initial);
3437
+
3438
+ // engine は同じ名前では先に書いた式を使う。 Map の一括生成で後ろから
3439
+ // 上書きすると、先の式の未解決を後の行の問題として伝えてしまう
3440
+ const 最初の行 = new Map<string, number>();
3441
+ const 重複した行 = new Map<string, number[]>();
3442
+ for (const v of doc.values ?? []) {
3443
+ if (!最初の行.has(v.name)) {
3444
+ 最初の行.set(v.name, v.pos?.line ?? 0);
3445
+ continue;
3446
+ }
3447
+ const 同じ名前の行 = 重複した行.get(v.name) ?? [];
3448
+ 同じ名前の行.push(v.pos?.line ?? 0);
3449
+ 重複した行.set(v.name, 同じ名前の行);
3450
+ }
3451
+ // 本文の値は `attachDerivedValues` が先頭へ置き、見本から引き継いだ値はその後ろに残る。
3452
+ // 同じ順で行を足すことで、duplicate-id を「後から書かれた宣言」へ正確に戻す。
3453
+ for (const [id, lines] of inheritedSourceLines ?? []) {
3454
+ for (const line of lines) {
3455
+ if (!最初の行.has(id)) {
3456
+ 最初の行.set(id, line);
3457
+ continue;
3458
+ }
3459
+ const 同じ名前の行 = 重複した行.get(id) ?? [];
3460
+ 同じ名前の行.push(line);
3461
+ 重複した行.set(id, 同じ名前の行);
3462
+ }
3463
+ }
3464
+ for (const n of applyDerivedValues(初期値, diagram.derived).notices) {
3465
+ onNotice({
3466
+ kind: n.kind === "duplicate-id" ? "value-duplicate" : "value-unresolved",
3467
+ actor: n.id,
3468
+ // 重複は後から書いた宣言そのものを、式の問題は engine が使う最初の宣言を指す
3469
+ line:
3470
+ n.kind === "duplicate-id"
3471
+ ? (重複した行.get(n.id)?.shift() ?? 最初の行.get(n.id) ?? 0)
3472
+ : (最初の行.get(n.id) ?? 0),
3473
+ message: n.message,
3474
+ hint: VALUE_NOTICE_HINT[n.kind],
3475
+ });
3476
+ }
3477
+ }
3478
+
3479
+ /** 止まった理由ごとの直し方。 engine の知らせは何が起きたかまでで、直し方は記法側が持つ */
3480
+ const VALUE_NOTICE_HINT: Readonly<Record<string, string>> = {
3481
+ cycle: "参照が一周しています。 どれか 1 つを states の初期値に変える",
3482
+ "unknown-reference": "その名前の states / values を足すか、綴りを直す",
3483
+ "parse-error": "式に書けるのは四則 (+ - * /) と括弧、比較、min / max だけ",
3484
+ "eval-error": "初期値で計算できない形です。 割る数や、数として読めない初期値を見直す",
3485
+ "duplicate-id": "同じ名前が 2 度あります。 片方を消すか名前を変える",
3486
+ "invalid-id": "名前に使えるのは英数字と _ だけ",
3487
+ };
3488
+
2075
3489
  /**
2076
3490
  * 図全体を 1 つの箱で描く種別。
2077
3491
  *
2078
3492
  * これらは中身 (扇 / 帯 / 枝) を payload で受け取り、 1 node で図全体を描く。 登場人物ごとの箱を
2079
3493
  * 持たないので、 段の `focus:` で名前を指しても引く先が無い。 `injectPhasesFallback` が
2080
3494
  * この一覧を使って「実在する名前ならその箱を光らせる」 に読み替える (#1076 / #1077)。
3495
+ *
3496
+ * `mind-map` は一時期 **記法から到達しなかった** (`#1174`)。 この種別を作っていたのは
3497
+ * `compileRadial` だけで `#1170` で消え、 記法の `type: mind` は `card` を 3 列に並べる
3498
+ * 別実装だった。
3499
+ *
3500
+ * それでも一覧に残すのは、 ここが「1 箱で図全体を描く種別」 という **性質の一覧** だから。
3501
+ * `mind-map` は engine 側でその性質を持ち続けており、 記法が到達しないのは当時の
3502
+ * `compileMind` の実装によるものだった。 `#1177` で `compileMind` を `mind-map` に寄せたため、
3503
+ * **今は記法からも到達する** (一覧へ戻す作業が要らなかったのはこのため)。
2081
3504
  */
2082
3505
  const SINGLE_BOX_KINDS: ReadonlySet<string> = new Set([
2083
3506
  "chart-pie", "chart-line", "chart-bar",
2084
- "gantt-timeline", "mind-map", "mind-radial",
3507
+ "gantt-timeline", "mind-map",
2085
3508
  "funnel-stages", "quadrant-matrix", "tree-hierarchy", "journey-map",
2086
3509
  ]);
2087
3510
 
@@ -2331,19 +3754,6 @@ function dropUnfittableEndKinds(diagram: CdlDiagram, doc: DslDocument): void {
2331
3754
  * 対応が取れない edge には何も入れない (呼出側が「対応が無い」 と「行 0」 を区別できるように
2332
3755
  * するため、 #998)。
2333
3756
  */
2334
- function fillFlowEdgeSources(diagram: CdlDiagram, doc: DslDocument, sourceLines: Map<string, number>): void {
2335
- if (doc.type !== "flow") return;
2336
- // animation ありは別経路 (`compileGenericWithAnimate`) で、 鎖の規則が当てはまらない。
2337
- if (doc.animate && doc.animate.phases.length > 0) return;
2338
- diagram.edges.forEach((e, idx) => {
2339
- const to = doc.actors[idx + 1];
2340
- if (to === undefined) return;
2341
- const step = doc.flow.find((s) => s.to === to.name);
2342
- if (step === undefined) return;
2343
- sourceLines.set(e.id, step.pos.line);
2344
- });
2345
- }
2346
-
2347
3757
  /**
2348
3758
  * v0.5+ groups section を topology preset 経由の diagram に container lane として反映。
2349
3759
  * group.lanes に含まれる lane id 集合に対し、 wrap する `group-{id}` lane を contain: true で生成。
@@ -2422,16 +3832,106 @@ function compileSolidity(doc: DslDocument): CdlDiagram {
2422
3832
  *
2423
3833
  * flow は依存関係を edge で表現 (横棒間の矢印)。
2424
3834
  */
3835
+ /**
3836
+ * `{名前}` が指す状態が取りうる値を、記法に書かれた範囲で集める (#1251)。
3837
+ *
3838
+ * 初期値と、段が動かす先 (`tween:` の両端と `set:` の値) を見る。 数として読めない値は
3839
+ * 落とす = 位置として使われないため、下限の判定には関係しない。
3840
+ */
3841
+ function 状態が取る値(参照: string, doc: DslDocument): number[] {
3842
+ const 名 = 参照.slice(1, -1);
3843
+ const out: number[] = [];
3844
+ const 数にする = (v: unknown): void => {
3845
+ if (typeof v === "number") {
3846
+ if (Number.isFinite(v)) out.push(v);
3847
+ return;
3848
+ }
3849
+ // **空文字と空白だけの値を数にしない**。 `Number("")` は 0 を返すため、そのままだと
3850
+ // 位置 0 として扱われ、始まりが 1 以降の帯に誤った知らせが出る。 描画側はこの値を
3851
+ // 解けず始まりへ倒すので、警告する相手ではない
3852
+ const 文字 = String(v).trim();
3853
+ if (文字 === "") return;
3854
+ const n = Number(文字);
3855
+ if (Number.isFinite(n)) out.push(n);
3856
+ };
3857
+ for (const st of doc.animate?.states ?? []) if (st.name === 名) 数にする(st.initial);
3858
+ for (const p of doc.animate?.phases ?? []) {
3859
+ for (const t of p.tweens ?? []) {
3860
+ if (t.state !== 名) continue;
3861
+ 数にする(t.from);
3862
+ 数にする(t.to);
3863
+ }
3864
+ for (const v of p.sets ?? []) if (v.state === 名) 数にする(v.value);
3865
+ }
3866
+ return out;
3867
+ }
3868
+
3869
+ /**
3870
+ * 工程が終わる位置を決める (#1251)。
3871
+ *
3872
+ * 書かなければ始まりと同じ = 帯が 1 コマ (従来の挙動)。
3873
+ *
3874
+ * `{名前}` を書いたらそのまま渡す。 描画側が状態を解いて位置に直すため、段で帯が伸び縮みする。
3875
+ * その場合 **時期の名前は始まりのものを使う** = 状態が指すのは位置であって時期の名前ではなく、
3876
+ * 帯の端に出す字が段ごとに変わるわけではない。
3877
+ *
3878
+ * 時期の名前を書いたら、その名前の位置に終わる。 書いた名前が目盛りに無い形は始まりと同じに
3879
+ * 倒す = 目盛りは書かれた順に作るため、載っていない名前は位置を持たない。
3880
+ */
3881
+ function 終わる位置(
3882
+ end: string | undefined,
3883
+ 始まり: number,
3884
+ 目盛り: readonly string[],
3885
+ 名前: string,
3886
+ 伝える: (名: string, message: string) => void,
3887
+ doc: DslDocument,
3888
+ ): { idx: number | string; label?: string } {
3889
+ if (end === undefined) return { idx: 始まり };
3890
+ if (/^\{\w+\}$/.test(end)) {
3891
+ // **状態が取る値は記法に全部書いてある**。 初期値と、段が動かす先 (`tween:` の両端と
3892
+ // `set:` の値) を集めれば、始まりより前に落ちる値をここで見つけられる。
3893
+ //
3894
+ // 覆えないのは `values:` の式から決まる値だけ = 他の状態から計算されるため、
3895
+ // 段ごとの結果を組み立ての時点では出せない
3896
+ const 低い = 状態が取る値(end, doc).filter((v) => v < 始まり);
3897
+ if (低い.length > 0) {
3898
+ 伝える(
3899
+ 名前,
3900
+ `type: gantt で ${truncateForMessage(名前)} の終わり (${truncateForMessage(end)}) が始まりより前になる値を取ります (${[...new Set(低い)].join(", ")})。 始まりは ${始まり} 番目です`,
3901
+ );
3902
+ }
3903
+ return { idx: end };
3904
+ }
3905
+ const i = 目盛り.indexOf(end);
3906
+ if (i < 0) return { idx: 始まり };
3907
+ // 始まりより前に終わる帯は描けない。 そのまま渡すと横幅が負になり、帯が始まりの位置から
3908
+ // 左へはみ出す。 始まりと同じに倒して伝える (黙って倒すと「書いたのに 1 コマのまま」 になる)
3909
+ if (i < 始まり) {
3910
+ 伝える(
3911
+ 名前,
3912
+ `type: gantt で ${truncateForMessage(名前)} の終わり (${truncateForMessage(end)}) が始まりより前です (始まりと同じに倒しました)`,
3913
+ );
3914
+ return { idx: 始まり };
3915
+ }
3916
+ return { idx: i, label: end };
3917
+ }
3918
+
2425
3919
  function compileGantt(doc: DslDocument): CdlDiagram {
2426
3920
  const b = diagram(slugify(doc.title), { topic: doc.title });
2427
3921
  const CHART_W = 720;
2428
- b.lane("gantt", { width: CHART_W, label: doc.title });
3922
+ b.lane("gantt", { width: CHART_W });
2429
3923
 
2430
3924
  // 目盛りは **書かれた順** に並べる。 以前は `Q1=200 / Q2=600 / ...` の決め打ちで、 Q1-Q4 以外は
2431
3925
  // 全て同じ位置に落ちていた。 順に並べれば月名でも週番号でも同じ規則で置ける
2432
3926
  const 目盛り: string[] = [];
2433
3927
  const 目盛りなし: string[] = [];
2434
- const タスク: { name: string; label: string; tone?: DslDocument["actors"][number]["tone"] }[] = [];
3928
+ const タスク: {
3929
+ name: string;
3930
+ label: string;
3931
+ tone?: DslDocument["actors"][number]["tone"];
3932
+ owner?: string;
3933
+ end?: string;
3934
+ }[] = [];
2435
3935
  for (const a of doc.actors) {
2436
3936
  const label = (a.value ?? a.subtitle ?? "").trim();
2437
3937
  if (label === "") {
@@ -2440,7 +3940,13 @@ function compileGantt(doc: DslDocument): CdlDiagram {
2440
3940
  }
2441
3941
  if (!目盛り.includes(label)) 目盛り.push(label);
2442
3942
  // 色は帯にそのまま渡す。 箱が 1 つになっても、 書いた色が消えないようにする
2443
- タスク.push({ name: a.name, label, ...(a.tone !== undefined ? { tone: a.tone } : {}) });
3943
+ タスク.push({
3944
+ name: a.name,
3945
+ label,
3946
+ ...(a.tone !== undefined ? { tone: a.tone } : {}),
3947
+ ...(a.owner !== undefined ? { owner: a.owner } : {}),
3948
+ ...(a.end !== undefined ? { end: a.end } : {}),
3949
+ });
2444
3950
  }
2445
3951
  if (目盛りなし.length > 0 && typeof console !== "undefined" && console.warn) {
2446
3952
  console.warn(
@@ -2477,6 +3983,12 @@ function compileGantt(doc: DslDocument): CdlDiagram {
2477
3983
  );
2478
3984
  }
2479
3985
 
3986
+ // 帯の向きの誤りは `console.warn` に出す。 この図種の他の知らせ (時期なし / 依存が結べない /
3987
+ // 矢印の飾り) が同じ経路を使っており、揃えないとどれが出るかが書き方で変わる
3988
+ const 逆向きを伝える = (_名: string, message: string): void => {
3989
+ if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${message}`);
3990
+ };
3991
+
2480
3992
  // 高さは件数から決める。 描画側は 1 行 28 以上 + 行間 20 で積み、 上下に 32 / 44 の余白を取る
2481
3993
  // (`kinds/gantt.tsx`)。 360 の固定だと 8 件目から最後の帯が枠の外に出る (実測 = 8 件で 56 はみ出す)
2482
3994
  const CHART_H = Math.max(360, 48 * タスク.length + 96);
@@ -2486,152 +3998,807 @@ function compileGantt(doc: DslDocument): CdlDiagram {
2486
3998
  stack: 0,
2487
3999
  kind: "gantt-timeline",
2488
4000
  title: doc.title,
4001
+ ...図の小見出し(doc),
2489
4002
  w: CHART_W,
2490
4003
  h: CHART_H,
2491
4004
  ganttData: タスク.map((t) => {
2492
4005
  const idx = 目盛り.indexOf(t.label);
2493
4006
  const from = 依存元.get(t.name);
4007
+ const 終わり = 終わる位置(t.end, idx, 目盛り, t.name, 逆向きを伝える, doc);
2494
4008
  return {
2495
4009
  id: slugify(t.name) || t.name,
2496
4010
  title: t.name,
2497
4011
  startIdx: idx,
2498
- endIdx: idx,
4012
+ endIdx: 終わり.idx,
2499
4013
  startLabel: t.label,
2500
- endLabel: t.label,
4014
+ endLabel: 終わり.label ?? t.label,
4015
+ ...(t.owner !== undefined ? { owner: t.owner } : {}),
2501
4016
  ...(from !== undefined ? { dependsOn: slugify(from) || from } : {}),
2502
4017
  ...(t.tone !== undefined ? { tone: t.tone } : {}),
2503
4018
  };
2504
4019
  }),
2505
4020
  });
2506
-
4021
+
4022
+ return b.build();
4023
+ }
4024
+
4025
+ /**
4026
+ * クラス図の組み立て。
4027
+ *
4028
+ * 各クラスを 1 つの箱 (`storage`) にする。 描画側は題 (クラス名) と区切り線と行
4029
+ * (項目 / 手続き) を UML のクラス箱として描く。
4030
+ *
4031
+ * **クラスごとに縦列を 1 本作り、横に並べる** (#1263)。 組立て API 側がそう並べており、
4032
+ * 1 本にまとめると同じ内容でも横並びが縦並びになる (実測 = 見本は 3 縦列 450 幅)。
4033
+ * 縦列に見出しは付けない = クラスの名前は箱が既に描いており、縦列は並べるための入れ物
4034
+ * (`er` / `state` と同じ扱い、#1241)。
4035
+ *
4036
+ * 箱の種類は `storage` に強制する。 行と小見出しは `applyV05Extensions` が後から載せる。
4037
+ *
4038
+ * 矢印は継承や保有を表す (`extends` / `aggregates` 等を書き手が説明に書く)。
4039
+ */
4040
+ function compileClass(doc: DslDocument): CdlDiagram {
4041
+ const b = diagram(slugify(doc.title), { topic: doc.title });
4042
+ const CLASS_W = 400;
4043
+ // 縦列の幅は箱より広く取る。 組立て API 側の値に揃える (#1263)
4044
+ const CLASS_LANE_W = 450;
4045
+ // 登場人物が 0 人なら枠も作らない。 先に作ると中身の無い枠が 1 つ残る (#1096)
4046
+ if (doc.actors.length === 0) return b.build();
4047
+
4048
+ // **クラスごとに縦列を 1 本作る** (#1263)。 組立て API 側がそう並べており、1 本にまとめると
4049
+ // 同じ内容でも横並びが縦並びになる (実測 = 見本は 3 縦列 450 幅、記法は 1 縦列に縦積み)。
4050
+ //
4051
+ // 縦列に見出しは付けない。 クラスの名前は箱が既に描いており、縦列は並べるための入れ物
4052
+ // (`er` / `state` と同じ扱い、#1241)
4053
+ doc.actors.forEach((a, idx) => {
4054
+ const nodeId = slugify(a.name) || `c${idx}`;
4055
+ const laneId = `lane-${nodeId}`;
4056
+ b.lane(laneId, { width: CLASS_LANE_W });
4057
+ b.node(nodeId, {
4058
+ lane: laneId,
4059
+ stack: 0,
4060
+ kind: "storage",
4061
+ title: a.name,
4062
+ w: CLASS_W,
4063
+ });
4064
+ });
4065
+
4066
+ for (const s of doc.flow) {
4067
+ const fromId = slugify(s.from);
4068
+ const toId = slugify(s.to);
4069
+ b.edge(fromId, toId, {
4070
+ label: s.label,
4071
+ ...(s.sub ? { sub: s.sub } : {}),
4072
+ ...(s.tone ? { tone: s.tone } : {}),
4073
+ ...(s.style ? { style: s.style } : {}),
4074
+ });
4075
+ }
4076
+
4077
+ return b.build();
4078
+ }
4079
+
4080
+ /**
4081
+ * Pie preset (円グラフ風 slice list 専用 layout)
4082
+ *
4083
+ * 設計 ... 円グラフの SVG arc 描画は engine 改修が大きいため、 簡易版として「slice list + value (%) 表示」
4084
+ * で代替する。 1 lane に slice を縦並びにし、 value 属性 (例 "30%") は applyV05Extensions が
4085
+ * node.value に merge することで「[Slice A] 30%」 「[Slice B] 25%」 のような pie chart 意図を伝える。
4086
+ *
4087
+ * 実装 ... 全 slice を 1 lane に縦 stack。 kind: card 強制、 w=480 h=120。
4088
+ *
4089
+ * flow は通常なし (slice 間に依存関係はない)、 author 明示時のみ edge を描く。
4090
+ */
4091
+ /**
4092
+ * 割合の書き方から数値を読む。 読めなければ `null`。
4093
+ *
4094
+ * 受けるのは `"45%"` / `"45"` / `"45.5%"` と、 前後の空白。 `"四割"` や `"0.45"` のような
4095
+ * 別の言い方は読まない = **黙って 0 にすると、 その分だけ欠けた円が「正しい図」 として出る**。
4096
+ * 読めなかったことは呼出側が警告に出す。
4097
+ */
4098
+ function parseShareValue(raw: string | undefined): number | null {
4099
+ if (raw === undefined) return null;
4100
+ const m = raw.trim().match(/^(-?\d+(?:\.\d+)?)\s*%?$/);
4101
+ if (m === null) return null;
4102
+ const v = Number(m[1]);
4103
+ return Number.isFinite(v) ? v : null;
4104
+ }
4105
+
4106
+ /**
4107
+ * 状態を読む欄かどうか (`{名前}`)。
4108
+ *
4109
+ * **`{名前}` そのものだけを受ける**。 `{v} 件` のような混ざった形は、描画側が数として
4110
+ * 読めず既定値に落ちて印が付くだけになる (`render/payload-binding.ts` は解いた文字列を
4111
+ * そのまま数にする)。 書けたのに効かない形を作らない。
4112
+ *
4113
+ * `%` を付けた形も受けない。 同じ理由で `"45%"` は数に直せるが `"{v}%"` は直せない。
4114
+ *
4115
+ * 名前に使えるのは英数字と `_` で、読む側 (cdl の `interpolate`) と同じ範囲に合わせる。
4116
+ * 決まった accessor (`.sum` 等) は付けてよい。
4117
+ */
4118
+ function parseBoundValue(raw: string | undefined): string | null {
4119
+ if (raw === undefined) return null;
4120
+ const t = raw.trim();
4121
+ return /^\{\w+(?:\.(?:length|sum|max|min|avg)|\[\d+\])?\}$/.test(t) ? t : null;
4122
+ }
4123
+
4124
+ /**
4125
+ * 図表の数の欄を読む。 数そのものか、状態を読む `{名前}` を返す。
4126
+ *
4127
+ * 数として解けない `{名前}` は、そのまま図表の中身に渡して描画側が段ごとに解く。
4128
+ * 受け取る側の型 (`BoundNumber`) は元から 2 通りを想定している = 入口だけが塞がっていた。
4129
+ */
4130
+ function parseChartValue(raw: string | undefined): number | string | null {
4131
+ const n = parseShareValue(raw);
4132
+ if (n !== null) return n;
4133
+ return parseBoundValue(raw);
4134
+ }
4135
+
4136
+
4137
+ /** 状態を読む欄が指している名前 (`{v.sum}` なら `v`)。 欄でなければ null */
4138
+ function 参照する名前(value: number | string | null): string | null {
4139
+ if (typeof value !== "string") return null;
4140
+ const m = value.match(/^\{(\w+)/);
4141
+ return m ? m[1]! : null;
4142
+ }
4143
+
4144
+ /**
4145
+ * 棒 / 折れ線の組立て。 円グラフと **入力の形が同じ**なので 1 つにまとめる。
4146
+ *
4147
+ * 3 種とも `- 名前: "45"` の 1 行 1 値で書く。 違うのは描画側の種別と、 値の意味だけ。
4148
+ *
4149
+ * | 型 | 種別 | 値の意味 |
4150
+ * |---|---|---|
4151
+ * | `pie` | `chart-pie` | 全体に対する取り分 |
4152
+ * | `bar` | `chart-bar` | 棒の高さ (単位は問わない) |
4153
+ * | `line` | `chart-line` | 線の高さ。 **書いた順に並ぶ** |
4154
+ *
4155
+ * 値を読めない項目は載せず、 まとめて警告に出す。 **黙って 0 にしない** = その項目だけ欠けた
4156
+ * 図が「正しい図」 として出てしまうため。
4157
+ *
4158
+ * 矢印は描けない。 書かれていたら警告に出して捨てる (「書いたのに効かない」 を残さない)。
4159
+ */
4160
+ function compileValueChart(
4161
+ doc: DslDocument,
4162
+ 型: "pie" | "bar" | "line",
4163
+ kind: "chart-pie" | "chart-bar" | "chart-line",
4164
+ onNotice?: (notice: CompileNotice) => void,
4165
+ ): CdlDiagram {
4166
+ const b = diagram(slugify(doc.title), { topic: doc.title });
4167
+ const CHART_W = 640;
4168
+ // **高さは型で違い、 格子に載せる**。 描画側 (`cdl` の `chart()` preset) は `pie` を 320、
4169
+ // 棒と折れ線を 360 とした上で **16 の倍数へ切り上げる** (360 は 16 で割り切れないので 368)。
4170
+ // 切り上げないと下端が格子から外れ、 全図で位置の警告が出る (review 指摘)
4171
+ const CHART_H = 型 === "pie" ? 320 : 368;
4172
+ b.lane("chart", { width: CHART_W + 64 });
4173
+
4174
+ const data: NonNullable<CdlDiagram["nodes"][number]["chartData"]> = [];
4175
+ const 読めない: string[] = [];
4176
+ const 未宣言: string[] = [];
4177
+ let 未宣言行 = 0;
4178
+ const 参照できる = 数の欄から参照できる名前(doc);
4179
+ // 最初に読めなかった行を覚える。 画面が案内できるようにする
4180
+ let 読めない行 = 0;
4181
+ for (const a of doc.actors) {
4182
+ // 値の置き場所は記法で 2 通りある。 略記 (`- TypeScript: "45%"`) は説明文に、
4183
+ // 縦書きの map (`- SliceA: { kind: card, value: "30%" }`) は値に入る。 両方を読む
4184
+ const value = parseChartValue(a.value ?? a.subtitle);
4185
+ // **負を受けるのは折れ線だけ**。 増減を追う図なので気温や損益のように 0 を跨ぐ値が来る。
4186
+ // 円は取り分、 棒は高さで、 どちらも負に意味が無い (review 指摘)。
4187
+ // 状態を読む欄 (`{名前}`) は書いた時点で符号が決まらないため、この検査を通す
4188
+ if (value === null || (typeof value === "number" && value < 0 && 型 !== "line")) {
4189
+ // `pos` を持たない経路がある (JSON 経路で組み立てた actor)。 無ければ 0 のまま
4190
+ if (読めない.length === 0) 読めない行 = a.pos?.line ?? 0;
4191
+ 読めない.push(a.name);
4192
+ continue;
4193
+ }
4194
+ // 数にならない参照は落とす。 通すと図は出るのに数が入っていない状態になる
4195
+ const 名前 = 参照する名前(value);
4196
+ if (名前 !== null && !参照できる.has(名前)) {
4197
+ if (未宣言.length === 0) 未宣言行 = a.pos?.line ?? 0;
4198
+ 未宣言.push(a.name);
4199
+ continue;
4200
+ }
4201
+ // 色はそのまま渡す。 箱が 1 つになっても、 書いた色が消えないようにする
4202
+ data.push({ label: a.name, value, ...(a.tone !== undefined ? { tone: a.tone } : {}) });
4203
+ }
4204
+ // 案内の言葉は型ごとに変える。 共通化した時に `pie` の「割合 / 円 / 45%」 が「値 / 図 / 45」 に
4205
+ // 薄まり、 既存の案内が後退した (review 指摘)。 何を書けばよいかは型ごとに違う
4206
+ const 語 =
4207
+ 型 === "pie"
4208
+ ? { 量: "割合", 図: "円", 例: '"45%"' }
4209
+ : 型 === "bar"
4210
+ ? { 量: "値", 図: "棒", 例: '"420"' }
4211
+ : { 量: "値", 図: "折れ線", 例: '"180"' };
4212
+
4213
+ /**
4214
+ * 利用者に伝える。 **`console.warn` だけにしない**。 エディタは受け取った notice を画面に
4215
+ * 出す経路を持っており、 log だけだと項目が消えた理由が誰にも見えない (review 指摘)。
4216
+ */
4217
+ const 伝える = (種類: CompileNotice["kind"], 名前: string, message: string, line = 0) => {
4218
+ onNotice?.({ kind: 種類, actor: 名前, line, message });
4219
+ if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${message}`);
4220
+ };
4221
+
4222
+ if (読めない.length > 0) {
4223
+ 伝える(
4224
+ "chart-value-unreadable",
4225
+ 読めない[0]!,
4226
+ `type: ${型} で${語.量}を読めない項目があります (${語.図}に載せません): ${読めない.join(", ")}。` +
4227
+ ` \`- 名前: ${語.例}\` の形で書いてください`,
4228
+ 読めない行,
4229
+ );
4230
+ }
4231
+ if (未宣言.length > 0) {
4232
+ 伝える(
4233
+ "chart-value-unreadable",
4234
+ 未宣言[0]!,
4235
+ `type: ${型} で数にならない値を参照した項目があります (${語.図}に載せません): ${未宣言.join(", ")}。` +
4236
+ ` \`states:\` にその名前を数で書いてください`,
4237
+ 未宣言行,
4238
+ );
4239
+ }
4240
+ if (doc.flow.length > 0) {
4241
+ 伝える(
4242
+ "chart-edge-dropped",
4243
+ doc.flow[0]?.from ?? "",
4244
+ `type: ${型} では矢印を描けません (${doc.flow.length} 本を無視しました)。` +
4245
+ ` 関係を描くなら type: flow を使ってください`,
4246
+ doc.flow[0]?.pos?.line ?? 0,
4247
+ );
4248
+ }
4249
+
4250
+ b.node(`${slugify(doc.title) || 型}-chart`, {
4251
+ lane: "chart",
4252
+ stack: 0,
4253
+ kind,
4254
+ title: doc.title,
4255
+ ...図の小見出し(doc),
4256
+ w: CHART_W,
4257
+ h: CHART_H,
4258
+ chartData: data,
4259
+ });
4260
+
4261
+ return b.build();
4262
+ }
4263
+
4264
+
4265
+ /**
4266
+ * 図表 4 種の組立て (#1154 段 2 / 段 3)。
4267
+ *
4268
+ * 描画側に 1 node で渡す形は値で描く 3 型と同じ。 違うのは **actor から何を読むか**。
4269
+ *
4270
+ * | 型 | 読むもの | 書き方 |
4271
+ * |---|---|---|
4272
+ * | `funnel` | 数 | `- 訪問: "12000"` |
4273
+ * | `tree` | 親子 | `flow` の矢印 (`親 -> 子`) |
4274
+ * | `journey` | 気持ち | `- 登録: "不満"` |
4275
+ * | `quadrant` | どの区画か | `- 重複削除: "左上"` |
4276
+
4277
+ * `tree` だけ `flow` を読む = 親子は 2 つの名前の関係で、 1 行 1 値では書けないため。
4278
+ */
4279
+
4280
+ /**
4281
+ * 図表の大きさ。 **格子 (16) の倍数にする**。
4282
+ *
4283
+ * 描画側 (`cdl` の `chart()` preset) は高さを 16 の倍数へ切り上げる。 揃えないと下端が格子から
4284
+ * 外れ、 正しい記法でも位置の警告が出る (review 指摘、 360 のまま 5 型が該当していた)。
4285
+ */
4286
+ /**
4287
+ * 図表の箱の大きさ (#1260)。
4288
+ *
4289
+ * **組立て API と同じ値を使う**。 別の値にすると、同じ内容を書いても描いた図の大きさが変わる
4290
+ * (実測 = 記法の `funnel` は 640x368、組立て API は 560x480 で、描いた図の viewBox が
4291
+ * 785x488 対 712x600 になっていた)。
4292
+ *
4293
+ * 組立て API 側は中身の件数で変えない (実測 = 2 / 4 / 8 件のどれでも同じ値)。 そのため
4294
+ * こちらも定数で持つ。 `gantt` だけは件数で高さを変える = 8 件目から最後の帯が枠の外に
4295
+ * 出るため (`compileGantt` の実測)、組立て API の固定 360 より正しい。
4296
+ */
4297
+ const 図表の大きさ = {
4298
+ funnel: { w: 560, h: 480 },
4299
+ tree: { w: 720, h: 480 },
4300
+ mind: { w: 720, h: 480 },
4301
+ journey: { w: 720, h: 480 },
4302
+ quadrant: { w: 640, h: 480 },
4303
+ } as const;
4304
+
4305
+ /** 気持ちの言葉。 書きやすさのため日本語で受ける。 */
4306
+ //
4307
+ // **`Map` で持つ**。 plain object だと `__proto__` / `constructor` が親から引けてしまい、
4308
+ // 書ける語の一覧に無い入力が値として通る (review 指摘)。 型は付いていても中身は object や
4309
+ // function になり、 描画側へそのまま流れる。
4310
+ const 気持ち = new Map<string, "delighted" | "happy" | "neutral" | "frustrated" | "angry">([
4311
+ ["最高", "delighted"],
4312
+ ["満足", "happy"],
4313
+ ["普通", "neutral"],
4314
+ ["不満", "frustrated"],
4315
+ ["怒り", "angry"],
4316
+ ]);
4317
+
4318
+ /** 区画の言葉。 縦横の位置をそのまま書く。 */
4319
+ // 同上の理由で `Map`。
4320
+ const 区画 = new Map<string, "topLeft" | "topRight" | "bottomLeft" | "bottomRight">([
4321
+ ["左上", "topLeft"],
4322
+ ["右上", "topRight"],
4323
+ ["左下", "bottomLeft"],
4324
+ ["右下", "bottomRight"],
4325
+ ]);
4326
+
4327
+ /**
4328
+ * 図表の欄が `{名前}` で読む値を、**1 か所で** 確かめる (#1200)。
4329
+ *
4330
+ * 図表には数の欄 (割合 / 段の人数) と語の欄 (気持ち / 区画) があり、どちらも `{名前}` で
4331
+ * 状態を読める。 確かめることは欄の種類で違うが、**土台は同じ** = 同じ名前を 2 回書いた時に
4332
+ * 後ろが効くこと、段で状態に入る値 (切り替え / 補間) も見ること、の 2 つ。
4333
+ *
4334
+ * #1198 と #1201 では欄ごとに検査を書き足しており、同じ土台を 3 度書いていた。 3 度とも
4335
+ * review で同じ形の穴を指摘されている (最初の宣言で判定する / 段で入る値を見落とす)。
4336
+ * 土台を 1 つにして、欄ごとの違いだけを外から渡す。
4337
+ *
4338
+ * ## 確かめること
4339
+ *
4340
+ * | 欄 | 通す値 | 段で入る値 |
4341
+ * |---|---|---|
4342
+ * | 数 | 数として読める (空文字は弾く、`Number("")` が 0 を返すため) | 切り替え先が数 |
4343
+ * | 語 | 語表にある語 | 切り替え先が語表にあり、補間されない (補間の行き先は数) |
4344
+ *
4345
+ * 自動で決まる値 (`values:`) は式の評価結果で必ず数になるため、数の欄からは参照できて
4346
+ * 語の欄からは参照できない。 式そのものの不備 (語を読む / 名前が無い) は
4347
+ * `value-unresolved` の警告が別に出る (実測で確認済)。
4348
+ *
4349
+ * ## 責務境界 (#1198 / #1200)
4350
+ *
4351
+ * **見るのは組み立ての時点で決まっている範囲だけ**。 記法の値は実行時に決まるため、ここで
4352
+ * 全部を判定しようとすると式の評価を組み立て側で再現することになる。 実際に #1199 の review で
4353
+ * 4 round 続けて同じ形の指摘が出て収束せず、境界を決めて切り分けた (穴を 1 つ塞ぐと別の形が
4354
+ * 出る = 塞ぎ方ではなく責務の置き場所の問題だった)。
4355
+ *
4356
+ * | 見る | 見ない | 見ない理由 |
4357
+ * |---|---|---|
4358
+ * | 名前が宣言されているか | 段の行き先が負になる形 | 描画側が問題なく描く (負の大きさも `NaN` も出ないことを実測) |
4359
+ * | 宣言の時点で読める値か | 式が実行時に返す値 | 式の不備は `value-unresolved` の警告が別に出る (実測で確認) |
4360
+ * | 段で状態に入る値 | | |
4361
+ *
4362
+ * 見ない範囲は描画側が受け持つ = 解けない値は既定値で描いて `data-cdl-unresolved` を付ける。
4363
+ */
4364
+ function 図表の欄から参照できる名前(
4365
+ doc: DslDocument,
4366
+ 欄: { 読めるか: (v: number | string) => boolean; 補間で壊れるか: boolean; 自動の値を許すか: boolean },
4367
+ ): Set<string> {
4368
+ // 同じ名前を 2 回宣言した時は後ろが効く。 描画側が後の宣言を有効値として扱うため、
4369
+ // 前の宣言で判定すると「読めると判定したのに読めない値が入る」 状態になる (実測)
4370
+ const 実効 = new Map<string, number | string>();
4371
+ for (const s of doc.animate?.states ?? []) 実効.set(s.name, s.initial);
4372
+
4373
+ const 壊れる = new Set<string>();
4374
+ for (const p of doc.animate?.phases ?? []) {
4375
+ for (const st of p.sets ?? []) if (!欄.読めるか(st.value)) 壊れる.add(st.state);
4376
+ if (欄.補間で壊れるか) for (const tw of p.tweens ?? []) 壊れる.add(tw.state);
4377
+ }
4378
+
4379
+ const out = new Set<string>();
4380
+ for (const [名前, 値] of 実効) {
4381
+ if (!欄.読めるか(値)) continue;
4382
+ if (壊れる.has(名前)) continue;
4383
+ out.add(名前);
4384
+ }
4385
+ if (欄.自動の値を許すか) for (const v of doc.values ?? []) out.add(v.name);
4386
+ return out;
4387
+ }
4388
+
4389
+ /** 数の欄が読める値か。 空文字と空白だけは弾く (`Number("")` は 0 を返す) */
4390
+ function 数として読めるか(v: number | string): boolean {
4391
+ if (typeof v === "number") return Number.isFinite(v);
4392
+ const t = v.trim();
4393
+ if (t === "") return false;
4394
+ return Number.isFinite(Number(t));
4395
+ }
4396
+
4397
+ function 数の欄から参照できる名前(doc: DslDocument): Set<string> {
4398
+ return 図表の欄から参照できる名前(doc, {
4399
+ 読めるか: 数として読めるか,
4400
+ // 補間の行き先は数なので、数の欄では壊れない
4401
+ 補間で壊れるか: false,
4402
+ 自動の値を許すか: true,
4403
+ });
4404
+ }
4405
+
4406
+ function 語の欄から参照できる名前(doc: DslDocument, 語表: Map<string, string>): Set<string> {
4407
+ return 図表の欄から参照できる名前(doc, {
4408
+ 読めるか: (v) => 語表.has(String(v).trim()),
4409
+ // 補間の行き先は数。 語の欄が読む状態を補間すると、その段で語が数に変わる
4410
+ 補間で壊れるか: true,
4411
+ // 式の評価結果は数になるため、語の欄からは読めない
4412
+ 自動の値を許すか: false,
4413
+ });
4414
+ }
4415
+
4416
+ /**
4417
+ * 記法の語で書いた状態を、図の語へ直す (#1201)。
4418
+ *
4419
+ * 語の欄が `{名前}` を持つとき、その名前が指す状態には記法の語 (「不満」 「左上」) が
4420
+ * 入っている。 描画側が知っているのは図の語 (`frustrated` / `topLeft`) なので、ここで直す。
4421
+ *
4422
+ * **記法の語彙に engine の内部語を混ぜないため**にこの形にしている。 状態にも図の語を
4423
+ * 書かせる形なら直す処理は要らないが、記法の語と内部語が同じ file に並ぶことになる。
4424
+ *
4425
+ * 直すのは語の欄から参照されている名前だけ。 同じ名前を数の欄からも参照している図では
4426
+ * 直さない (数として読めなくなるため)。
4427
+ *
4428
+ * **この「数の欄からも参照している」 分岐は、到達する入力を今は作れない**。 記法の図は
4429
+ * 1 つの型しか持たず、語の欄を持つ型 (`journey` / `quadrant`) と数の欄を持つ型
4430
+ * (`bar` / `line` / `pie` / `funnel`) は同時に現れないため。 変異試験でもこの行を外して
4431
+ * 検査が落ちないことを確かめた = 覆えていない。 見本を重ねる経路で両方の欄を持つ箱が
4432
+ * できた時のために残す。
4433
+ */
4434
+ function 語の状態を図の語へ直す(diagram: CdlDiagram): void {
4435
+ const 対象 = new Map<string, Map<string, string>>();
4436
+ const 数の欄から = new Set<string>();
4437
+ const 拾う = (v: unknown, 語表: Map<string, string>) => {
4438
+ const m = typeof v === "string" ? v.match(/^\{(\w+)/) : null;
4439
+ if (m) 対象.set(m[1]!, 語表);
4440
+ };
4441
+ for (const n of diagram.nodes) {
4442
+ for (const st of n.journeyData ?? []) 拾う(st.emotion, 気持ち);
4443
+ for (const it of n.quadrantData?.items ?? []) 拾う(it.quadrant, 区画);
4444
+ for (const d of n.chartData ?? []) {
4445
+ const m = typeof d.value === "string" ? d.value.match(/^\{(\w+)/) : null;
4446
+ if (m) 数の欄から.add(m[1]!);
4447
+ }
4448
+ for (const f of n.funnelData ?? []) {
4449
+ const m = typeof f.count === "string" ? f.count.match(/^\{(\w+)/) : null;
4450
+ if (m) 数の欄から.add(m[1]!);
4451
+ }
4452
+ }
4453
+ if (対象.size === 0) return;
4454
+
4455
+ const 直す = (名前: string, 値: string | number): string | number => {
4456
+ const 語表 = 対象.get(名前);
4457
+ if (!語表 || 数の欄から.has(名前)) return 値;
4458
+ return 語表.get(String(値).trim()) ?? 値;
4459
+ };
4460
+ for (const s of diagram.states) s.initial = 直す(s.id, s.initial);
4461
+ for (const p of diagram.phases) {
4462
+ for (const st of p.sets) st.value = 直す(st.stateId, st.value);
4463
+ }
4464
+ }
4465
+
4466
+ function compileFunnel(doc: DslDocument, onNotice?: (n: CompileNotice) => void): CdlDiagram {
4467
+ const b = diagram(slugify(doc.title), { topic: doc.title });
4468
+ const { w: W, h: H } = 図表の大きさ.funnel;
4469
+ b.lane("chart", { width: W + 64 });
4470
+ const data: NonNullable<CdlDiagram["nodes"][number]["funnelData"]> = [];
4471
+ const 読めない: string[] = [];
4472
+ const 未宣言: string[] = [];
4473
+ const 参照できる = 数の欄から参照できる名前(doc);
4474
+ for (const a of doc.actors) {
4475
+ const v = parseChartValue(a.value ?? a.subtitle);
4476
+ // 段の数なので負に意味が無い (状態を読む欄は符号が決まらないので通す)
4477
+ if (v === null || (typeof v === "number" && v < 0)) {
4478
+ 読めない.push(a.name);
4479
+ continue;
4480
+ }
4481
+ // 数にならない参照は落とす (棒 / 折れ線 / 円と同じ扱い)
4482
+ const 名前 = 参照する名前(v);
4483
+ if (名前 !== null && !参照できる.has(名前)) {
4484
+ 未宣言.push(a.name);
4485
+ continue;
4486
+ }
4487
+ data.push({ id: slugify(a.name), title: a.name, count: v });
4488
+ }
4489
+ if (未宣言.length > 0) {
4490
+ const m3 = `type: funnel で数にならない値を参照した項目があります (段に載せません): ${未宣言.join(", ")}。 \`states:\` にその名前を数で書いてください`;
4491
+ onNotice?.({ kind: "chart-value-unreadable", actor: 未宣言[0]!, line: 0, message: m3 });
4492
+ if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${m3}`);
4493
+ }
4494
+ if (読めない.length > 0) {
4495
+ const m = `type: funnel で数を読めない項目があります (段に載せません): ${読めない.join(", ")}。 \`- 訪問: "12000"\` の形で書いてください`;
4496
+ onNotice?.({ kind: "chart-value-unreadable", actor: 読めない[0]!, line: 0, message: m });
4497
+ if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${m}`);
4498
+ }
4499
+ // 矢印は描けない。 書かれていたら伝える (黙って捨てると「書いたのに効かない」 が残る)
4500
+ if (doc.flow.length > 0) {
4501
+ const m2 = `type: funnel では矢印を描けません (${doc.flow.length} 本を無視しました)。 関係を描くなら type: flow を使ってください`;
4502
+ onNotice?.({ kind: "chart-edge-dropped", actor: doc.flow[0]?.from ?? "", line: doc.flow[0]?.pos?.line ?? 0, message: m2 });
4503
+ if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${m2}`);
4504
+ }
4505
+ b.node(`${slugify(doc.title) || "funnel"}-chart`, {
4506
+ lane: "chart", stack: 0, kind: "funnel-stages", title: doc.title, ...図の小見出し(doc), w: W, h: H, funnelData: data,
4507
+ });
4508
+ return b.build();
4509
+ }
4510
+
4511
+ /**
4512
+ * 矢印から親子を決める (#1251)。
4513
+ *
4514
+ * 矢印の先が子で、 どこからも指されない名前が根になる。 `type: tree` と `type: mind` が
4515
+ * 同じ規則を使う = 同じ本文を書いた時に、 図種を変えただけで親子の解釈が変わらないようにする。
4516
+ *
4517
+ * **黙って上書きしない**。 同じ子に 2 本来たら後勝ちで消えるし、 書いていない名前を指した
4518
+ * 矢印は無い親を作る。 どちらも図が静かに変わるので伝える。
4519
+ *
4520
+ * 親を辿って自分に戻る形は木にならないため、 その枝を切って伝える。
4521
+ */
4522
+ function 矢印から親を決める(
4523
+ doc: DslDocument,
4524
+ 図種: "tree" | "mind",
4525
+ 名前: ReadonlySet<string>,
4526
+ 伝える: (名: string, message: string, line?: number) => void,
4527
+ ): Map<string, string> {
4528
+ const 親 = new Map<string, string>();
4529
+ for (const f of doc.flow) {
4530
+ const 子 = slugify(f.to);
4531
+ const 親名 = slugify(f.from);
4532
+ if (!名前.has(親名)) {
4533
+ 伝える(f.from, `type: ${図種} で書いていない名前を親にしています: ${f.from} -> ${f.to}`, f.pos?.line ?? 0);
4534
+ continue;
4535
+ }
4536
+ // 子の側も見る。 書いていない名前への矢印は、 黙って捨てると図から関係が消える
4537
+ if (!名前.has(子)) {
4538
+ 伝える(f.to, `type: ${図種} で書いていない名前を子にしています: ${f.from} -> ${f.to}`, f.pos?.line ?? 0);
4539
+ continue;
4540
+ }
4541
+ if (子 === 親名) {
4542
+ 伝える(f.to, `type: ${図種} で自分を親にしています: ${f.to}`, f.pos?.line ?? 0);
4543
+ continue;
4544
+ }
4545
+ const 既存 = 親.get(子);
4546
+ if (既存 !== undefined && 既存 !== 親名) {
4547
+ 伝える(f.to, `type: ${図種} で ${f.to} に親が 2 つあります (後の ${f.from} は使いません)`, f.pos?.line ?? 0);
4548
+ continue;
4549
+ }
4550
+ 親.set(子, 親名);
4551
+ }
4552
+ // 親を辿って自分に戻る形は木にならない。 その枝を切って伝える
4553
+ for (const 子 of [...親.keys()]) {
4554
+ const 見た = new Set<string>([子]);
4555
+ let p2 = 親.get(子);
4556
+ while (p2 !== undefined) {
4557
+ if (見た.has(p2)) {
4558
+ 伝える(子, `type: ${図種} で親を辿ると輪になります (${子} の親を外しました)`);
4559
+ 親.delete(子);
4560
+ break;
4561
+ }
4562
+ 見た.add(p2);
4563
+ p2 = 親.get(p2);
4564
+ }
4565
+ }
4566
+ return 親;
4567
+ }
4568
+
4569
+ function compileTree(doc: DslDocument, onNotice?: (n: CompileNotice) => void): CdlDiagram {
4570
+ const b = diagram(slugify(doc.title), { topic: doc.title });
4571
+ const { w: W, h: H } = 図表の大きさ.tree;
4572
+ b.lane("chart", { width: W + 64 });
4573
+ // **同じ slug になる名前を先に見る**。 違う名前が同じ id に潰れると、 自分を親にしたと
4574
+ // 誤判定したり、 同じ id の要素が 2 つできたりする (review 指摘)
4575
+ const slug別 = new Map<string, string[]>();
4576
+ for (const a of doc.actors) {
4577
+ const k = slugify(a.name);
4578
+ slug別.set(k, [...(slug別.get(k) ?? []), a.name]);
4579
+ }
4580
+ const 名前 = new Set(slug別.keys());
4581
+ // **行番号を渡す**。 `DslActor` / `DslStep` は `pos.line` を持つので遡れる。 前回「持てない」
4582
+ // と書いたのは誤り (review 指摘)。 0 にすると画面が問題の行を案内できない
4583
+ const 伝える = (名: string, message: string, line = 0) => {
4584
+ onNotice?.({ kind: "chart-value-unreadable", actor: 名, line, message });
4585
+ if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${message}`);
4586
+ };
4587
+ for (const [k, 群] of slug別) {
4588
+ if (群.length > 1) {
4589
+ 伝える(群[0]!, `type: tree で ${群.join(" / ")} が同じ id (${k}) になります。 名前を変えてください`);
4590
+ }
4591
+ }
4592
+ const 親 = 矢印から親を決める(doc, "tree", 名前, 伝える);
4593
+ const data: NonNullable<CdlDiagram["nodes"][number]["treeData"]> = doc.actors.map((a) => {
4594
+ const id = slugify(a.name);
4595
+ const p3 = 親.get(id);
4596
+ return { id, title: a.name, ...(p3 !== undefined ? { parent: p3 } : {}) };
4597
+ });
4598
+ b.node(`${slugify(doc.title) || "tree"}-chart`, {
4599
+ lane: "chart", stack: 0, kind: "tree-hierarchy", title: doc.title, ...図の小見出し(doc), w: W, h: H, treeData: data,
4600
+ });
2507
4601
  return b.build();
2508
4602
  }
2509
4603
 
2510
4604
  /**
2511
- * Class preset (UML class diagram 専用 layout)
4605
+ * 体験の道筋の段に添える欄 (#1251)
2512
4606
  *
2513
- * 設計 ... class 1 storage node として配置、 縦に stack する。 storage node renderer は
2514
- * title (class ) + divider + rows (fields / methods) を UML class box 風に表示する。
4607
+ * 書かなければ項目ごと落とす = `undefined` を明示して渡すと、 組立て側が「空を書いた」
4608
+ * 区別できなくなる (`図の小見出し` と同じ理由)
4609
+ */
4610
+ function 道筋の欄(a: DslActor): { touchpoint?: string; opportunity?: string } {
4611
+ return {
4612
+ ...(a.touchpoint !== undefined ? { touchpoint: a.touchpoint } : {}),
4613
+ ...(a.opportunity !== undefined ? { opportunity: a.opportunity } : {}),
4614
+ };
4615
+ }
4616
+
4617
+ /**
4618
+ * 体験の道筋の欄を、 それを描けない図種で書いた時に伝える (#1251)。
2515
4619
  *
2516
- * 実装 ... class 1 lane に縦 stack 配置。 actor の kind を強制 storage、 rows / subtitle は
2517
- * applyV05Extensions で node に merge される。
4620
+ * `touchpoint` `opportunity` `type: journey` の段だけが持つ。 他の図種では相手が無く、
4621
+ * 黙って捨てると「書いたのに出ない」 が手掛かりなしで起きる。
2518
4622
  *
2519
- * flow ... 継承 / 関連を edge で表現 (label に "extends" / "implements" 等を author が指定)。
4623
+ * `type: mind` では伝えない = `compileMind` が描けない欄をまとめて 1 件で伝えており、
4624
+ * そこに 2 つとも入っている (`放射で描けない欄`)。 二重に伝えない (#1246 と同じ扱い)。
2520
4625
  */
2521
- function compileClass(doc: DslDocument): CdlDiagram {
2522
- const b = diagram(slugify(doc.title), { topic: doc.title });
2523
- const CLASS_W = 400;
2524
- // 登場人物が 0 人なら枠も作らない。 先に作ると中身の無い枠が 1 つ残る (#1096)
2525
- if (doc.actors.length === 0) return b.build();
2526
- b.lane("class-stack", { width: CLASS_W, label: doc.title });
2527
-
2528
- doc.actors.forEach((a, idx) => {
2529
- const nodeId = slugify(a.name) || `c${idx}`;
2530
- b.node(nodeId, {
2531
- lane: "class-stack",
2532
- stack: idx,
2533
- kind: "storage",
2534
- title: a.name,
2535
- w: CLASS_W,
2536
- });
2537
- });
2538
-
2539
- for (const s of doc.flow) {
2540
- const fromId = slugify(s.from);
2541
- const toId = slugify(s.to);
2542
- b.edge(fromId, toId, {
2543
- label: s.label,
2544
- ...(s.sub ? { sub: s.sub } : {}),
2545
- ...(s.tone ? { tone: s.tone } : {}),
2546
- ...(s.style ? { style: s.style } : {}),
2547
- });
4626
+ function reportChartFieldsNotHonored(doc: DslDocument, onNotice?: (n: CompileNotice) => void): void {
4627
+ if (!onNotice) return;
4628
+ if (doc.type === "mind") return;
4629
+ for (const a of doc.actors) {
4630
+ if (a.partId !== undefined) continue;
4631
+ const 道筋 = doc.type === "journey" ? [] : [
4632
+ ...(a.touchpoint !== undefined ? ["touchpoint"] : []),
4633
+ ...(a.opportunity !== undefined ? ["opportunity"] : []),
4634
+ ];
4635
+ const 工程 = doc.type === "gantt" ? [] : [
4636
+ ...(a.owner !== undefined ? ["owner"] : []),
4637
+ ...(a.end !== undefined ? ["end"] : []),
4638
+ ];
4639
+ if (道筋.length > 0) {
4640
+ onNotice({
4641
+ kind: "chart-value-unreadable",
4642
+ actor: a.name,
4643
+ line: a.pos?.line ?? 0,
4644
+ message: `"${truncateForMessage(a.name)}" に書いた ${道筋.join(" / ")} は効きません (type: ${doc.type} には体験の道筋の欄がありません)`,
4645
+ hint: "体験の道筋を描くなら type: journey を使ってください",
4646
+ });
4647
+ }
4648
+ if (工程.length > 0) {
4649
+ onNotice({
4650
+ kind: "chart-value-unreadable",
4651
+ actor: a.name,
4652
+ line: a.pos?.line ?? 0,
4653
+ message: `"${truncateForMessage(a.name)}" に書いた ${工程.join(" / ")} は効きません (type: ${doc.type} には工程の並びの欄がありません)`,
4654
+ hint: "工程の並びを描くなら type: gantt を使ってください",
4655
+ });
4656
+ }
2548
4657
  }
4658
+ }
2549
4659
 
4660
+ function compileJourney(doc: DslDocument, onNotice?: (n: CompileNotice) => void): CdlDiagram {
4661
+ const b = diagram(slugify(doc.title), { topic: doc.title });
4662
+ const { w: W, h: H } = 図表の大きさ.journey;
4663
+ b.lane("chart", { width: W + 64 });
4664
+ const data: NonNullable<CdlDiagram["nodes"][number]["journeyData"]> = [];
4665
+ const 読めない: string[] = [];
4666
+ const 参照できる = 語の欄から参照できる名前(doc, 気持ち);
4667
+ for (const a of doc.actors) {
4668
+ const 語 = (a.value ?? a.subtitle ?? "").trim();
4669
+ // 状態を読む欄はそのまま渡す。 指す先の語は `語の状態を図の語へ直す` が図の語に直す
4670
+ const 参照 = 語.match(/^\{(\w+)\}$/);
4671
+ if (参照) {
4672
+ if (!参照できる.has(参照[1]!)) {
4673
+ 読めない.push(a.name);
4674
+ continue;
4675
+ }
4676
+ data.push({ id: slugify(a.name), title: a.name, emotion: 語 as never, ...道筋の欄(a) });
4677
+ continue;
4678
+ }
4679
+ const e = 気持ち.get(語);
4680
+ if (e === undefined) {
4681
+ 読めない.push(a.name);
4682
+ continue;
4683
+ }
4684
+ data.push({ id: slugify(a.name), title: a.name, emotion: e, ...道筋の欄(a) });
4685
+ }
4686
+ if (読めない.length > 0) {
4687
+ const m = `type: journey で気持ちを読めない項目があります (道筋に載せません): ${読めない.join(", ")}。 \`- 登録: "不満"\` の形で、 ${[...気持ち.keys()].join(" / ")} のどれかを書いてください`;
4688
+ onNotice?.({ kind: "chart-value-unreadable", actor: 読めない[0]!, line: 0, message: m });
4689
+ if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${m}`);
4690
+ }
4691
+ // 矢印は描けない。 書かれていたら伝える (黙って捨てると「書いたのに効かない」 が残る)
4692
+ if (doc.flow.length > 0) {
4693
+ const m2 = `type: journey では矢印を描けません (${doc.flow.length} 本を無視しました)。 関係を描くなら type: flow を使ってください`;
4694
+ onNotice?.({ kind: "chart-edge-dropped", actor: doc.flow[0]?.from ?? "", line: doc.flow[0]?.pos?.line ?? 0, message: m2 });
4695
+ if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${m2}`);
4696
+ }
4697
+ b.node(`${slugify(doc.title) || "journey"}-chart`, {
4698
+ lane: "chart", stack: 0, kind: "journey-map", title: doc.title, ...図の小見出し(doc), w: W, h: H, journeyData: data,
4699
+ });
2550
4700
  return b.build();
2551
4701
  }
2552
4702
 
4703
+ /** 軸を書かなかった時の名前。 何の軸か分からないため、位置をそのまま出す */
4704
+ const 軸の既定 = {
4705
+ xAxis: { left: "小さい", right: "大きい" },
4706
+ yAxis: { bottom: "小さい", top: "大きい" },
4707
+ quadrantLabels: { topLeft: "左上", topRight: "右上", bottomLeft: "左下", bottomRight: "右下" },
4708
+ } as const;
4709
+
2553
4710
  /**
2554
- * Pie preset (円グラフ風 slice list 専用 layout)
4711
+ * 2 軸で仕分ける図の軸と区画の名前を決める (#1251)
2555
4712
  *
2556
- * 設計 ... 円グラフの SVG arc 描画は engine 改修が大きいため、 簡易版として「slice list + value (%) 表示」
2557
- * で代替する。 1 lane に slice を縦並びにし、 value 属性 (例 "30%") は applyV05Extensions が
2558
- * node.value に merge することで「[Slice A] 30%」 「[Slice B] 25%」 のような pie chart 意図を伝える。
2559
- *
2560
- * 実装 ... 全 slice を 1 lane に縦 stack。 kind: card 強制、 w=480 h=120。
4713
+ * `axes:` を書かなければ従来どおり位置の名前 (`左上` 等) を出す = 既に描いてある図が動かない。
2561
4714
  *
2562
- * flow は通常なし (slice 間に依存関係はない) author 明示時のみ edge を描く。
2563
- */
2564
- /**
2565
- * 割合の書き方から数値を読む。 読めなければ `null`。
4715
+ * 書いたら区画の名前は **軸の名前から決める** (`{上} × {右}`) 組立て API 側が同じ規則で
4716
+ * 導いており (実測 = `xAxis: {left: "L", right: "R"}` / `yAxis: {bottom: "B", top: "T"}` で
4717
+ * `topLeft: "T × L"`)、 別の規則にすると同じ内容を書いても図が食い違う。
2566
4718
  *
2567
- * 受けるのは `"45%"` / `"45"` / `"45.5%"` と、 前後の空白。 `"四割"` や `"0.45"` のような
2568
- * 別の言い方は読まない = **黙って 0 にすると、 その分だけ欠けた円が「正しい図」 として出る**。
2569
- * 読めなかったことは呼出側が警告に出す。
4719
+ * 片側だけ書いた形では、書かなかった側は既定のままにする。 空文字を渡すと名前の無い軸が描かれる。
2570
4720
  */
2571
- function parseShareValue(raw: string | undefined): number | null {
2572
- if (raw === undefined) return null;
2573
- const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*%?$/);
2574
- if (m === null) return null;
2575
- const v = Number(m[1]);
2576
- return Number.isFinite(v) ? v : null;
4721
+ function 軸と区画の名前(doc: DslDocument): {
4722
+ xAxis: { left: string; right: string };
4723
+ yAxis: { bottom: string; top: string };
4724
+ quadrantLabels: { topLeft: string; topRight: string; bottomLeft: string; bottomRight: string };
4725
+ } {
4726
+ if (doc.axes === undefined) return 軸の既定;
4727
+ const left = doc.axes.x?.left ?? 軸の既定.xAxis.left;
4728
+ const right = doc.axes.x?.right ?? 軸の既定.xAxis.right;
4729
+ const bottom = doc.axes.y?.bottom ?? 軸の既定.yAxis.bottom;
4730
+ const top = doc.axes.y?.top ?? 軸の既定.yAxis.top;
4731
+ return {
4732
+ xAxis: { left, right },
4733
+ yAxis: { bottom, top },
4734
+ quadrantLabels: {
4735
+ topLeft: `${top} × ${left}`,
4736
+ topRight: `${top} × ${right}`,
4737
+ bottomLeft: `${bottom} × ${left}`,
4738
+ bottomRight: `${bottom} × ${right}`,
4739
+ },
4740
+ };
2577
4741
  }
2578
4742
 
2579
4743
  /**
2580
- * Pie preset (円グラフ)。
4744
+ * 軸の名前を、それを持たない図種で書いた時に伝える (#1251)。
2581
4745
  *
2582
- * 描画側の `chart-pie` に 1 node で渡す。 以前は `card` を縦に積むだけで、 `type: pie`
2583
- * 書いても円が出ず、 割合が箱の説明文として枠からはみ出していた (実機報告)。
2584
- *
2585
- * 大きさは cdl の `chart()` preset と同じ 640x320 (どちらも格子 16 の倍数)。 lane 幅は
2586
- * `chart()` が使う `gridAlignedLaneW` と同じ計算 = 中身 + 左右の余白 32 ずつ。
2587
- *
2588
- * 値は actor の説明文から読む (`- TypeScript: "45%"`)。 読めない actor は円に載せず、
2589
- * まとめて警告に出す。 合計が 100 にならなくても描画側が比で割るので、 こちらでは正規化しない。
4746
+ * 軸を持つのは `type: quadrant` だけ。 他の図種では相手が無く、黙って捨てると
4747
+ * 「書いたのに出ない」 が手掛かりなしで起きる。
2590
4748
  */
2591
- function compilePie(doc: DslDocument): CdlDiagram {
2592
- const b = diagram(slugify(doc.title), { topic: doc.title });
2593
- const CHART_W = 640;
2594
- const CHART_H = 320;
2595
- b.lane("chart", { width: CHART_W + 64, label: doc.title });
4749
+ function reportAxesNotHonored(doc: DslDocument, onNotice?: (n: CompileNotice) => void): void {
4750
+ if (!onNotice) return;
4751
+ if (doc.axes === undefined) return;
4752
+ if (doc.type === "quadrant") return;
4753
+ onNotice({
4754
+ kind: "chart-value-unreadable",
4755
+ actor: doc.title,
4756
+ line: doc.axesPos?.line ?? doc.pos?.line ?? 0,
4757
+ message: `最上位に書いた axes は効きません (type: ${doc.type} には軸がありません)`,
4758
+ hint: "2 つの軸で仕分ける図を描くなら type: quadrant を使ってください",
4759
+ });
4760
+ }
2596
4761
 
2597
- const data: NonNullable<CdlDiagram["nodes"][number]["chartData"]> = [];
4762
+ function compileQuadrant(doc: DslDocument, onNotice?: (n: CompileNotice) => void): CdlDiagram {
4763
+ const b = diagram(slugify(doc.title), { topic: doc.title });
4764
+ const { w: W, h: H } = 図表の大きさ.quadrant;
4765
+ b.lane("chart", { width: W + 64 });
4766
+ const items: NonNullable<CdlDiagram["nodes"][number]["quadrantData"]>["items"] = [];
2598
4767
  const 読めない: string[] = [];
4768
+ const 参照できる = 語の欄から参照できる名前(doc, 区画);
2599
4769
  for (const a of doc.actors) {
2600
- // 割合の置き場所は記法で 2 通りある。 略記 (`- TypeScript: "45%"`) は説明文に、
2601
- // 縦書きの map (`- SliceA: { kind: card, value: "30%" }`) は値に入る。 両方を読む
2602
- const value = parseShareValue(a.value ?? a.subtitle);
2603
- if (value === null) {
4770
+ const = (a.value ?? a.subtitle ?? "").trim();
4771
+ const 参照 = 語.match(/^\{(\w+)\}$/);
4772
+ if (参照) {
4773
+ if (!参照できる.has(参照[1]!)) {
4774
+ 読めない.push(a.name);
4775
+ continue;
4776
+ }
4777
+ items.push({ id: slugify(a.name), title: a.name, quadrant: 語 as never });
4778
+ continue;
4779
+ }
4780
+ const q = 区画.get(語);
4781
+ if (q === undefined) {
2604
4782
  読めない.push(a.name);
2605
4783
  continue;
2606
4784
  }
2607
- // 色は扇にそのまま渡す。 箱が 1 つになっても、 書いた色が消えないようにする
2608
- data.push({ label: a.name, value, ...(a.tone !== undefined ? { tone: a.tone } : {}) });
4785
+ items.push({ id: slugify(a.name), title: a.name, quadrant: q });
2609
4786
  }
2610
- if (読めない.length > 0 && typeof console !== "undefined" && console.warn) {
2611
- console.warn(
2612
- `[dragon] type: pie で割合を読めない項目があります (円に載せません): ${読めない.join(", ")}。` +
2613
- ` \`- 名前: "45%"\` の形で書いてください`,
2614
- );
4787
+ if (読めない.length > 0) {
4788
+ const m = `type: quadrant で区画を読めない項目があります (図に載せません): ${読めない.join(", ")}。 \`- 重複削除: "左上"\` の形で、 ${[...区画.keys()].join(" / ")} のどれかを書いてください`;
4789
+ onNotice?.({ kind: "chart-value-unreadable", actor: 読めない[0]!, line: 0, message: m });
4790
+ if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${m}`);
2615
4791
  }
2616
- // 円グラフは扇 1 枚が 1 項目で、 項目どうしを結ぶ線が無い。 書いた矢印は描けないので、
2617
- // 黙って捨てずに伝える (「書いたのに効かない」 を残さない)
2618
- if (doc.flow.length > 0 && typeof console !== "undefined" && console.warn) {
2619
- console.warn(
2620
- `[dragon] type: pie では矢印を描けません (${doc.flow.length} 本を無視しました)。` +
2621
- ` 関係を描くなら type: flow を使ってください`,
2622
- );
4792
+ // 矢印は描けない。 書かれていたら伝える (黙って捨てると「書いたのに効かない」 が残る)
4793
+ if (doc.flow.length > 0) {
4794
+ const m2 = `type: quadrant では矢印を描けません (${doc.flow.length} 本を無視しました)。 関係を描くなら type: flow を使ってください`;
4795
+ onNotice?.({ kind: "chart-edge-dropped", actor: doc.flow[0]?.from ?? "", line: doc.flow[0]?.pos?.line ?? 0, message: m2 });
4796
+ if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${m2}`);
2623
4797
  }
2624
-
2625
- b.node(`${slugify(doc.title) || "pie"}-chart`, {
2626
- lane: "chart",
2627
- stack: 0,
2628
- kind: "chart-pie",
2629
- title: doc.title,
2630
- w: CHART_W,
2631
- h: CHART_H,
2632
- chartData: data,
4798
+ b.node(`${slugify(doc.title) || "quadrant"}-chart`, {
4799
+ lane: "chart", stack: 0, kind: "quadrant-matrix", title: doc.title, ...図の小見出し(doc), w: W, h: H,
4800
+ quadrantData: { ...軸と区画の名前(doc), items },
2633
4801
  });
2634
-
2635
4802
  return b.build();
2636
4803
  }
2637
4804
 
@@ -2743,86 +4910,315 @@ function compileC4(doc: DslDocument): CdlDiagram {
2743
4910
  *
2744
4911
  * flow ... 宣言なしなら root → 各 leaf の暗黙 edge を自動生成、 宣言ありならそれを採用。
2745
4912
  */
2746
- function compileMind(doc: DslDocument): CdlDiagram {
4913
+ /**
4914
+ * 記法の `type: mind` を engine の `mind-map` 種別に寄せる (#1177)。
4915
+ *
4916
+ * 以前は `card` を 3 列 (`mind-left` / `mind-center` / `mind-right`) に並べる別実装で、
4917
+ * engine の `mind-map` を使っていなかった。 そのため 2 つの穴があった。
4918
+ *
4919
+ * | 穴 | 中身 |
4920
+ * |---|---|
4921
+ * | 枝の親を見る規則が届かない | `ruleMindMapParentReference` は `kind === "mind-map"` かつ `mindData` を持つ node にしか当たらない |
4922
+ * | `SINGLE_BOX_KINDS` の `mind-map` が到達しない | 一覧に載っているのに記法から辿り着けない項目として残る |
4923
+ *
4924
+ * **枝の親は書けない**。 記法の `actors` は「1 つ目が根、 残りが枝」 の並びで、 `parent` を
4925
+ * 書く場所が無い。 全ての枝を根の直下に置く。 親子を矢印で書く形は `type: tree` が持っており、
4926
+ * `mind` は簡便形として別に残す (Issue の 実装しない条件)。
4927
+ *
4928
+ * したがって **矢印は描けない**。 書かれていたら伝える = 黙って捨てると「書いたのに効かない」
4929
+ * が残る (`type: journey` / `type: quadrant` と同じ扱い)。
4930
+ *
4931
+ * 絵は変わる (3 列の箱 → 中心から放射)。 破壊的変更として `CHANGELOG` に記録している。
4932
+ */
4933
+ /**
4934
+ * 1 箱で描く放射が **描ける欄**。 これ以外は書いても出ない (#1177 Round 3)。
4935
+ *
4936
+ * 数え上げは 3 度直した。 副題 / 値 / 行 → 位置 / 大きさ → 種類 / 枠 / 積む順 …と、 見落とした
4937
+ * 欄が review のたびに出た。 数え漏らしても検査は通ってしまう = 「書いたのに出ない」 が黙って
4938
+ * 残る形が繰り返し発生した。
4939
+ *
4940
+ * そこで **`DslActor` の全ての欄を、 描ける側か描けない側のどちらかに必ず割り当てる**。
4941
+ * 欄が増えた時に両方へ入れ忘れると型検査が落ちるので、 「描けるのか描けないのか」 を必ず
4942
+ * 判断することになる (`rules/quality.md § 多層 SSOT 経路の全 registration 保証` と同じ形)。
4943
+ */
4944
+ type 放射で描ける欄 =
4945
+ /** 中心の名前 / 枝の名前になる */
4946
+ | "name"
4947
+ /**
4948
+ * 名前の後ろに続けて出す 2 欄。
4949
+ *
4950
+ * `- 描く量を減らす: "{draw} ms"` の形は副題として解析されるため、 値の欄だけを見ると
4951
+ * 記法で最も普通な書き方が届かない (#1230 で実測)。 明示的に書いた値の欄も同じ場所へ出す。
4952
+ *
4953
+ * 描画側が描くのは枝と中心の `title` だけで (`mind-map.tsx` は型にある `subtitle` を
4954
+ * 1 度も描かない)、 状態を読み替えるのも `title` と `rootTitle` に限る
4955
+ * (`resolveMindData`)。 届ける先が他に無いため名前と同じ欄へ載せる。
4956
+ */
4957
+ | "subtitle"
4958
+ | "value"
4959
+ /** 枝の色 (`MindBranchNode.tone`)。 中心は持てないので `描けない欄` が別に見る */
4960
+ | "tone"
4961
+ /** 見本は放射に載せず、 見本の中身だけを描く (別経路で伝える) */
4962
+ | "partId"
4963
+ /** 種類を書いたかどうかの印。 `kind` と対で見るので単独では扱わない */
4964
+ | "kindWritten"
4965
+ /** 本文の行番号。 知らせに載せるために使う */
4966
+ | "pos";
4967
+
4968
+ /** 放射では描けない欄。 書かれていたら伝える */
4969
+ type 放射で描けない欄 =
4970
+ | "kind"
4971
+ | "eyebrow"
4972
+ | "touchpoint"
4973
+ | "opportunity"
4974
+ | "owner"
4975
+ | "end"
4976
+ | "rows"
4977
+ | "lane"
4978
+ | "stack"
4979
+ | "initial"
4980
+ | "final"
4981
+ | "colorHex"
4982
+ | "stateOverride"
4983
+ | "posX"
4984
+ | "posY"
4985
+ | "posW"
4986
+ | "posH"
4987
+ | "scale"
4988
+ | "scaleKeys"
4989
+ | "posRel"
4990
+ | "nodes"
4991
+ | "layoutPos";
4992
+
4993
+ /** 引数が `never` でなければ型検査が落ちる */
4994
+ type 空であること<T extends never> = T;
4995
+
4996
+ /** `DslActor` に割り当て漏れの欄があると落ちる */
4997
+ export type _放射の欄を覆えている = 空であること<
4998
+ Exclude<keyof DslActor, 放射で描ける欄 | 放射で描けない欄>
4999
+ >;
5000
+
5001
+ /** `DslActor` に無い欄を割り当てていると落ちる */
5002
+ export type _放射の欄に余りがない = 空であること<
5003
+ Exclude<放射で描ける欄 | 放射で描けない欄, keyof DslActor>
5004
+ >;
5005
+
5006
+ /** 描ける側と描けない側が重なっていると落ちる */
5007
+ export type _放射の欄が重なっていない = 空であること<Extract<放射で描ける欄, 放射で描けない欄>>;
5008
+
5009
+ /**
5010
+ * 描けない欄と、 知らせに出す名前。
5011
+ *
5012
+ * **欄ごとに式を持たせない** (Round 5 の指摘)。 `{ 説明, 書いたか }` の形にすると、 項目名と式が
5013
+ * 型で結ばれず `scale: { 書いたか: () => false }` のように **判定を骨抜きにしても型検査が通る**。
5014
+ * 名前だけを持ち、 書かれていたかは下の 1 つの式で見る。
5015
+ *
5016
+ * `Record<放射で描けない欄, string>` なので、 欄を足すと項目も要る。 判定の側は欄ごとに書く所が
5017
+ * 無いため、 書き忘れも骨抜きも起きない。
5018
+ *
5019
+ * 表示の名前は複数の欄で同じでよい (`posX` と `posY` はどちらも「位置 (座標)」)。 出す時に
5020
+ * 重複を除く。
5021
+ */
5022
+ const 放射で描けない欄の名前: Record<放射で描けない欄, string> = {
5023
+ kind: "種類",
5024
+ eyebrow: "上の小見出し",
5025
+ touchpoint: "場所 (体験の道筋の欄)",
5026
+ opportunity: "改善の余地 (体験の道筋の欄)",
5027
+ owner: "担当 (工程の並びの欄)",
5028
+ end: "終わる時期 (工程の並びの欄)",
5029
+ rows: "行",
5030
+ lane: "枠の指定",
5031
+ stack: "積む順",
5032
+ initial: "始まり / 終わり の印",
5033
+ final: "始まり / 終わり の印",
5034
+ colorHex: "色番号",
5035
+ stateOverride: "状態の上書き",
5036
+ // 位置は「登場人物ごとの箱をどこに置くか」 の指定で、 箱が 1 つの図では置く先が無い
5037
+ posX: "位置 (座標)",
5038
+ posY: "位置 (座標)",
5039
+ posW: "大きさ",
5040
+ posH: "大きさ",
5041
+ scale: "倍率",
5042
+ scaleKeys: "倍率",
5043
+ posRel: "位置 (相対)",
5044
+ nodes: "中の箱ごとの指定",
5045
+ layoutPos: "配置のずらし",
5046
+ };
5047
+
5048
+ /**
5049
+ * その欄が書かれていたか。 **全ての欄をこの 1 つの式で見る**。
5050
+ *
5051
+ * 欄ごとに式を持たせると、 1 つだけ骨抜きにしても型検査が通る (Round 5 の指摘)。 1 つにすれば
5052
+ * 骨抜きにした時点で全ての欄の検査が落ちる。
5053
+ *
5054
+ * 例外は種類だけ。 既定値 (`actor`) が必ず入るので、 書いたかどうかの印 (`kindWritten`) で見る。
5055
+ * 真偽を持つ欄 (`initial` / `final`) は `false` を「書いていない」 として扱う = 既定と同じ意味で、
5056
+ * 伝えると書いていない人にも出る。
5057
+ */
5058
+ function 放射で描けない欄を書いたか(a: DslActor, 欄: 放射で描けない欄): boolean {
5059
+ if (欄 === "kind") return a.kindWritten === true;
5060
+ const v = a[欄];
5061
+ if (typeof v === "boolean") return v;
5062
+ return v !== undefined;
5063
+ }
5064
+
5065
+ /**
5066
+ * 放射の枝と中心に出す文字を組む (#1230)。
5067
+ *
5068
+ * 値の欄を書いた登場人物は名前の後ろに空白 1 つで続ける。 書いていない図は名前だけになり、
5069
+ * 変更前と同じ文字列になる。
5070
+ *
5071
+ * **名前と同じ欄に載せるしかない**。 描画側が描くのは枝と中心の `title` だけで
5072
+ * (`mind-map.tsx` は型にある `subtitle` を 1 度も描かない)、 状態を読み替えるのも
5073
+ * `title` と `rootTitle` に限る (`resolveMindData`)。 値を別の欄へ渡しても絵に出ない。
5074
+ */
5075
+ function 放射に出す文字(a: DslActor): string {
5076
+ const 続き = [a.subtitle, a.value].map((x) => x?.trim()).filter((x): x is string => !!x);
5077
+ return 続き.length > 0 ? `${a.name} ${続き.join(" ")}` : a.name;
5078
+ }
5079
+
5080
+ function compileMind(doc: DslDocument, onNotice?: (n: CompileNotice) => void): CdlDiagram {
2747
5081
  const b = diagram(slugify(doc.title), { topic: doc.title });
2748
- const LEAF_W = 280;
2749
- const ROOT_W = 320;
2750
- const GAP = 80;
5082
+ const { w: W, h: H } = 図表の大きさ.mind;
5083
+
5084
+ const 伝える = (kind: CompileNotice["kind"], 名: string, message: string, line = 0): void => {
5085
+ onNotice?.({ kind, actor: 名, line, message });
5086
+ if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${message}`);
5087
+ };
2751
5088
 
2752
- // 枝は左右に交互に置く。 中身のある枠だけ作る (#1096)。
5089
+ // **見本 (`parts`) を重ねた登場人物は中心にも枝にもしない** (review 指摘)。 後段の
5090
+ // `mergePartsFromActors` が見本の中身を別の箱として足すため、 こちらにも載せると同じ
5091
+ // 登場人物が 2 箇所に描かれる。 中心だけ外し忘れると、 中心が見本の記法で二重になる
5092
+ const 見本でない: DslActor[] = [];
5093
+ for (const a of doc.actors) {
5094
+ if (a.partId !== undefined) {
5095
+ 伝える(
5096
+ "part-not-drawn",
5097
+ a.name,
5098
+ `type: mind では見本 (${a.partId}) を中心にも枝にもできません。 見本はそのまま描き、 放射には載せません`,
5099
+ a.pos?.line ?? 0,
5100
+ );
5101
+ continue;
5102
+ }
5103
+ 見本でない.push(a);
5104
+ }
5105
+ // 枝の親は矢印で決まる (#1251)。 書かなければ全て中心の直下 = 従来と同じ図になる。
5106
+ // 規則は `type: tree` と共有する = 同じ本文で図種だけ変えた時に親子の解釈が割れない。
2753
5107
  //
2754
- // 3 枠を固定で作ると、 枝が 1 本の図で右の枠が中身なしで残る (実測 = 登場人物 2 人で
2755
- // `mind-right` が空)。 見る人には「何かが描かれ損ねた」 ようにしか見えない (`#1078` で
2756
- // `c4` を直したのと同じ欠陥)
2757
- const 枝の数 = Math.max(0, doc.actors.length - 1);
2758
- const 左に置く数 = Math.ceil(枝の数 / 2);
2759
- const 右に置く数 = 枝の数 - 左に置く数;
2760
- const 左を使う = 左に置く数 > 0;
2761
- const 右を使う = 右に置く数 > 0;
2762
-
2763
- // 登場人物が 0 人なら枠も作らない。 中央の枠を先に作ると、 中身の無い枠が 1 つ残る
2764
- // (実測 = `title` `type` だけの本文で `mind-center` が空、 Round 1 review の指摘)
2765
- if (doc.actors.length === 0) return b.build();
5108
+ // **早期 return より前に置く** = 見本しか居ない記法で矢印を書いた時、 枝が 1 本も無いため
5109
+ // どの矢印も親にできない。 後ろに置くと、その形で矢印が黙って消える (Round 3 の指摘と同じ理由)
5110
+ const 枝の名前 = new Set(見本でない.map((a) => slugify(a.name)));
5111
+ const = 矢印から親を決める(doc, "mind", 枝の名前, (名, message, line) =>
5112
+ // 親にできなかった矢印は描かれない。 知らせの種別も「矢印を落とした」 にする
5113
+ 伝える("chart-edge-dropped", 名, message, line ?? 0),
5114
+ );
5115
+
5116
+ // 放射に載る登場人物が 0 人なら枠も作らない。 中身の無い枠が 1 つ残るのを避ける (#1096)。
5117
+ // **枠を作る前に見る** = 見本しか居ない記法で作ると、 見本だけが描かれた図に空の枠が残る
5118
+ if (見本でない.length === 0) return b.build();
2766
5119
 
2767
- // 使う枠だけ左から詰める。 飛ばした位置に隙間を残すと、 やはり「抜けている」 ように見える
2768
- let x = 0;
2769
- if (左を使う) {
2770
- b.lane("mind-left", { x, width: LEAF_W, label: "" });
2771
- x += LEAF_W + GAP;
5120
+ b.lane("chart", { width: W + 64 });
5121
+
5122
+ // **同じ slug になる名前を先に見る** (`type: tree` と同じ理由) 違う名前が同じ id に潰れると、
5123
+ // 枝が 1 本消えたり、 枝の親を見る規則が別の枝を指したりする
5124
+ const slug別 = new Map<string, string[]>();
5125
+ for (const a of 見本でない) {
5126
+ const k = slugify(a.name);
5127
+ slug別.set(k, [...(slug別.get(k) ?? []), a.name]);
2772
5128
  }
2773
- b.lane("mind-center", { x, width: ROOT_W, label: doc.title });
2774
- x += ROOT_W + GAP;
2775
- if (右を使う) {
2776
- b.lane("mind-right", { x, width: LEAF_W, label: "" });
5129
+ for (const [k, 群] of slug別) {
5130
+ if (群.length > 1) {
5131
+ 伝える(
5132
+ "chart-value-unreadable",
5133
+ 群[0]!,
5134
+ `type: mind で ${群.join(" / ")} が同じ id (${k}) になります。 名前を変えてください`,
5135
+ );
5136
+ }
2777
5137
  }
2778
5138
 
2779
- const root = doc.actors[0]!;
5139
+ const root = 見本でない[0]!;
2780
5140
  const rootId = slugify(root.name) || "root";
2781
- const leafCount = doc.actors.length - 1;
2782
- const rootStack = Math.floor(leafCount / 2);
2783
- b.node(rootId, {
2784
- lane: "mind-center",
2785
- stack: rootStack,
2786
- kind: "card",
2787
- title: root.name,
2788
- w: ROOT_W,
2789
- });
2790
5141
 
2791
- const stackLeft = { v: 0 };
2792
- const stackRight = { v: 0 };
2793
- doc.actors.slice(1).forEach((a, i) => {
2794
- const isLeft = i % 2 === 0;
2795
- const lid = isLeft ? "mind-left" : "mind-right";
2796
- const counter = isLeft ? stackLeft : stackRight;
2797
- const nodeId = slugify(a.name) || `leaf-${i}`;
2798
- b.node(nodeId, {
2799
- lane: lid,
2800
- stack: counter.v,
2801
- kind: "card",
2802
- title: a.name,
2803
- w: LEAF_W,
5142
+ // **中心を子にする矢印は表せない** (#1251 Round 1 の指摘)。 中心は枝の並びに居ないため
5143
+ // 親を持てず、 解決はできても誰にも読まれずに消える。 黙って捨てると「書いたのに
5144
+ // 図が変わらない」 が手掛かりなしで起きるので、 行番号付きで伝える
5145
+ for (const f of doc.flow) {
5146
+ if (slugify(f.to) !== rootId) continue;
5147
+ 伝える(
5148
+ "chart-edge-dropped",
5149
+ f.to,
5150
+ `type: mind で中心 (${truncateForMessage(f.to)}) を子にはできません (${truncateForMessage(f.from)} -> ${truncateForMessage(f.to)} を使いません)。 中心は放射の真ん中に置く 1 つだけです`,
5151
+ f.pos?.line ?? 0,
5152
+ );
5153
+ }
5154
+
5155
+ // **1 箱で描く種別が持てる欄は限られる**。 枝は名前と色、 中心は名前だけ。 書いても描けない
5156
+ // 欄は伝える = 箱ごとに描いていた頃は載っていた欄で、 黙って消すと「書いたのに出ない」 が残る
5157
+ const 描けない欄 = (a: DslActor): string[] => {
5158
+ const out: string[] = [];
5159
+ for (const 欄 of Object.keys(放射で描けない欄の名前) as 放射で描けない欄[]) {
5160
+ if (!放射で描けない欄を書いたか(a, 欄)) continue;
5161
+ const 名前 = 放射で描けない欄の名前[欄];
5162
+ // 同じ名前を持つ欄 (`posX` と `posY`) は 1 度だけ出す
5163
+ if (!out.includes(名前)) out.push(名前);
5164
+ }
5165
+ return out;
5166
+ };
5167
+ const 消えた欄 = new Map<string, string[]>();
5168
+ const 記録する = (a: DslActor): void => {
5169
+ const 欄 = 描けない欄(a);
5170
+ if (欄.length > 0) 消えた欄.set(a.name, 欄);
5171
+ };
5172
+
5173
+ const branches: NonNullable<CdlDiagram["nodes"][number]["mindData"]>["branches"] = [];
5174
+ const 使った = new Set<string>([rootId]);
5175
+ 記録する(root);
5176
+ // 中心は色の欄を持たない (`MindBranchPayload` に `tone` が無い)
5177
+ if (root.tone) 消えた欄.set(root.name, [...(消えた欄.get(root.name) ?? []), "色 (中心は持てない)"]);
5178
+
5179
+ 見本でない.slice(1).forEach((a, i) => {
5180
+ const id = slugify(a.name) || `leaf-${i}`;
5181
+ if (使った.has(id)) {
5182
+ 伝える(
5183
+ "chart-value-unreadable",
5184
+ a.name,
5185
+ `type: mind で ${a.name} が既にある id (${id}) と重なります (枝に載せません)`,
5186
+ a.pos?.line ?? 0,
5187
+ );
5188
+ return;
5189
+ }
5190
+ 使った.add(id);
5191
+ 記録する(a);
5192
+ // 矢印を書かなかった枝は中心の直下。 中心を親に指した矢印も同じ値になる
5193
+ // (中心は `見本でない` の先頭なので、 その名前の slug が `rootId` そのもの)
5194
+ // 枝は色を持てる (`MindBranchNode.tone`)
5195
+ branches.push({
5196
+ id,
5197
+ title: 放射に出す文字(a),
5198
+ parent: 親.get(id) ?? rootId,
5199
+ ...(a.tone ? { tone: a.tone } : {}),
2804
5200
  });
2805
- counter.v += 1;
2806
5201
  });
2807
5202
 
2808
- if (doc.flow.length === 0 && doc.actors.length > 1) {
2809
- doc.actors.slice(1).forEach((a) => {
2810
- const leafId = slugify(a.name);
2811
- b.edge(rootId, leafId, { label: "" });
2812
- });
2813
- } else {
2814
- for (const s of doc.flow) {
2815
- const fromId = slugify(s.from);
2816
- const toId = slugify(s.to);
2817
- b.edge(fromId, toId, {
2818
- label: s.label,
2819
- ...(s.sub ? { sub: s.sub } : {}),
2820
- ...(s.tone ? { tone: s.tone } : {}),
2821
- ...(s.style ? { style: s.style } : {}),
2822
- });
2823
- }
5203
+ if (消えた欄.size > 0) {
5204
+ const 一覧 = [...消えた欄].map(([名, 欄]) => `${名} の${欄.join(" / ")}`).join("、 ");
5205
+ 伝える(
5206
+ "chart-value-unreadable",
5207
+ [...消えた欄.keys()][0]!,
5208
+ `type: mind は名前と副題 / 値、 枝の色しか描けません (描かない欄: ${一覧})。 これらを描くなら type: tree か type: flow を使ってください`,
5209
+ );
2824
5210
  }
2825
5211
 
5212
+ b.node(`${slugify(doc.title) || "mind"}-chart`, {
5213
+ lane: "chart",
5214
+ stack: 0,
5215
+ kind: "mind-map",
5216
+ title: doc.title,
5217
+ ...図の小見出し(doc),
5218
+ w: W,
5219
+ h: H,
5220
+ mindData: { rootId, rootTitle: 放射に出す文字(root), branches },
5221
+ });
2826
5222
  return b.build();
2827
5223
  }
2828
5224
 
@@ -2838,7 +5234,82 @@ function compileMind(doc: DslDocument): CdlDiagram {
2838
5234
  * これにより v0.5 syntax で 19 機能のうち以下が動く:
2839
5235
  * subtitle / eyebrow / value / rows / contain / lifeline / label / lane.x / lane.width / laneWidth
2840
5236
  */
2841
- function applyV05Extensions(diagram: CdlDiagram, doc: DslDocument): CdlDiagram {
5237
+ /**
5238
+ * `lanes:` で作った縦列と、見本 (parts) が作った同一idの縦列を 1 つに重ねる (#1241)。
5239
+ *
5240
+ * `lanes:` の中身を読むのは見本を重ねるより前で、その時点では見本の縦列がまだ無い。
5241
+ * そのため同じ id を書くと **縦列が 2 つできて、書いた幅は箱の入っていない方に付く**
5242
+ * (実測 = `g__l` が 777 と 400 の 2 本になり、箱は 400 の方に入った)。
5243
+ *
5244
+ * 後から来た見本の縦列に書いた値を移し、先に作った空の方を外す。 書いた人から見れば
5245
+ * 「id を書けば効く」 が成り立つ。
5246
+ */
5247
+ function mergeDuplicateDeclaredLanes(
5248
+ diagram: CdlDiagram,
5249
+ 追加した縦列: readonly DslLane[],
5250
+ ): void {
5251
+ for (const 宣言 of 追加した縦列) {
5252
+ const { id } = 宣言;
5253
+ const 同一idの縦列 = diagram.lanes.filter((l) => l.id === id);
5254
+ if (同一idの縦列.length < 2) continue;
5255
+ // 宣言 lane は parts より先に追加されるため、後から作った parts lane を残す。
5256
+ const 残す = 同一idの縦列.at(-1);
5257
+ if (残す === undefined) continue;
5258
+ const 元の中心X = (残す.x ?? 0) + 残す.width / 2;
5259
+ // 新規 lane に入れた既定値ではなく、DSL に明示された値だけを parts lane へ重ねる。
5260
+ if (宣言.x !== undefined) 残す.x = 宣言.x;
5261
+ if (宣言.width !== undefined) 残す.width = 宣言.width;
5262
+ if (宣言.label !== undefined) 残す.label = 宣言.label;
5263
+ if (宣言.contain !== undefined) 残す.contain = 宣言.contain;
5264
+ if (宣言.lifeline !== undefined) 残す.lifeline = 宣言.lifeline;
5265
+ const 移動X = (残す.x ?? 0) + 残す.width / 2 - 元の中心X;
5266
+ // parts node は絶対座標を持つため、lane だけ動かすと箱が元の場所に残る。
5267
+ for (const node of diagram.nodes) {
5268
+ if (node.lane === id && node.posX !== undefined) node.posX += 移動X;
5269
+ }
5270
+ for (let i = diagram.lanes.length - 1; i >= 0; i -= 1) {
5271
+ if (diagram.lanes[i]?.id === id && diagram.lanes[i] !== 残す) diagram.lanes.splice(i, 1);
5272
+ }
5273
+ }
5274
+ }
5275
+
5276
+ /**
5277
+ * `lanes:` で作った縦列に箱が 1 つも入らなかったら伝える (#1241)。
5278
+ *
5279
+ * 意図して空の縦列を置くことはあるが、その場合も「置いた」 と分かる形で知らせる方が、
5280
+ * 書き間違いを黙って捨てるより良い。
5281
+ */
5282
+ function reportEmptyDeclaredLanes(
5283
+ diagram: CdlDiagram,
5284
+ 追加した縦列: readonly DslLane[],
5285
+ onNotice?: (n: CompileNotice) => void,
5286
+ ): void {
5287
+ if (追加した縦列.length === 0) return;
5288
+ mergeDuplicateDeclaredLanes(diagram, 追加した縦列);
5289
+ if (!onNotice) return;
5290
+ const 使われている = new Set(diagram.nodes.map((n) => n.lane));
5291
+ const ある縦列 = diagram.lanes.map((l) => l.id).filter((x) => 使われている.has(x));
5292
+ for (const { id, pos } of 追加した縦列) {
5293
+ if (使われている.has(id)) continue;
5294
+ onNotice({
5295
+ kind: "lane-declared-empty",
5296
+ actor: id,
5297
+ line: pos?.line ?? 0,
5298
+ message: `lanes に書いた ${truncateForMessage(id)} はどの箱も入らない縦列です (新しく作りました)`,
5299
+ hint:
5300
+ ある縦列.length > 0
5301
+ ? `この図が持つ縦列 = ${ある縦列.join(" / ")}`
5302
+ : "この図は箱の入った縦列を持ちません",
5303
+ });
5304
+ }
5305
+ }
5306
+
5307
+ function applyV05Extensions(
5308
+ diagram: CdlDiagram,
5309
+ doc: DslDocument,
5310
+ /** `lanes:` が新しく作った縦列。 箱が入ったかは見本を重ねた後でないと分からない (#1241) */
5311
+ 追加した縦列out?: DslLane[],
5312
+ ): CdlDiagram {
2842
5313
  // actor の主要 node を preset 種別で回収する。 sequence / solidity は header/footer を対で生成する
2843
5314
  // preset で主要 node は header、 それ以外の preset は actor 名 slug がそのまま node id になる。
2844
5315
  //
@@ -2884,6 +5355,19 @@ function applyV05Extensions(diagram: CdlDiagram, doc: DslDocument): CdlDiagram {
2884
5355
  if (a.eyebrow !== undefined) node.eyebrow = a.eyebrow;
2885
5356
  if (a.value !== undefined) node.value = a.value;
2886
5357
  if (a.rows !== undefined) node.rows = a.rows;
5358
+ // 箱の大きさを反映する (#1259)。 **animation の有無に関係なく** = 動く図専用の
5359
+ // 組み立てだけで渡すと、同じ記法でも静止図では指定が消える。
5360
+ //
5361
+ // 順序図は名札 / 余白 / 足を組で作り、大きさが縦線の並びと結びつくため対象外。
5362
+ //
5363
+ // **幅が図に出るかは縦列との大小で決まる**。 縦列に収まれば箱だけが変わり、
5364
+ // 縦列より広ければ縦列ごと押し広げる (実測 = ステート図の 320 は縦列 370 に収まって
5365
+ // 図が変わらないが、拡張ステート図の 280 に対し既定 640 は縦列 330 を押し広げた)。
5366
+ // #1260 で 1 件だけ見て「見た目に出ない」 と判断し配線を外した = 同じ誤りを繰り返さない
5367
+ if (!isSeqLike) {
5368
+ if (a.posW !== undefined) node.w = a.posW;
5369
+ if (a.posH !== undefined) node.h = a.posH;
5370
+ }
2887
5371
  // seq-like preset の header / footer は kind を card 固定で作る。 書いた kind を載せる
2888
5372
  // (#975)。 載せないと「書いたのに効かない項目」 が残り、 `rows` を書いた時は行が card に
2889
5373
  // 付いて画面から消える (#387、 cdl 側 Axis 67 rows-not-rendered が検知する)。
@@ -2955,6 +5439,12 @@ function applyV05Extensions(diagram: CdlDiagram, doc: DslDocument): CdlDiagram {
2955
5439
  injectPhasesFallback(diagram, doc);
2956
5440
  }
2957
5441
  // top-level lanes section → lane merge
5442
+ //
5443
+ // **書いた id がどの縦列とも合わない形を後で伝える** (#1241)。 合わない id は新しい縦列を
5444
+ // 作るだけで、書いた幅や見出しは元の縦列に届かない。 書き間違い (`lane-idl` / 全角の
5445
+ // `lane-A`) がこの形になり、黙って捨てられていた (実測 = 幅 999 を持つ空の縦列が増え、
5446
+ // 元の縦列は 360 のままだった)
5447
+ const 追加した縦列: DslLane[] = 追加した縦列out ?? [];
2958
5448
  if (doc.lanes) {
2959
5449
  for (const [id, laneOpt] of Object.entries(doc.lanes)) {
2960
5450
  const lane = diagram.lanes.find((l) => l.id === id);
@@ -2974,6 +5464,7 @@ function applyV05Extensions(diagram: CdlDiagram, doc: DslDocument): CdlDiagram {
2974
5464
  contain: laneOpt.contain,
2975
5465
  lifeline: laneOpt.lifeline,
2976
5466
  });
5467
+ 追加した縦列.push(laneOpt);
2977
5468
  }
2978
5469
  }
2979
5470
  }
@@ -3153,8 +5644,11 @@ function compileSequenceWithAnimate(doc: DslDocument): CdlDiagram {
3153
5644
  // step ごとに DSL flow item に対応、 actor 名 → lane id の slugify を活用。
3154
5645
  const stepEdgeIds: string[] = [];
3155
5646
  doc.flow.forEach((s, idx) => {
3156
- const fromLaneId = actorIds.get(s.from) ?? s.from;
3157
- const toLaneId = actorIds.get(s.to) ?? s.to;
5647
+ // 解決できない名前の矢印は落とす (#1209) 以前は名前をそのまま lane id として使い、
5648
+ // 存在しない lane に箱を置いた図ができていた
5649
+ const fromLaneId = actorIds.get(s.from);
5650
+ const toLaneId = actorIds.get(s.to);
5651
+ if (fromLaneId === undefined || toLaneId === undefined) return;
3158
5652
  const stack = idx + 2;
3159
5653
  const fromBoxId = `s${idx}-${fromLaneId}`;
3160
5654
  const toBoxId = `s${idx}-${toLaneId}`;
@@ -3169,10 +5663,6 @@ function compileSequenceWithAnimate(doc: DslDocument): CdlDiagram {
3169
5663
  ...(s.sub ? { sub: s.sub } : {}),
3170
5664
  ...(s.tone ? { tone: s.tone } : {}),
3171
5665
  ...(s.style ? { style: s.style } : {}),
3172
- ...(s.guard ? { guard: s.guard } : {}),
3173
- ...(s.cardinality ? { cardinality: s.cardinality } : {}),
3174
- ...(s.labelOffsetX !== undefined ? { labelOffsetX: s.labelOffsetX } : {}),
3175
- ...(s.labelOffsetY !== undefined ? { labelOffsetY: s.labelOffsetY } : {}),
3176
5666
  });
3177
5667
  stepEdgeIds.push(edgeId);
3178
5668
  });
@@ -3183,16 +5673,27 @@ function compileSequenceWithAnimate(doc: DslDocument): CdlDiagram {
3183
5673
  const laneId = actorIds.get(a.name) ?? slugify(a.name);
3184
5674
  const footerId = `${laneId}-footer`;
3185
5675
  const actorW = Math.max(140, a.name.length * 22 + 52);
3186
- b.node(footerId, { lane: laneId, stack: footerStack, kind: "card", title: a.name, w: actorW, h: 72 });
5676
+ // `role` を付ける (#1273)。 付けないと生命線の終わりが footer より 100 下まで伸びる
5677
+ // (実測 = 組立て API は `y2=848`、記法は `y2=948`)。 枠の大きさは同じなので
5678
+ // 描いた図の大きさの比較では捕まらない
5679
+ b.node(footerId, {
5680
+ lane: laneId,
5681
+ stack: footerStack,
5682
+ kind: "card",
5683
+ title: a.name,
5684
+ w: actorW,
5685
+ h: 72,
5686
+ role: "lifeline-footer",
5687
+ });
3187
5688
  });
3188
5689
 
3189
5690
  // state を builder に登録
3190
- for (const st of doc.animate!.states) {
5691
+ for (const st of doc.animate?.states ?? []) {
3191
5692
  b.state(st.name, { initial: st.initial });
3192
5693
  }
3193
5694
 
3194
5695
  // phase を順次注入 ... highlight / tween / set / badge / body 全反映
3195
- for (const p of doc.animate!.phases) {
5696
+ for (const p of doc.animate?.phases ?? []) {
3196
5697
  b.phase(
3197
5698
  slugify(p.name) || p.name,
3198
5699
  {
@@ -3302,7 +5803,9 @@ function slugLookup(byName: ReadonlyMap<string, string>, wanted: string): string
3302
5803
 
3303
5804
  function compileFlow(doc: DslDocument): CdlDiagram {
3304
5805
  // v0.4 ... animation あり時 builder 直接経路で複数 phase 注入
3305
- if (doc.animate && doc.animate.phases.length > 0) {
5806
+ // **縦列を書いた形は動きの有無に関わらず generic 経路へ** (#1263) 動く図だけで効かせると、
5807
+ // 同じ記法でも静止図では指定が黙って消える (実測 = 縦列 3 本のはずが 1 本になり知らせも出ない)
5808
+ if ((doc.animate && doc.animate.phases.length > 0) || 書いた縦列に置く("flow", doc)) {
3306
5809
  return compileGenericWithAnimate(doc, { kind: "flow", laneId: "main", laneWidth: 400 });
3307
5810
  }
3308
5811
  // 登場人物が 0 人なら枠も作らない。 描画側の `flow()` は枠を必ず 1 つ作るため、 そのまま
@@ -3336,7 +5839,9 @@ function compileFlow(doc: DslDocument): CdlDiagram {
3336
5839
 
3337
5840
  function compileSwimlane(doc: DslDocument): CdlDiagram {
3338
5841
  // v0.4 ... animation あり時 builder 直接経路 (各 actor 別 lane で配置)
3339
- if (doc.animate && doc.animate.phases.length > 0) {
5842
+ // **縦列を書いた形は動きの有無に関わらず generic 経路へ** (#1263) 動く図だけで効かせると、
5843
+ // 同じ記法でも静止図では指定が黙って消える (実測 = 縦列 3 本のはずが 1 本になり知らせも出ない)
5844
+ if ((doc.animate && doc.animate.phases.length > 0) || 書いた縦列に置く("swimlane", doc)) {
3340
5845
  return compileGenericWithAnimate(doc, { kind: "swimlane", laneWidth: 400 });
3341
5846
  }
3342
5847
  // swimlane preset は lane 配置 + 自由 node/edge。
@@ -3376,10 +5881,25 @@ function compileSwimlane(doc: DslDocument): CdlDiagram {
3376
5881
  ...(s.sub ? { sub: s.sub } : {}),
3377
5882
  ...(s.tone ? { tone: s.tone } : {}),
3378
5883
  ...(s.style ? { style: s.style } : {}),
3379
- ...(s.guard ? { guard: s.guard } : {}),
3380
- ...(s.cardinality ? { cardinality: s.cardinality } : {}),
3381
- ...(s.labelOffsetX !== undefined ? { labelOffsetX: s.labelOffsetX } : {}),
3382
- ...(s.labelOffsetY !== undefined ? { labelOffsetY: s.labelOffsetY } : {}),
5884
+ });
5885
+ }
5886
+
5887
+ // **箱が 1 つも置けなかった時は、 登場人物をそのまま置く** (#1219)
5888
+ //
5889
+ // この図種は矢印の端から箱を作るため、 矢印が 1 本も無いと箱が 0 件になり `compile` が
5890
+ // 落ちる (実測 = 矢印を書かない図は本 Issue の前から落ちていた)。 解決できない矢印を
5891
+ // 外すと同じ形になるので、 受け皿を置く。
5892
+ //
5893
+ // **1 つでも置けた時は触らない**。 矢印に出てこない登場人物にも箱を置くと、 今まで枠だけ
5894
+ // だった所に箱が増えて既存の図の見た目が変わる (それを変えるかは別の判断)。
5895
+ if (placedNodes.size === 0) {
5896
+ doc.actors.forEach((a, i) => {
5897
+ swim.node(slugify(a.name) || `n${i}`, {
5898
+ lane: swim.laneId(a.name),
5899
+ stack: 0,
5900
+ kind: a.kind ?? "actor",
5901
+ title: a.name,
5902
+ });
3383
5903
  });
3384
5904
  }
3385
5905
  return swim.build();
@@ -3419,6 +5939,29 @@ function compileEr(doc: DslDocument): CdlDiagram {
3419
5939
  return erBuilder.build();
3420
5940
  }
3421
5941
 
5942
+ /**
5943
+ * 状態遷移図で、どの箱を始まり / 終わりとみなすか (#1275)。
5944
+ *
5945
+ * **書いた値が勝つ**。 `initial:` / `final:` は記法で書けるのに 1 度も読まれておらず、
5946
+ * 位置だけで決まっていた (実測 = 中央の箱に `final: true` を書いても、最後に書いた箱が
5947
+ * 「最終」 になった)。
5948
+ *
5949
+ * 1 つも書いていなければ従来どおり位置で決める = 書かない記法の図は変わらない。
5950
+ * 片方だけ書いた形も、書いた側だけが切り替わる。
5951
+ */
5952
+ function 始まりと終わりの決め方(doc: DslDocument): {
5953
+ 始まり: (a: DslActor, idx: number) => boolean;
5954
+ 終わり: (a: DslActor, idx: number) => boolean;
5955
+ } {
5956
+ const 書いた始まり = doc.actors.some((a) => a.initial === true);
5957
+ const 書いた終わり = doc.actors.some((a) => a.final === true);
5958
+ return {
5959
+ 始まり: (a, idx) => (書いた始まり ? a.initial === true : idx === 0),
5960
+ 終わり: (a, idx) =>
5961
+ 書いた終わり ? a.final === true : idx === doc.actors.length - 1 && doc.actors.length > 1,
5962
+ };
5963
+ }
5964
+
3422
5965
  function compileState(doc: DslDocument): CdlDiagram {
3423
5966
  // v0.4 ... animation あり時 builder 直接経路 (各 state を lane で配置、 transition を edge)
3424
5967
  if (doc.animate && doc.animate.phases.length > 0) {
@@ -3429,11 +5972,12 @@ function compileState(doc: DslDocument): CdlDiagram {
3429
5972
  id: slugify(doc.title),
3430
5973
  topic: doc.title,
3431
5974
  });
5975
+ const 決め方 = 始まりと終わりの決め方(doc);
3432
5976
  for (let i = 0; i < doc.actors.length; i++) {
3433
5977
  const a = doc.actors[i]!;
3434
- // 初期 / 最終 は (initial) / (final) kind 部分に書く慣習、 もしくは順序で決め打ち
3435
- const initial = i === 0;
3436
- const final = i === doc.actors.length - 1 && doc.actors.length > 1;
5978
+ // 書いた `initial:` / `final:` が勝つ。 1 つも書いていなければ順序で決める
5979
+ const initial = 決め方.始まり(a, i);
5980
+ const final = 決め方.終わり(a, i);
3437
5981
  fsm.state({
3438
5982
  id: slugify(a.name) || `s${i}`,
3439
5983
  title: a.name,
@@ -3455,7 +5999,9 @@ function compileState(doc: DslDocument): CdlDiagram {
3455
5999
 
3456
6000
  function compileTopology(doc: DslDocument): CdlDiagram {
3457
6001
  // v0.4 ... animation あり時 builder 直接経路 (各 actor を別 lane に)
3458
- if (doc.animate && doc.animate.phases.length > 0) {
6002
+ // **縦列を書いた形は動きの有無に関わらず generic 経路へ** (#1263) 動く図だけで効かせると、
6003
+ // 同じ記法でも静止図では指定が黙って消える (実測 = 縦列 3 本のはずが 1 本になり知らせも出ない)
6004
+ if ((doc.animate && doc.animate.phases.length > 0) || 書いた縦列に置く("topology", doc)) {
3459
6005
  return compileGenericWithAnimate(doc, { kind: "topology", laneWidth: 460 });
3460
6006
  }
3461
6007
  // 登場人物が 0 人なら枠も作らない。 描画側の `topology()` は枠を必ず 1 つ作るため、 そのまま
@@ -3503,13 +6049,84 @@ type GenericOpts = {
3503
6049
  laneWidth: number;
3504
6050
  };
3505
6051
 
6052
+ /**
6053
+ * 後ろへ戻る矢印か (#1260)。
6054
+ *
6055
+ * 状態の図は、後ろの状態へ戻る矢印を **箱の上を回して** 描く (`routing: "back-detour"`)。
6056
+ * 組立て API 側がそうしており、記法側で付けないと **描いた図の高さが変わる**
6057
+ * (実測 = viewBox が 404 対 486 で、戻る矢印が箱の右横を回っていた)。
6058
+ *
6059
+ * 判定は並び順。 指す先が指す元より前にあれば戻る矢印
6060
+ * (実測 = `stateMachine()` は c -> a と c -> b の 2 本だけに付け、a -> b と b -> c には付けない)。
6061
+ *
6062
+ * **状態の図だけに付ける**。 他の図種の組立て API は付けない (実測 = `er()` は付けなかった)。
6063
+ */
6064
+ function 後ろへ戻る矢印か(
6065
+ kind: GenericKind,
6066
+ fromId: string,
6067
+ toId: string,
6068
+ 箱の並び: ReadonlyMap<string, number>,
6069
+ ): boolean {
6070
+ if (kind !== "state") return false;
6071
+ const 元 = 箱の並び.get(fromId);
6072
+ const 先 = 箱の並び.get(toId);
6073
+ if (元 === undefined || 先 === undefined) return false;
6074
+ return 先 < 元;
6075
+ }
6076
+
6077
+ /**
6078
+ * 縦列を選べる図種 (#1263)。
6079
+ *
6080
+ * 縦列を **箱を並べるための入れ物** として使う図種だけを許す。 順序図と solidity は
6081
+ * 縦列がそのまま生命線として描かれる骨格なので許さない (#1248 の判断はこちらに当たる)。
6082
+ *
6083
+ * `er` / `state` / `class` は「1 縦列 1 箱」 が図の読み方そのもの (表 / 状態 / クラスが
6084
+ * 横に並ぶ) なので、2 つの箱を同じ縦列へ入れられる形にはしない。
6085
+ */
6086
+ const 縦列を選べる図種: ReadonlySet<GenericKind> = new Set(["flow", "topology", "swimlane"]);
6087
+
6088
+ /**
6089
+ * 書いた縦列に箱を置く形か (#1263)。
6090
+ *
6091
+ * **全ての箱が縦列を書いた時だけ** この形にする。 一部だけ書いた形は、書かなかった箱の
6092
+ * 行き先を決める規則が要る (既定の縦列に集める / 自分の縦列を作る) が、どちらも
6093
+ * 書いた人の意図と一致する保証が無い。 混ざった形は `reportLaneMixed` が知らせる。
6094
+ *
6095
+ * 見本 (parts) は縦列を張替え先として使うため、この判定からは外す。
6096
+ */
6097
+ function 書いた縦列に置く(kind: GenericKind, doc: DslDocument): boolean {
6098
+ if (!縦列を選べる図種.has(kind)) return false;
6099
+ const 対象 = doc.actors.filter((a) => a.partId === undefined);
6100
+ return 対象.length > 0 && 対象.every((a) => a.lane !== undefined);
6101
+ }
6102
+
3506
6103
  function compileGenericWithAnimate(doc: DslDocument, opts: GenericOpts): CdlDiagram {
3507
6104
  const b = diagram(slugify(doc.title), { topic: doc.title });
3508
6105
  const { kind, laneWidth } = opts;
3509
6106
 
3510
6107
  // lane / node 配置 ... preset kind に応じて切替
3511
6108
  const actorToNodeId = new Map<string, string>();
3512
- if (kind === "flow" || kind === "topology") {
6109
+ if (書いた縦列に置く(kind, doc)) {
6110
+ // **書いた縦列に置く** (#1263)。 縦列を並べるための入れ物として使う図種でだけ効く。
6111
+ // 縦列は書かれた順に作り、同じ縦列の箱は書かれた順に積む
6112
+ const 並び: string[] = [];
6113
+ for (const a of doc.actors) {
6114
+ const lid = a.lane;
6115
+ if (lid !== undefined && !並び.includes(lid)) 並び.push(lid);
6116
+ }
6117
+ for (const lid of 並び) {
6118
+ b.lane(lid, { width: laneWidth, ...(kind === "topology" ? { contain: true } : {}) });
6119
+ }
6120
+ const 積んだ数 = new Map<string, number>();
6121
+ doc.actors.forEach((a, idx) => {
6122
+ const id = slugify(a.name) || `n${idx}`;
6123
+ actorToNodeId.set(a.name, id);
6124
+ const lid = a.lane!;
6125
+ const stack = 積んだ数.get(lid) ?? 0;
6126
+ 積んだ数.set(lid, stack + 1);
6127
+ b.node(id, { lane: lid, stack, kind: a.kind, title: a.name });
6128
+ });
6129
+ } else if (kind === "flow" || kind === "topology") {
3513
6130
  // 1 lane に全 actor を縦 stack
3514
6131
  const lid = opts.laneId ?? "main";
3515
6132
  b.lane(lid, { width: laneWidth, label: doc.title, ...(kind === "topology" ? { contain: true } : {}) });
@@ -3520,14 +6137,24 @@ function compileGenericWithAnimate(doc: DslDocument, opts: GenericOpts): CdlDiag
3520
6137
  });
3521
6138
  } else {
3522
6139
  // swimlane / er / state ... actor ごとに 1 lane (横並び)
6140
+ //
6141
+ // **見出しを付けるのは `swimlane` だけ** (#1241)。 3 図種とも箱を 1 つずつ持ち、
6142
+ // その箱が既に名前を描く。 縦列にも同じ名前を渡すと **同じ字が縦に 2 つ並ぶ**
6143
+ // (実測 = 描いた絵に `Alpha` `Beta` が 2 度出る)。
6144
+ //
6145
+ // `swimlane` は縦列そのものが「誰の担当か」 を読ませる図なので見出しが要る。
6146
+ // `er` の縦列は表を並べるための入れ物、 `state` の縦列は状態を並べるための入れ物で、
6147
+ // どちらも読む人に見せる意味を持たない (組立て API 側も見出しを空のまま置く)。
6148
+ const 見出しを付ける = kind === "swimlane";
6149
+ const 決め方2 = 始まりと終わりの決め方(doc);
3523
6150
  doc.actors.forEach((a, idx) => {
3524
6151
  const lid = `lane-${slugify(a.name) || idx}`;
3525
- b.lane(lid, { width: laneWidth, label: a.name });
6152
+ b.lane(lid, { width: laneWidth, ...(見出しを付ける ? { label: a.name } : {}) });
3526
6153
  const id = slugify(a.name) || `n${idx}`;
3527
6154
  actorToNodeId.set(a.name, id);
3528
6155
  // er は entity、 state は initial/final marker、 swimlane はそのまま actor
3529
- const isInitial = kind === "state" && idx === 0;
3530
- const isFinal = kind === "state" && idx === doc.actors.length - 1 && doc.actors.length > 1;
6156
+ const isInitial = kind === "state" && 決め方2.始まり(a, idx);
6157
+ const isFinal = kind === "state" && 決め方2.終わり(a, idx);
3531
6158
  b.node(id, {
3532
6159
  lane: lid,
3533
6160
  stack: 0,
@@ -3540,10 +6167,20 @@ function compileGenericWithAnimate(doc: DslDocument, opts: GenericOpts): CdlDiag
3540
6167
  }
3541
6168
 
3542
6169
  // edge ... flow の各 step を edge として登録
6170
+ //
6171
+ // 解決できない名前の矢印は **落とす** (#1209)。 以前は名前をそのまま id として使っており、
6172
+ // 存在しない node を指す図ができて描画の直前で落ちていた
6173
+ // (実測 = `unknown-ref: edge "e0-v-c" の from "v" が node に存在しません`)。
6174
+ // 書いた人には `flow-actor-missing` の知らせが届く。
6175
+ // 箱の並び。 後ろへ戻る矢印を見分けるために使う (#1260)
6176
+ const 箱の並び = new Map([...actorToNodeId.values()].map((id, i) => [id, i]));
3543
6177
  const edgeIds: string[] = [];
3544
6178
  doc.flow.forEach((s, idx) => {
3545
- const fromId = actorToNodeId.get(s.from) ?? slugify(s.from);
3546
- const toId = actorToNodeId.get(s.to) ?? slugify(s.to);
6179
+ // 名前は入口で正規化済 (`canonicalizeFlowActors`) ここで slug を受け直すと、
6180
+ // 動きを書いていない図の組み立てと扱いが割れる
6181
+ const fromId = actorToNodeId.get(s.from);
6182
+ const toId = actorToNodeId.get(s.to);
6183
+ if (fromId === undefined || toId === undefined) return;
3547
6184
  const edgeId = `e${idx}-${fromId}-${toId}`;
3548
6185
  // ER preset では cardinality を label に "(1:N)" 形式で併記、 他 preset は label そのまま。
3549
6186
  const labelWithCard =
@@ -3555,24 +6192,21 @@ function compileGenericWithAnimate(doc: DslDocument, opts: GenericOpts): CdlDiag
3555
6192
  b.edge(fromId, toId, {
3556
6193
  id: edgeId,
3557
6194
  label: labelWithCard,
6195
+ ...後ろへ戻る矢印か(kind, fromId, toId, 箱の並び) ? { routing: "back-detour" as const } : {},
3558
6196
  ...(s.sub ? { sub: s.sub } : {}),
3559
6197
  ...(s.tone ? { tone: s.tone } : {}),
3560
6198
  ...(s.style ? { style: s.style } : {}),
3561
- ...(s.guard ? { guard: s.guard } : {}),
3562
- ...(s.cardinality ? { cardinality: s.cardinality } : {}),
3563
- ...(s.labelOffsetX !== undefined ? { labelOffsetX: s.labelOffsetX } : {}),
3564
- ...(s.labelOffsetY !== undefined ? { labelOffsetY: s.labelOffsetY } : {}),
3565
6199
  });
3566
6200
  edgeIds.push(edgeId);
3567
6201
  });
3568
6202
 
3569
6203
  // state 登録
3570
- for (const st of doc.animate!.states) {
6204
+ for (const st of doc.animate?.states ?? []) {
3571
6205
  b.state(st.name, { initial: st.initial });
3572
6206
  }
3573
6207
 
3574
6208
  // phase 注入
3575
- for (const p of doc.animate!.phases) {
6209
+ for (const p of doc.animate?.phases ?? []) {
3576
6210
  b.phase(
3577
6211
  slugify(p.name) || p.name,
3578
6212
  {
@@ -3643,6 +6277,9 @@ function resolveHighlightGeneric(
3643
6277
 
3644
6278
  // ─── helpers ──────────────────────────────────────────────────
3645
6279
 
6280
+ /** id の長さの上限。 `slugify` が切る幅で、 尾を付ける側もこの値から余地を決める (#1220) */
6281
+ const ID_MAX = 64;
6282
+
3646
6283
  function slugify(s: string): string {
3647
6284
  return (
3648
6285
  s
@@ -3650,7 +6287,7 @@ function slugify(s: string): string {
3650
6287
  .normalize("NFKC")
3651
6288
  .replace(/[^a-z0-9ぁ-んァ-ヶ一-龯\-_]+/g, "-")
3652
6289
  .replace(/^-+|-+$/g, "")
3653
- .slice(0, 64) || "n"
6290
+ .slice(0, ID_MAX) || "n"
3654
6291
  );
3655
6292
  }
3656
6293