@cardenelabs/dragon 0.7.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 ADDED
@@ -0,0 +1,3739 @@
1
+ /**
2
+ * AST (DslDocument) → 既存 preset API 経由 → LaidDiagram
3
+ *
4
+ * v0.3 ... アニメーション ブロック full compile 対応 (sequence preset のみ、 他 preset は v0.4 で順次)
5
+ * - state / tween / set / highlight / badge / body を実 phase に注入
6
+ * - アニメーション ありなら builder 直接経路 ... preset の標準 phase を置換
7
+ * - アニメーション なしは v0.2 と同じく preset 経由
8
+ *
9
+ * v0.2 ... 6 preset 全対応 (sequence / flow / swimlane / er / state / topology)
10
+ */
11
+
12
+ import type { DslDocument, DslPhase } from "./types";
13
+ import type { CdlDiagram, ErRelationCardinality, LaidDiagram } from "@cardenelabs/cdl";
14
+ import {
15
+ sequence, flow, swimlane, er, stateMachine, topology, diagram, layout,
16
+ rendersRows, requiredRowsHeight, requiredRowsWidth, NODE_KINDS,
17
+ } from "@cardenelabs/cdl";
18
+ import { parseFocusEntry } from "./focus";
19
+ import { isColorValue, stripExternalPaint } from "./color";
20
+ import {
21
+ MAX_INPUT_ELEMENTS,
22
+ countDiagramElements,
23
+ countDocElements,
24
+ describeOversize,
25
+ } from "./input-size";
26
+ import {
27
+ orderByDependency,
28
+ resolveRelativePos,
29
+ type AnchorBox,
30
+ type RelativeDirection,
31
+ } from "./relative-pos";
32
+
33
+ export interface CompileToCdlOpts {
34
+ /**
35
+ * CAR-1657 = parts identifier lookup catalog、 caller (CdlEditor / test) が inject。
36
+ * DslActor.partId が set された actor を検出したら partsCatalog[partId] から CdlDiagram を
37
+ * lookup + mergePartIntoDiagram で target に統合。 未渡し時は parts kind actor を skip + warn。
38
+ */
39
+ partsCatalog?: Record<string, CdlDiagram>;
40
+ /**
41
+ * 組み立ての途中で分かった「書いたのに効かなかったこと」 の受け取り口。
42
+ *
43
+ * 図は出せるので誤りにはしないが、 黙って捨てると書いた人が理由を追えない。 editor は
44
+ * これを受けて画面に出す。 判定は組み立て側だけが持ち、 画面側は表示に徹する。
45
+ */
46
+ onNotice?: (notice: CompileNotice) => void;
47
+ /**
48
+ * edge が DSL のどの行から来たかの受け取り口 (#998)。
49
+ *
50
+ * preset によっては書いた step と生成される edge が一致しない (`type: flow` は actor を鎖状に
51
+ * 繋ぐため `a -> c` と書いても `a -> b` になる)。 edge を起点に本文の行を直す機能は、 この
52
+ * 対応が無いと別の行を書き換える。
53
+ *
54
+ * **対応が取れない edge については呼ばれない**。 「対応が無い」 と「行 0」 を区別するため。
55
+ */
56
+ onEdgeSource?: (edgeId: string, line: number) => void;
57
+ }
58
+
59
+ /** 図は出せるが書いた通りにならなかった、 という知らせ。 */
60
+ export type CompileNotice = {
61
+ kind:
62
+ | "relative-position-ignored"
63
+ | "focus-target-missing"
64
+ | "state-override-rejected"
65
+ | "external-paint-dropped"
66
+ // 図の中に描く部品を持たない見本を重ねた (#1017)
67
+ | "part-not-drawn"
68
+ // `倍率:` を書いた見本が、同じ名前の状態も持っていた (#1026)
69
+ | "scale-reserved";
70
+ /** 対象の名前。 光らせる相手なら書かれた指定そのまま */
71
+ actor: string;
72
+ /** 書かれていた行 */
73
+ line: number;
74
+ message: string;
75
+ hint?: string;
76
+ };
77
+
78
+ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiagram {
79
+ // 大きすぎる図は組み立てない (#1005)。 組み立てにかかる時間は要素数の 2 乗で伸び、
80
+ // 待機の後に同じ流れの中で走るため、 貼ってしまうと画面が戻らない (実測 = 10,000 要素で 7.6 秒)。
81
+ // 両方の記法がここを通るので、 入口ごとに置かずここで 1 度だけ見る
82
+ const oversize = describeOversize({ elements: countDocElements(doc), bytes: 0 });
83
+ if (oversize) throw new Error(oversize);
84
+
85
+ let diagram: CdlDiagram;
86
+ switch (doc.type) {
87
+ case "sequence":
88
+ diagram = compileSequence(doc);
89
+ break;
90
+ case "flow":
91
+ diagram = compileFlow(doc);
92
+ break;
93
+ case "swimlane":
94
+ diagram = compileSwimlane(doc);
95
+ break;
96
+ case "er":
97
+ diagram = compileEr(doc);
98
+ break;
99
+ case "state":
100
+ diagram = compileState(doc);
101
+ break;
102
+ case "topology":
103
+ diagram = compileTopology(doc);
104
+ break;
105
+ case "solidity":
106
+ diagram = compileSolidity(doc);
107
+ break;
108
+ case "gantt":
109
+ diagram = compileGantt(doc);
110
+ break;
111
+ case "class":
112
+ diagram = compileClass(doc);
113
+ break;
114
+ case "pie":
115
+ diagram = compilePie(doc);
116
+ break;
117
+ case "c4":
118
+ diagram = compileC4(doc);
119
+ break;
120
+ case "mind":
121
+ diagram = compileMind(doc);
122
+ break;
123
+ default:
124
+ // switch case で全 type を網羅済のため default は unreachable、 template expression で
125
+ // never 型を直接埋込めないので String() で明示 (defensive runtime error message 用)。
126
+ throw new Error(`unknown type: ${String(doc.type)}`);
127
+ }
128
+ // edge と本文の行の対応は表に集めてから 1 edge = 1 回で知らせる (#998)。 経路ごとに
129
+ // その場で呼ぶと、 同じ edge に別の行を 2 度知らせることになる。
130
+ const edgeSourceLines = opts?.onEdgeSource ? new Map<string, number>() : undefined;
131
+ applyEdgeInlineOptions(diagram, doc, edgeSourceLines);
132
+ // `type: flow` は actor を鎖状に繋ぐため上の (from, to) 一致では取れない。 preset の規則で埋める。
133
+ if (edgeSourceLines) fillFlowEdgeSources(diagram, doc, edgeSourceLines);
134
+ applyGroupContainers(diagram, doc);
135
+ applyNodeTones(diagram, doc);
136
+ // 光らせる相手が実在するかを確かめる。 id への解決は図種ごとに違うが、 名前が居るか
137
+ // 居ないかは記述だけで決まるので 1 か所で見る
138
+ reportMissingFocusTargets(doc, opts?.onNotice);
139
+ // `位置: Web の右` を実際の配置から絶対座標に直す。 以降は座標を直接書いた時と同じ経路
140
+ const placed = resolveRelativeDoc(diagram, doc, opts?.onNotice, opts?.partsCatalog);
141
+ // canvas pivot 新 spec = 全 preset 共通の post-process で actor.posX/Y を CDL lane / node に伝播
142
+ applyCanvasPivotPositions(diagram, placed);
143
+ // 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);
146
+ // 表が揃ってから 1 edge = 1 回で知らせる。 merge 後に残っている edge だけを対象にする =
147
+ // 途中で消えた edge の行を知らせても呼出側が使えない。
148
+ if (edgeSourceLines && opts?.onEdgeSource) {
149
+ const alive = new Set(merged.edges.map((e) => e.id));
150
+ for (const [id, line] of edgeSourceLines) {
151
+ if (alive.has(id)) opts.onEdgeSource(id, line);
152
+ }
153
+ }
154
+ // 動かない図に段を 1 つ入れる (#1086)。
155
+ //
156
+ // 描画側は「段が 1 件以上」 を要求するが、 段を作るかどうかは種類ごとにばらけている。
157
+ // 実測 = `animation:` を書かない同じ記法を 12 種に与えると、 6 種 (sequence / flow / er /
158
+ // state / topology / solidity) は描かれ、 6 種 (swimlane / gantt / class / pie / c4 / mind)
159
+ // は「phase が 0 件です」 で弾かれた。 書く人から見ると区別する手がかりが無い。
160
+ //
161
+ // **出口で 1 度だけ見る**。 種類ごとに塞ぐと 12 経路のどれかを見落とす。 図は必ずここを
162
+ // 通るので、 ここで段が無ければ入れる。
163
+ injectStaticPhase(merged);
164
+ // 図の外を指す値を、 色を塗る位置から落とす (#1004)。
165
+ //
166
+ // 入口ごとに塞ぐ形は採らない。 状態の上書き / phase が入れる値 / 画面が直接書く背景色 /
167
+ // 埋め込んだ JSON と入口が 4 つ以上あり、 1 つ見落とすと穴が残る。 描画へ渡る図は必ず
168
+ // ここを通るので、 出口で 1 度だけ見る。
169
+ for (const dropped of stripExternalPaint(merged)) {
170
+ opts?.onNotice?.({
171
+ kind: "external-paint-dropped",
172
+ actor: dropped.path,
173
+ line: 0,
174
+ message: `図の外を指す値 (${truncateForMessage(dropped.value)}) は色として使えないため外しました`,
175
+ hint: "色は `#ff0000` のような色番号か、 `red` のような色名で書く",
176
+ });
177
+ }
178
+ return merged;
179
+ }
180
+
181
+ /**
182
+ * 動かない図に段を 1 つ入れる (#1086)。
183
+ *
184
+ * 描画側は段が 1 件以上あることを要求する。 一方で段を作るかどうかは種類ごとにばらけており、
185
+ * `animation:` を書かない図は 12 種のうち 6 種だけが描かれ、 残り 6 種は弾かれていた。
186
+ *
187
+ * ## 何も光らせない段にはしない
188
+ *
189
+ * 段には「この段で何が主役か」 を示す役割がある。 空の段を入れると図は描かれるが、 全ての
190
+ * 要素が主役でない状態 (薄い表示) になり、 動かない図として読めない。 全部を光らせる段なら、
191
+ * 動かない図が「常に全部が主役」 として自然に読める。
192
+ *
193
+ * ## 既に段がある図には触らない
194
+ *
195
+ * `animation:` を書いた図と、 描画側が段を作る 6 種はここに入らない。 段の数と中身は書いた
196
+ * とおりに保たれる。
197
+ */
198
+ function injectStaticPhase(diagram: CdlDiagram): void {
199
+ if (diagram.phases.length > 0) return;
200
+ // 光らせる相手が 1 つも無い図 (要素ゼロ) でも段は入れる。 描画側が要求するのは段の存在で
201
+ // あって中身ではなく、 ここで諦めると「空の図は描けない」 という別の欠落になる
202
+ const activate = [...diagram.nodes.map((n) => n.id), ...diagram.edges.map((e) => e.id)];
203
+ diagram.phases.push({
204
+ id: "static",
205
+ duration: 1000,
206
+ title: diagram.topic ?? "全体",
207
+ body: "",
208
+ activate,
209
+ tweens: [],
210
+ sets: [],
211
+ });
212
+ }
213
+
214
+ /** 知らせに載せる値を短く切る。 長い URL をそのまま出すと画面の帯が読めなくなる */
215
+ function truncateForMessage(v: string): string {
216
+ const s = v.trim();
217
+ return s.length <= 40 ? s : `${s.slice(0, 37)}...`;
218
+ }
219
+
220
+ /**
221
+ * 光らせる相手 (`focus:`) が実在しない分を知らせる。
222
+ *
223
+ * 名前が当たらなかった指定は静かに消える。 光らせたい相手を書いたのに光らない状態が、
224
+ * 手掛かりなしで起きる。
225
+ *
226
+ * 見るのは記述だけ。 id の形は図種で違うが、 「その名前の箱が居るか」「その矢印が流れに
227
+ * あるか」 は書かれた内容だけで決まる。 図種ごとの解決経路に検査を分けると、 経路が増える
228
+ * たびに検査が取り残される。
229
+ */
230
+ function reportMissingFocusTargets(
231
+ doc: DslDocument,
232
+ onNotice?: (notice: CompileNotice) => void,
233
+ ): void {
234
+ if (!onNotice || !doc.animate) return;
235
+ const names = new Set(doc.actors.map((a) => a.name));
236
+ // 解決側は名前が見つからない時に slug へ落とす。 受理集合もそれに合わせる。
237
+ // 合わせないと、 実際は光る指定 (`API Gateway` を `api-gateway` と書いた形) を
238
+ // 「見つかりません」 と誤報する (実測)
239
+ //
240
+ // 2 つ以上の名前が同じ slug になる時は受理しない。 解決側も曖昧として光らせないため、
241
+ // 受理すると「知らせは出ないのに何も光らない」 状態になる (実測)
242
+ const accepted = new Set(names);
243
+ const slugCount = new Map<string, number>();
244
+ for (const n of names) {
245
+ const sl = slugify(n);
246
+ slugCount.set(sl, (slugCount.get(sl) ?? 0) + 1);
247
+ }
248
+ for (const [sl, count] of slugCount) if (count === 1) accepted.add(sl);
249
+ //
250
+ // 縦列の id は受理しない。 3 つの解決経路はいずれも縦列を光らせないため、 受理すると
251
+ // 「知らせは出ないのに何も光らない」 状態を作る (実測 = `focus: [main]` で activate が空)
252
+ // 矢印は流れに書かれた組合せだけを認める。 名前に空白を含められる (`決済 基盤`) ため、
253
+ // 連結した 1 本の鍵にはしない (`"a b" -> "c"` と `"a" -> "b c"` が同じ鍵になる)
254
+ const steps = new Map<string, Set<string>>();
255
+ for (const st of doc.flow) {
256
+ const tos = steps.get(st.from) ?? new Set<string>();
257
+ tos.add(st.to);
258
+ steps.set(st.from, tos);
259
+ }
260
+
261
+ for (const phase of doc.animate.phases) {
262
+ for (const raw of phase.highlight ?? []) {
263
+ const entry = parseFocusEntry(raw, names);
264
+ const found =
265
+ entry.kind === "edge"
266
+ ? (steps.get(entry.from)?.has(entry.to) ?? false)
267
+ : accepted.has(entry.name);
268
+ if (found) continue;
269
+ onNotice({
270
+ kind: "focus-target-missing",
271
+ actor: raw,
272
+ line: phase.pos.line,
273
+ message:
274
+ entry.kind === "edge"
275
+ ? `光らせる矢印が流れにありません: "${raw}"`
276
+ : `光らせる相手が見つかりません: "${raw}"`,
277
+ hint:
278
+ entry.kind === "edge"
279
+ ? "flow: に書いた矢印と同じ向きで書く"
280
+ : `actors: に書かれている名前 = ${[...names].join(", ")}`,
281
+ // 縦列の id は受理しないので、 その旨は hint に出さない (光らせられないため)
282
+ });
283
+ }
284
+ }
285
+ }
286
+
287
+ /**
288
+ * 相対で書かれた位置 (`位置: Web の右 200`) を絶対座標に直した doc を返す。
289
+ *
290
+ * 基準の実座標は配置を 1 度計算しないと分からない。 cdl の `layout` を呼んで測り、
291
+ * 基準の縁から間隔を空けた位置を求める。 元の doc は書き換えず、 座標を入れた複製を返す。
292
+ *
293
+ * 相対指定が 1 件も無ければ何もしない。 配置計算は 1 回 1ms 前後かかるので、 使っていない
294
+ * 図に負担をかけない。
295
+ */
296
+ function resolveRelativeDoc(
297
+ diagram: CdlDiagram,
298
+ doc: DslDocument,
299
+ onNotice?: (notice: CompileNotice) => void,
300
+ partsCatalog?: Record<string, CdlDiagram>,
301
+ ): DslDocument {
302
+ if (!doc.actors.some((a) => a.posRel !== undefined)) return doc;
303
+
304
+ // 1. 座標を書いた分を先に反映してから測る。 基準がどこに居るかはここで分かる。
305
+ //
306
+ // 自動配置のまま測ると、 座標で固定した箱を基準にした指定が壊れる。 基準の自動配置位置
307
+ // から狙いを作るため、 実際の位置と食い違い、 最後の確認で「効きません」 と捨てられる
308
+ // (実測 = `Web @1000,500` の右に置くはずの箱が 200 に出て、 そのまま落とされた)。
309
+ const measured = measureActorBoxes(withPositions(diagram, doc, new Map()));
310
+ // パーツの箱は catalog から作る。 組み立て前の図に残っている仮の箱を測ると、 実際に
311
+ // 描かれる大きさと違う値で間隔を計算することになる
312
+ const baseBoxes = new Map(measured);
313
+ if (partsCatalog) {
314
+ for (const [name, box] of partBoxes(diagram, doc, partsCatalog)) baseBoxes.set(name, box);
315
+ }
316
+ const sizeOverride = partsCatalog
317
+ ? partSizes(doc, partsCatalog)
318
+ : new Map<string, { w: number; h: number; dx: number; dy: number }>();
319
+ const want = desiredCenters(doc, baseBoxes, sizeOverride);
320
+ if (want.size === 0) return doc;
321
+
322
+ // 2. 狙った中心をそのまま座標として仮に置く。
323
+ // パーツは渡す座標が段の中心なので、 矩形の中心とのずれを引く
324
+ const naive = new Map(
325
+ [...want].map(([name, c]) => {
326
+ const off = sizeOverride.get(name);
327
+ return [name, { posX: c.cx - (off?.dx ?? 0), posY: c.cy - (off?.dy ?? 0) }] as const;
328
+ }),
329
+ );
330
+
331
+ // 3. 測り直して、 狙いとの差を足す。
332
+ //
333
+ // 座標を書いた時に中心がどこに来るかは図種で違う。 順序図の座標は縦列の左端を動かすので、
334
+ // 中心を狙って書くと縦列の幅の半分だけ右にずれる (実測 = 200 空けたいのに 370 空いた)。
335
+ // 図種ごとの規則を書き写すと cdl 側の変更で黙って壊れるため、 実際に置いた結果との差を
336
+ // 使って直す。 差は図種ごとに一定なので 1 度で合う (実測 = 8 図種すべてで狙い通り)。
337
+ const isPart = new Set(doc.actors.filter((a) => a.partId !== undefined).map((a) => a.name));
338
+ // 確かめる時も、 基準になるパーツは catalog 由来の箱で見る。 この時点の図には仮の箱しか
339
+ // 無いため、 測ると解決側と違う基準で期待を作ることになる (実測 = 正しく置いた箱が
340
+ // 「効きません」 と落とされた)
341
+ const partOverride = new Map<string, AnchorBox>();
342
+ for (const [name, box] of baseBoxes) {
343
+ if (isPart.has(name)) partOverride.set(name, box);
344
+ }
345
+ for (const [name, c] of want) {
346
+ if (!isPart.has(name)) continue;
347
+ partOverride.set(name, c);
348
+ }
349
+ const placedBoxes = measureActorBoxes(withPositions(diagram, doc, naive));
350
+ const fixed = new Map<string, { posX: number; posY: number }>();
351
+ for (const [name, pos] of naive) {
352
+ // パーツは補正しない。 merge が座標を中心としてそのまま使うので狙いがそのまま効く。
353
+ // 一方この時点の図にはパーツの仮の箱しか無く、 動いていない位置を測って差を足すと
354
+ // ずれが二重になる (実測 = 狙い 760 に対して 1320 に飛んだ)
355
+ if (isPart.has(name)) {
356
+ fixed.set(name, pos);
357
+ continue;
358
+ }
359
+ const got = placedBoxes.get(name);
360
+ const target = want.get(name)!;
361
+ if (!got) {
362
+ fixed.set(name, pos);
363
+ continue;
364
+ }
365
+ fixed.set(name, {
366
+ posX: pos.posX + (target.cx - got.cx),
367
+ posY: pos.posY + (target.cy - got.cy),
368
+ });
369
+ }
370
+
371
+ // 4. 効いたかを確かめ、 効かなかった分は自動配置に戻す。
372
+ //
373
+ // 座標がどの向きにも効く保証は無い。 順序図の縦位置がその例で、 縦列は横に並ぶものなので
374
+ // 下に動かせない。 そのまま出すと基準の上に重なった図が出る (実測)。 動かなかった時は
375
+ // 書かなかった時と同じ配置に戻し、 何が効かなかったかを呼出側に伝える。
376
+ return withDocPositions(doc, verifyPlacement(diagram, doc, fixed, partOverride, onNotice));
377
+ }
378
+
379
+ /**
380
+ * 置いた結果が書いた通りかを確かめ、 外れた分を落とす。
381
+ *
382
+ * 確かめるのは最後の配置での「基準との位置関係」 で、 手順 1 で測った狙いではない。
383
+ * 誰かを固定すると周りの自動配置が動くため、 狙いと突き合わせると基準がずれた分を
384
+ * 見逃す。 書いた言葉 (`Web の右 200`) が最後の図でも成り立つかを見る。
385
+ */
386
+ function verifyPlacement(
387
+ diagram: CdlDiagram,
388
+ doc: DslDocument,
389
+ assign: ReadonlyMap<string, { posX: number; posY: number }>,
390
+ partOverride: ReadonlyMap<string, AnchorBox>,
391
+ onNotice?: (notice: CompileNotice) => void,
392
+ ): Map<string, { posX: number; posY: number }> {
393
+ const boxes = measureActorBoxes(withPositions(diagram, doc, assign));
394
+ // パーツは merge 前なので、 図には実寸と違う仮の箱しか無い。 解決側と同じ箱に差し替える。
395
+ //
396
+ // 差し替えないと 2 通りに壊れる。 パーツを基準にした箱は仮の箱から期待を作って落とされ
397
+ // (実測 = 正しく置いた箱が「効きません」 になった)、 パーツ自身も仮の箱の位置と
398
+ // 突き合わせて落とされる。
399
+ for (const [name, box] of partOverride) boxes.set(name, box);
400
+ const kept = new Map(assign);
401
+ for (const actor of doc.actors) {
402
+ const rel = actor.posRel;
403
+ const pos = assign.get(actor.name);
404
+ if (!rel || !pos) continue;
405
+ const self = boxes.get(actor.name);
406
+ const anchor = boxes.get(rel.anchor);
407
+ if (!self || !anchor) continue;
408
+ const expect = resolveRelativePos(rel, anchor, self);
409
+ const offX = Math.abs(expect.posX - self.cx);
410
+ const offY = Math.abs(expect.posY - self.cy);
411
+ if (offX <= PLACEMENT_TOLERANCE && offY <= PLACEMENT_TOLERANCE) continue;
412
+ kept.delete(actor.name);
413
+ onNotice?.({
414
+ kind: "relative-position-ignored",
415
+ actor: actor.name,
416
+ line: actor.pos.line,
417
+ message: `"${actor.name}" の位置 (${rel.anchor} の${DIRECTION_LABEL[rel.dir]}) は${doc.type}図では効きません`,
418
+ hint: "座標 (`位置: 300,200`) で置くか、 自動配置に任せる",
419
+ });
420
+ }
421
+ return kept;
422
+ }
423
+
424
+ /** 向きの表示名。 効かなかった時の知らせで、 書いた言葉に近い形で返すために持つ。 */
425
+ const DIRECTION_LABEL: Readonly<Record<RelativeDirection, string>> = {
426
+ right: "右",
427
+ left: "左",
428
+ above: "上",
429
+ below: "下",
430
+ };
431
+
432
+ /**
433
+ * 書いた通りに置けたと見なす誤差。
434
+ *
435
+ * 補正が効いた図種では実測 0.0 で一致する。 効かない向き (順序図の縦) は数百ずれるので、
436
+ * その間で切る。 丸めと配置計算の揺れを吸収する幅として 1 を取る。
437
+ */
438
+ const PLACEMENT_TOLERANCE = 1;
439
+
440
+ /**
441
+ * 相対で書かれた分について、 中心をどこに置きたいかを求める。
442
+ *
443
+ * 基準がまた相対で書かれていることがある (`C は B の右`、 `B は A の右`) ため、 依存の浅い順に
444
+ * 解く。 解けた中心は基準として次に使う。
445
+ */
446
+ function desiredCenters(
447
+ doc: DslDocument,
448
+ boxes: ReadonlyMap<string, AnchorBox>,
449
+ sizeOverride: ReadonlyMap<string, { w: number; h: number }> = new Map(),
450
+ ): Map<string, AnchorBox> {
451
+ // 大きさだけを見る。 中心のずれは呼ぶ側が座標に直す時に引く
452
+ const byName = new Map(doc.actors.map((a) => [a.name, a] as const));
453
+ const { order } = orderByDependency(doc.actors.map((a) => ({ name: a.name, rel: a.posRel })));
454
+ // 基準に使う中心。 相対で書かれていない分は測った位置をそのまま使う
455
+ const centers = new Map<string, AnchorBox>(boxes);
456
+ const out = new Map<string, AnchorBox>();
457
+
458
+ for (const name of order) {
459
+ const actor = byName.get(name);
460
+ // 自分の大きさ。 パーツは catalog の値を使う (図に残る仮の箱は実寸と違う)
461
+ const override = sizeOverride.get(name);
462
+ const measuredSelf = boxes.get(name);
463
+ const self = override
464
+ ? { cx: measuredSelf?.cx ?? 0, cy: measuredSelf?.cy ?? 0, w: override.w, h: override.h }
465
+ : measuredSelf;
466
+ if (!actor?.posRel || !self) continue;
467
+ const anchor = centers.get(actor.posRel.anchor);
468
+ // 測れない相手を基準にした分は自動配置のまま残す。 相手が居ることは parser が確かめて
469
+ // いるので、 ここに来るのは図に箱として現れない相手 (catalog に無いパーツ等) を指した場合
470
+ if (!anchor) continue;
471
+ const p = resolveRelativePos(actor.posRel, anchor, self);
472
+ const center: AnchorBox = { cx: p.posX, cy: p.posY, w: self.w, h: self.h };
473
+ centers.set(name, center);
474
+ out.set(name, center);
475
+ }
476
+ return out;
477
+ }
478
+
479
+ /**
480
+ * 登場人物ごとの、 図の上での中心と大きさを測る。
481
+ *
482
+ * 対応付けは箱に表示される名前で行う。 id を使わない理由は `applyNodeTones` と同じで、
483
+ * slug の作り方が dragon と cdl で違うため記号を含む名前で一致しない。
484
+ *
485
+ * 1 人が複数の箱に分かれる図種 (順序図の上端 / 下端) では、 全部を囲む矩形を返す。
486
+ * 箱として現れない登場人物は縦列の矩形で代用する。
487
+ */
488
+ export function measureActorBoxes(
489
+ diagram: CdlDiagram,
490
+ /**
491
+ * 配置まで済ませた図。 渡すとここでは測り直さない (#1006)。
492
+ *
493
+ * 呼出側が既に組み立てているなら、 ここで `layout` を呼ぶと同じ図の配置を 2 度計算する。
494
+ * 図の規模に比例して重く、 辺 500 本で約 300ms (実測)。
495
+ * 渡す時は `diagram` と対にする = 別の図の配置を渡すと、 測る位置がずれる。
496
+ */
497
+ laidHint?: LaidDiagram,
498
+ ): Map<string, AnchorBox> {
499
+ const laid = laidHint ?? layout(diagram);
500
+ const bounds = new Map<string, { x0: number; y0: number; x1: number; y1: number }>();
501
+ for (const n of laid.nodes) {
502
+ const title = n.title;
503
+ if (!title) continue;
504
+ const x0 = n.cx - n.w / 2;
505
+ const y0 = n.cy - n.h / 2;
506
+ const x1 = n.cx + n.w / 2;
507
+ const y1 = n.cy + n.h / 2;
508
+ const cur = bounds.get(title);
509
+ if (cur) {
510
+ cur.x0 = Math.min(cur.x0, x0);
511
+ cur.y0 = Math.min(cur.y0, y0);
512
+ cur.x1 = Math.max(cur.x1, x1);
513
+ cur.y1 = Math.max(cur.y1, y1);
514
+ } else {
515
+ bounds.set(title, { x0, y0, x1, y1 });
516
+ }
517
+ }
518
+ const out = new Map<string, AnchorBox>();
519
+ for (const [name, b] of bounds) {
520
+ out.set(name, { cx: (b.x0 + b.x1) / 2, cy: (b.y0 + b.y1) / 2, w: b.x1 - b.x0, h: b.y1 - b.y0 });
521
+ }
522
+ for (const lane of laid.lanes) {
523
+ const label = lane.label;
524
+ if (!label || out.has(label)) continue;
525
+ const y = lane.y ?? 0;
526
+ const h = lane.height ?? 0;
527
+ out.set(label, { cx: (lane.x ?? 0) + lane.width / 2, cy: y + h / 2, w: lane.width, h });
528
+ }
529
+ return out;
530
+ }
531
+
532
+ /** doc の複製に、 決まった座標を入れる。 元の doc は書き換えない。 */
533
+ function withDocPositions(
534
+ doc: DslDocument,
535
+ assign: ReadonlyMap<string, { posX: number; posY: number }>,
536
+ ): DslDocument {
537
+ if (assign.size === 0) return doc;
538
+ return {
539
+ ...doc,
540
+ actors: doc.actors.map((a) => {
541
+ const p = assign.get(a.name);
542
+ return p ? { ...a, posX: p.posX, posY: p.posY } : a;
543
+ }),
544
+ };
545
+ }
546
+
547
+ /** 決まった座標を反映した図の複製を作る。 測り直す時だけ使う捨て図。 */
548
+ function withPositions(
549
+ diagram: CdlDiagram,
550
+ doc: DslDocument,
551
+ assign: ReadonlyMap<string, { posX: number; posY: number }>,
552
+ ): CdlDiagram {
553
+ const probe: CdlDiagram = {
554
+ ...diagram,
555
+ lanes: diagram.lanes.map((l) => ({ ...l })),
556
+ nodes: diagram.nodes.map((n) => ({ ...n })),
557
+ };
558
+ applyCanvasPivotPositions(probe, withDocPositions(doc, assign));
559
+ return probe;
560
+ }
561
+
562
+ /**
563
+ * 全図種共通の後処理で、 登場人物に書かれた色を対応する箱に載せる。
564
+ *
565
+ * 箱を作る経路は図種ごとに違い、 cdl の preset を経由する図種 (流れ図 / ER / 状態遷移 / 構成図)
566
+ * では preset の入力型が色の項目を持たない。 箱が出来上がった後に id で対応付けることで、
567
+ * どの図種でも同じ書き方が効く。 座標を伝播する `applyCanvasPivotPositions` と同じ経路。
568
+ *
569
+ * 対応付けは箱に表示される名前との一致で行う。 id は使わない。
570
+ *
571
+ * id での対応付けは 2 通りに壊れる。 id は名前を slug に変換して作るが、 その変換規則が
572
+ * dragon と cdl で違い、 記号を含む名前では一致しない (実測 = `A_B` が dragon 側で `a_b`、
573
+ * cdl 側で `a-b`)。 逆に、 生成した id (`{slug}-header`) をそのまま名前に持つ登場人物が
574
+ * 居ると、 別人の箱を巻き込む。
575
+ *
576
+ * 表示名は変換を経ないので前者が起きず、 別人と一致しないので後者も起きない。 順序図で
577
+ * 1 人が分かれる複数の箱のうち、 間隔用と手順ごとの anchor は表示名が空なので自然に対象外に
578
+ * なる (色を持っても幅 2 で見えない)。
579
+ *
580
+ * parts は対象外。 parts の `tone` は色ではなく状態の上書きとして parser が扱うため、
581
+ * ここに色として渡ってこない。
582
+ */
583
+ function applyNodeTones(diagram: CdlDiagram, doc: DslDocument): void {
584
+ for (const actor of doc.actors) {
585
+ if (actor.tone === undefined) continue;
586
+ for (const node of diagram.nodes) {
587
+ if (node.title === actor.name) node.tone = actor.tone;
588
+ }
589
+ }
590
+ }
591
+
592
+ /**
593
+ * canvas pivot 新 spec = 全 preset 共通の post-process で actor.posX/Y/W/H を CDL 側 lane / node に伝播。
594
+ * preset builder が生成した diagram に対して、 doc.actors の 4 field を絶対座標として反映する。
595
+ * slugify で actor 名 → lane id / node id の逆引き、 posX/Y set 済 actor に対応する lane / node に
596
+ * 座標を書込む。 未指定 actor は従来 auto layout 経路そのまま。
597
+ */
598
+ function applyCanvasPivotPositions(diagram: CdlDiagram, doc: DslDocument): void {
599
+ for (const actor of doc.actors) {
600
+ if (actor.partId !== undefined) continue; // parts actor は別経路 (mergePartsFromActors) で処理
601
+ const aliasSlug = slugify(actor.name);
602
+ // actor 全体 posX/Y = lane と単一 node に一括反映 (従来経路)
603
+ if (actor.posX !== undefined && actor.posY !== undefined) {
604
+ for (const lane of diagram.lanes) {
605
+ if (lane.id === aliasSlug || lane.id === actor.name) {
606
+ lane.posX = actor.posX;
607
+ lane.posY = actor.posY;
608
+ if (actor.posW !== undefined) lane.posW = actor.posW;
609
+ if (actor.posH !== undefined) lane.posH = actor.posH;
610
+ }
611
+ }
612
+ for (const node of diagram.nodes) {
613
+ if (node.id === aliasSlug || node.id === actor.name) {
614
+ node.posX = actor.posX;
615
+ node.posY = actor.posY;
616
+ if (actor.posW !== undefined) node.posW = actor.posW;
617
+ if (actor.posH !== undefined) node.posH = actor.posH;
618
+ }
619
+ }
620
+ }
621
+ // canvas pivot UX 修正 (B1) = actor.nodes[subKey] を対応 CDL node に個別反映。
622
+ // sub-node id pattern を actor scope 限定の 2 経路に絞る (subagent review MAJOR-1 対応、 CAR-canvas-pivot):
623
+ // 1. `{aliasSlug}-{subKey}` = header / footer / spacer 等 suffix
624
+ // 2. `{subKey}-{aliasSlug}` = sequence step box `s{N}-{aliasSlug}` 等 prefix
625
+ // 旧 `node.id === subKey` 完全一致 fallback は actor scope を持たず cross-actor pollution risk
626
+ // (別 actor が保有する同名 id node に座標が漏れる silent bug) のため削除。 全 sub-node は必ず
627
+ // aliasSlug を接頭 / 接尾に含む形式で生成されるため、 2 経路で網羅済。
628
+ // lane 側は触らない = 他 sub-node の auto layout 経路を保持 (B1 独立性の SSOT)。
629
+ if (actor.nodes) {
630
+ for (const [subKey, override] of Object.entries(actor.nodes)) {
631
+ if (override.posX === undefined || override.posY === undefined) continue;
632
+ for (const node of diagram.nodes) {
633
+ if (
634
+ node.id === `${aliasSlug}-${subKey}` ||
635
+ node.id === `${subKey}-${aliasSlug}`
636
+ ) {
637
+ node.posX = override.posX;
638
+ node.posY = override.posY;
639
+ if (override.posW !== undefined) node.posW = override.posW;
640
+ if (override.posH !== undefined) node.posH = override.posH;
641
+ }
642
+ }
643
+ }
644
+ }
645
+ }
646
+ }
647
+
648
+ /**
649
+ * catalog からパーツ 1 個の図を引く。
650
+ *
651
+ * `Object.hasOwn` で引く。 素の添字だと `__proto__` 等の既定の持ち物が引けてしまう
652
+ * (catalog は呼出側が渡す untrusted な値)。
653
+ *
654
+ * **測れない図は「無い」 として扱う** (#1015)。 大きさを測れないまま取り込むと、既定の
655
+ * 400x200 の枠を確保した場所に中身が全て展開される。 上限を置いた目的 (大きすぎる入力で
656
+ * 止まらないようにする) も達成されない。
657
+ */
658
+ function lookupPart(
659
+ partsCatalog: Record<string, CdlDiagram>,
660
+ partId: string | undefined,
661
+ ): CdlDiagram | undefined {
662
+ const found = lookupPartRaw(partsCatalog, partId);
663
+ if (found === undefined) return undefined;
664
+ return partIsMeasurable(found) ? found : undefined;
665
+ }
666
+
667
+ /** catalog を引くところだけ。 測れるかは見ない。 */
668
+ function lookupPartRaw(
669
+ partsCatalog: Record<string, CdlDiagram>,
670
+ partId: string | undefined,
671
+ ): CdlDiagram | undefined {
672
+ if (typeof partId !== "string" || partId.length === 0) return undefined;
673
+ if (Object.hasOwn(partsCatalog, partId)) return partsCatalog[partId];
674
+ if (Object.hasOwn(partsCatalog, `parts-${partId}`)) return partsCatalog[`parts-${partId}`];
675
+ return undefined;
676
+ }
677
+
678
+ /**
679
+ * この図を取り込んでよいか (#1015)。
680
+ *
681
+ * 見るのは **要素数が上限 (`MAX_INPUT_ELEMENTS`) を超えていないこと** だけ。
682
+ * 超えた図を取り込むと、既定の 400x200 の枠を確保した場所に中身が全て展開される。
683
+ * 上限を置いた意図 (大きすぎる入力で止まらないようにする) も達成されない。
684
+ *
685
+ * **配置計算が通るかは見ない**。 取り込みは lane を張り替えるため、単体では配置計算が
686
+ * 通らない図でも取り込みは成功する (実測 = 存在しない lane を指す箱を持つ見本が、
687
+ * 取り込み後は正しい lane に載った)。 配置計算で弾くと、動いている本文が描けなくなる。
688
+ */
689
+ export function partIsMeasurable(part: CdlDiagram): boolean {
690
+ return countDiagramElements(part) <= MAX_INPUT_ELEMENTS;
691
+ }
692
+
693
+ /**
694
+ * 箱の大きさを書かなかった時に cdl が使う値。
695
+ *
696
+ * 幅は実測で 340 固定 (縦列の幅を変えても変わらない)。 高さは種類で変わるため、 よく使われる
697
+ * 値を既定にする。 パーツの図が大きさを書いていれば、 こちらは使われない。
698
+ */
699
+ const CDL_DEFAULT_NODE_W = 340;
700
+ const CDL_DEFAULT_NODE_H = 200;
701
+
702
+ /** 有限で正の数だけを通す。 catalog は呼出側が渡す値なので、 異常値を計算に入れない。 */
703
+ function positiveOr(value: unknown, fallback: number): number {
704
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
705
+ }
706
+
707
+ /**
708
+ * 配列の最大値 / 最小値。 spread で展開しない (要素数が多い catalog で stack が溢れる)。
709
+ *
710
+ * 空の時だけ既定値を返す。 既定値を初期値にすると、 全要素が既定値より小さい (大きい) 時に
711
+ * 存在しない値を範囲に含める (実測 = stack 5 だけのパーツで 0 を含め、 高さが 5 段分になった)。
712
+ */
713
+ function maxOf(values: readonly number[], fallback: number): number {
714
+ if (values.length === 0) return fallback;
715
+ let out = values[0]!;
716
+ for (const v of values) if (v > out) out = v;
717
+ return out;
718
+ }
719
+
720
+ function minOf(values: readonly number[], fallback: number): number {
721
+ if (values.length === 0) return fallback;
722
+ let out = values[0]!;
723
+ for (const v of values) if (v < out) out = v;
724
+ return out;
725
+ }
726
+
727
+ /**
728
+ * merge がパーツを縦に送る幅。 `mergePartIntoDiagram` の `STACK_PITCH_APPROX` と同じ値。
729
+ *
730
+ * 大きさの見積りは merge が実際に置く形と揃える。 別の規則で見積ると、 間隔が狂う
731
+ * (実測 = 2 段のパーツで 200 空けたいところが 90 になった)。
732
+ */
733
+ const PART_STACK_PITCH = 220;
734
+
735
+ /**
736
+ * 倍率の上限 (#1020)。
737
+ *
738
+ * 図枠は数百 world 単位なので、1000 倍で数十万になる。 これを超える倍率は画面上で意味を持たず、
739
+ * 掛けた先が非有限になる危険だけが残る。
740
+ */
741
+ export const MAX_PART_SCALE = 1000;
742
+
743
+ /**
744
+ * 本文に書かれた倍率を、描ける値に直す (#1020 / #1026)。
745
+ *
746
+ * 記法は `倍率: -2` も `倍率: 0` も、桁が溢れて `Infinity` になる値も書ける。 置き場所と
747
+ * 描画で別々に直すと、同じ見本が「置き場所は等倍・画面では消える」 状態になる (実測 =
748
+ * `scale: 0` が等倍の場所を占めるのに画面には出なかった)。 読んだ時点で直す。
749
+ *
750
+ * **画面側と組み立て側の両方から呼ぶ**。 別々に持つと、同じ本文が経路で別の絵になる (#1026)。
751
+ */
752
+ export function normalizePartScale(value: number): number {
753
+ if (!Number.isFinite(value) || value <= 0) return 1;
754
+ return Math.min(value, MAX_PART_SCALE);
755
+ }
756
+
757
+ /**
758
+ * `大きさ:` と `倍率:` を合成した最終の伸縮率 (#1026)。
759
+ *
760
+ * **上限は合成した後に 1 度だけ掛ける**。 率ごとに掛けると、`大きさ:` 由来 1000 倍と
761
+ * `倍率: 2` で合わせて 2000 倍になり、1 度だけ掛ける経路 (1000 倍) と食い違う (実測)。
762
+ *
763
+ * 基準は `大きさ:` と同じ物差し (縦列の外接矩形と段の送り幅)。 図枠を基準にすると、
764
+ * 図枠と外接矩形の差のぶんだけ余分に掛かる (実測 = 3 倍と書いて 4.0875 倍になった)。
765
+ *
766
+ * 画面側 (重ねて描く時の `transform`) と組み立て側 (取り込む時の伸縮) が同じ値を使う。
767
+ */
768
+ export function partScaleFactor(
769
+ part: CdlDiagram,
770
+ posW: number | undefined,
771
+ posH: number | undefined,
772
+ scale: number | undefined,
773
+ ): { x: number; y: number } {
774
+ const base = partScaleBase(part);
775
+ const k = scale === undefined ? 1 : normalizePartScale(scale);
776
+ const rx = posW !== undefined && posW > 0 ? posW / base.w : 1;
777
+ const ry = posH !== undefined && posH > 0 ? posH / base.h : 1;
778
+ return { x: normalizePartScale(rx * k), y: normalizePartScale(ry * k) };
779
+ }
780
+
781
+ /**
782
+ * 見本 1 件の狙いの大きさ (#1026)。
783
+ *
784
+ * 合成した率を基準に掛けて返す。 取り込み側はこの値から自分で率を出し直すため、
785
+ * ここで上限を掛けておかないと「見積りは上限どまり・実体は青天井」 になる (実測 =
786
+ * 見積り 1000 倍に対して実体 10000 倍)。
787
+ *
788
+ * 何も書かれていない辺は「狙いなし」 のまま返す。 基準の値を入れると、取り込み側が
789
+ * 自前で測る外接矩形との差だけ伸縮が掛かってしまう。
790
+ */
791
+ export function partTargetSize(
792
+ part: CdlDiagram,
793
+ posW: number | undefined,
794
+ posH: number | undefined,
795
+ scale: number | undefined,
796
+ ): { w: number | undefined; h: number | undefined } {
797
+ if (posW === undefined && posH === undefined && scale === undefined) {
798
+ return { w: undefined, h: undefined };
799
+ }
800
+ const base = partScaleBase(part);
801
+ const f = partScaleFactor(part, posW, posH, scale);
802
+ return {
803
+ w: posW === undefined && scale === undefined ? undefined : base.w * f.x,
804
+ h: posH === undefined && scale === undefined ? undefined : base.h * f.y,
805
+ };
806
+ }
807
+
808
+ /**
809
+ * `大きさ:` と `倍率:` が掛かる時の基準の大きさ (#1026)。
810
+ *
811
+ * 横は縦列の外接矩形、縦は段の送り幅の合計。 **図枠 (`partRenderSize`) ではない**。
812
+ * 図枠は余白を含むため、これを基準にすると書いた倍率より大きく掛かる。
813
+ *
814
+ * `partTargetScale` と `partTargetSize` が同じ物差しを使うことで、
815
+ * `partTargetScale(part, base.w * k, base.h * k)` が丁度 `k` 倍を返す関係が保たれる。
816
+ */
817
+ function partScaleBase(part: CdlDiagram): { w: number; h: number } {
818
+ const lanes = Array.isArray(part.lanes) ? part.lanes : [];
819
+ const nodes = Array.isArray(part.nodes) ? part.nodes : [];
820
+ const lefts: number[] = [];
821
+ const rights: number[] = [];
822
+ for (const l of lanes) {
823
+ const lx = typeof l.x === "number" && Number.isFinite(l.x) ? l.x : 0;
824
+ const lw = positiveOr(l.width, 400);
825
+ lefts.push(lx);
826
+ rights.push(lx + lw);
827
+ }
828
+ const stacks = nodes.map((n) =>
829
+ typeof n.stack === "number" && Number.isFinite(n.stack) ? n.stack : 0,
830
+ );
831
+ return {
832
+ w: positiveOr(maxOf(rights, 400) - minOf(lefts, 0), 400),
833
+ h: Math.max(1, (maxOf(stacks, 0) - minOf(stacks, 0) + 1) * PART_STACK_PITCH),
834
+ };
835
+ }
836
+
837
+ /**
838
+ * `大きさ:` を書いた時に、見本を何倍にするか (#1018)。
839
+ *
840
+ * 横は縦列の幅、縦は段の数から出す。 どちらも書かなければ 1 倍。
841
+ *
842
+ * **縦は「書いた高さにする」 ではなく「段の送り幅の合計に対する倍率」**。 `大きさ: 2000,300` を
843
+ * 1 段の見本に書くと、横は 2000 になるが縦は 300 ではなく 409 になる (段の送り幅 220 に対して
844
+ * 300 なので 1.36 倍、それが箱の高さ 300 に掛かる)。 意図した仕様かは怪しいが、既に本文が
845
+ * この前提で書かれているため変えない。 画面側も同じ規則で拡大する。
846
+ *
847
+ * 組み立て側 (`partExtent`) と画面側 (playground) の両方から呼ぶ。 別々に持つと、`大きさ:` を
848
+ * 書いた見本だけ経路で大きさが変わる。
849
+ */
850
+ export function partTargetScale(
851
+ part: CdlDiagram,
852
+ targetW?: number,
853
+ targetH?: number,
854
+ ): { x: number; y: number } {
855
+ const none = { x: 1, y: 1 };
856
+ if (!Array.isArray(part.lanes) || !Array.isArray(part.nodes)) return none;
857
+ // 箱が 1 つも無い図でも縦列があれば取り込み側は伸縮する。 ここで 1 に倒すと、
858
+ // 箱を持たない外部の見本だけ画面が等倍のまま残る
859
+
860
+ // 倍率を書かない場合の合成率。 上限の掛け方を 1 箇所に閉じるため同じ関数を通す
861
+ return partScaleFactor(part, targetW, targetH, undefined);
862
+ }
863
+
864
+
865
+
866
+ /**
867
+ * パーツ 1 個が図の上で占める外接矩形。
868
+ *
869
+ * `w` / `h` は大きさ、 `dx` / `dy` は矩形の中心が「merge に渡す座標」 からどれだけずれるか。
870
+ *
871
+ * merge がパーツを置く時に基準にするのは段の中心で、 外接矩形の中心とは一致しない。 段ごとに
872
+ * 箱の高さが違うと、 上下の伸び方が非対称になるため (実測 = 段 0 に高さ 50、 段 5 に高さ 200 の
873
+ * パーツで中心が 37.5 下にずれる)。 ずれを返して呼ぶ側が引く。
874
+ *
875
+ * 箱ごとに位置と大きさを見る。 一番高い箱の高さと段の数から概算すると実際の矩形と合わない
876
+ * (実測 = 段 5 だけのパーツで 200 空けたいところが 750、 段 0,5 で高さが違うと 275 になった)。
877
+ *
878
+ * 段の送り幅は merge の近似 (`PART_STACK_PITCH`) を使う。 パーツを自分の図として配置計算した
879
+ * 実寸とは段を持つパーツで 3% ほど違うが (実測 = 3 段で実高 620 に対して 640)、 ここで見たいのは
880
+ * 「merge がどこに置くか」 なので merge の規則に合わせる。
881
+ */
882
+ function partExtent(
883
+ part: CdlDiagram,
884
+ targetW?: number,
885
+ targetH?: number,
886
+ ): { w: number; h: number; dx: number; dy: number } {
887
+ const fallback = { w: 400, h: 200, dx: 0, dy: 0 };
888
+ if (!Array.isArray(part.lanes) || !Array.isArray(part.nodes)) return fallback;
889
+ if (part.nodes.length === 0) return fallback;
890
+
891
+ // 縦列の位置と幅を先に正す。 catalog は呼出側が渡す値なので、 数でない値を計算に入れない
892
+ const lanes = new Map<string, { x: number; w: number }>();
893
+ const laneLefts: number[] = [];
894
+ const laneRights: number[] = [];
895
+ for (const l of part.lanes) {
896
+ const x = typeof l.x === "number" && Number.isFinite(l.x) ? l.x : 0;
897
+ const w = positiveOr(l.width, 400);
898
+ lanes.set(l.id, { x, w });
899
+ laneLefts.push(x);
900
+ laneRights.push(x + w);
901
+ }
902
+ const bboxW = positiveOr(maxOf(laneRights, 400) - minOf(laneLefts, 0), 400);
903
+ const bboxCenterX = minOf(laneLefts, 0) + bboxW / 2;
904
+ const { x: scaleX, y: scaleY } = partTargetScale(part, targetW, targetH);
905
+
906
+ const stacks = part.nodes.map((n) =>
907
+ typeof n.stack === "number" && Number.isFinite(n.stack) ? n.stack : 0,
908
+ );
909
+ const maxStack = maxOf(stacks, 0);
910
+ const minStack = minOf(stacks, 0);
911
+ const centerStack = (minStack + maxStack) / 2;
912
+
913
+ // 箱ごとに、 merge が置く位置 (基準からの相対) と大きさから上下左右の端を出す
914
+ const tops: number[] = [];
915
+ const bottoms: number[] = [];
916
+ const lefts: number[] = [];
917
+ const rights: number[] = [];
918
+ part.nodes.forEach((n, i) => {
919
+ const lane = lanes.get(n.lane) ?? { x: 0, w: 320 };
920
+ const cx = (lane.x + lane.w / 2 - bboxCenterX) * scaleX;
921
+ const cy = ((stacks[i] ?? 0) - centerStack) * PART_STACK_PITCH * scaleY;
922
+ const halfW = (positiveOr(n.w, CDL_DEFAULT_NODE_W) * scaleX) / 2;
923
+ const halfH = (positiveOr(n.h, CDL_DEFAULT_NODE_H) * scaleY) / 2;
924
+ lefts.push(cx - halfW);
925
+ rights.push(cx + halfW);
926
+ tops.push(cy - halfH);
927
+ bottoms.push(cy + halfH);
928
+ });
929
+ const x0 = minOf(lefts, 0);
930
+ const x1 = maxOf(rights, 400);
931
+ const y0 = minOf(tops, 0);
932
+ const y1 = maxOf(bottoms, 200);
933
+
934
+ return {
935
+ w: positiveOr(x1 - x0, 400),
936
+ h: positiveOr(y1 - y0, 200),
937
+ dx: Number.isFinite((x0 + x1) / 2) ? (x0 + x1) / 2 : 0,
938
+ dy: Number.isFinite((y0 + y1) / 2) ? (y0 + y1) / 2 : 0,
939
+ };
940
+ }
941
+
942
+ /**
943
+ * パーツ 1 個が実際に描かれる大きさ (#937)。
944
+ *
945
+ * 図枠 (`viewBox`) を返す。 箱の外接矩形 (`partVisualSize`) ではない。 2 つは別物で、
946
+ * 実測では図枠 525x520 に対し箱 380x400 と余白の分だけ違う。 SVG は図枠を基準に
947
+ * `preserveAspectRatio` で収めるため、 箱の値を渡すと縮んで描いた大きさと食い違う
948
+ * (実測 = achievement が箱の値で描くと約 275x275 になった)。
949
+ *
950
+ * 箱を持たないパーツ (実体が操作パネルの部品である 17 件) でも図枠は出る。 箱だけを見ると
951
+ * 1x1 になり、 その値で描くと潰れる。
952
+ *
953
+ * ただし **図枠は場所を確保するだけで、図の中に何か描かれることは保証しない**。 上の 17 件は
954
+ * 図の中に描く部品を持たず、重ねても図には出ない (`partDrawsInDiagram`、#1017)。
955
+ *
956
+ * 画面が描く大きさと、 組み立て側の格子が確保する場所の両方がこれを見る。 別々の物差しを
957
+ * 持っていた頃は、 同じ本文でも通った経路でパーツの位置が変わっていた (#937)。
958
+ *
959
+ * 組み立てに失敗する図では、 既定の大きさに落とす (呼出側は catalog を渡すので通常起きない)。
960
+ */
961
+ export function partRenderSize(part: CdlDiagram): { w: number; h: number } {
962
+ const g = partFrameGeometry(part);
963
+ return { w: g.w, h: g.h };
964
+ }
965
+
966
+ /**
967
+ * この見本が、図の中に描かれる部品を持っているか (#1017)。
968
+ *
969
+ * 見本の中には実体が **操作パネルの部品** (`readouts`) だけのものがある。 配置計算も描画も
970
+ * `readouts` を図の中では扱わないため、図として重ねても何も出ない。 位置決めのための
971
+ * 1x1 の箱が 1 つあるだけになる。
972
+ *
973
+ * catalog 80 件を測ると、この 2 群は `readouts` の有無で完全に分かれた。
974
+ * `readouts` を持つ 17 件は箱と図枠の面積比が全件 0.0000 (箱は 1x1)、
975
+ * 持たない 63 件は最小でも 0.1877。 境目に入る件は無い。
976
+ *
977
+ * 判定は面積の閾値ではなく **`readouts` を持ち、かつ箱が図枠に対して極小** の 2 条件で行う。
978
+ * 閾値だけで見ると、小さい箱を意図して置いた見本を巻き込む。 `readouts` だけで見ると、
979
+ * 箱も実体も両方持つ見本 (現状 0 件だが作れる) を誤って弾く。
980
+ *
981
+ * 測れない図では「持っている」 側に倒す。 弾く側に倒すと、測れないだけの見本が使えなくなる。
982
+ */
983
+ export function partDrawsInDiagram(part: CdlDiagram): boolean {
984
+ const readouts = (part as { readouts?: unknown }).readouts;
985
+ if (!Array.isArray(readouts) || readouts.length === 0) return true;
986
+ const g = partFrameGeometry(part);
987
+ if (!(g.w > 0) || !(g.h > 0)) return true;
988
+ // 箱が図枠の 1% にも満たなければ、実体は図の外にある。
989
+ //
990
+ // **辺ごとに割ってから掛ける**。 面積を先に出すと桁の大きい図で溢れ、判定が反転する
991
+ // (実測 = 箱 1.7e305 x 1e4 / 図枠 1.7e308 x 1e4 は比 0.001 で「描かない」 が正しいのに、
992
+ // 面積を先に出すと Infinity / Infinity = NaN になって「描く」 に倒れた)。
993
+ //
994
+ // それでも出せない時は「持っている」 側に倒す。 弾く側に倒すと、
995
+ // 測れないだけの見本が使えなくなる
996
+ const ratio = (g.boxW / g.w) * (g.boxH / g.h);
997
+ if (!Number.isFinite(ratio)) return true;
998
+ return ratio >= 0.01;
999
+ }
1000
+
1001
+ /**
1002
+ * 図枠の中で、 箱の外接矩形がどこにどれだけの大きさで描かれるか (#1014)。
1003
+ *
1004
+ * `left` / `top` は図枠の左上からの余白、 `w` / `h` は箱の大きさ。 図枠は 1 対 1 で描かれるので、
1005
+ * 画面上の箱の位置は「図枠の左上 + `left`/`top`」 になる。
1006
+ *
1007
+ * 相対で書いた位置 (`位置: Web の右 200`) の間隔は、 見えている箱の縁から測る。 図枠の縁で
1008
+ * 測ると余白のぶんだけ広がる (実測 = 200 と書いて画面では 260 空いた)。 画面側が間隔を解く時に
1009
+ * 図枠ではなくこちらを使うことで、 組み立て側と同じ間隔になる。
1010
+ *
1011
+ * 測れない図では図枠と同じ大きさ・余白 0 を返す。 箱を持たない図でも同じで、 図枠がそのまま
1012
+ * 箱として扱われる。
1013
+ */
1014
+ export function partBoxInFrame(part: CdlDiagram): {
1015
+ w: number;
1016
+ h: number;
1017
+ left: number;
1018
+ top: number;
1019
+ } {
1020
+ const g = partFrameGeometry(part);
1021
+ return { w: g.boxW, h: g.boxH, left: g.left, top: g.top };
1022
+ }
1023
+
1024
+ /**
1025
+ * 見本 1 個の図枠と、 その中の箱の外接矩形 (位置と大きさ)。
1026
+ *
1027
+ * 図枠の大きさ・余白・箱の大きさは同じ配置計算から出るので、 1 回で全部を取る。 別々に呼ぶと
1028
+ * 同じ図を何度も組み立てることになり、 パーツを 1 個置くたびに配置計算が 2 回走る。
1029
+ *
1030
+ * 結果は見本ごとに覚えておく。 catalog の見本は複数の別名から同じものを指すため、 覚えないと
1031
+ * 別名の数だけ組み立て直す (相対指定があると 1 個につき 4 回になる)。 覚えるのは大きさだけで、
1032
+ * 色などの見た目は含まないため、 呼出側が色を差し替えても古い値にはならない。
1033
+ *
1034
+ * 組み立てと違い画面を描くたびに呼ばれるので、 大きすぎる図は測る前に止める。 上限は
1035
+ * 組み立て側と同じ物差しを使う (#1005)。 catalog の見本は数十要素なので通常は掛からない。
1036
+ *
1037
+ * 測れない図では既定の大きさと余白 0 に落とす。
1038
+ */
1039
+ type PartFrameGeometry = {
1040
+ w: number;
1041
+ h: number;
1042
+ left: number;
1043
+ top: number;
1044
+ boxW: number;
1045
+ boxH: number;
1046
+ };
1047
+
1048
+ const PART_FRAME_CACHE = new WeakMap<CdlDiagram, PartFrameGeometry>();
1049
+
1050
+ function partFrameGeometry(part: CdlDiagram): PartFrameGeometry {
1051
+ const cached = PART_FRAME_CACHE.get(part);
1052
+ if (cached) return cached;
1053
+ const fallback = { w: 400, h: 200, left: 0, top: 0, boxW: 400, boxH: 200 };
1054
+ let out = fallback;
1055
+ if (countDiagramElements(part) <= MAX_INPUT_ELEMENTS) {
1056
+ try {
1057
+ const own = layout(part);
1058
+ const vb = own.viewBox;
1059
+ const w = positiveOr(vb.w, 400);
1060
+ const h = positiveOr(vb.h, 200);
1061
+ if (own.nodes.length === 0) {
1062
+ // 箱を持たない図では図枠をそのまま箱として扱う。 相対指定の間隔は図枠の縁から測る
1063
+ out = { w, h, left: 0, top: 0, boxW: w, boxH: h };
1064
+ } else {
1065
+ let x0 = Infinity;
1066
+ let y0 = Infinity;
1067
+ let x1 = -Infinity;
1068
+ let y1 = -Infinity;
1069
+ for (const n of own.nodes) {
1070
+ x0 = Math.min(x0, n.cx - n.w / 2);
1071
+ x1 = Math.max(x1, n.cx + n.w / 2);
1072
+ y0 = Math.min(y0, n.cy - n.h / 2);
1073
+ y1 = Math.max(y1, n.cy + n.h / 2);
1074
+ }
1075
+ const left = x0 - vb.x;
1076
+ const top = y0 - vb.y;
1077
+ out = {
1078
+ w,
1079
+ h,
1080
+ left: Number.isFinite(left) ? left : 0,
1081
+ top: Number.isFinite(top) ? top : 0,
1082
+ boxW: positiveOr(x1 - x0, w),
1083
+ boxH: positiveOr(y1 - y0, h),
1084
+ };
1085
+ }
1086
+ } catch {
1087
+ out = fallback;
1088
+ }
1089
+ }
1090
+ PART_FRAME_CACHE.set(part, out);
1091
+ return out;
1092
+ }
1093
+
1094
+ /**
1095
+ * パーツ 1 個が図の上で確保する図枠 (merge に渡す座標での表し方)。
1096
+ *
1097
+ * `w` / `h` は図枠の大きさ、 `dx` / `dy` は図枠の中心が「merge に渡す座標」 からどれだけ
1098
+ * ずれるか。 画面側は図枠をそのまま置くので、 格子が図枠で場所を決めれば 2 経路が揃う。
1099
+ *
1100
+ * **合わせるのは箱の中心ではなく左上**。 段を 2 つ以上持つパーツは、 取り込んだ後に本体の
1101
+ * 送り幅で並び直すため箱の高さが単体の時と変わる (実測 = 単体 300 が取り込むと 320)。
1102
+ * 中心で合わせると、 高さの差の半分だけ上端がずれて段内の揃いが崩れる (実測で 12.5)。
1103
+ * 左上で合わせれば、 高さが変わっても上端は動かない。
1104
+ *
1105
+ * 図枠にも箱にも `大きさ:` の伸縮を掛ける。 画面側も同じ率で伸縮するので、掛けないと
1106
+ * 確保する場所だけが元の大きさのまま残る (実測 = 240 ずれた、#1018)。
1107
+ *
1108
+ * 確保するのは図枠と箱の両方を含む矩形。 縦横で率が違うと箱が図枠からはみ出すことがあり、
1109
+ * 図枠だけを確保すると隣に重なる (実測 = `大きさ: 2000,300` の箱が x=60..2060 に伸び、
1110
+ * 隣が 725 から始まって 1335 重なった)。 `大きさ:` を書かなければ図枠が箱を包むので、
1111
+ * 和は図枠と一致して 2 経路の一致は保たれる。
1112
+ */
1113
+ function partFrameExtent(
1114
+ part: CdlDiagram,
1115
+ targetW?: number,
1116
+ targetH?: number,
1117
+ ): { w: number; h: number; dx: number; dy: number } {
1118
+ const box = partExtent(part, targetW, targetH);
1119
+ const geom = partFrameGeometry(part);
1120
+ // 図枠にも `大きさ:` の伸縮を掛ける。 掛けないと箱だけが伸びて、確保する場所が足りなくなる
1121
+ // (実測 = `大きさ: 2000,300` で組み立て側の箱が 60..2060、画面側が 300..2300 と 240 ずれた、#1018)
1122
+ const t = partTargetScale(part, targetW, targetH);
1123
+ const frame = {
1124
+ w: geom.w * t.x,
1125
+ h: geom.h * t.y,
1126
+ left: geom.left * t.x,
1127
+ top: geom.top * t.y,
1128
+ };
1129
+ // merge に渡す座標を原点にした時の、 図枠の中心
1130
+ const frameDx = box.dx + frame.w / 2 - frame.left - box.w / 2;
1131
+ const frameDy = box.dy + frame.h / 2 - frame.top - box.h / 2;
1132
+ const x0 = Math.min(frameDx - frame.w / 2, box.dx - box.w / 2);
1133
+ const x1 = Math.max(frameDx + frame.w / 2, box.dx + box.w / 2);
1134
+ const y0 = Math.min(frameDy - frame.h / 2, box.dy - box.h / 2);
1135
+ const y1 = Math.max(frameDy + frame.h / 2, box.dy + box.h / 2);
1136
+ return {
1137
+ w: positiveOr(x1 - x0, frame.w),
1138
+ h: positiveOr(y1 - y0, frame.h),
1139
+ dx: Number.isFinite((x0 + x1) / 2) ? (x0 + x1) / 2 : frameDx,
1140
+ dy: Number.isFinite((y0 + y1) / 2) ? (y0 + y1) / 2 : frameDy,
1141
+ };
1142
+ }
1143
+
1144
+ /**
1145
+ * パーツ 1 個の箱の外接矩形。
1146
+ *
1147
+ * 図枠 (`partRenderSize`) とは別で、 余白を含まない。 相対指定を解く時の「縁からの距離」 に使う。
1148
+ */
1149
+ export function partVisualSize(
1150
+ part: CdlDiagram,
1151
+ targetW?: number,
1152
+ targetH?: number,
1153
+ ): { w: number; h: number } {
1154
+ const e = partExtent(part, targetW, targetH);
1155
+ return { w: e.w, h: e.h };
1156
+ }
1157
+
1158
+ /** 格子に並べる時の 1 行あたりの個数と隙間。 */
1159
+ const PARTS_PER_ROW = 3;
1160
+ const PARTS_GAP = 120;
1161
+ /**
1162
+ * 既存の図の下に置く時の目安。
1163
+ *
1164
+ * 既存の箱は自動配置なので、 この時点では座標を持たない。 箱の数から概算する。
1165
+ * 1 段あたりの高さは cdl の既定の縦送り幅に合わせる。
1166
+ */
1167
+ const STACK_PITCH = 280;
1168
+
1169
+ /**
1170
+ * 位置を書かなかったパーツを格子に並べた時の、 矩形の中心。
1171
+ *
1172
+ * 組み立て側 (`mergePartsFromActors`) と画面側 (playground の overlay) の両方から呼ぶ。
1173
+ * 別々に計算すると、 同じ本文でも経路によってパーツの位置が変わる。
1174
+ *
1175
+ * 列の送り幅は並べる全パーツの最大幅で揃える。 個々の幅で送ると、 幅の違うパーツが混ざった時に
1176
+ * 隣と重なる (実測 = 400 の次に 200 を置くと 280 重なった)。 段の高さも段内の最大高で揃える。
1177
+ * 縦は自分の高さの半分だけ段の上端から下げて、 段内で上端を揃える。
1178
+ *
1179
+ * @param baseNodeCount パーツ以外の箱の数。 既存の図の下から並べ始めるために使う
1180
+ */
1181
+ export function partsGridCenters(
1182
+ baseNodeCount: number,
1183
+ items: ReadonlyArray<{ id: string; w: number; h: number }>,
1184
+ ): Map<string, { cx: number; cy: number }> {
1185
+ const out = new Map<string, { cx: number; cy: number }>();
1186
+ if (items.length === 0) return out;
1187
+ // 公開している関数なので、 呼出側が渡す値を入口で閉じる。 数でない箱の数や桁溢れを
1188
+ // そのまま計算に入れると、 描けない座標を返すことになる
1189
+ const safeCount =
1190
+ Number.isSafeInteger(baseNodeCount) && baseNodeCount >= 0 ? baseNodeCount : 0;
1191
+ const top = safeCount * STACK_PITCH + PARTS_GAP * 2;
1192
+ // 同じ名前が 2 度来たら先の方だけを見る。 後の分を残すと、 どちらを指したか決められない
1193
+ // まま列の送り幅にも影響する
1194
+ const seen = new Set<string>();
1195
+ const unique = items.filter((i) => {
1196
+ if (seen.has(i.id)) return false;
1197
+ seen.add(i.id);
1198
+ return true;
1199
+ });
1200
+ const cellW = maxOf(
1201
+ unique.map((i) => positiveOr(i.w, 400)),
1202
+ 400,
1203
+ );
1204
+ const rowTops: number[] = [];
1205
+ {
1206
+ let y = top;
1207
+ for (let i = 0; i < unique.length; i += PARTS_PER_ROW) {
1208
+ rowTops.push(y);
1209
+ const rowH = maxOf(
1210
+ unique.slice(i, i + PARTS_PER_ROW).map((x) => positiveOr(x.h, 200)),
1211
+ 200,
1212
+ );
1213
+ y += rowH + PARTS_GAP;
1214
+ }
1215
+ }
1216
+ unique.forEach((item, i) => {
1217
+ const col = i % PARTS_PER_ROW;
1218
+ const row = Math.floor(i / PARTS_PER_ROW);
1219
+ const cx = col * (cellW + PARTS_GAP) + cellW / 2;
1220
+ const cy = (rowTops[row] ?? top) + positiveOr(item.h, 200) / 2;
1221
+ // 桁溢れした座標は描けない。 返さずに落として、 呼出側が自動配置に倒せるようにする
1222
+ if (!Number.isFinite(cx) || !Number.isFinite(cy)) return;
1223
+ out.set(item.id, { cx, cy });
1224
+ });
1225
+ return out;
1226
+ }
1227
+
1228
+ /**
1229
+ * 取り込んでよい見本の名前 (#1015)。
1230
+ *
1231
+ * 1 件ずつが上限以下でも、同じ見本を別名で何度も参照すれば合計は上限を超える
1232
+ * (実測 = 1,001 要素の見本を 3 名で参照して最終図が 3,005 要素になった)。
1233
+ * 本体の分を引いた残りを予算とし、本文に書かれた順に配る。
1234
+ *
1235
+ * 順に配るのは、どれを落とすかを決める規則が要るため。 先に書いたものを優先する形なら、
1236
+ * 書いた人から見て「後ろが落ちる」 と読める。
1237
+ */
1238
+ function partsBudget(
1239
+ target: CdlDiagram,
1240
+ partsActors: ReadonlyArray<{ name: string; partId?: string }>,
1241
+ partsCatalog: Record<string, CdlDiagram>,
1242
+ ): Set<number> {
1243
+ // 名前ではなく **書かれた順番** で覚える。 名前で覚えると、同じ名前を 2 度書いた時に
1244
+ // 先の 1 件が入れた名前で後の 1 件まで採用扱いになる
1245
+ const accepted = new Set<number>();
1246
+ let used = countDiagramElements(target);
1247
+ partsActors.forEach((a, i) => {
1248
+ const part = lookupPart(partsCatalog, a.partId);
1249
+ if (part === undefined) return;
1250
+ const cost = countDiagramElements(part);
1251
+ if (used + cost > MAX_INPUT_ELEMENTS) return;
1252
+ used += cost;
1253
+ accepted.add(i);
1254
+ });
1255
+ return accepted;
1256
+ }
1257
+
1258
+ /**
1259
+ * 位置を書かなかったパーツの、 merge に渡す座標。
1260
+ *
1261
+ * 格子の規則は `partsGridCenters` が持つ。 merge は矩形の中心を渡された座標に合わせるので、
1262
+ * 中心をそのまま渡す。
1263
+ */
1264
+ function partGridCenters(
1265
+ target: CdlDiagram,
1266
+ doc: DslDocument,
1267
+ partsCatalog: Record<string, CdlDiagram>,
1268
+ /** 取り込む見本の書かれた順番。 渡さなければ全部を並べる */
1269
+ accepted?: ReadonlySet<number>,
1270
+ /** 順番の元になった一覧 (本文に書かれた順) */
1271
+ acceptedFrom?: ReadonlyArray<{ name: string }>,
1272
+ ): Map<string, { cx: number; cy: number }> {
1273
+ const partsActors = doc.actors.filter((a) => a.partId !== undefined);
1274
+ // 格子に並ぶのは座標を 1 つも書かず相対でも書かなかった分だけ。
1275
+ //
1276
+ // merge 側は「縦横どちらも書かなかった時」 に格子へ落とす。 条件が食い違うと、 片方だけ
1277
+ // 書いたパーツが格子の枠を 1 つ消費して後続がずれる (実測 = 後続の中心が 200 から 720 に動いた)
1278
+ const autoActors = partsActors.filter(
1279
+ (a) => a.posX === undefined && a.posY === undefined && a.posRel === undefined,
1280
+ );
1281
+ if (autoActors.length === 0) return new Map();
1282
+ // パーツ自身の仮の箱は数えない。 この時点では未削除で残っており、 数えるとパーツを足すたびに
1283
+ // 置き場所が下へずれる。
1284
+ //
1285
+ // 名札 (`title`) だけを見ると、 順序図で 1 人につき作られる 3 つの箱のうち間隔用のものが
1286
+ // 漏れる (名札が空のため)。 パーツ 1 個につき 1 つ残り、 格子の起点が 1 段ぶん下がって
1287
+ // 画面側とずれていた (実測 = 縦が 840 = 3 段ぶん違った)。
1288
+ //
1289
+ // 属する列で特定するが、 列の id は名前を slug に変換して作るため名前とは一致しない
1290
+ // (実測 = `My Part` の列 id は `My-Part`)。 名前で引くと記号を含む名前だけ取りこぼす。
1291
+ // 列の `label` は slug の経路によらず名前の生値を持つので、 そちらで引く (§ merge の
1292
+ // 仮の箱の掃除が同じ方法を採っている)。
1293
+ //
1294
+ // どの列がパーツのものか決められない時は、 数から外さない。 外す側に倒すと本体の箱まで
1295
+ // 消えて、 パーツが本体の図に重なる (実測 = 同じ名前を本体とパーツの両方に書くと、
1296
+ // 上端が 1140 から 300 に飛んで本体の中に入った)。 外さなければ間隔が 1 段ぶん広がるだけで済む
1297
+ const otherActorNames = new Set(
1298
+ doc.actors.filter((a) => a.partId === undefined).map((a) => a.name),
1299
+ );
1300
+ // 本体にも同じ名前がある分は外さない。 名札でも列でも本体と区別できないため
1301
+ const partsActorNames = new Set(
1302
+ partsActors.map((a) => a.name).filter((n) => !otherActorNames.has(n)),
1303
+ );
1304
+ // 明示的に他の列へ張ったパーツは、 その列を専有していない (本体と共有している)
1305
+ const sharedLaneIds = new Set(
1306
+ partsActors.map((a) => a.lane).filter((l): l is string => l !== undefined),
1307
+ );
1308
+ const partsLaneIds = new Set<string>();
1309
+ for (const l of target.lanes) {
1310
+ if (l.label === undefined) continue;
1311
+ if (!partsActorNames.has(l.label)) continue;
1312
+ if (sharedLaneIds.has(l.id)) continue;
1313
+ partsLaneIds.add(l.id);
1314
+ }
1315
+ const baseNodes = target.nodes.filter(
1316
+ (n) => !partsActorNames.has(n.title) && !partsLaneIds.has(n.lane),
1317
+ );
1318
+ // 取り込まれない見本は格子の枠を使わない (#1015)。 枠を使うと、落とした見本の分だけ
1319
+ // 後続がずれる (実測 = 隣の見本の左端が 60 から 725 に動いた)
1320
+ const acceptedNames =
1321
+ accepted === undefined || acceptedFrom === undefined
1322
+ ? undefined
1323
+ : new Set(acceptedFrom.filter((_, i) => accepted.has(i)).map((a) => a.name));
1324
+ const placedActors = autoActors.filter(
1325
+ (a) =>
1326
+ lookupPart(partsCatalog, a.partId) !== undefined &&
1327
+ (acceptedNames === undefined || acceptedNames.has(a.name)),
1328
+ );
1329
+ const extents = new Map<string, { w: number; h: number; dx: number; dy: number }>();
1330
+ for (const a of placedActors) {
1331
+ const part = lookupPart(partsCatalog, a.partId)!;
1332
+ // 格子は図枠で決める。 画面側も図枠をそのまま置くので、 同じ物差しで並べれば
1333
+ // 2 経路の置き場所が揃う (#937)
1334
+ const t = partTargetSize(part, a.posW, a.posH, a.scale);
1335
+ extents.set(a.name, partFrameExtent(part, t.w, t.h));
1336
+ }
1337
+ const centers = partsGridCenters(
1338
+ baseNodes.length,
1339
+ placedActors.map((a) => ({ id: a.name, ...extents.get(a.name)! })),
1340
+ );
1341
+ // merge に渡すのは段の中心。 矩形の中心とのずれを引く。 引かないと、 段ごとに箱の高さが
1342
+ // 違うパーツで段内の上端が揃わない (実測 = 対称なパーツの上端 520 に対して 507.5)
1343
+ const out = new Map<string, { cx: number; cy: number }>();
1344
+ for (const [name, c] of centers) {
1345
+ const e = extents.get(name)!;
1346
+ out.set(name, { cx: c.cx - e.dx, cy: c.cy - e.dy });
1347
+ }
1348
+ return out;
1349
+ }
1350
+
1351
+ /**
1352
+ * パーツごとの外接矩形 (catalog 由来)。 相対指定を解く時に自分の大きさとして使う。
1353
+ *
1354
+ * `dx` / `dy` は矩形の中心と merge に渡す座標のずれ。 狙った中心から引いて座標にする。
1355
+ */
1356
+ function partSizes(
1357
+ doc: DslDocument,
1358
+ partsCatalog: Record<string, CdlDiagram>,
1359
+ ): Map<string, { w: number; h: number; dx: number; dy: number }> {
1360
+ const out = new Map<string, { w: number; h: number; dx: number; dy: number }>();
1361
+ for (const a of doc.actors) {
1362
+ if (a.partId === undefined) continue;
1363
+ const part = lookupPart(partsCatalog, a.partId);
1364
+ if (part) {
1365
+ const t = partTargetSize(part, a.posW, a.posH, a.scale);
1366
+ out.set(a.name, partExtent(part, t.w, t.h));
1367
+ }
1368
+ }
1369
+ return out;
1370
+ }
1371
+
1372
+ /**
1373
+ * パーツの箱 (中心と大きさ)。 相対指定を解く時の基準として使う。
1374
+ *
1375
+ * 大きさは catalog の図から求める。 組み立て前の図に残っている仮の箱を測ると、 実際に
1376
+ * 描かれる大きさと違う値で間隔を計算することになる。
1377
+ */
1378
+ function partBoxes(
1379
+ target: CdlDiagram,
1380
+ doc: DslDocument,
1381
+ partsCatalog: Record<string, CdlDiagram>,
1382
+ ): Map<string, AnchorBox> {
1383
+ const grid = partGridCenters(target, doc, partsCatalog);
1384
+ const out = new Map<string, AnchorBox>();
1385
+ for (const a of doc.actors) {
1386
+ if (a.partId === undefined) continue;
1387
+ const part = lookupPart(partsCatalog, a.partId);
1388
+ if (!part) continue;
1389
+ const t = partTargetSize(part, a.posW, a.posH, a.scale);
1390
+ const size = partExtent(part, t.w, t.h);
1391
+ const placed =
1392
+ a.posX !== undefined && a.posY !== undefined
1393
+ ? { cx: a.posX, cy: a.posY }
1394
+ : grid.get(a.name);
1395
+ // 相対で書いた分はここでは決まらない (解決側が後で埋める)
1396
+ if (!placed) continue;
1397
+ // 渡す座標は段の中心。 矩形の中心はそこからずれる
1398
+ out.set(a.name, { cx: placed.cx + size.dx, cy: placed.cy + size.dy, w: size.w, h: size.h });
1399
+ }
1400
+ return out;
1401
+ }
1402
+
1403
+ /**
1404
+ * パーツ用に作られた仮の箱 / 線 / 列を掃除する (#1015 で helper 化)。
1405
+ *
1406
+ * 取り込む時だけでなく **落とす時にも呼ぶ**。 落とした時に残すと、格子から外した後続の見本と
1407
+ * 重なる (実測で 64,000 の重なりが出た)。
1408
+ */
1409
+ function cleanupPlaceholderActor(
1410
+ target: CdlDiagram,
1411
+ doc: DslDocument,
1412
+ a: { name: string; lane?: string },
1413
+ ): void {
1414
+ const aliasSlug = slugify(a.name);
1415
+ const ownedLaneIds = new Set<string>();
1416
+ if (doc.type === "sequence" || doc.type === "solidity") {
1417
+ for (const l of target.lanes) {
1418
+ // 明示 lane mapping (a.lane) 先は part の張替え先で actor 専用 lane ではないため除外
1419
+ if (a.lane !== undefined && l.id === a.lane) continue;
1420
+ if (l.label === a.name) ownedLaneIds.add(l.id);
1421
+ }
1422
+ }
1423
+ const ownedNodeIds = new Set<string>();
1424
+ for (const n of target.nodes) {
1425
+ if (ownedLaneIds.has(n.lane)) ownedNodeIds.add(n.id);
1426
+ }
1427
+ // actor 専用 lane を引き当てられない経路 (flow / topology 等の共有 lane preset) は従来どおり dragon
1428
+ // slug の prefix match に fallback する。 これらは 1 actor = 1 node (id = slug) の生成規則。
1429
+ const matchesAliasSlug = (id: string): boolean => {
1430
+ if (id === aliasSlug) return true;
1431
+ if (id.startsWith(`${aliasSlug}-`)) return true;
1432
+ // sequence step anchor = `s{N}-{aliasSlug}` pattern
1433
+ if (/^s\d+-/.test(id) && id.endsWith(`-${aliasSlug}`)) return true;
1434
+ return false;
1435
+ };
1436
+ const relatedToActor = (id: string): boolean =>
1437
+ ownedLaneIds.size > 0 ? ownedNodeIds.has(id) : matchesAliasSlug(id);
1438
+ target.nodes = target.nodes.filter((n) => !relatedToActor(n.id));
1439
+ // edge も同経路で削除 (parts actor に接続していた flow を除去、 parts merge 後の flow は user が
1440
+ // 別途書く経路になる)。 削除した edge の id は phase.activate に残ると dangling 参照になるため回収する。
1441
+ const removedEdgeIds = new Set<string>();
1442
+ target.edges = target.edges.filter((e) => {
1443
+ const drop = relatedToActor(e.from) || relatedToActor(e.to);
1444
+ if (drop) removedEdgeIds.add(e.id);
1445
+ return !drop;
1446
+ });
1447
+ // lane も削除 = sequence preset は parts actor 用に lane (id = aliasSlug、 label = actor 名) を
1448
+ // 生成する。 node/edge だけ消して lane を残すと、 merge 後の part 側 lane (label = alias) と 2 本が
1449
+ // 同じ label を lane-label として描画し二重表示になる (actor ラベル二重表示 bug の root cause)。
1450
+ //
1451
+ // 削除は seq-like preset (sequence / solidity = compileSequence 経由) に限定する。 これらは
1452
+ // 1 actor = 1 lane (lane.label === a.name、 lane.id は actor 名の slug) の生成規則が成立し、
1453
+ // parts actor 用 lane を安全に削除できる。 他 preset (flow / topology / class / pie 等) は複数
1454
+ // actor が共有 lane (id = "main" 等) を参照するため、 一致 lane を消すと通常 actor の node が
1455
+ // 削除済 lane を参照する不正 diagram になる (cc-codex MAJOR 指摘)。
1456
+ //
1457
+ // leftover lane の特定は lane.label === a.name を第一に使う。 seq-like preset は非 animate 経路
1458
+ // (cdl preset の slugify) と animate 経路 (dragon の slugify) で lane.id の slug 規則が異なり
1459
+ // (`_`/全角の扱い等)、 aliasSlug (dragon slugify) と lane.id が不一致になる actor 名がある。 lane.label
1460
+ // は両経路とも a.name 生値なので slug 差の影響を受けず確実に一致する。 id === aliasSlug は
1461
+ // label 未設定 preset への fallback (exact match のみ、 prefix は false match risk のため付けない)。
1462
+ if (doc.type === "sequence" || doc.type === "solidity") {
1463
+ target.lanes = target.lanes.filter((l) => {
1464
+ // 明示 lane mapping (a.lane) 先は part の張替え先なので保持する。
1465
+ if (a.lane !== undefined && l.id === a.lane) return true;
1466
+ if (l.label === a.name) return false;
1467
+ if (l.id === aliasSlug) return false;
1468
+ return true;
1469
+ });
1470
+ }
1471
+ // 削除された node / edge を activate 参照している既存 phase の cleanup (node 削除と同じ判定経路
1472
+ // = 取りこぼすと存在しない id が activate に残り dangling 参照になる、 #873)
1473
+ for (const phase of target.phases) {
1474
+ phase.activate = phase.activate.filter((id) => !relatedToActor(id) && !removedEdgeIds.has(id));
1475
+ }
1476
+ }
1477
+
1478
+ /**
1479
+ * CAR-1657 = doc.actors 中の partId set actor を検出、 partsCatalog から CdlDiagram を lookup、
1480
+ * mergePartIntoDiagram で target に prefix 付き統合する。 partsCatalog 未渡し or 該当 partId
1481
+ * 未登録なら warn を残して skip、 diagram render は継続 (壊さない設計)。
1482
+ */
1483
+ function mergePartsFromActors(
1484
+ target: CdlDiagram,
1485
+ doc: DslDocument,
1486
+ partsCatalog?: Record<string, CdlDiagram>,
1487
+ onNotice?: (notice: CompileNotice) => void,
1488
+ ): CdlDiagram {
1489
+ const partsActors = doc.actors.filter((a) => a.partId !== undefined);
1490
+ if (partsActors.length === 0) return target;
1491
+ if (!partsCatalog) {
1492
+ if (typeof console !== "undefined" && console.warn) {
1493
+ const names = partsActors.map((a) => `${a.name} (kind: ${a.partId ?? "?"})`).join(", ");
1494
+ console.warn(`[dragon] parts kind actors detected but no partsCatalog provided: ${names}`);
1495
+ }
1496
+ return target;
1497
+ }
1498
+ // 位置を書かなかったパーツの置き場所は `partGridCenters` が決める。
1499
+ //
1500
+ // 以前はここで格子を組んでいたが、 相対指定を解く側も同じ位置を知る必要がある。
1501
+ // 別々に計算すると、 解決側が想定した位置と実際の置き場所がずれる。 規則を共有する。
1502
+ // 取り込んでよい合計を先に決める (#1015)。 1 件ずつ上限以下でも、同じ見本を別名で何度も
1503
+ // 参照すれば合計は上限を超える (実測 = 1,001 要素の見本を 3 名で参照して 3,005 要素になった)。
1504
+ // 本体の分を引いた残りを予算として、順に配って超えた分を落とす。
1505
+ //
1506
+ // 格子より先に決める。 後にすると、落とす見本が格子の枠を消費して後続がずれる
1507
+ const budget = partsBudget(target, partsActors, partsCatalog);
1508
+ const gridCenters = partGridCenters(target, doc, partsCatalog, budget, partsActors);
1509
+
1510
+ for (const [actorIndex, actor] of partsActors.entries()) {
1511
+ const partId = actor.partId;
1512
+ // codex-review CAR-1657 MAJOR fix (§ security) = partsCatalog は untrusted、 Object.hasOwn で
1513
+ // inherited property (`__proto__` 等) を除外する prototype pollution 対策。 `parts-` prefix 経路も
1514
+ // Object.hasOwn 経由で確認する。
1515
+ if (typeof partId !== "string" || partId.length === 0) continue;
1516
+ const found = lookupPartRaw(partsCatalog, partId);
1517
+ // 見つかっても大きすぎる図は取り込まない (#1015)。 黙って落とすと「書いたのに出ない」 に
1518
+ // なるため、見つからなかった時と分けて知らせる。
1519
+ // 1 件では収まっても合計で超える分も同じく落とす
1520
+ if (found !== undefined && !budget.has(actorIndex)) {
1521
+ const overOne = !partIsMeasurable(found);
1522
+ onNotice?.({
1523
+ kind: "part-not-drawn",
1524
+ actor: actor.name,
1525
+ line: 0,
1526
+ message: `"${actor.name}" (${partId}) は大きすぎるため取り込みません。`,
1527
+ hint: overOne
1528
+ ? `要素数が上限 (${MAX_INPUT_ELEMENTS}) を超えています`
1529
+ : `図全体の要素数が上限 (${MAX_INPUT_ELEMENTS}) を超えます`,
1530
+ });
1531
+ // 落とす時も仮の箱を掃除する。 残すと格子から外した後続の見本と重なる
1532
+ cleanupPlaceholderActor(target, doc, actor);
1533
+ continue;
1534
+ }
1535
+ const part = found;
1536
+ if (!part) {
1537
+ if (typeof console !== "undefined" && console.warn) {
1538
+ console.warn(`[dragon] parts kind "${partId}" not found in partsCatalog (actor: ${actor.name})`);
1539
+ }
1540
+ continue;
1541
+ }
1542
+ // codex-review MAJOR fix (§ sequence header/footer/spacer 削除) = preset (sequence 等) が生成した
1543
+ // parts actor 由来の node/edge を alias 経由で全削除する。 sequence は `{slug}-header / -spacer /
1544
+ // -footer / s{N}-{slug}` を生成、 slug prefix match で全 sweep。
1545
+ //
1546
+ // sweep に使う slug は 2 系統ある (#873)。 dragon の slugify は `_` / 全角を保持するが、 非 animate
1547
+ // sequence / solidity の node は cdl preset 側の slugify (`_` → `-` 置換、 NFKC なし) で生成される
1548
+ // ため、 dragon slug だけで sweep すると `arc_one` → 実 id `arc-one-header` を取りこぼし、 header /
1549
+ // footer (title = actor 名) が残って actor 名が多重表示される。
1550
+ //
1551
+ // seq-like preset は「actor 専用 lane に属する node」 を exact set で特定する経路を使う。
1552
+ // lane.label === actor.name で lane を引き当て (label は両 slug 経路とも actor.name 生値)、 その
1553
+ // lane に属する node (header / spacer / footer / step anchor は全て actor lane 所属) を node.lane で
1554
+ // 厳密収集する。 slug の prefix 推測を挟まないため、 slug 実装差の取りこぼしと、 別 actor を巻き込む
1555
+ // 誤削除 (parts actor `a_b` の lane id `a-b` が actor `a-b-c` の `a-b-c-header` に prefix match する)
1556
+ // の両方を同時に排除する。
1557
+ cleanupPlaceholderActor(target, doc, actor);
1558
+ const merged = applyColorHex(part, actor.colorHex, actor.stateOverride ?? {});
1559
+ // 位置を書いていないパーツは格子に並べる。 書いてあればその位置を使う
1560
+ let placeX = actor.posX;
1561
+ let placeY = actor.posY;
1562
+ // 格子に落とすのは縦横どちらも書かなかった時だけ。 片方だけ書いた時に残りを格子で
1563
+ // 埋めると、 書いた値と格子が混ざった位置になる (従来の条件をそのまま保つ)
1564
+ if (placeX === undefined && placeY === undefined) {
1565
+ const center = gridCenters.get(actor.name);
1566
+ placeX = center?.cx;
1567
+ placeY = center?.cy;
1568
+ }
1569
+ // `倍率` / `scale` は図形の倍率として予約した (#1026)。 同じ名前の状態を持つ見本では、
1570
+ // 予約する前は状態の上書きとして効いていた。 黙って意味が変わると気付けないので知らせる
1571
+ // 判定は **書かれた名前** で行う。 読めた値で判定すると `scale: x` のように値が
1572
+ // 読めない形で知らせが消え、逆に `scale` を書いて見本が `倍率` の状態を持つだけの
1573
+ // 組合せ (元から衝突していない) にも知らせてしまう
1574
+ const written = new Set(actor.scaleKeys ?? []);
1575
+ if (written.size > 0) {
1576
+ const clashed = (part.states ?? []).find((st) => written.has(String(st.id ?? "")));
1577
+ if (clashed) {
1578
+ onNotice?.({
1579
+ kind: "scale-reserved",
1580
+ actor: actor.name,
1581
+ line: actor.pos?.line ?? 0,
1582
+ message: `"${clashed.id}" は見本の大きさを変える項目として扱いました (${clashed.id} という名前の状態は変えていません)`,
1583
+ hint: `状態を変えたい時は \`state: { ${clashed.id}: ... }\` と書く`,
1584
+ });
1585
+ }
1586
+ }
1587
+ 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);
1589
+ }
1590
+ return target;
1591
+ }
1592
+
1593
+ /**
1594
+ * 状態の初期値に上書きを当てた結果と、 色として読めないため捨てたかどうか。
1595
+ *
1596
+ * **元の値が色の状態は、 上書きも色に限る** (#1004)。 状態の値は `fill` に入るため、
1597
+ * `url(https://example.invalid/x)` のような外部を指す値を通すと、 図を開いた人の環境から
1598
+ * その URL へ要求が飛ぶ。 書き出した SVG を配布しても同じことが起きる。
1599
+ * 色として読めない上書きは捨てて元の色を残す = 図は出るが外部は指さない。
1600
+ *
1601
+ * 元の値が色でない状態 (数値 / 文字列) は制限しない。 色として描かれないため、
1602
+ * 一律に弾くとゲージの値や説明文の差し替えという正当な用途を壊す。
1603
+ *
1604
+ * 捨てたことは呼出側が知らせる。 黙って捨てると、 書いた人は色が変わらない理由
1605
+ * (書き間違い / 拒否 / 描画不具合) を区別できない。
1606
+ */
1607
+ function resolveStateOverride(
1608
+ original: number | string,
1609
+ override: number | string | boolean | undefined,
1610
+ ): { initial: number | string; rejected: boolean } {
1611
+ if (override === undefined) return { initial: original, rejected: false };
1612
+ if (isColorValue(original) && !isColorValue(override)) return { initial: original, rejected: true };
1613
+ return { initial: override as number | string, rejected: false };
1614
+ }
1615
+
1616
+ /**
1617
+ * `色:` に書かれた色番号を、 パーツが持つ色の状態に入れる。
1618
+ *
1619
+ * 色を保持する状態の名前はパーツごとに違う (`bg` / `stFill` / `gFill` / `hue` など)。 名前を
1620
+ * 決め打ちすると、 別の名前を使うパーツで色を書いても何も起きない。
1621
+ *
1622
+ * パーツの状態のうち初期値が色番号のものを探して、 そこに入れる。 複数あれば全部に入れる
1623
+ * (`cpuC` / `memC` / `netC` のように系統ごとに分かれている場合、 1 つだけ変えるとちぐはぐになる)。
1624
+ */
1625
+ function applyColorHex(
1626
+ part: CdlDiagram,
1627
+ colorHex: string | undefined,
1628
+ stateOverride: Record<string, number | string | boolean>,
1629
+ ): Record<string, number | string | boolean> {
1630
+ if (!colorHex) return stateOverride;
1631
+ const colorStates = part.states.filter((st) => isColorValue(st.initial));
1632
+ if (colorStates.length === 0) return stateOverride;
1633
+ const out = { ...stateOverride };
1634
+ for (const st of colorStates) {
1635
+ // 名前を指定して書いた値が優先。 `色:` はまとめて塗る指定
1636
+ if (out[st.id] === undefined) out[st.id] = colorHex;
1637
+ }
1638
+ return out;
1639
+ }
1640
+
1641
+ /**
1642
+ * CAR-1657 = parts CdlDiagram (単一 part 内容) を target CdlDiagram に prefix 付きで merge する。
1643
+ * alias = user が書く actor 名 ('arc1')、 全 id を '{alias}__{origId}' で prefix、 lane 参照 rename、
1644
+ * state initial は stateOverride で上書き可、 shape / subtitle / value 内の '{stateName}' template も
1645
+ * '{alias__stateName}' に rewrite する。 phase parallel merge (activate / tweens / sets の id 参照 rename)。
1646
+ */
1647
+ function mergePartIntoDiagram(
1648
+ target: CdlDiagram,
1649
+ part: CdlDiagram,
1650
+ alias: string,
1651
+ stateOverride: Record<string, number | string | boolean>,
1652
+ laneMapping: string | undefined,
1653
+ /**
1654
+ * parts drop 位置 (drag-and-drop or click 追加時に呼出側が SVG viewBox 座標を書出す)。
1655
+ * 未指定 = 従来 (lane.x = 0 baked-in で canvas 左端に描画)、 指定時 = parts 内部 lane の
1656
+ * x / y に加算して drop 座標付近に描画。 D1 forensic (drop 座標乖離) の core fix。
1657
+ */
1658
+ offsetX?: number,
1659
+ offsetY?: number,
1660
+ /**
1661
+ * parts 全体 resize 対応 (I2 forensic) = parts を Miro 相当の「1 unit」 として扱い、
1662
+ * user が SE handle drag で拡大すると actor.posW/posH が書出される。 compile で受け取り、
1663
+ * parts 全 sub-node の w / h と cx / cy 相対位置に scale 係数を適用して等比拡大する。
1664
+ * 未指定 = 従来の parts 原寸 で描画 (scale なし)。
1665
+ */
1666
+ targetW?: number,
1667
+ targetH?: number,
1668
+ /** 書いたのに使わなかった上書きを知らせる口。 黙って捨てると理由を追えない (#1004) */
1669
+ onNotice?: (notice: CompileNotice) => void,
1670
+ /** 知らせに載せる行。 パーツを書いた行を指す。 行が取れない経路 (JSON) では 0 */
1671
+ noticeLine = 0,
1672
+ ): void {
1673
+ const prefix = (id: string): string => `${alias}__${id}`;
1674
+ const stateIdSet = new Set(part.states.map((s) => s.id));
1675
+ const rewriteTemplate = (s: string | undefined): string | undefined => {
1676
+ 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;
1679
+ });
1680
+ };
1681
+
1682
+ // 決定的 lane 参照 = user が書いた lane 指定を優先、 なければ parts 内部 lane を prefix 付きで作る
1683
+ const targetLaneId = laneMapping;
1684
+ const laneIdMap = new Map<string, string>();
1685
+ // parts lane の横位置。
1686
+ // offset (drop / click 座標) 指定時 = part 中心を offsetX に合わせる = user が置いた位置に
1687
+ // parts の中心が来る。 node は lane 中心 (lane.x + laneW/2) に描画されるため、 lane 左端を
1688
+ // offsetX - laneW/2 に置くと node 中心 = offsetX となり cursor / viewport 中央に一致する
1689
+ // (縦方向 offsetY と対称、 offsetY 側は partCenterStack で既に中心合わせ済)。
1690
+ // 従来の auto-adjust (max(offsetX, existingMax + gap) で既存 lane 右端へ強制右寄せ) は user
1691
+ // directive で廃止 (2026-07-21)。 重なりは user の意図位置を優先し、 手動移動で回避する経路。
1692
+ // 未指定 (座標なし fallback) 時のみ existingMax + gap で右外配置 (通常経路は drop/click で座標を渡す)。
1693
+ const PARTS_LANE_GAP = 300;
1694
+ const existingLaneMaxX = target.lanes.length > 0
1695
+ ? Math.max(...target.lanes.map((l) => (l.x ?? 0) + l.width))
1696
+ : 0;
1697
+ // parts 全体 resize (I2 forensic): user が SE handle drag で targetW/H 指定 = actor.posW/H。
1698
+ // scale 基準は part 全体の bbox 幅 (全 lane の最左端〜最右端) にする。 lane[0] 幅だけを基準にすると
1699
+ // multi-lane part (複数 lane を横に並べた part) で全体幅を過小評価し、 非先頭 lane の node が自 lane
1700
+ // 中心からずれる (#880)。
1701
+ // 縦列の位置と幅を先に正す。 catalog は呼出側が渡す値で、 生値のまま bbox を出すと
1702
+ // 拡大の基準が 1 に落ちて箱が桁違いに大きくなる (実測 = 指定間隔 200 が -31800 になった)
1703
+ const partLaneGeom = new Map<string, { x: number; w: number }>();
1704
+ for (const l of part.lanes) {
1705
+ partLaneGeom.set(l.id, {
1706
+ x: typeof l.x === "number" && Number.isFinite(l.x) ? l.x : 0,
1707
+ w: positiveOr(l.width, 400),
1708
+ });
1709
+ }
1710
+ const laneXs = [...partLaneGeom.values()].map((g) => g.x);
1711
+ const laneRights = [...partLaneGeom.values()].map((g) => g.x + g.w);
1712
+ const partMinLaneX = minOf(laneXs, 0);
1713
+ const partMaxLaneRight = maxOf(laneRights, 400);
1714
+ // 幅は max >= min で常に非負。 正の幅 (極小 sub-pixel 含む) はそのまま scale 基準に使い、
1715
+ // 0 (全 lane が同一 x + 幅 0 の退化ケース) の時だけ除算保護で 1 に fallback する。
1716
+ // Math.max(1, w) だと 0 < w < 1 の正当な幅まで 1 に floor して over-scale するため使わない。
1717
+ const rawBboxW = partMaxLaneRight - partMinLaneX;
1718
+ const partsBboxW = rawBboxW > 0 ? rawBboxW : 1;
1719
+ // 非有限は 1 に倒す。 桁が溢れた `大きさ:` (`Number()` が Infinity を返す長さ) を
1720
+ // そのまま掛けると描けない座標になり、 大きさを見積る側 (`partTargetScale`) だけが
1721
+ // 1 に倒していたため経路で食い違っていた (#1018)
1722
+ const rawLaneScaleX = targetW !== undefined && targetW > 0 ? targetW / partsBboxW : 1;
1723
+ const laneScaleX = Number.isFinite(rawLaneScaleX) && rawLaneScaleX > 0 ? rawLaneScaleX : 1;
1724
+ // part 全体を「元 bbox 中心 → drop 座標」 の scale 変換で写す単一式 mapLaneX。 lane も node も同じ式で
1725
+ // 変換し、 lane.x = mapLaneX(元 lane 左端) にすることで全 lane / 全 node が一貫して drop 座標を中心に
1726
+ // scale 配置される (cc-codex #879 の mapPartX と同じ発想を lane push まで前倒し、 #880 root fix)。
1727
+ const partOrigBboxCenterX = partMinLaneX + partsBboxW / 2;
1728
+ const dropCenterX = offsetX !== undefined
1729
+ ? offsetX
1730
+ : existingLaneMaxX + PARTS_LANE_GAP + (partsBboxW * laneScaleX) / 2;
1731
+ const mapLaneX = (x: number): number => (x - partOrigBboxCenterX) * laneScaleX + dropCenterX;
1732
+
1733
+ for (const laneOrig of part.lanes) {
1734
+ if (targetLaneId) {
1735
+ laneIdMap.set(laneOrig.id, targetLaneId);
1736
+ } else {
1737
+ const newLaneId = prefix(laneOrig.id);
1738
+ laneIdMap.set(laneOrig.id, newLaneId);
1739
+ // lane の左端を mapLaneX で変換 = 元 lane 左端 (x) を scale 変換後の位置に置く。 lane 幅も
1740
+ // scale して lane 中心が mapLaneX(元 lane 中心) に一致する。 これで multi-lane でも各 lane が
1741
+ // part 全体の scale 変換に沿って配置される。
1742
+ const geom = partLaneGeom.get(laneOrig.id) ?? { x: 0, w: 400 };
1743
+ target.lanes.push({
1744
+ ...laneOrig,
1745
+ id: newLaneId,
1746
+ label: laneOrig.label ?? alias,
1747
+ x: mapLaneX(geom.x),
1748
+ width: geom.w * laneScaleX,
1749
+ });
1750
+ }
1751
+ }
1752
+
1753
+ // parts drop 位置 fix (D1 + D2 root fix):
1754
+ // D2 = parts の stack 番号 (0/1/2/…) が target sequence の stack と衝突すると
1755
+ // CDL layout の rowH 計算で全 lane の同 row cy が拡張、 sequence footer 等が縦 shift。
1756
+ // → 2 段防御 で分離する:
1757
+ // (1) 全 parts node に posX/posY 明示 set (CDL layout の絶対配置経路 = stack 計算 skip)
1758
+ // (2) parts の stack 番号を target 側 max stack + STACK_ISOLATION_OFFSET (1000) に shift
1759
+ // = 万一 layout が rowH で参照しても sequence stack と重ならず影響 0 化
1760
+ // D1 = drop 座標尊重の縦方向 = parts の元 stack (0..N) から近似 pitch で cy を組み立て、
1761
+ // offsetY を加算して drop 座標付近に描画。 lane.x + lane.width/2 + offsetX で横位置。
1762
+ //
1763
+ const STACK_ISOLATION_OFFSET = 1000;
1764
+ const shouldForcePos = offsetX !== undefined || offsetY !== undefined;
1765
+ // target 側の現在 max stack + isolation offset で parts node の stack を shift、
1766
+ // sequence の rowH 計算と完全分離 (D2 fix、 posX/posY 明示との 2 段防御)。
1767
+ const targetMaxStack = shouldForcePos && target.nodes.length > 0
1768
+ ? Math.max(...target.nodes.map((n) => n.stack ?? 0))
1769
+ : 0;
1770
+ const stackShiftBase = shouldForcePos ? targetMaxStack + STACK_ISOLATION_OFFSET : 0;
1771
+ // parts 全体 resize scale (I2 forensic 対応): targetW / targetH 指定時、 parts の元 total size
1772
+ // に対する比率 = scale 係数、 全 sub-node の w / h + cx / cy 相対位置に scale 反映。
1773
+ // scaleX は lane push と同じ part bbox 幅基準 (laneScaleX) を使う = multi-lane で lane と node の
1774
+ // scale 係数が一致する (#880、 lane[0] 幅基準だと非先頭 lane の node がずれる)。
1775
+ // parts 内部 stack 別の垂直 pitch (world unit)。 CDL layout の実 stackGap (~100) +
1776
+ // 標準 node h (~140-200) の合計相当。 parts の cy を厳密に再現しないが、 渡した座標付近に
1777
+ // parts が中心配置される見た目に十分な近似。
1778
+ //
1779
+ // 実配置に置き換える案を試したが、 拡大の基準 (縦列基準 → 箱基準) まで変わって既存の
1780
+ // 期待 14 件が崩れた。 段を持つパーツ (実 catalog で 80 件中 7 件) の内部比率が実配置と
1781
+ // 3% ずれるが、 見た目の大きさは呼出側が揃えるため観測される差は無い
1782
+ const STACK_PITCH_APPROX = 220;
1783
+ // 数でない段は 0 として扱う。 大きさを見積る側 (`partTargetScale`) が同じ判定をしており、
1784
+ // ここだけ NaN を通すと段の数が NaN になって倍率が経路で食い違う (#1018)
1785
+ const partStacks = part.nodes.map((n) =>
1786
+ typeof n.stack === "number" && Number.isFinite(n.stack) ? n.stack : 0,
1787
+ );
1788
+ const minStack = partStacks.length > 0 ? Math.min(...partStacks) : 0;
1789
+ const maxStack = partStacks.length > 0 ? Math.max(...partStacks) : 0;
1790
+ const partCenterStack = (minStack + maxStack) / 2;
1791
+ const partOrigH = Math.max(1, (maxStack - minStack + 1) * STACK_PITCH_APPROX);
1792
+ const scaleX = laneScaleX;
1793
+ const rawScaleY = targetH !== undefined && targetH > 0 ? targetH / partOrigH : 1;
1794
+ const scaleY = Number.isFinite(rawScaleY) && rawScaleY > 0 ? rawScaleY : 1;
1795
+
1796
+ // node merge = id prefix + lane 参照 rewrite + shape / subtitle / value 内 template rewrite
1797
+ for (const nodeOrig of part.nodes) {
1798
+ const mappedLane = laneIdMap.get(nodeOrig.lane) ?? nodeOrig.lane;
1799
+ // codex-review MAJOR fix (§ nested shape template) = recursive walk で shape 内 nested object /
1800
+ // array の string leaf 全対象、 前実装は 1 depth のみで `fill: { gradient: "{v}" }` 等 miss。
1801
+ let newShape = nodeOrig.shape
1802
+ ? deepRewriteStrings(nodeOrig.shape as unknown, rewriteTemplate)
1803
+ : undefined;
1804
+ // parts 全体 resize (I2 forensic): shape 内 radius / outerRadius / innerRadius / thickness に
1805
+ // scale 反映 = user が SE handle drag で拡大すると shape の見た目も比例拡大される。 scaleX を採用
1806
+ // (等比 scale 相当、 縦方向 scaleY と乖離する場合は近似)、 shape 内数値 field のうち幾何寸法系
1807
+ // のみ scale 適用 (fill / stroke 色 field 等 non-numeric は影響なし)。
1808
+ if (newShape && (scaleX !== 1 || scaleY !== 1)) {
1809
+ const shapeScale = Math.min(scaleX, scaleY); // 等比 scale で circle 崩れ回避
1810
+ const geomKeys = new Set(["radius", "outerRadius", "innerRadius", "thickness"]);
1811
+ const scaleGeom = (obj: unknown): unknown => {
1812
+ if (obj === null || typeof obj !== "object") return obj;
1813
+ if (Array.isArray(obj)) return obj.map(scaleGeom);
1814
+ const out: Record<string, unknown> = {};
1815
+ for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
1816
+ if (geomKeys.has(k) && typeof v === "number") {
1817
+ out[k] = v * shapeScale;
1818
+ } else if (typeof v === "object" && v !== null) {
1819
+ out[k] = scaleGeom(v);
1820
+ } else {
1821
+ out[k] = v;
1822
+ }
1823
+ }
1824
+ return out;
1825
+ };
1826
+ newShape = scaleGeom(newShape) as typeof newShape;
1827
+ }
1828
+ // parts drop 位置 offset 反映:
1829
+ // - node.posX set 済 (parts が絶対座標を持つ) = その posX を part 中心基準で scale 変換
1830
+ // - offsetX 指定時 (drop 経路) で posX 未設定 = node が属する lane 中央を同じ式で変換
1831
+ // - offset なし (従来経路) は auto layout 継続 (posX undefined)
1832
+ //
1833
+ // 明示 posX と auto-layout の両経路を、 lane push と同じ単一式 mapLaneX で変換する
1834
+ // (cc-codex #879 Round 2/3 MAJOR + #880)。 mapLaneX は part bbox 中心 → drop 座標の scale 変換で、
1835
+ // lane / node / 明示 posX / auto-layout の全経路がこの 1 式を共有するため、 lane.x != 0 でも
1836
+ // multi-lane でも node 中心と自 lane 中心が一致する。
1837
+ let nodePosX: number | undefined = nodeOrig.posX !== undefined ? mapLaneX(nodeOrig.posX) : undefined;
1838
+ let nodePosY: number | undefined = nodeOrig.posY !== undefined ? nodeOrig.posY + (offsetY ?? 0) : undefined;
1839
+ if (shouldForcePos && nodePosX === undefined) {
1840
+ // posX を持たない node は所属 lane の中央 (auto layout の cx 相当) を同じ mapLaneX で変換する。
1841
+ const geom = partLaneGeom.get(nodeOrig.lane) ?? { x: 0, w: 320 };
1842
+ nodePosX = mapLaneX(geom.x + geom.w / 2);
1843
+ }
1844
+ if (shouldForcePos && nodePosY === undefined) {
1845
+ // parts の元 stack から近似 pitch で cy を組み立て、 全 parts の中心が offsetY に来るよう調整
1846
+ const stack = nodeOrig.stack ?? 0;
1847
+ nodePosY = (stack - partCenterStack) * STACK_PITCH_APPROX * scaleY + (offsetY ?? 0);
1848
+ }
1849
+ // parts sub-node の w / h に scale 適用 (I2 forensic 対応、 targetW/H 指定時のみ)
1850
+ // catalog の値は呼出側が渡すので、 拡大しない時も数として通るか確かめる。 通さないと
1851
+ // 座標が非有限になって図が描けない (実測 = 箱の中心が NaN になった)
1852
+ const rawNodeW = nodeOrig.w !== undefined ? positiveOr(nodeOrig.w, 200) : undefined;
1853
+ const rawNodeH = nodeOrig.h !== undefined ? positiveOr(nodeOrig.h, 200) : undefined;
1854
+ const nodeW = rawNodeW !== undefined && (scaleX !== 1 || scaleY !== 1)
1855
+ ? rawNodeW * scaleX
1856
+ : rawNodeW;
1857
+ const nodeH = rawNodeH !== undefined && (scaleX !== 1 || scaleY !== 1)
1858
+ ? rawNodeH * scaleY
1859
+ : rawNodeH;
1860
+ target.nodes.push({
1861
+ ...nodeOrig,
1862
+ id: prefix(nodeOrig.id),
1863
+ lane: mappedLane,
1864
+ title: rewriteTemplate(nodeOrig.title) ?? nodeOrig.title,
1865
+ subtitle: rewriteTemplate(nodeOrig.subtitle),
1866
+ value: rewriteTemplate(nodeOrig.value),
1867
+ // parts stack を target 側と分離 (D2 fix、 posX/posY 明示との 2 段防御)
1868
+ stack: (nodeOrig.stack ?? 0) + stackShiftBase,
1869
+ ...(newShape ? { shape: newShape as CdlDiagram["nodes"][number]["shape"] } : {}),
1870
+ ...(nodePosX !== undefined ? { posX: nodePosX } : {}),
1871
+ ...(nodePosY !== undefined ? { posY: nodePosY } : {}),
1872
+ ...(nodeW !== undefined ? { w: nodeW } : {}),
1873
+ ...(nodeH !== undefined ? { h: nodeH } : {}),
1874
+ });
1875
+ }
1876
+
1877
+ // state merge = id prefix + initial override
1878
+ for (const stateOrig of part.states) {
1879
+ const { initial, rejected } = resolveStateOverride(stateOrig.initial, stateOverride[stateOrig.id]);
1880
+ if (rejected) {
1881
+ onNotice?.({
1882
+ kind: "state-override-rejected",
1883
+ actor: alias,
1884
+ line: noticeLine,
1885
+ message: `"${alias}" の ${stateOrig.id} に書いた値は色として読めないため使いません`,
1886
+ hint: "色は `#ff0000` のような色番号か、 `red` のような色名で書く",
1887
+ });
1888
+ }
1889
+ target.states.push({ id: prefix(stateOrig.id), initial });
1890
+ }
1891
+
1892
+ // edge merge = id / from / to prefix (parts 内 edge は稀だが対応)
1893
+ for (const edgeOrig of part.edges) {
1894
+ target.edges.push({
1895
+ ...edgeOrig,
1896
+ id: prefix(edgeOrig.id),
1897
+ from: prefix(edgeOrig.from),
1898
+ to: prefix(edgeOrig.to),
1899
+ });
1900
+ }
1901
+
1902
+ // codex-review CRITICAL fix (§ readouts merge) = readout 系 parts (percent-ring / sparkline /
1903
+ // donut / KPI 等) は node/state だけでは render されず、 readouts field が必須。 全 readout の
1904
+ // id prefix + source / historySource / *Source field の state template rewrite で対応。
1905
+ if (part.readouts && part.readouts.length > 0) {
1906
+ if (!target.readouts) target.readouts = [];
1907
+ for (const readoutOrig of part.readouts) {
1908
+ const rewritten = deepRewriteStrings(readoutOrig as unknown, rewriteTemplate) as CdlDiagram["readouts"] extends readonly (infer R)[] ? R : never;
1909
+ // id は shape 全 walk で rewrite されないので個別に prefix
1910
+ target.readouts.push({
1911
+ ...(rewritten as { id: string }),
1912
+ id: prefix((rewritten as { id: string }).id),
1913
+ } as CdlDiagram["readouts"] extends readonly (infer R)[] ? R : never);
1914
+ }
1915
+ }
1916
+
1917
+ // codex-review MAJOR fix (§ phase parallel merge) = 前実装は append (sequential)、 spec は parallel
1918
+ // default = parts phase を target 側 phase 個別に merge、 duration は max、 activate / tweens / sets
1919
+ // は union。 stateOverride.phase === false 時は parts phase 破棄 (opt-out)。
1920
+ const phaseOptOut = stateOverride["phase"] === false;
1921
+ if (phaseOptOut) {
1922
+ return; // parts phase を破棄、 activate / tweens / sets の rewrite 不要
1923
+ }
1924
+ if (target.phases.length === 0) {
1925
+ // target に phase なし = parts phase をそのまま追加 (prefix 付き)
1926
+ for (const phaseOrig of part.phases) {
1927
+ target.phases.push({
1928
+ ...phaseOrig,
1929
+ id: prefix(phaseOrig.id),
1930
+ 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) })),
1933
+ });
1934
+ }
1935
+ } else {
1936
+ // parallel merge = 各 target phase に対応する parts phase を index-wise で合成 (min の phase 数まで)、
1937
+ // 残 parts phase は追加 append (target より parts phase 数が多い場合)
1938
+ const targetLen = target.phases.length;
1939
+ const partsLen = part.phases.length;
1940
+ const commonLen = Math.min(targetLen, partsLen);
1941
+ for (let i = 0; i < commonLen; i++) {
1942
+ const targetPhase = target.phases[i]!;
1943
+ const partPhase = part.phases[i]!;
1944
+ targetPhase.duration = Math.max(targetPhase.duration, partPhase.duration);
1945
+ 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) }))];
1948
+ }
1949
+ // parts phase 余剰は append (target より parts が長い場合)
1950
+ for (let i = commonLen; i < partsLen; i++) {
1951
+ const phaseOrig = part.phases[i]!;
1952
+ target.phases.push({
1953
+ ...phaseOrig,
1954
+ id: prefix(phaseOrig.id),
1955
+ 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) })),
1958
+ });
1959
+ }
1960
+ }
1961
+ }
1962
+
1963
+ /**
1964
+ * codex-review MAJOR fix = shape / readout の nested object / array 内 string leaf を全て
1965
+ * rewrite 関数に通す再帰 walk。 非 string leaf (number / boolean / null) は保持、
1966
+ * 循環参照は Set で防御 (現状 shape / readout は tree 構造で cycle なし想定、 defensive)。
1967
+ */
1968
+ function deepRewriteStrings(
1969
+ value: unknown,
1970
+ rewrite: (s: string | undefined) => string | undefined,
1971
+ seen: WeakSet<object> = new WeakSet(),
1972
+ ): unknown {
1973
+ if (typeof value === "string") return rewrite(value) ?? value;
1974
+ if (value === null || typeof value !== "object") return value;
1975
+ if (seen.has(value as object)) return value;
1976
+ seen.add(value as object);
1977
+ if (Array.isArray(value)) {
1978
+ return value.map((v) => deepRewriteStrings(v, rewrite, seen));
1979
+ }
1980
+ const out: Record<string, unknown> = {};
1981
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
1982
+ out[k] = deepRewriteStrings(v, rewrite, seen);
1983
+ }
1984
+ return out;
1985
+ }
1986
+
1987
+ /**
1988
+ * v0.5+ flow inline option (guard / cardinality / labelOffsetX / labelOffsetY) を
1989
+ * 既存 preset 経由で生成された CdlEdge に対し、 doc.flow の (from, to) 一致順マッチングで反映する。
1990
+ *
1991
+ * 設計:
1992
+ * - preset 経路ごとに edge id 命名規則が異なる (sequence: e{idx}-..、 ER: rel-{idx}-..、 FSM: t{idx}-..、
1993
+ * topology: c{idx}-..、 flow preset: e-{prev}-{node}、 swimlane: e{idx}-..) ため、 id 直接マッチは脆い。
1994
+ * - 代わりに doc.flow の 1 step に対し、 同じ (slugified-from, slugified-to) を持つ未マッチ edge を
1995
+ * 順に 1 つ消費する double-pointer 走査で対応付ける。 同 from-to の重複は出現順で順番に対応。
1996
+ * - ER preset で cardinality が author 明示なら、 既存の label "places (1:N)" に "(1:N)" を再付与せず、
1997
+ * 既に label に含まれている場合はスキップ (`label.includes(cardinality)` で判定)。
1998
+ */
1999
+ function applyEdgeInlineOptions(
2000
+ diagram: CdlDiagram,
2001
+ doc: DslDocument,
2002
+ /** 対応が取れた edge を記録する表。 callback は呼ばない (1 edge = 1 回にするため)。 */
2003
+ sourceLines?: Map<string, number>,
2004
+ ): void {
2005
+ const used = new Set<string>();
2006
+ // sequence preset では actor 名 が lane id、 edge.from は `s{stepIdx}-{laneId}` 形式。
2007
+ // solidity は sorted-actor を sequence preset 経由するため sequence と同形。
2008
+ // それ以外 (flow / swimlane / er / state / topology / gantt / class / pie / c4 / mind) は
2009
+ // edge.from / edge.to が plain slug (slugify(actor 名))。
2010
+ const isSeqLike = doc.type === "sequence" || doc.type === "solidity";
2011
+ doc.flow.forEach((s, stepIdx) => {
2012
+ const fromId = slugify(s.from);
2013
+ const toId = slugify(s.to);
2014
+ const target = diagram.edges.find((e) => {
2015
+ if (used.has(e.id)) return false;
2016
+ if (isSeqLike) {
2017
+ // edge.from / edge.to は `s{stepIdx}-{laneId}` 命名規則
2018
+ return (
2019
+ (e.from === `s${stepIdx}-${fromId}` || e.from === fromId) &&
2020
+ (e.to === `s${stepIdx}-${toId}` || e.from === e.to)
2021
+ );
2022
+ }
2023
+ return e.from === fromId && e.to === toId;
2024
+ });
2025
+ if (!target) return;
2026
+ used.add(target.id);
2027
+ 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;
2044
+ });
2045
+ }
2046
+
2047
+ /**
2048
+ * 描画側が大きさを持つ種別。
2049
+ *
2050
+ * 記法の `kind` は描画の種別より広い。 そのまま渡すと大きさを引けずに描画が落ちる
2051
+ * (実測 = solidity の golden 4 件が `Cannot read properties of undefined`)。
2052
+ */
2053
+ const DRAWABLE_KINDS: ReadonlySet<string> = new Set(NODE_KINDS);
2054
+
2055
+ /**
2056
+ * 描画側に無い記法の種別を、 意味の近い描画の種別に読み替える。
2057
+ *
2058
+ * Solidity の記法は `eoa` / `contract` のように領域固有の語を使う。 描画側に同じ名前は無いが、
2059
+ * 意味の対応する形はある (`shape-wallet` / `shape-smart-contract`)。 読み替えないと名札が
2060
+ * 一律 `card` になり、 「書いたとおりの形になる」 が Solidity の図だけ成立しない。
2061
+ *
2062
+ * 並び順 (`compileSolidity` の `kindOrder`) はこの読み替えの前の値で決まる = 読み替えても
2063
+ * 縦線の並びは変わらない。
2064
+ */
2065
+ const KIND_ALIAS: Readonly<Record<string, string>> = {
2066
+ eoa: "shape-wallet",
2067
+ wallet: "shape-wallet",
2068
+ multisig: "signer",
2069
+ contract: "shape-smart-contract",
2070
+ proxy: "shape-smart-contract",
2071
+ library: "shape-code-block",
2072
+ interface: "shape-code-block",
2073
+ };
2074
+
2075
+ /**
2076
+ * 図全体を 1 つの箱で描く種別。
2077
+ *
2078
+ * これらは中身 (扇 / 帯 / 枝) を payload で受け取り、 1 node で図全体を描く。 登場人物ごとの箱を
2079
+ * 持たないので、 段の `focus:` で名前を指しても引く先が無い。 `injectPhasesFallback` が
2080
+ * この一覧を使って「実在する名前ならその箱を光らせる」 に読み替える (#1076 / #1077)。
2081
+ */
2082
+ const SINGLE_BOX_KINDS: ReadonlySet<string> = new Set([
2083
+ "chart-pie", "chart-line", "chart-bar",
2084
+ "gantt-timeline", "mind-map", "mind-radial",
2085
+ "funnel-stages", "quadrant-matrix", "tree-hierarchy", "journey-map",
2086
+ ]);
2087
+
2088
+ /** 記法の種別を描画の種別に直す。 描けない種別のままなら `undefined`。 */
2089
+ function drawableKind(kind: string | undefined): string | undefined {
2090
+ if (kind === undefined) return undefined;
2091
+ const mapped = KIND_ALIAS[kind] ?? kind;
2092
+ return DRAWABLE_KINDS.has(mapped) ? mapped : undefined;
2093
+ }
2094
+
2095
+ /**
2096
+ * 順序図の名札 (lifeline 上端 / 下端) の高さを揃える。
2097
+ *
2098
+ * `kind` を書いたとおりに載せると、 種別ごとに要る高さが変わる (行を持つ storage は 206、
2099
+ * card は 72)。 揃えないと縦線の始まる位置がばらけ、 「同じ高さから下りる」 読み方が崩れる。
2100
+ *
2101
+ * 上端は最も高いものに合わせる。 下端も同じ値にする = 上下で形が違うと、 同じ登場人物が
2102
+ * 別物に見える。
2103
+ */
2104
+ function alignSeqHeaderHeights(diagram: CdlDiagram, doc: DslDocument): void {
2105
+ if (doc.type !== "sequence" && doc.type !== "solidity") return;
2106
+ // 名札の id は `{laneId}-header` / `{laneId}-footer` の構造。 末尾の一致だけで見ると、
2107
+ // 登場人物名が `Auth Header` の時に step の目印 `s0-auth-header` を拾い、 見えない 2px の
2108
+ // 箱を名札の高さまで広げてしまう (#883 と同根)。
2109
+ const isEnd = (n: CdlDiagram["nodes"][number]): boolean =>
2110
+ n.id === `${n.lane}-header` || n.id === `${n.lane}-footer`;
2111
+ const ends = diagram.nodes.filter(isEnd);
2112
+ if (ends.length === 0) return;
2113
+ // `posH` を書いた名札は揃えの外に置く。 「その名札だけを指定の大きさにし、 他には影響させない」
2114
+ // という指定なので (`types.ts` の `nodes` override)、 値を変えるのも、 他の名札を引きずるのも
2115
+ // 契約に反する (実測 = `posH: 400` を 1 つ書くと、 無関係な名札まで 72 → 400 になった)。
2116
+ const auto = ends.filter((n) => n.posH === undefined);
2117
+ if (auto.length === 0) return;
2118
+ const tallest = Math.max(...auto.map((n) => n.h ?? 0));
2119
+ if (tallest <= 0) return;
2120
+ for (const n of auto) n.h = tallest;
2121
+ }
2122
+
2123
+ /**
2124
+ * 名札に載せるのに要る高さ (world 単位)。
2125
+ *
2126
+ * 絵が箱の外に出る `shape-` のうち、 **高さを上げれば下のはみ出しが消える** 4 種だけを持つ。
2127
+ *
2128
+ * `actor` / `function` / `storage` / `event` の 4 種は `#1066` までここに載っていた。 描画側が
2129
+ * 名前を箱の高さに関係なく固定の位置に置いていたため、 名札 (高さ 72) に載せると名前が下端を
2130
+ * またいだ。 `cardene777/cdl#416` が小型用の配置を足して収まるようになったので外した
2131
+ * (実測 = 名札の高さで下へ 52.3 から 63.8 の余裕、 目印がある場合でも 9.5 以上)。
2132
+ *
2133
+ * 値は実測 = 高さを 1 ずつ変えて描き、 絵の下端が箱の下端を越えなくなる最小の整数を取った
2134
+ * (`type: flow` で `大きさ:` を書いて掃いた)。 **絵を変えたら測り直す**。
2135
+ * 表が実際の描画と合っているかは `apps/playground-spa/tests/node-label-fit.spec.ts` が
2136
+ * 両側 (この高さで収まる / 2 低いとはみ出す) を実 render で測って見る。
2137
+ */
2138
+ const LABEL_MIN_H: Readonly<Record<string, number>> = {
2139
+ // `shape-` のうち、 高さを上げれば下のはみ出しが消える 4 種 (#1067)。 値は「収まる最小の高さ」
2140
+ // で、 1 手前 (値 - 1) では 0.9-1 はみ出すことを実測した
2141
+ "shape-person": 228, // 名札 72 で下へ 155.9
2142
+ "shape-server-rack": 166, // 94
2143
+ "shape-website": 98, // 26
2144
+ "shape-warehouse": 79, // 7
2145
+ };
2146
+
2147
+ /**
2148
+ * 名札の高さをどれだけ上げても収まらない種別 (#1067)。
2149
+ *
2150
+ * `shape-` を名札に書くと絵が箱の外に描かれる。 49 種すべてを実測したところ、 完全に収まるのは
2151
+ * 5 種 (`shape-cloud` / `shape-window` / `shape-message-bubble` / `shape-token` /
2152
+ * `shape-online-shop`) だけだった。
2153
+ *
2154
+ * ## 分ける基準は「名前が読めるか」 (#1106 で変わった)
2155
+ *
2156
+ * | 向き | 実害 | 扱い |
2157
+ * |---|---|---|
2158
+ * | 下 | 縦線が絵を貫く | 落とす |
2159
+ * | 左右 | 隣の本とぶつかる (`shape-code-block` は右へ 132) | 落とす |
2160
+ * | 上のみ | 何ともぶつからず図の外にも出ない | **原則残すが例外あり** |
2161
+ *
2162
+ * `#1067` は向きだけで分け、 上だけのはみ出しは 10 種すべて残した。 実測で viewBox の内側に
2163
+ * 収まることを確かめており (最大の `shape-robot-arm` は上へ 129 だが余裕が 23)、 箱から出ても
2164
+ * 読み手には壊れて見えないと判断したため。
2165
+ *
2166
+ * **その判断が実機で崩れた**。 `shape-wallet` は上へ 12.1 しか出ないのに、 絵が小さく潰れて
2167
+ * 名前と重なり `EOA` が読めない状態だった。 はみ出し量では説明できない = 12.1 の
2168
+ * `shape-wallet` を落とし、 129 の `shape-robot-arm` を残す。
2169
+ *
2170
+ * したがって **基準は「名前が読めるか」** で、 向きは目安にすぎない。 上だけに出る 10 種のうち
2171
+ * 残るのは 9 種で、 分ける根拠は目視のみ (機械的な基準は無い)。
2172
+ *
2173
+ * ## ここに載るのは「高さで直らない」 種別だけ
2174
+ *
2175
+ * 下のはみ出しは高さで直ることがある。 4 種は `LABEL_MIN_H` に最小の高さを持たせ、 `rows` 等で
2176
+ * 名札が高くなった図では書いたとおりの形で載る (`#1061` の「収まる高さがある時は書いたとおりに
2177
+ * 載せる」 と同じ扱い)。
2178
+ *
2179
+ * こちらに載るのは 3 種類。 **左右にはみ出す 24 種** は横幅が高さで変わらないため直らない
2180
+ * (実測 = `shape-smart-contract` は h=72 でも h=430 でも右へ 15.2)。 **下のはみ出しが高さに
2181
+ * 依らない 6 種** は h を 72 から 600 まで上げても値が変わらない (実測 = `shape-stack` は
2182
+ * 常に 36、 `shape-cylinder` は 150 以上で常に 1)。 **`shape-wallet`** は上のはみ出しが
2183
+ * 高さで変わらず、 絵が潰れて名前と重なる (#1106)。
2184
+ *
2185
+ * ## 失うもの
2186
+ *
2187
+ * Solidity の読み替え (`#975`) 4 組のうち 3 組がここに入るため、 名札では `card` になる
2188
+ * (`contract` / `proxy` → `shape-smart-contract`、 `library` / `interface` →
2189
+ * `shape-code-block`、 `eoa` / `wallet` → `shape-wallet`)。 名札で形が残るのは
2190
+ * `multisig` → `signer` だけ。
2191
+ */
2192
+ const LABEL_NEVER_FITS: ReadonlySet<string> = new Set([
2193
+ // 左右にはみ出す 24 種。 横幅は高さで変わらないため直らない
2194
+ "shape-api-gateway", "shape-atm", "shape-auditor", "shape-bank", "shape-bitcoin-chain",
2195
+ "shape-blockchain", "shape-blockchain-block", "shape-blockchain-node", "shape-brokerage",
2196
+ "shape-code-block", "shape-customer-service", "shape-ethereum-chain", "shape-hexagon",
2197
+ "shape-kanban-card", "shape-lawyer", "shape-network-node", "shape-nft", "shape-notary",
2198
+ "shape-regulator", "shape-satellite", "shape-smart-contract", "shape-terminal",
2199
+ "shape-trader", "shape-trust-bank",
2200
+ // 下のはみ出しが高さに依らない 6 種
2201
+ "shape-cylinder", "shape-diamond", "shape-file", "shape-folder", "shape-mobile-device",
2202
+ "shape-stack",
2203
+ // 上へ出る絵が名札の大きさでは読めない。 `#1067` では「上は何ともぶつからない」 として残したが、
2204
+ // 実際には絵が小さく潰れて名前と重なり、 横に並べた時も 1 本だけ頭が浮く (user 実機確認)
2205
+ "shape-wallet",
2206
+ ]);
2207
+
2208
+ /**
2209
+ * 名札が、 小型の `card` では描かれない文字を持つか。
2210
+ *
2211
+ * 小型の `card` (`h < 100`) が描くのは名前だけ。 `subtitle` と `eyebrow` は分岐で外れ、
2212
+ * `value` は `card` が元から描かない。 `rows` を描くのは種別が限られる。
2213
+ * どれか 1 つでも持つ名札を `card` に落とすと、 著者が書いた文字が画面から消える。
2214
+ */
2215
+ function hasAuthoredText(n: CdlDiagram["nodes"][number]): boolean {
2216
+ if (n.subtitle !== undefined || n.eyebrow !== undefined || n.value !== undefined) return true;
2217
+ return rendersRows(n.kind) && (n.rows?.length ?? 0) > 0;
2218
+ }
2219
+
2220
+ /**
2221
+ * 絵が箱に収まらない `shape-` を名札から外す (#1061 / #1067)。
2222
+ *
2223
+ * `#975` が「書いた種別を名札に載せる」 挙動を入れ、 `#1058` が「書かなかった時は載せない」
2224
+ * を直した。 残っていたのは **書いた時にはみ出す** 側で、 名札は小型の箱 (`h: 72`) なのに
2225
+ * `shape-` は絵を自分の大きさで描くため、 絵が箱の外に出て縦線に貫かれる。
2226
+ *
2227
+ * `actor` / `function` / `storage` / `event` は `#1066` まで対象だった (名前を固定位置に置く
2228
+ * ため名前が下端をまたいだ = actor 21.6 / function 21.6 / storage 13.6 / event 24.2 world px)。
2229
+ * 描画側 (`cardene777/cdl#416`) が小さい箱で名前を中央に置くようになったので外した。
2230
+ * **いま落とす理由は絵のはみ出しだけ**。
2231
+ *
2232
+ * **収まる高さがある時は書いたとおりに載せる**。 `rows` を書いた名札は
2233
+ * `requiredRowsHeight` で 206 以上になり、 揃え (`alignSeqHeaderHeights`) がその高さを
2234
+ * 全本に配る。 判定を揃えの後に置くのはこのため。
2235
+ *
2236
+ * ただし **揃えで届くのは表の値が 206 以下の種別だけ**。 `shape-warehouse` (79) /
2237
+ * `shape-website` (98) / `shape-server-rack` (166) は収まるが、 `shape-person` (228) は
2238
+ * 届かず `card` に落ちる (`rows` 1 件では 206 まで)。
2239
+ *
2240
+ * **著者が書いた文字を持つ名札は落とさない**。 小型の `card` は名前しか描かない
2241
+ * (`subtitle` / `eyebrow` は `h < 100` の分岐で外れ、 `value` は元から描かない)。 落とすと
2242
+ * 書いた文字が画面から消える = 絵がはみ出すより悪い。 `rows` と同じ扱いにする。
2243
+ *
2244
+ * 落とす時に失うものは、 種別ごとの絵と、 既定の配色での枠線の色。 本 app の配色は枠線の色を
2245
+ * 上書きするため見た目は変わらないが、 既定の配色で使う利用者には差が出る。
2246
+ * それでも落とすのは、 絵が箱の外に出る方が読み手に与える誤りが大きいため。
2247
+ *
2248
+ * 高さを上げる方向は採らない。 名札の高さは全本で揃える規約があるため 1 本の指定が全体に
2249
+ * 伝播し、 全名札が 2-3 倍になる (`#1058` で実測、 golden 25 件が変化)。
2250
+ *
2251
+ * **判定は上下 1 組でする**。 `nodes` override で上端だけ大きさを書くと (`posH: 120`)、
2252
+ * 上端は収まり下端 (72) は収まらないため、 1 つずつ見ると同じ登場人物の上下で形が変わる。
2253
+ * 上下で形が違うと別物に見えるので、 どちらかが収まらなければ両方落とす。
2254
+ *
2255
+ * ## `shape-` も対象に含める (#1067)
2256
+ *
2257
+ * 当初は対象外にしていた。 これらは名前を箱ではなく自分の絵に対して置くため、 箱を基準に測ると
2258
+ * 収まっていないように見えるだけだと考えたため。 49 種を実測すると **絵そのものが箱の外に出て
2259
+ * 縦線に貫かれ、 隣の本ともぶつかって** いた。 どの種別をどう扱うかは `LABEL_NEVER_FITS` の
2260
+ * 説明が SSOT。
2261
+ *
2262
+ * ## 覆っていない範囲
2263
+ *
2264
+ * **上だけにはみ出す `shape-` は 9 種を残す**。 何ともぶつからず図の外にも出ないため
2265
+ * (`LABEL_NEVER_FITS` の説明を参照)。 箱の外に絵があること自体は直っておらず、 縦線が絵を貫く。
2266
+ * 縦線の終点は描画側が箱の下端で決めており、 組み立て側からは変えられない。
2267
+ *
2268
+ * 10 種のうち `shape-wallet` だけは落とす (#1106)。 上へ 12.1 しか出ないのに絵が潰れて名前と
2269
+ * 重なるため = 分ける基準ははみ出し量ではなく「名前が読めるか」。
2270
+ *
2271
+ * **著者が文字を書いた名札は落とさない**。 小型の `card` は名前しか描かないため、 落とすと
2272
+ * 書いた文字が画面から消える。 この保護によって `shape-` は落ちずに残るが、 描画側が絵を箱に
2273
+ * 収めるようになったので **下と左右には出ない** (`#1105`、 49 種を実測して 0)。 残るのは上だけで、
2274
+ * 上の扱いはこの節の 1 つ目と同じ。
2275
+ *
2276
+ * `actor` / `function` / `storage` / `event` は `#1066` まで落とす対象だった。 描画側
2277
+ * (`cardene777/cdl#416`) が小さい箱で名前を中央に置くようになったので外した = 名前がはみ出す
2278
+ * 理由で落とす経路はもう無い。 いま落とすのは `shape-` だけで、 理由は絵のはみ出し。
2279
+ */
2280
+ function dropUnfittableEndKinds(diagram: CdlDiagram, doc: DslDocument): void {
2281
+ if (doc.type !== "sequence" && doc.type !== "solidity") return;
2282
+ const ends = new Map<string, CdlDiagram["nodes"]>();
2283
+ for (const n of diagram.nodes) {
2284
+ if (n.id !== `${n.lane}-header` && n.id !== `${n.lane}-footer`) continue;
2285
+ const pair = ends.get(n.lane);
2286
+ if (pair === undefined) ends.set(n.lane, [n]);
2287
+ else pair.push(n);
2288
+ }
2289
+ for (const pair of ends.values()) {
2290
+ // 著者が書いた文字を持つ名札は落とさない。 小型の `card` は名前しか描かないため、
2291
+ // 落とすと書いた文字が画面から消える (`#387` と同じ壊れ方になる)。 これらは上端にしか
2292
+ // 載らないため 1 組で見る。
2293
+ if (pair.some(hasAuthoredText)) continue;
2294
+ const 収まらない = pair.some((n) => {
2295
+ // 高さを上げても直らない種別 (#1067)。 高さを見ずに落とす
2296
+ if (LABEL_NEVER_FITS.has(n.kind)) return true;
2297
+ const need = LABEL_MIN_H[n.kind];
2298
+ if (need === undefined) return false;
2299
+ // 描画で使う高さを見る。 `posH` が効くのは `posX` と `posY` が揃った node だけ
2300
+ // (`layout/nodes.ts`)。 揃っていない node の `posH` を見ると、 描画では使われない値で
2301
+ // 判定することになる。
2302
+ const h = (n.posX !== undefined && n.posY !== undefined ? n.posH : undefined) ?? n.h;
2303
+ // 高さを書いていない名札は描画側の既定 (`NODE_SIZE`、 4 種とも 170 以上) で描かれるので
2304
+ // 収まる。 名札は必ず高さを持つため通常ここには来ない。
2305
+ return h !== undefined && h < need;
2306
+ });
2307
+ if (!収まらない) continue;
2308
+ for (const n of pair) {
2309
+ if (LABEL_NEVER_FITS.has(n.kind) || LABEL_MIN_H[n.kind] !== undefined) n.kind = "card";
2310
+ }
2311
+ }
2312
+ }
2313
+
2314
+ /**
2315
+ * `(from, to)` の一致では取れない preset について、 edge と DSL の行の対応を埋める。
2316
+ *
2317
+ * `type: flow` は **actor を宣言順に一直線に並べ、 隣り合う actor の間に edge を引く**。
2318
+ * n 本目の edge は `actors[n]` から `actors[n+1]` へ向かい、 その label は
2319
+ * `doc.flow.find((s) => s.to === actors[n+1].name)` で選ばれる (`compileFlow`)。 そのため
2320
+ * `a -> c` / `c -> b` と書いても edge は `a -> b` / `b -> c` になり、 `(from, to)` の一致では
2321
+ * 1 件も取れない。
2322
+ *
2323
+ * **label を選ぶのと同じ規則で引く**。 `slugify` を挟んだ照合にすると、 別の名前が同じ slug に
2324
+ * なる形 (`API Gateway` と `api-gateway`) で label の出どころと違う step を返す。
2325
+ *
2326
+ * **汎用の `(from, to)` 照合が入れた値は上書きする**。 `type: flow` では label の出どころが
2327
+ * この規則で決まるので、 こちらが正しい。 上書きしないと、 たまたま `(from, to)` が一致した
2328
+ * 別の step の行が残る (実測 = `c -> b: いち` / `a -> b: に` の順で書くと、 edge の label は
2329
+ * `いち` なのに `に` の行を返した)。
2330
+ *
2331
+ * 対応が取れない edge には何も入れない (呼出側が「対応が無い」 と「行 0」 を区別できるように
2332
+ * するため、 #998)。
2333
+ */
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
+ /**
2348
+ * v0.5+ groups section を topology preset 経由の diagram に container lane として反映。
2349
+ * group.lanes に含まれる lane id 集合に対し、 wrap する `group-{id}` lane を contain: true で生成。
2350
+ *
2351
+ * 簡易実装 ... group container lane を独立 lane として並べ、 lane label に group.label を採用。
2352
+ * lane の物理的内包 (子 lane を group container の x 内に再配置) は engine layout に委ねる範囲外なので、
2353
+ * 本実装は CdlDiagram 上に「contain: true な group container lane」 を追加する最小骨格に留める。
2354
+ */
2355
+ function applyGroupContainers(diagram: CdlDiagram, doc: DslDocument): void {
2356
+ if (!doc.groups || Object.keys(doc.groups).length === 0) return;
2357
+ for (const [id, g] of Object.entries(doc.groups)) {
2358
+ const containerId = `group-${id}`;
2359
+ if (diagram.lanes.some((l) => l.id === containerId)) continue;
2360
+ diagram.lanes.push({
2361
+ id: containerId,
2362
+ width: 800,
2363
+ label: g.label ?? id,
2364
+ contain: true,
2365
+ // 束ねる lane 群に重ねて描く枠。 横に並べる lane ではないので、 engine の間隔調整
2366
+ // (lane を詰めた分を幅で埋め合わせる処理) の対象から外す。
2367
+ role: "overlay",
2368
+ });
2369
+ }
2370
+ }
2371
+
2372
+ /**
2373
+ * Solidity 専用 preset。
2374
+ *
2375
+ * 設計:
2376
+ * - actors を kind=contract / eoa / multisig 等で配置 (EOA は左、 contract は中央、 storage は右など layered layout)
2377
+ * - flow は function call の sequence (msg.sender → contract.fn() → internal call → emit event)
2378
+ * - storage 更新は state + rows binding で自動 (kind: storage の actor に rows: ["bal[A]: {balA}", ...])
2379
+ * - event は kind: event の actor を右端に並べ、 emit edge で発火を表現
2380
+ * - revert は tone: error の edge で表現
2381
+ *
2382
+ * sequence preset を base に使い、 Solidity 文脈に最適化した default を載せる:
2383
+ * - default tone: accent (call) / success (emit) / error (revert)
2384
+ * - default style: solid (call) / dotted-flow (state-change)
2385
+ */
2386
+ function compileSolidity(doc: DslDocument): CdlDiagram {
2387
+ // sequence preset と同等構造で組み立てる、 lane 順は eoa / contract / storage / event の優先順で sort
2388
+ const kindOrder: Record<string, number> = {
2389
+ eoa: 0,
2390
+ actor: 0,
2391
+ multisig: 0,
2392
+ signer: 0,
2393
+ wallet: 0,
2394
+ contract: 1,
2395
+ proxy: 1,
2396
+ library: 1,
2397
+ interface: 1,
2398
+ storage: 2,
2399
+ event: 3,
2400
+ };
2401
+ const sorted = [...doc.actors].sort(
2402
+ (a, b) => (kindOrder[a.kind] ?? 5) - (kindOrder[b.kind] ?? 5),
2403
+ );
2404
+ // sorted を doc.actors に上書きしてから sequence preset 経由で compile
2405
+ const sortedDoc: DslDocument = { ...doc, actors: sorted };
2406
+ return compileSequence(sortedDoc);
2407
+ }
2408
+
2409
+ /**
2410
+ * Gantt preset (横棒 timeline 専用 layout)
2411
+ *
2412
+ * 設計 ... 各 actor = 1 行 (= 1 task) として、 actor.subtitle ("Q1" / "Q2" / "Q3" / "Q4") を
2413
+ * 横軸 (時間軸) 上の位置にマッピングし、 actor を上下に縦 stack する形で「横棒 timeline」 を
2414
+ * 視覚的に作る。
2415
+ *
2416
+ * 寸法 ... 全体 timeline 幅 1400px / 各 task 横棒 w=280 h=64 / 中央 cx は Q1=200 / Q2=600 /
2417
+ * Q3=900 / Q4=1200。
2418
+ *
2419
+ * 実装 ... 背景 container lane (gantt-timeline) を 1 本 + 各 actor 用個別 lane (lane.x 明示) を
2420
+ * 1 本ずつ。 actor の stack は row index で、 全 lane 共通の row cy が layout で計算される。
2421
+ * kind: card 強制、 w / h を明示することで Gantt bar の視覚 size を担保。
2422
+ *
2423
+ * flow は依存関係を edge で表現 (横棒間の矢印)。
2424
+ */
2425
+ function compileGantt(doc: DslDocument): CdlDiagram {
2426
+ const b = diagram(slugify(doc.title), { topic: doc.title });
2427
+ const CHART_W = 720;
2428
+ b.lane("gantt", { width: CHART_W, label: doc.title });
2429
+
2430
+ // 目盛りは **書かれた順** に並べる。 以前は `Q1=200 / Q2=600 / ...` の決め打ちで、 Q1-Q4 以外は
2431
+ // 全て同じ位置に落ちていた。 順に並べれば月名でも週番号でも同じ規則で置ける
2432
+ const 目盛り: string[] = [];
2433
+ const 目盛りなし: string[] = [];
2434
+ const タスク: { name: string; label: string; tone?: DslDocument["actors"][number]["tone"] }[] = [];
2435
+ for (const a of doc.actors) {
2436
+ const label = (a.value ?? a.subtitle ?? "").trim();
2437
+ if (label === "") {
2438
+ 目盛りなし.push(a.name);
2439
+ continue;
2440
+ }
2441
+ if (!目盛り.includes(label)) 目盛り.push(label);
2442
+ // 色は帯にそのまま渡す。 箱が 1 つになっても、 書いた色が消えないようにする
2443
+ タスク.push({ name: a.name, label, ...(a.tone !== undefined ? { tone: a.tone } : {}) });
2444
+ }
2445
+ if (目盛りなし.length > 0 && typeof console !== "undefined" && console.warn) {
2446
+ console.warn(
2447
+ `[dragon] type: gantt で時期を読めない項目があります (帯に載せません): ${目盛りなし.join(", ")}。` +
2448
+ ` \`- 設計: "Q1"\` の形で書いてください`,
2449
+ );
2450
+ }
2451
+
2452
+ // 矢印は依存として読む (`- 設計 -> 実装` = 実装は設計の後)。 帯どうしを結ぶ線は描画側が
2453
+ // 依存として描くので、 書いた矢印を捨てずに使う。 居ない名前を指した矢印は伝える
2454
+ const タスク名 = new Set(タスク.map((t) => t.name));
2455
+ const 依存元 = new Map<string, string>();
2456
+ const 居ない: string[] = [];
2457
+ const 装飾つき: string[] = [];
2458
+ for (const s of doc.flow) {
2459
+ if (!タスク名.has(s.from) || !タスク名.has(s.to)) {
2460
+ 居ない.push(`${s.from} -> ${s.to}`);
2461
+ continue;
2462
+ }
2463
+ 依存元.set(s.to, s.from);
2464
+ // 帯の依存は「どちらが先か」 だけを持つ。 矢印に書いた文字や色は描けないので伝える
2465
+ if ((s.label ?? "") !== "" || (s.sub ?? "") !== "" || s.tone !== undefined || s.style !== undefined) {
2466
+ 装飾つき.push(`${s.from} -> ${s.to}`);
2467
+ }
2468
+ }
2469
+ if (居ない.length > 0 && typeof console !== "undefined" && console.warn) {
2470
+ console.warn(
2471
+ `[dragon] type: gantt で依存を結べない矢印があります (居ない項目か時期なし): ${居ない.join(", ")}`,
2472
+ );
2473
+ }
2474
+ if (装飾つき.length > 0 && typeof console !== "undefined" && console.warn) {
2475
+ console.warn(
2476
+ `[dragon] type: gantt の矢印は前後の関係だけを使います (文字 / 色 / 線種は描けません): ${装飾つき.join(", ")}`,
2477
+ );
2478
+ }
2479
+
2480
+ // 高さは件数から決める。 描画側は 1 行 28 以上 + 行間 20 で積み、 上下に 32 / 44 の余白を取る
2481
+ // (`kinds/gantt.tsx`)。 360 の固定だと 8 件目から最後の帯が枠の外に出る (実測 = 8 件で 56 はみ出す)
2482
+ const CHART_H = Math.max(360, 48 * タスク.length + 96);
2483
+
2484
+ b.node(`${slugify(doc.title) || "gantt"}-chart`, {
2485
+ lane: "gantt",
2486
+ stack: 0,
2487
+ kind: "gantt-timeline",
2488
+ title: doc.title,
2489
+ w: CHART_W,
2490
+ h: CHART_H,
2491
+ ganttData: タスク.map((t) => {
2492
+ const idx = 目盛り.indexOf(t.label);
2493
+ const from = 依存元.get(t.name);
2494
+ return {
2495
+ id: slugify(t.name) || t.name,
2496
+ title: t.name,
2497
+ startIdx: idx,
2498
+ endIdx: idx,
2499
+ startLabel: t.label,
2500
+ endLabel: t.label,
2501
+ ...(from !== undefined ? { dependsOn: slugify(from) || from } : {}),
2502
+ ...(t.tone !== undefined ? { tone: t.tone } : {}),
2503
+ };
2504
+ }),
2505
+ });
2506
+
2507
+ return b.build();
2508
+ }
2509
+
2510
+ /**
2511
+ * Class preset (UML class diagram 専用 layout)
2512
+ *
2513
+ * 設計 ... 各 class を 1 storage node として配置、 縦に stack する。 storage node renderer は
2514
+ * title (class 名) + divider + rows (fields / methods) を UML class box 風に表示する。
2515
+ *
2516
+ * 実装 ... 全 class を 1 lane に縦 stack 配置。 actor の kind を強制 storage、 rows / subtitle は
2517
+ * applyV05Extensions で node に merge される。
2518
+ *
2519
+ * flow ... 継承 / 関連を edge で表現 (label に "extends" / "implements" 等を author が指定)。
2520
+ */
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
+ });
2548
+ }
2549
+
2550
+ return b.build();
2551
+ }
2552
+
2553
+ /**
2554
+ * Pie preset (円グラフ風 slice list 専用 layout)
2555
+ *
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。
2561
+ *
2562
+ * flow は通常なし (slice 間に依存関係はない)、 author 明示時のみ edge を描く。
2563
+ */
2564
+ /**
2565
+ * 割合の書き方から数値を読む。 読めなければ `null`。
2566
+ *
2567
+ * 受けるのは `"45%"` / `"45"` / `"45.5%"` と、 前後の空白。 `"四割"` や `"0.45"` のような
2568
+ * 別の言い方は読まない = **黙って 0 にすると、 その分だけ欠けた円が「正しい図」 として出る**。
2569
+ * 読めなかったことは呼出側が警告に出す。
2570
+ */
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;
2577
+ }
2578
+
2579
+ /**
2580
+ * Pie preset (円グラフ)。
2581
+ *
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 にならなくても描画側が比で割るので、 こちらでは正規化しない。
2590
+ */
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 });
2596
+
2597
+ const data: NonNullable<CdlDiagram["nodes"][number]["chartData"]> = [];
2598
+ const 読めない: string[] = [];
2599
+ 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) {
2604
+ 読めない.push(a.name);
2605
+ continue;
2606
+ }
2607
+ // 色は扇にそのまま渡す。 箱が 1 つになっても、 書いた色が消えないようにする
2608
+ data.push({ label: a.name, value, ...(a.tone !== undefined ? { tone: a.tone } : {}) });
2609
+ }
2610
+ if (読めない.length > 0 && typeof console !== "undefined" && console.warn) {
2611
+ console.warn(
2612
+ `[dragon] type: pie で割合を読めない項目があります (円に載せません): ${読めない.join(", ")}。` +
2613
+ ` \`- 名前: "45%"\` の形で書いてください`,
2614
+ );
2615
+ }
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
+ );
2623
+ }
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,
2633
+ });
2634
+
2635
+ return b.build();
2636
+ }
2637
+
2638
+ /**
2639
+ * 段の目印を読み取る。 目印と、 それを落とした残りの説明を返す (#1098)。
2640
+ *
2641
+ * 目印 (`L1` / `L2` / `L3`) は「どの段に置くか」 を組み立てに伝えるためのもので、 読む人には
2642
+ * 意味を持たない。 段の名前は枠のラベル (`System Context` 等) が出すので二重でもある。
2643
+ * 読み取ったら説明から落とす = 書いた人が説明として書いた部分だけが箱に出る。
2644
+ *
2645
+ * | 書いた文字 | 段 | 残る説明 |
2646
+ * |---|---|---|
2647
+ * | `"L1"` | 1 | (無し) |
2648
+ * | `"L2: container"` | 2 | `container` |
2649
+ * | `"L1 利用者"` | 1 | `利用者` |
2650
+ * | `"利用者"` | 1 (既定) | `利用者` |
2651
+ *
2652
+ * 目印の直後の区切り (`:` と空白) も落とす。 残さないと `: container` のように区切りだけが
2653
+ * 先頭に残る。 `L2X` のような別の語を目印と読み違えないよう、 数字の直後が英数字でないことを
2654
+ * 条件にする。
2655
+ */
2656
+ function 段を読み取る(subtitle: string | undefined): { 段: number; 説明: string | undefined } {
2657
+ const 元 = (subtitle ?? "").trim();
2658
+ const m = 元.match(/^L([123])(?![0-9A-Za-z])/i);
2659
+ if (m === null) return { 段: 1, 説明: subtitle };
2660
+ // 目印と、 その直後の区切り (`:` / 全角コロン / 空白) を落とす
2661
+ const 残り = 元.slice(m[0].length).replace(/^[::\s]+/, "").trim();
2662
+ return { 段: Number(m[1]), 説明: 残り === "" ? undefined : 残り };
2663
+ }
2664
+
2665
+ /**
2666
+ * C4 preset (C4 model 階層 system context 専用 layout)
2667
+ *
2668
+ * 設計 ... actor.subtitle の先頭に "L1" / "L2" / "L3" を置き、 階層 lane を生成。
2669
+ * - L1 = System Context
2670
+ * - L2 = Container
2671
+ * - L3 = Component
2672
+ *
2673
+ * 実装 ... **中身のある段だけ** lane を作り、 使う段を左から順に詰めて横並び (contain: true で
2674
+ * 囲む) 配置する。 3 lane を常に作ると中身のない枠が画面に残り、 描かれ損ねたように見える (#1078)。
2675
+ * 同 lane 内の actor は内部 stack で縦並びになる (横並びは layout 制約上不可、
2676
+ * 段の区別が視覚的に最重要)。 subtitle marker 未指定なら L1 fallback。
2677
+ *
2678
+ * flow ... actor 間の関係を edge で表現。
2679
+ */
2680
+ function compileC4(doc: DslDocument): CdlDiagram {
2681
+ const b = diagram(slugify(doc.title), { topic: doc.title });
2682
+ const LANE_W = 400;
2683
+ const LANE_GAP = 80;
2684
+ const 段の名前: Record<number, string> = { 1: "System Context", 2: "Container", 3: "Component" };
2685
+
2686
+ // 段は **先頭一致** で読み、 読んだ目印は説明から落とす (`段を読み取る` の説明を参照)
2687
+ const 割当 = doc.actors.map((a, idx) => {
2688
+ const { 段, 説明 } = 段を読み取る(a.subtitle);
2689
+ return { 段, 説明, id: slugify(a.name) || `n${idx}`, actor: a };
2690
+ });
2691
+
2692
+ // **中身のある段だけ枠を作る**。 3 段を必ず作ると、 書いていない段が空の点線枠として残り、
2693
+ // 見た人には「何かが描かれ損ねた」 ようにしか見えない
2694
+ const 使う段 = [1, 2, 3].filter((lv) => 割当.some((x) => x.段 === lv));
2695
+ 使う段.forEach((lv, i) => {
2696
+ b.lane(`c4-l${lv}`, {
2697
+ // 空の段を飛ばした分だけ左に詰める。 飛ばした位置に隙間を残すと、 やはり
2698
+ // 「何かが抜けている」 ように見える
2699
+ x: i * (LANE_W + LANE_GAP),
2700
+ width: LANE_W,
2701
+ label: 段の名前[lv]!,
2702
+ contain: true,
2703
+ });
2704
+ });
2705
+
2706
+ const stackPerLane: Record<string, number> = { "c4-l1": 0, "c4-l2": 0, "c4-l3": 0 };
2707
+ for (const x of 割当) {
2708
+ const lid = `c4-l${x.段}`;
2709
+ const stack = stackPerLane[lid]!;
2710
+ stackPerLane[lid] = stack + 1;
2711
+ b.node(x.id, {
2712
+ lane: lid,
2713
+ stack,
2714
+ kind: x.actor.kind,
2715
+ title: x.actor.name,
2716
+ });
2717
+ }
2718
+
2719
+ for (const s of doc.flow) {
2720
+ const fromId = slugify(s.from);
2721
+ const toId = slugify(s.to);
2722
+ b.edge(fromId, toId, {
2723
+ label: s.label,
2724
+ ...(s.sub ? { sub: s.sub } : {}),
2725
+ ...(s.tone ? { tone: s.tone } : {}),
2726
+ ...(s.style ? { style: s.style } : {}),
2727
+ });
2728
+ }
2729
+
2730
+ return b.build();
2731
+ }
2732
+
2733
+ /**
2734
+ * Mind map preset (中央 root + leaf 専用 layout)
2735
+ *
2736
+ * 設計 ... 1 つ目の actor を root として中央 lane に配置、 残りを leaf として root の左右の
2737
+ * lane に交互配置する。 完全な放射状 (8 方向) は実装が大きいので、 簡略実装 layer 1 として
2738
+ * left / center (root) / right の 3 lane に leaf を交互配置する。
2739
+ *
2740
+ * 実装 ... 3 lane (mind-left / mind-center / mind-right)。 root を center に stack=中央 で配置
2741
+ * (leaf 数の半分相当の stack で root を中央化)、 leaf を奇数番 → left、 偶数番 → right に分配。
2742
+ * kind: card 強制。
2743
+ *
2744
+ * flow ... 宣言なしなら root → 各 leaf の暗黙 edge を自動生成、 宣言ありならそれを採用。
2745
+ */
2746
+ function compileMind(doc: DslDocument): CdlDiagram {
2747
+ const b = diagram(slugify(doc.title), { topic: doc.title });
2748
+ const LEAF_W = 280;
2749
+ const ROOT_W = 320;
2750
+ const GAP = 80;
2751
+
2752
+ // 枝は左右に交互に置く。 中身のある枠だけ作る (#1096)。
2753
+ //
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();
2766
+
2767
+ // 使う枠だけ左から詰める。 飛ばした位置に隙間を残すと、 やはり「抜けている」 ように見える
2768
+ let x = 0;
2769
+ if (左を使う) {
2770
+ b.lane("mind-left", { x, width: LEAF_W, label: "" });
2771
+ x += LEAF_W + GAP;
2772
+ }
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: "" });
2777
+ }
2778
+
2779
+ const root = doc.actors[0]!;
2780
+ 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
+
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,
2804
+ });
2805
+ counter.v += 1;
2806
+ });
2807
+
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
+ }
2824
+ }
2825
+
2826
+ return b.build();
2827
+ }
2828
+
2829
+ /**
2830
+ * v0.5+ inline option (subtitle / eyebrow / value / rows / stack / lane) +
2831
+ * top-level lanes / viewport / groups を post-process で反映。
2832
+ *
2833
+ * 設計: preset compile が既に基本 layout を作るので、 後付けで
2834
+ * - actor の inline option を該当 node に merge
2835
+ * - top-level lanes section の x / width / contain / lifeline / label を該当 lane に merge
2836
+ * - viewport の laneWidth (default lane width override) を全 lane に適用
2837
+ *
2838
+ * これにより v0.5 syntax で 19 機能のうち以下が動く:
2839
+ * subtitle / eyebrow / value / rows / contain / lifeline / label / lane.x / lane.width / laneWidth
2840
+ */
2841
+ function applyV05Extensions(diagram: CdlDiagram, doc: DslDocument): CdlDiagram {
2842
+ // actor の主要 node を preset 種別で回収する。 sequence / solidity は header/footer を対で生成する
2843
+ // preset で主要 node は header、 それ以外の preset は actor 名 slug がそのまま node id になる。
2844
+ //
2845
+ // seq-like の非 animate 経路は実 node id を CDL preset 側 slugify (`_` → `-` 置換 + 全角正規化) で
2846
+ // 生成する。 dragon slugify (`_` / 全角 保持) で `{slug}-header` を決め打つと、 actor `A_B` の
2847
+ // primaryNodeId `a_b-header` が実 node `a-b-header` と食い違い、 inline option (subtitle / eyebrow /
2848
+ // value / rows) が drop する (#881、 #873 / #877 と同根の dragon⇔CDL slug 不一致)。 lane.label は
2849
+ // 両 slug 経路とも actor.name の生値なので (#877)、 actor 専用 lane を label 一致で引き当て、 その
2850
+ // lane 内の `-header` node を権威 primary として回収する。 slug 決め打ちを廃して実装差を構造的に吸収。
2851
+ //
2852
+ // 経路を preset 種別 (isSeqLike) で分け、 かつ lane.label / node id を actor.name の exact 一致で
2853
+ // 引くことで、 actor 名 "A Header" の slug `a-header` が actor "A" の node に漏れる cross-actor leak
2854
+ // (#879) も同時に断つ。
2855
+ const isSeqLike = doc.type === "sequence" || doc.type === "solidity";
2856
+ // actor inline option → node merge
2857
+ for (const a of doc.actors) {
2858
+ const dragonSlug = slugify(a.name);
2859
+ let primaryNodes: CdlDiagram["nodes"];
2860
+ if (isSeqLike) {
2861
+ const ownedLaneIds = new Set(
2862
+ diagram.lanes.filter((l) => l.label === a.name).map((l) => l.id),
2863
+ );
2864
+ // lane.label で actor 専用 lane を引けた場合はその lane の header node を回収する。 引けない
2865
+ // (label 未設定等の) preset は従来どおり dragon slug の `{slug}-header` 決め打ちに fallback する。
2866
+ //
2867
+ // header node id は `{laneId}-header` の構造。 `endsWith("-header")` で判定すると step box
2868
+ // `s{idx}-{laneId}` が actor 名末尾 "Header" (slug `...-header`) で誤マッチし、 option が invisible
2869
+ // な step anchor にも copy される (cc-codex #883 MAJOR)。 lane id との構造 exact 一致で header だけを
2870
+ // 引くことで step box / spacer / footer を排除する。
2871
+ primaryNodes = ownedLaneIds.size > 0
2872
+ ? diagram.nodes.filter((n) => ownedLaneIds.has(n.lane) && n.id === `${n.lane}-header`)
2873
+ : diagram.nodes.filter((n) => n.id === `${dragonSlug}-header`);
2874
+ } else {
2875
+ // 非 seq preset は 1 actor = 1 node (id = dragon slug) で node id と dragon slug が一致する。
2876
+ primaryNodes = diagram.nodes.filter((n) => n.id === dragonSlug);
2877
+ }
2878
+ for (const node of primaryNodes) {
2879
+ // `type: c4` では説明の先頭に段の目印 (`L1` / `L2` / `L3`) を書く。 目印は組み立てに
2880
+ // 段を伝えるためのもので読む人に意味を持たず、 段の名前は枠のラベルが既に出している。
2881
+ // ここで落とさないと、 組み立てが読み取った目印がそのまま箱の説明として出る (#1098)
2882
+ const 説明 = doc.type === "c4" ? 段を読み取る(a.subtitle).説明 : a.subtitle;
2883
+ if (説明 !== undefined) node.subtitle = 説明;
2884
+ if (a.eyebrow !== undefined) node.eyebrow = a.eyebrow;
2885
+ if (a.value !== undefined) node.value = a.value;
2886
+ if (a.rows !== undefined) node.rows = a.rows;
2887
+ // seq-like preset の header / footer は kind を card 固定で作る。 書いた kind を載せる
2888
+ // (#975)。 載せないと「書いたのに効かない項目」 が残り、 `rows` を書いた時は行が card に
2889
+ // 付いて画面から消える (#387、 cdl 側 Axis 67 rows-not-rendered が検知する)。
2890
+ //
2891
+ // 以前は「行を描く kind かつ rows あり」 に絞っていた。 header の見た目を kind ごとに
2892
+ // 変えると読み方が変わることを懸念したためだが、 **書いたとおりにならない方が読み手を
2893
+ // 惑わせる**。 見本 412 図で影響を受けるのは 1 図 (4 actor) だけと実測した。
2894
+ // 書いた種別を名札に載せる。 描画側に無い語は意味の近い形に読み替える (#975)。
2895
+ //
2896
+ // **書いた時だけ載せる** (#1058)。 `kind` は書かなくても既定の `actor` が入るため、
2897
+ // 値だけを見ると「書かなかった」 が「`actor` と書いた」 に化ける。 名札は小型の箱
2898
+ // (`h: 72`) で作られ、 描画側は `card` に小型用の分岐を持つが `actor` には無い。
2899
+ // 既定値で上書きすると小型の分岐が外れ、 名前の文字が箱の下端をはみ出す。
2900
+ //
2901
+ // 判定は `!== false` で行う。 記法の parse は書かなかった時に `false` を明示するので
2902
+ // これで区別できる。 `=== true` にすると、 **記法を通さず `DslActor` を直接組み立てて
2903
+ // `compileToCdl()` を呼ぶ経路** (公開 API) が既定の `undefined` で全て「書かなかった」
2904
+ // に倒れ、 書いた種類が消える (#975 の挙動が壊れる)。
2905
+ const drawn = isSeqLike && a.kindWritten !== false ? drawableKind(a.kind) : undefined;
2906
+ if (drawn !== undefined) {
2907
+ node.kind = drawn as typeof node.kind;
2908
+ // 下端の名札も同じ形にする。 上下で形が違うと、 同じ登場人物が別物に見える。
2909
+ // `rows` は上端にだけ載る (`primaryNodes` が上端しか拾わない) ので、 行は 2 度出ない。
2910
+ const footer = diagram.nodes.find((n) => n.id === `${node.lane}-footer`);
2911
+ if (footer) footer.kind = drawn as typeof node.kind;
2912
+ }
2913
+ // 行を書いた時は枠に収まる高さと幅にする。 header は w / h を固定値で作られ、 cdl 側は
2914
+ // `n.w` / `n.h` を明示した node の自動拡張を尊重する (著者指定を壊さない) 設計なので、
2915
+ // preset が置いた固定値がそのまま残る。
2916
+ //
2917
+ // 必要な寸法は cdl の SSOT (`requiredRowsHeight` / `requiredRowsWidth`) から引く。
2918
+ // 式を dragon 側に写すと、 描画を変えた時に片方だけ古くなる。
2919
+ if (isSeqLike && a.rows !== undefined && a.rows.length > 0 && rendersRows(a.kind)) {
2920
+ node.h = Math.max(node.h ?? 0, requiredRowsHeight(a.kind, a.rows.length) ?? 0);
2921
+ // **種別も渡す**。 省くと cdl 側は「どちらで描かれるか分からない」 として広い方を返す
2922
+ // ため、 実際に描かれる書式より名札が広くなる。 高さと同じく種別を渡す。
2923
+ //
2924
+ // 差が出るのは比例の書式の方が広くなる行 = 比例は 1 字の最大が字の大きさの 1.038 倍
2925
+ // (`W`) で 18 なら 18.7、 等幅の 26 は 1 字 15.6。 実測 = `W` を 10 個並べた行を持つ
2926
+ // `storage` の名札が、 種別なしで 268 / 種別あり で 256。
2927
+ node.w = Math.max(node.w ?? 0, requiredRowsWidth(a.rows, a.kind));
2928
+ }
2929
+ // 名札の大きさも書いたとおりにする (#975)。 縦線の位置は `位置:` の x が lane に効く
2930
+ // (実測) が、 大きさはどこにも載っていなかった。
2931
+ //
2932
+ // 高さは指定をそのまま使わず、 揃える側 (`alignSeqHeaderHeights`) に渡す候補にする。
2933
+ // 1 本だけ高い名札を作ると、 縦線の始まる位置がばらける。
2934
+ if (isSeqLike) {
2935
+ // 書いた値をそのまま使う。 大きい方を採ると、 縮める指定 (`大きさ: 80,60`) が効かない。
2936
+ if (a.posW !== undefined) node.w = a.posW;
2937
+ if (a.posH !== undefined) node.h = a.posH;
2938
+ const footer = diagram.nodes.find((n) => n.id === `${node.lane}-footer`);
2939
+ if (footer && a.posW !== undefined) footer.w = a.posW;
2940
+ }
2941
+ }
2942
+ }
2943
+ // 名札の高さを揃える。 kind ごとに高さが変わると縦線の始まる位置がばらけ、 順序図の
2944
+ // 「同じ高さから下りる」 読み方が崩れる (実測 = 行を持つ名札だけ 134px 下にずれた)。
2945
+ alignSeqHeaderHeights(diagram, doc);
2946
+ // 揃えた後の高さで、 絵が箱に収まらない `shape-` を名札から外す (#1061 / #1067、 #1066 で
2947
+ // 4 種を対象から外した)。 揃えは高さを上げる方向にしか動かないので、 ここで見れば
2948
+ // 「行を書いた図では書いた種別が残る」 が成立する。
2949
+ dropUnfittableEndKinds(diagram, doc);
2950
+ // v0.5+ animation phase 後段注入 (CAR-1657 fix、 元 dragon PR #413 report user)。
2951
+ // preset (class / pie / c4 / mind / gantt) が doc.animate を無視して build するケースを補償。
2952
+ // 既に preset が phase を生成済 (sequence / flow / swimlane / er / state / topology 経由 = compileGenericWithAnimate) なら skip。
2953
+ // doc に phase 指定があって diagram.phases が空なら、 preset 由来 lane/node/edge に対して generic phase を注入する。
2954
+ if (doc.animate && doc.animate.phases.length > 0 && diagram.phases.length === 0) {
2955
+ injectPhasesFallback(diagram, doc);
2956
+ }
2957
+ // top-level lanes section → lane merge
2958
+ if (doc.lanes) {
2959
+ for (const [id, laneOpt] of Object.entries(doc.lanes)) {
2960
+ const lane = diagram.lanes.find((l) => l.id === id);
2961
+ if (lane) {
2962
+ if (laneOpt.x !== undefined) lane.x = laneOpt.x;
2963
+ if (laneOpt.width !== undefined) lane.width = laneOpt.width;
2964
+ if (laneOpt.label !== undefined) lane.label = laneOpt.label;
2965
+ if (laneOpt.contain !== undefined) lane.contain = laneOpt.contain;
2966
+ if (laneOpt.lifeline !== undefined) lane.lifeline = laneOpt.lifeline;
2967
+ } else {
2968
+ // lane が preset で作られていなければ新規追加
2969
+ diagram.lanes.push({
2970
+ id,
2971
+ x: laneOpt.x ?? 0,
2972
+ width: laneOpt.width ?? 320,
2973
+ label: laneOpt.label,
2974
+ contain: laneOpt.contain,
2975
+ lifeline: laneOpt.lifeline,
2976
+ });
2977
+ }
2978
+ }
2979
+ }
2980
+ // viewport.laneWidth → 全 lane width に override
2981
+ if (doc.viewport?.laneWidth !== undefined) {
2982
+ for (const lane of diagram.lanes) {
2983
+ lane.width = doc.viewport.laneWidth;
2984
+ }
2985
+ }
2986
+ // viewport.width / height / gap / laneGap / nodeGap / scale / labelMargin → CdlDiagram.viewport に集約
2987
+ if (doc.viewport) {
2988
+ diagram.viewport = {
2989
+ ...(diagram.viewport ?? {}),
2990
+ ...(doc.viewport.width !== undefined ? { width: doc.viewport.width } : {}),
2991
+ ...(doc.viewport.height !== undefined ? { height: doc.viewport.height } : {}),
2992
+ ...(doc.viewport.gap !== undefined ? { gap: doc.viewport.gap } : {}),
2993
+ ...(doc.viewport.laneGap !== undefined ? { laneGap: doc.viewport.laneGap } : {}),
2994
+ ...(doc.viewport.nodeGap !== undefined ? { nodeGap: doc.viewport.nodeGap } : {}),
2995
+ ...(doc.viewport.scale !== undefined ? { scale: doc.viewport.scale } : {}),
2996
+ ...(doc.viewport.labelMargin !== undefined ? { labelMargin: doc.viewport.labelMargin } : {}),
2997
+ };
2998
+ }
2999
+ return diagram;
3000
+ }
3001
+
3002
+ /**
3003
+ * v0.5+ animation phase 後段 fallback 注入 (CAR-1657)。
3004
+ *
3005
+ * class / pie / c4 / mind / gantt preset は独自 layout を持ち、 compileGenericWithAnimate 経路に
3006
+ * 乗らないため、 doc.animate.phases があっても diagram.phases が空になる。
3007
+ * 本 helper が applyV05Extensions から呼ばれて post-hoc に phase を差込む、 lane/node/edge は
3008
+ * 既存 preset 出力を保持したまま animation だけ追加する。
3009
+ *
3010
+ * highlight resolution = actor 名 = slugify → diagram.nodes.id 対応、 edge の "A -> B" は
3011
+ * from/to の slug で 1:1 対応する edge を検索。 preset 由来 edge id は各種 (`e-{from}-{to}` /
3012
+ * `e{idx}-{fromId}-{toId}` 等) 揺れがあるため、 (edge.from === slug(A) && edge.to === slug(B))
3013
+ * で辞書 lookup せず走査で解決する。
3014
+ */
3015
+ function injectPhasesFallback(diagram: CdlDiagram, doc: DslDocument): void {
3016
+ if (!doc.animate) return;
3017
+
3018
+ // states 反映 (未登録なら追加、 既存は上書きしない)。 CdlState は id field (name ではない)。
3019
+ const existingStateIds = new Set(diagram.states.map((s) => s.id));
3020
+ for (const st of doc.animate.states) {
3021
+ if (!existingStateIds.has(st.name)) {
3022
+ diagram.states.push({ id: st.name, initial: st.initial });
3023
+ }
3024
+ }
3025
+
3026
+ // highlight 解決関数 = actor 名 or "A -> B" / "A → B" を node.id / edge.id に変換。
3027
+ // codex-review CAR-1659 MAJOR fix = 全角矢印 `→` を対応 (generic 経路との互換)、
3028
+ // 同 from/to で複数 edge がある場合は全件 activate (`.find` → filter loop)。
3029
+ // 実在する名前。 矢印を含む名前 (`"A -> B"`) を矢印と読み違えないために渡す
3030
+ const knownNames = new Set(doc.actors.map((a) => a.name));
3031
+ // 図全体を 1 つの箱で描く種類は、 登場人物ごとの箱を持たない。
3032
+ // 箱が 1 つの時だけ対象にする = 2 つ以上あるとどれを指したのか決められない
3033
+ const singleBoxNodes = diagram.nodes.filter((n) => SINGLE_BOX_KINDS.has(String(n.kind)));
3034
+ const singleBoxNode = singleBoxNodes.length === 1 ? singleBoxNodes[0] : undefined;
3035
+ const resolveIds = (highlight: readonly string[]): string[] => {
3036
+ const out: string[] = [];
3037
+ for (const h of highlight) {
3038
+ const entry = parseFocusEntry(h, knownNames);
3039
+ if (entry.kind === "edge") {
3040
+ const fromSlug = slugify(entry.from);
3041
+ const toSlug = slugify(entry.to);
3042
+ let 見つかった = false;
3043
+ for (const e of diagram.edges) {
3044
+ if (e.from === fromSlug && e.to === toSlug) {
3045
+ out.push(e.id);
3046
+ 見つかった = true;
3047
+ }
3048
+ }
3049
+ // 線を持たない種類 (帯の依存等) では矢印が edge にならない。 両端が実在するなら
3050
+ // その箱を光らせる = 矢印を指した段で何も光らないより意図に近い (#1077)
3051
+ if (!見つかった && singleBoxNode !== undefined && knownNames.has(entry.from) && knownNames.has(entry.to)) {
3052
+ out.push(singleBoxNode.id);
3053
+ }
3054
+ continue;
3055
+ }
3056
+ const nodeSlug = slugify(entry.name);
3057
+ const node = diagram.nodes.find((n) => n.id === nodeSlug || n.id === `${nodeSlug}-header`);
3058
+ if (node) {
3059
+ out.push(node.id);
3060
+ continue;
3061
+ }
3062
+ // 図全体が 1 つの箱になる種類 (円グラフ等) では、 登場人物ごとの箱が無い。 名前で
3063
+ // 指しても解決できず、 書いた `focus:` が丸ごと消える (#1076 で pie を 1 箱にした時に
3064
+ // 発生)。 **書いた名前が実在するなら、 その箱を光らせる** = 何も光らないより意図に近い。
3065
+ // 実在しない名前は従来どおり無視する (綴り誤りを黙って光らせない)
3066
+ if (knownNames.has(entry.name) && singleBoxNode !== undefined) out.push(singleBoxNode.id);
3067
+ }
3068
+ // 同じ箱を複数回指した時に重複させない (円グラフで 4 人を指すと 4 回入る)
3069
+ return [...new Set(out)];
3070
+ };
3071
+
3072
+ // phase 注入。 CdlPhase.tweens[].stateId / sets[].stateId で state 参照 (state ではない)。
3073
+ for (const p of doc.animate.phases) {
3074
+ const activateIds: string[] = [...resolveIds(p.highlight ?? [])];
3075
+ diagram.phases.push({
3076
+ id: slugify(p.name) || p.name,
3077
+ duration: p.durationMs,
3078
+ title: p.name,
3079
+ body: p.body ?? "",
3080
+ activate: activateIds,
3081
+ tweens: (p.tweens ?? []).map((t) => ({ stateId: t.state, from: t.from, to: t.to })),
3082
+ sets: (p.sets ?? []).map((s) => ({ stateId: s.state, value: s.value })),
3083
+ ...(p.badge ? { badge: p.badge } : {}),
3084
+ });
3085
+ }
3086
+ }
3087
+
3088
+ function compileSequence(doc: DslDocument): CdlDiagram {
3089
+ // v0.3 ... アニメーション 有無で経路を分岐。
3090
+ // 有り = builder 直接経路で state / 複数 phase を注入。
3091
+ // 無し = v0.2 と同じく sequence preset の標準 phase を採用。
3092
+ if (doc.animate && doc.animate.phases.length > 0) {
3093
+ return compileSequenceWithAnimate(doc);
3094
+ }
3095
+ const seqBuilder = sequence({
3096
+ id: slugify(doc.title),
3097
+ topic: doc.title,
3098
+ actors: doc.actors.map((a) => a.name),
3099
+ });
3100
+ for (const s of doc.flow) {
3101
+ seqBuilder.step({
3102
+ from: s.from,
3103
+ to: s.to,
3104
+ label: s.label,
3105
+ ...(s.sub ? { sub: s.sub } : {}),
3106
+ ...(s.tone ? { tone: s.tone } : {}),
3107
+ ...(s.style ? { style: s.style } : {}),
3108
+ });
3109
+ }
3110
+ return seqBuilder.build();
3111
+ }
3112
+
3113
+ /**
3114
+ * v0.3 ... アニメーション full compile (sequence 向け)。
3115
+ * sequence preset と同じ構造 (lane / header / spacer / step box / footer) を builder 直接で組み立て、
3116
+ * 標準の 1 phase を DSL の複数 phase に置き換える。
3117
+ *
3118
+ * 標準 phase 1 個 → DSL phases N 個に展開。
3119
+ * state / tween / set / badge / body / highlight 全反映。
3120
+ */
3121
+ function compileSequenceWithAnimate(doc: DslDocument): CdlDiagram {
3122
+ const b = diagram(slugify(doc.title), { topic: doc.title });
3123
+ const laneW = 340;
3124
+
3125
+ // actor 名 → lane id / header / footer / spacer の slug 生成 (sequence preset と整合)
3126
+ const actorIds = new Map<string, string>();
3127
+ const headerNodeIds: string[] = [];
3128
+ doc.actors.forEach((a, i) => {
3129
+ const id = slugify(a.name) || `actor-${i}`;
3130
+ actorIds.set(a.name, id);
3131
+ actorIds.set(id, id);
3132
+ // canvas pivot 新 spec = actor.posX/posY set 済なら CDL layout skip 経路に流す。
3133
+ // sequence preset の lane はここで生成、 posW/posH は lane 全体の rect を上書き。
3134
+ const laneOpts: Parameters<typeof b.lane>[1] = { width: laneW, label: a.name, lifeline: true };
3135
+ if (a.posX !== undefined && a.posY !== undefined) {
3136
+ laneOpts.posX = a.posX;
3137
+ laneOpts.posY = a.posY;
3138
+ if (a.posW !== undefined) laneOpts.posW = a.posW;
3139
+ if (a.posH !== undefined) laneOpts.posH = a.posH;
3140
+ }
3141
+ b.lane(id, laneOpts);
3142
+ const headerId = `${id}-header`;
3143
+ // header/footer 幅を title 長に応じて auto-size (text-readability warning 解消)。
3144
+ // formula = 22px/char + 52px padding (visualValidate text-readability と完全一致)、 min 140 で従来 sample 互換維持。
3145
+ const actorW = Math.max(140, a.name.length * 22 + 52);
3146
+ b.node(headerId, { lane: id, stack: 0, kind: "card", title: a.name, w: actorW, h: 72 });
3147
+ headerNodeIds.push(headerId);
3148
+ const spacerId = `${id}-spacer`;
3149
+ b.node(spacerId, { lane: id, stack: 1, kind: "card", title: "", w: 2, h: 40 });
3150
+ });
3151
+
3152
+ // step boxes (sequence preset と同じ命名 ... `s${idx}-${laneId}` / `e${idx}-${from}-${to}`)
3153
+ // step ごとに DSL flow item に対応、 actor 名 → lane id の slugify を活用。
3154
+ const stepEdgeIds: string[] = [];
3155
+ doc.flow.forEach((s, idx) => {
3156
+ const fromLaneId = actorIds.get(s.from) ?? s.from;
3157
+ const toLaneId = actorIds.get(s.to) ?? s.to;
3158
+ const stack = idx + 2;
3159
+ const fromBoxId = `s${idx}-${fromLaneId}`;
3160
+ const toBoxId = `s${idx}-${toLaneId}`;
3161
+ b.node(fromBoxId, { lane: fromLaneId, stack, kind: "card", title: "", w: 2, h: 2 });
3162
+ if (fromLaneId !== toLaneId) {
3163
+ b.node(toBoxId, { lane: toLaneId, stack, kind: "card", title: "", w: 2, h: 2 });
3164
+ }
3165
+ const edgeId = `e${idx}-${fromLaneId}-${toLaneId}`;
3166
+ b.edge(fromBoxId, fromLaneId === toLaneId ? fromBoxId : toBoxId, {
3167
+ id: edgeId,
3168
+ label: s.label,
3169
+ ...(s.sub ? { sub: s.sub } : {}),
3170
+ ...(s.tone ? { tone: s.tone } : {}),
3171
+ ...(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
+ });
3177
+ stepEdgeIds.push(edgeId);
3178
+ });
3179
+
3180
+ // footer (sequence preset と整合)
3181
+ const footerStack = doc.flow.length + 2;
3182
+ doc.actors.forEach((a) => {
3183
+ const laneId = actorIds.get(a.name) ?? slugify(a.name);
3184
+ const footerId = `${laneId}-footer`;
3185
+ 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 });
3187
+ });
3188
+
3189
+ // state を builder に登録
3190
+ for (const st of doc.animate!.states) {
3191
+ b.state(st.name, { initial: st.initial });
3192
+ }
3193
+
3194
+ // phase を順次注入 ... highlight / tween / set / badge / body 全反映
3195
+ for (const p of doc.animate!.phases) {
3196
+ b.phase(
3197
+ slugify(p.name) || p.name,
3198
+ {
3199
+ duration: p.durationMs,
3200
+ title: p.name,
3201
+ body: p.body ?? "",
3202
+ },
3203
+ (pb) => {
3204
+ // highlight ... DSL の name (actor 名 or "from→to") を実 id に解決
3205
+ const activateIds = resolveHighlight(p, doc, actorIds, stepEdgeIds);
3206
+ if (activateIds.length > 0) {
3207
+ pb.activate(...activateIds);
3208
+ }
3209
+ // tween
3210
+ for (const t of p.tweens ?? []) {
3211
+ pb.tween(t.state, t.from, t.to);
3212
+ }
3213
+ // set
3214
+ for (const s of p.sets ?? []) {
3215
+ pb.set(s.state, s.value);
3216
+ }
3217
+ // badge
3218
+ if (p.badge) {
3219
+ pb.badge(p.badge);
3220
+ }
3221
+ return pb;
3222
+ },
3223
+ );
3224
+ }
3225
+
3226
+ return b.build();
3227
+ }
3228
+
3229
+ /**
3230
+ * DSL の highlight item (actor 名 or "A→B" or "A-B" 等) を実 node/edge id に解決する。
3231
+ */
3232
+ function resolveHighlight(
3233
+ phase: DslPhase,
3234
+ doc: DslDocument,
3235
+ actorIds: Map<string, string>,
3236
+ _stepEdgeIds: string[],
3237
+ ): string[] {
3238
+ const out: string[] = [];
3239
+ const knownNames = new Set(actorIds.keys());
3240
+ for (const raw of phase.highlight ?? []) {
3241
+ const entry = parseFocusEntry(raw, knownNames);
3242
+ // 矢印つき → 該当 step edge を全部探して active
3243
+ if (entry.kind === "edge") {
3244
+ const fromLaneId = actorIds.get(entry.from) ?? slugify(entry.from);
3245
+ const toLaneId = actorIds.get(entry.to) ?? slugify(entry.to);
3246
+ // 該当 edge を flow から検索
3247
+ doc.flow.forEach((s, idx) => {
3248
+ const sFromId = actorIds.get(s.from) ?? slugify(s.from);
3249
+ const sToId = actorIds.get(s.to) ?? slugify(s.to);
3250
+ if (sFromId === fromLaneId && sToId === toLaneId) {
3251
+ out.push(`e${idx}-${fromLaneId}-${toLaneId}`);
3252
+ }
3253
+ });
3254
+ // 関連する step box も active 化
3255
+ const stackIdx = doc.flow.findIndex((s) => {
3256
+ const sFromId = actorIds.get(s.from) ?? slugify(s.from);
3257
+ const sToId = actorIds.get(s.to) ?? slugify(s.to);
3258
+ return sFromId === fromLaneId && sToId === toLaneId;
3259
+ });
3260
+ if (stackIdx >= 0) {
3261
+ out.push(`s${stackIdx}-${fromLaneId}`);
3262
+ if (fromLaneId !== toLaneId) out.push(`s${stackIdx}-${toLaneId}`);
3263
+ }
3264
+ continue;
3265
+ }
3266
+ // actor 名 → header + footer + 全 step box を active
3267
+ const laneId = actorIds.get(entry.name) ?? slugLookup(actorIds, entry.name);
3268
+ if (laneId) {
3269
+ out.push(`${laneId}-header`);
3270
+ out.push(`${laneId}-footer`);
3271
+ // この lane の全 step box
3272
+ doc.flow.forEach((s, idx) => {
3273
+ const sFromId = actorIds.get(s.from) ?? slugify(s.from);
3274
+ const sToId = actorIds.get(s.to) ?? slugify(s.to);
3275
+ if (sFromId === laneId || sToId === laneId) {
3276
+ out.push(`s${idx}-${laneId}`);
3277
+ }
3278
+ });
3279
+ }
3280
+ }
3281
+ return out;
3282
+ }
3283
+
3284
+ /**
3285
+ * 名前が見つからない時に、 slug の形でも探す。
3286
+ *
3287
+ * 記法は表示名で書くが、 書く人は id の形 (`api-gateway`) で書くこともある。 図種によって
3288
+ * 受理する / しないが分かれると、 同じ記述が別の意味になる。
3289
+ *
3290
+ * 2 つ以上の名前が同じ slug になる時は解決しない。 どちらを指したか決められないため、
3291
+ * 黙ってどちらかを選ぶより光らせない方が書いた人が気付ける。
3292
+ */
3293
+ function slugLookup(byName: ReadonlyMap<string, string>, wanted: string): string | undefined {
3294
+ let hit: string | undefined;
3295
+ for (const [name, id] of byName) {
3296
+ if (slugify(name) !== wanted) continue;
3297
+ if (hit !== undefined) return undefined;
3298
+ hit = id;
3299
+ }
3300
+ return hit;
3301
+ }
3302
+
3303
+ function compileFlow(doc: DslDocument): CdlDiagram {
3304
+ // v0.4 ... animation あり時 builder 直接経路で複数 phase 注入
3305
+ if (doc.animate && doc.animate.phases.length > 0) {
3306
+ return compileGenericWithAnimate(doc, { kind: "flow", laneId: "main", laneWidth: 400 });
3307
+ }
3308
+ // 登場人物が 0 人なら枠も作らない。 描画側の `flow()` は枠を必ず 1 つ作るため、 そのまま
3309
+ // 通すと中身の無い枠が残る (実測 = `title` と `type` だけの本文で枠 `flow` が空)。
3310
+ // 枠を持たない図として返す = `swimlane` / `c4` が 0 人で枠 0 になるのと揃う
3311
+ if (doc.actors.length === 0) {
3312
+ return diagram(slugify(doc.title), { topic: doc.title }).build();
3313
+ }
3314
+ // flow preset は actors を順に step として配置、 step 間に edge auto
3315
+ const flowBuilder = flow({
3316
+ id: slugify(doc.title),
3317
+ topic: doc.title,
3318
+ });
3319
+ // 各 actor を step として登録、 edge label は流れ から拾う
3320
+ for (let i = 0; i < doc.actors.length; i++) {
3321
+ const a = doc.actors[i]!;
3322
+ // 直前の step との edge label = この actor を to に持つ flow から拾う
3323
+ const incomingEdge = doc.flow.find((s) => s.to === a.name);
3324
+ const edgeLabel = incomingEdge?.label;
3325
+ flowBuilder.step(
3326
+ {
3327
+ id: slugify(a.name) || `n${i}`,
3328
+ kind: a.kind,
3329
+ title: a.name,
3330
+ },
3331
+ edgeLabel,
3332
+ );
3333
+ }
3334
+ return flowBuilder.build();
3335
+ }
3336
+
3337
+ function compileSwimlane(doc: DslDocument): CdlDiagram {
3338
+ // v0.4 ... animation あり時 builder 直接経路 (各 actor 別 lane で配置)
3339
+ if (doc.animate && doc.animate.phases.length > 0) {
3340
+ return compileGenericWithAnimate(doc, { kind: "swimlane", laneWidth: 400 });
3341
+ }
3342
+ // swimlane preset は lane 配置 + 自由 node/edge。
3343
+ // v0.2 では actors を lane 化、 流れ の各 step から node を生成、 edge を引く。
3344
+ const swim = swimlane({
3345
+ id: slugify(doc.title),
3346
+ topic: doc.title,
3347
+ lanes: doc.actors.map((a) => a.name),
3348
+ });
3349
+
3350
+ // 各 step で from / to の node を lane 内 stack 配置
3351
+ const placedNodes = new Set<string>();
3352
+ const laneStackCount = new Map<string, number>();
3353
+ let edgeIdx = 0;
3354
+
3355
+ for (const s of doc.flow) {
3356
+ for (const actorName of [s.from, s.to]) {
3357
+ if (placedNodes.has(actorName)) continue;
3358
+ const laneId = swim.laneId(actorName);
3359
+ const actor = doc.actors.find((a) => a.name === actorName);
3360
+ const stack = laneStackCount.get(laneId) ?? 0;
3361
+ const nodeId = slugify(actorName) || `n${placedNodes.size}`;
3362
+ swim.node(nodeId, {
3363
+ lane: laneId,
3364
+ stack,
3365
+ kind: actor?.kind ?? "actor",
3366
+ title: actorName,
3367
+ });
3368
+ laneStackCount.set(laneId, stack + 1);
3369
+ placedNodes.add(actorName);
3370
+ }
3371
+ const fromId = slugify(s.from);
3372
+ const toId = slugify(s.to);
3373
+ swim.edge(fromId, toId, {
3374
+ id: `e${edgeIdx++}-${fromId}-${toId}`,
3375
+ label: s.label,
3376
+ ...(s.sub ? { sub: s.sub } : {}),
3377
+ ...(s.tone ? { tone: s.tone } : {}),
3378
+ ...(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 } : {}),
3383
+ });
3384
+ }
3385
+ return swim.build();
3386
+ }
3387
+
3388
+ function compileEr(doc: DslDocument): CdlDiagram {
3389
+ // v0.4 ... animation あり時 builder 直接経路 (entity を box として配置)
3390
+ if (doc.animate && doc.animate.phases.length > 0) {
3391
+ // 460 は preset 側の旧既定に合わせた値だった。 preset が箱 400 + 余白 25 × 2 = 450 を
3392
+ // 宣言するようになった (cardene777/cdl#359) ので、 同じ図が animate の有無で 10 world
3393
+ // ずれないようここも 450 にする。
3394
+ return compileGenericWithAnimate(doc, { kind: "er", laneWidth: 450 });
3395
+ }
3396
+ // er preset ... actors を entity に、 流れ を relation に
3397
+ const erBuilder = er({
3398
+ id: slugify(doc.title),
3399
+ topic: doc.title,
3400
+ });
3401
+ for (const a of doc.actors) {
3402
+ // entity rows は DSL では宣言できないので、 actor 名のみ entity 化
3403
+ // v0.3 で「列定義」 ブロックを追加検討
3404
+ erBuilder.entity({
3405
+ id: slugify(a.name) || a.name,
3406
+ title: a.name,
3407
+ rows: [], // v0.2 では rows なし
3408
+ });
3409
+ }
3410
+ for (const s of doc.flow) {
3411
+ erBuilder.relation({
3412
+ from: slugify(s.from),
3413
+ to: slugify(s.to),
3414
+ cardinality: parseCardinalityFromLabel(s.label) ?? "1:N",
3415
+ label: stripCardinality(s.label),
3416
+ ...(s.tone ? { tone: s.tone } : {}),
3417
+ });
3418
+ }
3419
+ return erBuilder.build();
3420
+ }
3421
+
3422
+ function compileState(doc: DslDocument): CdlDiagram {
3423
+ // v0.4 ... animation あり時 builder 直接経路 (各 state を lane で配置、 transition を edge)
3424
+ if (doc.animate && doc.animate.phases.length > 0) {
3425
+ return compileGenericWithAnimate(doc, { kind: "state", laneWidth: 360 });
3426
+ }
3427
+ // stateMachine preset ... actors を state に、 流れ を transition に
3428
+ const fsm = stateMachine({
3429
+ id: slugify(doc.title),
3430
+ topic: doc.title,
3431
+ });
3432
+ for (let i = 0; i < doc.actors.length; i++) {
3433
+ 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;
3437
+ fsm.state({
3438
+ id: slugify(a.name) || `s${i}`,
3439
+ title: a.name,
3440
+ ...(initial ? { initial: true } : {}),
3441
+ ...(final ? { final: true } : {}),
3442
+ });
3443
+ }
3444
+ for (const s of doc.flow) {
3445
+ fsm.transition({
3446
+ from: slugify(s.from),
3447
+ to: slugify(s.to),
3448
+ trigger: s.label,
3449
+ ...(s.sub ? { guard: s.sub } : {}),
3450
+ ...(s.tone ? { tone: s.tone } : {}),
3451
+ });
3452
+ }
3453
+ return fsm.build();
3454
+ }
3455
+
3456
+ function compileTopology(doc: DslDocument): CdlDiagram {
3457
+ // v0.4 ... animation あり時 builder 直接経路 (各 actor を別 lane に)
3458
+ if (doc.animate && doc.animate.phases.length > 0) {
3459
+ return compileGenericWithAnimate(doc, { kind: "topology", laneWidth: 460 });
3460
+ }
3461
+ // 登場人物が 0 人なら枠も作らない。 描画側の `topology()` は枠を必ず 1 つ作るため、 そのまま
3462
+ // 通すと中身の無い枠が残る (#1096)
3463
+ if (doc.actors.length === 0) {
3464
+ return diagram(slugify(doc.title), { topic: doc.title }).build();
3465
+ }
3466
+ // topology preset ... actors を 1 つの group 内 container として配置
3467
+ // v0.3 で「group」 ブロックを追加して複数 group 対応検討
3468
+ const topo = topology({
3469
+ id: slugify(doc.title),
3470
+ topic: doc.title,
3471
+ });
3472
+ const groupId = "main";
3473
+ const groupBuilder = topo.group(groupId, { label: doc.title });
3474
+ for (const a of doc.actors) {
3475
+ groupBuilder.add({
3476
+ id: slugify(a.name) || a.name,
3477
+ kind: a.kind,
3478
+ title: a.name,
3479
+ });
3480
+ }
3481
+ for (const s of doc.flow) {
3482
+ topo.connect(slugify(s.from), slugify(s.to), {
3483
+ label: s.label,
3484
+ ...(s.sub ? { sub: s.sub } : {}),
3485
+ ...(s.tone ? { tone: s.tone } : {}),
3486
+ ...(s.style ? { style: s.style } : {}),
3487
+ });
3488
+ }
3489
+ return topo.build();
3490
+ }
3491
+
3492
+ /**
3493
+ * v0.4 ... 5 preset (flow / swimlane / er / state / topology) 共通 animation compile。
3494
+ * sequence preset と異なり header / footer / step box 構造はない、 シンプルな lane + node + edge 構造。
3495
+ * preset kind ごとに lane 配置と layout を切替。
3496
+ */
3497
+ type GenericKind = "flow" | "swimlane" | "er" | "state" | "topology";
3498
+
3499
+ type GenericOpts = {
3500
+ kind: GenericKind;
3501
+ /** flow / topology は 1 lane に全 actor、 swimlane / state は actor ごと lane */
3502
+ laneId?: string;
3503
+ laneWidth: number;
3504
+ };
3505
+
3506
+ function compileGenericWithAnimate(doc: DslDocument, opts: GenericOpts): CdlDiagram {
3507
+ const b = diagram(slugify(doc.title), { topic: doc.title });
3508
+ const { kind, laneWidth } = opts;
3509
+
3510
+ // lane / node 配置 ... preset kind に応じて切替
3511
+ const actorToNodeId = new Map<string, string>();
3512
+ if (kind === "flow" || kind === "topology") {
3513
+ // 1 lane に全 actor を縦 stack
3514
+ const lid = opts.laneId ?? "main";
3515
+ b.lane(lid, { width: laneWidth, label: doc.title, ...(kind === "topology" ? { contain: true } : {}) });
3516
+ doc.actors.forEach((a, idx) => {
3517
+ const id = slugify(a.name) || `n${idx}`;
3518
+ actorToNodeId.set(a.name, id);
3519
+ b.node(id, { lane: lid, stack: idx, kind: a.kind, title: a.name });
3520
+ });
3521
+ } else {
3522
+ // swimlane / er / state ... actor ごとに 1 lane (横並び)
3523
+ doc.actors.forEach((a, idx) => {
3524
+ const lid = `lane-${slugify(a.name) || idx}`;
3525
+ b.lane(lid, { width: laneWidth, label: a.name });
3526
+ const id = slugify(a.name) || `n${idx}`;
3527
+ actorToNodeId.set(a.name, id);
3528
+ // 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;
3531
+ b.node(id, {
3532
+ lane: lid,
3533
+ stack: 0,
3534
+ kind: a.kind,
3535
+ title: a.name,
3536
+ ...(isInitial ? { eyebrow: "初期" } : {}),
3537
+ ...(isFinal ? { eyebrow: "最終" } : {}),
3538
+ });
3539
+ });
3540
+ }
3541
+
3542
+ // edge ... flow の各 step を edge として登録
3543
+ const edgeIds: string[] = [];
3544
+ 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);
3547
+ const edgeId = `e${idx}-${fromId}-${toId}`;
3548
+ // ER preset では cardinality を label に "(1:N)" 形式で併記、 他 preset は label そのまま。
3549
+ const labelWithCard =
3550
+ kind === "er" && s.cardinality && !s.label.includes(s.cardinality)
3551
+ ? s.label
3552
+ ? `${s.label} (${s.cardinality})`
3553
+ : `(${s.cardinality})`
3554
+ : s.label;
3555
+ b.edge(fromId, toId, {
3556
+ id: edgeId,
3557
+ label: labelWithCard,
3558
+ ...(s.sub ? { sub: s.sub } : {}),
3559
+ ...(s.tone ? { tone: s.tone } : {}),
3560
+ ...(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
+ });
3566
+ edgeIds.push(edgeId);
3567
+ });
3568
+
3569
+ // state 登録
3570
+ for (const st of doc.animate!.states) {
3571
+ b.state(st.name, { initial: st.initial });
3572
+ }
3573
+
3574
+ // phase 注入
3575
+ for (const p of doc.animate!.phases) {
3576
+ b.phase(
3577
+ slugify(p.name) || p.name,
3578
+ {
3579
+ duration: p.durationMs,
3580
+ title: p.name,
3581
+ body: p.body ?? "",
3582
+ },
3583
+ (pb) => {
3584
+ const activateIds = resolveHighlightGeneric(p, doc, actorToNodeId, edgeIds);
3585
+ if (activateIds.length > 0) {
3586
+ pb.activate(...activateIds);
3587
+ }
3588
+ for (const t of p.tweens ?? []) {
3589
+ pb.tween(t.state, t.from, t.to);
3590
+ }
3591
+ for (const s of p.sets ?? []) {
3592
+ pb.set(s.state, s.value);
3593
+ }
3594
+ if (p.badge) {
3595
+ pb.badge(p.badge);
3596
+ }
3597
+ return pb;
3598
+ },
3599
+ );
3600
+ }
3601
+
3602
+ return b.build();
3603
+ }
3604
+
3605
+ /**
3606
+ * 5 preset 共通 ... highlight item (actor 名 or "A→B") を node/edge id に解決。
3607
+ * sequence と異なり header/footer/step box はないのでシンプル。
3608
+ */
3609
+ function resolveHighlightGeneric(
3610
+ phase: DslPhase,
3611
+ doc: DslDocument,
3612
+ actorToNodeId: Map<string, string>,
3613
+ edgeIds: string[],
3614
+ ): string[] {
3615
+ const out: string[] = [];
3616
+ const knownNames = new Set(actorToNodeId.keys());
3617
+ for (const raw of phase.highlight ?? []) {
3618
+ const entry = parseFocusEntry(raw, knownNames);
3619
+ // 矢印あり → edge を特定
3620
+ if (entry.kind === "edge") {
3621
+ const fromId = actorToNodeId.get(entry.from) ?? slugify(entry.from);
3622
+ const toId = actorToNodeId.get(entry.to) ?? slugify(entry.to);
3623
+ // edge id は `e{idx}-{fromId}-{toId}` の形。 末尾一致で見る。
3624
+ // 部分一致で見ると、 名前に `-` を含む箱 (`api-gateway`) の id が別の矢印の id に
3625
+ // 混ざって当たる (実測 = 箱を光らせたい指定で矢印が光った)
3626
+ for (const edgeId of edgeIds) {
3627
+ if (edgeId.endsWith(`-${fromId}-${toId}`)) {
3628
+ out.push(edgeId);
3629
+ }
3630
+ }
3631
+ continue;
3632
+ }
3633
+ // actor 名 → node id。 見つからなければ slug の形でも探す。
3634
+ // 順序図だけが slug を受理する状態にすると、 同じ記述が図種で別の意味になる
3635
+ // (実測 = `api-gateway` が順序図では光り、 流れ図では何も光らなかった)
3636
+ const nodeId = actorToNodeId.get(entry.name) ?? slugLookup(actorToNodeId, entry.name);
3637
+ if (nodeId) {
3638
+ out.push(nodeId);
3639
+ }
3640
+ }
3641
+ return out;
3642
+ }
3643
+
3644
+ // ─── helpers ──────────────────────────────────────────────────
3645
+
3646
+ function slugify(s: string): string {
3647
+ return (
3648
+ s
3649
+ .toLowerCase()
3650
+ .normalize("NFKC")
3651
+ .replace(/[^a-z0-9ぁ-んァ-ヶ一-龯\-_]+/g, "-")
3652
+ .replace(/^-+|-+$/g, "")
3653
+ .slice(0, 64) || "n"
3654
+ );
3655
+ }
3656
+
3657
+ const CARDINALITY_PATTERNS: Array<[RegExp, ErRelationCardinality]> = [
3658
+ [/1:1/, "1:1"],
3659
+ [/1:N/i, "1:N"],
3660
+ [/N:1/i, "N:1"],
3661
+ [/N:M/i, "N:M"],
3662
+ [/0\.\.1/, "0..1"],
3663
+ [/1\.\.\*/, "1..*"],
3664
+ ];
3665
+
3666
+ // cardinality token を「単語の途中でない」 境界で囲んだ RegExp を作る (parse / strip で共有する SSOT)。
3667
+ // 前後が identifier 文字 (英数字 + アンダースコア) なら token とみなさない = `column:Metadata` の `n:M` /
3668
+ // `10:11:12` の `1:1` / `field_1:N` の `1:N` を cardinality と誤認して壊すのを防ぐ
3669
+ // (cc-codex #879 Round 9/10/11)。 `_` を含むのは ER label が DB schema 由来で snake_case 命名が多く、
3670
+ // `_` 直後に cardinality 様の部分列が来る label が現実的に起こるため (`field_1:N` / `parent_N:M_child`)。
3671
+ // strip と parse で別々に pattern.test / replace すると境界規則が drift するため、 この 1 関数を両経路で使う。
3672
+ function boundedCardinalityRegExp(pattern: RegExp, extraFlags = ""): RegExp {
3673
+ const base = pattern.flags.includes("i") ? "i" : "";
3674
+ return new RegExp(`(?<![A-Za-z0-9_])(?:${pattern.source})(?![A-Za-z0-9_])`, base + extraFlags);
3675
+ }
3676
+
3677
+ function parseCardinalityFromLabel(label: string): ErRelationCardinality | null {
3678
+ for (const [pattern, card] of CARDINALITY_PATTERNS) {
3679
+ if (boundedCardinalityRegExp(pattern).test(label)) return card;
3680
+ }
3681
+ return null;
3682
+ }
3683
+
3684
+ // stripCardinality が「水平空白」 として畳んでよい文字を明示列挙する (space / tab / 全角空白 U+3000)。
3685
+ // 改行系 (LF / CR / U+2028 line separator / U+2029 paragraph separator / vertical tab / form feed) は
3686
+ // 含めない = これらは label の行構造として保持する (cc-codex #879 Round 5/6 指摘 = `\s` / `[^\S\r\n]`
3687
+ // では Unicode 行区切りや CRLF を誤って畳んでしまう)。 括弧除去側と正規化側で同じ class を共有する。
3688
+ const HORIZONTAL_WS = " \\t\\u3000";
3689
+ const HWS = `[${HORIZONTAL_WS}]`;
3690
+
3691
+ function stripCardinality(label: string): string {
3692
+ let r = label;
3693
+ let removed = false;
3694
+ for (const [pattern] of CARDINALITY_PATTERNS) {
3695
+ // cardinality token を「それを囲む括弧ごと 1 単位」 で除去する。
3696
+ // まず `(1:N)` のように token を直接包む括弧つき形を除去し、 次に裸の token を除去する。
3697
+ // 括弧を token 単位で消すことで、 label 中の cardinality と無関係な正当な括弧 (例
3698
+ // `fn() now` の `()`) を壊さない (cc-codex #879 Round 4 指摘 = 空括弧の全域除去は過剰)。
3699
+ // 括弧と token の間は水平空白のみ許容し、 改行を挟む形 (`(\n1:N\n)`) は括弧除去の対象外にする
3700
+ // (改行を消費して行構造を壊すのを防ぐ、 Round 6 Finding 2)。
3701
+ const src = pattern.source;
3702
+ const flags = pattern.flags.includes("i") ? "gi" : "g";
3703
+ const before = r;
3704
+ r = r.replace(new RegExp(`\\(${HWS}*${src}${HWS}*\\)`, flags), "");
3705
+ // 裸 token 除去 = parse と同じ単語境界付き matcher (boundedCardinalityRegExp) を global で適用する。
3706
+ // 前後が英数字なら token とみなさないため、 timestamp (`10:11:12`) / 比率 (`10:11`) / alphabet 埋め込み
3707
+ // (`column:Metadata`) を壊さず、 同一 token の複数出現 (`1:N and 1:N`) は全て消す。 parse 側と境界規則を
3708
+ // 単一 SSOT にすることで strip/parse の乖離 (strip は消すが parse は残す等) を構造的に防ぐ
3709
+ // (cc-codex #879 Round 9/10 = 数字境界だけ / strip 側だけの修正では 2 経路 drift + alphabet 埋め込み穴)。
3710
+ r = r.replace(boundedCardinalityRegExp(pattern, "g"), "");
3711
+ if (r !== before) removed = true;
3712
+ }
3713
+ // token を除去していない label は空白を一切いじらない (無条件適用でも改行 / 複数空白を保持する、
3714
+ // cc-codex #879 Round 5 指摘 = 無条件正規化は改行を含む label を破壊した)。
3715
+ if (!removed) return label;
3716
+ // 除去で生じた水平空白 (space / tab / 全角空白) のみ単一化する (例 "A 1:N B" → "A B" → "A B")。
3717
+ // 改行系は HWS に含めないため保持される。
3718
+ // - 各行内の連続水平空白を単一化
3719
+ // - 改行 (LF / CR) の前後の水平空白を除去 (改行直前の trailing 空白も落とす)
3720
+ r = r
3721
+ .replace(new RegExp(`${HWS}{2,}`, "g"), " ")
3722
+ .replace(new RegExp(`${HWS}*([\\r\\n])${HWS}*`, "g"), "$1")
3723
+ .replace(new RegExp(`^${HWS}+|${HWS}+$`, "g"), "");
3724
+ // fallback = cardinality 除去後に「視覚的に意味のある文字」 が残らない場合は元 label を返す
3725
+ // (Round 6 Finding 1 = 除去後に空白/不可視文字だけ残ると不可視 label になるのを防ぐ)。
3726
+ //
3727
+ // 「意味のある文字」 の判定は個別の空白/不可視文字を列挙 (denylist) すると際限が無く、
3728
+ // Round 7 で `\s` → `\p{White_Space}` に変えたら NEL は拾えたが BOM を落とす等のいたちごっこに
3729
+ // なった (cc-codex #879 Round 7/8/9)。 そこで Unicode の「見えない文字」 を 4 カテゴリで構造的に
3730
+ // 判定する = 以下のいずれでもない可視文字が 1 つでもあれば意味あり。
3731
+ // - White_Space ... 全空白 (space / tab / NBSP / NEL / 全角空白 / 各種 Unicode space / 改行系)
3732
+ // - Cf (Format) ... BOM / ZWSP / ZWNJ / ZWJ / WORD JOINER / soft hyphen 等
3733
+ // - Cc (Control) ... 制御文字
3734
+ // - Default_Ignorable_Code_Point ... variation selector (Mn) / Hangul filler (Lo) 等、 Cf に
3735
+ // 入らない不可視文字 (Cf/Cc/White_Space だけでは取りこぼすと Round 9 で判明)
3736
+ // 4 カテゴリで Unicode の非表示文字を網羅する (Braille blank U+2800 や通常文字は content 維持)。
3737
+ const hasVisible = /[^\p{White_Space}\p{Cf}\p{Cc}\p{Default_Ignorable_Code_Point}]/u.test(r);
3738
+ return hasVisible ? r : label;
3739
+ }