@cardenelabs/dragon 0.16.0 → 0.17.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
@@ -26,6 +26,7 @@ import type {
26
26
  FormulaAst,
27
27
  LaidDiagram,
28
28
  NodeKind,
29
+ RowMark,
29
30
  } from "@cardenelabs/cdl";
30
31
  import {
31
32
  sequence,
@@ -33,13 +34,12 @@ import {
33
34
  swimlane,
34
35
  er,
35
36
  stateMachine,
37
+ classDiagram,
38
+ FSM_ACTION_MARK,
39
+ sequenceStepId,
36
40
  topology,
37
41
  diagram,
38
42
  layout,
39
- rendersRows,
40
- requiredRowsHeight,
41
- requiredRowsWidth,
42
- NODE_KINDS,
43
43
  applyDerivedValues,
44
44
  parseFormula,
45
45
  extractIdentifiers,
@@ -162,8 +162,6 @@ export type CompileNotice = {
162
162
  | "flow-actor-missing"
163
163
  // きっかけ形の値を段に畳めなかった (段が無い / 相手が境目を通らない / 段からはみ出す、 #1161)
164
164
  | "value-trigger-unresolved"
165
- // 矢印の両端が同じ登場人物だった (#1227)
166
- | "flow-self-loop"
167
165
  // 箱に `lane:` を書いたが、 縦列は図種が決めるため効かなかった (#1246)
168
166
  | "lane-not-honored"
169
167
  // `lanes:` に書いた縦列に箱が 1 つも入らなかった (#1241)
@@ -179,7 +177,13 @@ export type CompileNotice = {
179
177
  // 式が、どこにも書かれていない名前を読んだ (#1391)
180
178
  | "formula-unresolved"
181
179
  // 出来事が指す相手が図に無い (#1393)
182
- | "event-target-missing";
180
+ | "event-target-missing"
181
+ // 順序図で面に種類を書いたが、板は名前と呼び名しか描かない (#1466)
182
+ | "actor-kind-not-honored"
183
+ // 箱の中の小さな箱に位置を書いたが、その名前の箱が図に無かった (#1466)
184
+ | "sub-node-not-found"
185
+ // 順序図の言づてに、板が描かない飾り (色味 / 添え字 / 寄せ) を書いた (#1466)
186
+ | "message-option-not-honored";
183
187
  /** 対象の名前。 光らせる相手なら書かれた指定そのまま */
184
188
  actor: string;
185
189
  /** 書かれていた行 */
@@ -208,9 +212,6 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
208
212
  // 解決できない矢印を組み立てから外す (#1219)。 残すと、 存在しない箱や枠を指す図ができて
209
213
  // 描画の直前で落ちる (実測 = 8 図種)
210
214
  doc = dropUnresolvedFlow(doc);
211
- // 自分へ戻る矢印を組み立てから外す (#1227)。 残すと描画側の検査が図ごと落とし、
212
- // 本文のどの行が原因かも出ない
213
- doc = dropSelfLoopFlow(doc);
214
215
 
215
216
  // 名前から作る id が重なる分を解く (#1220)。 **矢印を落とした後**に見る = 落とした矢印の
216
217
  // 端にしか出てこない名前で id を分けても、 その箱は作られない
@@ -309,11 +310,12 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
309
310
  // 矢印が指す名前が actors に居るかを確かめる。 図種ごとの解決より前に、 記述だけで決まる
310
311
  reportMissingFlowActors(書いたまま, opts?.onNotice);
311
312
  // 両端が同じ矢印を伝える (#1227)。 落とす前の `flow` を見る
312
- reportSelfLoopFlow(書いたまま, opts?.onNotice);
313
313
  // 静止した `type: flow` で書いた矢印の端が使われないことを伝える (#1269)。
314
314
  // 自分へ戻る形と居ない名前を指す形は既に落ちた後の `doc` を見る = 上の 2 件と重ねない
315
315
  reportFlowEndpointNotHonored(doc, 分けた.元の名前, opts?.onNotice);
316
316
  reportLaneNotHonored(書いたまま, opts?.onNotice);
317
+ reportActorKindNotHonored(書いたまま, opts?.onNotice);
318
+ reportMessageOptionNotHonored(書いたまま, opts?.onNotice);
317
319
  reportDocEyebrowNotHonored(書いたまま, opts?.onNotice);
318
320
  reportDrawNotHonored(書いたまま, opts?.onNotice);
319
321
  reportChartFieldsNotHonored(書いたまま, opts?.onNotice);
@@ -321,7 +323,7 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
321
323
  // `位置: Web の右` を実際の配置から絶対座標に直す。 以降は座標を直接書いた時と同じ経路
322
324
  const placed = resolveRelativeDoc(diagram, doc, opts?.onNotice, opts?.partsCatalog);
323
325
  // canvas pivot 新 spec = 全 preset 共通の post-process で actor.posX/Y を CDL lane / node に伝播
324
- applyCanvasPivotPositions(diagram, placed);
326
+ applyCanvasPivotPositions(diagram, placed, opts?.onNotice);
325
327
  // CAR-1657 = parts kind actor を merge (opts.partsCatalog 経由)、 applyV05Extensions 後段で実行
326
328
  const 追加した縦列: DslLane[] = [];
327
329
  const extended = applyV05Extensions(diagram, placed, 追加した縦列);
@@ -370,6 +372,13 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
370
372
  // 書いた状態を図に載せる (#1162)。 段を書かない図でも値が届くようにする。
371
373
  // **値を載せるより先に呼ぶ**。 状態が空のまま式を解くと、参照が全て「無い名前」 になる。
372
374
  materializeStates(merged, doc);
375
+ /*
376
+ * 矢印をいつ出すか (#1470)。 描き手がそのまま読む欄なので、書いた語を素通しする。
377
+ *
378
+ * **出口で 1 度だけ載せる**。 図の種類ごとに組み立てが分かれており、経路ごとに書くと
379
+ * どれかを見落とす (`injectStaticPhase` / `materializeStates` と同じ理由)。
380
+ */
381
+ if (doc.reveal !== undefined) merged.edgeReveal = doc.reveal;
373
382
  // 語の欄が状態を読むとき、その状態には記法の語が入っている。 図の語へ直す (#1201)
374
383
  語の状態を図の語へ直す(merged);
375
384
  // きっかけ形の値を段の時計を読む式へ畳む (#1161 段 2)。 **値を載せるより先に呼ぶ** =
@@ -740,56 +749,6 @@ function dropUnresolvedFlow(doc: DslDocument): DslDocument {
740
749
  return flow.length === doc.flow.length ? doc : { ...doc, flow };
741
750
  }
742
751
 
743
- /**
744
- * 自分へ戻る矢印を落とした `flow` を返す (#1227)。
745
- *
746
- * 描画側は両端が同じ矢印を受けない (`validate` が `self-loop` で落とす)。 記法の側は通すため、
747
- * 書けてしまって描画の直前で図ごと落ちていた。 本文のどの行が原因かも出ない。
748
- *
749
- * `#1219` が「解決できない矢印は組み立てから外し、 知らせは元の `flow` を見る」 という形を
750
- * 決めているので、 それに揃える。 落とすのは **組み立てに渡す分だけ** で、 書いた人には
751
- * `reportSelfLoopFlow` が行番号付きで伝える。
752
- *
753
- * ## `type: state` の自己遷移をどう扱うか
754
- *
755
- * 矢印としては描けない。 描画側に自分へ戻る矢印を足すのは別 repo の判断で、 本 repo の
756
- * 記法から決められない。 代わりに 2 通りの書き方が残る = 途中の箱を 1 つ足して 2 本の矢印に
757
- * 分けるか、 段 (`animation`) で状態が変わる様子として見せる。 知らせの `hint` がこの 2 つを
758
- * 案内する。
759
- *
760
- * 図全体を 1 箱で描く種別は矢印を作らないため対象にしない (本数を数えて別に伝えている)。
761
- */
762
- function dropSelfLoopFlow(doc: DslDocument): DslDocument {
763
- if (図種の作り[doc.type] === "図全体を 1 箱") return doc;
764
- const flow = doc.flow.filter((s) => s.from !== s.to);
765
- return flow.length === doc.flow.length ? doc : { ...doc, flow };
766
- }
767
-
768
- /**
769
- * 自分へ戻る矢印を書いた人に伝える (#1227)。
770
- *
771
- * **落とす前の `flow` を見る**。 落とした後を渡すと、 図が壊れないように外した矢印が
772
- * 書いた人に届かない (`reportMissingFlowActors` と同じ理由)。
773
- *
774
- * 同じ登場人物に何本書いても 1 件にまとめる = 本数だけ知らせが並んでも直し方は変わらない。
775
- */
776
- function reportSelfLoopFlow(doc: DslDocument, onNotice?: (n: CompileNotice) => void): void {
777
- if (!onNotice) return;
778
- if (図種の作り[doc.type] === "図全体を 1 箱") return;
779
- const 知らせた = new Set<string>();
780
- for (const s of doc.flow) {
781
- if (s.from !== s.to || 知らせた.has(s.from)) continue;
782
- 知らせた.add(s.from);
783
- onNotice({
784
- kind: "flow-self-loop",
785
- actor: s.from,
786
- line: s.pos.line,
787
- message: `"${truncateForMessage(s.from)}" から自分へ戻る矢印は描けません (組み立てから外しました)`,
788
- hint: "途中の箱を 1 つ足して 2 本に分けるか、 段 (animation) で状態が変わる様子として見せる",
789
- });
790
- }
791
- }
792
-
793
752
  /**
794
753
  * 図全体を 1 箱にする図種で、 その箱の上に出す小見出しを渡す (#1247)。
795
754
  *
@@ -913,8 +872,10 @@ function reportLaneMixed(doc: DslDocument, onNotice: (n: CompileNotice) => void)
913
872
  *
914
873
  * ## 既に落ちた行は見ない
915
874
  *
916
- * 自分へ戻る形 (`flow-self-loop`) と居ない名前を指す形 (`flow-actor-missing`)
917
- * 別の知らせが担う。 落とした後の `doc` を見ることで、同じ 1 行に知らせが 2 件並ぶのを避ける。
875
+ * 居ない名前を指す形 (`flow-actor-missing`) は別の知らせが担う。 落とした後の `doc`
876
+ * 見ることで、同じ 1 行に知らせが 2 件並ぶのを避ける。
877
+ *
878
+ * 自分へ戻る形は #1462 で落とさなくなった (描画側が輪として描ける)。
918
879
  */
919
880
  function reportFlowEndpointNotHonored(
920
881
  doc: DslDocument,
@@ -971,12 +932,83 @@ function reportFlowEndpointNotHonored(
971
932
  * (`compileMind` の「名前と副題 / 値、 枝の色しか描けません」)、 そこに `枠の指定` が既に
972
933
  * 入っている。 二重に伝えると同じ 1 行について知らせが 2 件並ぶ。
973
934
  */
935
+ /**
936
+ * 順序図で面に書いた飾りが使われないことを伝える (#1466)。
937
+ *
938
+ * 順序図は 1 つの板が図を丸ごと描く形になり、面は上端の見出しに **名前と呼び名だけ** で並ぶ。
939
+ * 面ごとの箱が無いので、種類 / 大きさ / 位置 / 行 / 色 / 小見出し / 値 / 図形を載せる先も無い。
940
+ * 黙って落とすと、書いた側は効いていると思い込む。
941
+ *
942
+ * 見本 (`parts`) を重ねた面は対象外 = 見本は別経路で図に取り込まれ、板の見出しには並ばない。
943
+ */
944
+ /**
945
+ * 順序図の言づてに書いた飾りが使われないことを伝える (#1466)。
946
+ *
947
+ * 板は言づてを **語と向きと種類** で描く。 色味 (`tone`) / 添え字 (`sub`) / 寄せ (`side`) を
948
+ * 載せる場所が無い = 矢印だった頃はその 3 つが矢印に付いていたが、板では行になった。
949
+ * 黙って落とすと、書いた側は効いていると思い込む。
950
+ */
951
+ function reportMessageOptionNotHonored(doc: DslDocument, onNotice?: (n: CompileNotice) => void): void {
952
+ if (!onNotice) return;
953
+ if (doc.type !== "sequence" && doc.type !== "solidity") return;
954
+ for (const s of doc.flow) {
955
+ const 効かない = [
956
+ s.tone !== undefined ? "色味" : "",
957
+ s.sub !== undefined ? "添え字" : "",
958
+ s.side !== undefined ? "寄せ" : "",
959
+ ].filter((x) => x !== "");
960
+ if (効かない.length === 0) continue;
961
+ onNotice({
962
+ kind: "message-option-not-honored",
963
+ actor: s.from,
964
+ line: s.pos?.line ?? 0,
965
+ message: `"${truncateForMessage(s.label)}" に書いた ${効かない.join(" / ")} は効きません (type: ${doc.type} の板は語と向きと種類だけを描きます)`,
966
+ hint: "板には矢印が無いため飾りを載せる先がありません。 言づての種類 (kind: call / return / fire) で描き分けてください",
967
+ });
968
+ }
969
+ }
970
+
971
+ function reportActorKindNotHonored(doc: DslDocument, onNotice?: (n: CompileNotice) => void): void {
972
+ if (!onNotice) return;
973
+ if (doc.type !== "sequence" && doc.type !== "solidity") return;
974
+ for (const a of doc.actors) {
975
+ if (a.partId !== undefined) continue;
976
+ const 効かない = [
977
+ /*
978
+ * 種類は `sequence` でだけ落ちる。
979
+ *
980
+ * `solidity` は種類で **面の並びを決める** (`compileSolidity`) ので、絵にならなくても
981
+ * 書いた意味は figure に出ている。 落ちたと伝えると、正しく効いている指定に毎回鳴る。
982
+ *
983
+ * 記法を通すと既定の `actor` が必ず入るため、値ではなく書いたかどうかの印で見る。
984
+ * 記法を通さず直接組み立てた場合はこの印が無いので、種類を置いたこと自体を「書いた」 とみなす
985
+ */
986
+ doc.type === "sequence" && a.kindWritten !== false && a.kind !== undefined ? "種類" : "",
987
+ a.posW !== undefined || a.posH !== undefined ? "大きさ" : "",
988
+ a.posX !== undefined || a.posY !== undefined || a.posRel !== undefined ? "位置" : "",
989
+ a.rows !== undefined ? "行" : "",
990
+ a.tone !== undefined ? "色" : "",
991
+ a.eyebrow !== undefined ? "小見出し" : "",
992
+ a.value !== undefined ? "値" : "",
993
+ a.shape !== undefined ? "図形" : "",
994
+ ].filter((x) => x !== "");
995
+ if (効かない.length === 0) continue;
996
+ onNotice({
997
+ kind: "actor-kind-not-honored",
998
+ actor: a.name,
999
+ line: a.pos?.line ?? 0,
1000
+ message: `"${truncateForMessage(a.name)}" に書いた ${効かない.join(" / ")} は効きません (type: ${doc.type} の板は名前と呼び名だけを描きます)`,
1001
+ hint: "板には面ごとの箱が無いため飾りを載せる先がありません。 呼び名 (subtitle) に書くか、箱を持つ図種を使ってください",
1002
+ });
1003
+ }
1004
+ }
1005
+
974
1006
  function reportLaneNotHonored(doc: DslDocument, onNotice?: (n: CompileNotice) => void): void {
975
1007
  if (!onNotice) return;
976
1008
  if (doc.type === "mind") return;
977
1009
  // 縦列を選べる図種では、全ての箱が書いていれば効く (#1263)。 効く形では知らせない
978
- const 効く図種 = 縦列を選べる図種.has(doc.type as GenericKind);
979
- if (効く図種 && 書いた縦列に置く(doc.type as GenericKind, doc)) return;
1010
+ const 効く図種 = 縦列を選べる図種.has(doc.type);
1011
+ if (効く図種 && 書いた縦列に置く(doc.type, doc)) return;
980
1012
  if (効く図種) {
981
1013
  reportLaneMixed(doc, onNotice);
982
1014
  return;
@@ -1709,7 +1741,12 @@ function applyNodeTones(diagram: CdlDiagram, doc: DslDocument): void {
1709
1741
  * slugify で actor 名 → lane id / node id の逆引き、 posX/Y set 済 actor に対応する lane / node に
1710
1742
  * 座標を書込む。 未指定 actor は従来 auto layout 経路そのまま。
1711
1743
  */
1712
- function applyCanvasPivotPositions(diagram: CdlDiagram, doc: DslDocument): void {
1744
+ function applyCanvasPivotPositions(
1745
+ diagram: CdlDiagram,
1746
+ doc: DslDocument,
1747
+ // 下見 (`probe`) の呼出では渡さない = 同じ知らせが 2 度出る
1748
+ onNotice?: (notice: CompileNotice) => void,
1749
+ ): void {
1713
1750
  for (const actor of doc.actors) {
1714
1751
  if (actor.partId !== undefined) continue; // parts actor は別経路 (mergePartsFromActors) で処理
1715
1752
  const aliasSlug = slugify(actor.name);
@@ -1743,14 +1780,32 @@ function applyCanvasPivotPositions(diagram: CdlDiagram, doc: DslDocument): void
1743
1780
  if (actor.nodes) {
1744
1781
  for (const [subKey, override] of Object.entries(actor.nodes)) {
1745
1782
  if (override.posX === undefined || override.posY === undefined) continue;
1783
+ let 当たった = 0;
1746
1784
  for (const node of diagram.nodes) {
1747
1785
  if (node.id === `${aliasSlug}-${subKey}` || node.id === `${subKey}-${aliasSlug}`) {
1748
1786
  node.posX = override.posX;
1749
1787
  node.posY = override.posY;
1750
1788
  if (override.posW !== undefined) node.posW = override.posW;
1751
1789
  if (override.posH !== undefined) node.posH = override.posH;
1790
+ 当たった += 1;
1752
1791
  }
1753
1792
  }
1793
+ /*
1794
+ * **当たらなかったことを伝える** (#1466)。
1795
+ *
1796
+ * この経路が動くのは、図種が 1 人につき複数の箱を作る時だけ。 順序図が名札 / 余白 /
1797
+ * 足を作っていた頃はそこに当たっていたが、板になって作らなくなった = いまはどの図種も
1798
+ * この形の箱を作らない。 黙って落とすと、書いた側は効いていると思い込む。
1799
+ */
1800
+ if (当たった === 0) {
1801
+ onNotice?.({
1802
+ kind: "sub-node-not-found",
1803
+ actor: actor.name,
1804
+ line: actor.pos?.line ?? 0,
1805
+ message: `"${truncateForMessage(actor.name)}" の nodes に書いた "${truncateForMessage(subKey)}" に当たる箱が図にありません`,
1806
+ hint: "1 人に複数の箱を作る図種でだけ効きます。 箱そのものの位置は actor 側の 位置: に書いてください",
1807
+ });
1808
+ }
1754
1809
  }
1755
1810
  }
1756
1811
  }
@@ -2275,6 +2330,20 @@ const PARTS_GAP = 120;
2275
2330
  */
2276
2331
  const STACK_PITCH = 280;
2277
2332
 
2333
+ /**
2334
+ * 既存の図が縦に何段ぶんを占めるかの目安 (#1481)。
2335
+ *
2336
+ * **箱の数では数えない**。 数で数えると、図を丸ごと 1 つの箱で描く種別が 1 段に潰れる。
2337
+ * 順序図は `#1466` で 1 枚の板になり、高さ 432 の箱 1 つになった = 1 段 (280) と見積もられ、
2338
+ * その下に置いたパーツが板に 31px 重なっていた (実測)。
2339
+ *
2340
+ * 大きさを自分で持つ箱は、その高さから段数を出す。 持たない箱は今までどおり 1 段と数えるので、
2341
+ * 高さを書かない図の並び方は変わらない。
2342
+ */
2343
+ export function partsBaseRows(nodes: ReadonlyArray<{ h?: number }>): number {
2344
+ return nodes.reduce((acc, n) => acc + Math.max(1, Math.ceil(positiveOr(n.h, 0) / STACK_PITCH)), 0);
2345
+ }
2346
+
2278
2347
  /**
2279
2348
  * 位置を書かなかったパーツを格子に並べた時の、 矩形の中心。
2280
2349
  *
@@ -2285,17 +2354,17 @@ const STACK_PITCH = 280;
2285
2354
  * 隣と重なる (実測 = 400 の次に 200 を置くと 280 重なった)。 段の高さも段内の最大高で揃える。
2286
2355
  * 縦は自分の高さの半分だけ段の上端から下げて、 段内で上端を揃える。
2287
2356
  *
2288
- * @param baseNodeCount パーツ以外の箱の数。 既存の図の下から並べ始めるために使う
2357
+ * @param baseRows 既存の図が占める段数の目安 (`partsBaseRows`)。 図の下から並べ始めるために使う
2289
2358
  */
2290
2359
  export function partsGridCenters(
2291
- baseNodeCount: number,
2360
+ baseRows: number,
2292
2361
  items: ReadonlyArray<{ id: string; w: number; h: number }>,
2293
2362
  ): Map<string, { cx: number; cy: number }> {
2294
2363
  const out = new Map<string, { cx: number; cy: number }>();
2295
2364
  if (items.length === 0) return out;
2296
2365
  // 公開している関数なので、 呼出側が渡す値を入口で閉じる。 数でない箱の数や桁溢れを
2297
2366
  // そのまま計算に入れると、 描けない座標を返すことになる
2298
- const safeCount = Number.isSafeInteger(baseNodeCount) && baseNodeCount >= 0 ? baseNodeCount : 0;
2367
+ const safeCount = Number.isSafeInteger(baseRows) && baseRows >= 0 ? baseRows : 0;
2299
2368
  const top = safeCount * STACK_PITCH + PARTS_GAP * 2;
2300
2369
  // 同じ名前が 2 度来たら先の方だけを見る。 後の分を残すと、 どちらを指したか決められない
2301
2370
  // まま列の送り幅にも影響する
@@ -2443,7 +2512,7 @@ function partGridCenters(
2443
2512
  extents.set(a.name, partFrameExtent(part, t.w, t.h));
2444
2513
  }
2445
2514
  const centers = partsGridCenters(
2446
- baseNodes.length,
2515
+ partsBaseRows(baseNodes),
2447
2516
  placedActors.map((a) => ({ id: a.name, ...extents.get(a.name)! })),
2448
2517
  );
2449
2518
  // merge に渡すのは段の中心。 矩形の中心とのずれを引く。 引かないと、 段ごとに箱の高さが
@@ -2530,9 +2599,24 @@ function cleanupPlaceholderActor(
2530
2599
  for (const n of target.nodes) {
2531
2600
  if (ownedLaneIds.has(n.lane)) ownedNodeIds.add(n.id);
2532
2601
  }
2602
+ /*
2603
+ * 素の登場人物が持つ id は消さない (#1466)。
2604
+ *
2605
+ * 下の頭一致は、見本のために作られた仮の箱 (`a-header` / `s0-a`) を拾うためのもの。
2606
+ * ところが名前が重なった素の登場人物は `重なりを解く` 側で `a-c40bf6` のような id に
2607
+ * 作り替えられるため、同じ頭で始まり **本体の箱まで巻き込む**。
2608
+ *
2609
+ * 見本と重なる名前を書いた図で、素の箱が黙って消えていた (実測で `flow` / `swimlane` /
2610
+ * `er` / `state` / `topology` / `class` / `c4` の 7 図種すべて)。 順序図だけは面ごとの
2611
+ * 縦列で引けたため免れており、#1466 で板になって縦列が無くなると同じ穴に落ちる。
2612
+ */
2613
+ const 素の箱のid = new Set(
2614
+ doc.actors.filter((x) => x.partId === undefined).map((x) => slugify(x.name)),
2615
+ );
2533
2616
  // actor 専用 lane を引き当てられない経路 (flow / topology 等の共有 lane preset) は従来どおり dragon
2534
2617
  // slug の prefix match に fallback する。 これらは 1 actor = 1 node (id = slug) の生成規則。
2535
2618
  const matchesAliasSlug = (id: string): boolean => {
2619
+ if (素の箱のid.has(id)) return false;
2536
2620
  if (id === aliasSlug) return true;
2537
2621
  if (id.startsWith(`${aliasSlug}-`)) return true;
2538
2622
  // sequence step anchor = `s{N}-{aliasSlug}` pattern
@@ -2565,15 +2649,24 @@ function cleanupPlaceholderActor(
2565
2649
  // (`_`/全角の扱い等)、 aliasSlug (dragon slugify) と lane.id が不一致になる actor 名がある。 lane.label
2566
2650
  // は両経路とも a.name 生値なので slug 差の影響を受けず確実に一致する。 id === aliasSlug は
2567
2651
  // label 未設定 preset への fallback (exact match のみ、 prefix は false match risk のため付けない)。
2568
- if (doc.type === "sequence" || doc.type === "solidity") {
2569
- target.lanes = target.lanes.filter((l) => {
2570
- // 明示 lane mapping (a.lane) 先は part の張替え先なので保持する。
2571
- if (a.lane !== undefined && l.id === a.lane) return true;
2572
- if (l.label === a.name) return false;
2573
- if (l.id === aliasSlug) return false;
2574
- return true;
2575
- });
2576
- }
2652
+ /*
2653
+ * **図種で分けない** (#1466) 以前は順序図系だけを掃除していたが、順序図が板になって
2654
+ * 面ごとの縦列を作らなくなり、この分岐は誰も通らなくなった。 一方で縦列を作る他の図種
2655
+ * (`swimlane` 等) では見本の仮の縦列が空のまま残っていた (実測)
2656
+ *
2657
+ * **中身が残っている縦列は消さない**。 1 本の縦列を全員で共有する図種 (`flow` / `topology`)
2658
+ * では、その縦列の名札がたまたま登場人物の名前と一致することがある。 消すと本体の箱が
2659
+ * 行き場を失う。
2660
+ */
2661
+ const 残る箱を持つ = new Set(target.nodes.map((n) => n.lane));
2662
+ target.lanes = target.lanes.filter((l) => {
2663
+ // 明示 lane mapping (a.lane) 先は part の張替え先なので保持する。
2664
+ if (a.lane !== undefined && l.id === a.lane) return true;
2665
+ if (残る箱を持つ.has(l.id)) return true;
2666
+ if (l.label === a.name) return false;
2667
+ if (l.id === aliasSlug) return false;
2668
+ return true;
2669
+ });
2577
2670
  // 削除された node / edge を activate 参照している既存 phase の cleanup (node 削除と同じ判定経路
2578
2671
  // = 取りこぼすと存在しない id が activate に残り dangling 参照になる、 #873)
2579
2672
  for (const phase of target.phases) {
@@ -3251,23 +3344,14 @@ function applyEdgeInlineOptions(
3251
3344
  return;
3252
3345
  }
3253
3346
  const used = new Set<string>();
3254
- // sequence preset では actor 名 が lane id、 edge.from`s{stepIdx}-{laneId}` 形式。
3255
- // solidity は sorted-actor を sequence preset 経由するため sequence と同形。
3256
- // それ以外 (flow / swimlane / er / state / topology / gantt / class / pie / c4 / mind) は
3257
- // edge.from / edge.to が plain slug (slugify(actor 名))。
3258
- const isSeqLike = doc.type === "sequence" || doc.type === "solidity";
3259
- doc.flow.forEach((s, stepIdx) => {
3347
+ // edge.from / edge.toplain slug (slugify(actor 名))。
3348
+ //
3349
+ // 順序図系 (`sequence` / `solidity`) はここに来ない = #1466 で板になり矢印を作らない。
3350
+ doc.flow.forEach((s) => {
3260
3351
  const fromId = slugify(s.from);
3261
3352
  const toId = slugify(s.to);
3262
3353
  const target = diagram.edges.find((e) => {
3263
3354
  if (used.has(e.id)) return false;
3264
- if (isSeqLike) {
3265
- // edge.from / edge.to は `s{stepIdx}-{laneId}` 命名規則
3266
- return (
3267
- (e.from === `s${stepIdx}-${fromId}` || e.from === fromId) &&
3268
- (e.to === `s${stepIdx}-${toId}` || e.from === e.to)
3269
- );
3270
- }
3271
3355
  return e.from === fromId && e.to === toId;
3272
3356
  });
3273
3357
  if (!target) return;
@@ -3340,6 +3424,15 @@ function 矢印へ書き写す(target: CdlEdge, s: DslStep, doc: DslDocument): v
3340
3424
  }
3341
3425
  // 矢印がどの辺から出るか (#1385)。 書かなければ描画側が自動で選ぶ
3342
3426
  if (s.side !== undefined) target.side = s.side;
3427
+ // 矢印の先の形 (#1462)。 書かない矢印には値を入れない = 既存の図が変わらない。
3428
+ //
3429
+ // **矢印を作る 9 図種すべてがここを通る**。 図種ごとの組み立てにも同じ形を置いたが、
3430
+ // 外しても 9 図種とも渡っていた (変異試験で実測) = 余分だったので消した
3431
+ if (s.head !== undefined) target.head = s.head;
3432
+ // 端の残り 3 欄 (#1466)。 出どころ側の印と、両端の塗り。 ER は端ごとに違う個数を示す
3433
+ if (s.tailHead !== undefined) target.tailHead = s.tailHead;
3434
+ if (s.headFill !== undefined) target.headFill = s.headFill;
3435
+ if (s.tailHeadFill !== undefined) target.tailHeadFill = s.tailHeadFill;
3343
3436
  if (s.labelOffsetX !== undefined) target.labelOffsetX = s.labelOffsetX;
3344
3437
  if (s.labelOffsetY !== undefined) target.labelOffsetY = s.labelOffsetY;
3345
3438
  if (s.overlay !== undefined) target.overlay = s.overlay;
@@ -3377,34 +3470,6 @@ function 鎖のどの行から来たか(doc: DslDocument, edgeIndex: number): Ds
3377
3470
  return doc.flow.find((s) => s.to === to.name);
3378
3471
  }
3379
3472
 
3380
- /**
3381
- * 描画側が大きさを持つ種別。
3382
- *
3383
- * 記法の `kind` は描画の種別より広い。 そのまま渡すと大きさを引けずに描画が落ちる
3384
- * (実測 = solidity の golden 4 件が `Cannot read properties of undefined`)。
3385
- */
3386
- const DRAWABLE_KINDS: ReadonlySet<string> = new Set(NODE_KINDS);
3387
-
3388
- /**
3389
- * 描画側に無い記法の種別を、 意味の近い描画の種別に読み替える。
3390
- *
3391
- * Solidity の記法は `eoa` / `contract` のように領域固有の語を使う。 描画側に同じ名前は無いが、
3392
- * 意味の対応する形はある (`shape-wallet` / `shape-smart-contract`)。 読み替えないと名札が
3393
- * 一律 `card` になり、 「書いたとおりの形になる」 が Solidity の図だけ成立しない。
3394
- *
3395
- * 並び順 (`compileSolidity` の `kindOrder`) はこの読み替えの前の値で決まる = 読み替えても
3396
- * 縦線の並びは変わらない。
3397
- */
3398
- const KIND_ALIAS: Readonly<Record<string, string>> = {
3399
- eoa: "shape-wallet",
3400
- wallet: "shape-wallet",
3401
- multisig: "signer",
3402
- contract: "shape-smart-contract",
3403
- proxy: "shape-smart-contract",
3404
- library: "shape-code-block",
3405
- interface: "shape-code-block",
3406
- };
3407
-
3408
3473
  /**
3409
3474
  * 記法の `values:` を図に載せる (#1162)。
3410
3475
  *
@@ -3908,6 +3973,7 @@ const VALUE_NOTICE_HINT: Readonly<Record<string, string>> = {
3908
3973
  "invalid-id": "名前に使えるのは英数字と _ だけ",
3909
3974
  };
3910
3975
 
3976
+
3911
3977
  /**
3912
3978
  * 図全体を 1 つの箱で描く種別。
3913
3979
  *
@@ -3936,253 +4002,6 @@ const SINGLE_BOX_KINDS: ReadonlySet<string> = new Set([
3936
4002
  "journey-map",
3937
4003
  ]);
3938
4004
 
3939
- /** 記法の種別を描画の種別に直す。 描けない種別のままなら `undefined`。 */
3940
- function drawableKind(kind: string | undefined): string | undefined {
3941
- if (kind === undefined) return undefined;
3942
- const mapped = KIND_ALIAS[kind] ?? kind;
3943
- return DRAWABLE_KINDS.has(mapped) ? mapped : undefined;
3944
- }
3945
-
3946
- /**
3947
- * 順序図の名札 (lifeline 上端 / 下端) の高さを揃える。
3948
- *
3949
- * `kind` を書いたとおりに載せると、 種別ごとに要る高さが変わる (行を持つ storage は 206、
3950
- * card は 72)。 揃えないと縦線の始まる位置がばらけ、 「同じ高さから下りる」 読み方が崩れる。
3951
- *
3952
- * 上端は最も高いものに合わせる。 下端も同じ値にする = 上下で形が違うと、 同じ登場人物が
3953
- * 別物に見える。
3954
- */
3955
- function alignSeqHeaderHeights(diagram: CdlDiagram, doc: DslDocument): void {
3956
- if (doc.type !== "sequence" && doc.type !== "solidity") return;
3957
- // 名札の id は `{laneId}-header` / `{laneId}-footer` の構造。 末尾の一致だけで見ると、
3958
- // 登場人物名が `Auth Header` の時に step の目印 `s0-auth-header` を拾い、 見えない 2px の
3959
- // 箱を名札の高さまで広げてしまう (#883 と同根)。
3960
- const isEnd = (n: CdlDiagram["nodes"][number]): boolean =>
3961
- n.id === `${n.lane}-header` || n.id === `${n.lane}-footer`;
3962
- const ends = diagram.nodes.filter(isEnd);
3963
- if (ends.length === 0) return;
3964
- // `posH` を書いた名札は揃えの外に置く。 「その名札だけを指定の大きさにし、 他には影響させない」
3965
- // という指定なので (`types.ts` の `nodes` override)、 値を変えるのも、 他の名札を引きずるのも
3966
- // 契約に反する (実測 = `posH: 400` を 1 つ書くと、 無関係な名札まで 72 → 400 になった)。
3967
- const auto = ends.filter((n) => n.posH === undefined);
3968
- if (auto.length === 0) return;
3969
- const tallest = Math.max(...auto.map((n) => n.h ?? 0));
3970
- if (tallest <= 0) return;
3971
- for (const n of auto) n.h = tallest;
3972
- }
3973
-
3974
- /**
3975
- * 名札に載せるのに要る高さ (world 単位)。
3976
- *
3977
- * 絵が箱の外に出る `shape-` のうち、 **高さを上げれば下のはみ出しが消える** 4 種だけを持つ。
3978
- *
3979
- * `actor` / `function` / `storage` / `event` の 4 種は `#1066` までここに載っていた。 描画側が
3980
- * 名前を箱の高さに関係なく固定の位置に置いていたため、 名札 (高さ 72) に載せると名前が下端を
3981
- * またいだ。 `cardene777/cdl#416` が小型用の配置を足して収まるようになったので外した
3982
- * (実測 = 名札の高さで下へ 52.3 から 63.8 の余裕、 目印がある場合でも 9.5 以上)。
3983
- *
3984
- * 値は実測 = 高さを 1 ずつ変えて描き、 絵の下端が箱の下端を越えなくなる最小の整数を取った
3985
- * (`type: flow` で `大きさ:` を書いて掃いた)。 **絵を変えたら測り直す**。
3986
- * 表が実際の描画と合っているかは `apps/playground-spa/tests/node-label-fit.spec.ts` が
3987
- * 両側 (この高さで収まる / 2 低いとはみ出す) を実 render で測って見る。
3988
- */
3989
- const LABEL_MIN_H: Readonly<Record<string, number>> = {
3990
- // `shape-` のうち、 高さを上げれば下のはみ出しが消える 4 種 (#1067)。 値は「収まる最小の高さ」
3991
- // で、 1 手前 (値 - 1) では 0.9-1 はみ出すことを実測した
3992
- "shape-person": 228, // 名札 72 で下へ 155.9
3993
- "shape-server-rack": 166, // 94
3994
- "shape-website": 98, // 26
3995
- "shape-warehouse": 79, // 7
3996
- };
3997
-
3998
- /**
3999
- * 名札の高さをどれだけ上げても収まらない種別 (#1067)。
4000
- *
4001
- * `shape-` を名札に書くと絵が箱の外に描かれる。 49 種すべてを実測したところ、 完全に収まるのは
4002
- * 5 種 (`shape-cloud` / `shape-window` / `shape-message-bubble` / `shape-token` /
4003
- * `shape-online-shop`) だけだった。
4004
- *
4005
- * ## 分ける基準は「名前が読めるか」 (#1106 で変わった)
4006
- *
4007
- * | 向き | 実害 | 扱い |
4008
- * |---|---|---|
4009
- * | 下 | 縦線が絵を貫く | 落とす |
4010
- * | 左右 | 隣の本とぶつかる (`shape-code-block` は右へ 132) | 落とす |
4011
- * | 上のみ | 何ともぶつからず図の外にも出ない | **原則残すが例外あり** |
4012
- *
4013
- * `#1067` は向きだけで分け、 上だけのはみ出しは 10 種すべて残した。 実測で viewBox の内側に
4014
- * 収まることを確かめており (最大の `shape-robot-arm` は上へ 129 だが余裕が 23)、 箱から出ても
4015
- * 読み手には壊れて見えないと判断したため。
4016
- *
4017
- * **その判断が実機で崩れた**。 `shape-wallet` は上へ 12.1 しか出ないのに、 絵が小さく潰れて
4018
- * 名前と重なり `EOA` が読めない状態だった。 はみ出し量では説明できない = 12.1 の
4019
- * `shape-wallet` を落とし、 129 の `shape-robot-arm` を残す。
4020
- *
4021
- * したがって **基準は「名前が読めるか」** で、 向きは目安にすぎない。 上だけに出る 10 種のうち
4022
- * 残るのは 9 種で、 分ける根拠は目視のみ (機械的な基準は無い)。
4023
- *
4024
- * ## ここに載るのは「高さで直らない」 種別だけ
4025
- *
4026
- * 下のはみ出しは高さで直ることがある。 4 種は `LABEL_MIN_H` に最小の高さを持たせ、 `rows` 等で
4027
- * 名札が高くなった図では書いたとおりの形で載る (`#1061` の「収まる高さがある時は書いたとおりに
4028
- * 載せる」 と同じ扱い)。
4029
- *
4030
- * こちらに載るのは 3 種類。 **左右にはみ出す 24 種** は横幅が高さで変わらないため直らない
4031
- * (実測 = `shape-smart-contract` は h=72 でも h=430 でも右へ 15.2)。 **下のはみ出しが高さに
4032
- * 依らない 6 種** は h を 72 から 600 まで上げても値が変わらない (実測 = `shape-stack` は
4033
- * 常に 36、 `shape-cylinder` は 150 以上で常に 1)。 **`shape-wallet`** は上のはみ出しが
4034
- * 高さで変わらず、 絵が潰れて名前と重なる (#1106)。
4035
- *
4036
- * ## 失うもの
4037
- *
4038
- * Solidity の読み替え (`#975`) 4 組のうち 3 組がここに入るため、 名札では `card` になる
4039
- * (`contract` / `proxy` → `shape-smart-contract`、 `library` / `interface` →
4040
- * `shape-code-block`、 `eoa` / `wallet` → `shape-wallet`)。 名札で形が残るのは
4041
- * `multisig` → `signer` だけ。
4042
- */
4043
- const LABEL_NEVER_FITS: ReadonlySet<string> = new Set([
4044
- // 左右にはみ出す 24 種。 横幅は高さで変わらないため直らない
4045
- "shape-api-gateway",
4046
- "shape-atm",
4047
- "shape-auditor",
4048
- "shape-bank",
4049
- "shape-bitcoin-chain",
4050
- "shape-blockchain",
4051
- "shape-blockchain-block",
4052
- "shape-blockchain-node",
4053
- "shape-brokerage",
4054
- "shape-code-block",
4055
- "shape-customer-service",
4056
- "shape-ethereum-chain",
4057
- "shape-hexagon",
4058
- "shape-kanban-card",
4059
- "shape-lawyer",
4060
- "shape-network-node",
4061
- "shape-nft",
4062
- "shape-notary",
4063
- "shape-regulator",
4064
- "shape-satellite",
4065
- "shape-smart-contract",
4066
- "shape-terminal",
4067
- "shape-trader",
4068
- "shape-trust-bank",
4069
- // 下のはみ出しが高さに依らない 6 種
4070
- "shape-cylinder",
4071
- "shape-diamond",
4072
- "shape-file",
4073
- "shape-folder",
4074
- "shape-mobile-device",
4075
- "shape-stack",
4076
- // 上へ出る絵が名札の大きさでは読めない。 `#1067` では「上は何ともぶつからない」 として残したが、
4077
- // 実際には絵が小さく潰れて名前と重なり、 横に並べた時も 1 本だけ頭が浮く (user 実機確認)
4078
- "shape-wallet",
4079
- ]);
4080
-
4081
- /**
4082
- * 名札が、 小型の `card` では描かれない文字を持つか。
4083
- *
4084
- * 小型の `card` (`h < 100`) が描くのは名前だけ。 `subtitle` と `eyebrow` は分岐で外れ、
4085
- * `value` は `card` が元から描かない。 `rows` を描くのは種別が限られる。
4086
- * どれか 1 つでも持つ名札を `card` に落とすと、 著者が書いた文字が画面から消える。
4087
- */
4088
- function hasAuthoredText(n: CdlDiagram["nodes"][number]): boolean {
4089
- if (n.subtitle !== undefined || n.eyebrow !== undefined || n.value !== undefined) return true;
4090
- return rendersRows(n.kind) && (n.rows?.length ?? 0) > 0;
4091
- }
4092
-
4093
- /**
4094
- * 絵が箱に収まらない `shape-` を名札から外す (#1061 / #1067)。
4095
- *
4096
- * `#975` が「書いた種別を名札に載せる」 挙動を入れ、 `#1058` が「書かなかった時は載せない」
4097
- * を直した。 残っていたのは **書いた時にはみ出す** 側で、 名札は小型の箱 (`h: 72`) なのに
4098
- * `shape-` は絵を自分の大きさで描くため、 絵が箱の外に出て縦線に貫かれる。
4099
- *
4100
- * `actor` / `function` / `storage` / `event` は `#1066` まで対象だった (名前を固定位置に置く
4101
- * ため名前が下端をまたいだ = actor 21.6 / function 21.6 / storage 13.6 / event 24.2 world px)。
4102
- * 描画側 (`cardene777/cdl#416`) が小さい箱で名前を中央に置くようになったので外した。
4103
- * **いま落とす理由は絵のはみ出しだけ**。
4104
- *
4105
- * **収まる高さがある時は書いたとおりに載せる**。 `rows` を書いた名札は
4106
- * `requiredRowsHeight` で 206 以上になり、 揃え (`alignSeqHeaderHeights`) がその高さを
4107
- * 全本に配る。 判定を揃えの後に置くのはこのため。
4108
- *
4109
- * ただし **揃えで届くのは表の値が 206 以下の種別だけ**。 `shape-warehouse` (79) /
4110
- * `shape-website` (98) / `shape-server-rack` (166) は収まるが、 `shape-person` (228) は
4111
- * 届かず `card` に落ちる (`rows` 1 件では 206 まで)。
4112
- *
4113
- * **著者が書いた文字を持つ名札は落とさない**。 小型の `card` は名前しか描かない
4114
- * (`subtitle` / `eyebrow` は `h < 100` の分岐で外れ、 `value` は元から描かない)。 落とすと
4115
- * 書いた文字が画面から消える = 絵がはみ出すより悪い。 `rows` と同じ扱いにする。
4116
- *
4117
- * 落とす時に失うものは、 種別ごとの絵と、 既定の配色での枠線の色。 本 app の配色は枠線の色を
4118
- * 上書きするため見た目は変わらないが、 既定の配色で使う利用者には差が出る。
4119
- * それでも落とすのは、 絵が箱の外に出る方が読み手に与える誤りが大きいため。
4120
- *
4121
- * 高さを上げる方向は採らない。 名札の高さは全本で揃える規約があるため 1 本の指定が全体に
4122
- * 伝播し、 全名札が 2-3 倍になる (`#1058` で実測、 golden 25 件が変化)。
4123
- *
4124
- * **判定は上下 1 組でする**。 `nodes` override で上端だけ大きさを書くと (`posH: 120`)、
4125
- * 上端は収まり下端 (72) は収まらないため、 1 つずつ見ると同じ登場人物の上下で形が変わる。
4126
- * 上下で形が違うと別物に見えるので、 どちらかが収まらなければ両方落とす。
4127
- *
4128
- * ## `shape-` も対象に含める (#1067)
4129
- *
4130
- * 当初は対象外にしていた。 これらは名前を箱ではなく自分の絵に対して置くため、 箱を基準に測ると
4131
- * 収まっていないように見えるだけだと考えたため。 49 種を実測すると **絵そのものが箱の外に出て
4132
- * 縦線に貫かれ、 隣の本ともぶつかって** いた。 どの種別をどう扱うかは `LABEL_NEVER_FITS` の
4133
- * 説明が SSOT。
4134
- *
4135
- * ## 覆っていない範囲
4136
- *
4137
- * **上だけにはみ出す `shape-` は 9 種を残す**。 何ともぶつからず図の外にも出ないため
4138
- * (`LABEL_NEVER_FITS` の説明を参照)。 箱の外に絵があること自体は直っておらず、 縦線が絵を貫く。
4139
- * 縦線の終点は描画側が箱の下端で決めており、 組み立て側からは変えられない。
4140
- *
4141
- * 10 種のうち `shape-wallet` だけは落とす (#1106)。 上へ 12.1 しか出ないのに絵が潰れて名前と
4142
- * 重なるため = 分ける基準ははみ出し量ではなく「名前が読めるか」。
4143
- *
4144
- * **著者が文字を書いた名札は落とさない**。 小型の `card` は名前しか描かないため、 落とすと
4145
- * 書いた文字が画面から消える。 この保護によって `shape-` は落ちずに残るが、 描画側が絵を箱に
4146
- * 収めるようになったので **下と左右には出ない** (`#1105`、 49 種を実測して 0)。 残るのは上だけで、
4147
- * 上の扱いはこの節の 1 つ目と同じ。
4148
- *
4149
- * `actor` / `function` / `storage` / `event` は `#1066` まで落とす対象だった。 描画側
4150
- * (`cardene777/cdl#416`) が小さい箱で名前を中央に置くようになったので外した = 名前がはみ出す
4151
- * 理由で落とす経路はもう無い。 いま落とすのは `shape-` だけで、 理由は絵のはみ出し。
4152
- */
4153
- function dropUnfittableEndKinds(diagram: CdlDiagram, doc: DslDocument): void {
4154
- if (doc.type !== "sequence" && doc.type !== "solidity") return;
4155
- const ends = new Map<string, CdlDiagram["nodes"]>();
4156
- for (const n of diagram.nodes) {
4157
- if (n.id !== `${n.lane}-header` && n.id !== `${n.lane}-footer`) continue;
4158
- const pair = ends.get(n.lane);
4159
- if (pair === undefined) ends.set(n.lane, [n]);
4160
- else pair.push(n);
4161
- }
4162
- for (const pair of ends.values()) {
4163
- // 著者が書いた文字を持つ名札は落とさない。 小型の `card` は名前しか描かないため、
4164
- // 落とすと書いた文字が画面から消える (`#387` と同じ壊れ方になる)。 これらは上端にしか
4165
- // 載らないため 1 組で見る。
4166
- if (pair.some(hasAuthoredText)) continue;
4167
- const 収まらない = pair.some((n) => {
4168
- // 高さを上げても直らない種別 (#1067)。 高さを見ずに落とす
4169
- if (LABEL_NEVER_FITS.has(n.kind)) return true;
4170
- const need = LABEL_MIN_H[n.kind];
4171
- if (need === undefined) return false;
4172
- // 描画で使う高さを見る。 `posH` が効くのは `posX` と `posY` が揃った node だけ
4173
- // (`layout/nodes.ts`)。 揃っていない node の `posH` を見ると、 描画では使われない値で
4174
- // 判定することになる。
4175
- const h = (n.posX !== undefined && n.posY !== undefined ? n.posH : undefined) ?? n.h;
4176
- // 高さを書いていない名札は描画側の既定 (`NODE_SIZE`、 4 種とも 170 以上) で描かれるので
4177
- // 収まる。 名札は必ず高さを持つため通常ここには来ない。
4178
- return h !== undefined && h < need;
4179
- });
4180
- if (!収まらない) continue;
4181
- for (const n of pair) {
4182
- if (LABEL_NEVER_FITS.has(n.kind) || LABEL_MIN_H[n.kind] !== undefined) n.kind = "card";
4183
- }
4184
- }
4185
- }
4186
4005
 
4187
4006
  /**
4188
4007
  * `(from, to)` の一致では取れない preset について、 edge と DSL の行の対応を埋める。
@@ -4495,44 +4314,78 @@ function compileGantt(doc: DslDocument): CdlDiagram {
4495
4314
  * 矢印は継承や保有を表す (`extends` / `aggregates` 等を書き手が説明に書く)。
4496
4315
  */
4497
4316
  function compileClass(doc: DslDocument): CdlDiagram {
4498
- const b = diagram(slugify(doc.title), { topic: doc.title });
4499
- const CLASS_W = 400;
4500
- // 縦列の幅は箱より広く取る。 組立て API 側の値に揃える (#1263)
4501
- const CLASS_LANE_W = 450;
4317
+ /*
4318
+ * **組み立て器 (`classDiagram`) に渡す** (#1466)。
4319
+ *
4320
+ * 以前は箱と矢印を直に組んでいたため、行頭の印・端の塗り・印が付く側・段の配置が
4321
+ * 1 つも出なかった = 画面の図と記法の図が別物になっていた。 組み立て器へ渡せば、
4322
+ * 意匠の決まり (`CLASS_RELATION_LOOK`) を 1 箇所から引ける。
4323
+ */
4324
+ const b = classDiagram({ id: slugify(doc.title), topic: doc.title });
4502
4325
  // 登場人物が 0 人なら枠も作らない。 先に作ると中身の無い枠が 1 つ残る (#1096)
4503
- if (doc.actors.length === 0) return b.build();
4326
+ if (doc.actors.length === 0) return diagram(slugify(doc.title), { topic: doc.title }).build();
4504
4327
 
4505
- // **クラスごとに縦列を 1 本作る** (#1263)。 組立て API 側がそう並べており、1 本にまとめると
4506
- // 同じ内容でも横並びが縦並びになる (実測 = 見本は 3 縦列 450 幅、記法は 1 縦列に縦積み)。
4507
- //
4508
- // 縦列に見出しは付けない。 クラスの名前は箱が既に描いており、縦列は並べるための入れ物
4509
- // (`er` / `state` と同じ扱い、#1241)
4510
- doc.actors.forEach((a, idx) => {
4511
- const nodeId = slugify(a.name) || `c${idx}`;
4512
- const laneId = `lane-${nodeId}`;
4513
- b.lane(laneId, { width: CLASS_LANE_W });
4514
- b.node(nodeId, {
4515
- lane: laneId,
4516
- stack: 0,
4517
- kind: "storage",
4328
+ /*
4329
+ * 縦列は `lane:` の順、段は `stack:` で決まる (#1466)。
4330
+ *
4331
+ * 書かない図は宣言した順に横 1 列 = 従来どおり。 1 つでも書けば格子に置く =
4332
+ * **箱の 1 つの辺には関係を 1 本まで** を守るには段が要る。
4333
+ */
4334
+ const 列番号 = new Map<string, number>();
4335
+ for (const a of doc.actors) {
4336
+ if (a.lane === undefined) continue;
4337
+ if (!列番号.has(a.lane)) 列番号.set(a.lane, 列番号.size);
4338
+ }
4339
+
4340
+ for (const a of doc.actors) {
4341
+ /*
4342
+ * 行を持ち物と振る舞いに割る (#1466)。 **1 行ずつ括弧の有無で見る**。
4343
+ *
4344
+ * 区切りの行 (`───`) の位置では割らない = 振る舞いしか持たない箱は区切りを書けず、
4345
+ * 全部が持ち物に落ちる (実測 = `Auditable` の `audit()` が四角の印で出た)。
4346
+ * 区切りの行そのものは、組み立て器が群の間を空の行で作るので捨てる。
4347
+ */
4348
+ const rows = (a.rows ?? []).filter((r) => !/^[─-]+$/.test(r.trim()));
4349
+ const 振る舞い = (r: string): boolean => r.includes("(");
4350
+ const attributes = rows.filter((r) => !振る舞い(r));
4351
+ const methods = rows.filter(振る舞い);
4352
+ b.class({
4353
+ id: slugify(a.name),
4518
4354
  title: 箱の題(a),
4519
- w: CLASS_W,
4355
+ ...(attributes.length > 0 ? { attributes: [...attributes] } : {}),
4356
+ ...(methods.length > 0 ? { methods: [...methods] } : {}),
4357
+ ...(a.eyebrow ? { stereotype: a.eyebrow } : {}),
4358
+ ...(a.lane !== undefined ? { col: 列番号.get(a.lane) ?? 0 } : {}),
4359
+ ...(a.stack !== undefined ? { row: a.stack } : {}),
4520
4360
  });
4521
- });
4361
+ }
4522
4362
 
4523
- for (const s of doc.flow) {
4524
- const fromId = slugify(s.from);
4525
- const toId = slugify(s.to);
4526
- b.edge(fromId, toId, {
4527
- label: s.label,
4528
- ...(s.sub ? { sub: s.sub } : {}),
4529
- ...(s.side ? { side: s.side } : {}),
4530
- ...(s.tone ? { tone: s.tone } : {}),
4531
- ...(s.style ? { style: s.style } : {}),
4363
+ for (const s2 of doc.flow) {
4364
+ b.relation({
4365
+ from: slugify(s2.from),
4366
+ to: slugify(s2.to),
4367
+ // 種類を書かない矢印は「使う」 扱い = 端が開いた矢になり、線と印の組が最も素直
4368
+ type: s2.relation ?? "uses",
4369
+ ...(s2.label ? { label: s2.label } : {}),
4370
+ ...(s2.sub ? { cardinality: s2.sub } : {}),
4371
+ ...(s2.tone ? { tone: s2.tone } : {}),
4372
+ ...(s2.style ? { style: s2.style } : {}),
4373
+ ...(s2.head ? { head: s2.head } : {}),
4374
+ ...(s2.headFill ? { headFill: s2.headFill } : {}),
4375
+ ...(s2.tailHead ? { tailHead: s2.tailHead } : {}),
4532
4376
  });
4533
4377
  }
4534
4378
 
4535
- return b.build();
4379
+ const built = b.build();
4380
+ /*
4381
+ * 記法が段を書いた図では、組み立て器が作る 1 つの段を捨てる (#1466)。
4382
+ *
4383
+ * 段の注入 (`injectPhasesFallback`) は「段が 1 つも無い」 図にだけ効く。 組み立て器へ
4384
+ * 渡すようにしたことで自動の段が 1 つ付き、書いた段が届かなくなった (実測 = 6 段書いた
4385
+ * 図が 1 段で出た)。
4386
+ */
4387
+ const 書いた段がある = (doc.animate?.phases.length ?? 0) > 0;
4388
+ return 書いた段がある ? { ...built, phases: [] } : built;
4536
4389
  }
4537
4390
 
4538
4391
  /**
@@ -5464,6 +5317,7 @@ function compileC4(doc: DslDocument): CdlDiagram {
5464
5317
  label: s.label,
5465
5318
  ...(s.sub ? { sub: s.sub } : {}),
5466
5319
  ...(s.side ? { side: s.side } : {}),
5320
+
5467
5321
  ...(s.tone ? { tone: s.tone } : {}),
5468
5322
  ...(s.style ? { style: s.style } : {}),
5469
5323
  });
@@ -5546,11 +5400,101 @@ type 放射で描ける欄 =
5546
5400
  *
5547
5401
  * 名前は図の中で 1 つに決まる必要がある (`focus:` と `flow:` が名前で指す) 一方、題は
5548
5402
  * 重なってよい。 同じ題の箱を並べる図と、題を持たない箱は、名前と切り離さないと書けない。
5403
+ *
5404
+ * **始まりと終わりの印は題を持たない** (#1466)。 塗った丸と輪で描くもので、名前を出す場所が
5405
+ * 無い。 名前は矢印の端として指すために要るので、名前をそのまま題にすると `begin` の字が
5406
+ * 丸の上に乗る (組み立て API 側の `.mark()` は題を空で作る)。 書いた題があればそれを使う。
5549
5407
  */
5408
+ const 題を持たない種類: ReadonlySet<string> = new Set(["mark-start", "mark-end"]);
5409
+
5550
5410
  function 箱の題(a: DslActor): string {
5411
+ if (a.title === undefined && a.kind !== undefined && 題を持たない種類.has(a.kind)) return "";
5551
5412
  return a.title ?? a.name;
5552
5413
  }
5553
5414
 
5415
+ /**
5416
+ * 行を組み立て器が加工する図の種類 (#1466)。
5417
+ *
5418
+ * ここに載る種類では、記法に書いた行をそのまま箱へ載せ直さない = 組み立て器が行頭の印に
5419
+ * 合わせて字を落としているため。
5420
+ */
5421
+ function 行を組み立て器が持つ(type: DslDocument["type"]): boolean {
5422
+ return type === "class";
5423
+ }
5424
+
5425
+ /**
5426
+ * 書いた語を行頭の印に読み替える (#1466)。
5427
+ *
5428
+ * **軸の意味は図の種類が決める**。 印そのものは 形 (四角 / 山形) × 塗り (塗る / 中空) の
5429
+ * 2 軸で共通だが、その軸が何を指すかは種類ごとに違う。
5430
+ *
5431
+ * | 種類 | 山形 | 塗り |
5432
+ * |---|---|---|
5433
+ * | `er` | 外を指す列 (`fk`) | 空にできない (`opt` を書かない) |
5434
+ * | `state` | 出入りの瞬間 (`entry` / `exit`) | 続く・入る側 (`entry` / `do`) |
5435
+ *
5436
+ * ER の `pk` は印の 2 軸とは別の段 (名前の下線) に載るので、`fk` と重ねて書ける。
5437
+ *
5438
+ * 語を 1 つも知らない図の種類では `null` を返す = 印を付けない。
5439
+ */
5440
+ /**
5441
+ * 行と印を組む (#1466)。 群の分け方は図の種類が決める。
5442
+ *
5443
+ * ER は **鍵の群を上にまとめ、間を空の行 1 つで開ける** (組み立て器 `er()` と同じ形)。
5444
+ * 記法に空の行を書かせないのは、`rows` が空の要素を捨てるため = 書いても消える。
5445
+ * 状態遷移は群を分けないので、書いた並びのまま。
5446
+ */
5447
+ function 行と印を組む(
5448
+ type: DslDocument["type"],
5449
+ rows: readonly string[],
5450
+ marks: readonly string[],
5451
+ ): { rows: string[]; rowMarks: (RowMark | null)[] } | null {
5452
+ const 印 = 行頭の印にする(type, marks);
5453
+ if (印 === null) return null;
5454
+ if (type !== "er") {
5455
+ return { rows: [...rows], rowMarks: rows.map((_, i) => 印[i] ?? null) };
5456
+ }
5457
+ const 鍵: number[] = [];
5458
+ const 値: number[] = [];
5459
+ rows.forEach((_, i) => ((印[i]?.underline === true ? 鍵 : 値).push(i)));
5460
+ const 並び = 鍵.length > 0 && 値.length > 0 ? [...鍵, -1, ...値] : [...鍵, ...値];
5461
+ return {
5462
+ rows: 並び.map((i) => (i < 0 ? "" : (rows[i] ?? ""))),
5463
+ rowMarks: 並び.map((i) => (i < 0 ? null : (印[i] ?? null))),
5464
+ };
5465
+ }
5466
+
5467
+ function 行頭の印にする(
5468
+ type: DslDocument["type"],
5469
+ marks: readonly string[],
5470
+ ): (RowMark | null)[] | null {
5471
+ if (type === "er") {
5472
+ return marks.map((m) => {
5473
+ const 語 = m.trim().split(/\s+/).filter(Boolean);
5474
+ /*
5475
+ * **語を書かない行は「ただの値」**。 印を付けない行にはしない (#1466)。
5476
+ *
5477
+ * ER の印は 2 軸とも既定を持つ = 四角 (外を指さない) で塗る (空にできない)。
5478
+ * 印なしにすると、書かなかった列だけ行頭が空いて群の間と見分けが付かなくなる。
5479
+ */
5480
+ return {
5481
+ shape: 語.includes("fk") ? ("chevron" as const) : ("square" as const),
5482
+ filled: !語.includes("opt"),
5483
+ ...(語.includes("pk") ? { underline: true } : {}),
5484
+ };
5485
+ });
5486
+ }
5487
+ if (type === "state") {
5488
+ return marks.map((m) => {
5489
+ const 語 = m.trim();
5490
+ if (語 === "") return null;
5491
+ const 表 = FSM_ACTION_MARK as Record<string, RowMark>;
5492
+ return 表[語] ?? null;
5493
+ });
5494
+ }
5495
+ return null;
5496
+ }
5497
+
5554
5498
  /** 放射では描けない欄。 書かれていたら伝える */
5555
5499
  type 放射で描けない欄 =
5556
5500
  | "kind"
@@ -5560,6 +5504,7 @@ type 放射で描けない欄 =
5560
5504
  | "owner"
5561
5505
  | "end"
5562
5506
  | "rows"
5507
+ | "marks"
5563
5508
  | "lane"
5564
5509
  | "stack"
5565
5510
  | "initial"
@@ -5625,6 +5570,7 @@ const 放射で描けない欄の名前: Record<放射で描けない欄, string
5625
5570
  owner: "担当 (工程の並びの欄)",
5626
5571
  end: "終わる時期 (工程の並びの欄)",
5627
5572
  rows: "行",
5573
+ marks: "印",
5628
5574
  lane: "枠の指定",
5629
5575
  stack: "積む順",
5630
5576
  initial: "始まり / 終わり の印",
@@ -5927,54 +5873,20 @@ function applyV05Extensions(
5927
5873
  // seq-like の非 animate 経路は実 node id を CDL preset 側 slugify (`_` → `-` 置換 + 全角正規化) で
5928
5874
  // 生成する。 dragon slugify (`_` / 全角 保持) で `{slug}-header` を決め打つと、 actor `A_B` の
5929
5875
  // primaryNodeId `a_b-header` が実 node `a-b-header` と食い違い、 inline option (subtitle / eyebrow /
5930
- // value / rows) が drop する (#881、 #873 / #877 と同根の dragon⇔CDL slug 不一致)。 lane.label は
5931
- // 両 slug 経路とも actor.name の生値なので (#877)、 actor 専用 lane を label 一致で引き当て、 その
5932
- // lane 内の `-header` node を権威 primary として回収する。 slug 決め打ちを廃して実装差を構造的に吸収。
5876
+ // value / rows) が drop する (#881、 #873 / #877 と同根の dragon⇔CDL slug 不一致)。
5933
5877
  //
5934
- // 経路を preset 種別 (isSeqLike) で分け、 かつ lane.label / node id actor.name の exact 一致で
5935
- // 引くことで、 actor "A Header" の slug `a-header` が actor "A" の node に漏れる cross-actor leak
5936
- // (#879) も同時に断つ。
5937
- const isSeqLike = doc.type === "sequence" || doc.type === "solidity";
5878
+ // **順序図系はここを通らない** (#1466) `sequence` / `solidity` 1 枚の板になり、面ごとの
5879
+ // 箱も縦列も作らなくなった = 書いた欄を写す相手が無い。 面に書いた内容が効かないことは
5880
+ // `reportActorKindNotHonored` が伝える。
5938
5881
  // actor inline option → node merge
5939
5882
  for (const a of doc.actors) {
5940
5883
  const dragonSlug = slugify(a.name);
5941
- let primaryNodes: CdlDiagram["nodes"];
5942
- if (isSeqLike) {
5943
- const ownedLaneIds = new Set(
5944
- diagram.lanes.filter((l) => l.label === a.name).map((l) => l.id),
5945
- );
5946
- // lane.label で actor 専用 lane を引けた場合はその lane の header node を回収する。 引けない
5947
- // (label 未設定等の) preset は従来どおり dragon slug の `{slug}-header` 決め打ちに fallback する。
5948
- //
5949
- // header node id は `{laneId}-header` の構造。 `endsWith("-header")` で判定すると step box
5950
- // `s{idx}-{laneId}` が actor 名末尾 "Header" (slug `...-header`) で誤マッチし、 option が invisible
5951
- // な step anchor にも copy される (cc-codex #883 MAJOR)。 lane id との構造 exact 一致で header だけを
5952
- // 引くことで step box / spacer / footer を排除する。
5953
- primaryNodes =
5954
- ownedLaneIds.size > 0
5955
- ? diagram.nodes.filter((n) => ownedLaneIds.has(n.lane) && n.id === `${n.lane}-header`)
5956
- : diagram.nodes.filter((n) => n.id === `${dragonSlug}-header`);
5957
- } else {
5958
- // 非 seq preset は 1 actor = 1 node (id = dragon slug) で node id と dragon slug が一致する。
5959
- primaryNodes = diagram.nodes.filter((n) => n.id === dragonSlug);
5960
- }
5884
+ // 1 actor = 1 node (id = dragon slug) で node id と dragon slug が一致する。
5885
+ const primaryNodes = diagram.nodes.filter((n) => n.id === dragonSlug);
5961
5886
  for (const node of primaryNodes) {
5962
5887
  // 識別に使う名前と、箱に出す題を分ける (#1381)。 preset が actor 名で
5963
5888
  // node を作る経路 (sequence / solidity / swimlane / c4) もここで書き換える。
5964
- if (a.title !== undefined) {
5965
- node.title = a.title;
5966
- if (isSeqLike) {
5967
- const footer = diagram.nodes.find((n) => n.id === `${node.lane}-footer`);
5968
- if (footer) footer.title = a.title;
5969
-
5970
- // 名前で決めた preset の幅を題に合わせる。 明示の大きさは後段が優先する。
5971
- if (a.posW === undefined) {
5972
- const titleW = Math.max(140, a.title.length * 22 + 52);
5973
- node.w = titleW;
5974
- if (footer) footer.w = titleW;
5975
- }
5976
- }
5977
- }
5889
+ if (a.title !== undefined) node.title = a.title;
5978
5890
  // `type: c4` では説明の先頭に段の目印 (`L1` / `L2` / `L3`) を書く。 目印は組み立てに
5979
5891
  // 段を伝えるためのもので読む人に意味を持たず、 段の名前は枠のラベルが既に出している。
5980
5892
  // ここで落とさないと、 組み立てが読み取った目印がそのまま箱の説明として出る (#1098)
@@ -5982,74 +5894,42 @@ function applyV05Extensions(
5982
5894
  if (説明 !== undefined) node.subtitle = 説明;
5983
5895
  if (a.eyebrow !== undefined) node.eyebrow = a.eyebrow;
5984
5896
  if (a.value !== undefined) node.value = a.value;
5985
- if (a.rows !== undefined) node.rows = a.rows;
5897
+ /*
5898
+ * 行は **組み立て器が持つ図では上書きしない** (#1466)。
5899
+ *
5900
+ * クラス図は行頭の印を出すために、公開の記号 (`+` / `-`) と呼び出しの括弧を字から
5901
+ * 落とし、群の区切り (`───`) を空の行に置き換える。 書いた字をそのまま載せ直すと
5902
+ * その加工が消え、印と字が同じことを 2 度言う形に戻る (実測)。
5903
+ */
5904
+ if (a.rows !== undefined && !行を組み立て器が持つ(doc.type)) node.rows = a.rows;
5905
+ // 行頭の印 (#1466)。 書いた語を図の種類ごとの意味で読み、群の分け方も種類が決める
5906
+ if (a.marks !== undefined && a.rows !== undefined) {
5907
+ const 組 = 行と印を組む(doc.type, a.rows, a.marks);
5908
+ if (組 !== null) {
5909
+ node.rows = 組.rows;
5910
+ node.rowMarks = 組.rowMarks;
5911
+ }
5912
+ }
5913
+ /*
5914
+ * **行を持たない状態でも欄を置く** (#1466)。
5915
+ *
5916
+ * 描き手は `rowMarks` の有無で新しい意匠かどうかを決める (`storage.tsx`)。 行の無い箱で
5917
+ * 欄ごと省くと、その箱だけ従来の意匠に落ちて呼び名が消える (組み立て API 側の
5918
+ * `stateMachine` は同じ理由で空の欄を置いている)。
5919
+ */
5920
+ if (doc.type === "state" && node.kind === "storage" && node.rowMarks === undefined) {
5921
+ node.rows = a.rows ?? [];
5922
+ node.rowMarks = [];
5923
+ }
5986
5924
  // 箱の大きさを反映する (#1259)。 **animation の有無に関係なく** = 動く図専用の
5987
5925
  // 組み立てだけで渡すと、同じ記法でも静止図では指定が消える。
5988
5926
  //
5989
- // 順序図は名札 / 余白 / 足を組で作り、大きさが縦線の並びと結びつくため対象外。
5990
- //
5991
5927
  // **幅が図に出るかは縦列との大小で決まる**。 縦列に収まれば箱だけが変わり、
5992
5928
  // 縦列より広ければ縦列ごと押し広げる (実測 = ステート図の 320 は縦列 370 に収まって
5993
5929
  // 図が変わらないが、拡張ステート図の 280 に対し既定 640 は縦列 330 を押し広げた)。
5994
5930
  // #1260 で 1 件だけ見て「見た目に出ない」 と判断し配線を外した = 同じ誤りを繰り返さない
5995
- if (!isSeqLike) {
5996
- if (a.posW !== undefined) node.w = a.posW;
5997
- if (a.posH !== undefined) node.h = a.posH;
5998
- }
5999
- // seq-like preset の header / footer は kind を card 固定で作る。 書いた kind を載せる
6000
- // (#975)。 載せないと「書いたのに効かない項目」 が残り、 `rows` を書いた時は行が card に
6001
- // 付いて画面から消える (#387、 cdl 側 Axis 67 rows-not-rendered が検知する)。
6002
- //
6003
- // 以前は「行を描く kind かつ rows あり」 に絞っていた。 header の見た目を kind ごとに
6004
- // 変えると読み方が変わることを懸念したためだが、 **書いたとおりにならない方が読み手を
6005
- // 惑わせる**。 見本 412 図で影響を受けるのは 1 図 (4 actor) だけと実測した。
6006
- // 書いた種別を名札に載せる。 描画側に無い語は意味の近い形に読み替える (#975)。
6007
- //
6008
- // **書いた時だけ載せる** (#1058)。 `kind` は書かなくても既定の `actor` が入るため、
6009
- // 値だけを見ると「書かなかった」 が「`actor` と書いた」 に化ける。 名札は小型の箱
6010
- // (`h: 72`) で作られ、 描画側は `card` に小型用の分岐を持つが `actor` には無い。
6011
- // 既定値で上書きすると小型の分岐が外れ、 名前の文字が箱の下端をはみ出す。
6012
- //
6013
- // 判定は `!== false` で行う。 記法の parse は書かなかった時に `false` を明示するので
6014
- // これで区別できる。 `=== true` にすると、 **記法を通さず `DslActor` を直接組み立てて
6015
- // `compileToCdl()` を呼ぶ経路** (公開 API) が既定の `undefined` で全て「書かなかった」
6016
- // に倒れ、 書いた種類が消える (#975 の挙動が壊れる)。
6017
- const drawn = isSeqLike && a.kindWritten !== false ? drawableKind(a.kind) : undefined;
6018
- if (drawn !== undefined) {
6019
- node.kind = drawn as typeof node.kind;
6020
- // 下端の名札も同じ形にする。 上下で形が違うと、 同じ登場人物が別物に見える。
6021
- // `rows` は上端にだけ載る (`primaryNodes` が上端しか拾わない) ので、 行は 2 度出ない。
6022
- const footer = diagram.nodes.find((n) => n.id === `${node.lane}-footer`);
6023
- if (footer) footer.kind = drawn as typeof node.kind;
6024
- }
6025
- // 行を書いた時は枠に収まる高さと幅にする。 header は w / h を固定値で作られ、 cdl 側は
6026
- // `n.w` / `n.h` を明示した node の自動拡張を尊重する (著者指定を壊さない) 設計なので、
6027
- // preset が置いた固定値がそのまま残る。
6028
- //
6029
- // 必要な寸法は cdl の SSOT (`requiredRowsHeight` / `requiredRowsWidth`) から引く。
6030
- // 式を dragon 側に写すと、 描画を変えた時に片方だけ古くなる。
6031
- if (isSeqLike && a.rows !== undefined && a.rows.length > 0 && rendersRows(a.kind)) {
6032
- node.h = Math.max(node.h ?? 0, requiredRowsHeight(a.kind, a.rows.length) ?? 0);
6033
- // **種別も渡す**。 省くと cdl 側は「どちらで描かれるか分からない」 として広い方を返す
6034
- // ため、 実際に描かれる書式より名札が広くなる。 高さと同じく種別を渡す。
6035
- //
6036
- // 差が出るのは比例の書式の方が広くなる行 = 比例は 1 字の最大が字の大きさの 1.038 倍
6037
- // (`W`) で 18 なら 18.7、 等幅の 26 は 1 字 15.6。 実測 = `W` を 10 個並べた行を持つ
6038
- // `storage` の名札が、 種別なしで 268 / 種別あり で 256。
6039
- node.w = Math.max(node.w ?? 0, requiredRowsWidth(a.rows, a.kind));
6040
- }
6041
- // 名札の大きさも書いたとおりにする (#975)。 縦線の位置は `位置:` の x が lane に効く
6042
- // (実測) が、 大きさはどこにも載っていなかった。
6043
- //
6044
- // 高さは指定をそのまま使わず、 揃える側 (`alignSeqHeaderHeights`) に渡す候補にする。
6045
- // 1 本だけ高い名札を作ると、 縦線の始まる位置がばらける。
6046
- if (isSeqLike) {
6047
- // 書いた値をそのまま使う。 大きい方を採ると、 縮める指定 (`大きさ: 80,60`) が効かない。
6048
- if (a.posW !== undefined) node.w = a.posW;
6049
- if (a.posH !== undefined) node.h = a.posH;
6050
- const footer = diagram.nodes.find((n) => n.id === `${node.lane}-footer`);
6051
- if (footer && a.posW !== undefined) footer.w = a.posW;
6052
- }
5931
+ if (a.posW !== undefined) node.w = a.posW;
5932
+ if (a.posH !== undefined) node.h = a.posH;
6053
5933
  // 箱の中に描く図形 (#1374)。 renderer が shape を描くのは dyn-* kind だけなので、
6054
5934
  // shape 自身を SSOT にして対応する kind へ揃える。 card 等のまま shape だけ渡すと、指定を
6055
5935
  // 保持しているのに画面には何も出ない。 paint 検査が入力を mutate しないよう object も写す。
@@ -6057,29 +5937,13 @@ function applyV05Extensions(
6057
5937
  const dynamicKind = `dyn-${a.shape.kind}` as typeof node.kind;
6058
5938
  node.kind = dynamicKind;
6059
5939
  node.shape = { ...a.shape };
6060
- if (isSeqLike) {
6061
- const footer = diagram.nodes.find((n) => n.id === `${node.lane}-footer`);
6062
- if (footer) {
6063
- footer.kind = dynamicKind;
6064
- footer.shape = { ...a.shape };
6065
- }
6066
- }
6067
- }
6068
- // その箱を出すかどうかの条件 (#1381)。 名札と足にも同じ条件を渡す = 片方だけ隠すと
6069
- // 順序図で縦線の頭と足が食い違う
6070
- if (a.visibleIf !== undefined) {
6071
- node.visibleIf = a.visibleIf;
6072
- if (isSeqLike) {
6073
- const footer = diagram.nodes.find((n) => n.id === `${node.lane}-footer`);
6074
- if (footer) footer.visibleIf = a.visibleIf;
6075
- }
6076
5940
  }
5941
+ // その箱を出すかどうかの条件 (#1381)
5942
+ if (a.visibleIf !== undefined) node.visibleIf = a.visibleIf;
6077
5943
  /*
6078
5944
  * 値に追随する 5 欄 (#1392)。
6079
5945
  *
6080
- * **名札と足へは渡さない**。 `visibleIf` は片方だけ隠すと順序図の縦線の頭と足が
6081
- * 食い違うため揃えるが、こちらは見た目の大きさ / 濃さ / ずらしで、名札まで同じだけ
6082
- * 動かすと縦線の頭が本体から離れる。 書いた箱にだけ効かせる。
5946
+ * 見た目の大きさ / 濃さ / ずらしを、書いた箱にだけ効かせる。
6083
5947
  */
6084
5948
  if (a.wBind !== undefined) node.wBind = a.wBind;
6085
5949
  if (a.hBind !== undefined) node.hBind = a.hBind;
@@ -6088,13 +5952,6 @@ function applyV05Extensions(
6088
5952
  if (a.renderOffsetY !== undefined) node.renderOffsetY = a.renderOffsetY;
6089
5953
  }
6090
5954
  }
6091
- // 名札の高さを揃える。 kind ごとに高さが変わると縦線の始まる位置がばらけ、 順序図の
6092
- // 「同じ高さから下りる」 読み方が崩れる (実測 = 行を持つ名札だけ 134px 下にずれた)。
6093
- alignSeqHeaderHeights(diagram, doc);
6094
- // 揃えた後の高さで、 絵が箱に収まらない `shape-` を名札から外す (#1061 / #1067、 #1066 で
6095
- // 4 種を対象から外した)。 揃えは高さを上げる方向にしか動かないので、 ここで見れば
6096
- // 「行を書いた図では書いた種別が残る」 が成立する。
6097
- dropUnfittableEndKinds(diagram, doc);
6098
5955
  // v0.5+ animation phase 後段注入 (CAR-1657 fix、 元 dragon PR #413 report user)。
6099
5956
  // preset (class / pie / c4 / mind / gantt) が doc.animate を無視して build するケースを補償。
6100
5957
  // 既に preset が phase を生成済 (sequence / flow / swimlane / er / state / topology 経由 = compileGenericWithAnimate) なら skip。
@@ -6261,14 +6118,23 @@ function injectPhasesFallback(diagram: CdlDiagram, doc: DslDocument): void {
6261
6118
  function compileSequence(doc: DslDocument): CdlDiagram {
6262
6119
  // v0.3 ... アニメーション 有無で経路を分岐。
6263
6120
  // 有り = builder 直接経路で state / 複数 phase を注入。
6264
- // 無し = v0.2 と同じく sequence preset の標準 phase を採用。
6265
- if (doc.animate && doc.animate.phases.length > 0) {
6266
- return compileSequenceWithAnimate(doc);
6267
- }
6121
+ /*
6122
+ * **段があっても組み立て器へ渡す** (#1466)
6123
+ *
6124
+ * 順序図は 1 つの箱が図を丸ごと描く形になり、言づては箱の中の行になった。 段ごとに
6125
+ * 別経路で箱と縦線を組む形 (`compileSequenceWithAnimate`) では、その骨格が出ない。
6126
+ */
6268
6127
  const seqBuilder = sequence({
6269
6128
  id: slugify(doc.title),
6270
6129
  topic: doc.title,
6271
- actors: doc.actors.map((a) => a.name),
6130
+ /*
6131
+ * 見出しに出すのは **書いた題** (#1466)。 名前は矢印の端として指すためのもので、
6132
+ * `title:` を書いたらそちらを出す (`箱の題`)。 板でも他の図種と同じ規約にする。
6133
+ */
6134
+ actors: doc.actors.map((a) =>
6135
+ a.subtitle ? { name: 箱の題(a), subtitle: a.subtitle } : 箱の題(a),
6136
+ ),
6137
+ ...(doc.bands && doc.bands.length > 0 ? { bands: doc.bands } : {}),
6272
6138
  });
6273
6139
  for (const s of doc.flow) {
6274
6140
  seqBuilder.step({
@@ -6279,192 +6145,52 @@ function compileSequence(doc: DslDocument): CdlDiagram {
6279
6145
  ...(s.side ? { side: s.side } : {}),
6280
6146
  ...(s.tone ? { tone: s.tone } : {}),
6281
6147
  ...(s.style ? { style: s.style } : {}),
6148
+ ...(s.msgKind ? { kind: s.msgKind } : {}),
6282
6149
  });
6283
6150
  }
6284
- return seqBuilder.build();
6285
- }
6286
-
6287
- /**
6288
- * v0.3 ... アニメーション full compile (sequence 向け)。
6289
- * sequence preset と同じ構造 (lane / header / spacer / step box / footer) を builder 直接で組み立て、
6290
- * 標準の 1 phase DSL の複数 phase に置き換える。
6291
- *
6292
- * 標準 phase 1 個 → DSL phases N 個に展開。
6293
- * state / tween / set / badge / body / highlight 全反映。
6294
- */
6295
- function compileSequenceWithAnimate(doc: DslDocument): CdlDiagram {
6296
- const b = diagram(slugify(doc.title), { topic: doc.title });
6297
- const laneW = 340;
6298
-
6299
- // actor → lane id / header / footer / spacer の slug 生成 (sequence preset と整合)
6300
- const actorIds = new Map<string, string>();
6301
- const headerNodeIds: string[] = [];
6302
- doc.actors.forEach((a, i) => {
6303
- const id = slugify(a.name) || `actor-${i}`;
6304
- actorIds.set(a.name, id);
6305
- actorIds.set(id, id);
6306
- // canvas pivot 新 spec = actor.posX/posY set 済なら CDL layout skip 経路に流す。
6307
- // sequence preset の lane はここで生成、 posW/posH は lane 全体の rect を上書き。
6308
- const laneOpts: Parameters<typeof b.lane>[1] = { width: laneW, label: a.name, lifeline: true };
6309
- if (a.posX !== undefined && a.posY !== undefined) {
6310
- laneOpts.posX = a.posX;
6311
- laneOpts.posY = a.posY;
6312
- if (a.posW !== undefined) laneOpts.posW = a.posW;
6313
- if (a.posH !== undefined) laneOpts.posH = a.posH;
6314
- }
6315
- b.lane(id, laneOpts);
6316
- const headerId = `${id}-header`;
6317
- // header/footer 幅を title 長に応じて auto-size (text-readability warning 解消)。
6318
- // formula = 22px/char + 52px padding (visualValidate text-readability と完全一致)、 min 140 で従来 sample 互換維持。
6319
- const actorW = Math.max(140, 箱の題(a).length * 22 + 52);
6320
- b.node(headerId, { lane: id, stack: 0, kind: "card", title: 箱の題(a), w: actorW, h: 72 });
6321
- headerNodeIds.push(headerId);
6322
- const spacerId = `${id}-spacer`;
6323
- b.node(spacerId, { lane: id, stack: 1, kind: "card", title: "", w: 2, h: 40 });
6324
- });
6325
-
6326
- // step boxes (sequence preset と同じ命名 ... `s${idx}-${laneId}` / `e${idx}-${from}-${to}`)
6327
- // step ごとに DSL flow item に対応、 actor 名 → lane id の slugify を活用。
6328
- const stepEdgeIds: string[] = [];
6329
- doc.flow.forEach((s, idx) => {
6330
- // 解決できない名前の矢印は落とす (#1209)。 以前は名前をそのまま lane id として使い、
6331
- // 存在しない lane に箱を置いた図ができていた
6332
- const fromLaneId = actorIds.get(s.from);
6333
- const toLaneId = actorIds.get(s.to);
6334
- if (fromLaneId === undefined || toLaneId === undefined) return;
6335
- const stack = idx + 2;
6336
- const fromBoxId = `s${idx}-${fromLaneId}`;
6337
- const toBoxId = `s${idx}-${toLaneId}`;
6338
- b.node(fromBoxId, { lane: fromLaneId, stack, kind: "card", title: "", w: 2, h: 2 });
6339
- if (fromLaneId !== toLaneId) {
6340
- b.node(toBoxId, { lane: toLaneId, stack, kind: "card", title: "", w: 2, h: 2 });
6341
- }
6342
- const edgeId = `e${idx}-${fromLaneId}-${toLaneId}`;
6343
- b.edge(fromBoxId, fromLaneId === toLaneId ? fromBoxId : toBoxId, {
6344
- id: edgeId,
6345
- label: s.label,
6346
- ...(s.sub ? { sub: s.sub } : {}),
6347
- ...(s.side ? { side: s.side } : {}),
6348
- ...(s.tone ? { tone: s.tone } : {}),
6349
- ...(s.style ? { style: s.style } : {}),
6350
- });
6351
- stepEdgeIds.push(edgeId);
6352
- });
6353
-
6354
- // footer (sequence preset と整合)
6355
- const footerStack = doc.flow.length + 2;
6356
- doc.actors.forEach((a) => {
6357
- const laneId = actorIds.get(a.name) ?? slugify(a.name);
6358
- const footerId = `${laneId}-footer`;
6359
- const actorW = Math.max(140, 箱の題(a).length * 22 + 52);
6360
- // `role` を付ける (#1273)。 付けないと生命線の終わりが footer より 100 下まで伸びる
6361
- // (実測 = 組立て API は `y2=848`、記法は `y2=948`)。 枠の大きさは同じなので
6362
- // 描いた図の大きさの比較では捕まらない
6363
- b.node(footerId, {
6364
- lane: laneId,
6365
- stack: footerStack,
6366
- kind: "card",
6367
- title: 箱の題(a),
6368
- w: actorW,
6369
- h: 72,
6370
- role: "lifeline-footer",
6371
- });
6372
- });
6373
-
6374
- // state を builder に登録
6375
- for (const st of doc.animate?.states ?? []) {
6376
- b.state(st.name, { initial: st.initial });
6377
- }
6378
-
6379
- // phase を順次注入 ... highlight / tween / set / badge / body 全反映
6380
- for (const p of doc.animate?.phases ?? []) {
6381
- b.phase(
6382
- slugify(p.name) || p.name,
6383
- {
6151
+ const built = seqBuilder.build();
6152
+ const 段 = doc.animate?.phases ?? [];
6153
+ if (段.length === 0) return built;
6154
+ /*
6155
+ * 書いた段を「今どの言づてか」 に読み替える (#1466)。
6156
+ *
6157
+ * 記法は段ごとに光らせる矢印を並べる (`focus: [A -> B, ...]`) が、言づては矢印ではなく
6158
+ * 箱の中の行になった = 光らせる先が無い。 代わりに **その段までに出た言づての番号** を
6159
+ * 状態へ書き、箱がそこまでを描く。
6160
+ */
6161
+ const 番号 = new Map<string, number>();
6162
+ doc.flow.forEach((f, i) => 番号.set(`${slugify(f.from)} -> ${slugify(f.to)}`, i));
6163
+ const 状態名 = sequenceStepId();
6164
+ return {
6165
+ ...built,
6166
+ states: [...built.states, { id: 状態名, initial: "0" }],
6167
+ phases: 段.map((p, i) => {
6168
+ const = Math.max(
6169
+ 0,
6170
+ ...(p.highlight ?? []).map(
6171
+ (f) => 番号.get(f.split("->").map((x) => slugify(x.trim())).join(" -> ")) ?? -1,
6172
+ ),
6173
+ );
6174
+ return {
6175
+ id: `p${i}`,
6384
6176
  duration: p.durationMs,
6385
6177
  title: p.name,
6386
6178
  body: p.body ?? "",
6387
- },
6388
- (pb) => {
6389
- // highlight ... DSL の name (actor 名 or "from→to") を実 id に解決
6390
- const activateIds = resolveHighlight(p, doc, actorIds, stepEdgeIds);
6391
- if (activateIds.length > 0) {
6392
- pb.activate(...activateIds);
6393
- }
6394
- // tween
6395
- for (const t of p.tweens ?? []) {
6396
- pb.tween(t.state, t.from, t.to);
6397
- }
6398
- // set
6399
- for (const s of p.sets ?? []) {
6400
- pb.set(s.state, s.value);
6401
- }
6402
- // badge
6403
- if (p.badge) {
6404
- pb.badge(p.badge);
6405
- }
6406
- return pb;
6407
- },
6408
- );
6409
- }
6410
-
6411
- return b.build();
6179
+ activate: [slugify(doc.title)],
6180
+ ...(p.badge ? { badge: p.badge } : {}),
6181
+ /*
6182
+ * **書いた状態の動きも一緒に運ぶ** (#1466)。 板の段は「今どの言づてか」 を状態に
6183
+ * 書くが、記法は同じ段に `遷移:` / `切替:` も書ける。 板の分だけを載せると、
6184
+ * 書いた動きが黙って落ちる (実測で `tweens` が 0 件になっていた)
6185
+ */
6186
+ sets: [...(p.sets ?? []).map((x) => ({ stateId: x.state, value: x.value })), { stateId: 状態名, value: 番 }],
6187
+ tweens: (p.tweens ?? []).map((t) => ({ stateId: t.state, from: t.from, to: t.to })),
6188
+ };
6189
+ }),
6190
+ };
6412
6191
  }
6413
6192
 
6414
- /**
6415
- * DSL の highlight item (actor 名 or "A→B" or "A-B" 等) を実 node/edge id に解決する。
6416
- */
6417
- function resolveHighlight(
6418
- phase: DslPhase,
6419
- doc: DslDocument,
6420
- actorIds: Map<string, string>,
6421
- _stepEdgeIds: string[],
6422
- ): string[] {
6423
- const out: string[] = [];
6424
- const knownNames = new Set(actorIds.keys());
6425
- for (const raw of phase.highlight ?? []) {
6426
- const entry = parseFocusEntry(raw, knownNames);
6427
- // 矢印つき → 該当 step edge を全部探して active
6428
- if (entry.kind === "edge") {
6429
- const fromLaneId = actorIds.get(entry.from) ?? slugify(entry.from);
6430
- const toLaneId = actorIds.get(entry.to) ?? slugify(entry.to);
6431
- // 該当 edge を flow から検索
6432
- doc.flow.forEach((s, idx) => {
6433
- const sFromId = actorIds.get(s.from) ?? slugify(s.from);
6434
- const sToId = actorIds.get(s.to) ?? slugify(s.to);
6435
- if (sFromId === fromLaneId && sToId === toLaneId) {
6436
- out.push(`e${idx}-${fromLaneId}-${toLaneId}`);
6437
- }
6438
- });
6439
- // 関連する step box も active 化
6440
- const stackIdx = doc.flow.findIndex((s) => {
6441
- const sFromId = actorIds.get(s.from) ?? slugify(s.from);
6442
- const sToId = actorIds.get(s.to) ?? slugify(s.to);
6443
- return sFromId === fromLaneId && sToId === toLaneId;
6444
- });
6445
- if (stackIdx >= 0) {
6446
- out.push(`s${stackIdx}-${fromLaneId}`);
6447
- if (fromLaneId !== toLaneId) out.push(`s${stackIdx}-${toLaneId}`);
6448
- }
6449
- continue;
6450
- }
6451
- // actor 名 → header + footer + 全 step box を active
6452
- const laneId = actorIds.get(entry.name) ?? slugLookup(actorIds, entry.name);
6453
- if (laneId) {
6454
- out.push(`${laneId}-header`);
6455
- out.push(`${laneId}-footer`);
6456
- // この lane の全 step box
6457
- doc.flow.forEach((s, idx) => {
6458
- const sFromId = actorIds.get(s.from) ?? slugify(s.from);
6459
- const sToId = actorIds.get(s.to) ?? slugify(s.to);
6460
- if (sFromId === laneId || sToId === laneId) {
6461
- out.push(`s${idx}-${laneId}`);
6462
- }
6463
- });
6464
- }
6465
- }
6466
- return out;
6467
- }
6193
+
6468
6194
 
6469
6195
  /**
6470
6196
  * 名前が見つからない時に、 slug の形でも探す。
@@ -6564,6 +6290,7 @@ function compileSwimlane(doc: DslDocument): CdlDiagram {
6564
6290
  label: s.label,
6565
6291
  ...(s.sub ? { sub: s.sub } : {}),
6566
6292
  ...(s.side ? { side: s.side } : {}),
6293
+
6567
6294
  ...(s.tone ? { tone: s.tone } : {}),
6568
6295
  ...(s.style ? { style: s.style } : {}),
6569
6296
  });
@@ -6740,6 +6467,7 @@ function compileTopology(doc: DslDocument): CdlDiagram {
6740
6467
  label: s.label,
6741
6468
  ...(s.sub ? { sub: s.sub } : {}),
6742
6469
  ...(s.side ? { side: s.side } : {}),
6470
+
6743
6471
  ...(s.tone ? { tone: s.tone } : {}),
6744
6472
  ...(s.style ? { style: s.style } : {}),
6745
6473
  });
@@ -6792,10 +6520,20 @@ function 後ろへ戻る矢印か(
6792
6520
  * 縦列を **箱を並べるための入れ物** として使う図種だけを許す。 順序図と solidity は
6793
6521
  * 縦列がそのまま生命線として描かれる骨格なので許さない (#1248 の判断はこちらに当たる)。
6794
6522
  *
6795
- * `er` / `state` / `class` は「1 縦列 1 箱」 が図の読み方そのもの ( / 状態 / クラスが
6796
- * 横に並ぶ) なので、2 つの箱を同じ縦列へ入れられる形にはしない。
6523
+ * `er` は「1 縦列 1 箱」 が図の読み方そのもの (表が横に並ぶ) なので、2 つの箱を同じ縦列へ
6524
+ * 入れられる形にはしない。
6525
+ *
6526
+ * **クラス図と状態遷移図は外した** (#1466)。 設計が格子に置く形になり (クラス 3 列 3 段 /
6527
+ * 状態 2 列 5 段)、「箱の 1 つの辺には関係を 1 本まで」 を守るには 1 つの縦列に複数の箱が要る。
6528
+ * 「1 縦列 1 箱」 が読み方だった前提はここで崩れている。
6797
6529
  */
6798
- const 縦列を選べる図種: ReadonlySet<GenericKind> = new Set(["flow", "topology", "swimlane"]);
6530
+ const 縦列を選べる図種: ReadonlySet<PresetType> = new Set<PresetType>([
6531
+ "flow",
6532
+ "topology",
6533
+ "swimlane",
6534
+ "class",
6535
+ "state",
6536
+ ]);
6799
6537
 
6800
6538
  /**
6801
6539
  * 書いた縦列に箱を置く形か (#1263)。
@@ -6806,7 +6544,7 @@ const 縦列を選べる図種: ReadonlySet<GenericKind> = new Set(["flow", "top
6806
6544
  *
6807
6545
  * 見本 (parts) は縦列を張替え先として使うため、この判定からは外す。
6808
6546
  */
6809
- function 書いた縦列に置く(kind: GenericKind, doc: DslDocument): boolean {
6547
+ function 書いた縦列に置く(kind: PresetType, doc: DslDocument): boolean {
6810
6548
  if (!縦列を選べる図種.has(kind)) return false;
6811
6549
  const 対象 = doc.actors.filter((a) => a.partId === undefined);
6812
6550
  return 対象.length > 0 && 対象.every((a) => a.lane !== undefined);
@@ -6958,6 +6696,7 @@ function compileGenericWithAnimate(doc: DslDocument, opts: GenericOpts): CdlDiag
6958
6696
  : {}),
6959
6697
  ...(s.sub ? { sub: s.sub } : {}),
6960
6698
  ...(s.side ? { side: s.side } : {}),
6699
+
6961
6700
  ...(s.tone ? { tone: s.tone } : {}),
6962
6701
  ...(s.style ? { style: s.style } : {}),
6963
6702
  });