@cardenelabs/dragon 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +78 -5
  2. package/dist/index.cjs +9246 -8698
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +186 -66
  5. package/dist/index.d.ts +186 -66
  6. package/dist/index.js +9247 -8700
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/compile/actor-ids.ts +306 -0
  10. package/src/compile/actors.ts +74 -0
  11. package/src/compile/c4.ts +77 -0
  12. package/src/compile/chain.ts +54 -0
  13. package/src/compile/chart-fields.ts +178 -0
  14. package/src/compile/class.ts +100 -0
  15. package/src/compile/deep-rewrite.ts +30 -0
  16. package/src/compile/derived-source.ts +18 -0
  17. package/src/compile/direction.ts +63 -0
  18. package/src/compile/er-relation.ts +200 -0
  19. package/src/compile/er.ts +44 -0
  20. package/src/compile/flow.ts +54 -0
  21. package/src/compile/funnel.ts +75 -0
  22. package/src/compile/gantt.ts +232 -0
  23. package/src/compile/generic.ts +284 -0
  24. package/src/compile/hierarchy.ts +92 -0
  25. package/src/compile/journey.ts +84 -0
  26. package/src/compile/kinds.ts +156 -0
  27. package/src/compile/lanes.ts +56 -0
  28. package/src/compile/mind.ts +343 -0
  29. package/src/compile/node-title.ts +24 -0
  30. package/src/compile/notice.ts +94 -0
  31. package/src/compile/parts-lookup.ts +17 -0
  32. package/src/compile/parts.ts +2635 -0
  33. package/src/compile/placement-apply.ts +673 -0
  34. package/src/compile/placement.ts +15 -0
  35. package/src/compile/quadrant.ts +110 -0
  36. package/src/compile/rows.ts +40 -0
  37. package/src/compile/sequence.ts +226 -0
  38. package/src/compile/slug.ts +29 -0
  39. package/src/compile/solidity.ts +39 -0
  40. package/src/compile/state.ts +71 -0
  41. package/src/compile/subtitle.ts +22 -0
  42. package/src/compile/swimlane.ts +83 -0
  43. package/src/compile/topology.ts +50 -0
  44. package/src/compile/tree.ts +60 -0
  45. package/src/compile/value-chart.ts +183 -0
  46. package/src/compile/values.ts +432 -0
  47. package/src/compile/word-state.ts +82 -0
  48. package/src/compile.ts +513 -6858
  49. package/src/index.ts +8 -7
  50. package/src/json-line-map.ts +30 -0
  51. package/src/json-parser.ts +356 -123
  52. package/src/near-name.ts +42 -0
  53. package/src/relative-pos.ts +71 -2
  54. package/src/schema.ts +1 -1
  55. package/src/schemas/diagram.json +38 -4
  56. package/src/types.ts +1 -1
  57. package/src/v05/parser.ts +91 -72
