@cardenelabs/dragon 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/compile.ts CHANGED
@@ -9,16 +9,44 @@
9
9
  * v0.2 ... 6 preset 全対応 (sequence / flow / swimlane / er / state / topology)
10
10
  */
11
11
 
12
- import type { DslActor, DslDocument, DslLane, DslPhase, DslStep, DslValue, PresetType } from "./types";
13
- import type { CdlDiagram, CdlEdge, ErRelationCardinality, FormulaAst, LaidDiagram } from "@cardenelabs/cdl";
12
+ import type {
13
+ DslActor,
14
+ DslDocument,
15
+ DslLane,
16
+ DslPhase,
17
+ DslStep,
18
+ DslEventBinding,
19
+ DslValue,
20
+ PresetType,
21
+ } from "./types";
22
+ import type {
23
+ CdlDiagram,
24
+ CdlEdge,
25
+ ErRelationCardinality,
26
+ FormulaAst,
27
+ LaidDiagram,
28
+ } from "@cardenelabs/cdl";
14
29
  import {
15
- sequence, flow, swimlane, er, stateMachine, topology, diagram, layout,
16
- rendersRows, requiredRowsHeight, requiredRowsWidth, NODE_KINDS,
17
- applyDerivedValues, parseFormula,
30
+ sequence,
31
+ flow,
32
+ swimlane,
33
+ er,
34
+ stateMachine,
35
+ topology,
36
+ diagram,
37
+ layout,
38
+ rendersRows,
39
+ requiredRowsHeight,
40
+ requiredRowsWidth,
41
+ NODE_KINDS,
42
+ applyDerivedValues,
43
+ parseFormula,
44
+ extractIdentifiers,
45
+ inputDefaultValue,
18
46
  } from "@cardenelabs/cdl";
19
47
  import { parseFocusEntry } from "./focus";
20
48
  import { DRAW_TARGETS } from "./v05/parser";
