@cardenelabs/cdl 0.5.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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +343 -0
  3. package/SPEC.md +374 -0
  4. package/dist/index.cjs +28085 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +3415 -0
  7. package/dist/index.d.ts +3415 -0
  8. package/dist/index.js +27962 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/react.cjs +23043 -0
  11. package/dist/react.cjs.map +1 -0
  12. package/dist/react.d.cts +2 -0
  13. package/dist/react.d.ts +2 -0
  14. package/dist/react.js +23041 -0
  15. package/dist/react.js.map +1 -0
  16. package/dist/render-C78lIXeC.d.cts +2449 -0
  17. package/dist/render-C78lIXeC.d.ts +2449 -0
  18. package/examples/quick-start.md +88 -0
  19. package/package.json +79 -0
  20. package/src/anim/core/easing.ts +77 -0
  21. package/src/anim/core/timeline.ts +335 -0
  22. package/src/anim/core/types.ts +42 -0
  23. package/src/anim/index.ts +20 -0
  24. package/src/anim/react/index.ts +3 -0
  25. package/src/anim/react/useReducedMotion.ts +27 -0
  26. package/src/anim/react/useTimeline.ts +48 -0
  27. package/src/assert-never.ts +16 -0
  28. package/src/author-intent-verify.ts +587 -0
  29. package/src/builder.ts +3740 -0
  30. package/src/compile.ts +12 -0
  31. package/src/dom-verify-core.ts +121 -0
  32. package/src/dom-verify-types.ts +21 -0
  33. package/src/dom-verify.ts +948 -0
  34. package/src/event-handler/index.ts +184 -0
  35. package/src/formula/ast.ts +46 -0
  36. package/src/formula/evaluator.ts +163 -0
  37. package/src/formula/formula-computeds.ts +83 -0
  38. package/src/formula/index.ts +5 -0
  39. package/src/formula/parser.ts +399 -0
  40. package/src/index.ts +284 -0
  41. package/src/input-signals.ts +74 -0
  42. package/src/kinds/actor.tsx +79 -0
  43. package/src/kinds/card.tsx +61 -0
  44. package/src/kinds/chart-bar.tsx +121 -0
  45. package/src/kinds/chart-line.tsx +163 -0
  46. package/src/kinds/chart-pie.tsx +107 -0
  47. package/src/kinds/compact-title.ts +164 -0
  48. package/src/kinds/dyn-shape.tsx +394 -0
  49. package/src/kinds/event.tsx +70 -0
  50. package/src/kinds/function.tsx +53 -0
  51. package/src/kinds/funnel.tsx +122 -0
  52. package/src/kinds/gantt.tsx +193 -0
  53. package/src/kinds/generic.tsx +368 -0
  54. package/src/kinds/mind-map.tsx +367 -0
  55. package/src/kinds/mind-radial.tsx +209 -0
  56. package/src/kinds/node-tone.ts +18 -0
  57. package/src/kinds/quadrant.tsx +164 -0
  58. package/src/kinds/row-align.ts +179 -0
  59. package/src/kinds/shape-basement.tsx +683 -0
  60. package/src/kinds/shape-blockchain.tsx +750 -0
  61. package/src/kinds/shape-commerce.tsx +553 -0
  62. package/src/kinds/shape-finance.tsx +706 -0
  63. package/src/kinds/shape-hardware.tsx +862 -0
  64. package/src/kinds/shape-people.tsx +439 -0
  65. package/src/kinds/shape-region.tsx +383 -0
  66. package/src/kinds/shape-software.tsx +859 -0
  67. package/src/kinds/storage.tsx +180 -0
  68. package/src/kinds/text-width.ts +73 -0
  69. package/src/kinds/tree.tsx +275 -0
  70. package/src/kinds/user-journey.tsx +240 -0
  71. package/src/label-text.ts +71 -0
  72. package/src/layout/clearance-constants.ts +47 -0
  73. package/src/layout/collisions.ts +2078 -0
  74. package/src/layout/edges.ts +2498 -0
  75. package/src/layout/footer-shape.ts +97 -0
  76. package/src/layout/geometry.ts +135 -0
  77. package/src/layout/label-shift.ts +28 -0
  78. package/src/layout/lanes.ts +328 -0
  79. package/src/layout/nodes.ts +190 -0
  80. package/src/layout/predict-bbox.ts +76 -0
  81. package/src/layout/px-projection.ts +176 -0
  82. package/src/layout/spec.ts +872 -0
  83. package/src/layout/text-width.ts +133 -0
  84. package/src/layout/tokens.ts +181 -0
  85. package/src/layout/viewbox.ts +47 -0
  86. package/src/layout-with-validation.ts +175 -0
  87. package/src/layout.ts +381 -0
  88. package/src/presets.ts +2108 -0
  89. package/src/reactive/batch.ts +48 -0
  90. package/src/reactive/computed.ts +85 -0
  91. package/src/reactive/effect.ts +145 -0
  92. package/src/reactive/index.ts +6 -0
  93. package/src/reactive/internal.ts +109 -0
  94. package/src/reactive/signal.ts +113 -0
  95. package/src/render/edges.tsx +318 -0
  96. package/src/render/header.tsx +146 -0
  97. package/src/render/interactive-panel.tsx +9620 -0
  98. package/src/render/nodes.tsx +319 -0
  99. package/src/render/stage.tsx +377 -0
  100. package/src/render/tone.ts +64 -0
  101. package/src/render/utils.ts +238 -0
  102. package/src/render.tsx +221 -0
  103. package/src/scroll-trigger/index.ts +109 -0
  104. package/src/scroll-trigger/progress.ts +49 -0
  105. package/src/thumbnail.tsx +114 -0
  106. package/src/types.ts +2371 -0
  107. package/src/validate.ts +268 -0
  108. package/src/visual-validate.ts +4381 -0