@@ -0,0 +1,54 @@
1
+ import { diagram, flow } from "@cardenelabs/cdl";
2
+ import type { CdlDiagram } from "@cardenelabs/cdl";
3
+ import type { DslDocument } from "../types";
4
+ import { compileGenericWithAnimate } from "./generic";
5
+ import { 描ける種別 } from "./kinds";
6
+ import { 鎖でつなぐ形か, 鎖に並べる登場人物 } from "./chain";
7
+ import { 箱の題 } from "./node-title";
8
+ import { slugify } from "./slug";
9
+ export function compileFlow(
10
+ doc: DslDocument,
11
+ /** 鎖に並べない部品を決める一覧 (#1987) */
12
+ partsCatalog?: Record<string, CdlDiagram>,
13
+ ): CdlDiagram {
14
+ // v0.4 ... animation あり時 builder 直接経路で複数 phase 注入
15
+ // **縦列を書いた形は動きの有無に関わらず generic 経路へ** (#1263)。 動く図だけで効かせると、
16
+ // 同じ記法でも静止図では指定が黙って消える (実測 = 縦列 3 本のはずが 1 本になり知らせも出ない)
17
+ // **向きを書いた形も generic 経路へ** (#1494)。 静止図の経路は並びを固定で持つので、
18
+ // ここを通さないと書いた向きが黙って消える (縦列を書いた形と同じ理由)
19
+ //
20
+ // 振り分けは行の対応と端の知らせと同じ判定で行う (#1986)
21
+ if (!鎖でつなぐ形か(doc)) {
22
+ return compileGenericWithAnimate(doc, { kind: "flow", laneId: "main", laneWidth: 400 });
23
+ }
24
+ // 登場人物が 0 人なら枠も作らない。 描画側の `flow()` は枠を必ず 1 つ作るため、 そのまま
25
+ // 通すと中身の無い枠が残る (実測 = `title` と `type` だけの本文で枠 `flow` が空)。
26
+ // 枠を持たない図として返す = `swimlane` / `c4` が 0 人で枠 0 になるのと揃う。
27
+ // 鎖に並べる登場人物で見る = 行に書かれていない部品しか居ない図も枠を作らない (#1987)
28
+ const 並び = 鎖に並べる登場人物(doc, partsCatalog);
29
+ if (並び.length === 0) {
30
+ return diagram(slugify(doc.title), { topic: doc.title, type: "flow" }).build();
31
+ }
32
+ // flow preset は actors を順に step として配置、 step 間に edge auto
33
+ const flowBuilder = flow({
34
+ id: slugify(doc.title),
35
+ topic: doc.title,
36
+ });
37
+ // 各 actor を step として登録、 edge label は流れ から拾う
38
+ for (const a of 並び) {
39
+ // 名前から id を作れない時の番号は、書いた順の番号にする (部品を飛ばしても変わらない)
40
+ const i = doc.actors.indexOf(a);
41
+ // 直前の step との edge label = この actor を to に持つ flow から拾う
42
+ const incomingEdge = doc.flow.find((s) => s.to === a.name);
43
+ const edgeLabel = incomingEdge?.label;
44
+ flowBuilder.step(
45
+ {
46
+ id: slugify(a.name) || `n${i}`,
47
+ kind: 描ける種別(a.kind),
48
+ title: 箱の題(a),
49
+ },
50
+ edgeLabel,
51
+ );
52
+ }
53
+ return flowBuilder.build();
54
+ }
@@ -0,0 +1,75 @@
1
+ import { diagram } from "@cardenelabs/cdl";
2
+ import type { CdlDiagram } from "@cardenelabs/cdl";
3
+ import type { DslDocument } from "../types";
4
+ import {
5
+ parseChartValue,
6
+ 数の欄から参照できる名前,
7
+ 参照する名前,
8
+ 図表の大きさ,
9
+ } from "./chart-fields";
10
+
11
+ import { 箱の題 } from "./node-title";
12
+ import type { CompileNotice } from "./notice";
13
+ import { slugify } from "./slug";
14
+ import { 図の小見出し } from "./subtitle";
15
+ /**
16
+ * 漏斗 (`type: funnel`) の組み立て (#1154)。 描画側に 1 つの箱で渡す。
17
+ *
18
+ * 登場人物ごとに **数** を読む (`- 訪問: "12000"`)。 段の人数なので負の数は読めない値として扱う。
19
+ */
20
+ export function compileFunnel(doc: DslDocument, onNotice?: (n: CompileNotice) => void): CdlDiagram {
21
+ const b = diagram(slugify(doc.title), { topic: doc.title, type: "funnel" });
22
+ const { w: W, h: H } = 図表の大きさ.funnel;
23
+ b.lane("chart", { width: W + 64 });
24
+ const data: NonNullable<CdlDiagram["nodes"][number]["funnelData"]> = [];
25
+ const 読めない: string[] = [];
26
+ const 未宣言: string[] = [];
27
+ const 参照できる = 数の欄から参照できる名前(doc);
28
+ for (const a of doc.actors) {
29
+ const v = parseChartValue(a.value ?? a.subtitle);
30
+ // 段の数なので負に意味が無い (状態を読む欄は符号が決まらないので通す)
31
+ if (v === null || (typeof v === "number" && v < 0)) {
32
+ 読めない.push(a.name);
33
+ continue;
34
+ }
35
+ // 数にならない参照は落とす (棒 / 折れ線 / 円と同じ扱い)
36
+ const 名前 = 参照する名前(v);
37
+ if (名前 !== null && !参照できる.has(名前)) {
38
+ 未宣言.push(a.name);
39
+ continue;
40
+ }
41
+ data.push({ id: slugify(a.name), title: 箱の題(a), count: v });
42
+ }
43
+ if (未宣言.length > 0) {
44
+ const m3 = `type: funnel で数にならない値を参照した項目があります (段に載せません): ${未宣言.join(", ")}。 \`states:\` にその名前を数で書いてください`;
45
+ onNotice?.({ kind: "chart-value-unreadable", actor: 未宣言[0]!, line: 0, message: m3 });
46
+ if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${m3}`);
47
+ }
48
+ if (読めない.length > 0) {
49
+ const m = `type: funnel で数を読めない項目があります (段に載せません): ${読めない.join(", ")}。 \`- 訪問: "12000"\` の形で書いてください`;
50
+ onNotice?.({ kind: "chart-value-unreadable", actor: 読めない[0]!, line: 0, message: m });
51
+ if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${m}`);
52
+ }
53
+ // 矢印は描けない。 書かれていたら伝える (黙って捨てると「書いたのに効かない」 が残る)
54
+ if (doc.flow.length > 0) {
55
+ const m2 = `type: funnel では矢印を描けません (${doc.flow.length} 本を無視しました)。 関係を描くなら type: flow を使ってください`;
56
+ onNotice?.({
57
+ kind: "chart-edge-dropped",
58
+ actor: doc.flow[0]?.from ?? "",
59
+ line: doc.flow[0]?.pos?.line ?? 0,
60
+ message: m2,
61
+ });
62
+ if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${m2}`);
63
+ }
64
+ b.node(`${slugify(doc.title) || "funnel"}-chart`, {
65
+ lane: "chart",
66
+ stack: 0,
67
+ kind: "funnel-stages",
68
+ title: doc.title,
69
+ ...図の小見出し(doc),
70
+ w: W,
71
+ h: H,
72
+ funnelData: data,
73
+ });
74
+ return b.build();
75
+ }
@@ -0,0 +1,232 @@
1
+ import { diagram } from "@cardenelabs/cdl";
2
+ import type { CdlDiagram } from "@cardenelabs/cdl";
3
+ import type { DslDocument } from "../types";
4
+
5
+ import { actorRefTable } from "./actors";
6
+ import { 書いた多重度を読む } from "./er-relation";
7
+ import type { CompileNotice } from "./notice";
8
+ import { slugify } from "./slug";
9
+ import { 箱の題 } from "./node-title";
10
+ import { truncateForMessage, 図の小見出し } from "./subtitle";
11
+ /**
12
+ * 工程表 (`type: gantt`) の組み立て (#2030 で `compile.ts` から移した)。
13
+ */
14
+
15
+ export function compileGantt(doc: DslDocument, onNotice?: (n: CompileNotice) => void): CdlDiagram {
16
+ const b = diagram(slugify(doc.title), { topic: doc.title, type: "gantt" });
17
+ const CHART_W = 720;
18
+ b.lane("gantt", { width: CHART_W });
19
+
20
+ /**
21
+ * 書いたのに効かない形を伝える。 **`console.warn` だけにしない** (#2111) = 編集画面は知らせだけを
22
+ * 画面に出すため、log だけだと書いている人に理由が見えない。 値の図 (`value-chart.ts`) と同じ形
23
+ */
24
+ const 伝える = (種類: CompileNotice["kind"], 名前: string, message: string, line = 0): void => {
25
+ onNotice?.({ kind: 種類, actor: 名前, line, message });
26
+ if (typeof console !== "undefined" && console.warn) console.warn(`[dragon] ${message}`);
27
+ };
28
+
29
+ // 目盛りは **書かれた順** に並べる。 以前は `Q1=200 / Q2=600 / ...` の決め打ちで、 Q1-Q4 以外は
30
+ // 全て同じ位置に落ちていた。 順に並べれば月名でも週番号でも同じ規則で置ける
31
+ const 目盛り: string[] = [];
32
+ const 目盛りなし: string[] = [];
33
+ // 最初に時期を読めなかった項目の行。 画面が案内できるようにする (値の図と同じ)
34
+ let 目盛りなし行 = 0;
35
+ const タスク: {
36
+ name: string;
37
+ title: string;
38
+ label: string;
39
+ line: number;
40
+ tone?: DslDocument["actors"][number]["tone"];
41
+ owner?: string;
42
+ end?: string;
43
+ }[] = [];
44
+ for (const a of doc.actors) {
45
+ const label = (a.value ?? a.subtitle ?? "").trim();
46
+ if (label === "") {
47
+ if (目盛りなし.length === 0) 目盛りなし行 = a.pos?.line ?? 0;
48
+ 目盛りなし.push(a.name);
49
+ continue;
50
+ }
51
+ if (!目盛り.includes(label)) 目盛り.push(label);
52
+ // 色は帯にそのまま渡す。 箱が 1 つになっても、 書いた色が消えないようにする
53
+ タスク.push({
54
+ name: a.name,
55
+ title: 箱の題(a),
56
+ label,
57
+ line: a.pos?.line ?? 0,
58
+ ...(a.tone !== undefined ? { tone: a.tone } : {}),
59
+ ...(a.owner !== undefined ? { owner: a.owner } : {}),
60
+ ...(a.end !== undefined ? { end: a.end } : {}),
61
+ });
62
+ }
63
+ if (目盛りなし.length > 0) {
64
+ 伝える(
65
+ "chart-value-unreadable",
66
+ 目盛りなし[0]!,
67
+ `type: gantt で時期を読めない項目があります (帯に載せません): ${目盛りなし.join(", ")}。` +
68
+ ` \`- 設計: "Q1"\` の形で書いてください`,
69
+ 目盛りなし行,
70
+ );
71
+ }
72
+
73
+ // 矢印は依存として読む (`- 設計 -> 実装` = 実装は設計の後)。 帯どうしを結ぶ線は描画側が
74
+ // 依存として描くので、 書いた矢印を捨てずに使う
75
+ const タスク名 = new Set(タスク.map((t) => t.name));
76
+ // 矢印の端の名前を引く表。 全ての図種に共通の「居ない名前」 の知らせと同じ表を使う
77
+ const 名前の表 = actorRefTable(doc);
78
+ const 依存元 = new Map<string, string>();
79
+ for (const s of doc.flow) {
80
+ const line = s.pos?.line ?? 0;
81
+ const 矢印 = `${truncateForMessage(s.from)} -> ${truncateForMessage(s.to)}`;
82
+ if (!タスク名.has(s.from) || !タスク名.has(s.to)) {
83
+ // `actors` に居ない名前を指す矢印は、全ての図種に共通の知らせ (`flow-actor-missing`) が
84
+ // 書いた行で伝えている。 ここでも伝えると同じ行に 2 件並ぶ (#2111)
85
+ if (!名前の表.has(s.from) || !名前の表.has(s.to)) continue;
86
+ const 時期なし = [s.from, s.to].filter((n) => !タスク名.has(n)).map(truncateForMessage);
87
+ 伝える(
88
+ "chart-edge-dropped",
89
+ s.from,
90
+ `type: gantt で ${矢印} の依存を結べません (${時期なし.join(" / ")} に時期がありません)`,
91
+ line,
92
+ );
93
+ continue;
94
+ }
95
+ 依存元.set(s.to, s.from);
96
+ // 帯の依存は「どちらが先か」 だけを持つ。 矢印に書いた文字や色は描けないので伝える。
97
+ // 多重度もここに含める = 多重度の知らせ (#2107) と同じ行に 2 件並べない
98
+ const 効かない = [
99
+ (s.label ?? "") !== "" || (s.sub ?? "") !== "" ? "文字" : "",
100
+ s.tone !== undefined ? "色" : "",
101
+ s.style !== undefined ? "線種" : "",
102
+ 書いた多重度を読む(s.cardinality) !== undefined ? "多重度" : "",
103
+ ].filter((x) => x !== "");
104
+ if (効かない.length > 0) {
105
+ 伝える(
106
+ "edge-option-not-honored",
107
+ s.from,
108
+ `type: gantt で ${矢印} に書いた ${効かない.join(" / ")} は描けません (工程表の矢印は前後の関係だけを使います)`,
109
+ line,
110
+ );
111
+ }
112
+ }
113
+
114
+ // 高さは件数から決める。 描画側は 1 行 28 以上 + 行間 20 で積み、 上下に 32 / 44 の余白を取る
115
+ // (`kinds/gantt.tsx`)。 360 の固定だと 8 件目から最後の帯が枠の外に出る (実測 = 8 件で 56 はみ出す)
116
+ const CHART_H = Math.max(360, 48 * タスク.length + 96);
117
+
118
+ b.node(`${slugify(doc.title) || "gantt"}-chart`, {
119
+ lane: "gantt",
120
+ stack: 0,
121
+ kind: "gantt-timeline",
122
+ title: doc.title,
123
+ ...図の小見出し(doc),
124
+ w: CHART_W,
125
+ h: CHART_H,
126
+ ganttData: タスク.map((t) => {
127
+ const idx = 目盛り.indexOf(t.label);
128
+ const from = 依存元.get(t.name);
129
+ // 帯の向きの誤りは、その項目を書いた行で伝える
130
+ const 逆向きを伝える = (名: string, message: string): void =>
131
+ 伝える("gantt-end-before-start", 名, message, t.line);
132
+ const 終わり = 終わる位置(t.end, idx, 目盛り, t.name, 逆向きを伝える, doc);
133
+ return {
134
+ id: slugify(t.name) || t.name,
135
+ title: t.title,
136
+ startIdx: idx,
137
+ endIdx: 終わり.idx,
138
+ startLabel: t.label,
139
+ endLabel: 終わり.label ?? t.label,
140
+ ...(t.owner !== undefined ? { owner: t.owner } : {}),
141
+ ...(from !== undefined ? { dependsOn: slugify(from) || from } : {}),
142
+ ...(t.tone !== undefined ? { tone: t.tone } : {}),
143
+ };
144
+ }),
145
+ });
146
+
147
+ return b.build();
148
+ }
149
+
150
+ /**
151
+ * `{名前}` が指す状態が取りうる値を、記法に書かれた範囲で集める (#1251)。
152
+ *
153
+ * 初期値と、段が動かす先 (`tween:` の両端と `set:` の値) を見る。 数として読めない値は
154
+ * 落とす = 位置として使われないため、下限の判定には関係しない。
155
+ */
156
+ function 状態が取る値(参照: string, doc: DslDocument): number[] {
157
+ const 名 = 参照.slice(1, -1);
158
+ const out: number[] = [];
159
+ const 数にする = (v: unknown): void => {
160
+ if (typeof v === "number") {
161
+ if (Number.isFinite(v)) out.push(v);
162
+ return;
163
+ }
164
+ // **空文字と空白だけの値を数にしない**。 `Number("")` は 0 を返すため、そのままだと
165
+ // 位置 0 として扱われ、始まりが 1 以降の帯に誤った知らせが出る。 描画側はこの値を
166
+ // 解けず始まりへ倒すので、警告する相手ではない
167
+ const 文字 = String(v).trim();
168
+ if (文字 === "") return;
169
+ const n = Number(文字);
170
+ if (Number.isFinite(n)) out.push(n);
171
+ };
172
+ for (const st of doc.animate?.states ?? []) if (st.name === 名) 数にする(st.initial);
173
+ for (const p of doc.animate?.phases ?? []) {
174
+ for (const t of p.tweens ?? []) {
175
+ if (t.state !== 名) continue;
176
+ 数にする(t.from);
177
+ 数にする(t.to);
178
+ }
179
+ for (const v of p.sets ?? []) if (v.state === 名) 数にする(v.value);
180
+ }
181
+ return out;
182
+ }
183
+
184
+ /**
185
+ * 工程が終わる位置を決める (#1251)。
186
+ *
187
+ * 書かなければ始まりと同じ = 帯が 1 コマ (従来の挙動)。
188
+ *
189
+ * `{名前}` を書いたらそのまま渡す。 描画側が状態を解いて位置に直すため、段で帯が伸び縮みする。
190
+ * その場合 **時期の名前は始まりのものを使う** = 状態が指すのは位置であって時期の名前ではなく、
191
+ * 帯の端に出す字が段ごとに変わるわけではない。
192
+ *
193
+ * 時期の名前を書いたら、その名前の位置に終わる。 書いた名前が目盛りに無い形は始まりと同じに
194
+ * 倒す = 目盛りは書かれた順に作るため、載っていない名前は位置を持たない。
195
+ */
196
+ function 終わる位置(
197
+ end: string | undefined,
198
+ 始まり: number,
199
+ 目盛り: readonly string[],
200
+ 名前: string,
201
+ 伝える: (名: string, message: string) => void,
202
+ doc: DslDocument,
203
+ ): { idx: number | string; label?: string } {
204
+ if (end === undefined) return { idx: 始まり };
205
+ if (/^\{\w+\}$/.test(end)) {
206
+ // **状態が取る値は記法に全部書いてある**。 初期値と、段が動かす先 (`tween:` の両端と
207
+ // `set:` の値) を集めれば、始まりより前に落ちる値をここで見つけられる。
208
+ //
209
+ // 覆えないのは `values:` の式から決まる値だけ = 他の状態から計算されるため、
210
+ // 段ごとの結果を組み立ての時点では出せない
211
+ const 低い = 状態が取る値(end, doc).filter((v) => v < 始まり);
212
+ if (低い.length > 0) {
213
+ 伝える(
214
+ 名前,
215
+ `type: gantt で ${truncateForMessage(名前)} の終わり (${truncateForMessage(end)}) が始まりより前になる値を取ります (${[...new Set(低い)].join(", ")})。 始まりは ${始まり} 番目です`,
216
+ );
217
+ }
218
+ return { idx: end };
219
+ }
220
+ const i = 目盛り.indexOf(end);
221
+ if (i < 0) return { idx: 始まり };
222
+ // 始まりより前に終わる帯は描けない。 そのまま渡すと横幅が負になり、帯が始まりの位置から
223
+ // 左へはみ出す。 始まりと同じに倒して伝える (黙って倒すと「書いたのに 1 コマのまま」 になる)
224
+ if (i < 始まり) {
225
+ 伝える(
226
+ 名前,
227
+ `type: gantt で ${truncateForMessage(名前)} の終わり (${truncateForMessage(end)}) が始まりより前です (始まりと同じに倒しました)`,
228
+ );
229
+ return { idx: 始まり };
230
+ }
231
+ return { idx: i, label: end };
232
+ }
@@ -0,0 +1,284 @@
1
+ import { diagram } from "@cardenelabs/cdl";
2
+ import type { CdlDiagram } from "@cardenelabs/cdl";
3
+ import { parseFocusEntry } from "../focus";
4
+ import type { DslDocument, DslPhase } from "../types";
5
+ import { 始まりと終わりの決め方 } from "./actors";
6
+ import { 並べる向き, 後ろへ戻る矢印か, type GenericKind } from "./direction";
7
+ import { ERの関係の指定を作る, ERの関係の矢印 } from "./er-relation";
8
+ import { 描ける種別 } from "./kinds";
9
+ import { 書いた縦列に置く } from "./lanes";
10
+ import { 箱の題 } from "./node-title";
11
+ import { slugify } from "./slug";
12
+ /**
13
+ * 段を持つ図種の共通の組み立て (#2030 で `compile.ts` から移した)。
14
+ *
15
+ * `flow` / `swimlane` / `er` / `state` / `topology` の 5 図種が、段を書いた時にここを通る。
16
+ */
17
+
18
+ export type GenericOpts = {
19
+ kind: GenericKind;
20
+ /** flow / topology は 1 lane に全 actor、 swimlane / state は actor ごと lane */
21
+ laneId?: string;
22
+ laneWidth: number;
23
+ };
24
+
25
+ /**
26
+ * v0.4 ... 5 preset (flow / swimlane / er / state / topology) 共通 animation compile。
27
+ * sequence preset と異なり header / footer / step box 構造はない、 シンプルな lane + node + edge 構造。
28
+ * preset kind ごとに lane 配置と layout を切替。
29
+ */
30
+ export function compileGenericWithAnimate(doc: DslDocument, opts: GenericOpts): CdlDiagram {
31
+ const { kind, laneWidth } = opts;
32
+ const b = diagram(slugify(doc.title), { topic: doc.title, type: kind });
33
+
34
+ // lane / node 配置 ... preset kind に応じて切替
35
+ const actorToNodeId = new Map<string, string>();
36
+ if (書いた縦列に置く(kind, doc)) {
37
+ /*
38
+ * **書いた縦列に置く** (#1263)。 縦列を並べるための入れ物として使う図種でだけ効く。
39
+ *
40
+ * 縦列の並びは **`lanes:` に書いた順** を優先する (#1394)。 箱が最初に使った順で
41
+ * 並べていた間、`lanes:` で左から順に宣言しても箱の書き順で入れ替わっていた
42
+ * (実測 = 中心を先に書いた放射の図で、左端の縦列が中心の右へ回った)。
43
+ *
44
+ * `lanes:` に無い縦列は、これまでどおり箱が使った順で後ろに続ける。
45
+ */
46
+ const 使った: string[] = [];
47
+ for (const a of doc.actors) {
48
+ const lid = a.lane;
49
+ if (lid !== undefined && !使った.includes(lid)) 使った.push(lid);
50
+ }
51
+ const 書いた順 = doc.lanes ? Object.keys(doc.lanes) : [];
52
+ const 並び = [
53
+ ...書いた順.filter((lid) => 使った.includes(lid)),
54
+ ...使った.filter((lid) => !書いた順.includes(lid)),
55
+ ];
56
+ for (const lid of 並び) {
57
+ b.lane(lid, { width: laneWidth, ...(kind === "topology" ? { contain: true } : {}) });
58
+ }
59
+ /*
60
+ * 段は **書いた番号をそのまま持つ** (#1394)。
61
+ *
62
+ * 書き順で 0 から詰め直していた間、`stack: 1` と書いた箱が 0 へ落ちていた。
63
+ * 決定木のように「同じ高さに並ばない」 ことが図の意味そのものになる形では、
64
+ * 詰めた瞬間に別の図になる (実測 = 3 段の木の根が 1 段目へ上がった)。
65
+ *
66
+ * 書かなかった箱は、その縦列で **空いている一番小さい段** に置く。 単に数え上げると
67
+ * 書いた番号と重なり、2 つの箱が同じ段に載る。
68
+ */
69
+ const 埋まった段 = new Map<string, Set<number>>();
70
+ const 埋める = (lid: string, stack: number): void => {
71
+ const 集合 = 埋まった段.get(lid) ?? new Set<number>();
72
+ 集合.add(stack);
73
+ 埋まった段.set(lid, 集合);
74
+ };
75
+ for (const a of doc.actors) {
76
+ if (a.lane !== undefined && a.stack !== undefined) 埋める(a.lane, a.stack);
77
+ }
78
+ doc.actors.forEach((a, idx) => {
79
+ const id = slugify(a.name) || `n${idx}`;
80
+ actorToNodeId.set(a.name, id);
81
+ const lid = a.lane!;
82
+ let stack = a.stack;
83
+ if (stack === undefined) {
84
+ stack = 0;
85
+ const 集合 = 埋まった段.get(lid);
86
+ while (集合?.has(stack)) stack += 1;
87
+ 埋める(lid, stack);
88
+ }
89
+ b.node(id, {
90
+ lane: lid,
91
+ stack,
92
+ kind: 描ける種別(a.kind),
93
+ title: 箱の題(a),
94
+ });
95
+ });
96
+ } else if (並べる向き(kind, doc) === "縦") {
97
+ // 1 lane に全 actor を縦 stack
98
+ const lid = opts.laneId ?? "main";
99
+ b.lane(lid, {
100
+ width: laneWidth,
101
+ /*
102
+ * **見出しは自然に縦へ積む図種だけ** (#1494)。
103
+ *
104
+ * `direction: 縦` を書いた泳法図がここへ来るようになった。 その図に題を渡すと、図の題が
105
+ * 縦列の見出しとしてもう 1 度出る (実測 = 「認証の流れ」 が題と見出しの 2 箇所に並んだ)。
106
+ */
107
+ ...(kind === "flow" || kind === "topology" ? { label: doc.title } : {}),
108
+ ...(kind === "topology" ? { contain: true } : {}),
109
+ });
110
+ doc.actors.forEach((a, idx) => {
111
+ const id = slugify(a.name) || `n${idx}`;
112
+ actorToNodeId.set(a.name, id);
113
+ b.node(id, {
114
+ lane: lid,
115
+ stack: idx,
116
+ kind: 描ける種別(a.kind),
117
+ title: 箱の題(a),
118
+ });
119
+ });
120
+ } else {
121
+ // actor ごとに 1 lane (横並び)。 `swimlane` / `er` / `state` の既定と、
122
+ // `向き: 横` を書いた流れ図がここに来る (#1494)
123
+ //
124
+ // **見出しを付けるのは `swimlane` だけ** (#1241)。 3 図種とも箱を 1 つずつ持ち、
125
+ // その箱が既に名前を描く。 縦列にも同じ名前を渡すと **同じ字が縦に 2 つ並ぶ**
126
+ // (実測 = 描いた絵に `Alpha` `Beta` が 2 度出る)。
127
+ //
128
+ // `swimlane` は縦列そのものが「誰の担当か」 を読ませる図なので見出しが要る。
129
+ // `er` の縦列は表を並べるための入れ物、 `state` の縦列は状態を並べるための入れ物で、
130
+ // どちらも読む人に見せる意味を持たない (組立て API 側も見出しを空のまま置く)。
131
+ const 見出しを付ける = kind === "swimlane";
132
+ const 決め方2 = 始まりと終わりの決め方(doc);
133
+ doc.actors.forEach((a, idx) => {
134
+ const lid = `lane-${slugify(a.name) || idx}`;
135
+ b.lane(lid, { width: laneWidth, ...(見出しを付ける ? { label: a.name } : {}) });
136
+ const id = slugify(a.name) || `n${idx}`;
137
+ actorToNodeId.set(a.name, id);
138
+ // er は entity、 state は initial/final marker、 swimlane はそのまま actor
139
+ const isInitial = kind === "state" && 決め方2.始まり(a, idx);
140
+ const isFinal = kind === "state" && 決め方2.終わり(a, idx);
141
+ b.node(id, {
142
+ lane: lid,
143
+ stack: 0,
144
+ kind: 描ける種別(a.kind),
145
+ title: 箱の題(a),
146
+ ...(isInitial ? { eyebrow: "初期" } : {}),
147
+ ...(isFinal ? { eyebrow: "最終" } : {}),
148
+ });
149
+ });
150
+ }
151
+
152
+ // edge ... flow の各 step を edge として登録
153
+ //
154
+ // 解決できない名前の矢印は **落とす** (#1209)。 以前は名前をそのまま id として使っており、
155
+ // 存在しない node を指す図ができて描画の直前で落ちていた
156
+ // (実測 = `unknown-ref: edge "e0-v-c" の from "v" が node に存在しません`)。
157
+ // 書いた人には `flow-actor-missing` の知らせが届く。
158
+ // 箱の並び。 後ろへ戻る矢印を見分けるために使う (#1260)
159
+ const 箱の並び = new Map([...actorToNodeId.values()].map((id, i) => [id, i]));
160
+ const edgeIds: string[] = [];
161
+ doc.flow.forEach((s, idx) => {
162
+ // 名前は入口で正規化済 (`canonicalizeFlowActors`)。 ここで slug を受け直すと、
163
+ // 動きを書いていない図の組み立てと扱いが割れる
164
+ const fromId = actorToNodeId.get(s.from);
165
+ const toId = actorToNodeId.get(s.to);
166
+ if (fromId === undefined || toId === undefined) return;
167
+ const edgeId = `e${idx}-${fromId}-${toId}`;
168
+ // ER の多重度は、段の無い図 (`compileEr`) と同じ 1 か所から名前と名前の下の行と両端を作る (#2105)。
169
+ // 札に `(1:N)` と添えるだけだった間、段を持つ ER 図には端の形が 1 つも付かなかった
170
+ const 関係 = kind === "er" ? ERの関係の矢印(ERの関係の指定を作る(s)) : undefined;
171
+ b.edge(fromId, toId, {
172
+ id: edgeId,
173
+ label: 関係?.label ?? s.label,
174
+ ...(後ろへ戻る矢印か(kind, fromId, toId, 箱の並び)
175
+ ? { routing: "back-detour" as const }
176
+ : {}),
177
+ ...(関係?.sub ? { sub: 関係.sub } : {}),
178
+ ...(関係?.head ? { head: 関係.head } : {}),
179
+ ...(関係?.tailHead ? { tailHead: 関係.tailHead } : {}),
180
+ ...(s.sub ? { sub: s.sub } : {}),
181
+ ...(s.side ? { side: s.side } : {}),
182
+
183
+ ...(s.tone ? { tone: s.tone } : {}),
184
+ ...(s.style ? { style: s.style } : {}),
185
+ });
186
+ edgeIds.push(edgeId);
187
+ });
188
+
189
+ // state 登録
190
+ for (const st of doc.animate?.states ?? []) {
191
+ b.state(st.name, { initial: st.initial });
192
+ }
193
+
194
+ // phase 注入
195
+ for (const p of doc.animate?.phases ?? []) {
196
+ b.phase(
197
+ slugify(p.name) || p.name,
198
+ {
199
+ duration: p.durationMs,
200
+ title: p.name,
201
+ body: p.body ?? "",
202
+ },
203
+ (pb) => {
204
+ const activateIds = resolveHighlightGeneric(p, doc, actorToNodeId, edgeIds);
205
+ if (activateIds.length > 0) {
206
+ pb.activate(...activateIds);
207
+ }
208
+ for (const t of p.tweens ?? []) {
209
+ pb.tween(t.state, t.from, t.to);
210
+ }
211
+ for (const s of p.sets ?? []) {
212
+ pb.set(s.state, s.value);
213
+ }
214
+ if (p.badge) {
215
+ pb.badge(p.badge);
216
+ }
217
+ return pb;
218
+ },
219
+ );
220
+ }
221
+
222
+ return b.build();
223
+ }
224
+
225
+ /**
226
+ * 5 preset 共通 ... highlight item (actor 名 or "A→B") を node/edge id に解決。
227
+ * sequence と異なり header/footer/step box はないのでシンプル。
228
+ */
229
+ export function resolveHighlightGeneric(
230
+ phase: DslPhase,
231
+ doc: DslDocument,
232
+ actorToNodeId: Map<string, string>,
233
+ edgeIds: string[],
234
+ ): string[] {
235
+ const out: string[] = [];
236
+ const knownNames = new Set(actorToNodeId.keys());
237
+ for (const raw of phase.highlight ?? []) {
238
+ const entry = parseFocusEntry(raw, knownNames);
239
+ // 矢印あり → edge を特定
240
+ if (entry.kind === "edge") {
241
+ const fromId = actorToNodeId.get(entry.from) ?? slugify(entry.from);
242
+ const toId = actorToNodeId.get(entry.to) ?? slugify(entry.to);
243
+ // edge id は `e{idx}-{fromId}-{toId}` の形。 末尾一致で見る。
244
+ // 部分一致で見ると、 名前に `-` を含む箱 (`api-gateway`) の id が別の矢印の id に
245
+ // 混ざって当たる (実測 = 箱を光らせたい指定で矢印が光った)
246
+ for (const edgeId of edgeIds) {
247
+ if (edgeId.endsWith(`-${fromId}-${toId}`)) {
248
+ out.push(edgeId);
249
+ }
250
+ }
251
+ continue;
252
+ }
253
+ // actor 名 → node id。 見つからなければ slug の形でも探す。
254
+ // 順序図だけが slug を受理する状態にすると、 同じ記述が図種で別の意味になる
255
+ // (実測 = `api-gateway` が順序図では光り、 流れ図では何も光らなかった)
256
+ const nodeId = actorToNodeId.get(entry.name) ?? slugLookup(actorToNodeId, entry.name);
257
+ if (nodeId) {
258
+ out.push(nodeId);
259
+ }
260
+ }
261
+ return out;
262
+ }
263
+
264
+ /**
265
+ * 名前が見つからない時に、 slug の形でも探す。
266
+ *
267
+ * 記法は表示名で書くが、 書く人は id の形 (`api-gateway`) で書くこともある。 図種によって
268
+ * 受理する / しないが分かれると、 同じ記述が別の意味になる。
269
+ *
270
+ * 2 つ以上の名前が同じ slug になる時は解決しない。 どちらを指したか決められないため、
271
+ * 黙ってどちらかを選ぶより光らせない方が書いた人が気付ける。
272
+ */
273
+ export function slugLookup(
274
+ byName: ReadonlyMap<string, string>,
275
+ wanted: string,
276
+ ): string | undefined {
277
+ let hit: string | undefined;
278
+ for (const [name, id] of byName) {
279
+ if (slugify(name) !== wanted) continue;
280
+ if (hit !== undefined) return undefined;
281
+ hit = id;
282
+ }
283
+ return hit;
284
+ }