21
- import { isColorValue, stripExternalPaint } from "./color";
49
+ import { isColorValue, pointsOutside, stripExternalPaint } from "./color";
22
50
  import {
23
51
  MAX_INPUT_ELEMENTS,
24
52
  countDiagramElements,
@@ -96,7 +124,11 @@ export type CompileNotice = {
96
124
  // 起点から描く動きを持たない図種で段に `draw:` を書いた (#1312)
97
125
  | "draw-not-honored"
98
126
  // `draw:` の語がその図種と食い違う (`type: bar` に `draw: pie`、 #1314)
99
- | "draw-target-mismatch";
127
+ | "draw-target-mismatch"
128
+ // 式が、どこにも書かれていない名前を読んだ (#1391)
129
+ | "formula-unresolved"
130
+ // 出来事が指す相手が図に無い (#1393)
131
+ | "event-target-missing";
100
132
  /** 対象の名前。 光らせる相手なら書かれた指定そのまま */
101
133
  actor: string;
102
134
  /** 書かれていた行 */
@@ -279,12 +311,150 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
279
311
  // 扱いが式形と揃う。 時計の状態と段の補間もここで足す
280
312
  const 畳んだきっかけ = foldValueTriggers(merged, doc, opts?.onNotice);
281
313
  attachDerivedValues(merged, doc, opts?.onNotice, inheritedDerivedSourceLines, 畳んだきっかけ);
314
+ // 値を見せる部品を図に載せる (#1374)。 parts が持つ部品は merge 済みなので残したまま足す。
315
+ // **外部参照を落とす前に載せる**。 後から足すと readout の color / colors だけが出口の検査を
316
+ // 迂回し、 `url(https://...)` がそのまま SVG の paint 属性へ届く。
317
+ if (doc.readouts && doc.readouts.length > 0) {
318
+ // 出口の paint 検査は diagram を直接書き換える。 doc の object を共有すると、検査が
319
+ // compileToCdl の入力まで書き換えて入力不変性を壊すため、nested field も含めて写す。
320
+ const ownReadouts = doc.readouts.map(
321
+ (readout) => deepRewriteStrings(readout, (value) => value) as typeof readout,
322
+ );
323
+ merged.readouts = [...(merged.readouts ?? []), ...ownReadouts];
324
+ }
325
+ /*
326
+ * 読む人が動かすつまみを図に載せる (#1389)。
327
+ *
328
+ * 部品と同じく写して載せる = 出口の paint 検査が diagram を直接書き換えるため、doc の
329
+ * object を共有すると `compileToCdl` の入力まで書き換わって入力不変性が壊れる。
330
+ */
331
+ if (doc.inputs && doc.inputs.length > 0) {
332
+ const ownInputs = doc.inputs.map(
333
+ (input) => deepRewriteStrings(input, (value) => value) as typeof input,
334
+ );
335
+ merged.inputs = [...(merged.inputs ?? []), ...ownInputs];
336
+ }
337
+ /*
338
+ * つまみの値から決まる値を図に載せる (#1391)。
339
+ *
340
+ * 部品やつまみと同じく写して載せる。 式の文字列は色を塗る位置に届かないが、
341
+ * 入力を書き換えない形を 3 経路で揃える方が読み手に説明しやすい。
342
+ */
343
+ if (doc.formulas && doc.formulas.length > 0) {
344
+ const ownFormulas = doc.formulas.map((formula) => ({
345
+ id: formula.id,
346
+ expression: formula.expression,
347
+ line: formula.pos?.line ?? 0,
348
+ }));
349
+ /*
350
+ * 式が読む名前が、どこにも書かれていないことを知らせる (#1391)。
351
+ *
352
+ * engine が読める名前は、つまみと **先に宣言した式** だけ。綴り違い、状態、
353
+ * `values:`、後から宣言する式を渡すと、描画時に未定義参照として例外になる。
354
+ *
355
+ * **解けない式は図へ載せない**。 engine は未定義参照や input/formula の同名衝突を
356
+ * runtime error にするため、残すと図全体が描けない。式以外の図は出し、知らせを返す。
357
+ */
358
+ const つまみ = new Map((merged.inputs ?? []).map((input) => [input.id, input]));
359
+ const 先に書かれた式 = new Set((merged.formulas ?? []).map((formula) => formula.id));
360
+ const 載せる式 = [...(merged.formulas ?? [])];
361
+ for (const formula of ownFormulas) {
362
+ if (つまみ.has(formula.id) || 先に書かれた式.has(formula.id)) {
363
+ opts?.onNotice?.({
364
+ kind: "formula-unresolved",
365
+ actor: formula.id,
366
+ line: formula.line,
367
+ message: `式 "${formula.id}" の名前が、先に書かれたつまみまたは式と重なっています`,
368
+ hint: "`inputs:` と `formulas:` では重ならない名前を使う",
369
+ });
370
+ continue;
371
+ }
372
+ let 名前たち: Set<string>;
373
+ try {
374
+ 名前たち = extractIdentifiers(parseFormula(formula.expression));
375
+ } catch {
376
+ // 読めない式は記法の読み取りが既に知らせている。 ここで二重に出さない
377
+ continue;
378
+ }
379
+ let 解けない = false;
380
+ for (const 名 of 名前たち) {
381
+ if (先に書かれた式.has(名)) continue;
382
+ const input = つまみ.get(名);
383
+ if (input) {
384
+ const 初期値 = inputDefaultValue(input);
385
+ if (typeof 初期値 === "number" || typeof 初期値 === "boolean") continue;
386
+ 解けない = true;
387
+ opts?.onNotice?.({
388
+ kind: "formula-unresolved",
389
+ actor: formula.id,
390
+ line: formula.line,
391
+ message: `式 "${formula.id}" が、数でも真偽でもないつまみ "${名}" を読んでいます`,
392
+ hint: "式が読めるつまみは slider / number / stepper / timeline / toggle",
393
+ });
394
+ continue;
395
+ }
396
+ 解けない = true;
397
+ opts?.onNotice?.({
398
+ kind: "formula-unresolved",
399
+ actor: formula.id,
400
+ line: formula.line,
401
+ message: `式 "${formula.id}" が、つまみまたは先に書かれた式ではない名前 "${名}" を読んでいます`,
402
+ hint: "`inputs:` に書くか、参照される式をこの式より前に書く",
403
+ });
404
+ }
405
+ if (解けない) continue;
406
+ 載せる式.push({ id: formula.id, expression: formula.expression });
407
+ 先に書かれた式.add(formula.id);
408
+ }
409
+ if (載せる式.length > 0) merged.formulas = 載せる式;
410
+ else delete merged.formulas;
411
+ }
412
+ /*
413
+ * 押下などの出来事で動く仕掛けを図に載せる (#1393)。
414
+ *
415
+ * **相手の名前を識別子へ直す**。 記法は識別子を書けないため名前で指す。 指す先が
416
+ * 見つからない形は載せずに知らせる = 描画側は知らない識別子を黙って無視するため、
417
+ * 残すと「書いたのに押しても何も起きない」 が手掛かりなしで起きる。
418
+ *
419
+ * 識別子は書いた順に `evt-1` から振る (組み立て API と同じ)。
420
+ */
421
+ if (doc.events && doc.events.length > 0) {
422
+ const 載せる: NonNullable<CdlDiagram["eventBindings"]> = [...(merged.eventBindings ?? [])];
423
+ for (const e of doc.events) {
424
+ const 相手 = 出来事の相手を解く(merged, doc, e);
425
+ if (相手 === undefined) {
426
+ opts?.onNotice?.({
427
+ kind: "event-target-missing",
428
+ actor: e.handlerId,
429
+ line: e.pos.line,
430
+ message: `出来事 "${e.handlerId}" が指す相手が見つかりません`,
431
+ hint: "box は箱の名前、 lane は縦列の名前、 arrow は `A -> B` で書く",
432
+ });
433
+ continue;
434
+ }
435
+ 載せる.push({
436
+ id: `evt-${載せる.length + 1}`,
437
+ event: e.event,
438
+ target: 相手,
439
+ handlerId: e.handlerId,
440
+ });
441
+ }
442
+ if (載せる.length > 0) merged.eventBindings = 載せる;
443
+ }
444
+ // 巻き上げに応じて進む値を図に載せる (#1393)。 相手を持たないのでそのまま写す
445
+ if (doc.scrolls && doc.scrolls.length > 0) {
446
+ merged.scrollTriggers = [
447
+ ...(merged.scrollTriggers ?? []),
448
+ ...doc.scrolls.map((x) => ({ ...x })),
449
+ ];
450
+ }
282
451
  // 図の外を指す値を、 色を塗る位置から落とす (#1004)。
283
452
  //
284
453
  // 入口ごとに塞ぐ形は採らない。 状態の上書き / phase が入れる値 / 画面が直接書く背景色 /
285
454
  // 埋め込んだ JSON / states / values と入口が複数あり、 1 つ見落とすと穴が残る。
286
455
  // **図への追加を全て終えた後**、 出口で 1 度だけ見る。 この後に状態を足すと検査を迂回する。
287
- for (const dropped of stripExternalPaint(merged)) {
456
+ const 外した部品の配色 = stripExternalReadoutPalettes(merged);
457
+ for (const dropped of [...stripExternalPaint(merged), ...外した部品の配色]) {
288
458
  opts?.onNotice?.({
289
459
  kind: "external-paint-dropped",
290
460
  actor: dropped.path,
@@ -299,6 +469,29 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
299
469
  return merged;
300
470
  }
301
471
 
472
+ /**
473
+ * readout の配色配列から外部参照を落とす (#1374)。
474
+ *
475
+ * 共通の `stripExternalPaint` は `color` / `fill` のような key を見るが、 `colors` の中へ
476
+ * 入ると配列要素には key が無い。 `heat-cell` が公開した配色だけはここで要素ごとに閉じる。
477
+ */
478
+ function stripExternalReadoutPalettes(diagram: CdlDiagram): Array<{ path: string; value: string }> {
479
+ const stripped: Array<{ path: string; value: string }> = [];
480
+ for (let i = 0; i < (diagram.readouts?.length ?? 0); i += 1) {
481
+ const readout = diagram.readouts?.[i] as { colors?: readonly string[] } | undefined;
482
+ if (!readout?.colors) continue;
483
+ const colors = [...readout.colors];
484
+ for (let j = 0; j < colors.length; j += 1) {
485
+ const color = colors[j];
486
+ if (color === undefined || !pointsOutside(color)) continue;
487
+ colors[j] = "none";
488
+ stripped.push({ path: `readouts[${i}].colors[${j}]`, value: color });
489
+ }
490
+ readout.colors = colors;
491
+ }
492
+ return stripped;
493
+ }
494
+
302
495
  /**
303
496
  * 書いた状態 (`states:`) を図に載せる (#1162)。
304
497
  *
@@ -907,7 +1100,9 @@ function disambiguateActorIds(
907
1100
  }
908
1101
  const 新しい名前 = new Map<string, string>();
909
1102
  // 名前で並べてから配る = 書いた順に依らない
910
- for (const a of [...作り替える].sort((x, y) => (x.name < y.name ? -1 : x.name > y.name ? 1 : 0))) {
1103
+ for (const a of [...作り替える].sort((x, y) =>
1104
+ x.name < y.name ? -1 : x.name > y.name ? 1 : 0,
1105
+ )) {
911
1106
  const 尾 = 名前の尾(a.name);
912
1107
  // **id の長さの上限のぶん、 元の名前を先に切る** (Round 1 の指摘)。 切らないと尾が
913
1108
  // 64 字で落ちて、 同じ頭を持つ長い名前どうしが元のまま重なる
@@ -935,6 +1130,26 @@ function disambiguateActorIds(
935
1130
  const to = 直す(s.to);
936
1131
  return from === s.from && to === s.to ? s : { ...s, from, to };
937
1132
  }),
1133
+ events: doc.events?.map((event) => {
1134
+ const target = event.target;
1135
+ if (target.kind === "node") {
1136
+ const name = 直す(target.name);
1137
+ return name === target.name ? event : { ...event, target: { ...target, name } };
1138
+ }
1139
+ if (target.kind === "edge") {
1140
+ const from = 直す(target.from);
1141
+ const to = 直す(target.to);
1142
+ return from === target.from && to === target.to
1143
+ ? event
1144
+ : { ...event, target: { ...target, from, to } };
1145
+ }
1146
+ // sequence 系の縦列は登場人物から作るため、同じ名前の読み替えが必要。
1147
+ if (target.kind === "lane" && (doc.type === "sequence" || doc.type === "solidity")) {
1148
+ const name = 直す(target.name);
1149
+ return name === target.name ? event : { ...event, target: { ...target, name } };
1150
+ }
1151
+ return event;
1152
+ }),
938
1153
  };
939
1154
  // 光らせる指定と位置の基準も名前で書くので、 同じ表で直す
940
1155
  if (次.animate) {
@@ -1453,10 +1668,7 @@ function applyCanvasPivotPositions(diagram: CdlDiagram, doc: DslDocument): void
1453
1668
  for (const [subKey, override] of Object.entries(actor.nodes)) {
1454
1669
  if (override.posX === undefined || override.posY === undefined) continue;
1455
1670
  for (const node of diagram.nodes) {
1456
- if (
1457
- node.id === `${aliasSlug}-${subKey}` ||
1458
- node.id === `${subKey}-${aliasSlug}`
1459
- ) {
1671
+ if (node.id === `${aliasSlug}-${subKey}` || node.id === `${subKey}-${aliasSlug}`) {
1460
1672
  node.posX = override.posX;
1461
1673
  node.posY = override.posY;
1462
1674
  if (override.posW !== undefined) node.posW = override.posW;
@@ -1684,8 +1896,6 @@ export function partTargetScale(
1684
1896
  return partScaleFactor(part, targetW, targetH, undefined);
1685
1897
  }
1686
1898
 
1687
-
1688
-
1689
1899
  /**
1690
1900
  * パーツ 1 個が図の上で占める外接矩形。
1691
1901
  *
@@ -2009,8 +2219,7 @@ export function partsGridCenters(
2009
2219
  if (items.length === 0) return out;
2010
2220
  // 公開している関数なので、 呼出側が渡す値を入口で閉じる。 数でない箱の数や桁溢れを
2011
2221
  // そのまま計算に入れると、 描けない座標を返すことになる
2012
- const safeCount =
2013
- Number.isSafeInteger(baseNodeCount) && baseNodeCount >= 0 ? baseNodeCount : 0;
2222
+ const safeCount = Number.isSafeInteger(baseNodeCount) && baseNodeCount >= 0 ? baseNodeCount : 0;
2014
2223
  const top = safeCount * STACK_PITCH + PARTS_GAP * 2;
2015
2224
  // 同じ名前が 2 度来たら先の方だけを見る。 後の分を残すと、 どちらを指したか決められない
2016
2225
  // まま列の送り幅にも影響する
@@ -2212,9 +2421,7 @@ function partBoxes(
2212
2421
  const t = partTargetSize(part, a.posW, a.posH, a.scale);
2213
2422
  const size = partExtent(part, t.w, t.h);
2214
2423
  const placed =
2215
- a.posX !== undefined && a.posY !== undefined
2216
- ? { cx: a.posX, cy: a.posY }
2217
- : grid.get(a.name);
2424
+ a.posX !== undefined && a.posY !== undefined ? { cx: a.posX, cy: a.posY } : grid.get(a.name);
2218
2425
  // 相対で書いた分はここでは決まらない (解決側が後で埋める)
2219
2426
  if (!placed) continue;
2220
2427
  // 渡す座標は段の中心。 矩形の中心はそこからずれる
@@ -2234,68 +2441,68 @@ function cleanupPlaceholderActor(
2234
2441
  doc: DslDocument,
2235
2442
  a: { name: string; lane?: string },
2236
2443
  ): void {
2237
- const aliasSlug = slugify(a.name);
2238
- const ownedLaneIds = new Set<string>();
2239
- if (doc.type === "sequence" || doc.type === "solidity") {
2240
- for (const l of target.lanes) {
2241
- // 明示 lane mapping (a.lane) 先は part の張替え先で actor 専用 lane ではないため除外
2242
- if (a.lane !== undefined && l.id === a.lane) continue;
2243
- if (l.label === a.name) ownedLaneIds.add(l.id);
2244
- }
2245
- }
2246
- const ownedNodeIds = new Set<string>();
2247
- for (const n of target.nodes) {
2248
- if (ownedLaneIds.has(n.lane)) ownedNodeIds.add(n.id);
2444
+ const aliasSlug = slugify(a.name);
2445
+ const ownedLaneIds = new Set<string>();
2446
+ if (doc.type === "sequence" || doc.type === "solidity") {
2447
+ for (const l of target.lanes) {
2448
+ // 明示 lane mapping (a.lane) 先は part の張替え先で actor 専用 lane ではないため除外
2449
+ if (a.lane !== undefined && l.id === a.lane) continue;
2450
+ if (l.label === a.name) ownedLaneIds.add(l.id);
2249
2451
  }
2250
- // actor 専用 lane を引き当てられない経路 (flow / topology 等の共有 lane preset) は従来どおり dragon
2251
- // slug の prefix match に fallback する。 これらは 1 actor = 1 node (id = slug) の生成規則。
2252
- const matchesAliasSlug = (id: string): boolean => {
2253
- if (id === aliasSlug) return true;
2254
- if (id.startsWith(`${aliasSlug}-`)) return true;
2255
- // sequence step anchor = `s{N}-{aliasSlug}` pattern
2256
- if (/^s\d+-/.test(id) && id.endsWith(`-${aliasSlug}`)) return true;
2257
- return false;
2258
- };
2259
- const relatedToActor = (id: string): boolean =>
2260
- ownedLaneIds.size > 0 ? ownedNodeIds.has(id) : matchesAliasSlug(id);
2261
- target.nodes = target.nodes.filter((n) => !relatedToActor(n.id));
2262
- // edge も同経路で削除 (parts actor に接続していた flow を除去、 parts merge 後の flow は user が
2263
- // 別途書く経路になる)。 削除した edge の id は phase.activate に残ると dangling 参照になるため回収する。
2264
- const removedEdgeIds = new Set<string>();
2265
- target.edges = target.edges.filter((e) => {
2266
- const drop = relatedToActor(e.from) || relatedToActor(e.to);
2267
- if (drop) removedEdgeIds.add(e.id);
2268
- return !drop;
2452
+ }
2453
+ const ownedNodeIds = new Set<string>();
2454
+ for (const n of target.nodes) {
2455
+ if (ownedLaneIds.has(n.lane)) ownedNodeIds.add(n.id);
2456
+ }
2457
+ // actor 専用 lane を引き当てられない経路 (flow / topology 等の共有 lane preset) は従来どおり dragon
2458
+ // slug の prefix match に fallback する。 これらは 1 actor = 1 node (id = slug) の生成規則。
2459
+ const matchesAliasSlug = (id: string): boolean => {
2460
+ if (id === aliasSlug) return true;
2461
+ if (id.startsWith(`${aliasSlug}-`)) return true;
2462
+ // sequence step anchor = `s{N}-{aliasSlug}` pattern
2463
+ if (/^s\d+-/.test(id) && id.endsWith(`-${aliasSlug}`)) return true;
2464
+ return false;
2465
+ };
2466
+ const relatedToActor = (id: string): boolean =>
2467
+ ownedLaneIds.size > 0 ? ownedNodeIds.has(id) : matchesAliasSlug(id);
2468
+ target.nodes = target.nodes.filter((n) => !relatedToActor(n.id));
2469
+ // edge も同経路で削除 (parts actor に接続していた flow を除去、 parts merge 後の flow は user が
2470
+ // 別途書く経路になる)。 削除した edge の id は phase.activate に残ると dangling 参照になるため回収する。
2471
+ const removedEdgeIds = new Set<string>();
2472
+ target.edges = target.edges.filter((e) => {
2473
+ const drop = relatedToActor(e.from) || relatedToActor(e.to);
2474
+ if (drop) removedEdgeIds.add(e.id);
2475
+ return !drop;
2476
+ });
2477
+ // lane も削除 = sequence preset は parts actor 用に lane (id = aliasSlug、 label = actor 名) を
2478
+ // 生成する。 node/edge だけ消して lane を残すと、 merge 後の part 側 lane (label = alias) と 2 本が
2479
+ // 同じ label を lane-label として描画し二重表示になる (actor ラベル二重表示 bug の root cause)。
2480
+ //
2481
+ // 削除は seq-like preset (sequence / solidity = compileSequence 経由) に限定する。 これらは
2482
+ // 1 actor = 1 lane (lane.label === a.name、 lane.id は actor 名の slug) の生成規則が成立し、
2483
+ // parts actor 用 lane を安全に削除できる。 他 preset (flow / topology / class / pie 等) は複数
2484
+ // actor が共有 lane (id = "main" 等) を参照するため、 一致 lane を消すと通常 actor の node が
2485
+ // 削除済 lane を参照する不正 diagram になる (cc-codex MAJOR 指摘)。
2486
+ //
2487
+ // leftover lane の特定は lane.label === a.name を第一に使う。 seq-like preset は非 animate 経路
2488
+ // (cdl preset の slugify) と animate 経路 (dragon の slugify) で lane.id の slug 規則が異なり
2489
+ // (`_`/全角の扱い等)、 aliasSlug (dragon slugify) と lane.id が不一致になる actor 名がある。 lane.label
2490
+ // は両経路とも a.name 生値なので slug 差の影響を受けず確実に一致する。 id === aliasSlug は
2491
+ // label 未設定 preset への fallback (exact match のみ、 prefix は false match risk のため付けない)。
2492
+ if (doc.type === "sequence" || doc.type === "solidity") {
2493
+ target.lanes = target.lanes.filter((l) => {
2494
+ // 明示 lane mapping (a.lane) 先は part の張替え先なので保持する。
2495
+ if (a.lane !== undefined && l.id === a.lane) return true;
2496
+ if (l.label === a.name) return false;
2497
+ if (l.id === aliasSlug) return false;
2498
+ return true;
2269
2499
  });
2270
- // lane も削除 = sequence preset は parts actor 用に lane (id = aliasSlug、 label = actor 名) を
2271
- // 生成する。 node/edge だけ消して lane を残すと、 merge 後の part 側 lane (label = alias) と 2 本が
2272
- // 同じ label lane-label として描画し二重表示になる (actor ラベル二重表示 bug の root cause)
2273
- //
2274
- // 削除は seq-like preset (sequence / solidity = compileSequence 経由) に限定する。 これらは
2275
- // 1 actor = 1 lane (lane.label === a.name、 lane.id は actor 名の slug) の生成規則が成立し、
2276
- // parts actor 用 lane を安全に削除できる。 他 preset (flow / topology / class / pie 等) は複数
2277
- // actor が共有 lane (id = "main" 等) を参照するため、 一致 lane を消すと通常 actor の node が
2278
- // 削除済 lane を参照する不正 diagram になる (cc-codex MAJOR 指摘)。
2279
- //
2280
- // leftover lane の特定は lane.label === a.name を第一に使う。 seq-like preset は非 animate 経路
2281
- // (cdl preset の slugify) と animate 経路 (dragon の slugify) で lane.id の slug 規則が異なり
2282
- // (`_`/全角の扱い等)、 aliasSlug (dragon slugify) と lane.id が不一致になる actor 名がある。 lane.label
2283
- // は両経路とも a.name 生値なので slug 差の影響を受けず確実に一致する。 id === aliasSlug は
2284
- // label 未設定 preset への fallback (exact match のみ、 prefix は false match risk のため付けない)。
2285
- if (doc.type === "sequence" || doc.type === "solidity") {
2286
- target.lanes = target.lanes.filter((l) => {
2287
- // 明示 lane mapping (a.lane) 先は part の張替え先なので保持する。
2288
- if (a.lane !== undefined && l.id === a.lane) return true;
2289
- if (l.label === a.name) return false;
2290
- if (l.id === aliasSlug) return false;
2291
- return true;
2292
- });
2293
- }
2294
- // 削除された node / edge を activate 参照している既存 phase の cleanup (node 削除と同じ判定経路
2295
- // = 取りこぼすと存在しない id が activate に残り dangling 参照になる、 #873)
2296
- for (const phase of target.phases) {
2297
- phase.activate = phase.activate.filter((id) => !relatedToActor(id) && !removedEdgeIds.has(id));
2298
- }
2500
+ }
2501
+ // 削除された node / edge activate 参照している既存 phase cleanup (node 削除と同じ判定経路
2502
+ // = 取りこぼすと存在しない id activate に残り dangling 参照になる、 #873)
2503
+ for (const phase of target.phases) {
2504
+ phase.activate = phase.activate.filter((id) => !relatedToActor(id) && !removedEdgeIds.has(id));
2505
+ }
2299
2506
  }
2300
2507
 
2301
2508
  /**
@@ -2362,7 +2569,9 @@ function mergePartsFromActors(
2362
2569
  const part = found;
2363
2570
  if (!part) {
2364
2571
  if (typeof console !== "undefined" && console.warn) {
2365
- console.warn(`[dragon] parts kind "${partId}" not found in partsCatalog (actor: ${actor.name})`);
2572
+ console.warn(
2573
+ `[dragon] parts kind "${partId}" not found in partsCatalog (actor: ${actor.name})`,
2574
+ );
2366
2575
  }
2367
2576
  continue;
2368
2577
  }
@@ -2450,7 +2659,8 @@ function resolveStateOverride(
2450
2659
  override: number | string | boolean | undefined,
2451
2660
  ): { initial: number | string; rejected: boolean } {
2452
2661
  if (override === undefined) return { initial: original, rejected: false };
2453
- if (isColorValue(original) && !isColorValue(override)) return { initial: original, rejected: true };
2662
+ if (isColorValue(original) && !isColorValue(override))
2663
+ return { initial: original, rejected: true };
2454
2664
  return { initial: override as number | string, rejected: false };
2455
2665
  }
2456
2666
 
@@ -2615,9 +2825,8 @@ function mergePartIntoDiagram(
2615
2825
  // directive で廃止 (2026-07-21)。 重なりは user の意図位置を優先し、 手動移動で回避する経路。
2616
2826
  // 未指定 (座標なし fallback) 時のみ existingMax + gap で右外配置 (通常経路は drop/click で座標を渡す)。
2617
2827
  const PARTS_LANE_GAP = 300;
2618
- const existingLaneMaxX = target.lanes.length > 0
2619
- ? Math.max(...target.lanes.map((l) => (l.x ?? 0) + l.width))
2620
- : 0;
2828
+ const existingLaneMaxX =
2829
+ target.lanes.length > 0 ? Math.max(...target.lanes.map((l) => (l.x ?? 0) + l.width)) : 0;
2621
2830
  // parts 全体 resize (I2 forensic): user が SE handle drag で targetW/H 指定 = actor.posW/H。
2622
2831
  // scale 基準は part 全体の bbox 幅 (全 lane の最左端〜最右端) にする。 lane[0] 幅だけを基準にすると
2623
2832
  // multi-lane part (複数 lane を横に並べた part) で全体幅を過小評価し、 非先頭 lane の node が自 lane
@@ -2649,9 +2858,10 @@ function mergePartIntoDiagram(
2649
2858
  // 変換し、 lane.x = mapLaneX(元 lane 左端) にすることで全 lane / 全 node が一貫して drop 座標を中心に
2650
2859
  // scale 配置される (cc-codex #879 の mapPartX と同じ発想を lane push まで前倒し、 #880 root fix)。
2651
2860
  const partOrigBboxCenterX = partMinLaneX + partsBboxW / 2;
2652
- const dropCenterX = offsetX !== undefined
2653
- ? offsetX
2654
- : existingLaneMaxX + PARTS_LANE_GAP + (partsBboxW * laneScaleX) / 2;
2861
+ const dropCenterX =
2862
+ offsetX !== undefined
2863
+ ? offsetX
2864
+ : existingLaneMaxX + PARTS_LANE_GAP + (partsBboxW * laneScaleX) / 2;
2655
2865
  const mapLaneX = (x: number): number => (x - partOrigBboxCenterX) * laneScaleX + dropCenterX;
2656
2866
 
2657
2867
  for (const laneOrig of part.lanes) {
@@ -2688,9 +2898,10 @@ function mergePartIntoDiagram(
2688
2898
  const shouldForcePos = offsetX !== undefined || offsetY !== undefined;
2689
2899
  // target 側の現在 max stack + isolation offset で parts node の stack を shift、
2690
2900
  // sequence の rowH 計算と完全分離 (D2 fix、 posX/posY 明示との 2 段防御)。
2691
- const targetMaxStack = shouldForcePos && target.nodes.length > 0
2692
- ? Math.max(...target.nodes.map((n) => n.stack ?? 0))
2693
- : 0;
2901
+ const targetMaxStack =
2902
+ shouldForcePos && target.nodes.length > 0
2903
+ ? Math.max(...target.nodes.map((n) => n.stack ?? 0))
2904
+ : 0;
2694
2905
  const stackShiftBase = shouldForcePos ? targetMaxStack + STACK_ISOLATION_OFFSET : 0;
2695
2906
  // parts 全体 resize scale (I2 forensic 対応): targetW / targetH 指定時、 parts の元 total size
2696
2907
  // に対する比率 = scale 係数、 全 sub-node の w / h + cx / cy 相対位置に scale 反映。
@@ -2758,8 +2969,10 @@ function mergePartIntoDiagram(
2758
2969
  // (cc-codex #879 Round 2/3 MAJOR + #880)。 mapLaneX は part bbox 中心 → drop 座標の scale 変換で、
2759
2970
  // lane / node / 明示 posX / auto-layout の全経路がこの 1 式を共有するため、 lane.x != 0 でも
2760
2971
  // multi-lane でも node 中心と自 lane 中心が一致する。
2761
- let nodePosX: number | undefined = nodeOrig.posX !== undefined ? mapLaneX(nodeOrig.posX) : undefined;
2762
- let nodePosY: number | undefined = nodeOrig.posY !== undefined ? nodeOrig.posY + (offsetY ?? 0) : undefined;
2972
+ let nodePosX: number | undefined =
2973
+ nodeOrig.posX !== undefined ? mapLaneX(nodeOrig.posX) : undefined;
2974
+ let nodePosY: number | undefined =
2975
+ nodeOrig.posY !== undefined ? nodeOrig.posY + (offsetY ?? 0) : undefined;
2763
2976
  if (shouldForcePos && nodePosX === undefined) {
2764
2977
  // posX を持たない node は所属 lane の中央 (auto layout の cx 相当) を同じ mapLaneX で変換する。
2765
2978
  const geom = partLaneGeom.get(nodeOrig.lane) ?? { x: 0, w: 320 };
@@ -2775,12 +2988,10 @@ function mergePartIntoDiagram(
2775
2988
  // 座標が非有限になって図が描けない (実測 = 箱の中心が NaN になった)
2776
2989
  const rawNodeW = nodeOrig.w !== undefined ? positiveOr(nodeOrig.w, 200) : undefined;
2777
2990
  const rawNodeH = nodeOrig.h !== undefined ? positiveOr(nodeOrig.h, 200) : undefined;
2778
- const nodeW = rawNodeW !== undefined && (scaleX !== 1 || scaleY !== 1)
2779
- ? rawNodeW * scaleX
2780
- : rawNodeW;
2781
- const nodeH = rawNodeH !== undefined && (scaleX !== 1 || scaleY !== 1)
2782
- ? rawNodeH * scaleY
2783
- : rawNodeH;
2991
+ const nodeW =
2992
+ rawNodeW !== undefined && (scaleX !== 1 || scaleY !== 1) ? rawNodeW * scaleX : rawNodeW;
2993
+ const nodeH =
2994
+ rawNodeH !== undefined && (scaleX !== 1 || scaleY !== 1) ? rawNodeH * scaleY : rawNodeH;
2784
2995
  target.nodes.push({
2785
2996
  ...nodeOrig,
2786
2997
  id: prefix(nodeOrig.id),
@@ -2800,7 +3011,10 @@ function mergePartIntoDiagram(
2800
3011
 
2801
3012
  // state merge = id prefix + initial override
2802
3013
  for (const stateOrig of part.states) {
2803
- const { initial, rejected } = resolveStateOverride(stateOrig.initial, stateOverride[stateOrig.id]);
3014
+ const { initial, rejected } = resolveStateOverride(
3015
+ stateOrig.initial,
3016
+ stateOverride[stateOrig.id],
3017
+ );
2804
3018
  if (rejected) {
2805
3019
  onNotice?.({
2806
3020
  kind: "state-override-rejected",
@@ -2844,7 +3058,10 @@ function mergePartIntoDiagram(
2844
3058
  if (part.readouts && part.readouts.length > 0) {
2845
3059
  if (!target.readouts) target.readouts = [];
2846
3060
  for (const readoutOrig of part.readouts) {
2847
- const rewritten = deepRewriteStrings(readoutOrig as unknown, rewriteTemplate) as CdlDiagram["readouts"] extends readonly (infer R)[] ? R : never;
3061
+ const rewritten = deepRewriteStrings(
3062
+ readoutOrig as unknown,
3063
+ rewriteTemplate,
3064
+ ) as CdlDiagram["readouts"] extends readonly (infer R)[] ? R : never;
2848
3065
  // id は shape 全 walk で rewrite されないので個別に prefix
2849
3066
  target.readouts.push({
2850
3067
  ...(rewritten as { id: string }),
@@ -2882,8 +3099,14 @@ function mergePartIntoDiagram(
2882
3099
  const partPhase = part.phases[i]!;
2883
3100
  targetPhase.duration = Math.max(targetPhase.duration, partPhase.duration);
2884
3101
  targetPhase.activate = [...targetPhase.activate, ...partPhase.activate.map(prefix)];
2885
- targetPhase.tweens = [...targetPhase.tweens, ...partPhase.tweens.map((t) => ({ ...t, stateId: valuePrefix(t.stateId) }))];
2886
- targetPhase.sets = [...targetPhase.sets, ...partPhase.sets.map((s) => ({ ...s, stateId: valuePrefix(s.stateId) }))];
3102
+ targetPhase.tweens = [
3103
+ ...targetPhase.tweens,
3104
+ ...partPhase.tweens.map((t) => ({ ...t, stateId: valuePrefix(t.stateId) })),
3105
+ ];
3106
+ targetPhase.sets = [
3107
+ ...targetPhase.sets,
3108
+ ...partPhase.sets.map((s) => ({ ...s, stateId: valuePrefix(s.stateId) })),
3109
+ ];
2887
3110
  }
2888
3111
  // parts phase 余剰は append (target より parts が長い場合)
2889
3112
  for (let i = commonLen; i < partsLen; i++) {
@@ -2978,6 +3201,49 @@ function applyEdgeInlineOptions(
2978
3201
  });
2979
3202
  }
2980
3203
 
3204
+ /**
3205
+ * 出来事が指す相手を、図の識別子へ直す (#1393)。 見つからなければ `undefined`。
3206
+ *
3207
+ * 箱と縦列は **名前から作った識別子** と **描かれる題** の両方で探す。 記法は名前で書き、
3208
+ * 図の識別子はそこから作られるが、見本を重ねた図など識別子が名前と揃わない形もある。
3209
+ * 矢印は両端の名前から識別子を作って突き合わせる。
3210
+ */
3211
+ function 出来事の相手を解く(
3212
+ diagram: CdlDiagram,
3213
+ doc: DslDocument,
3214
+ e: DslEventBinding,
3215
+ ): NonNullable<CdlDiagram["eventBindings"]>[number]["target"] | undefined {
3216
+ if (e.target.kind === "diagram") return { kind: "diagram" };
3217
+ if (e.target.kind === "edge") {
3218
+ const from = slugify(e.target.from);
3219
+ const to = slugify(e.target.to);
3220
+ let 矢印: CdlEdge | undefined;
3221
+ if (doc.type === "sequence" || doc.type === "solidity") {
3222
+ const stepIdx = doc.flow.findIndex(
3223
+ (step) => slugify(step.from) === from && slugify(step.to) === to,
3224
+ );
3225
+ if (stepIdx >= 0) {
3226
+ 矢印 = diagram.edges.find(
3227
+ (x) =>
3228
+ (x.from === `s${stepIdx}-${from}` || x.from === from) &&
3229
+ (x.to === `s${stepIdx}-${to}` || x.from === x.to),
3230
+ );
3231
+ }
3232
+ } else {
3233
+ 矢印 = diagram.edges.find((x) => x.from === from && x.to === to);
3234
+ }
3235
+ return 矢印 ? { kind: "edge", id: 矢印.id } : undefined;
3236
+ }
3237
+ const 名 = e.target.name;
3238
+ const slug = slugify(名);
3239
+ if (e.target.kind === "node") {
3240
+ const 箱 = diagram.nodes.find((n) => n.id === slug || n.id === 名 || n.title === 名);
3241
+ return 箱 ? { kind: "node", id: 箱.id } : undefined;
3242
+ }
3243
+ const 列 = (diagram.lanes ?? []).find((l) => l.id === slug || l.id === 名 || l.label === 名);
3244
+ return 列 ? { kind: "lane", id: 列.id } : undefined;
3245
+ }
3246
+
2981
3247
  /** 本文に書いた矢印の指定を、対応が取れた矢印へ書き写す。 対応の取り方は呼出側が決める。 */
2982
3248
  function 矢印へ書き写す(target: CdlEdge, s: DslStep, doc: DslDocument): void {
2983
3249
  // **書いた補足が勝つ** (#1275)。 ここで写さないと 2 つ落ちる。 静止した `type: flow` は
@@ -2993,14 +3259,19 @@ function 矢印へ書き写す(target: CdlEdge, s: DslStep, doc: DslDocument): v
2993
3259
  target.cardinality = s.cardinality;
2994
3260
  // ER preset の場合 label に "(1:N)" 形式で併記 (既に含まれていればスキップ)
2995
3261
  if (doc.type === "er" && !target.label.includes(s.cardinality)) {
2996
- target.label = target.label
2997
- ? `${target.label} (${s.cardinality})`
2998
- : `(${s.cardinality})`;
3262
+ target.label = target.label ? `${target.label} (${s.cardinality})` : `(${s.cardinality})`;
2999
3263
  }
3000
3264
  }
3265
+ // 矢印がどの辺から出るか (#1385)。 書かなければ描画側が自動で選ぶ
3266
+ if (s.side !== undefined) target.side = s.side;
3001
3267
  if (s.labelOffsetX !== undefined) target.labelOffsetX = s.labelOffsetX;
3002
3268
  if (s.labelOffsetY !== undefined) target.labelOffsetY = s.labelOffsetY;
3003
3269
  if (s.overlay !== undefined) target.overlay = s.overlay;
3270
+ // 値に追随する 3 欄 (#1396)。 太さ / 色 / 破線の位置が値に合わせて動く。
3271
+ // 通り道そのものは動かないので、配置計算と重なり解消には影響しない
3272
+ if (s.widthBind !== undefined) target.widthBind = s.widthBind;
3273
+ if (s.strokeBind !== undefined) target.strokeBind = s.strokeBind;
3274
+ if (s.dashOffsetBind !== undefined) target.dashOffsetBind = s.dashOffsetBind;
3004
3275
  }
3005
3276
 
3006
3277
  /**
@@ -3090,7 +3361,8 @@ function 式に書く数(n: number): string {
3090
3361
  const decimalAt = whole.length + Number(exponentText);
3091
3362
  let expanded: string;
3092
3363
  if (decimalAt <= 0) expanded = `0.${"0".repeat(-decimalAt)}${digits}`;
3093
- else if (decimalAt >= digits.length) expanded = `${digits}${"0".repeat(decimalAt - digits.length)}`;
3364
+ else if (decimalAt >= digits.length)
3365
+ expanded = `${digits}${"0".repeat(decimalAt - digits.length)}`;
3094
3366
  else expanded = `${digits.slice(0, decimalAt)}.${digits.slice(decimalAt)}`;
3095
3367
  return negative ? `-${expanded}` : expanded;
3096
3368
  }
@@ -3109,13 +3381,20 @@ type 動く区間 = { 段: number; start: number; end: number; dur: number; from
3109
3381
  function 境目を通る時刻(区間: 動く区間, op: string, 境目: number): number | null {
3110
3382
  const 満たす = (x: number): boolean => {
3111
3383
  switch (op) {
3112
- case ">=": return x >= 境目;
3113
- case ">": return x > 境目;
3114
- case "<=": return x <= 境目;
3115
- case "<": return x < 境目;
3116
- case "==": return x === 境目;
3117
- case "!=": return x !== 境目;
3118
- default: return false;
3384
+ case ">=":
3385
+ return x >= 境目;
3386
+ case ">":
3387
+ return x > 境目;
3388
+ case "<=":
3389
+ return x <= 境目;
3390
+ case "<":
3391
+ return x < 境目;
3392
+ case "==":
3393
+ return x === 境目;
3394
+ case "!=":
3395
+ return x !== 境目;
3396
+ default:
3397
+ return false;
3119
3398
  }
3120
3399
  };
3121
3400
  if (満たす(区間.from)) return 区間.start;
@@ -3196,7 +3475,13 @@ function foldValueTriggers(
3196
3475
  const 解決中 = new Set<string>();
3197
3476
 
3198
3477
  const 知らせる = (v: DslValue, message: string, hint: string): void => {
3199
- onNotice?.({ kind: "value-trigger-unresolved", actor: v.name, line: v.pos?.line ?? 0, message, hint });
3478
+ onNotice?.({
3479
+ kind: "value-trigger-unresolved",
3480
+ actor: v.name,
3481
+ line: v.pos?.line ?? 0,
3482
+ message,
3483
+ hint,
3484
+ });
3200
3485
  };
3201
3486
 
3202
3487
  const 解く = (name: string): 動く区間 | null => {
@@ -3207,7 +3492,11 @@ function foldValueTriggers(
3207
3492
  if (!v || !v.trigger) return null;
3208
3493
  if (解決中.has(name)) {
3209
3494
  解けない.add(name);
3210
- 知らせる(v, `"${name}" のきっかけが一周しています`, "どれか 1 つを `trigger: step ...` に変える");
3495
+ 知らせる(
3496
+ v,
3497
+ `"${name}" のきっかけが一周しています`,
3498
+ "どれか 1 つを `trigger: step ...` に変える",
3499
+ );
3211
3500
  return null;
3212
3501
  }
3213
3502
  解決中.add(name);
@@ -3231,7 +3520,11 @@ function foldValueTriggers(
3231
3520
  if (trigger.kind === "step") {
3232
3521
  const idx = 段の番号.get(trigger.step);
3233
3522
  if (idx === undefined) {
3234
- 知らせる(v, `"${trigger.step}" という段がありません`, "`animation:` にその名前の段を書くか、 段の名前に合わせる");
3523
+ 知らせる(
3524
+ v,
3525
+ `"${trigger.step}" という段がありません`,
3526
+ "`animation:` にその名前の段を書くか、 段の名前に合わせる",
3527
+ );
3235
3528
  return null;
3236
3529
  }
3237
3530
  段 = idx;
@@ -3287,7 +3580,8 @@ function foldValueTriggers(
3287
3580
  段ごとの時計.set(段, 名前);
3288
3581
  diagram.states.push({ id: 名前, initial: 0 });
3289
3582
  const 段の中身 = diagram.phases[段];
3290
- if (段の中身) 段の中身.tweens = [...段の中身.tweens, { stateId: 名前, from: 0, to: 段の中身.duration }];
3583
+ if (段の中身)
3584
+ 段の中身.tweens = [...段の中身.tweens, { stateId: 名前, from: 0, to: 段の中身.duration }];
3291
3585
  return 名前;
3292
3586
  };
3293
3587
 
@@ -3555,9 +3849,15 @@ const VALUE_NOTICE_HINT: Readonly<Record<string, string>> = {
3555
3849
  * **今は記法からも到達する** (一覧へ戻す作業が要らなかったのはこのため)。
3556
3850
  */
3557
3851
  const SINGLE_BOX_KINDS: ReadonlySet<string> = new Set([
3558
- "chart-pie", "chart-line", "chart-bar",
3559
- "gantt-timeline", "mind-map",
3560
- "funnel-stages", "quadrant-matrix", "tree-hierarchy", "journey-map",
3852
+ "chart-pie",
3853
+ "chart-line",
3854
+ "chart-bar",
3855
+ "gantt-timeline",
3856
+ "mind-map",
3857
+ "funnel-stages",
3858
+ "quadrant-matrix",
3859
+ "tree-hierarchy",
3860
+ "journey-map",
3561
3861
  ]);
3562
3862
 
3563
3863
  /** 記法の種別を描画の種別に直す。 描けない種別のままなら `undefined`。 */
@@ -3666,14 +3966,36 @@ const LABEL_MIN_H: Readonly<Record<string, number>> = {
3666
3966
  */
3667
3967
  const LABEL_NEVER_FITS: ReadonlySet<string> = new Set([
3668
3968
  // 左右にはみ出す 24 種。 横幅は高さで変わらないため直らない
3669
- "shape-api-gateway", "shape-atm", "shape-auditor", "shape-bank", "shape-bitcoin-chain",
3670
- "shape-blockchain", "shape-blockchain-block", "shape-blockchain-node", "shape-brokerage",
3671
- "shape-code-block", "shape-customer-service", "shape-ethereum-chain", "shape-hexagon",
3672
- "shape-kanban-card", "shape-lawyer", "shape-network-node", "shape-nft", "shape-notary",
3673
- "shape-regulator", "shape-satellite", "shape-smart-contract", "shape-terminal",
3674
- "shape-trader", "shape-trust-bank",
3969
+ "shape-api-gateway",
3970
+ "shape-atm",
3971
+ "shape-auditor",
3972
+ "shape-bank",
3973
+ "shape-bitcoin-chain",
3974
+ "shape-blockchain",
3975
+ "shape-blockchain-block",
3976
+ "shape-blockchain-node",
3977
+ "shape-brokerage",
3978
+ "shape-code-block",
3979
+ "shape-customer-service",
3980
+ "shape-ethereum-chain",
3981
+ "shape-hexagon",
3982
+ "shape-kanban-card",
3983
+ "shape-lawyer",
3984
+ "shape-network-node",
3985
+ "shape-nft",
3986
+ "shape-notary",
3987
+ "shape-regulator",
3988
+ "shape-satellite",
3989
+ "shape-smart-contract",
3990
+ "shape-terminal",
3991
+ "shape-trader",
3992
+ "shape-trust-bank",
3675
3993
  // 下のはみ出しが高さに依らない 6 種
3676
- "shape-cylinder", "shape-diamond", "shape-file", "shape-folder", "shape-mobile-device",
3994
+ "shape-cylinder",
3995
+ "shape-diamond",
3996
+ "shape-file",
3997
+ "shape-folder",
3998
+ "shape-mobile-device",
3677
3999
  "shape-stack",
3678
4000
  // 上へ出る絵が名札の大きさでは読めない。 `#1067` では「上は何ともぶつからない」 として残したが、
3679
4001
  // 実際には絵が小さく潰れて名前と重なり、 横に並べた時も 1 本だけ頭が浮く (user 実機確認)
@@ -3979,6 +4301,7 @@ function compileGantt(doc: DslDocument): CdlDiagram {
3979
4301
  const 目盛りなし: string[] = [];
3980
4302
  const タスク: {
3981
4303
  name: string;
4304
+ title: string;
3982
4305
  label: string;
3983
4306
  tone?: DslDocument["actors"][number]["tone"];
3984
4307
  owner?: string;
@@ -3994,6 +4317,7 @@ function compileGantt(doc: DslDocument): CdlDiagram {
3994
4317
  // 色は帯にそのまま渡す。 箱が 1 つになっても、 書いた色が消えないようにする
3995
4318
  タスク.push({
3996
4319
  name: a.name,
4320
+ title: 箱の題(a),
3997
4321
  label,
3998
4322
  ...(a.tone !== undefined ? { tone: a.tone } : {}),
3999
4323
  ...(a.owner !== undefined ? { owner: a.owner } : {}),
@@ -4020,7 +4344,12 @@ function compileGantt(doc: DslDocument): CdlDiagram {
4020
4344
  }
4021
4345
  依存元.set(s.to, s.from);
4022
4346
  // 帯の依存は「どちらが先か」 だけを持つ。 矢印に書いた文字や色は描けないので伝える
4023
- if ((s.label ?? "") !== "" || (s.sub ?? "") !== "" || s.tone !== undefined || s.style !== undefined) {
4347
+ if (
4348
+ (s.label ?? "") !== "" ||
4349
+ (s.sub ?? "") !== "" ||
4350
+ s.tone !== undefined ||
4351
+ s.style !== undefined
4352
+ ) {
4024
4353
  装飾つき.push(`${s.from} -> ${s.to}`);
4025
4354
  }
4026
4355
  }
@@ -4059,7 +4388,7 @@ function compileGantt(doc: DslDocument): CdlDiagram {
4059
4388
  const 終わり = 終わる位置(t.end, idx, 目盛り, t.name, 逆向きを伝える, doc);
4060
4389
  return {
4061
4390
  id: slugify(t.name) || t.name,
4062
- title: t.name,
4391
+ title: t.title,
4063
4392
  startIdx: idx,
4064
4393
  endIdx: 終わり.idx,
4065
4394
  startLabel: t.label,
@@ -4110,7 +4439,7 @@ function compileClass(doc: DslDocument): CdlDiagram {
4110
4439
  lane: laneId,
4111
4440
  stack: 0,
4112
4441
  kind: "storage",
4113
- title: a.name,
4442
+ title: 箱の題(a),
4114
4443
  w: CLASS_W,
4115
4444
  });
4116
4445
  });
@@ -4121,6 +4450,7 @@ function compileClass(doc: DslDocument): CdlDiagram {
4121
4450
  b.edge(fromId, toId, {
4122
4451
  label: s.label,
4123
4452
  ...(s.sub ? { sub: s.sub } : {}),
4453
+ ...(s.side ? { side: s.side } : {}),
4124
4454
  ...(s.tone ? { tone: s.tone } : {}),
4125
4455
  ...(s.style ? { style: s.style } : {}),
4126
4456
  });
@@ -4185,7 +4515,6 @@ function parseChartValue(raw: string | undefined): number | string | null {
4185
4515
  return parseBoundValue(raw);
4186
4516
  }
4187
4517
 
4188
-
4189
4518
  /** 状態を読む欄が指している名前 (`{v.sum}` なら `v`)。 欄でなければ null */
4190
4519
  function 参照する名前(value: number | string | null): string | null {
4191
4520
  if (typeof value !== "string") return null;
@@ -4251,7 +4580,7 @@ function compileValueChart(
4251
4580
  continue;
4252
4581
  }
4253
4582
  // 色はそのまま渡す。 箱が 1 つになっても、 書いた色が消えないようにする
4254
- data.push({ label: a.name, value, ...(a.tone !== undefined ? { tone: a.tone } : {}) });
4583
+ data.push({ label: 箱の題(a), value, ...(a.tone !== undefined ? { tone: a.tone } : {}) });
4255
4584
  }
4256
4585
  // 案内の言葉は型ごとに変える。 共通化した時に `pie` の「割合 / 円 / 45%」 が「値 / 図 / 45」 に
4257
4586
  // 薄まり、 既存の案内が後退した (review 指摘)。 何を書けばよいかは型ごとに違う
@@ -4313,7 +4642,6 @@ function compileValueChart(
4313
4642
  return b.build();
4314
4643
  }
4315
4644
 
4316
-
4317
4645
  /**
4318
4646
  * 図表 4 種の組立て (#1154 段 2 / 段 3)。
4319
4647
  *
@@ -4415,7 +4743,11 @@ const 区画 = new Map<string, "topLeft" | "topRight" | "bottomLeft" | "bottomRi
4415
4743
  */
4416
4744
  function 図表の欄から参照できる名前(
4417
4745
  doc: DslDocument,
4418
- 欄: { 読めるか: (v: number | string) => boolean; 補間で壊れるか: boolean; 自動の値を許すか: boolean },
4746
+ 欄: {
4747
+ 読めるか: (v: number | string) => boolean;
4748
+ 補間で壊れるか: boolean;
4749
+ 自動の値を許すか: boolean;
4750
+ },
4419
4751
  ): Set<string> {
4420
4752
  // 同じ名前を 2 回宣言した時は後ろが効く。 描画側が後の宣言を有効値として扱うため、
4421
4753
  // 前の宣言で判定すると「読めると判定したのに読めない値が入る」 状態になる (実測)
@@ -4536,7 +4868,7 @@ function compileFunnel(doc: DslDocument, onNotice?: (n: CompileNotice) => void):
4536
4868
  未宣言.push(a.name);
4537
4869
  continue;
4538
4870
  }
4539
- data.push({ id: slugify(a.name), title: a.name, count: v });
4871
+ data.push({ id: slugify(a.name), title: 箱の題(a), count: v });
4540
4872
  }
4541
4873
  if (未宣言.length > 0) {
4542
4874
  const m3 = `type: funnel で数にならない値を参照した項目があります (段に載せません): ${未宣言.join(", ")}。 \`states:\` にその名前を数で書いてください`;
@@ -4551,11 +4883,23 @@ function compileFunnel(doc: DslDocument, onNotice?: (n: CompileNotice) => void):
4551
4883
  // 矢印は描けない。 書かれていたら伝える (黙って捨てると「書いたのに効かない」 が残る)
4552
4884
  if (doc.flow.length > 0) {
4553
4885
  const m2 = `type: funnel では矢印を描けません (${doc.flow.length} 本を無視しました)。 関係を描くなら type: flow を使ってください`;
4554
- onNotice?.({ kind: "chart-edge-dropped", actor: doc.flow[0]?.from ?? "", line: doc.flow[0]?.pos?.line ?? 0, message: m2 });
4886
+ onNotice?.({
4887
+ kind: "chart-edge-dropped",
4888
+ actor: doc.flow[0]?.from ?? "",
4889
+ line: doc.flow[0]?.pos?.line ?? 0,
4890
+ message: m2,
4891
+ });
4555
4892
  if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${m2}`);
4556
4893
  }
4557
4894
  b.node(`${slugify(doc.title) || "funnel"}-chart`, {
4558
- lane: "chart", stack: 0, kind: "funnel-stages", title: doc.title, ...図の小見出し(doc), w: W, h: H, funnelData: data,
4895
+ lane: "chart",
4896
+ stack: 0,
4897
+ kind: "funnel-stages",
4898
+ title: doc.title,
4899
+ ...図の小見出し(doc),
4900
+ w: W,
4901
+ h: H,
4902
+ funnelData: data,
4559
4903
  });
4560
4904
  return b.build();
4561
4905
  }
@@ -4582,12 +4926,20 @@ function 矢印から親を決める(
4582
4926
  const 子 = slugify(f.to);
4583
4927
  const 親名 = slugify(f.from);
4584
4928
  if (!名前.has(親名)) {
4585
- 伝える(f.from, `type: ${図種} で書いていない名前を親にしています: ${f.from} -> ${f.to}`, f.pos?.line ?? 0);
4929
+ 伝える(
4930
+ f.from,
4931
+ `type: ${図種} で書いていない名前を親にしています: ${f.from} -> ${f.to}`,
4932
+ f.pos?.line ?? 0,
4933
+ );
4586
4934
  continue;
4587
4935
  }
4588
4936
  // 子の側も見る。 書いていない名前への矢印は、 黙って捨てると図から関係が消える
4589
4937
  if (!名前.has(子)) {
4590
- 伝える(f.to, `type: ${図種} で書いていない名前を子にしています: ${f.from} -> ${f.to}`, f.pos?.line ?? 0);
4938
+ 伝える(
4939
+ f.to,
4940
+ `type: ${図種} で書いていない名前を子にしています: ${f.from} -> ${f.to}`,
4941
+ f.pos?.line ?? 0,
4942
+ );
4591
4943
  continue;
4592
4944
  }
4593
4945
  if (子 === 親名) {
@@ -4596,7 +4948,11 @@ function 矢印から親を決める(
4596
4948
  }
4597
4949
  const 既存 = 親.get(子);
4598
4950
  if (既存 !== undefined && 既存 !== 親名) {
4599
- 伝える(f.to, `type: ${図種} で ${f.to} に親が 2 つあります (後の ${f.from} は使いません)`, f.pos?.line ?? 0);
4951
+ 伝える(
4952
+ f.to,
4953
+ `type: ${図種} で ${f.to} に親が 2 つあります (後の ${f.from} は使いません)`,
4954
+ f.pos?.line ?? 0,
4955
+ );
4600
4956
  continue;
4601
4957
  }
4602
4958
  親.set(子, 親名);
@@ -4638,7 +4994,10 @@ function compileTree(doc: DslDocument, onNotice?: (n: CompileNotice) => void): C
4638
4994
  };
4639
4995
  for (const [k, 群] of slug別) {
4640
4996
  if (群.length > 1) {
4641
- 伝える(群[0]!, `type: tree で ${群.join(" / ")} が同じ id (${k}) になります。 名前を変えてください`);
4997
+ 伝える(
4998
+ 群[0]!,
4999
+ `type: tree で ${群.join(" / ")} が同じ id (${k}) になります。 名前を変えてください`,
5000
+ );
4642
5001
  }
4643
5002
  }
4644
5003
  const 親 = 矢印から親を決める(doc, "tree", 名前, 伝える);
@@ -4650,7 +5009,14 @@ function compileTree(doc: DslDocument, onNotice?: (n: CompileNotice) => void): C
4650
5009
  return { id, ...放射に出す文字(a), ...(p3 !== undefined ? { parent: p3 } : {}) };
4651
5010
  });
4652
5011
  b.node(`${slugify(doc.title) || "tree"}-chart`, {
4653
- lane: "chart", stack: 0, kind: "tree-hierarchy", title: doc.title, ...図の小見出し(doc), w: W, h: H, treeData: data,
5012
+ lane: "chart",
5013
+ stack: 0,
5014
+ kind: "tree-hierarchy",
5015
+ title: doc.title,
5016
+ ...図の小見出し(doc),
5017
+ w: W,
5018
+ h: H,
5019
+ treeData: data,
4654
5020
  });
4655
5021
  return b.build();
4656
5022
  }
@@ -4677,19 +5043,25 @@ function 道筋の欄(a: DslActor): { touchpoint?: string; opportunity?: string
4677
5043
  * `type: mind` では伝えない = `compileMind` が描けない欄をまとめて 1 件で伝えており、
4678
5044
  * そこに 2 つとも入っている (`放射で描けない欄`)。 二重に伝えない (#1246 と同じ扱い)。
4679
5045
  */
4680
- function reportChartFieldsNotHonored(doc: DslDocument, onNotice?: (n: CompileNotice) => void): void {
5046
+ function reportChartFieldsNotHonored(
5047
+ doc: DslDocument,
5048
+ onNotice?: (n: CompileNotice) => void,
5049
+ ): void {
4681
5050
  if (!onNotice) return;
4682
5051
  if (doc.type === "mind") return;
4683
5052
  for (const a of doc.actors) {
4684
5053
  if (a.partId !== undefined) continue;
4685
- const 道筋 = doc.type === "journey" ? [] : [
4686
- ...(a.touchpoint !== undefined ? ["touchpoint"] : []),
4687
- ...(a.opportunity !== undefined ? ["opportunity"] : []),
4688
- ];
4689
- const 工程 = doc.type === "gantt" ? [] : [
4690
- ...(a.owner !== undefined ? ["owner"] : []),
4691
- ...(a.end !== undefined ? ["end"] : []),
4692
- ];
5054
+ const 道筋 =
5055
+ doc.type === "journey"
5056
+ ? []
5057
+ : [
5058
+ ...(a.touchpoint !== undefined ? ["touchpoint"] : []),
5059
+ ...(a.opportunity !== undefined ? ["opportunity"] : []),
5060
+ ];
5061
+ const 工程 =
5062
+ doc.type === "gantt"
5063
+ ? []
5064
+ : [...(a.owner !== undefined ? ["owner"] : []), ...(a.end !== undefined ? ["end"] : [])];
4693
5065
  if (道筋.length > 0) {
4694
5066
  onNotice({
4695
5067
  kind: "chart-value-unreadable",
@@ -4727,7 +5099,7 @@ function compileJourney(doc: DslDocument, onNotice?: (n: CompileNotice) => void)
4727
5099
  読めない.push(a.name);
4728
5100
  continue;
4729
5101
  }
4730
- data.push({ id: slugify(a.name), title: a.name, emotion: 語 as never, ...道筋の欄(a) });
5102
+ data.push({ id: slugify(a.name), title: 箱の題(a), emotion: 語 as never, ...道筋の欄(a) });
4731
5103
  continue;
4732
5104
  }
4733
5105
  const e = 気持ち.get(語);
@@ -4735,7 +5107,7 @@ function compileJourney(doc: DslDocument, onNotice?: (n: CompileNotice) => void)
4735
5107
  読めない.push(a.name);
4736
5108
  continue;
4737
5109
  }
4738
- data.push({ id: slugify(a.name), title: a.name, emotion: e, ...道筋の欄(a) });
5110
+ data.push({ id: slugify(a.name), title: 箱の題(a), emotion: e, ...道筋の欄(a) });
4739
5111
  }
4740
5112
  if (読めない.length > 0) {
4741
5113
  const m = `type: journey で気持ちを読めない項目があります (道筋に載せません): ${読めない.join(", ")}。 \`- 登録: "不満"\` の形で、 ${[...気持ち.keys()].join(" / ")} のどれかを書いてください`;
@@ -4745,11 +5117,23 @@ function compileJourney(doc: DslDocument, onNotice?: (n: CompileNotice) => void)
4745
5117
  // 矢印は描けない。 書かれていたら伝える (黙って捨てると「書いたのに効かない」 が残る)
4746
5118
  if (doc.flow.length > 0) {
4747
5119
  const m2 = `type: journey では矢印を描けません (${doc.flow.length} 本を無視しました)。 関係を描くなら type: flow を使ってください`;
4748
- onNotice?.({ kind: "chart-edge-dropped", actor: doc.flow[0]?.from ?? "", line: doc.flow[0]?.pos?.line ?? 0, message: m2 });
5120
+ onNotice?.({
5121
+ kind: "chart-edge-dropped",
5122
+ actor: doc.flow[0]?.from ?? "",
5123
+ line: doc.flow[0]?.pos?.line ?? 0,
5124
+ message: m2,
5125
+ });
4749
5126
  if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${m2}`);
4750
5127
  }
4751
5128
  b.node(`${slugify(doc.title) || "journey"}-chart`, {
4752
- lane: "chart", stack: 0, kind: "journey-map", title: doc.title, ...図の小見出し(doc), w: W, h: H, journeyData: data,
5129
+ lane: "chart",
5130
+ stack: 0,
5131
+ kind: "journey-map",
5132
+ title: doc.title,
5133
+ ...図の小見出し(doc),
5134
+ w: W,
5135
+ h: H,
5136
+ journeyData: data,
4753
5137
  });
4754
5138
  return b.build();
4755
5139
  }
@@ -4828,7 +5212,7 @@ function compileQuadrant(doc: DslDocument, onNotice?: (n: CompileNotice) => void
4828
5212
  読めない.push(a.name);
4829
5213
  continue;
4830
5214
  }
4831
- items.push({ id: slugify(a.name), title: a.name, quadrant: 語 as never });
5215
+ items.push({ id: slugify(a.name), title: 箱の題(a), quadrant: 語 as never });
4832
5216
  continue;
4833
5217
  }
4834
5218
  const q = 区画.get(語);
@@ -4836,7 +5220,7 @@ function compileQuadrant(doc: DslDocument, onNotice?: (n: CompileNotice) => void
4836
5220
  読めない.push(a.name);
4837
5221
  continue;
4838
5222
  }
4839
- items.push({ id: slugify(a.name), title: a.name, quadrant: q });
5223
+ items.push({ id: slugify(a.name), title: 箱の題(a), quadrant: q });
4840
5224
  }
4841
5225
  if (読めない.length > 0) {
4842
5226
  const m = `type: quadrant で区画を読めない項目があります (図に載せません): ${読めない.join(", ")}。 \`- 重複削除: "左上"\` の形で、 ${[...区画.keys()].join(" / ")} のどれかを書いてください`;
@@ -4846,11 +5230,22 @@ function compileQuadrant(doc: DslDocument, onNotice?: (n: CompileNotice) => void
4846
5230
  // 矢印は描けない。 書かれていたら伝える (黙って捨てると「書いたのに効かない」 が残る)
4847
5231
  if (doc.flow.length > 0) {
4848
5232
  const m2 = `type: quadrant では矢印を描けません (${doc.flow.length} 本を無視しました)。 関係を描くなら type: flow を使ってください`;
4849
- onNotice?.({ kind: "chart-edge-dropped", actor: doc.flow[0]?.from ?? "", line: doc.flow[0]?.pos?.line ?? 0, message: m2 });
5233
+ onNotice?.({
5234
+ kind: "chart-edge-dropped",
5235
+ actor: doc.flow[0]?.from ?? "",
5236
+ line: doc.flow[0]?.pos?.line ?? 0,
5237
+ message: m2,
5238
+ });
4850
5239
  if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${m2}`);
4851
5240
  }
4852
5241
  b.node(`${slugify(doc.title) || "quadrant"}-chart`, {
4853
- lane: "chart", stack: 0, kind: "quadrant-matrix", title: doc.title, ...図の小見出し(doc), w: W, h: H,
5242
+ lane: "chart",
5243
+ stack: 0,
5244
+ kind: "quadrant-matrix",
5245
+ title: doc.title,
5246
+ ...図の小見出し(doc),
5247
+ w: W,
5248
+ h: H,
4854
5249
  quadrantData: { ...軸と区画の名前(doc), items },
4855
5250
  });
4856
5251
  return b.build();
@@ -4879,7 +5274,10 @@ function 段を読み取る(subtitle: string | undefined): { 段: number; 説明
4879
5274
  const m = 元.match(/^L([123])(?![0-9A-Za-z])/i);
4880
5275
  if (m === null) return { 段: 1, 説明: subtitle };
4881
5276
  // 目印と、 その直後の区切り (`:` / 全角コロン / 空白) を落とす
4882
- const 残り = 元.slice(m[0].length).replace(/^[::\s]+/, "").trim();
5277
+ const 残り =
5278
+ .slice(m[0].length)
5279
+ .replace(/^[::\s]+/, "")
5280
+ .trim();
4883
5281
  return { 段: Number(m[1]), 説明: 残り === "" ? undefined : 残り };
4884
5282
  }
4885
5283
 
@@ -4943,6 +5341,7 @@ function compileC4(doc: DslDocument): CdlDiagram {
4943
5341
  b.edge(fromId, toId, {
4944
5342
  label: s.label,
4945
5343
  ...(s.sub ? { sub: s.sub } : {}),
5344
+ ...(s.side ? { side: s.side } : {}),
4946
5345
  ...(s.tone ? { tone: s.tone } : {}),
4947
5346
  ...(s.style ? { style: s.style } : {}),
4948
5347
  });
@@ -4998,6 +5397,8 @@ function compileC4(doc: DslDocument): CdlDiagram {
4998
5397
  type 放射で描ける欄 =
4999
5398
  /** 中心の名前 / 枝の名前になる */
5000
5399
  | "name"
5400
+ /** 名前と分けて中心 / 枝に出す題。 書かなければ名前を出す */
5401
+ | "title"
5001
5402
  /**
5002
5403
  * 名前とは分けて補足に出す 2 欄。
5003
5404
  *
@@ -5018,6 +5419,16 @@ type 放射で描ける欄 =
5018
5419
  /** 本文の行番号。 知らせに載せるために使う */
5019
5420
  | "pos";
5020
5421
 
5422
+ /**
5423
+ * 箱に出す題 (#1381)。 書いていなければ名前をそのまま使う。
5424
+ *
5425
+ * 名前は図の中で 1 つに決まる必要がある (`focus:` と `flow:` が名前で指す) 一方、題は
5426
+ * 重なってよい。 同じ題の箱を並べる図と、題を持たない箱は、名前と切り離さないと書けない。
5427
+ */
5428
+ function 箱の題(a: DslActor): string {
5429
+ return a.title ?? a.name;
5430
+ }
5431
+
5021
5432
  /** 放射では描けない欄。 書かれていたら伝える */
5022
5433
  type 放射で描けない欄 =
5023
5434
  | "kind"
@@ -5041,7 +5452,17 @@ type 放射で描けない欄 =
5041
5452
  | "scaleKeys"
5042
5453
  | "posRel"
5043
5454
  | "nodes"
5044
- | "layoutPos";
5455
+ | "layoutPos"
5456
+ // 箱の中に描く図形 (#1374)。 放射の枝は箱の中に図形を持たない
5457
+ | "shape"
5458
+ // 出す条件 (#1381)。 放射の枝は個別に出し分けられない
5459
+ | "visibleIf"
5460
+ // 値に追随する 5 欄 (#1392)。 放射の枝は大きさも位置も中心からの配置で決まる
5461
+ | "wBind"
5462
+ | "hBind"
5463
+ | "opacity"
5464
+ | "renderOffsetX"
5465
+ | "renderOffsetY";
5045
5466
 
5046
5467
  /** 引数が `never` でなければ型検査が落ちる */
5047
5468
  type 空であること<T extends never> = T;
@@ -5096,6 +5517,13 @@ const 放射で描けない欄の名前: Record<放射で描けない欄, string
5096
5517
  posRel: "位置 (相対)",
5097
5518
  nodes: "中の箱ごとの指定",
5098
5519
  layoutPos: "配置のずらし",
5520
+ shape: "箱の中の図形",
5521
+ visibleIf: "出す条件",
5522
+ wBind: "値に追随する大きさ",
5523
+ hBind: "値に追随する大きさ",
5524
+ opacity: "濃さ",
5525
+ renderOffsetX: "描く時のずらし",
5526
+ renderOffsetY: "描く時のずらし",
5099
5527
  };
5100
5528
 
5101
5529
  /**
@@ -5126,7 +5554,7 @@ function 放射で描けない欄を書いたか(a: DslActor, 欄: 放射で描
5126
5554
  */
5127
5555
  function 放射に出す文字(a: DslActor): { title: string; subtitle?: string } {
5128
5556
  const 続き = [a.subtitle, a.value].map((x) => x?.trim()).filter((x): x is string => !!x);
5129
- return { title: a.name, ...(続き.length > 0 ? { subtitle: 続き.join(" ") } : {}) };
5557
+ return { title: 箱の題(a), ...(続き.length > 0 ? { subtitle: 続き.join(" ") } : {}) };
5130
5558
  }
5131
5559
 
5132
5560
  function compileMind(doc: DslDocument, onNotice?: (n: CompileNotice) => void): CdlDiagram {
@@ -5226,7 +5654,8 @@ function compileMind(doc: DslDocument, onNotice?: (n: CompileNotice) => void): C
5226
5654
  const 使った = new Set<string>([rootId]);
5227
5655
  記録する(root);
5228
5656
  // 中心は色の欄を持たない (`MindBranchPayload` に `tone` が無い)
5229
- if (root.tone) 消えた欄.set(root.name, [...(消えた欄.get(root.name) ?? []), "色 (中心は持てない)"]);
5657
+ if (root.tone)
5658
+ 消えた欄.set(root.name, [...(消えた欄.get(root.name) ?? []), "色 (中心は持てない)"]);
5230
5659
 
5231
5660
  見本でない.slice(1).forEach((a, i) => {
5232
5661
  const id = slugify(a.name) || `leaf-${i}`;
@@ -5304,10 +5733,7 @@ function compileMind(doc: DslDocument, onNotice?: (n: CompileNotice) => void): C
5304
5733
  * 後から来た見本の縦列に書いた値を移し、先に作った空の方を外す。 書いた人から見れば
5305
5734
  * 「id を書けば効く」 が成り立つ。
5306
5735
  */
5307
- function mergeDuplicateDeclaredLanes(
5308
- diagram: CdlDiagram,
5309
- 追加した縦列: readonly DslLane[],
5310
- ): void {
5736
+ function mergeDuplicateDeclaredLanes(diagram: CdlDiagram, 追加した縦列: readonly DslLane[]): void {
5311
5737
  for (const 宣言 of 追加した縦列) {
5312
5738
  const { id } = 宣言;
5313
5739
  const 同一idの縦列 = diagram.lanes.filter((l) => l.id === id);
@@ -5399,14 +5825,31 @@ function applyV05Extensions(
5399
5825
  // `s{idx}-{laneId}` が actor 名末尾 "Header" (slug `...-header`) で誤マッチし、 option が invisible
5400
5826
  // な step anchor にも copy される (cc-codex #883 MAJOR)。 lane id との構造 exact 一致で header だけを
5401
5827
  // 引くことで step box / spacer / footer を排除する。
5402
- primaryNodes = ownedLaneIds.size > 0
5403
- ? diagram.nodes.filter((n) => ownedLaneIds.has(n.lane) && n.id === `${n.lane}-header`)
5404
- : diagram.nodes.filter((n) => n.id === `${dragonSlug}-header`);
5828
+ primaryNodes =
5829
+ ownedLaneIds.size > 0
5830
+ ? diagram.nodes.filter((n) => ownedLaneIds.has(n.lane) && n.id === `${n.lane}-header`)
5831
+ : diagram.nodes.filter((n) => n.id === `${dragonSlug}-header`);
5405
5832
  } else {
5406
5833
  // 非 seq preset は 1 actor = 1 node (id = dragon slug) で node id と dragon slug が一致する。
5407
5834
  primaryNodes = diagram.nodes.filter((n) => n.id === dragonSlug);
5408
5835
  }
5409
5836
  for (const node of primaryNodes) {
5837
+ // 識別に使う名前と、箱に出す題を分ける (#1381)。 preset が actor 名で
5838
+ // node を作る経路 (sequence / solidity / swimlane / c4) もここで書き換える。
5839
+ if (a.title !== undefined) {
5840
+ node.title = a.title;
5841
+ if (isSeqLike) {
5842
+ const footer = diagram.nodes.find((n) => n.id === `${node.lane}-footer`);
5843
+ if (footer) footer.title = a.title;
5844
+
5845
+ // 名前で決めた preset の幅を題に合わせる。 明示の大きさは後段が優先する。
5846
+ if (a.posW === undefined) {
5847
+ const titleW = Math.max(140, a.title.length * 22 + 52);
5848
+ node.w = titleW;
5849
+ if (footer) footer.w = titleW;
5850
+ }
5851
+ }
5852
+ }
5410
5853
  // `type: c4` では説明の先頭に段の目印 (`L1` / `L2` / `L3`) を書く。 目印は組み立てに
5411
5854
  // 段を伝えるためのもので読む人に意味を持たず、 段の名前は枠のラベルが既に出している。
5412
5855
  // ここで落とさないと、 組み立てが読み取った目印がそのまま箱の説明として出る (#1098)
@@ -5482,6 +5925,42 @@ function applyV05Extensions(
5482
5925
  const footer = diagram.nodes.find((n) => n.id === `${node.lane}-footer`);
5483
5926
  if (footer && a.posW !== undefined) footer.w = a.posW;
5484
5927
  }
5928
+ // 箱の中に描く図形 (#1374)。 renderer が shape を描くのは dyn-* kind だけなので、
5929
+ // shape 自身を SSOT にして対応する kind へ揃える。 card 等のまま shape だけ渡すと、指定を
5930
+ // 保持しているのに画面には何も出ない。 paint 検査が入力を mutate しないよう object も写す。
5931
+ if (a.shape !== undefined) {
5932
+ const dynamicKind = `dyn-${a.shape.kind}` as typeof node.kind;
5933
+ node.kind = dynamicKind;
5934
+ node.shape = { ...a.shape };
5935
+ if (isSeqLike) {
5936
+ const footer = diagram.nodes.find((n) => n.id === `${node.lane}-footer`);
5937
+ if (footer) {
5938
+ footer.kind = dynamicKind;
5939
+ footer.shape = { ...a.shape };
5940
+ }
5941
+ }
5942
+ }
5943
+ // その箱を出すかどうかの条件 (#1381)。 名札と足にも同じ条件を渡す = 片方だけ隠すと
5944
+ // 順序図で縦線の頭と足が食い違う
5945
+ if (a.visibleIf !== undefined) {
5946
+ node.visibleIf = a.visibleIf;
5947
+ if (isSeqLike) {
5948
+ const footer = diagram.nodes.find((n) => n.id === `${node.lane}-footer`);
5949
+ if (footer) footer.visibleIf = a.visibleIf;
5950
+ }
5951
+ }
5952
+ /*
5953
+ * 値に追随する 5 欄 (#1392)。
5954
+ *
5955
+ * **名札と足へは渡さない**。 `visibleIf` は片方だけ隠すと順序図の縦線の頭と足が
5956
+ * 食い違うため揃えるが、こちらは見た目の大きさ / 濃さ / ずらしで、名札まで同じだけ
5957
+ * 動かすと縦線の頭が本体から離れる。 書いた箱にだけ効かせる。
5958
+ */
5959
+ if (a.wBind !== undefined) node.wBind = a.wBind;
5960
+ if (a.hBind !== undefined) node.hBind = a.hBind;
5961
+ if (a.opacity !== undefined) node.opacity = a.opacity;
5962
+ if (a.renderOffsetX !== undefined) node.renderOffsetX = a.renderOffsetX;
5963
+ if (a.renderOffsetY !== undefined) node.renderOffsetY = a.renderOffsetY;
5485
5964
  }
5486
5965
  }
5487
5966
  // 名札の高さを揃える。 kind ごとに高さが変わると縦線の始まる位置がばらけ、 順序図の
@@ -5599,7 +6078,12 @@ function injectPhasesFallback(diagram: CdlDiagram, doc: DslDocument): void {
5599
6078
  }
5600
6079
  // 線を持たない種類 (帯の依存等) では矢印が edge にならない。 両端が実在するなら
5601
6080
  // その箱を光らせる = 矢印を指した段で何も光らないより意図に近い (#1077)
5602
- if (!見つかった && singleBoxNode !== undefined && knownNames.has(entry.from) && knownNames.has(entry.to)) {
6081
+ if (
6082
+ !見つかった &&
6083
+ singleBoxNode !== undefined &&
6084
+ knownNames.has(entry.from) &&
6085
+ knownNames.has(entry.to)
6086
+ ) {
5603
6087
  out.push(singleBoxNode.id);
5604
6088
  }
5605
6089
  continue;
@@ -5629,9 +6113,7 @@ function injectPhasesFallback(diagram: CdlDiagram, doc: DslDocument): void {
5629
6113
  // **`activate` と兼ねない**。 描画側は焦点と別集合で持つ (`cdl#512`) = 焦点が当たり
5630
6114
  // 続ける図で毎段引き直しになるため。 書いた段だけが欄を持つ
5631
6115
  const drawIds =
5632
- p.draw !== undefined &&
5633
- DRAW_TARGETS.get(p.draw) === doc.type &&
5634
- singleBoxNode !== undefined
6116
+ p.draw !== undefined && DRAW_TARGETS.get(p.draw) === doc.type && singleBoxNode !== undefined
5635
6117
  ? [singleBoxNode.id]
5636
6118
  : [];
5637
6119
  diagram.phases.push({
@@ -5666,6 +6148,7 @@ function compileSequence(doc: DslDocument): CdlDiagram {
5666
6148
  to: s.to,
5667
6149
  label: s.label,
5668
6150
  ...(s.sub ? { sub: s.sub } : {}),
6151
+ ...(s.side ? { side: s.side } : {}),
5669
6152
  ...(s.tone ? { tone: s.tone } : {}),
5670
6153
  ...(s.style ? { style: s.style } : {}),
5671
6154
  });
@@ -5705,8 +6188,8 @@ function compileSequenceWithAnimate(doc: DslDocument): CdlDiagram {
5705
6188
  const headerId = `${id}-header`;
5706
6189
  // header/footer 幅を title 長に応じて auto-size (text-readability warning 解消)。
5707
6190
  // formula = 22px/char + 52px padding (visualValidate text-readability と完全一致)、 min 140 で従来 sample 互換維持。
5708
- const actorW = Math.max(140, a.name.length * 22 + 52);
5709
- b.node(headerId, { lane: id, stack: 0, kind: "card", title: a.name, w: actorW, h: 72 });
6191
+ const actorW = Math.max(140, 箱の題(a).length * 22 + 52);
6192
+ b.node(headerId, { lane: id, stack: 0, kind: "card", title: 箱の題(a), w: actorW, h: 72 });
5710
6193
  headerNodeIds.push(headerId);
5711
6194
  const spacerId = `${id}-spacer`;
5712
6195
  b.node(spacerId, { lane: id, stack: 1, kind: "card", title: "", w: 2, h: 40 });
@@ -5733,6 +6216,7 @@ function compileSequenceWithAnimate(doc: DslDocument): CdlDiagram {
5733
6216
  id: edgeId,
5734
6217
  label: s.label,
5735
6218
  ...(s.sub ? { sub: s.sub } : {}),
6219
+ ...(s.side ? { side: s.side } : {}),
5736
6220
  ...(s.tone ? { tone: s.tone } : {}),
5737
6221
  ...(s.style ? { style: s.style } : {}),
5738
6222
  });
@@ -5744,7 +6228,7 @@ function compileSequenceWithAnimate(doc: DslDocument): CdlDiagram {
5744
6228
  doc.actors.forEach((a) => {
5745
6229
  const laneId = actorIds.get(a.name) ?? slugify(a.name);
5746
6230
  const footerId = `${laneId}-footer`;
5747
- const actorW = Math.max(140, a.name.length * 22 + 52);
6231
+ const actorW = Math.max(140, 箱の題(a).length * 22 + 52);
5748
6232
  // `role` を付ける (#1273)。 付けないと生命線の終わりが footer より 100 下まで伸びる
5749
6233
  // (実測 = 組立て API は `y2=848`、記法は `y2=948`)。 枠の大きさは同じなので
5750
6234
  // 描いた図の大きさの比較では捕まらない
@@ -5752,7 +6236,7 @@ function compileSequenceWithAnimate(doc: DslDocument): CdlDiagram {
5752
6236
  lane: laneId,
5753
6237
  stack: footerStack,
5754
6238
  kind: "card",
5755
- title: a.name,
6239
+ title: 箱の題(a),
5756
6240
  w: actorW,
5757
6241
  h: 72,
5758
6242
  role: "lifeline-footer",
@@ -5901,7 +6385,7 @@ function compileFlow(doc: DslDocument): CdlDiagram {
5901
6385
  {
5902
6386
  id: slugify(a.name) || `n${i}`,
5903
6387
  kind: a.kind,
5904
- title: a.name,
6388
+ title: 箱の題(a),
5905
6389
  },
5906
6390
  edgeLabel,
5907
6391
  );
@@ -5951,6 +6435,7 @@ function compileSwimlane(doc: DslDocument): CdlDiagram {
5951
6435
  id: `e${edgeIdx++}-${fromId}-${toId}`,
5952
6436
  label: s.label,
5953
6437
  ...(s.sub ? { sub: s.sub } : {}),
6438
+ ...(s.side ? { side: s.side } : {}),
5954
6439
  ...(s.tone ? { tone: s.tone } : {}),
5955
6440
  ...(s.style ? { style: s.style } : {}),
5956
6441
  });
@@ -5970,7 +6455,7 @@ function compileSwimlane(doc: DslDocument): CdlDiagram {
5970
6455
  lane: swim.laneId(a.name),
5971
6456
  stack: 0,
5972
6457
  kind: a.kind ?? "actor",
5973
- title: a.name,
6458
+ title: 箱の題(a),
5974
6459
  });
5975
6460
  });
5976
6461
  }
@@ -5995,7 +6480,7 @@ function compileEr(doc: DslDocument): CdlDiagram {
5995
6480
  // v0.3 で「列定義」 ブロックを追加検討
5996
6481
  erBuilder.entity({
5997
6482
  id: slugify(a.name) || a.name,
5998
- title: a.name,
6483
+ title: 箱の題(a),
5999
6484
  rows: [], // v0.2 では rows なし
6000
6485
  });
6001
6486
  }
@@ -6052,7 +6537,7 @@ function compileState(doc: DslDocument): CdlDiagram {
6052
6537
  const final = 決め方.終わり(a, i);
6053
6538
  fsm.state({
6054
6539
  id: slugify(a.name) || `s${i}`,
6055
- title: a.name,
6540
+ title: 箱の題(a),
6056
6541
  ...(initial ? { initial: true } : {}),
6057
6542
  ...(final ? { final: true } : {}),
6058
6543
  });
@@ -6093,13 +6578,14 @@ function compileTopology(doc: DslDocument): CdlDiagram {
6093
6578
  groupBuilder.add({
6094
6579
  id: slugify(a.name) || a.name,
6095
6580
  kind: a.kind,
6096
- title: a.name,
6581
+ title: 箱の題(a),
6097
6582
  });
6098
6583
  }
6099
6584
  for (const s of doc.flow) {
6100
6585
  topo.connect(slugify(s.from), slugify(s.to), {
6101
6586
  label: s.label,
6102
6587
  ...(s.sub ? { sub: s.sub } : {}),
6588
+ ...(s.side ? { side: s.side } : {}),
6103
6589
  ...(s.tone ? { tone: s.tone } : {}),
6104
6590
  ...(s.style ? { style: s.style } : {}),
6105
6591
  });
@@ -6179,33 +6665,72 @@ function compileGenericWithAnimate(doc: DslDocument, opts: GenericOpts): CdlDiag
6179
6665
  // lane / node 配置 ... preset kind に応じて切替
6180
6666
  const actorToNodeId = new Map<string, string>();
6181
6667
  if (書いた縦列に置く(kind, doc)) {
6182
- // **書いた縦列に置く** (#1263)。 縦列を並べるための入れ物として使う図種でだけ効く。
6183
- // 縦列は書かれた順に作り、同じ縦列の箱は書かれた順に積む
6184
- const 並び: string[] = [];
6668
+ /*
6669
+ * **書いた縦列に置く** (#1263)。 縦列を並べるための入れ物として使う図種でだけ効く。
6670
+ *
6671
+ * 縦列の並びは **`lanes:` に書いた順** を優先する (#1394)。 箱が最初に使った順で
6672
+ * 並べていた間、`lanes:` で左から順に宣言しても箱の書き順で入れ替わっていた
6673
+ * (実測 = 中心を先に書いた放射の図で、左端の縦列が中心の右へ回った)。
6674
+ *
6675
+ * `lanes:` に無い縦列は、これまでどおり箱が使った順で後ろに続ける。
6676
+ */
6677
+ const 使った: string[] = [];
6185
6678
  for (const a of doc.actors) {
6186
6679
  const lid = a.lane;
6187
- if (lid !== undefined && !並び.includes(lid)) 並び.push(lid);
6680
+ if (lid !== undefined && !使った.includes(lid)) 使った.push(lid);
6188
6681
  }
6682
+ const 書いた順 = doc.lanes ? Object.keys(doc.lanes) : [];
6683
+ const 並び = [
6684
+ ...書いた順.filter((lid) => 使った.includes(lid)),
6685
+ ...使った.filter((lid) => !書いた順.includes(lid)),
6686
+ ];
6189
6687
  for (const lid of 並び) {
6190
6688
  b.lane(lid, { width: laneWidth, ...(kind === "topology" ? { contain: true } : {}) });
6191
6689
  }
6192
- const 積んだ数 = new Map<string, number>();
6690
+ /*
6691
+ * 段は **書いた番号をそのまま持つ** (#1394)。
6692
+ *
6693
+ * 書き順で 0 から詰め直していた間、`stack: 1` と書いた箱が 0 へ落ちていた。
6694
+ * 決定木のように「同じ高さに並ばない」 ことが図の意味そのものになる形では、
6695
+ * 詰めた瞬間に別の図になる (実測 = 3 段の木の根が 1 段目へ上がった)。
6696
+ *
6697
+ * 書かなかった箱は、その縦列で **空いている一番小さい段** に置く。 単に数え上げると
6698
+ * 書いた番号と重なり、2 つの箱が同じ段に載る。
6699
+ */
6700
+ const 埋まった段 = new Map<string, Set<number>>();
6701
+ const 埋める = (lid: string, stack: number): void => {
6702
+ const 集合 = 埋まった段.get(lid) ?? new Set<number>();
6703
+ 集合.add(stack);
6704
+ 埋まった段.set(lid, 集合);
6705
+ };
6706
+ for (const a of doc.actors) {
6707
+ if (a.lane !== undefined && a.stack !== undefined) 埋める(a.lane, a.stack);
6708
+ }
6193
6709
  doc.actors.forEach((a, idx) => {
6194
6710
  const id = slugify(a.name) || `n${idx}`;
6195
6711
  actorToNodeId.set(a.name, id);
6196
6712
  const lid = a.lane!;
6197
- const stack = 積んだ数.get(lid) ?? 0;
6198
- 積んだ数.set(lid, stack + 1);
6199
- b.node(id, { lane: lid, stack, kind: a.kind, title: a.name });
6713
+ let stack = a.stack;
6714
+ if (stack === undefined) {
6715
+ stack = 0;
6716
+ const 集合 = 埋まった段.get(lid);
6717
+ while (集合?.has(stack)) stack += 1;
6718
+ 埋める(lid, stack);
6719
+ }
6720
+ b.node(id, { lane: lid, stack, kind: a.kind, title: 箱の題(a) });
6200
6721
  });
6201
6722
  } else if (kind === "flow" || kind === "topology") {
6202
6723
  // 1 lane に全 actor を縦 stack
6203
6724
  const lid = opts.laneId ?? "main";
6204
- b.lane(lid, { width: laneWidth, label: doc.title, ...(kind === "topology" ? { contain: true } : {}) });
6725
+ b.lane(lid, {
6726
+ width: laneWidth,
6727
+ label: doc.title,
6728
+ ...(kind === "topology" ? { contain: true } : {}),
6729
+ });
6205
6730
  doc.actors.forEach((a, idx) => {
6206
6731
  const id = slugify(a.name) || `n${idx}`;
6207
6732
  actorToNodeId.set(a.name, id);
6208
- b.node(id, { lane: lid, stack: idx, kind: a.kind, title: a.name });
6733
+ b.node(id, { lane: lid, stack: idx, kind: a.kind, title: 箱の題(a) });
6209
6734
  });
6210
6735
  } else {
6211
6736
  // swimlane / er / state ... actor ごとに 1 lane (横並び)
@@ -6231,7 +6756,7 @@ function compileGenericWithAnimate(doc: DslDocument, opts: GenericOpts): CdlDiag
6231
6756
  lane: lid,
6232
6757
  stack: 0,
6233
6758
  kind: a.kind,
6234
- title: a.name,
6759
+ title: 箱の題(a),
6235
6760
  ...(isInitial ? { eyebrow: "初期" } : {}),
6236
6761
  ...(isFinal ? { eyebrow: "最終" } : {}),
6237
6762
  });
@@ -6264,8 +6789,11 @@ function compileGenericWithAnimate(doc: DslDocument, opts: GenericOpts): CdlDiag
6264
6789
  b.edge(fromId, toId, {
6265
6790
  id: edgeId,
6266
6791
  label: labelWithCard,
6267
- ...後ろへ戻る矢印か(kind, fromId, toId, 箱の並び) ? { routing: "back-detour" as const } : {},
6792
+ ...(後ろへ戻る矢印か(kind, fromId, toId, 箱の並び)
6793
+ ? { routing: "back-detour" as const }
6794
+ : {}),
6268
6795
  ...(s.sub ? { sub: s.sub } : {}),
6796
+ ...(s.side ? { side: s.side } : {}),
6269
6797
  ...(s.tone ? { tone: s.tone } : {}),
6270
6798
  ...(s.style ? { style: s.style } : {}),
6271
6799
  });