@@ -0,0 +1,872 @@
1
+ /**
2
+ * Layout spec SSOT (world unit)。
3
+ *
4
+ * CAR-421 (PR 1 / CAR-418 chain 伝搬 shift + spec 固定化) で新設した固定値 SSOT。
5
+ * 既存 `clearance-constants.ts` は clearance (near collision 距離下限) の SSOT だが、
6
+ * 本 file は「positive spec」 = engine が満たすべき正しい配置目標値 の SSOT。
7
+ *
8
+ * clearance-constants.ts と役割分担。
9
+ * - clearance-constants.ts ... 「これ以下は誤読」 の下限 (near collision detection)
10
+ * - spec.ts (本 file) ... 「これを満たすと正しい」 の目標値 (positive-check axis)
11
+ *
12
+ * downstream (layout / edges / collisions / visual-validate) は本 module を import して同一値を
13
+ * 使うことで、 magic number の散在を防ぐ。 dragon 側 pixel-perfect gate も同 SSOT を参照する。
14
+ *
15
+ * world unit = SVG viewBox 内の座標系単位 (clearance-constants.ts と同定義)。
16
+ */
17
+
18
+ import { CLEARANCE_LABEL_LABEL, CLEARANCE_NODE_LABEL } from "./clearance-constants";
19
+ import { measureTextWidth } from "./text-width";
20
+ import { EDGE_LABEL_TEXT } from "../label-text";
21
+ import type { CdlEdge, LaidEdge } from "../types";
22
+
23
+ // ─── edge label pill bbox SSOT (CAR-431 で 3 定義箇所を集約) ──
24
+
25
+ /**
26
+ * edge label pill 高さ (sub あり時、 world unit)。
27
+ *
28
+ * `render/edges.tsx` の `<rect height={boxH}>` 属性値と一致する SSOT。 sub あり = main + sub の
29
+ * 2 行 label で 68 world (main line 22px + sub line 19px + 上下 padding + 行間)。 render / layout /
30
+ * predict-bbox 3 箇所で個別定義していた drift (64 vs 68) を CAR-431 で本定数に集約。
31
+ */
32
+ export const LABEL_PILL_H_WITH_SUB = 68;
33
+
34
+ /**
35
+ * edge label pill 高さ (main のみ、 world unit)。
36
+ *
37
+ * sub なし = 1 行 label で 36 world (main line 22px + 上下 padding)。 render 側 rect height と一致。
38
+ */
39
+ export const LABEL_PILL_H_MAIN_ONLY = 36;
40
+
41
+ /**
42
+ * edge label pill 水平 padding (左右対称、 world unit)。
43
+ *
44
+ * `boxW = measureTextWidth(label) + LABEL_PILL_PAD_X * 2` の左右 padding。 render / layout /
45
+ * predict-bbox 全てで 18 world 固定。
46
+ */
47
+ export const LABEL_PILL_PAD_X = 18;
48
+
49
+ /**
50
+ * edge label の pill が実際に描かれるか (SSOT)。
51
+ *
52
+ * `label` が空でも `sub` があれば描かれ、 空白のみの `label` は描かれない。 render / bbox 収集 /
53
+ * 検査 / stack gap の先手拡張 が同じ判定を共有する。
54
+ *
55
+ * `!e.label` だけで判定すると `sub` のみの pill が漏れる。 実際に `layoutEdges` の label 逃がし
56
+ * (Issue #211) と `layoutNodes` の stack gap 拡張 (Issue #224) の 2 箇所で欠陥を生んだ。
57
+ */
58
+ export function hasRenderedLabel(e: { label?: string; sub?: string }): boolean {
59
+ return Boolean(e.label?.trim() || e.sub?.trim());
60
+ }
61
+
62
+ /**
63
+ * `title` を文字として描かない node の種別。
64
+ *
65
+ * 2 群ある。 形だけを描く `dyn-*` 5 種は `DynShapeNode` に回り、 `node.title` を一度も参照しない
66
+ * (描くのは形と数値の小さな label だけ)。 payload を自前 layout する chart / timeline / analytic
67
+ * 11 種は、 描くのが payload 側の label で `title` は使わない。
68
+ *
69
+ * 一覧は手で持つと描画側とずれるため、 `test/node-title-render.test.tsx` が `NODE_KINDS` を
70
+ * **全件 render** して「title の文字が出るか」 と本述語の返り値の一致を検査する。 新しい種別を
71
+ * 足すと `NODE_KINDS` に載った時点で検査対象に入るので、 追記を忘れれば必ず落ちる。
72
+ */
73
+ const KINDS_WITHOUT_TITLE_TEXT: ReadonlySet<string> = new Set([
74
+ // 形だけを描く (DynShapeNode)
75
+ "dyn-rect",
76
+ "dyn-circle",
77
+ "dyn-arc",
78
+ "dyn-wave",
79
+ "dyn-polygon",
80
+ // payload を自前 layout して描く (label は payload 側が持つ)
81
+ "chart-pie",
82
+ "chart-line",
83
+ "chart-bar",
84
+ "gantt-timeline",
85
+ "mind-map",
86
+ "mind-radial",
87
+ "funnel-stages",
88
+ "quadrant-matrix",
89
+ "tree-hierarchy",
90
+ "journey-map",
91
+ // 形の中に自前の文字を置く (title は使わない)
92
+ "shape-blockchain-block",
93
+ ]);
94
+
95
+ /**
96
+ * その node が `title` を文字として描くか (SSOT)。
97
+ *
98
+ * 描かない種別に対して「文字が幅を超えて切れる」 (`text-readability`) や「文字が無い」
99
+ * (`i18n-cjk-detection` / `bidi-hyphenation`) を判定しても、 前提が成り立たない。
100
+ *
101
+ * `accessibility-basics` (軸 24) は #363 で「図全体の代替説明が組み立つか」 に変わったため、
102
+ * 本述語は使わない (節の文字は絵の中の点でしかなく、 読み上げる名前の置き場所にならない)。
103
+ */
104
+ export function rendersTitleText(kind: string | undefined): boolean {
105
+ return !KINDS_WITHOUT_TITLE_TEXT.has(kind ?? "");
106
+ }
107
+
108
+ /**
109
+ * `rows` を文字として描く node の種別。
110
+ *
111
+ * 2 群ある。 `storage` は `StorageNode` が専用の表組みで描き、 残る 21 種は `GenericNode` の
112
+ * 汎用描画が描く。 行の縦位置が群ごとに違うので、 `rowBaselineY` で分けている。
113
+ *
114
+ * 除外側 (`KINDS_WITHOUT_TITLE_TEXT`) ではなく採用側を列挙するのは、 描く種別が少数で、
115
+ * 新しい種別が増えた時に既定で「描かない」 側に入るのが正しいため。 描くようにしたら明示的に
116
+ * ここへ足す。
117
+ *
118
+ * 一覧は手で持つと描画側とずれるため、 `test/node-rows-render.test.tsx` が `NODE_KINDS` を
119
+ * **全件 render** して「行の文字が出るか」 と本述語の返り値の一致を検査する。 新しい種別を足すと
120
+ * `NODE_KINDS` に載った時点で検査対象に入るので、 追記を忘れれば必ず落ちる。
121
+ */
122
+ const KINDS_WITH_ROW_TEXT: ReadonlySet<string> = new Set([
123
+ // 専用の表組み (StorageNode)
124
+ "storage",
125
+ // 汎用描画 (GenericNode)
126
+ "person",
127
+ "user-group",
128
+ "admin",
129
+ "developer",
130
+ "external-user",
131
+ "database",
132
+ "cache",
133
+ "queue",
134
+ "message-bus",
135
+ "cloud",
136
+ "cdn",
137
+ "service",
138
+ "api",
139
+ "frontend",
140
+ "backend",
141
+ "webhook",
142
+ "microservice",
143
+ "signer",
144
+ "oracle",
145
+ "merkle-tree",
146
+ "decision",
147
+ ]);
148
+
149
+ /**
150
+ * その node が `rows` を文字として描くか (SSOT)。
151
+ *
152
+ * 描かない種別に対して「行が `key: value` 形式か」 (`row-format`) や「行が枠内に収まるか」
153
+ * (`row-vertical-spacing`) を判定しても、 前提が成り立たない。 逆に、 描かない種別に `rows` が
154
+ * 書かれていること自体は「書いた内容が消える」 という別の破綻で、 `rows-not-rendered` が見る。
155
+ */
156
+ export function rendersRows(kind: string | undefined): boolean {
157
+ return KINDS_WITH_ROW_TEXT.has(kind ?? "");
158
+ }
159
+
160
+ /**
161
+ * `rows[i]` の baseline が node 上端から何 world 下に来るか (SSOT)。
162
+ *
163
+ * `storage` は `kinds/storage.tsx` が `130 + i * 56`、 残りは `kinds/generic.tsx` が
164
+ * `100 + i * 28` に置く。 行を描かない種別は `null`。
165
+ *
166
+ * `layout/nodes.ts` の `autoStorageHeight` は storage 側の式から必要 h を出しており、
167
+ * `row-vertical-spacing` (軸 16) は本関数から最終行の位置を得て枠内に収まるかを見る。 どちらも
168
+ * 描画側の式を 1 箇所から引くことで、 render を変えた時に両方が同時にずれるのを防ぐ。
169
+ */
170
+ export function rowBaselineY(kind: string | undefined, index: number): number | null {
171
+ if (!rendersRows(kind)) return null;
172
+ return kind === "storage"
173
+ ? ROW_TOP_STORAGE + index * ROW_PITCH_STORAGE
174
+ : ROW_TOP_GENERIC + index * ROW_PITCH_GENERIC;
175
+ }
176
+
177
+ /** `kinds/storage.tsx` の 1 行目 baseline と行送り。 */
178
+ export const ROW_TOP_STORAGE = 130;
179
+ export const ROW_PITCH_STORAGE = 56;
180
+
181
+ /** `kinds/generic.tsx` の 1 行目 baseline と行送り。 */
182
+ export const ROW_TOP_GENERIC = 100;
183
+ export const ROW_PITCH_GENERIC = 28;
184
+
185
+ /**
186
+ * 行の文字寸法。 `kinds/storage.tsx` は 26px、 `kinds/generic.tsx` は左 18px / 右 21px。
187
+ * descender の見積もりには大きい方を使う。
188
+ */
189
+ export const ROW_FONT_STORAGE = 26;
190
+ export const ROW_FONT_GENERIC = 21;
191
+
192
+ /** `kinds/storage.tsx` の名前 (title) の baseline。 */
193
+ export const STORAGE_TITLE_BASELINE = 78;
194
+
195
+ /**
196
+ * `storage` の名前と列の間に引く区切り線の Y。
197
+ *
198
+ * 名前の baseline と 1 行目の字形の上端の中間に置く。 どちらかに寄せると片側だけ詰まって見える。
199
+ * 変更前は 100 の固定値で、 実測すると名前の下端から 15 / 1 行目の上端から 5 と偏っていた
200
+ * (`#419`)。
201
+ *
202
+ * 1 行目の字形の上端は baseline から font size の 0.72 倍だけ上と見込む (cap 高さの目安)。
203
+ */
204
+ export const STORAGE_ROW_DIVIDER_Y = Math.round(
205
+ (STORAGE_TITLE_BASELINE + (ROW_TOP_STORAGE - ROW_FONT_STORAGE * 0.72)) / 2,
206
+ );
207
+
208
+ /**
209
+ * baseline から下へ字形が伸びる量を、 font size の何割と **見込むか**。
210
+ *
211
+ * 実測値ではなく仮定である。 任意の Unicode 文字列に対する上限は存在しない (結合文字を
212
+ * 下方向に重ねればいくらでも深くなり、 fallback font の字形も予測できない)。 本定数が
213
+ * 押さえるのは **preset が行に出す文字の範囲** で、 その中で最も深い字形に合わせてある。
214
+ *
215
+ * - 欧文の descender (`g` / `y` / `p`) ... em の 0.20-0.22
216
+ * - `classDiagram` preset が区切りに入れる `───` (`presets.ts`) の box-drawing 字形 ...
217
+ * JetBrains Mono の `┃` で 0.40em
218
+ * - 丸囲み数字 (`①` 等) ... Inter で約 0.32em
219
+ *
220
+ * 著者が結合文字などを直接書いた場合は仮定の外に出る。 その場合の枠外描画は **どこでも
221
+ * 捕まえていない**。 文字列ごとの実寸には実 DOM が要り、 layout 出力だけを見る本軸では
222
+ * 原理的に扱えない。 `dom-verify.ts` は実 DOM を読むが、 現状は node の枠の位置と大きさを
223
+ * 比べるだけで、 行の文字が枠に収まっているかは見ていない (#390)。
224
+ */
225
+ export const ROW_GLYPH_DEPTH_RATIO = 0.4;
226
+
227
+ /**
228
+ * 最終行の baseline から下に、 字形が枠に触れないために要ると **見込む** 量。
229
+ *
230
+ * baseline は文字の下端ではない。 字形はここからさらに下へ出る。 行を描かない種別は `null`。
231
+ *
232
+ * `row-vertical-spacing` (軸 16) が「文字が枠外に出ている」 と言うための閾値。
233
+ * `ROW_LAYOUT_BOTTOM_PAD` (レイアウトが確保する余白) とは別物で、 こちらの方が小さい。
234
+ * レイアウトの余白を検査の閾値に使うと、 実際には文字が収まっている高さまで警告する。
235
+ *
236
+ * 仮定の範囲は `ROW_GLYPH_DEPTH_RATIO` の説明を参照。 preset が出す文字の範囲を押さえる。
237
+ */
238
+ export function rowGlyphDepth(kind: string | undefined): number | null {
239
+ if (!rendersRows(kind)) return null;
240
+ const font = kind === "storage" ? ROW_FONT_STORAGE : ROW_FONT_GENERIC;
241
+ return Math.ceil(font * ROW_GLYPH_DEPTH_RATIO);
242
+ }
243
+
244
+ /**
245
+ * レイアウトが最終行の下に確保する余白。
246
+ *
247
+ * 文字が触れないための最小量 (`rowGlyphDepth`) より広く取って、 枠と文字の間に見た目の
248
+ * 間隔を作る。 検査の閾値ではない。
249
+ */
250
+ export const ROW_LAYOUT_BOTTOM_PAD = 20;
251
+
252
+ /**
253
+ * `rows` を持つ node に必要な高さ (SSOT)。
254
+ *
255
+ * 最終行の baseline から 1 行送りぶんとレイアウト余白を確保する。 行を描かない種別は `null`。
256
+ *
257
+ * `layout/nodes.ts` の `autoStorageHeight` と、 下流 (dragon の sequence header 等) が
258
+ * 同じ値を使う。 各所で式を写すと、 描画を変えた時に一部だけ古くなる。
259
+ *
260
+ * これは「そう置くとよい高さ」 で、 「これを下回ると破綻する高さ」 ではない。 後者は
261
+ * `rowBaselineY` + `rowGlyphDepth` で、 軸 16 はそちらを見る。
262
+ */
263
+ export function requiredRowsHeight(kind: string | undefined, rowCount: number): number | null {
264
+ if (rowCount <= 0) return null;
265
+ const lastBaseline = rowBaselineY(kind, rowCount - 1);
266
+ if (lastBaseline === null) return null;
267
+ const pitch = kind === "storage" ? ROW_PITCH_STORAGE : ROW_PITCH_GENERIC;
268
+ return lastBaseline + pitch + ROW_LAYOUT_BOTTOM_PAD;
269
+ }
270
+
271
+ /**
272
+ * その node が画面に出るか (SSOT)。
273
+ *
274
+ * `visibleIf` が **固定の偽** (`"0"` / `"false"` / `"null"` / `"undefined"` / `"nan"`) の node は
275
+ * 常に非表示。 判定文字列は `render/nodes.tsx` の falsy 判定と同じ。
276
+ *
277
+ * 空文字は **出る側** に倒す。 render は `if (node.visibleIf)` で分岐に入るため、 空文字は
278
+ * JavaScript の falsy として分岐自体を素通りし、 node は描画される。 述語だけ非表示と判定すると
279
+ * 実際には出ている node の検査が外れる。
280
+ *
281
+ * `{signal}` を含む場合は状態しだいで表示されるので、 これも出る側に倒す。
282
+ *
283
+ * 寸法は見ない。 実効寸法は `posW` / `posH` が上書きするうえ、 小さい node を非表示扱いにするのは
284
+ * 軸 1 (`node-visibility`) の担当で、 そちらは幅だけを見る規則を持っている。 二重に別基準を
285
+ * 持つと食い違う。
286
+ */
287
+ export function isRenderedNode(n: { visibleIf?: string }): boolean {
288
+ if (n.visibleIf === undefined || n.visibleIf === "") return true;
289
+ if (n.visibleIf.includes("{")) return true;
290
+ const v = n.visibleIf.trim().toLowerCase();
291
+ return !(v === "" || v === "0" || v === "false" || v === "null" || v === "undefined" || v === "nan");
292
+ }
293
+
294
+ /** edge label pill の高さ (world unit)。 sub の有無で 2 値。 */
295
+ export function labelPillH(sub: string | undefined): number {
296
+ return sub ? LABEL_PILL_H_WITH_SUB : LABEL_PILL_H_MAIN_ONLY;
297
+ }
298
+
299
+ /**
300
+ * 重なりを解くために、 1 直線上の要素を最小の移動量でずらす (#372)。
301
+ *
302
+ * 各要素は「そこに居たい位置」 (`want`) と大きさ (`size`) を持つ。 隣り合う 2 つの中心は
303
+ * `(size_i + size_{i+1}) / 2 + gap` 以上離す。 その制約の下で、 移動量の 2 乗和を最小にする
304
+ * 配置を返す。
305
+ *
306
+ * label を並べる用途では `want` に「自分の線の上の位置」 を渡す。 動かす量が最小になるので、
307
+ * 重ならない範囲で全ての label が自分の線の近くに残る。
308
+ *
309
+ * 手順は 2 段。 まず制約を「単調増加」 に読み替える。 i 番目までに必要な最小の間隔を累積した
310
+ * 値を引くと、 制約は「引いた後の列が単調増加」 と同値になる。 次に単調な列への最小 2 乗
311
+ * 当てはめを解く (隣り合う塊が順序を破る間、 塊を併合して平均を取る)。
312
+ *
313
+ * 併合を使うのは、 前から順に押し出すだけでは移動量が最小にならないため。 前から押すと後ろの
314
+ * 要素だけが動いて群れ全体が一方向に流れる。 併合すると群れが両側に均等に開く。
315
+ *
316
+ * @param items 順不同でよい。 `want` 昇順に並べ替えてから解く
317
+ * @param gap 隣り合う要素の縁どうしに残す最小の隙間
318
+ * @returns 入力と同じ並びの、 移動後の中心位置
319
+ */
320
+ export function spreadWithMinimalShift(
321
+ items: ReadonlyArray<{ want: number; size: number }>,
322
+ gap: number,
323
+ ): number[] {
324
+ if (items.length === 0) return [];
325
+ if (items.length === 1) return [items[0]!.want];
326
+
327
+ const order = items.map((_, i) => i).sort((a, b) => items[a]!.want - items[b]!.want);
328
+ const sorted = order.map((i) => items[i]!);
329
+
330
+ // cum[i] = i 番目を最小間隔で詰めた時の、 先頭からの累積距離。
331
+ const cum: number[] = [0];
332
+ for (let i = 1; i < sorted.length; i++) {
333
+ cum.push(cum[i - 1]! + (sorted[i - 1]!.size + sorted[i]!.size) / 2 + gap);
334
+ }
335
+
336
+ // 単調増加列への最小 2 乗当てはめ。 塊 = 平均でまとめて置く、 連続した要素の集まり。
337
+ const blocks: Array<{ sum: number; count: number }> = [];
338
+ for (let i = 0; i < sorted.length; i++) {
339
+ blocks.push({ sum: sorted[i]!.want - cum[i]!, count: 1 });
340
+ while (blocks.length >= 2) {
341
+ const last = blocks[blocks.length - 1]!;
342
+ const prev = blocks[blocks.length - 2]!;
343
+ if (prev.sum / prev.count <= last.sum / last.count) break;
344
+ blocks.pop();
345
+ prev.sum += last.sum;
346
+ prev.count += last.count;
347
+ }
348
+ }
349
+
350
+ const out = new Array<number>(items.length);
351
+ let idx = 0;
352
+ for (const b of blocks) {
353
+ const mean = b.sum / b.count;
354
+ for (let k = 0; k < b.count; k++, idx++) {
355
+ out[order[idx]!] = mean + cum[idx]!;
356
+ }
357
+ }
358
+ return out;
359
+ }
360
+
361
+ /**
362
+ * 同じ位置に重なる parallel edge の label を縦に分散する時の中心間 step (world unit、 Issue #202)。
363
+ *
364
+ * pill が縦に並ぶので、 中心間距離は「最も高い pill の高さ + label 間の要求 clearance」。
365
+ * 旧実装は `pill 36 + minClearance 16 = 52` の固定値で、 label 同士の実 gap は 16 しか残らず
366
+ * `CLEARANCE_LABEL_LABEL` (36) を満たさなかった (catalog 実測 = read ↔ write gap 16、 need 36)。
367
+ *
368
+ * @param pillHs 同 group の pill 高さ
369
+ */
370
+ export function parallelLabelStepY(pillHs: readonly number[]): number {
371
+ return Math.max(...pillHs) + CLEARANCE_LABEL_LABEL;
372
+ }
373
+
374
+ /**
375
+ * 縦 edge の label 群が上下 node の間に収まるために必要な隙間 (world unit、 Issue #202)。
376
+ *
377
+ * label 群の中心は隙間の中点に置かれる (`centeredOffset` が 0 を中心に対称分散する)。
378
+ * よって中点から上下に伸びる距離は
379
+ * `(本数 - 1) / 2 * step + 端 pill の半分` で、 大きい方が隙間の半分を決める。
380
+ *
381
+ * 端 pill の半分ずつを足す (= 帯の実高) と、 高さが非対称な group で片側の余白が不足する
382
+ * (Codex review 実測 = pill [68, 36] で狭い側が 24、 要求 32)。 そこで端 pill の大きい方を採る。
383
+ *
384
+ * 横方向の `expandLaneGapsForEdgeLabels` (CAR-470) と対称だが、 縦 edge は曲がる前の
385
+ * 水平直進を挟まないので `EDGE_STUB_OUT` は加算しない。
386
+ */
387
+ export function requiredVerticalLabelGap(pillHs: readonly number[]): number {
388
+ if (pillHs.length === 0) return 0;
389
+ const first = pillHs[0]!;
390
+ const last = pillHs[pillHs.length - 1]!;
391
+ const step = parallelLabelStepY(pillHs);
392
+ return (pillHs.length - 1) * step + Math.max(first, last) + CLEARANCE_NODE_LABEL * 2;
393
+ }
394
+
395
+ /**
396
+ * edge label pill の world 幅計算 (SSOT)。 main / sub text の実測 advance + padding。
397
+ *
398
+ * label / sub の (undefined 含む) 両方から SSOT font 設定で measureTextWidth を呼び、
399
+ * max(main, sub) + LABEL_PILL_PAD_X * 2 を返す。 CAR-431 で render / layout (collisions +
400
+ * edges routePathL) / predict-bbox 4 箇所で個別実装されていた同一計算を本 helper に集約。
401
+ *
402
+ * `computeLabelBBoxWorld` は本 helper を呼出、 layout/edges.ts routePathL は labelX / labelY
403
+ * 未確定の段階で幅だけ必要なので本 helper を単独で呼ぶ。
404
+ *
405
+ * 返す値は **偶数に切り上げる**。 label 位置は `経路の端 + 余白 + 幅 / 2` で決まり、
406
+ * 文字幅の実測値をそのまま使うと半端な小数が乗って輪郭がにじむ (`subpixel-precision` 軸)。
407
+ * 幅を偶数にすると `幅 / 2` が整数になり、 端と余白が整数である限り位置も整数に揃う。
408
+ *
409
+ * 切り下げでなく切り上げるのは、 幅を狭める向きの丸めが節や他の label との間隔を
410
+ * 減らすため。 広げる向きなら間隔は増える一方で、 増分は 2 world 未満に収まる
411
+ * (clearance の下限は 8 world 以上なので判定を跨がない)。
412
+ *
413
+ * @param label main text (undefined / 空文字は最低幅 clamp)
414
+ * @param sub sub text (undefined なら 0)
415
+ * @returns pill 全体の world 幅 (偶数)
416
+ */
417
+ export function computeLabelBoxW(label: string | undefined, sub: string | undefined): number {
418
+ const mainW = measureTextWidth(label ?? "", { fontSize: EDGE_LABEL_TEXT.main.fontSize });
419
+ const subW = sub
420
+ ? measureTextWidth(sub, { fontSize: EDGE_LABEL_TEXT.sub.fontSize, fontFamily: "mono" })
421
+ : 0;
422
+ const raw = Math.max(mainW, subW) + LABEL_PILL_PAD_X * 2;
423
+ // 切り上げる前に浮動小数の誤差を落とす。 文字幅は係数の掛け算と足し算を重ねるので、
424
+ // 数学的にちょうど偶数になる値が 102.00000000000001 のように出る。 そのまま切り上げると
425
+ // 既に偶数の幅を余計に 2 広げてしまい、 帯の間隔が図ごとに 2 world ずれる。
426
+ const EPSILON = 1e-6;
427
+ return Math.ceil((raw - EPSILON) / 2) * 2;
428
+ }
429
+
430
+ /**
431
+ * edge label pill の bbox 予測 (world unit)。 SSOT = render/edges.tsx の rect 描画位置。
432
+ *
433
+ * render (packages/cdl/src/render/edges.tsx) / layout (packages/cdl/src/layout/collisions.ts) /
434
+ * predict-bbox (packages/cdl/src/layout/predict-bbox.ts) の 3 箇所で個別実装されていた bbox 計算を
435
+ * CAR-431 で本 function に集約。 downstream は本 helper 経由で bbox を取得する事で drift 再発防止。
436
+ *
437
+ * 計算経路 (CAR-581 で sub 有無に関わらず pill 中心 = labelY の対称配置に統一):
438
+ * 1. boxW = computeLabelBoxW(label, sub) (= max(mainW, subW) + LABEL_PILL_PAD_X * 2)
439
+ * 2. boxH = edge.sub ? LABEL_PILL_H_WITH_SUB : LABEL_PILL_H_MAIN_ONLY
440
+ * 3. boxLeftX = anchor==="start" ? 0 : anchor==="end" ? -boxW : -boxW/2
441
+ * 4. boxTopY = -boxH / 2 (sub 有無に関わらず pill 中心 = labelY で対称配置)
442
+ *
443
+ * 対称化の背景 (CAR-581): 旧実装は sub あり時のみ `boxTopY = -boxH - LABEL_PILL_SUB_Y_OFFSET_ADJ`
444
+ * で pill 全体を labelY より上に押上げていた (非対称)。 routePath の `labelY = A.y - labelInit`
445
+ * は pill 中心 = labelY を前提とする式なので、 sub あり時に二重補正で水平 edge の pill 下端 → path
446
+ * gap が 44 world (期待 8) に膨らむ regression が発生。 対称化で解消。
447
+ *
448
+ * @param edge LaidEdge (labelX / labelY / labelAnchor / label / sub 既確定)
449
+ * @returns { x, y, w, h } の world 座標 bbox (render 実 rect 位置と一致)
450
+ */
451
+ export function computeLabelBBoxWorld(edge: LaidEdge): { x: number; y: number; w: number; h: number } {
452
+ const boxW = computeLabelBoxW(edge.label, edge.sub);
453
+ const boxH = edge.sub ? LABEL_PILL_H_WITH_SUB : LABEL_PILL_H_MAIN_ONLY;
454
+ const anchor = edge.labelAnchor;
455
+ const boxLeftX = anchor === "start" ? 0 : anchor === "end" ? -boxW : -boxW / 2;
456
+ const boxTopY = -boxH / 2;
457
+ return {
458
+ x: edge.labelX + boxLeftX,
459
+ y: edge.labelY + boxTopY,
460
+ w: boxW,
461
+ h: boxH,
462
+ };
463
+ }
464
+
465
+ // ─── path geometry SSOT ────────────────────────────────────────
466
+
467
+ /**
468
+ * label pill 端 と path segment 距離 (world unit)。
469
+ *
470
+ * **engine が label を離して置く時の目標値**。 検証器の軸 59 `edge-label-clearance` が
471
+ * 「この値未満なら貼り付いている」 と判定していたが、 #386 で畳んだ = 中置き label (線の上に
472
+ * 載せて pill で線を隠す配置) が正常なので、 距離の下限を一律に課せない。
473
+ *
474
+ * 下限の契約は layout の test が持つ (`test/car202-edge-label-clearance.test.ts` が、 離して
475
+ * 置いた辺について実際に描かれる線との隙間を測る)。 検証器はこの値を見ない。
476
+ *
477
+ * clearance-constants.ts の `CLEARANCE_PATH_LABEL` (14) より大きい ... 本値は
478
+ * 「positive spec」 = 目標値、 CLEARANCE_PATH_LABEL は「これ以下は誤読」 の下限。
479
+ * 16 world unit = 大 diagram で ~4 px。 経緯 = CAR-483 で 8 → 16 拡大 → CAR-570 で
480
+ * 16 → 8 縮小 (「16 は離れすぎ」 判定) → CAR-630 で 8 → 16 に戻す (実描画で 8 world
481
+ * ~2 px は近すぎ、 user 目視判定「4px が最適」 で 16 world 復帰)。
482
+ */
483
+ export const LABEL_TO_PATH_CLEARANCE = 16;
484
+
485
+ /**
486
+ * edge label center と path center の初期距離 (world unit)。
487
+ *
488
+ * routePath() が返す init labelY は path 中央から本値だけ離れた位置に配置する。
489
+ * pill 端 と path 実距離 = 本値 - LABEL_PILL_H_MAIN_ONLY / 2 = LABEL_TO_PATH_CLEARANCE。
490
+ *
491
+ * 計算式 = LABEL_TO_PATH_CLEARANCE (16) + LABEL_PILL_H_MAIN_ONLY (36) / 2 = 34 world。
492
+ * CAR-468 で 5 箇所 (旧 30 / 30 / 32 / 50 / 30 magic number) を本 SSOT に集約、 CAR-482 で
493
+ * LABEL_TO_PATH_CLEARANCE 4 → 8 拡大に伴い derived value 22 → 26、 CAR-483 で
494
+ * 8 → 16 拡大に伴い derived value 26 → 34、 CAR-570 で 16 → 8 縮小に伴い derived
495
+ * value 34 → 26 に戻す、 CAR-630 で 8 → 16 に戻し derived value 26 → 34 に復帰
496
+ * (user 目視判定 「4px が最適」)。
497
+ * spec.ts の 2 定数を組み合わせた derived value のため、 downstream から本定数を import する。
498
+ */
499
+ export const LABEL_INIT_CLEARANCE = LABEL_TO_PATH_CLEARANCE + LABEL_PILL_H_MAIN_ONLY / 2;
500
+
501
+ /**
502
+ * dotted-flow style edge の marker glow 最外周 radius (world unit)。
503
+ *
504
+ * `render/edges.tsx` の EdgeGlowCircles で描画する 3 重 glow 円 (r=28/18/11 world) のうち最外周値。
505
+ * 動く marker が path 両側に広がる範囲 = 本値、 dotted-flow の label 距離拡張基準として使う。
506
+ * render 側 hard-code 値と一致必須、 render 側 radius 変更時は本値も同期。
507
+ */
508
+ export const MARKER_GLOW_RADIUS = 28;
509
+
510
+ /**
511
+ * dotted-flow style edge の label center と path center の初期距離 (world unit)。
512
+ *
513
+ * CAR-518 で追加。 dotted-flow edge (Fan-out / Fan-in / Rollback / flow preset 等) は
514
+ * 動く marker glow (r=28 world) が path 両側に広がるため、 通常 LABEL_INIT_CLEARANCE (34) では
515
+ * pill 端 と marker 外周の実距離 = 16 - 28 = -12 world (marker が pill 内部に食い込む)。
516
+ *
517
+ * 計算式 = LABEL_INIT_CLEARANCE (34) + MARKER_GLOW_RADIUS (28) = 62 world。
518
+ * pill 端 と marker 外周実距離 = 62 - 18 - 28 = LABEL_TO_PATH_CLEARANCE (16) と等しくなり、
519
+ * solid edge と同じ視覚的 clearance を marker 通過帯を挟んで確保する。 CAR-570 で
520
+ * LABEL_TO_PATH_CLEARANCE 16 → 8 縮小に伴い derived value 62 → 54、 CAR-630 で
521
+ * 8 → 16 に戻し derived value 54 → 62 に復帰。
522
+ * routePath 内は `labelInitClearance(edge)` helper 経由でのみ本値を参照する (SSOT 単一経路)。
523
+ */
524
+ export const LABEL_INIT_CLEARANCE_DOTTED_FLOW = LABEL_INIT_CLEARANCE + MARKER_GLOW_RADIUS;
525
+
526
+ /**
527
+ * edge style に応じた label init clearance (world unit) を返す。
528
+ *
529
+ * routePath 内 labelY 計算 8 箇所の SSOT helper。 solid = 34 (LABEL_INIT_CLEARANCE)、
530
+ * dotted-flow = 62 (LABEL_INIT_CLEARANCE_DOTTED_FLOW)。 edge 未指定 (routePath 単体 test 経路) は
531
+ * solid 扱い。
532
+ *
533
+ * 分岐を 8 箇所に散らさず helper 化することで、 (a) 将来の新 style 追加時に 1 箇所修正で済む
534
+ * (b) test で helper を直叩きして分岐を全 style pattern で cover 可能、 という 2 利点を得る。
535
+ */
536
+ export function labelInitClearance(edge?: Pick<CdlEdge, "style" | "sub"> | null): number {
537
+ const pillHalfH = edge?.sub ? LABEL_PILL_H_WITH_SUB / 2 : LABEL_PILL_H_MAIN_ONLY / 2;
538
+ const base = LABEL_TO_PATH_CLEARANCE + pillHalfH;
539
+ return edge?.style === "dotted-flow" ? base + MARKER_GLOW_RADIUS : base;
540
+ }
541
+
542
+ /**
543
+ * pill 端 → path の水平方向距離 (world unit)。
544
+ *
545
+ * 垂直 edge が pill を左右にオフセットする際の基準値。 dotted-flow は marker glow radius 分拡張。
546
+ *
547
+ * 計算式 = LABEL_TO_PATH_CLEARANCE (16) + (dotted-flow ? MARKER_GLOW_RADIUS (28) : 0)
548
+ * = solid: 16 world (pill 左端 → path X)
549
+ * = dotted-flow: 44 world (pill 左端 → path X、 glow radius=28 分拡張)
550
+ *
551
+ * `labelInitClearance` が水平 edge で label CENTER (Y) の距離を扱うのに対し、 本 helper は
552
+ * 垂直 edge が pill 端 (X) の距離を扱う SSOT。 対称的な 2 helper で全方向 SSOT 統一。
553
+ *
554
+ * CAR-601 で新設。 CAR-573 で垂直 edge labelX を `labelInit + labelHalfW` →
555
+ * `LABEL_TO_PATH_CLEARANCE + labelHalfW` に変更した際、 dotted-flow の MARKER_GLOW_RADIUS
556
+ * (28) 加算が漏れた regression を解消。 dotted-flow 垂直 edge の pill 左端が marker glow
557
+ * 通過帯 (r=28 world) に食い込む症状 (gap=8 実測、 期待=36) を修正。 CAR-630 で
558
+ * LABEL_TO_PATH_CLEARANCE 8 → 16 に戻し、 solid 8 → 16、 dotted-flow 36 → 44 に復帰。
559
+ */
560
+ export function labelPathClearanceHorizontal(edge?: Pick<CdlEdge, "style"> | null): number {
561
+ const base = LABEL_TO_PATH_CLEARANCE;
562
+ return edge?.style === "dotted-flow" ? base + MARKER_GLOW_RADIUS : base;
563
+ }
564
+
565
+ /**
566
+ * edge 起点から水平方向に直進する最小距離 (world unit)。
567
+ *
568
+ * L 字 edge が起点即折れすると (起点直後で垂直方向 に turn すると) 「起点がどの
569
+ * node 由来か視覚判別困難」 になる。 EDGE_STUB_OUT world 以上直進すると起点が
570
+ * node bbox 縁から明確に離れる → 起点認識容易。
571
+ *
572
+ * 40 world = 通常 node w (200-400) の 10-20%、 detour 発火閾値と分離、 label pill
573
+ * 標準 h (36 world) より大きく起点 stub が label 位置と干渉しない値。
574
+ */
575
+ export const EDGE_STUB_OUT = 40;
576
+
577
+ /**
578
+ * 同 node の対称対 edge (out + in が同 side) の下側 (lower) edge の平行 shift 距離 (world unit)。
579
+ *
580
+ * CAR-541 で SYMMETRIC_ENDPOINT_OFFSET (±16 両側 endpoint 単独 shift) から本 SSOT に置換。
581
+ * 対称対のうち upper (相手 node の Y / X が小さい側) は shift 0 で不変、 lower (相手 node
582
+ * の Y / X が大きい側) は edge 全体 (両 endpoint 同時) を perpendicular 軸で +本値 shift。
583
+ *
584
+ * 両 endpoint を同量 shift する事で、 直線 edge (A.y = B.y) の直進性 SSOT = 「path = M A L B の
585
+ * 1 直線」 を保存する (旧 CAR-532 は endpoint 単独 shift で A.y != B.y を生み S 字風 corner 挿入
586
+ * bug (Issue CAR-541 fc-0 submit-review) を発生させていた)。 lower 側は shift 後も A.y = B.y の
587
+ * 平行移動なので corner 挿入されない。
588
+ *
589
+ * 32 world = 旧 SYMMETRIC_ENDPOINT_OFFSET (16) × 2 = 対称対 2 edge 間の視覚的 gap を維持
590
+ * (旧: 中央 ±16 で 32 gap、 新: 上側 0 + 下側 32 で同じく 32 gap)。 node bbox 半 h (100+ world)
591
+ * 内に収まって node 縁を逸脱しない。 CAR-489 の endpoint 保持 chain shift とは加算方式で共存可能。
592
+ *
593
+ * 判定 (どちらが lower か) ...
594
+ * - 各 edge の distant endpoint 座標 (out edge = target node、 in edge = source node)
595
+ * の perpendicular 軸 (horizontal side → Y、 vertical side → X) 値の min を direction 別 (out / in) に集計
596
+ * - min が小さい direction = upper (shift 0)、 大きい direction = lower (+32 shift)
597
+ * - tie の場合 = in edge を lower とする (CAR-532 test の out=Y-16, in=Y+16 挙動を
598
+ * out=Y, in=Y+32 に翻訳、 tie-break で 決定論的挙動維持)
599
+ */
600
+ export const SYMMETRIC_PAIR_SHIFT = 32;
601
+
602
+ /**
603
+ * 同じ 2 点を同じ向きに結ぶ edge が複数ある時、 弓なりに離す間隔 (world unit)。
604
+ *
605
+ * 端点は動かせない = 同じ node の同じ辺から出る edge は起点を 1 点に集める規約があり
606
+ * (`fan-origin-single-point` 軸)、 矢頭は辺の中央に着地する規約もある
607
+ * (`arrow-endpoint-center` 軸)。 そのため途中だけを膨らませて分ける。
608
+ *
609
+ * 32 world = `SYMMETRIC_PAIR_SHIFT` と同じ値。 向きが逆の 2 本を離す間隔と揃えることで、
610
+ * 「同じ 2 点を結ぶ 2 本」 の見え方が向きに依らず同じになる。 中点での 2 本の間隔がこの値、
611
+ * 各 path は中心線から半分ずつ膨らむ。
612
+ */
613
+ export const PARALLEL_EDGE_BOW = 32;
614
+
615
+ /**
616
+ * 同一 obstacle を迂回する複数 detour path の Y 分離距離 (world unit)。
617
+ *
618
+ * 例 = A → C と B → C の 2 edge が同 obstacle X を上方 detour するとき、 detour
619
+ * top Y が近すぎると 2 path が視覚的に重なる。 DETOUR_SLOT_GAP world 以上離すと
620
+ * どちらがどの edge か視認可能。
621
+ *
622
+ * 30 world = 通常 edge label h (36) より少し小さく、 detour 群が縦に密集しても label
623
+ * 表示スペースを圧迫しない値。 単一 obstacle の detour 3-4 本まで実用的に分離可能。
624
+ */
625
+ export const DETOUR_SLOT_GAP = 30;
626
+
627
+ /**
628
+ * 同 Y の detour crest (迂回の水平区間) が X 方向に重なってよい長さ (world unit)。
629
+ *
630
+ * peak 点 1 個の比較では、 peak X を離すだけで水平区間が同 Y で完全に重なっていても
631
+ * 検知できない (dragon #890)。 crest を区間として扱い、 重なり長がこの値を超えたら
632
+ * 「後から描いた線が前の線を覆っている」 症状と判定する。
633
+ *
634
+ * 8 world = edge の線幅 (active 5 / 非 active 2.5) より広く、 角の丸め (Q command を
635
+ * 弦に落とした時に生じる数 world の食い違い) では超えない値。
636
+ */
637
+ export const DETOUR_CREST_OVERLAP_MIN = 8;
638
+
639
+ /**
640
+ * 同 row 内 node の cy 差分許容 (world unit)。
641
+ *
642
+ * 同 row 概念 = 同 stack index を持つ横並び node 群。 各 node の cy が完全一致
643
+ * (差 0) が理想だが、 subpixel 丸め誤差で 1 world 未満の差が発生し得る。 この値
644
+ * 未満は許容、 超過は positive-check axis `row-alignment` で fail 判定。
645
+ *
646
+ * 1 world = 大 diagram で subpixel 相当、 実測差 = 0 の場合のみ pass する厳格判定。
647
+ */
648
+ export const MIN_ROW_ALIGNMENT_TOLERANCE = 1;
649
+
650
+ /**
651
+ * 同 column 内 node の cx 差分許容 (world unit)。
652
+ *
653
+ * 同 column 概念 = 同 lane 内で縦積みされた node 群。 cx は lane center に engine が
654
+ * 揃える設計、 差 0 が理想だが `MIN_ROW_ALIGNMENT_TOLERANCE` と同 tolerance を採用。
655
+ */
656
+ export const MIN_COLUMN_ALIGNMENT_TOLERANCE = 1;
657
+
658
+ // ─── clearance policy 拡張 (kind pair 別) ──────────────────────
659
+
660
+ /**
661
+ * kind pair 別 clearance policy (world unit)。
662
+ *
663
+ * `collisions.ts` の CLEARANCE_POLICY (near collision 下限) と分離、 本 policy は
664
+ * 目標値を満たすかを見る軸 (`row-alignment` / `column-alignment` 等) が使う。
665
+ * `edge-label-clearance` は #386 で畳んだ。
666
+ *
667
+ * pair key format = `${kindA}|${kindB}` (アルファベット順)、 `requiredSpecClearance()` で
668
+ * 双方向 lookup。 未登録 pair は 0 = 判定対象外。
669
+ */
670
+ export const SPEC_CLEARANCE_POLICY: Record<string, number> = {
671
+ "edge-label|edge-path": LABEL_TO_PATH_CLEARANCE,
672
+ "edge-path|edge-label": LABEL_TO_PATH_CLEARANCE,
673
+ };
674
+
675
+ /**
676
+ * kind pair 別 spec clearance を返す。 未登録 pair は 0 (判定対象外)。
677
+ */
678
+ export function requiredSpecClearance(kindA: string, kindB: string): number {
679
+ const keys = [`${kindA}|${kindB}`, `${kindB}|${kindA}`];
680
+ for (const k of keys) {
681
+ if (SPEC_CLEARANCE_POLICY[k] !== undefined) return SPEC_CLEARANCE_POLICY[k]!;
682
+ }
683
+ return 0;
684
+ }
685
+
686
+ // ─── chain 伝搬 shift SSOT ─────────────────────────────────────
687
+
688
+ /**
689
+ * chain 伝搬 shift resolver の最大 iter 数。
690
+ *
691
+ * resolveOverlapsWithChain() で shift → 再検出 → 再 shift を loop するが、
692
+ * 発散防止のため N iter で強制収束。 通常 diagram は 3-5 iter で収束、 20 iter は
693
+ * 極端に密な layout でも十分な余裕。
694
+ */
695
+ export const CHAIN_SHIFT_MAX_ITER = 20;
696
+
697
+ /**
698
+ * chain 伝搬 shift の最小 clearance (world unit)。
699
+ *
700
+ * overlap 検出時に shift する距離 = overlap 実測 + `CHAIN_MIN_CLEARANCE`。
701
+ * shift 後の gap が最低この値になる保証。 16 world = 通常 node gap の 20% 相当、
702
+ * shift 後に「くっつきすぎ」 を視覚防止する余裕距離。
703
+ */
704
+ export const CHAIN_MIN_CLEARANCE = 16;
705
+
706
+ /**
707
+ * chain 伝搬 shift の 1 要素あたり累積 shift 上限 (world unit)。
708
+ *
709
+ * CAR-508 SSOT。 chain 伝搬 loop 内で label / edge-path が各軸方向にこの値以上
710
+ * 移動することを禁止し、 発散を止める。 Fan-out / Fan-in topology で label が互いに
711
+ * shift 連鎖して viewBox 外に飛ぶ regression の防止 guard。
712
+ *
713
+ * 100 world = label pill 高 (36) + label-label clearance (36) + 予備 (28) を目安、
714
+ * 通常の shift ケース (fail label ↔ retry path で 30-50 world 程度) には影響しない。
715
+ */
716
+ export const CHAIN_SHIFT_MAX_ACCUMULATED = 100;
717
+
718
+ // ─── CAR-422 (PR 2) positive-spec 追加値 ───────────────────────
719
+
720
+ /**
721
+ * arrow 終点と終点 node の辺中央との距離許容 (world unit)。
722
+ *
723
+ * 終点 node の toSide (top/right/bottom/left) の辺中央 と、 arrow 実際の
724
+ * 終点座標との距離が本値以下なら「中央着地」 と判定。 超過は 4 隅当て症状。
725
+ *
726
+ * 8 world = 通常 node 短辺 (h=100 前後) の 8% 相当、 実描画上「辺の真ん中」
727
+ * と視覚判別できる余裕距離。 axis 8 (arrow-endpoint-anchoring) が「側 or 内側」
728
+ * を判定するのに対し、 本値は「側の中央 or 端」 を判定する棲み分け。
729
+ */
730
+ export const ARROW_ENDPOINT_CENTER_TOL = 8;
731
+
732
+ /**
733
+ * 同 row 内 node 間の gap variance 許容 (world unit)。
734
+ *
735
+ * 同 stack index を持つ node 群を cx 昇順で並べ、 隣接 node cx 差 = gap を求める。
736
+ * gap の max - min が本値以下なら「均一」 判定。 超過は「片寄っている」 症状で
737
+ * axis `row-gap-uniform` で fail 判定。
738
+ *
739
+ * 40 world = 通常 node 間 gap (100-300) の 15-40% 相当、 layout engine が
740
+ * 均等配置に努めた結果として許容される subpixel 誤差 + 意図的 offset 余裕。
741
+ */
742
+ export const ROW_GAP_VARIANCE_TOL = 40;
743
+
744
+ /**
745
+ * 同 column 内 node 間の端間 gap variance 許容 (world unit)。
746
+ *
747
+ * 同 lane 内 node 群を cy 昇順で並べ、 隣接 node の端間 gap
748
+ * (下の node の上端 - 上の node の下端) を求める。
749
+ * gap の max - min が本値以下なら「均一」 判定。 縦積み layout の gap uniform 判定。
750
+ * 40 world = row 側と同 tolerance で対称、 vertical stacking の意図的 offset 余裕を含む。
751
+ *
752
+ * 中心間距離 (cy 差) ではなく端間 gap を測る (Issue #202)。 node 高さは kind ごとに
753
+ * 異なるため、 中心間距離は端間 gap が完全に均一でも node 高さ差の分だけばらつく。
754
+ * engine が制御しているのは端間 gap であり、 検査もそれに合わせる。
755
+ */
756
+ export const COLUMN_GAP_VARIANCE_TOL = 40;
757
+
758
+ /**
759
+ * edge-label bbox と lane border 貫通の判定 gap (world unit)。
760
+ *
761
+ * edge-label pill が lane 縁 (lane.x / lane.x+width) を貫通すると、 label の一部が
762
+ * 「別 lane に侵入」 したように見える。 label bbox 端が lane border から本値以下の
763
+ * 距離にあれば「貫通疑い」 として警告。
764
+ *
765
+ * 2 world = lane border の描画線幅 (通常 1-2 px) と bbox pill 端の許容誤差。
766
+ * 0 = 完全接触は許容、 内側に食い込むと fail。
767
+ */
768
+ export const LANE_BORDER_CLEARANCE_TOL = 2;
769
+
770
+ // ─── near-collision policy SSOT (CAR-424 で collisions.ts から集約) ──
771
+
772
+ /**
773
+ * near-collision 検出用 kind pair 別 clearance policy (world unit)。
774
+ *
775
+ * `SPEC_CLEARANCE_POLICY` (positive spec、 目標値) と分離、 本 policy は
776
+ * `detectNearCollisions()` (`collisions.ts`) が「これ以下は誤読」 の下限判定で使う。
777
+ *
778
+ * SSOT 集約 (CAR-424) — 旧実装は `collisions.ts` 内 module-local const `CLEARANCE_POLICY`
779
+ * として定義されていたが、 CAR-421 の `spec.ts` 追加後に「positive spec は spec.ts、
780
+ * near-collision は collisions.ts」 の分散所有になっていた。 本 file に集約する事で
781
+ * layout SSOT が 1 module に集中する (`clearance-constants.ts` の primitive 定数と
782
+ * 2 SSOT policy dict は組み合わせて downstream が使う)。
783
+ *
784
+ * default 未登録 pair は `NEAR_COLLISION_DEFAULT_MIN` (16 world unit)。
785
+ */
786
+ export const NEAR_COLLISION_POLICY: Record<string, number> = {
787
+ "node|node": 70,
788
+ "node|edge-label": 32,
789
+ "edge-label|edge-label": 36,
790
+ "edge-label|edge-path": 14,
791
+ "node|lane-label": 0,
792
+ "lane-label|edge-label": 24,
793
+ "lane-label|lane-label": 0,
794
+ "lane-label|edge-path": 0,
795
+ "edge-path|node": 0,
796
+ "edge-path|edge-path": 0,
797
+ };
798
+
799
+ /**
800
+ * `NEAR_COLLISION_POLICY` に登録なし pair の default 下限 (world unit)。
801
+ */
802
+ export const NEAR_COLLISION_DEFAULT_MIN = 16;
803
+
804
+ /**
805
+ * layout が node 同士に確保する間隔を、 policy ちょうどではなくこの分だけ広く取る (world unit)。
806
+ *
807
+ * policy は「これを割ったら誤読する」 下限で、 判定は `gap < required` の strict 比較。
808
+ * 下限ぴったりに置くと「通るだけ」 の状態になり、 lane 配置が 1 world 動いただけで割れる。
809
+ * 実際 dragon catalog では 68 対が下限ちょうど (余裕 0) に張り付いていた。
810
+ */
811
+ export const NODE_GAP_MARGIN = 10;
812
+
813
+ /**
814
+ * layout が node 同士に確保する目標間隔 (world unit)。
815
+ *
816
+ * 検査側の下限 (`NEAR_COLLISION_POLICY["node|node"]`) に余裕を足した値。 layout 側はこの値を
817
+ * 目標に lane 幅を決め、 検査側は下限で判定する = 目標を下げても即座に error にはならない。
818
+ */
819
+ export function targetNodeNodeClearance(): number {
820
+ return requiredNearClearance("node", "node") + NODE_GAP_MARGIN;
821
+ }
822
+
823
+ /**
824
+ * near-collision 用 kind pair 別 clearance を返す。 未登録 pair は
825
+ * `NEAR_COLLISION_DEFAULT_MIN` を返す (旧 collisions.ts `requiredClearance` 挙動保持)。
826
+ */
827
+ export function requiredNearClearance(kindA: string, kindB: string): number {
828
+ const keys = [`${kindA}|${kindB}`, `${kindB}|${kindA}`];
829
+ for (const k of keys) {
830
+ if (NEAR_COLLISION_POLICY[k] !== undefined) return NEAR_COLLISION_POLICY[k]!;
831
+ }
832
+ return NEAR_COLLISION_DEFAULT_MIN;
833
+ }
834
+
835
+ /**
836
+ * 図を読み上げる時の代替説明 (#363)。
837
+ *
838
+ * cdl の図は `<svg role="img">` で描く = 支援技術には **1 枚の絵** として渡り、 中の節や辺は
839
+ * 個別に公開されない (WAI-ARIA の `img` role は子孫を公開しない)。 そのため読み上げの質は
840
+ * この 1 文で決まる。
841
+ *
842
+ * 操作できる部品 (スライダー等) は `<svg>` の外の HTML なので、 絵として扱っても隠れない。
843
+ *
844
+ * 組み立ては 3 つを繋ぐ。
845
+ *
846
+ * | 部分 | 出どころ | 無い時 |
847
+ * |---|---|---|
848
+ * | 図が何を示すか | `topic` | 落とす |
849
+ * | 今どの段か | 段の `title` | 落とす |
850
+ * | 段が何を説明するか | 段の `body` | 落とす |
851
+ *
852
+ * 元は「題名 + 段の **id**」 を出していた (`"OAuth 認可コードの往復を追う p"`)。 段の id は
853
+ * 著者が付ける短い記号 (`"p"` 等) で、 読み上げても意味を持たない。
854
+ *
855
+ * @param topic 図が何を示すか
856
+ * @param phase 現在の段 (無ければ省略)
857
+ * @returns 読み上げる 1 文。 全て空なら空文字
858
+ */
859
+ export function buildDiagramAltText(
860
+ topic: string | undefined,
861
+ phase: { title?: string; body?: string } | undefined,
862
+ ): string {
863
+ const parts: string[] = [];
864
+ const topicText = topic?.trim() ?? "";
865
+ if (topicText) parts.push(topicText.endsWith("。") ? topicText : `${topicText}。`);
866
+ const title = phase?.title?.trim() ?? "";
867
+ const body = phase?.body?.trim() ?? "";
868
+ if (title && body) parts.push(`${title} — ${body}`);
869
+ else if (title) parts.push(title);
870
+ else if (body) parts.push(body);
871
+ return parts.join(" ");
872
+ }