@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,4381 @@
1
+ /**
2
+ * Visual validator ... layout 出力 (LaidDiagram) を 67 軸で検証する。
3
+ *
4
+ * cdl 著者が「component が render される」 までで完成と思いがちな問題に対し、
5
+ * 「視覚的に正しい」 を engine 層で機械判定する。
6
+ *
7
+ * 軸の一覧は `VisualAxis` 型が SSOT。 Axis 48 (`axis-documentation-completeness`) が見るのは
8
+ * **手で並べた `allAxes` の要素数が `EXPECTED_AXIS_COUNT` と一致するか** だけで、 `VisualAxis` /
9
+ * `emptyCounts` / `allAxes` の 3 つが揃っているかまでは見ない。 型と `emptyCounts` のずれは
10
+ * 型検査が捕まえるが、 `allAxes` への追記漏れは捕まらない (#381)。
11
+ *
12
+ * 戻り値 ... ViolationReport (各軸の違反一覧 + sum + ok flag)。
13
+ * 既存 validate (構造的破綻) と別 layer。 throw せず report のみ返す純粋関数。
14
+ *
15
+ * ───────────────────────────────────────────────────────────
16
+ * ## 新しい軸を足す時の判断基準 (#381)
17
+ *
18
+ * **軸は、 品質と結びつく量を、 検証済の前提の下で測る**。 これを外すと、 件数が 0 でも品質を
19
+ * 表さない軸ができる。 0 件は「問題がない」 とも「測れていない」 とも読めるが、 軸の側からは
20
+ * 区別できない。
21
+ *
22
+ * 2026-07-31 の 1 セッションで、 同じ性質の欠陥を 9 例続けて直した。 件数は dragon の見本
23
+ * 512 図での実測。
24
+ *
25
+ * | 軸 | 何がずれていたか | 件数 |
26
+ * |---|---|---|
27
+ * | `lane-lane-gap` | `contain: false` の帯は境界を描かないのに、 境界どうしの間隔を測っていた | 99 |
28
+ * | `text-readability` / `accessibility-basics` | 題名を一度も描かない種別と、 常に非表示の節を対象にしていた | 50 |
29
+ * | `responsive-viewport` | 幅の下限の判定が **反転** していた (十分広い時に警告) | 17 |
30
+ * | `mermaid-parity` | 外部ライブラリの対応表が古く、 対応物がある 3 種を「なし」 としていた | 3 |
31
+ * | `edge-label-proximity` | label の **中心** から **弦** までを測っていた (弦は描かれず、 中心は label の長さで動く) | 3 |
32
+ * | `grid-alignment` | 箱の **中心** が 16 の格子に載るかを見ていた (中心を格子に載せる実装は無い) | 2 |
33
+ * | 衝突判定 (label × 線) | 弦に落とした線と label 矩形の交差を見て、 自分の弦と一致する segment を除いていた。 端点が同じ弧は弦も一致するため、 兄弟の弧が丸ごと消えていた | **-3** |
34
+ * | `accessibility-basics` (再) | `role="img"` の子孫は支援技術に公開されないのに、 節ごとの題名を見ていた | 0 |
35
+ * | `mermaid-parity` (再) | 図の id から組み立て器を推定していた (id は著者が付ける値で無関係) | 0 |
36
+ *
37
+ * `-3` は「見えていなかった破綻が 3 件あった」 の意味。 直すと件数が **増える** 側の例。
38
+ *
39
+ * ずれ方は 4 つに分かれる。
40
+ *
41
+ * - **描かれない図形を測る** ... 見えない帯の境界 / 弦 / 箱の中心。 幾何の代表点を実際の
42
+ * 描画物と取り違える形
43
+ * - **描かれない情報を測る** ... 題名を描かない種別 / 支援技術に公開されない節。 データには
44
+ * あるが出力に出ない値を測る形
45
+ * - **意図を形から推測する** ... id からの組み立て器推定。 著者が持つ情報を図の形から当てよう
46
+ * とする形。 推測は必ず両方向に外れる (偽陽性と偽陰性が同時に出る)
47
+ * - **外部の情報が古びる** ... mermaid の対応表。 外部が更新されても軸は気付かない
48
+ *
49
+ * `responsive-viewport` だけは別で、 測る量 (`viewBox` の幅) は実在した。 **判定の向きが逆**
50
+ * だっただけで、 これは片方向の例しか試さないと通ってしまう。
51
+ *
52
+ * 足す前に 6 点を確かめる。
53
+ *
54
+ * 1. **測る量が出力から取れるか**。 DOM / SVG の `d` / 描画結果のいずれかから取れないなら、
55
+ * その軸は書けない。 データ構造の値で代用しない
56
+ * 2. **その量が品質と結びつく根拠を、 実装か規約で示せるか**。 「箱の中心が 16 の格子に載る」
57
+ * のように、 **誰も実装していない不変条件** を軸が勝手に決めていないか確かめる。 出力から
58
+ * 取れる量でも、 品質と結びついていなければ測る意味が無い
59
+ * 3. **発火する例と発火しない例を両方書いたか**。 判定の向きの誤りは片方だけでは通る
60
+ * 4. **0 件になった時、 それが「実在しない」 ことを示せるか**。 示せないなら、 発火する反例を
61
+ * 1 つ作って test に置く (軸が dead になっていないことの証明)
62
+ * 5. **著者の意図を要する判定は、 形から推測せず宣言させる**。 宣言の例 =
63
+ * `structuredData: "exclude"`
64
+ * 6. **外部の情報に依存するなら、 基準にした版を書き、 いつ確かめ直すかを決める**。 例 =
65
+ * `MERMAID_PARITY_BASIS_VERSION`。 版だけでは外部の更新に気付けないので、 版を書いた上で
66
+ * 「次にこの表を見直す条件」 も併せて残す
67
+ *
68
+ * 同じことを静的 test が拾えるなら、 図ごとの軸は要らない。 静的 test は実際の export を
69
+ * 列挙でき、 推測が要らない (例 = `test/mermaid-parity.test.ts` が `presets.ts` の export を
70
+ * 全件列挙する)。
71
+ *
72
+ * 直した後は、 手で持つ一覧が実装とずれない仕組みを併せて置く。 全 90 種別の描画結果と述語の
73
+ * 一致 / 全 export と対応表の照合 / 生成物と種別集合の一致 のように、 **実装を列挙して照合する**
74
+ * test が無いと、 次に種別を足した時に同じ状態へ戻る。
75
+ * ───────────────────────────────────────────────────────────
76
+ */
77
+ import type { CdlDiagram, LaidDiagram, NodeKind } from "./types";
78
+ import { CLEARANCE_LANE_LABEL, DIST_LABEL_PATH_MAX } from "./layout/clearance-constants";
79
+ import { layout } from "./layout";
80
+ import {
81
+ collectBBoxes,
82
+ detectCollisions,
83
+ detectNearCollisions,
84
+ extractPathSegments,
85
+ flattenPathSegments,
86
+ measurePathLabelGapSegs,
87
+ segmentsCrossRect,
88
+ } from "./layout/collisions";
89
+ import { measureTextWidth } from "./layout/text-width";
90
+ import {
91
+ bboxOverlaps,
92
+ isNearAny,
93
+ pointRectEdgeDistance,
94
+ rectRectOverlapArea,
95
+ segmentIntersection,
96
+ segmentsBBox,
97
+ } from "./layout/geometry";
98
+ import { TONE_HEX as TONE_SSOT, TONE_FALLBACK, hasTone } from "./render/tone";
99
+ import {
100
+ EDGE_LABEL_TEXT,
101
+ describeTextSpec,
102
+ requiredContrastRatio,
103
+ type EdgeLabelLine,
104
+ type EdgeLabelTextSpec,
105
+ } from "./label-text";
106
+
107
+ // 文字仕様の SSOT は `label-text.ts`。 ここからも読めるようにしておくのは、 対比の
108
+ // 検査を読む側が閾値を辿れるようにするため。
109
+ export {
110
+ EDGE_LABEL_TEXT,
111
+ WCAG_AA_LARGE,
112
+ WCAG_AA_NORMAL,
113
+ isLargeText,
114
+ requiredContrastRatio,
115
+ } from "./label-text";
116
+ export type { EdgeLabelLine, EdgeLabelTextSpec } from "./label-text";
117
+ import {
118
+ ARROW_ENDPOINT_CENTER_TOL,
119
+ COLUMN_GAP_VARIANCE_TOL,
120
+ DETOUR_CREST_OVERLAP_MIN,
121
+ DETOUR_SLOT_GAP,
122
+ EDGE_STUB_OUT,
123
+ LABEL_PILL_PAD_X,
124
+ computeLabelBoxW,
125
+ buildDiagramAltText,
126
+ computeLabelBBoxWorld,
127
+ hasRenderedLabel,
128
+ isRenderedNode,
129
+ rendersRows,
130
+ rendersTitleText,
131
+ rowBaselineY,
132
+ rowGlyphDepth,
133
+ LANE_BORDER_CLEARANCE_TOL,
134
+ MIN_COLUMN_ALIGNMENT_TOLERANCE,
135
+ MIN_ROW_ALIGNMENT_TOLERANCE,
136
+ ROW_GAP_VARIANCE_TOL,
137
+ } from "./layout/spec";
138
+
139
+ /**
140
+ * 対応表が基準にする mermaid.js の版。
141
+ *
142
+ * mermaid は diagram type を増やし続けており、 表を書いた時点を残さないと「対応物が無い」 が
143
+ * いつの話か分からなくなる。 実際にこの表は Architecture (v11.1.0) / TreeView (v11.14.0) /
144
+ * Swimlanes (v11.16.0) の追加に追随できておらず、 対応物がある組み立て器を「対応なし」 側に
145
+ * 置いたままだった。 表を更新する時はこの値も上げる。
146
+ */
147
+ export const MERMAID_PARITY_BASIS_VERSION = "11.16";
148
+
149
+ /**
150
+ * mermaid.js に対応する diagram type がある組み立て器。
151
+ *
152
+ * 短い別名 (`topo` / `infra` 等) も載せる。 図の id とは無関係で、 移行の手引きとして
153
+ * 「この呼び名でも同じ組み立て器を指す」 を示すためだけに置いている (#366 で図の id から
154
+ * 組み立て器を推定する判定は削除した)。
155
+ *
156
+ * 対応先。
157
+ *
158
+ * | 組み立て器 | mermaid の diagram type |
159
+ * |---|---|
160
+ * | sequence | sequenceDiagram |
161
+ * | flow / flowchart | flowchart |
162
+ * | stateMachine / stateMachine2 | stateDiagram-v2 (入れ子の状態と entry / exit の動作を扱える) |
163
+ * | er | erDiagram |
164
+ * | classDiagram | classDiagram |
165
+ * | gantt | gantt |
166
+ * | quadrant | quadrantChart |
167
+ * | mindMap / mindMapRadial | mindmap |
168
+ * | userJourney | journey |
169
+ * | chart / pie | xyChart / pie |
170
+ * | topology / infrastructure / network | architecture-beta (v11.1.0、 service / group / edge で構成を描く) |
171
+ * | swimlane | swimlanes (v11.16.0、 上位 subgraph が lane になる) |
172
+ * | tree | treeView-beta (v11.14.0、 階層を directory 風に描く) |
173
+ * | gitGraph | gitGraph |
174
+ * | block | block |
175
+ */
176
+ export const MERMAID_COMPATIBLE_PRESETS: ReadonlySet<string> = new Set([
177
+ "sequence",
178
+ "seq",
179
+ "flow",
180
+ "flowchart",
181
+ "statemachine",
182
+ "fsm",
183
+ "statemachine2",
184
+ "sm2",
185
+ "er",
186
+ "classdiagram",
187
+ "class",
188
+ "gantt",
189
+ "quadrant",
190
+ "quad",
191
+ "mindmap",
192
+ "mind",
193
+ "mindmapradial",
194
+ "mindradial",
195
+ "userjourney",
196
+ "journey",
197
+ "chart",
198
+ "pie",
199
+ "topology",
200
+ "topo",
201
+ "infrastructure",
202
+ "infra",
203
+ "network",
204
+ "swimlane",
205
+ "swim",
206
+ "tree",
207
+ "gitgraph",
208
+ "git",
209
+ "block",
210
+ ]);
211
+
212
+ /**
213
+ * mermaid.js に対応物を持たない cdl 独自の組み立て器。
214
+ *
215
+ * 対応物が無いのは設計上の事実で図の不備ではない。 拾いたいのは「組み立て器を足したのに
216
+ * 分類そのものを記録し忘れた」 状態だけで、 それは `test/mermaid-parity.test.ts` が
217
+ * `presets.ts` の export を全件列挙して固定する (#366)。
218
+ *
219
+ * `funnel` は mermaid 側で diagram type として提案されている段階で、 実装されていない。
220
+ * flowchart で近似はできるが、 段の幅で量を表す図としては別物なので対応物なしとする。
221
+ */
222
+ export const CDL_ONLY_PRESETS: ReadonlySet<string> = new Set(["funnel"]);
223
+
224
+ /**
225
+ * 箱を格子 (16 world) に載せる組み立て器が作る節の種別。
226
+ *
227
+ * これらは canvas の寸法を 16 の倍数で作り、 中身を自前で layout する。 `grid-alignment` は
228
+ * 箱の左 / 右 / 上 / 下の 4 辺が格子に載るかを見る。
229
+ */
230
+ export const GRID_ALIGNED_KINDS: ReadonlySet<NodeKind> = new Set([
231
+ "chart-pie",
232
+ "chart-line",
233
+ "chart-bar",
234
+ "funnel-stages",
235
+ "quadrant-matrix",
236
+ ]);
237
+
238
+ /** 点 (px, py) と線分 (x1,y1)-(x2,y2) の最短距離 */
239
+ function pointToSegmentDistance(
240
+ px: number,
241
+ py: number,
242
+ x1: number,
243
+ y1: number,
244
+ x2: number,
245
+ y2: number,
246
+ ): number {
247
+ const dx = x2 - x1;
248
+ const dy = y2 - y1;
249
+ const lenSq = dx * dx + dy * dy;
250
+ if (lenSq === 0) return Math.hypot(px - x1, py - y1);
251
+ let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
252
+ t = Math.max(0, Math.min(1, t));
253
+ const cx = x1 + t * dx;
254
+ const cy = y1 + t * dy;
255
+ return Math.hypot(px - cx, py - cy);
256
+ }
257
+
258
+ export interface Violation {
259
+ axis: VisualAxis;
260
+ diagramId: string;
261
+ detail: string;
262
+ severity: "error" | "warn";
263
+ }
264
+
265
+ export type VisualAxis =
266
+ | "node-visibility"
267
+ | "edge-label-overlap"
268
+ | "edge-label-proximity"
269
+ | "text-readability"
270
+ | "row-format"
271
+ | "alignment"
272
+ | "clearance"
273
+ // CAR-Y (PR #75) で追加した位置検証 5 軸。 arrow endpoint / label char range / node z-order /
274
+ // edge crossing / edge-node cross の実座標検証を engine 側で機械化 SSOT。
275
+ | "arrow-endpoint-anchoring"
276
+ | "label-char-range"
277
+ | "node-overlap"
278
+ | "edge-crossing"
279
+ | "edge-node-cross"
280
+ // CAR-Z (PR #76) 追加 = より深い位置検証 5 軸。
281
+ | "edge-segment-orthogonality"
282
+ | "label-inside-viewbox"
283
+ | "lane-cx-consistency"
284
+ | "row-vertical-spacing"
285
+ | "group-boundary-clearance"
286
+ // CAR-Z2 (PR #81) 追加 = 隣接 clearance / arrow marker の 3 軸拡張。
287
+ | "node-vertical-clearance"
288
+ | "lane-lane-gap"
289
+ | "arrow-marker-clearance"
290
+ // CAR-Z3 (PR #82) 追加 = 4 領域拡張。
291
+ | "grid-alignment"
292
+ | "phase-layout-stability"
293
+ | "responsive-viewport"
294
+ | "accessibility-basics"
295
+ // CAR-Z4 (PR #83) 追加 = animation / i18n / contrast / print-media 4 領域。
296
+ | "animation-frame-integrity"
297
+ | "i18n-cjk-detection"
298
+ | "contrast-basics"
299
+ | "print-media-compat"
300
+ // CAR-Z5 (PR #84) 追加 = SVG filter / marker / gradient integrity 4 領域。
301
+ | "color-blind-safety"
302
+ | "marker-gradient-def-integrity"
303
+ // CAR-Z6 (PR #85) 追加 = subpixel / DOM complexity / motion / touch 4 領域。
304
+ | "subpixel-precision"
305
+ | "dom-complexity-budget"
306
+ | "reduced-motion-compat"
307
+ | "touch-target-size"
308
+ // CAR-Z7 (PR #86) 追加 = row-typing / terminal-safe-text / GPU / memory 4 領域。
309
+ | "row-content-typing"
310
+ | "terminal-safe-text"
311
+ | "gpu-layer-efficiency"
312
+ | "memory-budget"
313
+ // CAR-Z8 (PR #87) 追加 = XSS / SEO / bidi / structured-data 4 領域。
314
+ | "svg-injection-safety"
315
+ | "seo-metadata-quality"
316
+ | "bidi-hyphenation"
317
+ | "structured-data-extraction"
318
+ // CAR-Z9 (PR #88) 追加 = semver / migration / meta 4 領域。
319
+ | "diagram-version-semver"
320
+ | "migration-path-consistency"
321
+ | "axis-coverage-meta"
322
+ | "axis-documentation-completeness"
323
+ // CAR-Z10 (PR #89) 追加 = fixture-drift / locale / performance 3 領域
324
+ // (mermaid は #366 で削除、 対応表の完全性は静的 test に一本化)。
325
+ | "fixture-drift-detection"
326
+ | "locale-parity"
327
+ | "validate-performance-budget"
328
+ // Axis 53 (2026-07-03 追加) = node が diagram viewBox 内に収まっているか。
329
+ // 既存 Axis 14 (label-inside-viewbox) の node 版、 clip 検知の直接手段。
330
+ | "node-inside-viewbox"
331
+ // Axis 54 (2026-07-03 追加) = node bbox が所属 lane 内に収まっているか。
332
+ // Axis 15 (lane-cx-consistency) は中心 x のみ、 本 axis は node 4 辺と lane 4 辺の包含関係を検知。
333
+ | "node-inside-lane"
334
+ // Axis 55 (2026-07-03 追加) = edge path の全 segment 端点が viewBox 内に収まっているか。
335
+ // Axis 14 (label-inside-viewbox) は edge-label のみ、 Axis 53 (node-inside-viewbox) は node のみ、
336
+ // 本 axis は edge path (SVG d 属性の全 segment) の viewBox 収まりを見る。
337
+ | "edge-inside-viewbox"
338
+ // Axis 56 (2026-07-03 追加) = lane label (kind: "lane-label" bbox) が viewBox 内に収まっているか。
339
+ // Axis 14 は edge-label 対象、 本 axis は lane-label 対象で棲み分け。
340
+ | "lane-label-inside-viewbox"
341
+ | "lane-label-overlap"
342
+ // CAR-421 (PR 1 / CAR-418 chain 伝搬 shift + spec 固定化) 追加 = positive-check axis 5 種。
343
+ // 既存 axis は「これ以下は誤読」 の下限、 本 5 axis は「これを満たすと正しい」 の positive spec。
344
+ // spec.ts の SSOT 定数を参照。
345
+ // Axis 57 = 同 row 内 node cy 揃い (MIN_ROW_ALIGNMENT_TOLERANCE)。
346
+ | "row-alignment"
347
+ // Axis 58 = 同 column (同 lane) 内 node cx 揃い (MIN_COLUMN_ALIGNMENT_TOLERANCE)。
348
+ | "column-alignment"
349
+ // Axis 60 = edge 起点から水平方向に EDGE_STUB_OUT world 以上直進 (起点判別容易化)。
350
+ | "edge-stubout-min"
351
+ // Axis 61 = 同 from node から出る全 edge の起点 Y が同一 (fan-out origin 分散防止)。
352
+ | "fan-origin-single-point"
353
+ // CAR-422 (PR 2 / CAR-418 chain) 追加 = positive-check axis 5 種。
354
+ // Axis 62 = 同 obstacle を迂回する複数 detour path の Y peak 差 ≥ DETOUR_SLOT_GAP。
355
+ | "detour-slot-distinct"
356
+ // Axis 63 = arrow 終点 が終点 node の toSide 辺中央 (±ARROW_ENDPOINT_CENTER_TOL) に収束。
357
+ | "arrow-endpoint-center"
358
+ // Axis 64 = edge-label bbox が lane border 貫通なし (LANE_BORDER_CLEARANCE_TOL)。
359
+ | "lane-border-clearance"
360
+ // Axis 65 = 同 stack 内 node 間 gap variance (max-min) ≤ ROW_GAP_VARIANCE_TOL。
361
+ | "row-gap-uniform"
362
+ // Axis 66 = 同 lane 内 node 間 gap variance (max-min) ≤ COLUMN_GAP_VARIANCE_TOL。
363
+ | "column-gap-uniform"
364
+ // Axis 68 (#387) = 行を描かない種別に rows が書かれている (書いた内容が画面に出ない)。
365
+ | "rows-not-rendered"
366
+ // #393 = 検査に渡された図の形自体が壊れている (検査できなかったことを結果として出す)。
367
+ | "malformed-input"
368
+ // #393 = 検査が例外で中断したが、 原因が入力の破綻か検査側の欠陥か **決められない**。
369
+ // 入口が何も見つけていない時にここへ来る。 「検査側の欠陥」 と断定する名前を付けると、
370
+ // 実際には入力側 (getter が例外を投げる 等 入口の届かない破綻) の時に調査を誤らせる。
371
+ | "validation-interrupted";
372
+
373
+ /**
374
+ * 検査の用途。 用途によって見るべき軸が違う。
375
+ *
376
+ * - `production` = 公開する web ページ。 全ての軸を見る (既定)
377
+ * - `catalog` = 記法の見本。 SEO の軸を見ない
378
+ *
379
+ * 見本は「記法をどう書くか」 を示すもので、 検索結果に出す対象ではない。 SEO の軸
380
+ * (`seo-metadata-quality` / `structured-data-extraction`) は題名の長さや有名な節の数を見るため、
381
+ * 短い題名の見本を足すたびに警告が出る。
382
+ *
383
+ * 経緯 = cardene777/dragon#887 の起票時は見本 412 図で 179 件出ていたが、 その後 見本側の題名が
384
+ * 整備されて現在は 0 件 (実測 = 316 図で 0 件)。 本区分は「今ある noise を消す」 のではなく
385
+ * 「見本に SEO の軸を課さない」 契約を先に置くもの。
386
+ */
387
+ export type ValidationProfile = "production" | "catalog";
388
+
389
+ /** 用途ごとに見ない軸。 */
390
+ const SKIPPED_AXES: Readonly<Record<ValidationProfile, ReadonlySet<VisualAxis>>> = {
391
+ production: new Set(),
392
+ catalog: new Set<VisualAxis>(["seo-metadata-quality", "structured-data-extraction"]),
393
+ };
394
+
395
+ /** 検査の設定。 */
396
+ export interface ValidateOptions {
397
+ /** 検査の用途。 既定は `production` (全ての軸を見る)。 */
398
+ profile?: ValidationProfile;
399
+ }
400
+
401
+ export interface VisualValidationReport {
402
+ diagramId: string;
403
+ ok: boolean;
404
+ violations: Violation[];
405
+ counts: Record<VisualAxis, number>;
406
+ /** 検査した用途。 report を保存して後から読む時に、 何を見たかが分かる。 */
407
+ profile: ValidationProfile;
408
+ /**
409
+ * 用途によって見なかった軸。
410
+ *
411
+ * `counts` はこれらも 0 を返す。 載せないと「検査して 0 件」 と「検査していない」 を
412
+ * 区別できず、 集計した時に見た範囲を過大に読む。
413
+ */
414
+ skippedAxes: VisualAxis[];
415
+ }
416
+
417
+ const MIN_NODE_W = 80;
418
+ const MIN_NODE_H = 40;
419
+ const MIN_FONT_SIZE = 12;
420
+ const ROW_FORMAT = /^[^:]+:\s*.+$/;
421
+ const PLACEHOLDER_RE = /\{(\w+)\}/g;
422
+
423
+ function emptyCounts(): Record<VisualAxis, number> {
424
+ return {
425
+ "node-visibility": 0,
426
+ "edge-label-overlap": 0,
427
+ "edge-label-proximity": 0,
428
+ "text-readability": 0,
429
+ "row-format": 0,
430
+ alignment: 0,
431
+ clearance: 0,
432
+ "arrow-endpoint-anchoring": 0,
433
+ "label-char-range": 0,
434
+ "node-overlap": 0,
435
+ "edge-crossing": 0,
436
+ "edge-node-cross": 0,
437
+ "edge-segment-orthogonality": 0,
438
+ "label-inside-viewbox": 0,
439
+ "lane-cx-consistency": 0,
440
+ "row-vertical-spacing": 0,
441
+ "group-boundary-clearance": 0,
442
+ "node-vertical-clearance": 0,
443
+ "lane-lane-gap": 0,
444
+ "arrow-marker-clearance": 0,
445
+ "grid-alignment": 0,
446
+ "phase-layout-stability": 0,
447
+ "responsive-viewport": 0,
448
+ "accessibility-basics": 0,
449
+ "animation-frame-integrity": 0,
450
+ "i18n-cjk-detection": 0,
451
+ "contrast-basics": 0,
452
+ "print-media-compat": 0,
453
+ "color-blind-safety": 0,
454
+ "marker-gradient-def-integrity": 0,
455
+ "subpixel-precision": 0,
456
+ "dom-complexity-budget": 0,
457
+ "reduced-motion-compat": 0,
458
+ "touch-target-size": 0,
459
+ "row-content-typing": 0,
460
+ "terminal-safe-text": 0,
461
+ "gpu-layer-efficiency": 0,
462
+ "memory-budget": 0,
463
+ "svg-injection-safety": 0,
464
+ "seo-metadata-quality": 0,
465
+ "bidi-hyphenation": 0,
466
+ "structured-data-extraction": 0,
467
+ "diagram-version-semver": 0,
468
+ "migration-path-consistency": 0,
469
+ "axis-coverage-meta": 0,
470
+ "axis-documentation-completeness": 0,
471
+ "fixture-drift-detection": 0,
472
+ "locale-parity": 0,
473
+ "validate-performance-budget": 0,
474
+ "node-inside-viewbox": 0,
475
+ "node-inside-lane": 0,
476
+ "edge-inside-viewbox": 0,
477
+ "lane-label-inside-viewbox": 0,
478
+ "lane-label-overlap": 0,
479
+ "row-alignment": 0,
480
+ "column-alignment": 0,
481
+ "edge-stubout-min": 0,
482
+ "fan-origin-single-point": 0,
483
+ "detour-slot-distinct": 0,
484
+ "arrow-endpoint-center": 0,
485
+ "lane-border-clearance": 0,
486
+ "row-gap-uniform": 0,
487
+ "column-gap-uniform": 0,
488
+ "rows-not-rendered": 0,
489
+ "malformed-input": 0,
490
+ "validation-interrupted": 0,
491
+ };
492
+ }
493
+
494
+ /**
495
+ * LaidDiagram を直接受け取って 56 axis で視覚検証する低 layer API (v0.9+ 追加)。
496
+ *
497
+ * visualValidate() は layout 経由で LaidDiagram を得た後、 本関数を呼び出す thin wrapper。
498
+ * fixture test で layout を bypass して LaidDiagram を直接構築、 各 axis の real defect を
499
+ * assertion 化する際に本 API を使う。
500
+ *
501
+ * @param laid layout 済 diagram
502
+ * @param diag 元 CdlDiagram (viewport / states 等の check に必要)
503
+ */
504
+ /**
505
+ * edge 起点の stub 長 (world unit) を測る (Issue #202)。
506
+ *
507
+ * 起点から、 進行が起点 side の軸方向に進み続ける距離。
508
+ *
509
+ * `extractPathSegments` は L 字 corner の `Q` 命令を弦 (始点 → 終点の直線) に近似する。
510
+ * engine は `L exitX - corner` → `Q exitX ...` の順で曲がり、 corner の弧が最後の
511
+ * corner 半径分を担って `exitX = 起点 + EDGE_STUB_OUT` に到達する (edges.ts の CAR-472 SSOT)。
512
+ * よって第 1 segment の長さだけを見ると corner 半径 (14) だけ短く読める。
513
+ *
514
+ * 軸方向の変位を累積し、 進行が止まる (or 反転する) 時点で打ち切ることで、
515
+ * 直線と corner の弧をまとめて「起点からどれだけ離れたか」 を測る。
516
+ *
517
+ * @param segs `extractPathSegments` の出力 (2 segment 以上)
518
+ * @returns 起点 side 軸方向の到達距離
519
+ */
520
+ export function measureStubOut(
521
+ segs: readonly { x1: number; y1: number; x2: number; y2: number }[],
522
+ ): number {
523
+ const first = segs[0]!;
524
+ const dx0 = first.x2 - first.x1;
525
+ const dy0 = first.y2 - first.y1;
526
+ const axis: "x" | "y" = Math.abs(dx0) >= Math.abs(dy0) ? "x" : "y";
527
+ const sign = Math.sign(axis === "x" ? dx0 : dy0);
528
+ if (sign === 0) return 0;
529
+
530
+ let stub = 0;
531
+ for (const s of segs) {
532
+ const delta = (axis === "x" ? s.x2 - s.x1 : s.y2 - s.y1) * sign;
533
+ if (delta <= 0) break;
534
+ stub += delta;
535
+ }
536
+ return stub;
537
+ }
538
+
539
+ /** `render/edges.tsx` の `var(--cdl-label-bg, #ffffff)` の既定値。 */
540
+ export const LABEL_BG_DEFAULT = "#ffffff";
541
+ /** `render/edges.tsx` の非活性時の `var(--cdl-text-dim, #5a6270)` の既定値。 */
542
+ export const LABEL_TEXT_INACTIVE_DEFAULT = "#5a6270";
543
+
544
+ function hexToRgb(hex: string): [number, number, number] {
545
+ const h = hex.replace("#", "");
546
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
547
+ }
548
+
549
+ /** WCAG 2.x の相対輝度 (0..1)。 */
550
+ function relativeLuminance(rgb: [number, number, number]): number {
551
+ const [r, g, b] = rgb.map((v) => {
552
+ const s = v / 255;
553
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
554
+ }) as [number, number, number];
555
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
556
+ }
557
+
558
+ /** WCAG 2.x の contrast ratio (1..21)。 */
559
+ export function contrastRatio(a: string, b: string): number {
560
+ const la = relativeLuminance(hexToRgb(a));
561
+ const lb = relativeLuminance(hexToRgb(b));
562
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
563
+ }
564
+
565
+ /** 前景色を背景色に alpha で乗せた合成色。 半透明の文字を実効色に直す。 */
566
+ export function blendOver(fg: string, bg: string, alpha: number): string {
567
+ const [f, b] = [hexToRgb(fg), hexToRgb(bg)];
568
+ const ch = (i: 0 | 1 | 2) => Math.round(f[i] * alpha + b[i] * (1 - alpha));
569
+ return `#${[ch(0), ch(1), ch(2)].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
570
+ }
571
+
572
+ /**
573
+ * edge label の文字と背景の対比を測る (Axis 27 `contrast-basics` の本体)。
574
+ *
575
+ * 色を引数で受け取る純粋関数にしてある。 cdl の既定値は呼出側が渡し、 test は閾値をまたぐ色を
576
+ * 注入して判定の向きを直接確かめられる (定数のままだと、 合成や分岐を消しても既定値では
577
+ * 判定が変わらず test が通ってしまう)。
578
+ *
579
+ * ## 何を測るか
580
+ *
581
+ * `render/edges.tsx` の edge label は 2 行構成で、 行ごとに WCAG の区分が違う。
582
+ *
583
+ * main 行 ... fontSize 22 / fontWeight 700 / opacity 1 → large text (3:1)
584
+ * sub 行 ... fontSize 19 / 太字なし / opacity 0.95 → 通常文字 (4.5:1)
585
+ *
586
+ * WCAG の large text は 24px 以上 または 太字 18.66px 以上。 sub 行はどちらも満たさない。
587
+ * sub 行は半透明なので、 背景と合成した実効色で測る。
588
+ *
589
+ * 文字色は状態で変わる (`fill={active ? color : "var(--cdl-text-dim, ...)"}`)。 tone の色が
590
+ * 文字になるのは活性時だけで、 非活性時は tone に依らず 1 色。
591
+ *
592
+ * どちらの状態が起きるかは phase が決める。 `render/stage.tsx` は
593
+ * `activeSet = new Set(currentPhase?.activate ?? [])` なので、
594
+ *
595
+ * - phase が無ければ全ての辺が常に非活性
596
+ * - ある辺を activate しない phase があれば、 その辺は非活性の見た目も起きる
597
+ * - どの phase も activate しない辺の tone は、 label の文字として一度も描かれない
598
+ *
599
+ * ## 何を測らないか
600
+ *
601
+ * 未知の tone は飛ばさない。 描画側 (`render/tone.ts` の `toneColor`) が既定色に落として
602
+ * 描くので、 その既定色で測る。
603
+ *
604
+ * 下流の CSS。 背景も文字色も CSS 変数で上書きでき、 さらに
605
+ * `[data-cdl-role="edge-label"]` への `fill: ... !important` で変数ごと迂回できる
606
+ * (dragon の 6 主題は実際にそうしている)。 下流側の対比は下流の test が見る。
607
+ *
608
+ * 非活性の pill の `opacity 0.92`。 背後の色が 8% 混ざるが、 既定の stage 背景との合成で
609
+ * 対比 6.15 → 6.10、 背後が純黒でも 5.16 で、 閾値 3.0 から十分離れている。
610
+ *
611
+ * @returns 閾値を割った組合せ。 (状態, 行, 実効文字色) が同じものは 1 件にまとめる
612
+ */
613
+ export function evaluateLabelContrast(opts: {
614
+ edges: ReadonlyArray<{ id: string; label?: string; sub?: string; tone: string }>;
615
+ phases: ReadonlyArray<{ activate: readonly string[] }>;
616
+ /** tone 名から色を引く。 描画側と同じ解決 (未知の tone は既定色に落とす) を渡す。 */
617
+ toneColor: (tone: string) => string;
618
+ labelBg: string;
619
+ inactiveText: string;
620
+ /**
621
+ * 行ごとの文字仕様。 既定は描画側の SSOT なので、 呼出側は渡さない。
622
+ * test が「不透明度が 1 未満なら合成して測る」 等の分岐を動かすためだけに差し替える。
623
+ */
624
+ textSpec?: Record<EdgeLabelLine, EdgeLabelTextSpec>;
625
+ }): Array<{ detail: string; ratio: number }> {
626
+ const { edges, phases, toneColor, labelBg, inactiveText, textSpec = EDGE_LABEL_TEXT } = opts;
627
+
628
+ // 型では `phases` も `activate` も配列だが、 検査は壊れた入力にも呼ばれる (dragon の変異
629
+ // test が phase を差し替えて別軸の発火を確かめる)。 本軸が例外を投げると、 その図の
630
+ // **全軸の検査が巻き添えで落ちる**。
631
+ //
632
+ // 配列でないものは「無い」 と見て進む。 `activate` が文字列でも同じ = 文字列は反復可能で
633
+ // `includes` も持つため、 `Array.isArray` を外すと 1 文字ずつを辺 id と誤解する。
634
+ // 形が違う要素は **数えずに落とす**。 空の `activate` を持つ phase として残すと、
635
+ // 有効な全 phase で活性な辺に「非活性の見た目も起きる」 と誤判定する。
636
+ const isObject = (v: unknown): v is Record<string, unknown> =>
637
+ typeof v === "object" && v !== null;
638
+ const phaseList = (Array.isArray(phases) ? phases : []).filter(
639
+ (p): p is { activate: readonly string[] } => isObject(p) && Array.isArray(p.activate),
640
+ );
641
+
642
+ const everActive = new Set<string>();
643
+ for (const p of phaseList) for (const id of p.activate) everActive.add(id);
644
+ const everInactive = (edgeId: string): boolean =>
645
+ phaseList.length === 0 || phaseList.some((p) => !p.activate.includes(edgeId));
646
+
647
+ const out: Array<{ detail: string; ratio: number }> = [];
648
+ const reported = new Set<string>();
649
+ // edge も形が違えば飛ばす。 `hasRenderedLabel` は `label?.trim()` を呼ぶので、 `label` が
650
+ // 文字列でないと例外になる。
651
+ const isStringOrAbsent = (v: unknown): v is string | undefined =>
652
+ v === undefined || typeof v === "string";
653
+ for (const e of Array.isArray(edges) ? edges : []) {
654
+ if (!isObject(e)) continue;
655
+ if (!isStringOrAbsent(e.label) || !isStringOrAbsent(e.sub)) continue;
656
+ if (typeof e.id !== "string" || typeof e.tone !== "string") continue;
657
+ if (!hasRenderedLabel(e)) continue;
658
+ // 行ごとの閾値と合成は描画側の SSOT (`label-text.ts`) から導く。 ここに 22 / 19 /
659
+ // 0.95 を書き写すと、 描画を変えた時に検査だけが古い値を測り続ける。
660
+ const lines: EdgeLabelLine[] = [];
661
+ if (e.label?.trim()) lines.push("main");
662
+ if (e.sub?.trim()) lines.push("sub");
663
+
664
+ const states: Array<{ state: "活性" | "非活性"; fg: string }> = [];
665
+ if (everActive.has(e.id)) {
666
+ // 未知の tone も描画側は既定色で描く (`render/tone.ts` の `toneColor`)。 ここで
667
+ // 飛ばすと、 既定色が閾値を割った時に見逃す。 `toneColors[e.tone]` の形で引くと
668
+ // `toString` 等の prototype 由来の key で関数が返り、 色として扱って落ちる
669
+ // (同じ欠陥を `render/tone.ts` が `hasTone` で既に塞いでいる)。
670
+ states.push({ state: "活性", fg: toneColor(e.tone) });
671
+ }
672
+ if (everInactive(e.id)) states.push({ state: "非活性", fg: inactiveText });
673
+
674
+ for (const { state, fg } of states) {
675
+ for (const line of lines) {
676
+ const spec = textSpec[line];
677
+ const minRatio = requiredContrastRatio(spec);
678
+ const effective = spec.opacity === 1 ? fg : blendOver(fg, labelBg, spec.opacity);
679
+ const key = `${state}:${line}:${effective}`;
680
+ if (reported.has(key)) continue;
681
+ const ratio = contrastRatio(effective, labelBg);
682
+ if (ratio >= minRatio) continue;
683
+ reported.add(key);
684
+ out.push({
685
+ ratio,
686
+ detail: `${state}時の edge label ${line} 行 (${describeTextSpec(spec)}) の文字 ${effective} vs 背景 ${labelBg} の対比 ${ratio.toFixed(2)}:1 が ${minRatio}:1 未満`,
687
+ });
688
+ }
689
+ }
690
+ }
691
+ return out;
692
+ }
693
+
694
+ /**
695
+ * property を読む。 getter が例外を投げる形でも落ちない。
696
+ *
697
+ * 検査は「壊れた入力を報告する」 のが仕事なので、 読取そのものが落ちると仕事にならない。
698
+ * 読めなかったことは呼出側が壊れている印として扱う。
699
+ */
700
+ function safeRead(obj: object, key: string): { ok: true; value: unknown } | { ok: false } {
701
+ try {
702
+ return { ok: true, value: (obj as Record<string, unknown>)[key] };
703
+ } catch {
704
+ return { ok: false };
705
+ }
706
+ }
707
+
708
+ /**
709
+ * 配列の長さを読む。 Proxy は `length` にも trap を差せるので、 読取が落ちうる。
710
+ *
711
+ * **読めなかったことを呼出側に返す**。 0 を返して黙って走査を終えると「要素が無い」 と
712
+ * 区別できず、 検査していないのに違反 0 件と報告することになる (本 issue が消そうとして
713
+ * いる失敗そのもの)。
714
+ */
715
+ function safeLength(arr: readonly unknown[]): { ok: true; value: number } | { ok: false } {
716
+ try {
717
+ const n = arr.length;
718
+ return typeof n === "number" && Number.isFinite(n) ? { ok: true, value: n } : { ok: false };
719
+ } catch {
720
+ return { ok: false };
721
+ }
722
+ }
723
+
724
+ /**
725
+ * 浅い copy を作る。 spread (`{ ...obj }`) は全 getter を走らせるので、 1 つでも例外を投げると
726
+ * copy 自体が落ちる。 読めた key だけを移す。
727
+ */
728
+ function safeShallowCopy(obj: object): Record<string, unknown> {
729
+ const out: Record<string, unknown> = {};
730
+ let keys: string[] = [];
731
+ try {
732
+ keys = Object.keys(obj);
733
+ } catch {
734
+ return out;
735
+ }
736
+ for (const k of keys) {
737
+ const r = safeRead(obj, k);
738
+ // 読めない key は落とす。 壊れていることは `findMalformedInput` が別に報告する。
739
+ if (r.ok) out[k] = r.value;
740
+ }
741
+ return out;
742
+ }
743
+
744
+ /**
745
+ * 例外の説明を安全に取り出す。
746
+ *
747
+ * `err` は何でも投げられる (文字列 / object / getter が例外を投げる Error)。 `.message` の
748
+ * 読取自体が落ちると、 受け止めた意味が無くなる。
749
+ */
750
+ function describeError(err: unknown): string {
751
+ try {
752
+ if (err instanceof Error) return `${err.name}: ${err.message}`;
753
+ if (typeof err === "string") return err;
754
+ return `非 Error の値 (${err === null ? "null" : typeof err})`;
755
+ } catch {
756
+ return "説明を取り出せない値";
757
+ }
758
+ }
759
+
760
+ /**
761
+ * 例外がどこで起きたかを 1 行で添える。
762
+ *
763
+ * どの軸で落ちたかを記録するには 69 箇所に印を置くことになる。 stack の最初のフレームは
764
+ * file と行を持つので、 印を置かずに追跡できる。 取れない環境では空を返す。
765
+ */
766
+ function describeErrorOrigin(err: unknown): string {
767
+ try {
768
+ const stack = err instanceof Error ? err.stack : undefined;
769
+ if (typeof stack !== "string") return "";
770
+ const frame = stack.split("\n").find((l) => l.trim().startsWith("at "));
771
+ return frame === undefined ? "" : ` [${frame.trim()}]`;
772
+ } catch {
773
+ return "";
774
+ }
775
+ }
776
+
777
+ /** 壊れた `diag` でも報告に載せられる id を返す。 */
778
+ function safeDiagramId(diag: unknown): string {
779
+ if (typeof diag !== "object" || diag === null) return "";
780
+ const r = safeRead(diag, "id");
781
+ return r.ok && typeof r.value === "string" ? r.value : "";
782
+ }
783
+
784
+ /** 2 辺が共有する節を返す。 共有しなければ空。 */
785
+ function sharedNodes(
786
+ a: { from?: string; to?: string },
787
+ b: { from?: string; to?: string },
788
+ ): string[] {
789
+ const other = new Set([b.from, b.to].filter((x): x is string => x !== undefined));
790
+ return [a.from, a.to].filter((x): x is string => x !== undefined && other.has(x));
791
+ }
792
+
793
+ /**
794
+ * 共有節ごとの、 2 辺それぞれの「その節に付く側」 (#445 review)。
795
+ *
796
+ * 端の **座標だけ** を渡すと、 辺の向きを座標の近さで当てることになる。 節の別々の側に付く
797
+ * 辺どうし (一方は左から、 もう一方は上から入る) では端の座標が離れるため、 向きを取り違える。
798
+ * `from` / `to` から決めれば取り違えない。
799
+ */
800
+ type SharedSide = {
801
+ a: { toStart: boolean; end: { x: number; y: number } };
802
+ b: { toStart: boolean; end: { x: number; y: number } };
803
+ };
804
+
805
+ /** 2 辺が共有する節ごとに、 各辺のどちらの端がその節かを返す。 */
806
+ function sharedSides(
807
+ aRef: { from?: string; to?: string },
808
+ aEnds: ReadonlyArray<{ x: number; y: number }>,
809
+ bRef: { from?: string; to?: string },
810
+ bEnds: ReadonlyArray<{ x: number; y: number }>,
811
+ ): SharedSide[] {
812
+ const 側 = (
813
+ ref: { from?: string; to?: string },
814
+ ends: ReadonlyArray<{ x: number; y: number }>,
815
+ id: string,
816
+ ): { toStart: boolean; end: { x: number; y: number } } | undefined => {
817
+ // 経路は `from` から `to` へ描かれるので、 始点側が `from`、 終点側が `to`。
818
+ if (ref.from === id && ends[0]) return { toStart: true, end: ends[0] };
819
+ if (ref.to === id && ends[1]) return { toStart: false, end: ends[1] };
820
+ return undefined;
821
+ };
822
+ const out: SharedSide[] = [];
823
+ for (const id of sharedNodes(aRef, bRef)) {
824
+ const a = 側(aRef, aEnds, id);
825
+ const b = 側(bRef, bEnds, id);
826
+ if (a && b) out.push({ a, b });
827
+ }
828
+ return out;
829
+ }
830
+
831
+ type Seg = { x1: number; y1: number; x2: number; y2: number };
832
+
833
+ /**
834
+ * 合流かどうかの判定。
835
+ *
836
+ * `aborted` は「予算が足りなくて見ていない」。 **`not-merged` と混ぜてはいけない** = 見た上で
837
+ * 合流でないのか、 見ていないのかで、 呼出側が交点を数えるかどうかは同じでも「数え切れた」
838
+ * を下げる必要があるかが変わる。
839
+ */
840
+ type 歩いた結果 = "merged" | "not-merged" | "aborted";
841
+
842
+ /**
843
+ * 線分 `seg` のうち、 点 `c` から距離 `tol` 以内にある範囲を媒介変数の区間で返す。
844
+ *
845
+ * **2 次方程式の判別式を直に使わない**。 `b * b - 4 * a * c` は片が長いと桁が大きく育ち、
846
+ * 引き算で有効桁が消える。 消えた分を「丸めの見込み」 で埋めようとすると、 幅を狭くすれば
847
+ * 接する形が拾えなくなり、 広くすれば離れている 2 本を重なっていると読む。 どちらに寄せても
848
+ * 片の長さに judgment が依存する (codex review Round 6 / 7 の実測)。
849
+ *
850
+ * 代わりに幾何で組む。 中心から線への **垂線の足** を求め、 そこからの半幅を出す。
851
+ *
852
+ * - 垂線の足の位置 `t*` = 中心を線の向きへ射影した量
853
+ * - 線からの距離 `h` = 外積 / 片の長さ
854
+ * - 半幅 = `sqrt((tol - h) * (tol + h))` / 片の長さ
855
+ *
856
+ * 半幅を `tol * tol - h * h` でなく `(tol - h) * (tol + h)` で書くのは、 接する形 (`h` が `tol`
857
+ * と等しい) でちょうど 0 になるため。 二乗してから引くと桁落ちで符号が揺れる。
858
+ *
859
+ * **書き方の細部は検査で覆えていない**。 `tol * tol - h * h` に戻す / 距離を外積でなく射影の
860
+ * 引き算で出す / 接する形の見込みを外す / 向きを単位長へ直さず `len * len` を作る / 媒介変数の
861
+ * 見込み幅を定数に戻す、 のいずれも手元の形では結果が変わらなかった (実測)。 落ちるのは
862
+ * 判別式を直に使う形に戻した時で、 検査が押さえているのはそこ。 細部は、 入力の大きさが
863
+ * 変わった時に効く余地を見込んで書いてある。
864
+ */
865
+ function 円に入る区間(seg: Seg, cx: number, cy: number, tol: number): [number, number] | null {
866
+ const dx = seg.x2 - seg.x1;
867
+ const dy = seg.y2 - seg.y1;
868
+ const len = Math.hypot(dx, dy);
869
+ if (len === 0) return Math.hypot(seg.x1 - cx, seg.y1 - cy) <= tol ? [0, 1] : null;
870
+
871
+ // **向きを先に単位長へ直す**。 `len * len` を作ると、 片が極端に短い時にそれ自体が 0 へ
872
+ // 落ちて商が `NaN` になる (片の長さは 0 でないのに区間が `[NaN, NaN]` になった)。 割り算を
873
+ // 長さ 1 回ぶんに留めれば、 その形は出ない。
874
+ const nx = dx / len;
875
+ const ny = dy / len;
876
+ const ux = cx - seg.x1;
877
+ const uy = cy - seg.y1;
878
+ // ここまでは距離の単位で持ち、 最後に長さで割って媒介変数に直す。
879
+ const 足 = ux * nx + uy * ny;
880
+ const h = Math.abs(nx * uy - ny * ux);
881
+ // 接する形は `h` が `tol` と等しい。 丸めで僅かに超える分だけ見込む (`tol` に対する割合な
882
+ // ので、 片の長さでは緩まない)。
883
+ if (h > tol * (1 + 4 * Number.EPSILON)) return null;
884
+ const 半幅 = Math.sqrt(Math.max(0, (tol - h) * (tol + h)));
885
+
886
+ const t0 = (足 - 半幅) / len;
887
+ const t1 = (足 + 半幅) / len;
888
+ // **切り詰める前に外れているかを見る**。 先に `[0, 1]` へ丸めると、 丸めで `t0` が 1 を
889
+ // 僅かに超えた時に下端が上端を追い越して区間が消える (同じ形へ節を足すだけで判定が変わる)。
890
+ //
891
+ // 見込む幅は媒介変数の刻みで置く。 定数 (`1e-9` 等) にすると、 片が長い時にそれが実距離で
892
+ // 大きな幅になる = 片の長さで判定が緩む (長さ 5 億で 0.5 相当になり、 離れている 2 本を
893
+ // 合流に落とした)。
894
+ const eps = 8 * Number.EPSILON * Math.max(1, Math.abs(t0), Math.abs(t1));
895
+ if (t1 < -eps || t0 > 1 + eps) return null;
896
+ return [Math.min(1, Math.max(0, t0)), Math.max(0, Math.min(1, t1))];
897
+ }
898
+
899
+ /** 経路の `i` 番目の節。 `i` が片の数と同じなら終点。 */
900
+ function 節(path: ReadonlyArray<Seg>, i: number): { x: number; y: number } {
901
+ const last = path[path.length - 1]!;
902
+ if (i >= path.length) return { x: last.x2, y: last.y2 };
903
+ const s = path[i]!;
904
+ return { x: s.x1, y: s.y1 };
905
+ }
906
+
907
+ /**
908
+ * 2 つの経路を **同時に、 後戻りせずに** 辿って、 最初から最後まで距離 `tol` 以内を保てるか。
909
+ *
910
+ * 互いに相手の帯へ入っているかを別々に見る形では、 **通る順序** が抜ける。 同じ場所の集まりを
911
+ * 逆順に通る 2 本が「どちらも相手に覆われている」 になり、 本物の交差を合流として落とす
912
+ * (codex review Round 4 の実測)。
913
+ *
914
+ * 片方を止めて相手だけ進めてよい (帯の中で蛇行する形を許す) が、 戻ることは許さない。 これは
915
+ * 2 曲線の Fréchet 距離が `tol` 以下かの判定そのもので、 **経路をどう分割しても値が変わらない**
916
+ * = 同じ形を別の `d` で書いても判定が変わらない。
917
+ *
918
+ * 判定は自由空間図 (Alt-Godau) で行う。 片の組 `(i, j)` ごとに、 左の辺 (`P` の節 `i` から見た
919
+ * `Q_j` の範囲) と下の辺 (`Q` の節 `j` から見た `P_i` の範囲) を円との交わりとして出し、
920
+ * 左下から右上へ届くかを順に伝える。
921
+ */
922
+ function 一緒に歩ける(
923
+ P: ReadonlyArray<Seg>,
924
+ Q: ReadonlyArray<Seg>,
925
+ tol: number,
926
+ 予算: { 残り: number },
927
+ ): 歩いた結果 {
928
+ const n = P.length;
929
+ const m = Q.length;
930
+ if (n === 0 || m === 0) return "not-merged";
931
+ // **外側の比較と同じ予算から引く**。 ここは片の数の積だけ手間がかかるので、 予算を分けると
932
+ // 外側の上限を素通りして図 1 枚の時間を食い潰す (実測 = 片 1001 本どうしで 237ms、 軸が
933
+ // 前提にする 100ms を超えた)。 足りなければ数えずに打ち切りを返す。
934
+ const セル数 = n * m;
935
+ // 足りなければ数えずに返す。 呼出元が切り出しの手間を先に引いているので、 打ち切りが
936
+ // 続いても予算は必ず減る = 交点の数だけ同じ手間を繰り返すことにはならない。
937
+ if (セル数 > 予算.残り) return "aborted";
938
+ 予算.残り -= セル数;
939
+ // 媒介変数の刻みの何倍か。 **定数 (`1e-9` 等) にしない** = 片が長いとそれ自体が大きな実距離に
940
+ // なり、 端まで届いていない経路を届いたと読む (長さ 5 億で、 離れている 2 本が合流に落ちた)。
941
+ const eps = 8 * Number.EPSILON;
942
+ const 始点が近い = Math.hypot(節(P, 0).x - 節(Q, 0).x, 節(P, 0).y - 節(Q, 0).y) <= tol;
943
+ if (!始点が近い) return "not-merged";
944
+
945
+ /** 左の辺の自由な範囲。 `P` の節 `i` から見た `Q_j` 上の範囲。 */
946
+ const LF = (i: number, j: number) => {
947
+ const v = 節(P, i);
948
+ return 円に入る区間(Q[j]!, v.x, v.y, tol);
949
+ };
950
+ /** 下の辺の自由な範囲。 `Q` の節 `j` から見た `P_i` 上の範囲。 */
951
+ const BF = (i: number, j: number) => {
952
+ const v = 節(Q, j);
953
+ return 円に入る区間(P[i]!, v.x, v.y, tol);
954
+ };
955
+ /**
956
+ * 下端を `lo` まで押し上げる (後戻りを許さない)。
957
+ *
958
+ * **これを外しても落ちる検査が無い** (実測)。 片の組を右か上へしか進めない構造が、 片を
959
+ * またぐ後戻りを既に止めているため、 残るのは 1 つの片の内側で戻る形だけ。 手元の形では
960
+ * そこまで細かい後戻りが結果を変えなかった。 算法としては要るので残す。
961
+ */
962
+ const 押し上げ = (span: [number, number] | null, lo: number): [number, number] | null => {
963
+ if (!span) return null;
964
+ const a = Math.max(span[0], lo);
965
+ return a <= span[1] ? [a, span[1]] : null;
966
+ };
967
+ /** 区間が右端 (1) まで届いているか。 */
968
+ const 端まで = (span: [number, number] | null) => span !== null && span[1] >= 1 - eps;
969
+
970
+ // **行 1 本だけ持つ**。 全体を格子で持つと片の数の積だけ場所を取る。 各セルが要るのは左と
971
+ // 下の 1 つずつなので、 1 行ぶん持って上書きしながら進めれば足りる。
972
+ //
973
+ // `左列` は処理中の行の左の辺、 `下行` は下の辺。 左は読んだ直後に「次の行の左」 で上書きし、
974
+ // 下は 1 つ先 (`j + 1`) に書くので、 同じ配列で足りる。
975
+ const 左列: Array<[number, number] | null> = new Array(m).fill(null);
976
+ const 下行: Array<[number, number] | null> = new Array(m + 1).fill(null);
977
+
978
+ 左列[0] = LF(0, 0);
979
+ 下行[0] = BF(0, 0);
980
+ // 一番左の列は、 手前の片が上端まで自由な時だけ先へ進める。
981
+ for (let j = 1; j < m; j++) 左列[j] = 端まで(左列[j - 1] ?? null) ? LF(0, j) : null;
982
+
983
+ for (let i = 0; i < n; i++) {
984
+ // 一番下の行も同じ。 前の行の下の辺 (添字 0 は行内で書き換えないので残っている) から決める。
985
+ if (i > 0) 下行[0] = 端まで(下行[0] ?? null) ? BF(i, 0) : null;
986
+ for (let j = 0; j < m; j++) {
987
+ const left = 左列[j] ?? null;
988
+ const bottom = 下行[j] ?? null;
989
+ // 右の辺 = 次の行の左の辺。 下から入れれば全体、 左からだけなら後戻りを許さない。
990
+ 左列[j] = bottom ? LF(i + 1, j) : left ? 押し上げ(LF(i + 1, j), left[0]) : null;
991
+ // 上の辺 = 次の列の下の辺。
992
+ 下行[j + 1] = left ? BF(i, j + 1) : bottom ? 押し上げ(BF(i, j + 1), bottom[0]) : null;
993
+ }
994
+ }
995
+ // 右上の角 (両方の終点) に届いたか。
996
+ return 端まで(左列[m - 1] ?? null) ? "merged" : "not-merged";
997
+ }
998
+
999
+ /**
1000
+ * 交点から共有節の側へ、 2 辺の経路が **連続して** 重なっているか (#408)。
1001
+ *
1002
+ * 扇 (同じ節から出る) も合流 (同じ節へ入る) も、 交点から共有節側は同じ道を通る。 これを見れば
1003
+ * 端点からの距離や辺長に対する割合を決め打つ必要が無い (実測 = 扇の交点は辺長の 12-66%、
1004
+ * 本物の交差は 11-85% で、 どちらの尺度でも重なる)。
1005
+ *
1006
+ * **経路の順序を追う**。 交点から共有節側の部分経路を両方から切り出し、 それを同時に後戻り
1007
+ * せずに辿れるか (`一緒に歩ける`) で見る。 順序を見ない形は次の 2 つを通してしまう。
1008
+ *
1009
+ * | 見ない形 | 通ってしまう形 |
1010
+ * |---|---|
1011
+ * | 固定の点をいくつか取って「全て相手に近い」 | 標本点の付近だけ接近して間で離れる (Round 2 の実測) |
1012
+ * | 互いに相手の帯に入っているかだけを見る | 同じ場所の集まりを逆順に通る (Round 4 の実測) |
1013
+ *
1014
+ * 交点を含む片を丸ごと飛ばすと、 交点が片の内側にあるか端にあるかで区間が 1 片ずれる (#412)。
1015
+ */
1016
+ function mergesTowardShared(
1017
+ aFlat: ReadonlyArray<{ x1: number; y1: number; x2: number; y2: number }>,
1018
+ bFlat: ReadonlyArray<{ x1: number; y1: number; x2: number; y2: number }>,
1019
+ p: { x: number; y: number },
1020
+ 共有側: ReadonlyArray<SharedSide>,
1021
+ tol: number,
1022
+ 予算: { 残り: number },
1023
+ ): 歩いた結果 {
1024
+ /**
1025
+ * 交点から共有節側の部分経路を切り出す。
1026
+ *
1027
+ * **交点を含む片は切って使う**。 片ごと飛ばすと、 交点が片の内側にあるか端にあるかで区間が
1028
+ * 1 片ずれる (#412)。 交点が片の端にある形では切片の長さが 0 になり、 飛ばすのと一致する。
1029
+ *
1030
+ * @param toStart 共有節が経路の始点側にあるか (辺の `from` / `to` から決める)
1031
+ */
1032
+ const 共有節側の経路 = (
1033
+ flat: ReadonlyArray<{ x1: number; y1: number; x2: number; y2: number }>,
1034
+ toStart: boolean,
1035
+ ): Array<{ x1: number; y1: number; x2: number; y2: number }> => {
1036
+ let at = -1;
1037
+ let best = Infinity;
1038
+ for (let i = 0; i < flat.length; i++) {
1039
+ const s = flat[i]!;
1040
+ const d = pointToSegmentDistance(p.x, p.y, s.x1, s.y1, s.x2, s.y2);
1041
+ if (d < best) {
1042
+ best = d;
1043
+ at = i;
1044
+ }
1045
+ }
1046
+ if (at < 0) return [];
1047
+ const 交点の片 = flat[at]!;
1048
+ const 端 = toStart ? { x: 交点の片.x1, y: 交点の片.y1 } : { x: 交点の片.x2, y: 交点の片.y2 };
1049
+ const out = [{ x1: p.x, y1: p.y, x2: 端.x, y2: 端.y }];
1050
+ const step = toStart ? -1 : 1;
1051
+ for (let i = at + step; i >= 0 && i < flat.length; i += step) {
1052
+ const seg = flat[i]!;
1053
+ // **始点側へ辿る時は片の向きも返す**。 並べ替えるだけだと片の終わりと次の片の始まりが
1054
+ // 繋がらず、 経路が途切れる。 順序を見る判定 (`一緒に歩ける`) では、 これが「戻った」
1055
+ // 扱いになって扇を交差と数える。
1056
+ out.push(toStart ? { x1: seg.x2, y1: seg.y2, x2: seg.x1, y2: seg.y1 } : seg);
1057
+ }
1058
+ return out;
1059
+ };
1060
+
1061
+ // 1 つでも合流と言える側があれば合流。 言えないまま打ち切った側があれば、 それを伝える
1062
+ // (「見た上で合流でない」 と「見ていない」 を混ぜない)。
1063
+ let 打ち切った = false;
1064
+ for (const 側 of 共有側) {
1065
+ // 端点と交点がほぼ同じ位置なら、 間に経路が無い。 その形は端点の除外が既に捌く。
1066
+ if (Math.hypot(側.a.end.x - p.x, 側.a.end.y - p.y) < tol) continue;
1067
+ if (Math.hypot(側.b.end.x - p.x, 側.b.end.y - p.y) < tol) continue;
1068
+ // 交点から共有節まで、 両方の経路が途切れずに重なっているか。
1069
+ // **切り出しも予算から引く**。 交点に一番近い片を探すのに経路を全部見るので、 手間は片の
1070
+ // 数に比例する。 格子の手間だけを数えると、 交点が多い図でこの走査が上限を素通りする
1071
+ // (実測 = 片 400 本で 796ms、 軸が前提にする 100ms を超えた)。
1072
+ const 切り出しの手間 = aFlat.length + bFlat.length;
1073
+ // 予算が切り出しの手間にも足りない = ここから先は何も見られない。 以降の交点でもこの
1074
+ // 判定で即座に返るので、 交点が多くても手間は増えない。
1075
+ if (予算.残り < 切り出しの手間) return "aborted";
1076
+ 予算.残り -= 切り出しの手間;
1077
+ const aPath = 共有節側の経路(aFlat, 側.a.toStart);
1078
+ const bPath = 共有節側の経路(bFlat, 側.b.toStart);
1079
+ if (aPath.length === 0 || bPath.length === 0) continue;
1080
+ const r = 一緒に歩ける(aPath, bPath, tol, 予算);
1081
+ if (r === "merged") return "merged";
1082
+ if (r === "aborted") 打ち切った = true;
1083
+ }
1084
+ return 打ち切った ? "aborted" : "not-merged";
1085
+ }
1086
+
1087
+ /** 点が線分のどちら側にあるか。 線上なら 0。 */ /** 点が線分のどちら側にあるか。 線上なら 0。 */
1088
+ function sideOf(
1089
+ seg: { x1: number; y1: number; x2: number; y2: number },
1090
+ x: number,
1091
+ y: number,
1092
+ ): number {
1093
+ const v = (seg.x2 - seg.x1) * (y - seg.y1) - (seg.y2 - seg.y1) * (x - seg.x1);
1094
+ return v > 0 ? 1 : v < 0 ? -1 : 0;
1095
+ }
1096
+
1097
+ /**
1098
+ * 交点で本当に反対側へ抜けているか。 触れて同じ側へ戻る形は交差に数えない。
1099
+ *
1100
+ * 分割点でヒットした時、 その前後で相手の線のどちら側に居るかを見る。 側が変わっていなければ
1101
+ * 接しているだけ (実測 = 水平線に下から触れて戻る曲線 3 本で 3 件の誤検知)。
1102
+ *
1103
+ * 相手の線は交点の近くの分割片で近似する。 曲線どうしでも、 交点の周りでは直線とみなせる。
1104
+ */
1105
+ function crossesSides(
1106
+ aFlat: ReadonlyArray<{ x1: number; y1: number; x2: number; y2: number }>,
1107
+ bFlat: ReadonlyArray<{ x1: number; y1: number; x2: number; y2: number }>,
1108
+ p: { x: number; y: number },
1109
+ tol: number,
1110
+ ): boolean {
1111
+ // 交点に最も近い相手の分割片を基準線にする。
1112
+ let base: { x1: number; y1: number; x2: number; y2: number } | undefined;
1113
+ let bestD = Infinity;
1114
+ for (const sb of bFlat) {
1115
+ const d = pointToSegmentDistance(p.x, p.y, sb.x1, sb.y1, sb.x2, sb.y2);
1116
+ if (d < bestD) {
1117
+ bestD = d;
1118
+ base = sb;
1119
+ }
1120
+ }
1121
+ if (!base) return true;
1122
+ // 自分の線の、 交点より手前と先の点を取る。 交点の近くの点は側が定まらないので飛ばす。
1123
+ const pts: Array<{ x: number; y: number }> = [];
1124
+ for (const sa of aFlat) {
1125
+ pts.push({ x: sa.x1, y: sa.y1 }, { x: sa.x2, y: sa.y2 });
1126
+ }
1127
+ const idx = pts.findIndex((q) => Math.abs(q.x - p.x) <= tol && Math.abs(q.y - p.y) <= tol);
1128
+ const before =
1129
+ idx >= 0 ? pts.slice(0, idx) : pts.filter((q) => q.x < p.x || (q.x === p.x && q.y < p.y));
1130
+ const after =
1131
+ idx >= 0 ? pts.slice(idx + 1) : pts.filter((q) => q.x > p.x || (q.x === p.x && q.y > p.y));
1132
+ const sideBefore = [...before]
1133
+ .reverse()
1134
+ .map((q) => sideOf(base, q.x, q.y))
1135
+ .find((v) => v !== 0);
1136
+ const sideAfter = after.map((q) => sideOf(base, q.x, q.y)).find((v) => v !== 0);
1137
+ // 片側しか無い (端で接している) 形は交差ではない。
1138
+ if (sideBefore === undefined || sideAfter === undefined) return false;
1139
+ return sideBefore !== sideAfter;
1140
+ }
1141
+
1142
+ /**
1143
+ * 辺どうしの交差を **実際に描かれる曲線** で数える (軸 11、 #385)。
1144
+ *
1145
+ * 弦のまま数えていた頃は、 弦どうしが平行で弧だけが交わる形を落としていた (#383 の反例 =
1146
+ * 弦 0 件 / 実曲線 2 件)。
1147
+ *
1148
+ * 折れ線に開くだけでは直らない。 `segmentsIntersect` は端点の接触を交差に数えないため、
1149
+ * 交差が分割点にちょうど乗ると隣り合う 2 本の分割片が両方とも端点接触になり、 交差が
1150
+ * **消える** (#384 実測 = `M 0 0 Q 80 80, 160 160` と y=40/80/120 の 3 本で 弦 3 件 → 折れ線
1151
+ * 0 件)。 端点を数える判定に替えると、 今度は同じ節から出る辺どうしの共有端点を数える。
1152
+ *
1153
+ * そこで交点の **座標** を返す判定を使い、 辺の元の端点に一致する交点だけを除く。 分割で
1154
+ * 生まれた節は除かない。 残りを座標で重複排除すると、 分割点に乗った交差が 2 度数えられる
1155
+ * ことも無くなる。
1156
+ *
1157
+ * 関数に切り出してあるのは、 計算量を test から直接測れるようにするため。 検証全体の時間で
1158
+ * 測ると、 曲線では他の軸も分割の分だけ重くなって本軸の寄与が読めない (実測 = 全体の差 138ms に
1159
+ * 対し本軸の寄与は 41ms)。
1160
+ *
1161
+ * @param warnAt 数えるのをやめる件数。 判定は「この件数以上か」 だけなので、 達したら打ち切る。
1162
+ * @returns `complete` は最後まで数え切れたか。 比較回数の上限で止めた時は `false` になり、
1163
+ * 「交差が無い」 と「数え切れなかった」 を呼出側が区別できる。
1164
+ */
1165
+ export function countEdgeCrossings(
1166
+ edges: ReadonlyArray<{ id: string; d: string; from?: string; to?: string }>,
1167
+ warnAt = 3,
1168
+ ): { count: number; pairs: string[]; complete: boolean } {
1169
+ /** 同じ点とみなす距離。 分割で生まれた節の両側から同じ交点が出るのを 1 件にまとめる。 */
1170
+ const SAME_POINT_TOL = 0.5;
1171
+ /** 経路が重なっているとみなす距離。 曲がり角を折れ線に開く刻みの差を吸収する分だけ持たせる。 */
1172
+ const MERGE_TOL = 2;
1173
+ /**
1174
+ * 分割片どうしの比較回数の上限。
1175
+ *
1176
+ * 辺の総当たりで、 各対の分割片を全比較する。 曲線 380 本の図では 1850 万回になり 264ms
1177
+ * かかった (実測)。 1 図あたりの予算は `PERF_BUDGET_PER_DIAGRAM_MS` = 100 で、 他の軸が
1178
+ * 150ms 前後使うため本軸に使えるのは僅か。
1179
+ *
1180
+ * **上限に達したら数えるのをやめる**。 包囲箱で絞れないのは曲線どうしが広く重なる図で、
1181
+ * その形は総当たり以外に手が無い。 打ち切ると警告が出ないが、 出ない状態は弦で数えていた
1182
+ * 頃と同じで、 見逃しが増えるわけではない。
1183
+ */
1184
+ const MAX_CHECKS = 500_000;
1185
+ /**
1186
+ * 1 対あたりの比較回数の上限。
1187
+ *
1188
+ * 全体の上限だけだと、 先頭の対が上限を使い切って後続を 1 本も見ない。 交差の無い扇 64 本で
1189
+ * 「打ち切った」 警告が出ていた (実測)。 対ごとに配れば、 重い対があっても他の対は見る。
1190
+ *
1191
+ * 分割は最小 16 / 最大 4096。 通常の辺どうし (16x16 = 256) の 100 倍を上限に置く。
1192
+ *
1193
+ * これでも辺が数十本を超えると全体の上限に達する (実測 = 扇 64 本 / 2016 対)。 見本の最大は
1194
+ * 8 辺 (28 対) で完走する。 それを超える規模で見落とすのは、 全体の上限が持つ性質と同じ。
1195
+ */
1196
+ const MAX_CHECKS_PER_PAIR = 25_600;
1197
+ let checks = 0;
1198
+ let count = 0;
1199
+ let complete = true;
1200
+ const pairs: string[] = [];
1201
+ const prepared = edges.map((e) => {
1202
+ const flat = flattenPathSegments(extractPathSegments(e.d));
1203
+ const first = flat[0];
1204
+ const last = flat[flat.length - 1];
1205
+ return {
1206
+ id: e.id,
1207
+ ref: e,
1208
+ flat,
1209
+ // 辺そのものの端点。 同じ節から出る辺どうしの共有端点を交差に数えないために使う。
1210
+ ends:
1211
+ first && last
1212
+ ? [
1213
+ { x: first.x1, y: first.y1 },
1214
+ { x: last.x2, y: last.y2 },
1215
+ ]
1216
+ : [],
1217
+ box: segmentsBBox(flat),
1218
+ };
1219
+ });
1220
+ outer: for (let i = 0; i < prepared.length; i++) {
1221
+ const a = prepared[i]!;
1222
+ for (let j = i + 1; j < prepared.length; j++) {
1223
+ const b = prepared[j]!;
1224
+ // 包囲箱が離れていれば分割片を 1 本も比べない。
1225
+ if (!bboxOverlaps(a.box, b.box)) continue;
1226
+ // 節を共有する辺どうしも、 **一律には除かない** (#408)。
1227
+ //
1228
+ // #385 では扇 (同じ節から出る) と合流 (同じ節へ入る) の誤検知を避けるため、 節を共有する
1229
+ // 対をまとめて外していた。 代償として、 共有節と無関係な場所で交わる形まで消えていた。
1230
+ //
1231
+ // その後 #385 の Round 1 で足した 2 つの判定が、 扇と合流を別経路で捌くようになった。
1232
+ //
1233
+ // - 辺の端点に一致する交点を除く (`isNearAny`)
1234
+ // - 触れて同じ側へ戻る形を除く (`crossesSides`)
1235
+ //
1236
+ // 見本の `pattern-fan-out` / `pattern-fan-in` で実測 = 対の除外を外しても 0 件のまま。
1237
+ // 一律の除外は要らなくなった。
1238
+ // 節を共有する辺は、 その節に向かって経路が重なる区間の交点だけを除く。
1239
+ //
1240
+ // 経路が最後まで重なる対 (engine が作る扇 / 合流のうち、 分かれる前に交点が無い形) は
1241
+ // 交点そのものが出ない。 分割片の総当たりだけが残って比較回数を食うので、 先に外す。
1242
+ // 実測 = 扇 64 本で上限に達し、 交差の無い図でも「打ち切った」 警告が出ていた。
1243
+ const 共有側 = sharedSides(a.ref, a.ends, b.ref, b.ends);
1244
+ const hits: Array<{ x: number; y: number }> = [];
1245
+ /** 上限で止めたか。 止めた時も、 それまでに見つけた交点は捨てない。 */
1246
+ let aborted = false;
1247
+ /** この対で使った比較回数。 1 対が全体の上限を使い切らないようにする。 */
1248
+ let pairChecks = 0;
1249
+ /** この対を上限で切ったか。 */
1250
+ let pairAborted = false;
1251
+ for (const sa of a.flat) {
1252
+ if (aborted || pairAborted) break;
1253
+ // 分割片の包囲箱も見る。 辺の包囲箱が重なっていても、 実際に近いのはその一部だけ。
1254
+ const saMinX = Math.min(sa.x1, sa.x2);
1255
+ const saMaxX = Math.max(sa.x1, sa.x2);
1256
+ const saMinY = Math.min(sa.y1, sa.y2);
1257
+ const saMaxY = Math.max(sa.y1, sa.y2);
1258
+ for (const sb of b.flat) {
1259
+ // 除外そのものも回数に数える。 除外した分を数えないと、 包囲箱の判定が 1850 万回
1260
+ // そのまま残って上限が効かない (実測 = 552ms)。
1261
+ if (++checks > MAX_CHECKS) {
1262
+ aborted = true;
1263
+ complete = false;
1264
+ break;
1265
+ }
1266
+ // 1 対が上限を使い切ると後続の対を 1 本も見ない。 対ごとに切って次へ進む。
1267
+ //
1268
+ // **`complete` は下げる**。 その対を最後まで見ていないので、 後ろに交点があっても
1269
+ // 気付けない。 「見ていない」 ことを黙って隠すと、 呼出側が 0 件を「交差なし」 と
1270
+ // 読む (codex review Round 2 の指摘)。
1271
+ if (++pairChecks > MAX_CHECKS_PER_PAIR) {
1272
+ pairAborted = true;
1273
+ complete = false;
1274
+ break;
1275
+ }
1276
+ if (Math.min(sb.x1, sb.x2) > saMaxX || Math.max(sb.x1, sb.x2) < saMinX) continue;
1277
+ if (Math.min(sb.y1, sb.y2) > saMaxY || Math.max(sb.y1, sb.y2) < saMinY) continue;
1278
+ const p = segmentIntersection(sa, sb);
1279
+ if (!p) continue;
1280
+ // 触れて同じ側へ戻る形は交差ではない。 分割点で相手に接するだけの曲線を数えると、
1281
+ // 実際には交わらない図で警告が出る (実測 = 水平線に下から触れて戻る曲線 3 本で 3 件)。
1282
+ // 両方向で見る。 片方向だけだと辺の並べ順で結果が変わる (実測 = 同じ接線の形で
1283
+ // 曲線→水平線は 0 件、 水平線→曲線は 1 件)。
1284
+ //
1285
+ // **この判定も経路を全部見る**。 交点 1 つにつき片の数の和だけ手間がかかるので、
1286
+ // 予算から引く。 引かないと、 同じ場所で何度も交わる形でここが上限を素通りする
1287
+ // (実測 = 片 1000 本どうしが同じ 2 点を往復する形で 220ms、 軸が前提にする 100ms 超)。
1288
+ const 側の手間 = a.flat.length + b.flat.length;
1289
+ if (checks + 側の手間 > MAX_CHECKS) {
1290
+ aborted = true;
1291
+ complete = false;
1292
+ break;
1293
+ }
1294
+ if (pairChecks + 側の手間 > MAX_CHECKS_PER_PAIR) {
1295
+ pairAborted = true;
1296
+ complete = false;
1297
+ break;
1298
+ }
1299
+ checks += 側の手間;
1300
+ pairChecks += 側の手間;
1301
+ if (!crossesSides(a.flat, b.flat, p, SAME_POINT_TOL)) continue;
1302
+ if (!crossesSides(b.flat, a.flat, p, SAME_POINT_TOL)) continue;
1303
+
1304
+ // 辺の端点で接しているだけなら数えない (同じ節から出る 2 辺)。
1305
+ if (isNearAny(p, a.ends, SAME_POINT_TOL) || isNearAny(p, b.ends, SAME_POINT_TOL))
1306
+ continue;
1307
+ // 共有する節に向かって経路が重なる区間なら数えない (扇の根元 / 合流点)。
1308
+ //
1309
+ // 判定は外側と同じ予算から引く。 使った分を戻して外側の counter に足すことで、
1310
+ // 合流の判定だけが上限を素通りして時間を食い潰す形にならない。
1311
+ if (共有側.length > 0) {
1312
+ const 枠 = Math.min(MAX_CHECKS - checks, MAX_CHECKS_PER_PAIR - pairChecks);
1313
+ const 予算 = { 残り: Math.max(0, 枠) };
1314
+ const 判定 = mergesTowardShared(a.flat, b.flat, p, 共有側, MERGE_TOL, 予算);
1315
+ const 使った = Math.max(0, 枠) - 予算.残り;
1316
+ checks += 使った;
1317
+ pairChecks += 使った;
1318
+ if (判定 === "merged") continue;
1319
+ // **打ち切った時は交点を捨てない**。 合流かどうかを見ていないので、 捨てると
1320
+ // 「見ていない」 が「交差なし」 に化ける。 数えた上で `complete` を下げる。
1321
+ if (判定 === "aborted") complete = false;
1322
+ }
1323
+ // 分割で生まれた節に交差が乗ると、 両側の分割片から同じ点が出る。
1324
+ if (isNearAny(p, hits, SAME_POINT_TOL)) continue;
1325
+ hits.push(p);
1326
+ }
1327
+ }
1328
+ if (hits.length > 0) {
1329
+ count += hits.length;
1330
+ pairs.push(`${a.id}×${b.id}(${hits.length})`);
1331
+ if (count >= warnAt) break outer;
1332
+ }
1333
+ // 上限で止めた時は、 この対の分を確定させてから抜ける。 途中で捨てると、 交差のある辺を
1334
+ // 末尾に置いた図で 0 件になる (実測 = 同じ辺集合で並べ順を変えると 0 と 3 に割れた)。
1335
+ if (aborted) break outer;
1336
+ }
1337
+ }
1338
+ return { count, pairs, complete };
1339
+ }
1340
+
1341
+ export function visualValidateLaid(
1342
+ laid: LaidDiagram,
1343
+ diag: CdlDiagram,
1344
+ opts?: ValidateOptions,
1345
+ ): VisualValidationReport {
1346
+ const violations: Violation[] = [];
1347
+ const counts = emptyCounts();
1348
+ const profile = opts?.profile ?? "production";
1349
+ const skipped = SKIPPED_AXES[profile];
1350
+ const push = (axis: VisualAxis, detail: string, severity: "error" | "warn" = "error") => {
1351
+ // 用途に合わない軸は数えない。
1352
+ if (skipped.has(axis)) return;
1353
+ // `diag` は壊れた形でも渡りうる (`#393`)。 報告そのものが例外になると、 壊れている
1354
+ // ことを伝える手段が無くなる。
1355
+ violations.push({ axis, diagramId: safeDiagramId(diag), detail, severity });
1356
+ counts[axis]++;
1357
+ };
1358
+
1359
+ return visualValidateInner(laid, diag, violations, counts, push, profile, [...skipped]);
1360
+ }
1361
+
1362
+ /**
1363
+ * 1 つの cdl diagram を 6 軸で視覚検証する。
1364
+ *
1365
+ * layout 計算込みで実行する。 `diag` は CdlDiagram (compile 前の builder 出力)。
1366
+ * layout 中の console.warn ([cdl layout] overlap / near) は本 validator が再判定するため抑止する。
1367
+ */
1368
+ export function visualValidate(diag: CdlDiagram, opts?: ValidateOptions): VisualValidationReport {
1369
+ const violations: Violation[] = [];
1370
+ const counts = emptyCounts();
1371
+ const profile = opts?.profile ?? "production";
1372
+ const skipped = SKIPPED_AXES[profile];
1373
+ const push = (axis: VisualAxis, detail: string, severity: "error" | "warn" = "error") => {
1374
+ // 用途に合わない軸は数えない。 見なかった軸は report の `skippedAxes` に載せる。
1375
+ if (skipped.has(axis)) return;
1376
+ violations.push({ axis, diagramId: safeDiagramId(diag), detail, severity });
1377
+ counts[axis]++;
1378
+ };
1379
+
1380
+ // `diag` の検査と正規化は **`layout()` より前に** 行う。 後ろに置くと、 壊れた `diag` は
1381
+ // `layout()` の中で例外になり `clearance` として報告されて `malformed-input` に届かない。
1382
+ // 本 API が公開の入口なので、 ここを通らないと変更の目的を果たさない (`#393`)。
1383
+ const diagProblems = findMalformedInput(undefined, diag).filter((p) =>
1384
+ p.where.startsWith("diag"),
1385
+ );
1386
+ for (const p of diagProblems) push("malformed-input", p.detail, "error");
1387
+ const safeDiag =
1388
+ diagProblems.length > 0 ? normalizeForValidation(diag, diagProblems, "diag") : diag;
1389
+
1390
+ // layout を 1 回計算
1391
+ let laid: LaidDiagram;
1392
+ // layout 内の console.warn を一時抑止 (本 validator が同じ判定をやり直す)
1393
+ const origWarn = console.warn;
1394
+ console.warn = () => {};
1395
+ try {
1396
+ laid = layout(safeDiag);
1397
+ } catch (err) {
1398
+ console.warn = origWarn;
1399
+ // 正規化しても `layout()` が落ちる形は入力の破綻として報告する。 `clearance` に混ぜると
1400
+ // 「間隔の問題」 と読めてしまう。
1401
+ push("malformed-input", `layout が例外で落ちた: ${describeError(err)}`, "error");
1402
+ return {
1403
+ diagramId: safeDiagramId(safeDiag),
1404
+ ok: false,
1405
+ violations,
1406
+ counts,
1407
+ profile,
1408
+ skippedAxes: [...skipped],
1409
+ };
1410
+ } finally {
1411
+ console.warn = origWarn;
1412
+ }
1413
+
1414
+ return visualValidateInner(laid, safeDiag, violations, counts, push, profile, [...skipped]);
1415
+ }
1416
+
1417
+ /**
1418
+ * 入口で形を見る top-level の配列。 `[所有者, field 名]` の組で持つ。
1419
+ *
1420
+ * 基準は **`LaidDiagram` / `CdlDiagram` が必須の配列として宣言している field を全て** で、
1421
+ * 「どの軸が読むか」 では選ばない。 読む側で選ぶと、 軸を足した時に一覧の更新が要る
1422
+ * (`laid.states` は現在どの軸も読まないが、 読み始めた時に追記を忘れれば穴が開く)。
1423
+ * 型から導ける基準にすることで、 その drift を作らない。
1424
+ *
1425
+ * **optional な配列は入れない**。 `undefined` が正当なので「配列でない」 が壊れている印に
1426
+ * ならない。 個別に列挙すると型に追随できないので、 条件で書く (`RequiredArrayKeys<T>` が
1427
+ * `undefined` を含む key を落とすため、 一覧と型の突き合わせも自動で optional を除く)。
1428
+ *
1429
+ * 深さ 1 段までしか見ない。 要素の中の field が壊れた形 (`phases[0].tweens` 欠落 等) は
1430
+ * 入口を通り抜けて受け止め側に落ちる。 深くしていくと静的検査の非収束に入るため、
1431
+ * ここで止める (`#393`)。
1432
+ */
1433
+ /**
1434
+ * `T` が **必須** の配列として宣言している key の union。
1435
+ *
1436
+ * optional (`?:`) は `undefined` を含むので `readonly unknown[]` に代入できず、 自動的に外れる。
1437
+ * 一覧の網羅を型で確かめるために使う (正規表現で型定義を読む形は、 宣言の書き方が変わると
1438
+ * 黙って拾えなくなる)。
1439
+ */
1440
+ type RequiredArrayKeys<T> = {
1441
+ [K in keyof T]-?: undefined extends T[K] ? never : T[K] extends readonly unknown[] ? K : never;
1442
+ }[keyof T];
1443
+
1444
+ /** 一覧が両型の必須配列を **過不足なく** 覆うことを compile 時に確かめる。 */
1445
+ type LaidArrayKey = RequiredArrayKeys<LaidDiagram> & string;
1446
+ type DiagArrayKey = RequiredArrayKeys<CdlDiagram> & string;
1447
+
1448
+ export const VALIDATED_ARRAY_FIELDS = [
1449
+ ["laid", "lanes"],
1450
+ ["laid", "nodes"],
1451
+ ["laid", "edges"],
1452
+ ["laid", "states"],
1453
+ ["laid", "phases"],
1454
+ ["laid", "bboxes"],
1455
+ ["laid", "collisions"],
1456
+ ["laid", "nearCollisions"],
1457
+ ["diag", "lanes"],
1458
+ ["diag", "nodes"],
1459
+ ["diag", "edges"],
1460
+ ["diag", "states"],
1461
+ ["diag", "phases"],
1462
+ ] as const;
1463
+
1464
+ // 一覧が両型の必須配列を **過不足なく** 覆うことを compile 時に確かめる。
1465
+ //
1466
+ // `as const` で literal を保つのが要 (`ReadonlyArray<...>` と注釈すると literal が消えて
1467
+ // 突き合わせが恒真になり、 key を落としても通ってしまう = 一度これで空振りさせた)。
1468
+ type CoveredLaid = Extract<(typeof VALIDATED_ARRAY_FIELDS)[number], readonly ["laid", string]>[1];
1469
+ type CoveredDiag = Extract<(typeof VALIDATED_ARRAY_FIELDS)[number], readonly ["diag", string]>[1];
1470
+
1471
+ // 足りない key があれば `never` でなくなり、 `true` を代入できずに型検査が落ちる。
1472
+ const _noMissingLaid: Exclude<LaidArrayKey, CoveredLaid> extends never ? true : never = true;
1473
+ const _noMissingDiag: Exclude<DiagArrayKey, CoveredDiag> extends never ? true : never = true;
1474
+ // 逆向き = 型に無い key を一覧に書いていないか。
1475
+ const _noExtraLaid: Exclude<CoveredLaid, LaidArrayKey> extends never ? true : never = true;
1476
+ const _noExtraDiag: Exclude<CoveredDiag, DiagArrayKey> extends never ? true : never = true;
1477
+ void _noMissingLaid;
1478
+ void _noMissingDiag;
1479
+ void _noExtraLaid;
1480
+ void _noExtraDiag;
1481
+
1482
+ /** `viewBox` に要る数値。 1 つでも欠けると座標の比較が `undefined` との比較になる。 */
1483
+ const VIEWBOX_KEYS = ["x", "y", "w", "h"] as const;
1484
+
1485
+ /**
1486
+ * `phases[i]` の中で、 軸が **反復する** 配列。
1487
+ *
1488
+ * 深さ 2 だが、 **3 つに閉じているので非収束にならない**。 「要素の中を一般に検査する」 の
1489
+ * ではなく、 反復される配列を名指しする。 塞ぐ価値が高いのは、 ここが `#392` の事故そのもの
1490
+ * だから (`p.activate` の無条件反復で 68 軸が巻き添えになった)。
1491
+ *
1492
+ * 増やす時は「軸が反復するか」 で判断する。 反復しない field を足すと、 壊れていても実害の
1493
+ * 無い形まで報告することになる。
1494
+ */
1495
+ const VALIDATED_PHASE_ARRAY_FIELDS = ["activate", "tweens", "sets"] as const;
1496
+
1497
+ export interface MalformedInputProblem {
1498
+ /** `laid.edges` のような、 どこが壊れているかの名前。 */
1499
+ where: string;
1500
+ detail: string;
1501
+ }
1502
+
1503
+ /**
1504
+ * 検査に渡された図の形を見て、 壊れている箇所を挙げる (`#393`)。
1505
+ *
1506
+ * 見るのは top-level の配列 13 個と `viewBox` の数値 4 つ。 配列でない field と、 要素に
1507
+ * object でないものが混ざる場合を挙げる。
1508
+ *
1509
+ * **正規化はここでは行わない**。 呼出側が「報告してから差し替える」 を順に行う。
1510
+ */
1511
+ export function findMalformedInput(laid: unknown, diag: unknown): MalformedInputProblem[] {
1512
+ const out: MalformedInputProblem[] = [];
1513
+ // `typeof null` は `"object"` なので、 そのまま出すと「object でない (object)」 になる。
1514
+ // 値そのものは出さない = 入力に秘密が入っていても報告に流れない。 `String(v)` を使うと
1515
+ // `Symbol.toPrimitive` / `toString` が走り、 そこで例外が出ると **検査自体が落ちる**
1516
+ // (層 2 の受け止めより前なので拾えない)。
1517
+ const kindOf = (v: unknown): string => {
1518
+ if (v === null) return "null";
1519
+ if (typeof v !== "number") return typeof v;
1520
+ return Number.isNaN(v) ? "NaN" : Number.isFinite(v) ? "number" : "Infinity";
1521
+ };
1522
+ const owners: Record<"laid" | "diag", unknown> = { laid, diag };
1523
+
1524
+ for (const [owner, field] of VALIDATED_ARRAY_FIELDS) {
1525
+ const holder = owners[owner];
1526
+ if (typeof holder !== "object" || holder === null) {
1527
+ // 所有者ごと壊れている場合は field を見ても意味がない。 1 度だけ挙げて次へ。
1528
+ if (!out.some((p) => p.where === owner)) {
1529
+ out.push({ where: owner, detail: `${owner} が object でない (${kindOf(holder)})` });
1530
+ }
1531
+ continue;
1532
+ }
1533
+ const read = safeRead(holder, field);
1534
+ if (!read.ok) {
1535
+ out.push({ where: `${owner}.${field}`, detail: `${owner}.${field} の読取が例外になった` });
1536
+ continue;
1537
+ }
1538
+ const value = read.value;
1539
+ if (!Array.isArray(value)) {
1540
+ out.push({
1541
+ where: `${owner}.${field}`,
1542
+ detail: `${owner}.${field} が配列でない (${kindOf(value)})`,
1543
+ });
1544
+ continue;
1545
+ }
1546
+ // 要素の読取も保護する。 配列は index に getter を差せるので、 走査そのものが落ちうる。
1547
+ let badIndex = -1;
1548
+ let badKind = "";
1549
+ try {
1550
+ const lenRead = safeLength(value);
1551
+ if (!lenRead.ok) {
1552
+ out.push({
1553
+ where: `${owner}.${field}`,
1554
+ detail: `${owner}.${field} の長さの読取が例外になった`,
1555
+ });
1556
+ continue;
1557
+ }
1558
+ for (let i = 0; i < lenRead.value; i++) {
1559
+ const v = value[i];
1560
+ if (typeof v !== "object" || v === null) {
1561
+ badIndex = i;
1562
+ badKind = kindOf(v);
1563
+ break;
1564
+ }
1565
+ }
1566
+ } catch {
1567
+ out.push({
1568
+ where: `${owner}.${field}`,
1569
+ detail: `${owner}.${field} の要素の読取が例外になった`,
1570
+ });
1571
+ continue;
1572
+ }
1573
+ if (badIndex >= 0) {
1574
+ out.push({
1575
+ where: `${owner}.${field}[${badIndex}]`,
1576
+ detail: `${owner}.${field}[${badIndex}] が object でない (${badKind})`,
1577
+ });
1578
+ }
1579
+ }
1580
+
1581
+ // phase の中の配列 3 つ。 `diag.phases` が配列として通った時だけ見る。
1582
+ const phasesRead =
1583
+ typeof diag === "object" && diag !== null ? safeRead(diag, "phases") : ({ ok: false } as const);
1584
+ const phases = phasesRead.ok ? phasesRead.value : undefined;
1585
+ if (Array.isArray(phases)) {
1586
+ const phLen = safeLength(phases);
1587
+ if (!phLen.ok) {
1588
+ out.push({ where: "diag.phases", detail: "diag.phases の長さの読取が例外になった" });
1589
+ }
1590
+ for (let i = 0; phLen.ok && i < phLen.value; i++) {
1591
+ const phRead = safeRead(phases, String(i));
1592
+ if (!phRead.ok) continue; // 読取の失敗は上で挙げた
1593
+ const ph = phRead.value;
1594
+ if (typeof ph !== "object" || ph === null) continue; // 要素そのものは上で挙げた
1595
+ for (const f of VALIDATED_PHASE_ARRAY_FIELDS) {
1596
+ const rd = safeRead(ph, f);
1597
+ if (!rd.ok) {
1598
+ out.push({
1599
+ where: `diag.phases[${i}].${f}`,
1600
+ detail: `diag.phases[${i}].${f} の読取が例外になった`,
1601
+ });
1602
+ continue;
1603
+ }
1604
+ const v = rd.value;
1605
+ if (!Array.isArray(v)) {
1606
+ out.push({
1607
+ where: `diag.phases[${i}].${f}`,
1608
+ detail: `diag.phases[${i}].${f} が配列でない (${kindOf(v)})`,
1609
+ });
1610
+ }
1611
+ }
1612
+ }
1613
+ }
1614
+
1615
+ const vbRead =
1616
+ typeof laid === "object" && laid !== null
1617
+ ? safeRead(laid, "viewBox")
1618
+ : ({ ok: false } as const);
1619
+ if (!vbRead.ok) out.push({ where: "laid.viewBox", detail: "laid.viewBox の読取が例外になった" });
1620
+ const vb = vbRead.ok ? vbRead.value : undefined;
1621
+ if (typeof vb !== "object" || vb === null) {
1622
+ out.push({ where: "laid.viewBox", detail: `laid.viewBox が object でない (${kindOf(vb)})` });
1623
+ } else {
1624
+ for (const k of VIEWBOX_KEYS) {
1625
+ const nr = safeRead(vb, k);
1626
+ if (!nr.ok) {
1627
+ out.push({ where: `laid.viewBox.${k}`, detail: `laid.viewBox.${k} の読取が例外になった` });
1628
+ continue;
1629
+ }
1630
+ const n = nr.value;
1631
+ if (typeof n !== "number" || !Number.isFinite(n)) {
1632
+ out.push({
1633
+ where: `laid.viewBox.${k}`,
1634
+ detail: `laid.viewBox.${k} が有限の数値でない (${kindOf(n)})`,
1635
+ });
1636
+ }
1637
+ }
1638
+ }
1639
+ return out;
1640
+ }
1641
+
1642
+ /**
1643
+ * 壊れた箇所を、 検査が反復できる形に差し替える。
1644
+ *
1645
+ * 元の object は書き換えない (呼出側が渡したものを検査の副作用で変えない)。 配列でない field は
1646
+ * 空配列に、 object でない要素は取り除く。 `viewBox` は原点と 0 幅に倒す = 「何も含まない枠」
1647
+ * として扱われ、 枠の内外を見る軸は全 node を外と判定する。 これは誤判定ではなく、 枠が
1648
+ * 壊れている以上「収まっている」 とは言えないため。
1649
+ */
1650
+ function normalizeForValidation<T>(
1651
+ value: T,
1652
+ problems: MalformedInputProblem[],
1653
+ owner: "laid" | "diag",
1654
+ ): T {
1655
+ if (problems.length === 0) return value;
1656
+ if (typeof value !== "object" || value === null) {
1657
+ return (
1658
+ owner === "laid"
1659
+ ? {
1660
+ id: "",
1661
+ topic: "",
1662
+ viewBox: { x: 0, y: 0, w: 0, h: 0 },
1663
+ nodes: [],
1664
+ edges: [],
1665
+ lanes: [],
1666
+ phases: [],
1667
+ states: [],
1668
+ bboxes: [],
1669
+ collisions: [],
1670
+ nearCollisions: [],
1671
+ }
1672
+ : {
1673
+ id: "",
1674
+ topic: "",
1675
+ viewport: {},
1676
+ nodes: [],
1677
+ edges: [],
1678
+ lanes: [],
1679
+ phases: [],
1680
+ states: [],
1681
+ }
1682
+ ) as T;
1683
+ }
1684
+ const next = safeShallowCopy(value as object);
1685
+ for (const [o, field] of VALIDATED_ARRAY_FIELDS) {
1686
+ if (o !== owner) continue;
1687
+ const v = next[field];
1688
+ if (!Array.isArray(v)) {
1689
+ next[field] = [];
1690
+ continue;
1691
+ }
1692
+ // 要素の読取も落ちうる (index に getter を差せる)。 読めた要素だけを残す。
1693
+ const kept: unknown[] = [];
1694
+ // 長さが読めなければ空に倒す (読めなかったことは `findMalformedInput` が報告済)。
1695
+ const len = safeLength(v as readonly unknown[]);
1696
+ for (let i = 0; len.ok && i < len.value; i++) {
1697
+ const r = safeRead(v as unknown as object, String(i));
1698
+ if (!r.ok) continue;
1699
+ if (typeof r.value === "object" && r.value !== null) kept.push(r.value);
1700
+ }
1701
+ next[field] = kept;
1702
+ }
1703
+ if (owner === "diag" && Array.isArray(next["phases"])) {
1704
+ next["phases"] = (next["phases"] as unknown[]).map((ph) => {
1705
+ // ここへ来る配列は上の loop が作り直したもの = 読めた要素だけが入っている。
1706
+ if (typeof ph !== "object" || ph === null) return ph;
1707
+ const rec = safeShallowCopy(ph as object);
1708
+ if (VALIDATED_PHASE_ARRAY_FIELDS.every((f) => Array.isArray(rec[f]))) return ph;
1709
+ const copy = rec;
1710
+ for (const f of VALIDATED_PHASE_ARRAY_FIELDS) {
1711
+ if (!Array.isArray(copy[f])) copy[f] = [];
1712
+ }
1713
+ return copy;
1714
+ });
1715
+ }
1716
+ if (owner === "laid") {
1717
+ const vb = next["viewBox"];
1718
+ const ok =
1719
+ typeof vb === "object" &&
1720
+ vb !== null &&
1721
+ VIEWBOX_KEYS.every((k) => {
1722
+ // getter が例外を投げる形でも落ちない。 ここは受け止めの外側なので、 落ちると
1723
+ // 報告を返せない。
1724
+ const r = safeRead(vb, k);
1725
+ return r.ok && typeof r.value === "number" && Number.isFinite(r.value);
1726
+ });
1727
+ if (!ok) next["viewBox"] = { x: 0, y: 0, w: 0, h: 0 };
1728
+ }
1729
+ return next as T;
1730
+ }
1731
+
1732
+ function visualValidateInner(
1733
+ laid: LaidDiagram,
1734
+ diag: CdlDiagram,
1735
+ violations: Violation[],
1736
+ counts: Record<VisualAxis, number>,
1737
+ push: (axis: VisualAxis, detail: string, severity?: "error" | "warn") => void,
1738
+ profile: ValidationProfile,
1739
+ skippedAxes: VisualAxis[],
1740
+ ): VisualValidationReport {
1741
+ // 層 1 = 入口の検査 (top-level の配列 13 個と `viewBox`)。 壊れている箇所を報告してから、
1742
+ // 検査が反復できる形に差し替える。
1743
+ // 報告を先に出すのは、 差し替えた後は「元が壊れていた」 と「元から空だった」 が
1744
+ // 区別できなくなるため。
1745
+ const problems = findMalformedInput(laid, diag);
1746
+ for (const p of problems) push("malformed-input", p.detail, "error");
1747
+ if (problems.length > 0) {
1748
+ laid = normalizeForValidation(laid, problems, "laid");
1749
+ diag = normalizeForValidation(diag, problems, "diag");
1750
+ }
1751
+
1752
+ // 層 2 = すり抜けの受け止め。 入口は深さ 1 段までしか見ないので、 要素の中の field が
1753
+ // 壊れた形は例外になりうる。 例外を外に出すと **その図の全軸の結果が失われる**
1754
+ // (`#392` で実際に起きた = 1 軸の例外が 68 軸を巻き添えにした)。
1755
+ //
1756
+ // 途中までに見つけた違反は `violations` に積まれているので保持される。 例外を投げた軸より
1757
+ // 後ろの軸は走らない = 「検査できなかった」 ことを報告に出す。
1758
+ try {
1759
+ return runAxes(laid, diag, violations, counts, push, profile, skippedAxes);
1760
+ } catch (err) {
1761
+ // 入口が既に破綻を見つけていたなら入力側、 何も見つけていなければ検査側の欠陥。
1762
+ // 直す先が違うので軸を分ける。 まとめると「入力が悪い」 と誤読して本 file の bug を
1763
+ // 見逃す。
1764
+ // 入口が破綻を見つけていれば入力側と分かる。 見つけていない時は **決められない**
1765
+ // (入口が届かない破綻 = getter が例外を投げる形 も、 検査側の欠陥も、 同じに見える)。
1766
+ // 判定は `problems` ではなく `counts` を見る。 `visualValidate` は `layout()` の前にも
1767
+ // `diag` を検査して報告を積むので、 ここの入口検査だけを見ると **前段の発見を見落とす**。
1768
+ //
1769
+ // 節を持たない図では `layout()` の出力が汚れず (`viewBox` が有限のまま)、 ここの入口が
1770
+ // 何も見つけない。 その状態で後段が落ちると、 既に分かっている入力の破綻を「判別不能」
1771
+ // と報告してしまう (実測で再現した)。 `counts` は前段の push も数えている。
1772
+ const known = counts["malformed-input"] > 0;
1773
+ const axis: VisualAxis = known ? "malformed-input" : "validation-interrupted";
1774
+ const cause = known
1775
+ ? "入力の破綻が原因"
1776
+ : "原因は入力の深部の破綻か検査側の欠陥のどちらか (入口では判別できない)";
1777
+ push(
1778
+ axis,
1779
+ `検査が例外で中断した (ここまでの ${violations.length} 件は有効、 以降の軸は未実行、 ${cause}): ` +
1780
+ `${describeError(err)}${describeErrorOrigin(err)}`,
1781
+ "error",
1782
+ );
1783
+ return { diagramId: safeDiagramId(diag), ok: false, violations, counts, profile, skippedAxes };
1784
+ }
1785
+ }
1786
+
1787
+ function runAxes(
1788
+ laid: LaidDiagram,
1789
+ diag: CdlDiagram,
1790
+ violations: Violation[],
1791
+ counts: Record<VisualAxis, number>,
1792
+ push: (axis: VisualAxis, detail: string, severity?: "error" | "warn") => void,
1793
+ profile: ValidationProfile,
1794
+ skippedAxes: VisualAxis[],
1795
+ ): VisualValidationReport {
1796
+ // ───────────────────────────────────────────────────────────
1797
+ // Axis 1: node-visibility (bbox 面積 / 最小寸法)
1798
+ //
1799
+ // sequence preset の lifeline (w=2 の極細 node) は線として描画される intentional 実装で
1800
+ // 視覚的に正しい。 w < 8 の node は lifeline / 装飾線扱いとして判定対象外。
1801
+ // ───────────────────────────────────────────────────────────
1802
+ const LIFELINE_W_THRESHOLD = 8;
1803
+ for (const n of laid.nodes) {
1804
+ if (n.w < LIFELINE_W_THRESHOLD) continue;
1805
+ if (n.w < MIN_NODE_W || n.h < MIN_NODE_H) {
1806
+ push(
1807
+ "node-visibility",
1808
+ `node "${n.id}" size ${n.w}x${n.h} 未満 (要 ${MIN_NODE_W}x${MIN_NODE_H} 以上)`,
1809
+ );
1810
+ }
1811
+ }
1812
+
1813
+ // ───────────────────────────────────────────────────────────
1814
+ // Axis 2: edge-label-overlap (edge label ↔ node の AABB collision)
1815
+ // ───────────────────────────────────────────────────────────
1816
+ const bboxes = collectBBoxes(laid.lanes, laid.nodes, laid.edges);
1817
+ const collisions = detectCollisions(bboxes, laid.edges);
1818
+ for (const c of collisions) {
1819
+ const involvesEdgeLabel = c.a.kind === "edge-label" || c.b.kind === "edge-label";
1820
+ if (!involvesEdgeLabel) continue;
1821
+ push(
1822
+ "edge-label-overlap",
1823
+ `${c.a.kind}:${c.a.id} ↔ ${c.b.kind}:${c.b.id} overlap=${c.overlap_area}`,
1824
+ );
1825
+ }
1826
+
1827
+ // ───────────────────────────────────────────────────────────
1828
+ // Axis 3: text-readability (font size 固定判定)
1829
+ // ───────────────────────────────────────────────────────────
1830
+ // cdl render は各 kind で font size を hard-code (kinds/*.tsx) しているため、
1831
+ // ここでは「title 文字長 vs node 幅」 を見て切れる可能性を warn する。
1832
+ for (const n of laid.nodes) {
1833
+ // spacer node (id 末尾 "-spacer" or title 空/whitespace のみ) は幅 2px で意図的に不可視配置、
1834
+ // false positive 発火を回避するため text-readability 判定対象外
1835
+ if (n.id.endsWith("-spacer")) continue;
1836
+ // 文字を描かない種別は title が幅を超えて切れることが起きない。
1837
+ if (!rendersTitleText(n.kind)) continue;
1838
+ // 常に非表示の node の文字は画面に出ないので、 切れようがない。
1839
+ if (!isRenderedNode(n)) continue;
1840
+ const titleTrimmed = n.title?.trim() ?? "";
1841
+ if (!titleTrimmed) continue;
1842
+ // 日本語 1 文字 ~22px (kinds/storage.tsx の fontSize 22 を基準) 想定
1843
+ // title 長 * 22 + padding 52 > node.w なら切れる
1844
+ const titleLen = titleTrimmed.length;
1845
+ const expectedW = titleLen * 22 + 52;
1846
+ if (expectedW > n.w + 8) {
1847
+ push(
1848
+ "text-readability",
1849
+ `node "${n.id}" title "${n.title}" が node 幅 ${n.w}px を超過する可能性 (期待 ${expectedW}px)`,
1850
+ "warn",
1851
+ );
1852
+ }
1853
+ }
1854
+ // edge label の font size は collisions.ts で BBox 計算済、 ここでは MIN_FONT_SIZE 違反のみ check
1855
+ // (実装では cdl が 12px 以下を render することはないので no-op、 将来拡張用)
1856
+ void MIN_FONT_SIZE;
1857
+
1858
+ // ───────────────────────────────────────────────────────────
1859
+ // Axis 4: row-format (rows[] format + placeholder 解決)
1860
+ //
1861
+ // 例外行 ... divider (`─` のみで構成、 classDiagram preset で attributes と methods を区切る視覚要素)
1862
+ // / 区切りラベル目的の単一非 ASCII 行は format 判定対象外。
1863
+ // ───────────────────────────────────────────────────────────
1864
+ // 行を描かない種別の `rows` は画面に出ないので、 形式を要求しても意味がない。 書かれている
1865
+ // こと自体が破綻なのは Axis 68 (`rows-not-rendered`) が別に見る。
1866
+ const DIVIDER_ONLY = /^[─━—-]+$/;
1867
+ const stateIds = new Set(diag.states.map((s) => s.id));
1868
+ for (const n of diag.nodes) {
1869
+ if (!n.rows) continue;
1870
+ if (!rendersRows(n.kind)) continue;
1871
+ if (!isRenderedNode(n)) continue;
1872
+ for (const row of n.rows) {
1873
+ if (DIVIDER_ONLY.test(row)) continue;
1874
+ if (!ROW_FORMAT.test(row)) {
1875
+ push("row-format", `node "${n.id}" row "${row}" が "key: value" 形式でない`);
1876
+ }
1877
+ let m: RegExpExecArray | null;
1878
+ const re = new RegExp(PLACEHOLDER_RE);
1879
+ while ((m = re.exec(row)) !== null) {
1880
+ const id = m[1];
1881
+ if (id && !stateIds.has(id)) {
1882
+ push("row-format", `node "${n.id}" row "${row}" placeholder "{${id}}" が未定義 state`);
1883
+ }
1884
+ }
1885
+ }
1886
+ }
1887
+
1888
+ // ───────────────────────────────────────────────────────────
1889
+ // Axis 5: alignment (同 lane 内 node の cx 一致)
1890
+ // ───────────────────────────────────────────────────────────
1891
+ const byLane = new Map<string, typeof laid.nodes>();
1892
+ for (const n of laid.nodes) {
1893
+ const list = byLane.get(n.lane) ?? [];
1894
+ list.push(n);
1895
+ byLane.set(n.lane, list);
1896
+ }
1897
+ for (const [laneId, nodes] of byLane) {
1898
+ if (nodes.length < 2) continue;
1899
+ const cx0 = nodes[0]!.cx;
1900
+ for (const n of nodes.slice(1)) {
1901
+ if (Math.abs(n.cx - cx0) > 1) {
1902
+ push(
1903
+ "alignment",
1904
+ `lane "${laneId}" 内 node "${n.id}" cx=${n.cx} が他 node cx=${cx0} と不一致`,
1905
+ );
1906
+ }
1907
+ }
1908
+ }
1909
+
1910
+ // ───────────────────────────────────────────────────────────
1911
+ // Axis 6: clearance (near collision 0 件、 CLEARANCE_POLICY 違反)
1912
+ // ───────────────────────────────────────────────────────────
1913
+ const nears = detectNearCollisions(bboxes, laid.edges);
1914
+ for (const n of nears) {
1915
+ push(
1916
+ "clearance",
1917
+ `${n.a.kind}:${n.a.id} ↔ ${n.b.kind}:${n.b.id} gap=${n.gap.toFixed(1)}px (need ${n.required}px)`,
1918
+ );
1919
+ }
1920
+
1921
+ // ───────────────────────────────────────────────────────────
1922
+ // Axis 7: edge-label-proximity (label が自分の線の近くにある)
1923
+ //
1924
+ // label が自分の線から離れすぎると「どの辺の label か」 が読み取れなくなる。
1925
+ //
1926
+ // 判定 = label pill の箱から、 実際に描かれる線までの最短距離。 3 点が要る。
1927
+ //
1928
+ // 1. **箱から測る** — 中心から測ると、 線に貼り付いた長い label ほど遠く出る。
1929
+ // 実測 (dragon flow-demo) = 縦線の右に置いた 3 本の label は箱の左端が全て 44 world
1930
+ // (見た目の隙間は同一) なのに、 中心までは 110 / 134 / 159 と label の長さで開く。
1931
+ // 同 file の `clearance` 軸は既に箱の 8 点から測っており、 本軸だけが中心だった。
1932
+ // 2. **曲線を開く** — `extractPathSegments` は `Q` / `C` を弦に落とすが、 弦は描かれない。
1933
+ // 膨らむ側にある label は実際より遠く、 へこむ側は近く出る (誤検知と見逃しの両方)。
1934
+ // 3. **文字を描く辺だけ見る** — label も sub も空なら pill は描かれない。
1935
+ //
1936
+ // 閾値は箱で測ることを前提に定数で置く。 旧実装が持っていた補正項 (label 半 h / fontSize /
1937
+ // node 干渉逃げ) は「中心から測る」 ための換算で、 箱で測れば要らない。 実測でも節の寸法と
1938
+ // 箱までの距離は連動しない (節 426 の図でも 44 world)。
1939
+ //
1940
+ // warn は `DIST_LABEL_PATH_MAX` (見た目の隙間の上限として engine が持つ SSOT 定数、 80) を
1941
+ // そのまま使う。 error はその 2 倍 = 旧実装の warn 80 : error 160 の比を保つ。
1942
+ // ───────────────────────────────────────────────────────────
1943
+ const PROXIMITY_WARN_THRESHOLD = DIST_LABEL_PATH_MAX;
1944
+ const PROXIMITY_ERROR_THRESHOLD = DIST_LABEL_PATH_MAX * 2;
1945
+ for (const e of laid.edges) {
1946
+ if (!hasRenderedLabel(e)) continue;
1947
+ const segs = extractPathSegments(e.d);
1948
+ if (segs.length === 0) continue;
1949
+ const minDist = measurePathLabelGapSegs(flattenPathSegments(segs), computeLabelBBoxWorld(e));
1950
+ if (!Number.isFinite(minDist)) continue;
1951
+ if (minDist > PROXIMITY_ERROR_THRESHOLD) {
1952
+ push(
1953
+ "edge-label-proximity",
1954
+ `edge "${e.id}" label の箱が線から ${minDist.toFixed(0)}px 離れている (labelX=${e.labelX.toFixed(0)}, labelY=${e.labelY.toFixed(0)}、 error 閾値 ${PROXIMITY_ERROR_THRESHOLD}px)`,
1955
+ );
1956
+ } else if (minDist > PROXIMITY_WARN_THRESHOLD) {
1957
+ push(
1958
+ "edge-label-proximity",
1959
+ `edge "${e.id}" label の箱が線から ${minDist.toFixed(0)}px 離れている (border、 label 位置調整推奨、 warn 閾値 ${PROXIMITY_WARN_THRESHOLD}px)`,
1960
+ "warn",
1961
+ );
1962
+ }
1963
+ }
1964
+
1965
+ // ───────────────────────────────────────────────────────────
1966
+ // Axis 8: arrow-endpoint-anchoring (arrow の始点 / 終点が node bbox 縁と一致)
1967
+ //
1968
+ // 判定 = edge path の最初 / 最後 point が from / to node bbox の縁 (±4 world) にあるか。
1969
+ // node 内側に沈み込む (endpoint が node bbox 中心方向にはみ出す) or 縁から遠すぎる (10 world 超)
1970
+ // 場合は矢印がどこから出てるか視覚的に判別不能になる。
1971
+ // ───────────────────────────────────────────────────────────
1972
+ const nodeById = new Map<string, LaidDiagram["nodes"][number]>();
1973
+ for (const n of laid.nodes) nodeById.set(n.id, n);
1974
+ const ENDPOINT_ANCHOR_TOL = 4;
1975
+ const ENDPOINT_FAR_LIMIT = 40;
1976
+ for (const e of laid.edges) {
1977
+ const segs = extractPathSegments(e.d);
1978
+ if (segs.length === 0) continue;
1979
+ const start = { x: segs[0]!.x1, y: segs[0]!.y1 };
1980
+ const end = { x: segs[segs.length - 1]!.x2, y: segs[segs.length - 1]!.y2 };
1981
+ for (const [side, pt] of [
1982
+ ["from", start],
1983
+ ["to", end],
1984
+ ] as const) {
1985
+ const nodeId = side === "from" ? e.from : e.to;
1986
+ const node = nodeById.get(nodeId);
1987
+ if (!node) continue;
1988
+ const rect = { x: node.cx - node.w / 2, y: node.cy - node.h / 2, w: node.w, h: node.h };
1989
+ const distToEdge = pointRectEdgeDistance(pt.x, pt.y, rect);
1990
+ const inside =
1991
+ pt.x > rect.x + ENDPOINT_ANCHOR_TOL &&
1992
+ pt.x < rect.x + rect.w - ENDPOINT_ANCHOR_TOL &&
1993
+ pt.y > rect.y + ENDPOINT_ANCHOR_TOL &&
1994
+ pt.y < rect.y + rect.h - ENDPOINT_ANCHOR_TOL;
1995
+ if (inside) {
1996
+ push(
1997
+ "arrow-endpoint-anchoring",
1998
+ `edge "${e.id}" ${side} endpoint が node "${nodeId}" 内側に沈み込み (pt=${pt.x.toFixed(0)},${pt.y.toFixed(0)}、 node bbox=${rect.x.toFixed(0)},${rect.y.toFixed(0)}+${rect.w.toFixed(0)}x${rect.h.toFixed(0)})`,
1999
+ );
2000
+ } else if (distToEdge > ENDPOINT_FAR_LIMIT) {
2001
+ push(
2002
+ "arrow-endpoint-anchoring",
2003
+ `edge "${e.id}" ${side} endpoint が node "${nodeId}" 縁から ${distToEdge.toFixed(0)} world 離れている (許容 ${ENDPOINT_FAR_LIMIT} world)`,
2004
+ "warn",
2005
+ );
2006
+ }
2007
+ }
2008
+ }
2009
+
2010
+ // ───────────────────────────────────────────────────────────
2011
+ // Axis 9: label-char-range (edge label bbox が label text 実占有領域を最小限含む)
2012
+ //
2013
+ // 判定 = engine 予測 label bbox の width が measureTextWidth 実測値 + padding より
2014
+ // 極端に狭い場合、 実 render 時に text が bbox からはみ出す (rect stroke 内に文字が入らない)。
2015
+ // engine boxW = max(mainW, subW) + 36 で 4 world tolerance を許容、 未満なら error。
2016
+ // ───────────────────────────────────────────────────────────
2017
+ for (const e of laid.edges) {
2018
+ if (!hasRenderedLabel(e)) continue;
2019
+ const mainW = measureTextWidth(e.label ?? "", { fontSize: EDGE_LABEL_TEXT.main.fontSize });
2020
+ const subW = e.sub
2021
+ ? measureTextWidth(e.sub, { fontSize: EDGE_LABEL_TEXT.sub.fontSize, fontFamily: "mono" })
2022
+ : 0;
2023
+ const expectedBoxW = Math.max(mainW, subW) + LABEL_PILL_PAD_X * 2;
2024
+ const bbox = bboxes.find((b) => b.kind === "edge-label" && b.id === e.id);
2025
+ if (!bbox) continue;
2026
+ if (bbox.w + 4 < expectedBoxW) {
2027
+ push(
2028
+ "label-char-range",
2029
+ `edge "${e.id}" label bbox width ${bbox.w.toFixed(0)} が実測 char range ${expectedBoxW.toFixed(0)} world より狭い (text 溢れ)`,
2030
+ );
2031
+ }
2032
+ }
2033
+
2034
+ // ───────────────────────────────────────────────────────────
2035
+ // Axis 10: node-overlap (node bbox 同士の重なり + z-order 崩壊)
2036
+ //
2037
+ // 判定 = 2 node bbox が overlap 面積 > 0 で検知。 node は装飾線 (lifeline w<8) を除き
2038
+ // 論理的に重ならないべき (視覚的に混同する)。 spacer / marker (id endsWith "-spacer" or
2039
+ // /^s\d+-/) は判定対象外。
2040
+ // ───────────────────────────────────────────────────────────
2041
+ const nodeList = laid.nodes.filter(
2042
+ (n) => n.w >= 8 && !n.id.endsWith("-spacer") && !/^s\d+-/.test(n.id),
2043
+ );
2044
+ for (let i = 0; i < nodeList.length; i++) {
2045
+ const a = nodeList[i]!;
2046
+ const aRect = { x: a.cx - a.w / 2, y: a.cy - a.h / 2, w: a.w, h: a.h };
2047
+ for (let j = i + 1; j < nodeList.length; j++) {
2048
+ const b = nodeList[j]!;
2049
+ const bRect = { x: b.cx - b.w / 2, y: b.cy - b.h / 2, w: b.w, h: b.h };
2050
+ const overlap = rectRectOverlapArea(aRect, bRect);
2051
+ if (overlap > 0) {
2052
+ push(
2053
+ "node-overlap",
2054
+ `node "${a.id}" ↔ "${b.id}" が overlap 面積 ${overlap.toFixed(0)} world² で重なり (a=${a.cx.toFixed(0)},${a.cy.toFixed(0)} b=${b.cx.toFixed(0)},${b.cy.toFixed(0)})`,
2055
+ );
2056
+ }
2057
+ }
2058
+ }
2059
+
2060
+ // ───────────────────────────────────────────────────────────
2061
+ // Axis 11: edge-crossing (edge path 間の交差数、 単一 diagram 内で多すぎると読解性低下)
2062
+ //
2063
+ // 判定 = 全 edge pair について path segments 間の line-line intersection を数え、
2064
+ // diagram あたり 3 件超で warn (視覚的に読みにくい)。 単純な交差 1-2 件は避けられない場合が
2065
+ // 多いので許容、 4 件以上を warn 対象。
2066
+ // ───────────────────────────────────────────────────────────
2067
+ // catalog 114 diagram 実測分布 = nonzero 4 件 / max 2 crossings / p95=2 (PR #80 計測)。
2068
+ // 現状 4 件超は dead threshold (実際に発火しない)、 3 件以上で warn 化して実効性を回復。
2069
+ //
2070
+ // 実際に描かれる曲線で交差を数える (#385)。 実装は `countEdgeCrossings` (計算量を直接測れる
2071
+ // ように切り出してある)。
2072
+ const CROSSING_WARN = 3;
2073
+ const {
2074
+ count: crossingCount,
2075
+ pairs: crossingPairs,
2076
+ complete: crossingComplete,
2077
+ } = countEdgeCrossings(laid.edges, CROSSING_WARN);
2078
+ if (!crossingComplete && crossingCount < CROSSING_WARN) {
2079
+ // 数え切れなかったことを黙って「交差なし」 にしない。 上限で止めた図は、 実際には交差が
2080
+ // あっても警告が出ない。
2081
+ push(
2082
+ "edge-crossing",
2083
+ `diagram edge 交差の走査が比較回数の上限で打ち切られた (数えた分 ${crossingCount} 件、 実際はこれより多い可能性がある)`,
2084
+ "warn",
2085
+ );
2086
+ }
2087
+ if (crossingCount >= CROSSING_WARN) {
2088
+ push(
2089
+ "edge-crossing",
2090
+ `diagram edge 交差 ${crossingCount} 件 (pairs: ${crossingPairs.slice(0, 5).join(", ")}${crossingPairs.length > 5 ? " 他" : ""})、 CROSSING_WARN=${CROSSING_WARN}`,
2091
+ "warn",
2092
+ );
2093
+ }
2094
+
2095
+ // ───────────────────────────────────────────────────────────
2096
+ // Axis 12: edge-node-cross (edge path が「関係ない node」 の bbox を貫通)
2097
+ //
2098
+ // 判定 = edge の from / to 以外の node bbox を edge path segment が横切ると誤読を起こす
2099
+ // (edge の中継点として誤解される)。 lifeline / spacer は除外。
2100
+ //
2101
+ // 曲線は `flattenPathSegments` で開く。 弦のままだと、 弦が節を外れて実際の弧だけが節に入る
2102
+ // 形を見逃す (#383 実測 = 節 618..938 / 128..278 に対し `M 350 60 Q 778 438, 1206 60` は
2103
+ // 頂点 y=249 が節の中を通るのに 0 件)。 本軸は `error` で見本一括検査が止める対象のため、
2104
+ // 見逃しはそのまま「見えない破綻が通る」 になる。
2105
+ // ───────────────────────────────────────────────────────────
2106
+ for (const e of laid.edges) {
2107
+ const segs = flattenPathSegments(extractPathSegments(e.d));
2108
+ if (segs.length === 0) continue;
2109
+ for (const n of nodeList) {
2110
+ if (n.id === e.from || n.id === e.to) continue;
2111
+ const rect = { x: n.cx - n.w / 2, y: n.cy - n.h / 2, w: n.w, h: n.h };
2112
+ if (segmentsCrossRect(segs, rect)) {
2113
+ push(
2114
+ "edge-node-cross",
2115
+ `edge "${e.id}" (from=${e.from} to=${e.to}) が関係ない node "${n.id}" を貫通`,
2116
+ );
2117
+ }
2118
+ }
2119
+ }
2120
+
2121
+ // ───────────────────────────────────────────────────────────
2122
+ // Axis 13: edge-segment-orthogonality (L 字 routing segment の水平 / 垂直度)
2123
+ //
2124
+ // 判定 = 各 segment の傾き角度 (degrees) を計算、 水平 / 垂直 / 45° 対角以外の
2125
+ // 中途半端な傾き (5-40° / 50-85° / 95-130° / 140-175°) は L 字破綻の可能性。
2126
+ // ただし bezier ("C" / "Q") / 短い segment (<20 world) は除外。 warn only。
2127
+ // ───────────────────────────────────────────────────────────
2128
+ for (const e of laid.edges) {
2129
+ // bezier ("C" 三次 / "Q" 二次) を含む path は曲線制御点で角度が非直交になるのが intentional、
2130
+ // Axis 13 の判定対象外 (前 comment には書いてあったが実装 chek 抜けの bug、 PR #80 で修正)。
2131
+ if (/[CcQq]/.test(e.d)) continue;
2132
+ const segs = extractPathSegments(e.d);
2133
+ for (const s of segs) {
2134
+ const len = Math.hypot(s.x2 - s.x1, s.y2 - s.y1);
2135
+ if (len < 20) continue;
2136
+ const angle = Math.abs((Math.atan2(s.y2 - s.y1, s.x2 - s.x1) * 180) / Math.PI);
2137
+ // 0° / 90° / 180° に近い (±5°) or 45° / 135° に近い (±3°) は許容
2138
+ const near = (target: number, tol: number) => Math.abs(angle - target) < tol;
2139
+ const orthogonal = near(0, 5) || near(90, 5) || near(180, 5);
2140
+ const diagonal = near(45, 3) || near(135, 3);
2141
+ if (!orthogonal && !diagonal) {
2142
+ push(
2143
+ "edge-segment-orthogonality",
2144
+ `edge "${e.id}" segment 角度 ${angle.toFixed(1)}° (0/45/90/135/180 のいずれにも近くない、 L 字破綻の可能性)`,
2145
+ "warn",
2146
+ );
2147
+ }
2148
+ }
2149
+ }
2150
+
2151
+ // ───────────────────────────────────────────────────────────
2152
+ // Axis 14: label-inside-viewbox (label bbox が diagram viewBox 内に収まる)
2153
+ //
2154
+ // 判定 = viewBox 外に label が飛ぶと clip されて視覚破綻。 label bbox の
2155
+ // いずれかの辺が viewBox 外なら error。
2156
+ // ───────────────────────────────────────────────────────────
2157
+ const vb = laid.viewBox;
2158
+ for (const b of bboxes) {
2159
+ if (b.kind !== "edge-label") continue;
2160
+ if (b.x < vb.x || b.y < vb.y || b.x + b.w > vb.x + vb.w || b.y + b.h > vb.y + vb.h) {
2161
+ push(
2162
+ "label-inside-viewbox",
2163
+ `edge-label "${b.id}" bbox (${b.x.toFixed(0)},${b.y.toFixed(0)} ${b.w.toFixed(0)}x${b.h.toFixed(0)}) が viewBox (${vb.x.toFixed(0)},${vb.y.toFixed(0)} ${vb.w.toFixed(0)}x${vb.h.toFixed(0)}) から食み出し`,
2164
+ );
2165
+ }
2166
+ }
2167
+
2168
+ // ───────────────────────────────────────────────────────────
2169
+ // Axis 53: node-inside-viewbox (node bbox が diagram viewBox 内に収まる、 Axis 14 の node 版)
2170
+ //
2171
+ // 判定 = viewBox 外に node bbox が飛ぶと clip されて視覚破綻。 node bbox (cx±w/2, cy±h/2) の
2172
+ // いずれかの辺が viewBox 外なら error。 Axis 14 は edge-label 対象、 本 axis は node 対象で棲み分け。
2173
+ // spacer node (id endsWith "-spacer" or /^s\d+-/) は layout 補助用の非表示 node なので判定対象外。
2174
+ // ───────────────────────────────────────────────────────────
2175
+ for (const n of laid.nodes) {
2176
+ if (n.id.endsWith("-spacer") || /^s\d+-/.test(n.id)) continue;
2177
+ const nodeLeft = n.cx - n.w / 2;
2178
+ const nodeTop = n.cy - n.h / 2;
2179
+ const nodeRight = n.cx + n.w / 2;
2180
+ const nodeBottom = n.cy + n.h / 2;
2181
+ if (nodeLeft < vb.x || nodeTop < vb.y || nodeRight > vb.x + vb.w || nodeBottom > vb.y + vb.h) {
2182
+ const overflows = [];
2183
+ if (nodeLeft < vb.x) overflows.push(`left ${(vb.x - nodeLeft).toFixed(0)}px`);
2184
+ if (nodeTop < vb.y) overflows.push(`top ${(vb.y - nodeTop).toFixed(0)}px`);
2185
+ if (nodeRight > vb.x + vb.w)
2186
+ overflows.push(`right ${(nodeRight - vb.x - vb.w).toFixed(0)}px`);
2187
+ if (nodeBottom > vb.y + vb.h)
2188
+ overflows.push(`bottom ${(nodeBottom - vb.y - vb.h).toFixed(0)}px`);
2189
+ push(
2190
+ "node-inside-viewbox",
2191
+ `node "${n.id}" bbox (${nodeLeft.toFixed(0)},${nodeTop.toFixed(0)} ${n.w.toFixed(0)}x${n.h.toFixed(0)}) が viewBox (${vb.x.toFixed(0)},${vb.y.toFixed(0)} ${vb.w.toFixed(0)}x${vb.h.toFixed(0)}) から食み出し (${overflows.join(" / ")})`,
2192
+ );
2193
+ }
2194
+ }
2195
+
2196
+ // ───────────────────────────────────────────────────────────
2197
+ // Axis 54: node-inside-lane (contain / lifeline lane の内側に node bbox が収まる)
2198
+ //
2199
+ // 判定 = contain: true or lifeline: true な lane は「node を包含する」 期待 (boundary 描画あり)
2200
+ // なので、 node 4 辺が lane 4 辺の内側に収まっている必要がある。 default lane は header
2201
+ // 領域のみ height を持つ (SPEC 5 プリミティブ)、 これは node 内包の判定対象外。
2202
+ // Axis 15 (lane-cx-consistency) は中心 x のみ、 本 axis は 4 辺の包含関係を contain lane 限定で見る。
2203
+ // ───────────────────────────────────────────────────────────
2204
+ const laneByIdForInside = new Map<string, LaidDiagram["lanes"][number]>();
2205
+ for (const l of laid.lanes) laneByIdForInside.set(l.id, l);
2206
+ for (const n of laid.nodes) {
2207
+ if (n.id.endsWith("-spacer") || /^s\d+-/.test(n.id)) continue;
2208
+ if (!n.lane) continue;
2209
+ const lane = laneByIdForInside.get(n.lane);
2210
+ if (!lane) continue;
2211
+ // contain / lifeline lane のみ対象、 default lane は header 領域のみで node 内包対象外
2212
+ if (!lane.contain && !lane.lifeline) continue;
2213
+ const nodeLeft = n.cx - n.w / 2;
2214
+ const nodeTop = n.cy - n.h / 2;
2215
+ const nodeRight = n.cx + n.w / 2;
2216
+ const nodeBottom = n.cy + n.h / 2;
2217
+ const laneRight = lane.x + lane.width;
2218
+ const laneBottom = lane.y + lane.height;
2219
+ if (nodeLeft < lane.x || nodeRight > laneRight || nodeTop < lane.y || nodeBottom > laneBottom) {
2220
+ const overflows = [];
2221
+ if (nodeLeft < lane.x) overflows.push(`left ${(lane.x - nodeLeft).toFixed(0)}px`);
2222
+ if (nodeRight > laneRight) overflows.push(`right ${(nodeRight - laneRight).toFixed(0)}px`);
2223
+ if (nodeTop < lane.y) overflows.push(`top ${(lane.y - nodeTop).toFixed(0)}px`);
2224
+ if (nodeBottom > laneBottom)
2225
+ overflows.push(`bottom ${(nodeBottom - laneBottom).toFixed(0)}px`);
2226
+ push(
2227
+ "node-inside-lane",
2228
+ `node "${n.id}" bbox (${nodeLeft.toFixed(0)},${nodeTop.toFixed(0)} ${n.w.toFixed(0)}x${n.h.toFixed(0)}) が ${lane.contain ? "contain" : "lifeline"} lane "${lane.id}" bbox (${lane.x.toFixed(0)},${lane.y.toFixed(0)} ${lane.width.toFixed(0)}x${lane.height.toFixed(0)}) から食み出し (${overflows.join(" / ")})`,
2229
+ "warn",
2230
+ );
2231
+ }
2232
+ }
2233
+
2234
+ // ───────────────────────────────────────────────────────────
2235
+ // Axis 55: edge-inside-viewbox (edge path 全 segment 端点が viewBox 内に収まる)
2236
+ //
2237
+ // 判定 = edge の SVG path を segment 化し、 全端点 (x1,y1) / (x2,y2) が viewBox 内に
2238
+ // 収まっているか。 Axis 14 (label-inside-viewbox) は edge-label のみ、 Axis 53
2239
+ // (node-inside-viewbox) は node のみ対象、 本 axis は path 幾何学的 clip 検知。
2240
+ //
2241
+ // 端点だけを見れば足りるのは直線の時だけで、 これは viewBox が矩形 = 凸だから成り立つ
2242
+ // (両端が中にあれば線分は丸ごと中)。 曲線は凸性が効かないので `flattenPathSegments` で開く。
2243
+ // 弦のままだと、 両端が中にあって弧だけが外へ出る形を見逃す (#383 実測 = viewBox 上端 -34 に
2244
+ // 対し `M 350 60 Q 778 -2034, 1206 60` は頂点 y=-987 が 953 world 外へ出るのに 0 件)。
2245
+ //
2246
+ // 診断は「最初に見つけた 3 件の座標」 ではなく「最も外へ出た 1 点とその量」 を出す。 曲線を
2247
+ // 開くと 1 本の弧が 16 の分割片になり、 先頭 3 件は弧の入口付近に固まって、 どこがどれだけ
2248
+ // 外へ出たのかが読めなくなる。
2249
+ // ───────────────────────────────────────────────────────────
2250
+ for (const e of laid.edges) {
2251
+ const segs = flattenPathSegments(extractPathSegments(e.d));
2252
+ if (segs.length === 0) continue;
2253
+ /** viewBox の外へどれだけ出たか。 中なら 0。 */
2254
+ const outBy = (x: number, y: number): number =>
2255
+ Math.max(vb.x - x, 0, x - (vb.x + vb.w), vb.y - y, 0, y - (vb.y + vb.h));
2256
+ let worst = 0;
2257
+ let worstAt = "";
2258
+ let outCount = 0;
2259
+ for (const s of segs) {
2260
+ for (const p of [
2261
+ { x: s.x1, y: s.y1 },
2262
+ { x: s.x2, y: s.y2 },
2263
+ ]) {
2264
+ const over = outBy(p.x, p.y);
2265
+ if (over <= 0) continue;
2266
+ outCount += 1;
2267
+ if (over > worst) {
2268
+ worst = over;
2269
+ worstAt = `(${p.x.toFixed(0)},${p.y.toFixed(0)})`;
2270
+ }
2271
+ }
2272
+ }
2273
+ if (outCount > 0) {
2274
+ push(
2275
+ "edge-inside-viewbox",
2276
+ `edge "${e.id}" が viewBox (${vb.x.toFixed(0)},${vb.y.toFixed(0)} ${vb.w.toFixed(0)}x${vb.h.toFixed(0)}) の外へ出る : 最も外は ${worstAt} で ${worst.toFixed(0)} world`,
2277
+ );
2278
+ }
2279
+ }
2280
+
2281
+ // ───────────────────────────────────────────────────────────
2282
+ // Axis 56: lane-label-inside-viewbox (lane label bbox が viewBox 内に収まる)
2283
+ //
2284
+ // 判定 = lane 上部に描画される label が viewBox 外に飛ぶと clip、 lane 識別不能。
2285
+ // Axis 14 (label-inside-viewbox) は edge-label のみ、 本 axis は lane-label 対象で棲み分け。
2286
+ // ───────────────────────────────────────────────────────────
2287
+ for (const b of bboxes) {
2288
+ if (b.kind !== "lane-label") continue;
2289
+ if (b.x < vb.x || b.y < vb.y || b.x + b.w > vb.x + vb.w || b.y + b.h > vb.y + vb.h) {
2290
+ push(
2291
+ "lane-label-inside-viewbox",
2292
+ `lane-label "${b.id}" bbox (${b.x.toFixed(0)},${b.y.toFixed(0)} ${b.w.toFixed(0)}x${b.h.toFixed(0)}) が viewBox (${vb.x.toFixed(0)},${vb.y.toFixed(0)} ${vb.w.toFixed(0)}x${vb.h.toFixed(0)}) から食み出し`,
2293
+ );
2294
+ }
2295
+ }
2296
+
2297
+ // ───────────────────────────────────────────────────────────
2298
+ // Axis 67: lane-label-overlap (帯の名前同士が重なる / 近すぎる)
2299
+ //
2300
+ // 判定 = 帯の名前 (`lane.label`) の bbox が別の帯の名前の bbox と重なる、 または
2301
+ // 水平方向の間隔が CLEARANCE_LANE_LABEL (24 world) を下回る。
2302
+ //
2303
+ // 名前は `lane.contain` の有無に関わらず常に描かれる (`render/stage.tsx`)。 一方で
2304
+ // 重なるかどうかは **名前の文字幅** で決まり、 帯の間隔とは独立している。 帯を 200 world
2305
+ // 離しても、 名前が 300 world 分の長さなら隣の名前に食い込む。
2306
+ //
2307
+ // 経緯 = 帯の間隔を見る axis 19 (lane-lane-gap) が「間隔 40 world 未満」 を警告することで
2308
+ // 偶然この一部を拾っていたが、 axis 19 は #353 で枠が描かれる帯の対に絞った。 そもそも
2309
+ // axis 19 は名前の検出器ではない (間隔が広くて名前が長い組合せは以前から素通りしていた)。
2310
+ // 名前の重なりは文字幅で判定する本 axis の担当とする。
2311
+ //
2312
+ // 縦の重なりを先に確かめてから横の間隔を見る。 帯を横に並べる図では名前が全て上端に
2313
+ // 同じ高さで描かれるので縦は必ず重なるが、 `lane.posY` で座標を固定した図では帯が縦に
2314
+ // ずれ、 名前も縦に離れる。 横だけで判定すると、 上下に離れて置いた名前を「重なっている」
2315
+ // と誤判定する。
2316
+ //
2317
+ // 対は隣接だけでなく総当たりで見る。 縦に離れた名前を挟むと、 x 昇順の隣接では
2318
+ // 「間に別の名前がある」 ように見えて実際に重なっている対を取り逃がす。 帯の本数は
2319
+ // 図あたり数本なので総当たりで問題ない (見本 512 図の最大が 7 本)。
2320
+ // ───────────────────────────────────────────────────────────
2321
+ {
2322
+ const laneLabels = bboxes.filter((b) => b.kind === "lane-label").sort((a, b) => a.x - b.x);
2323
+ for (let i = 0; i < laneLabels.length; i++) {
2324
+ for (let j = i + 1; j < laneLabels.length; j++) {
2325
+ const left = laneLabels[i]!;
2326
+ const right = laneLabels[j]!;
2327
+ const vOverlap = Math.min(left.y + left.h, right.y + right.h) - Math.max(left.y, right.y);
2328
+ if (vOverlap <= 0) continue;
2329
+ const gap = right.x - (left.x + left.w);
2330
+ if (gap >= CLEARANCE_LANE_LABEL) continue;
2331
+ push(
2332
+ "lane-label-overlap",
2333
+ gap < 0
2334
+ ? `lane-label "${left.id}" と "${right.id}" が ${(-gap).toFixed(0)} world 重なっている`
2335
+ : `lane-label "${left.id}" と "${right.id}" の水平 gap ${gap.toFixed(0)} world が ${CLEARANCE_LANE_LABEL} 未満`,
2336
+ );
2337
+ }
2338
+ }
2339
+ }
2340
+
2341
+ // ───────────────────────────────────────────────────────────
2342
+ // Axis 57: row-alignment (CAR-421 positive-check)
2343
+ //
2344
+ // 判定 = 同 stack index を持つ横並び node 群 (= 同 row) の cy が
2345
+ // MIN_ROW_ALIGNMENT_TOLERANCE 以内で揃っているか。
2346
+ //
2347
+ // 同 row 定義 = 同 stack index を持つ node 群 (lane が異なっても、 layout engine が
2348
+ // 同一 stack index で cy を揃える設計)。 spacer / lifeline (id 末尾 -spacer or ^s\d+-)
2349
+ // は intentional な cy 差を持つので判定対象外。
2350
+ //
2351
+ // 発火 0 = 正常状態、 発火 = engine が row cy を揃え損ねている症状 (regression 検知)。
2352
+ // ───────────────────────────────────────────────────────────
2353
+ {
2354
+ const byStack = new Map<number, LaidDiagram["nodes"]>();
2355
+ for (const n of laid.nodes) {
2356
+ if (n.id.endsWith("-spacer") || /^s\d+-/.test(n.id)) continue;
2357
+ const list = byStack.get(n.stack) ?? [];
2358
+ list.push(n);
2359
+ byStack.set(n.stack, list);
2360
+ }
2361
+ for (const [stack, group] of byStack) {
2362
+ if (group.length < 2) continue;
2363
+ const cy0 = group[0]!.cy;
2364
+ for (const n of group.slice(1)) {
2365
+ const diff = Math.abs(n.cy - cy0);
2366
+ if (diff > MIN_ROW_ALIGNMENT_TOLERANCE) {
2367
+ push(
2368
+ "row-alignment",
2369
+ `stack=${stack} row 内 node "${n.id}" cy=${n.cy.toFixed(1)} が row 基準 cy=${cy0.toFixed(1)} と ${diff.toFixed(1)} world 差 (許容 ${MIN_ROW_ALIGNMENT_TOLERANCE})`,
2370
+ );
2371
+ }
2372
+ }
2373
+ }
2374
+ }
2375
+
2376
+ // ───────────────────────────────────────────────────────────
2377
+ // Axis 58: column-alignment (CAR-421 positive-check)
2378
+ //
2379
+ // 判定 = 同 lane 内で縦積みされた node 群 (= 同 column) の cx が
2380
+ // MIN_COLUMN_ALIGNMENT_TOLERANCE 以内で揃っているか。
2381
+ //
2382
+ // Axis 5 (alignment) は同 lane 内 node の相対的 cx 一致を見るが、 本 axis は
2383
+ // spec.ts SSOT (MIN_COLUMN_ALIGNMENT_TOLERANCE = 1 world) の厳格 tolerance で判定。
2384
+ // Axis 5 は tolerance 1 で hard error、 本 axis は tolerance を SSOT 値と同期する
2385
+ // positive-check 経路 (両者 同 tolerance を持つが、 SSOT 経由で spec-driven 判定にする)。
2386
+ //
2387
+ // spacer / lifeline は intentional な cx 差を持つので判定対象外。
2388
+ // ───────────────────────────────────────────────────────────
2389
+ {
2390
+ const byLaneCol = new Map<string, LaidDiagram["nodes"]>();
2391
+ for (const n of laid.nodes) {
2392
+ if (n.id.endsWith("-spacer") || /^s\d+-/.test(n.id)) continue;
2393
+ const list = byLaneCol.get(n.lane) ?? [];
2394
+ list.push(n);
2395
+ byLaneCol.set(n.lane, list);
2396
+ }
2397
+ for (const [laneId, group] of byLaneCol) {
2398
+ if (group.length < 2) continue;
2399
+ const cx0 = group[0]!.cx;
2400
+ for (const n of group.slice(1)) {
2401
+ const diff = Math.abs(n.cx - cx0);
2402
+ if (diff > MIN_COLUMN_ALIGNMENT_TOLERANCE) {
2403
+ push(
2404
+ "column-alignment",
2405
+ `lane "${laneId}" column 内 node "${n.id}" cx=${n.cx.toFixed(1)} が column 基準 cx=${cx0.toFixed(1)} と ${diff.toFixed(1)} world 差 (許容 ${MIN_COLUMN_ALIGNMENT_TOLERANCE})`,
2406
+ );
2407
+ }
2408
+ }
2409
+ }
2410
+ }
2411
+
2412
+ // ───────────────────────────────────────────────────────────
2413
+ // Axis 59 (edge-label-clearance) は #386 で畳んだ。
2414
+ //
2415
+ // 「label pill の 4 隅から自分の線までの距離」 を測っていたが、 **測る量が見える破綻と
2416
+ // 結びついていなかった**。 幅 438 の pill の中心を縦に貫く線で 4 隅からの距離は 219 world に
2417
+ // なり、 pill の中を線が通っているのに「219 離れている」 と読む。
2418
+ //
2419
+ // 測り方を変える候補を 3 つ試し、 見本 412 図でいずれも発火 0 だった (実測)。
2420
+ //
2421
+ // - 面として測る (pill の中を通るのを異常とする) → 53 本が該当するが全て正常な中置き label
2422
+ // - 角の近くを通る場合だけ見る → 0 件
2423
+ // - pill の外に出た部分だけ見る → 中置き label は境界に接するので 0 になり異常にならない
2424
+ //
2425
+ // 「label と線が近すぎる」 が異常になる形を定義できない。 中置き label は線の上に載るのが
2426
+ // 普通の配置で、 pill が背景を塗って線を隠す。
2427
+ //
2428
+ // 同じ量 (箱と実際に描かれる線の距離) は軸 7 `edge-label-proximity` が測っており、 そちらは
2429
+ // **離れすぎ** を見る。 近すぎ側に異常が無い以上、 軸を分ける理由が無い。
2430
+ //
2431
+ // pill が完全な不透明でない点 (`render/edges.tsx` の `opacity=0.92` で背後の線が 8% 残る) は
2432
+ // 描画の設定の話で、 幾何の検査では捉えられない。
2433
+
2434
+ // ───────────────────────────────────────────────────────────
2435
+ // Axis 60: edge-stubout-min (CAR-421 positive-check)
2436
+ //
2437
+ // 判定 = 各 edge の起点から、 進行が起点 side の軸方向に進み続ける距離が
2438
+ // EDGE_STUB_OUT world 以上あるか (水平 or 垂直方向どちらでも判定 OK)。
2439
+ //
2440
+ // 起点即折れ (起点 stub < EDGE_STUB_OUT) は「起点がどの node 由来か視覚判別困難」
2441
+ // 症状。 本 axis で「起点 stub が spec 満たすか」 を positive-check する。
2442
+ //
2443
+ // corner の弧を含めて測る (Issue #202)。 engine は `L exitX - corner` → `Q exitX ...` で
2444
+ // 曲がり、 exitX = 起点 + EDGE_STUB_OUT に到達する (edges.ts の CAR-472 SSOT)。
2445
+ // 直線部分は corner 半径 (14) だけ短くなるため、 第 1 segment 長だけを見ると
2446
+ // 26 world と読めて誤検知する (catalog 実測 = fsm-demo / sm2-demo / 認証-fsm-dsl の 3 件)。
2447
+ //
2448
+ // 例外 = 直線 edge (segments.length === 1) は起点 stub 概念不在、 判定対象外。
2449
+ // 極短 edge (from-to 距離自体が短く stub 長さも短い場合) は自然な結果、 warn 化。
2450
+ // ───────────────────────────────────────────────────────────
2451
+ {
2452
+ for (const e of laid.edges) {
2453
+ const segs = extractPathSegments(e.d);
2454
+ // 単一 segment = 直線、 stub 概念なし
2455
+ if (segs.length <= 1) continue;
2456
+ const stubLen = measureStubOut(segs);
2457
+ if (stubLen < EDGE_STUB_OUT) {
2458
+ // from-to 距離自体が短い場合は自然な結果、 warn に降格
2459
+ const fromNode = nodeById.get(e.from);
2460
+ const toNode = nodeById.get(e.to);
2461
+ const nodeDist =
2462
+ fromNode && toNode
2463
+ ? Math.hypot(toNode.cx - fromNode.cx, toNode.cy - fromNode.cy)
2464
+ : Infinity;
2465
+ const severity = nodeDist < EDGE_STUB_OUT * 3 ? "warn" : "error";
2466
+ push(
2467
+ "edge-stubout-min",
2468
+ `edge "${e.id}" 起点 stub 長 ${stubLen.toFixed(1)} world が spec ${EDGE_STUB_OUT} 未満 (from-to 距離 ${nodeDist === Infinity ? "?" : nodeDist.toFixed(0)})`,
2469
+ severity,
2470
+ );
2471
+ }
2472
+ }
2473
+ }
2474
+
2475
+ // ───────────────────────────────────────────────────────────
2476
+ // Axis 61: fan-origin-single-point (CAR-421 positive-check)
2477
+ //
2478
+ // 判定 = 同一 node から出る (from が同 node の) 複数 edge の 起点 (path 第 1 point) の
2479
+ // Y 座標が全て一致 (±0.5 world) しているか。
2480
+ //
2481
+ // fan-out で engine が起点 Y を Y offset で分散すると、 「1 node から複数線が発散する」
2482
+ // 視覚が「異なる node から出ているように見える」 現象になる。 起点は 1 point に
2483
+ // 収束し、 分岐は起点直後で発生するのが正しい fan 表現。
2484
+ //
2485
+ // 発火 0 = 正常状態、 発火 = fan-out engine が起点 Y を offset している (regression 検知)。
2486
+ // ───────────────────────────────────────────────────────────
2487
+ {
2488
+ const byFrom = new Map<
2489
+ string,
2490
+ Array<{ e: LaidDiagram["edges"][number]; startX: number; startY: number }>
2491
+ >();
2492
+ for (const e of laid.edges) {
2493
+ const segs = extractPathSegments(e.d);
2494
+ if (segs.length === 0) continue;
2495
+ const list = byFrom.get(e.from) ?? [];
2496
+ list.push({ e, startX: segs[0]!.x1, startY: segs[0]!.y1 });
2497
+ byFrom.set(e.from, list);
2498
+ }
2499
+ for (const [fromId, group] of byFrom) {
2500
+ if (group.length < 2) continue;
2501
+ // 例外 = 同 node から異なる side (top/bottom/left/right) に出る fan は起点 Y が異なるのが正しい、
2502
+ // side が同一な edge のみ判定対象にする。 horizontal side (left/right) = 起点 Y 一致、
2503
+ // vertical side (top/bottom) = 起点 X 一致で判定軸を切り替える。
2504
+ const bySide = new Map<string, typeof group>();
2505
+ for (const g of group) {
2506
+ const list = bySide.get(g.e.fromSide) ?? [];
2507
+ list.push(g);
2508
+ bySide.set(g.e.fromSide, list);
2509
+ }
2510
+ for (const [side, sameSide] of bySide) {
2511
+ if (sameSide.length < 2) continue;
2512
+ const ref = sameSide[0]!;
2513
+ const isHorizontalSide = side === "left" || side === "right";
2514
+ for (const g of sameSide.slice(1)) {
2515
+ const refVal = isHorizontalSide ? ref.startY : ref.startX;
2516
+ const gVal = isHorizontalSide ? g.startY : g.startX;
2517
+ const diff = Math.abs(gVal - refVal);
2518
+ if (diff > 0.5) {
2519
+ const axisName = isHorizontalSide ? "Y" : "X";
2520
+ push(
2521
+ "fan-origin-single-point",
2522
+ `node "${fromId}" side=${side} 起点 ${axisName} が edge "${g.e.id}" (${gVal.toFixed(1)}) と "${ref.e.id}" (${refVal.toFixed(1)}) で ${diff.toFixed(1)} world 差 (fan 起点は同点収束が正しい)`,
2523
+ );
2524
+ }
2525
+ }
2526
+ }
2527
+ }
2528
+ }
2529
+
2530
+ // ───────────────────────────────────────────────────────────
2531
+ // Axis 15: lane-cx-consistency (lane 内 node の cx が lane 中心と一致)
2532
+ //
2533
+ // 判定 = lane 中心 (lane.x + lane.width/2) と node.cx の差が 2 world 以上あれば
2534
+ // 「同 lane 内 node が水平 stack から外れている」 症状の可能性。 Axis 5 (alignment) は
2535
+ // 同 lane 内 node 同士の cx 一致 (相対) を見るが、 本 axis は lane 中心との絶対整合を見る。
2536
+ // ───────────────────────────────────────────────────────────
2537
+ const laneById = new Map<string, LaidDiagram["lanes"][number]>();
2538
+ for (const l of laid.lanes) laneById.set(l.id, l);
2539
+ for (const n of laid.nodes) {
2540
+ if (n.id.endsWith("-spacer") || /^s\d+-/.test(n.id)) continue;
2541
+ // 座標を明示した node は lane 中心から外れているのが指定どおりの状態なので対象外。
2542
+ // 「lane 中心と一致しない」 を症状として報告すると、 意図した配置が毎回 warn になる。
2543
+ if (n.posX !== undefined && n.posY !== undefined) continue;
2544
+ const lane = laneById.get(n.lane);
2545
+ if (!lane) continue;
2546
+ const laneCx = lane.x + lane.width / 2;
2547
+ if (Math.abs(n.cx - laneCx) > 2) {
2548
+ push(
2549
+ "lane-cx-consistency",
2550
+ `node "${n.id}" cx=${n.cx.toFixed(0)} が lane "${n.lane}" 中心 cx=${laneCx.toFixed(0)} と ${Math.abs(n.cx - laneCx).toFixed(0)} world 不一致`,
2551
+ "warn",
2552
+ );
2553
+ }
2554
+ }
2555
+
2556
+ // ───────────────────────────────────────────────────────────
2557
+ // Axis 16: row-vertical-spacing (rows の最終行が node の枠内に収まる)
2558
+ //
2559
+ // 判定 = 行を描く種別で、 最終行の baseline が node の下端を越えていないか。 越えると
2560
+ // 最後の行が枠の外に出て読めない。
2561
+ //
2562
+ // 行の縦位置は `rowBaselineY` (`layout/spec.ts`) が SSOT で、 描画側の式をそのまま持つ。
2563
+ // 群ごとに違う (`storage` は `130 + i * 56`、 `GenericNode` に回る 21 種は `100 + i * 28`)
2564
+ // ので、 1 つの式で全種別を測ることはできない。 旧実装は `100 + 行数 * 40` という、 どちらの
2565
+ // 描画とも一致しない式で全種別を測っていた (#387)。
2566
+ //
2567
+ // `storage` は `layout/nodes.ts` の `autoStorageHeight` が行数から h を伸ばすので、 著者が
2568
+ // h を明示した時だけ不足しうる。 `GenericNode` 側は h を自動で伸ばさないため、 行数が増えると
2569
+ // そのまま枠外に出る。
2570
+ // ───────────────────────────────────────────────────────────
2571
+ for (const n of laid.nodes) {
2572
+ if (!n.rows || n.rows.length === 0) continue;
2573
+ if (!rendersRows(n.kind)) continue;
2574
+ if (!isRenderedNode(n)) continue;
2575
+ const lastBaseline = rowBaselineY(n.kind, n.rows.length - 1);
2576
+ if (lastBaseline === null) continue;
2577
+ // baseline は文字の下端ではない。 字形が下へ伸びるので、 baseline が枠の内側にある
2578
+ // だけでは文字が枠線に重なる。 閾値は字形の深さの **見込み** (`rowGlyphDepth`) で、
2579
+ // レイアウトが確保する余白 (`ROW_LAYOUT_BOTTOM_PAD` = 20) より小さい。 後者を閾値に使うと、
2580
+ // 実際には文字が収まっている高さまで警告する。
2581
+ //
2582
+ // 見込みが押さえるのは preset が行に出す文字の範囲まで。 著者が結合文字などを直接書くと
2583
+ // 仮定の外に出る。 文字列ごとの実寸は layout 出力からは取れず、 実 DOM を読む経路も
2584
+ // 現状は行の文字を見ていないため、 その場合の枠外描画は **どこでも検査していない** (#390)。
2585
+ const glyphDepth = rowGlyphDepth(n.kind) ?? 0;
2586
+ const needed = lastBaseline + glyphDepth;
2587
+ if (needed > n.h) {
2588
+ push(
2589
+ "row-vertical-spacing",
2590
+ `node "${n.id}" (kind=${n.kind}) の ${n.rows.length} 行目 baseline y=${lastBaseline} + 字形の深さ ${glyphDepth} が node 高さ ${n.h} を ${(needed - n.h).toFixed(0)} world 越え、 文字が枠外に出る`,
2591
+ "warn",
2592
+ );
2593
+ }
2594
+ }
2595
+
2596
+ // ───────────────────────────────────────────────────────────
2597
+ // Axis 68: rows-not-rendered (行を描かない種別に rows が書かれている)
2598
+ //
2599
+ // 判定 = `rendersRows` が偽の種別に `rows` が 1 行以上あるか。 書いた内容が画面に出ないまま
2600
+ // 消えるので、 著者は書いたつもりでいる。
2601
+ //
2602
+ // Axis 4 (`row-format`) / Axis 16 (`row-vertical-spacing`) が描く種別だけを見るのと対になる。
2603
+ // 「描かない種別に対して行の検査をしない」 だけだと、 消えていること自体を誰も見なくなる。
2604
+ // ───────────────────────────────────────────────────────────
2605
+ for (const n of diag.nodes) {
2606
+ if (!n.rows || n.rows.length === 0) continue;
2607
+ if (rendersRows(n.kind)) continue;
2608
+ // 常時非表示の node は対象外。 本軸が扱うのは「書いた行が表示される機会を失う」 形で、
2609
+ // 常時非表示の node の行にはそもそも表示される局面が無い。 行が出ない原因が種別ではなく
2610
+ // node ごと出ないことにある以上、 本軸の内容消失には当たらない。
2611
+ //
2612
+ // かつ「固定の偽で隠す」 は意図的な書き方として実在する (見本 412 図に 17 件。 parts の
2613
+ // 例が `w: 1, h: 1, visibleIf: "0"` の anchor node を配置の足場に使う)。
2614
+ //
2615
+ // なお非表示 node も layout の対象で、 `rows` は自動幅に効く (実測 320 → 670)。 行が
2616
+ // 何の影響も持たないわけではない。
2617
+ if (!isRenderedNode(n)) continue;
2618
+ push(
2619
+ "rows-not-rendered",
2620
+ `node "${n.id}" (kind=${n.kind ?? "未指定"}) が rows ${n.rows.length} 行を持つが、 この種別は行を描かない (書いた内容が画面に出ない)`,
2621
+ );
2622
+ }
2623
+
2624
+ // ───────────────────────────────────────────────────────────
2625
+ // Axis 17: group-boundary-clearance (topology group の boundary padding + 外部 clearance)
2626
+ //
2627
+ // 判定 = lane.contain=true の lane 境界と内部 node の padding が 24 world 未満、
2628
+ // または外部 node との clearance が 16 world 未満なら group boundary が乱れる。
2629
+ // ───────────────────────────────────────────────────────────
2630
+ // Axis 17 の閾値実測 tune。 patterns 系 (pattern-passthrough / pattern-branch / pattern-fan-in) の
2631
+ // contain=true lane は実測 padding 20 world で intentional な密着 design を採用しているため、
2632
+ // 閾値を 24 → 20 world に緩め、 実効に「明確な破綻」 のみ warn 化。
2633
+ const GROUP_INTERNAL_PAD = 20;
2634
+ const GROUP_EXTERNAL_CLEAR = 16;
2635
+ for (const lane of laid.lanes) {
2636
+ // contain: true (topology group / infrastructure zone) のみ boundary 検査対象、
2637
+ // 通常の swim lane / flow lane は lane.height が動的で node と一致しない設計のため対象外。
2638
+ if (!lane.contain) continue;
2639
+ const contained = laid.nodes.filter((n) => n.lane === lane.id);
2640
+ if (contained.length === 0) continue;
2641
+ // 内部 padding = lane 境界と内部 node の最短距離、 GROUP_INTERNAL_PAD 未満で warn
2642
+ for (const n of contained) {
2643
+ const nodeLeft = n.cx - n.w / 2;
2644
+ const nodeTop = n.cy - n.h / 2;
2645
+ const nodeRight = n.cx + n.w / 2;
2646
+ const nodeBottom = n.cy + n.h / 2;
2647
+ const padLeft = nodeLeft - lane.x;
2648
+ const padTop = nodeTop - lane.y;
2649
+ const padRight = lane.x + lane.width - nodeRight;
2650
+ const padBottom = lane.y + lane.height - nodeBottom;
2651
+ const minPad = Math.min(padLeft, padTop, padRight, padBottom);
2652
+ if (minPad < GROUP_INTERNAL_PAD) {
2653
+ push(
2654
+ "group-boundary-clearance",
2655
+ `lane "${lane.id}" 内部 node "${n.id}" との padding ${minPad.toFixed(0)} world が ${GROUP_INTERNAL_PAD} 未満`,
2656
+ "warn",
2657
+ );
2658
+ }
2659
+ }
2660
+ // 外部 clearance = lane 境界と他 lane の外部 node の最短距離
2661
+ const externalNodes = laid.nodes.filter((n) => n.lane !== lane.id);
2662
+ for (const n of externalNodes) {
2663
+ const laneRect = { x: lane.x, y: lane.y, w: lane.width, h: lane.height };
2664
+ const nodeRect = { x: n.cx - n.w / 2, y: n.cy - n.h / 2, w: n.w, h: n.h };
2665
+ const dx = Math.max(
2666
+ 0,
2667
+ Math.max(laneRect.x - (nodeRect.x + nodeRect.w), nodeRect.x - (laneRect.x + laneRect.w)),
2668
+ );
2669
+ const dy = Math.max(
2670
+ 0,
2671
+ Math.max(laneRect.y - (nodeRect.y + nodeRect.h), nodeRect.y - (laneRect.y + laneRect.h)),
2672
+ );
2673
+ const clear = Math.hypot(dx, dy);
2674
+ const overlap = dx === 0 && dy === 0;
2675
+ if (!overlap && clear < GROUP_EXTERNAL_CLEAR) {
2676
+ push(
2677
+ "group-boundary-clearance",
2678
+ `lane "${lane.id}" と外部 node "${n.id}" の clearance ${clear.toFixed(0)} world が ${GROUP_EXTERNAL_CLEAR} 未満`,
2679
+ "warn",
2680
+ );
2681
+ }
2682
+ }
2683
+ }
2684
+
2685
+ // ───────────────────────────────────────────────────────────
2686
+ // Axis 18: node-vertical-clearance (同 lane 内隣接 node の垂直 gap)
2687
+ //
2688
+ // 判定 = 同 lane 内 stack 隣接 node ペアの垂直 gap (下 top - 上 bottom) が
2689
+ // NODE_V_CLEAR (40 world) 未満なら視覚判別困難。 lifeline / spacer / -spacer 除外。
2690
+ // ───────────────────────────────────────────────────────────
2691
+ const NODE_V_CLEAR = 40;
2692
+ const laneNodesMap = new Map<string, LaidDiagram["nodes"]>();
2693
+ for (const n of laid.nodes) {
2694
+ if (n.w < 8 || n.id.endsWith("-spacer") || /^s\d+-/.test(n.id)) continue;
2695
+ const list = laneNodesMap.get(n.lane) ?? [];
2696
+ list.push(n);
2697
+ laneNodesMap.set(n.lane, list);
2698
+ }
2699
+ for (const [laneId, nodesInLane] of laneNodesMap) {
2700
+ const sorted = [...nodesInLane].sort((a, b) => a.cy - b.cy);
2701
+ for (let i = 0; i + 1 < sorted.length; i++) {
2702
+ const upper = sorted[i]!;
2703
+ const lower = sorted[i + 1]!;
2704
+ const upperBottom = upper.cy + upper.h / 2;
2705
+ const lowerTop = lower.cy - lower.h / 2;
2706
+ const gap = lowerTop - upperBottom;
2707
+ if (gap < NODE_V_CLEAR) {
2708
+ push(
2709
+ "node-vertical-clearance",
2710
+ `lane "${laneId}" 内 node "${upper.id}" ↔ "${lower.id}" の垂直 gap ${gap.toFixed(0)} world が ${NODE_V_CLEAR} 未満`,
2711
+ "warn",
2712
+ );
2713
+ }
2714
+ }
2715
+ }
2716
+
2717
+ // ───────────────────────────────────────────────────────────
2718
+ // Axis 19: lane-lane-gap (隣接 lane 間の水平 gap)
2719
+ //
2720
+ // 判定 = x 昇順で並べた lane 隣接ペアの水平 gap が LANE_H_GAP (40 world) 未満なら
2721
+ // lane boundary が判別困難。 ただし同 x (重なり) は spec 上 intentional として除外。
2722
+ //
2723
+ // 対象は **枠が描かれる lane の対のみ**。 render は `lane.contain` が true の lane にだけ
2724
+ // 破線の boundary rect を描き、 それ以外は fill / stroke とも none の不可視 rect しか出さない
2725
+ // (`render/stage.tsx`)。 枠が無ければ「lane boundary」 も無いので、 間隔が狭いこと自体が
2726
+ // 画面に現れない。
2727
+ //
2728
+ // 経緯 = contain の有無を見ずに全対を測っていた頃、 dragon の見本 512 図で 99 件の warn が出て、
2729
+ // 実測するとその全件が枠なしの対だった (枠ありの対 0 件)。 描かれていない境界の間隔を測って
2730
+ // いたことになる。 枠なし lane に並ぶ node 同士の見た目の間隔は node-node clearance が担保し、
2731
+ // 検査の下限 70 (`NEAR_COLLISION_POLICY["node|node"]`) に対して `expandLanesForNodes` が
2732
+ // 目標 80 (`targetNodeNodeClearance`) で lane 余白を逆算する。 座標を固定した lane と
2733
+ // overlay は逆算の対象外だが、 そこは node-node clearance の軸が下限割れを報告する。
2734
+ //
2735
+ // lane.label は contain と無関係に常に描かれるが (`render/stage.tsx`)、 label 同士の
2736
+ // 重なりは lane 間隔ではなく label の文字幅で決まる (間隔 200 でも長い label なら重なる)。
2737
+ // 本 axis を label の検出器として兼用すると両方の精度が落ちるため、 label 重なりは
2738
+ // 別 axis の担当とする (dragon 見本での実測は 0 件)。
2739
+ // ───────────────────────────────────────────────────────────
2740
+ const LANE_H_GAP = 40;
2741
+ // gantt / quadrant 等の preset は lane 密着 (日付範囲 / 象限 grid) が intentional design、
2742
+ // lane id prefix で allowlist 化して対象外にする。
2743
+ const DENSE_LANE_PREFIXES = ["gantt-", "quadrant-", "funnel-", "chart-"];
2744
+ // 重ねることが目的の lane (role: "overlay") は鎖から除く。 group を囲む枠や gantt の
2745
+ // timeline 帯がこれにあたり、 間隔を空けるのは配置の意図に反する。 単に「その対を見ない」
2746
+ // だけだと帯を挟んだ両隣の column lane 同士が隣接と判定されず、 詰まりを見逃す
2747
+ // (`expandLanesForNodes` が columnChain を組む時と同じ理由)。
2748
+ const sortedLanes = [...laid.lanes].filter((l) => l.role !== "overlay").sort((a, b) => a.x - b.x);
2749
+ for (let i = 0; i + 1 < sortedLanes.length; i++) {
2750
+ const left = sortedLanes[i]!;
2751
+ const right = sortedLanes[i + 1]!;
2752
+ if (DENSE_LANE_PREFIXES.some((p) => left.id.startsWith(p) || right.id.startsWith(p))) continue;
2753
+ if (!left.contain && !right.contain) continue;
2754
+ const gap = right.x - (left.x + left.width);
2755
+ if (gap > 0 && gap < LANE_H_GAP) {
2756
+ push(
2757
+ "lane-lane-gap",
2758
+ `lane "${left.id}" と "${right.id}" の水平 gap ${gap.toFixed(0)} world が ${LANE_H_GAP} 未満`,
2759
+ "warn",
2760
+ );
2761
+ }
2762
+ }
2763
+
2764
+ // ───────────────────────────────────────────────────────────
2765
+ // Axis 20: arrow-marker-clearance (arrow head と to node bbox の clearance)
2766
+ //
2767
+ // 判定 = edge の最終 segment 終点 (arrow head 位置) と to node bbox 縁の distance が
2768
+ // ARROW_MARKER_MIN (4 world) 未満で「食い込み」、 ARROW_MARKER_MAX (30 world) 超で「離れすぎ」。
2769
+ // sequence lifeline (w<10) は arrow が横に伸びる intentional 構造で対象外。
2770
+ // ───────────────────────────────────────────────────────────
2771
+ const ARROW_MARKER_MIN = 4;
2772
+ const ARROW_MARKER_MAX = 30;
2773
+ for (const e of laid.edges) {
2774
+ const segs = extractPathSegments(e.d);
2775
+ if (segs.length === 0) continue;
2776
+ const lastSeg = segs[segs.length - 1]!;
2777
+ const endPt = { x: lastSeg.x2, y: lastSeg.y2 };
2778
+ const toNode = nodeById.get(e.to);
2779
+ if (!toNode) continue;
2780
+ if (toNode.w < 10 || toNode.h < 10) continue;
2781
+ const rect = {
2782
+ x: toNode.cx - toNode.w / 2,
2783
+ y: toNode.cy - toNode.h / 2,
2784
+ w: toNode.w,
2785
+ h: toNode.h,
2786
+ };
2787
+ const inside =
2788
+ endPt.x > rect.x &&
2789
+ endPt.x < rect.x + rect.w &&
2790
+ endPt.y > rect.y &&
2791
+ endPt.y < rect.y + rect.h;
2792
+ const distToEdge = pointRectEdgeDistance(endPt.x, endPt.y, rect);
2793
+ if (inside) {
2794
+ const depth = Math.min(
2795
+ endPt.x - rect.x,
2796
+ rect.x + rect.w - endPt.x,
2797
+ endPt.y - rect.y,
2798
+ rect.y + rect.h - endPt.y,
2799
+ );
2800
+ if (depth > ARROW_MARKER_MIN) {
2801
+ push(
2802
+ "arrow-marker-clearance",
2803
+ `edge "${e.id}" arrow head が node "${e.to}" 内側に ${depth.toFixed(0)} world 食い込み`,
2804
+ "warn",
2805
+ );
2806
+ }
2807
+ } else if (distToEdge > ARROW_MARKER_MAX) {
2808
+ push(
2809
+ "arrow-marker-clearance",
2810
+ `edge "${e.id}" arrow head が node "${e.to}" 縁から ${distToEdge.toFixed(0)} world 離れすぎ (max ${ARROW_MARKER_MAX})`,
2811
+ "warn",
2812
+ );
2813
+ }
2814
+ }
2815
+
2816
+ // ───────────────────────────────────────────────────────────
2817
+ // Axis 21: grid-alignment (格子に載せる図の箱が格子に載るか)
2818
+ //
2819
+ // 判定 = chart / funnel / quadrant が作る箱の **4 辺** が GRID_SIZE (16 world) の倍数
2820
+ // (±2 world) に載るか。 左上と寸法の両方を見る (左上だけだと、 寸法が 16 の倍数でない時に
2821
+ // 右下が外れる)。
2822
+ //
2823
+ // 中心ではなく左上を見る。 箱が格子に載るかは原点と寸法で決まり、 中心は寸法が
2824
+ // 32 の倍数でない限り必ず半目ずれる (高さ 360 の chart は上端 128 で載っているのに
2825
+ // 中心 308 では 4 ずれる)。
2826
+ //
2827
+ // 対象は節の **種別** で絞る。 元は節の id が `chart-` 等で始まるかで見ていたが、 id は
2828
+ // `{図の id}-chart` の形で作られるため、 図の id を変えるだけで検査対象から外れていた。
2829
+ // ───────────────────────────────────────────────────────────
2830
+ const GRID_SIZE = 16;
2831
+ const GRID_TOL = 2;
2832
+ for (const n of laid.nodes) {
2833
+ if (!GRID_ALIGNED_KINDS.has(n.kind)) continue;
2834
+ if (n.w < 8) continue;
2835
+ const offGrid = (v: number): number => Math.abs(v - Math.round(v / GRID_SIZE) * GRID_SIZE);
2836
+ const left = n.cx - n.w / 2;
2837
+ const top = n.cy - n.h / 2;
2838
+ const rx = Math.max(offGrid(left), offGrid(left + n.w));
2839
+ const ry = Math.max(offGrid(top), offGrid(top + n.h));
2840
+ if (rx > GRID_TOL || ry > GRID_TOL) {
2841
+ push(
2842
+ "grid-alignment",
2843
+ `node "${n.id}" 箱 (${left.toFixed(0)},${top.toFixed(0)} ${n.w}x${n.h}) の辺が grid ${GRID_SIZE} 倍数から (dx=${rx.toFixed(1)}, dy=${ry.toFixed(1)}) 外れ`,
2844
+ "warn",
2845
+ );
2846
+ }
2847
+ }
2848
+
2849
+ // ───────────────────────────────────────────────────────────
2850
+ // Axis 22: phase-layout-stability (phase 別 layout が node cx / cy を動かさない)
2851
+ //
2852
+ // 判定 = diag に複数 phase がある場合、 phase 別 layout 実行で node cx / cy が
2853
+ // ±PHASE_STABILITY_TOL (1 world) 以内で安定するか。 layout() 自身が phase 非依存なので
2854
+ // 実質「同一 layout 再計算の deterministic 性」 を確認する gate。
2855
+ // ───────────────────────────────────────────────────────────
2856
+ const PHASE_STABILITY_TOL = 1;
2857
+ if (diag.phases.length > 1) {
2858
+ let secondLaid: LaidDiagram;
2859
+ try {
2860
+ const origWarn2 = console.warn;
2861
+ console.warn = () => {};
2862
+ try {
2863
+ secondLaid = layout(diag);
2864
+ } finally {
2865
+ console.warn = origWarn2;
2866
+ }
2867
+ const secondById = new Map(secondLaid.nodes.map((n) => [n.id, n]));
2868
+ for (const n of laid.nodes) {
2869
+ const s = secondById.get(n.id);
2870
+ if (!s) continue;
2871
+ const dx = Math.abs(n.cx - s.cx);
2872
+ const dy = Math.abs(n.cy - s.cy);
2873
+ if (dx > PHASE_STABILITY_TOL || dy > PHASE_STABILITY_TOL) {
2874
+ push(
2875
+ "phase-layout-stability",
2876
+ `node "${n.id}" が 2 回目 layout で (dx=${dx.toFixed(1)}, dy=${dy.toFixed(1)}) drift、 phase 間不安定`,
2877
+ "warn",
2878
+ );
2879
+ }
2880
+ }
2881
+ } catch {
2882
+ // 再 layout 失敗は他 axis が捕捉
2883
+ }
2884
+ }
2885
+
2886
+ // ───────────────────────────────────────────────────────────
2887
+ // Axis 23: responsive-viewport (viewBox の過剰扁平回避)
2888
+ //
2889
+ // 判定 = viewBox aspect ratio が RESPONSIVE_MAX_ASPECT (6:1) を超えると横長すぎで
2890
+ // responsive 破綻。 縦 1 段の図に lane を並べ続けると高さが伸びないまま幅だけ増え、
2891
+ // 親幅に収めた時に帯状に潰れて中身が読めなくなる。
2892
+ //
2893
+ // viewBox width の下限は見ない。 render は `w-full` + `preserveAspectRatio="xMidYMid meet"`
2894
+ // で親要素の幅いっぱいに伸ばすため (`render/stage.tsx`)、 画面上の文字サイズは
2895
+ //
2896
+ // 画面上の文字サイズ = 文字の world 寸法 × (親要素の幅 px ÷ viewBox width)
2897
+ //
2898
+ // で決まる。 viewBox width が小さいほど倍率が上がって読みやすくなるので、 「幅が小さい =
2899
+ // 識別不能」 は成り立たない。 以前は 400 world 未満を warn にしていたが、 dragon の見本で
2900
+ // 発火した 17 件は全て単体部品の図 (271-390 world) で、 幅 375px の携帯では 1.0-1.38 倍に
2901
+ // 拡大される最も読みやすい部類だった。 閾値を下げても判定の向きが誤ったまま残るため、
2902
+ // 判定ごと外している。
2903
+ //
2904
+ // 幅の下限を外した分、 描画不能な寸法 (0 / 負値 / 非有限) だけは別に拾う。 縦横比は
2905
+ // 0 幅で NaN、 負値で符号が反転して比較が素通りするため、 比を出す前に弾く。
2906
+ // ───────────────────────────────────────────────────────────
2907
+ const RESPONSIVE_MAX_ASPECT = 6;
2908
+ const vbResp = laid.viewBox;
2909
+ if (
2910
+ !(vbResp.w > 0) ||
2911
+ !(vbResp.h > 0) ||
2912
+ !Number.isFinite(vbResp.w) ||
2913
+ !Number.isFinite(vbResp.h)
2914
+ ) {
2915
+ push(
2916
+ "responsive-viewport",
2917
+ `viewBox 寸法 ${vbResp.w}×${vbResp.h} が描画不能 (正の有限値である必要がある)`,
2918
+ "warn",
2919
+ );
2920
+ } else {
2921
+ // 横長と縦長を同じ尺度で見る。 縦長を aspect のまま報告すると detail が
2922
+ // 「aspect 0.1 が 6:1 超え」 のように読めない値になる。
2923
+ const worstAspect = Math.max(vbResp.w / vbResp.h, vbResp.h / vbResp.w);
2924
+ if (worstAspect > RESPONSIVE_MAX_ASPECT) {
2925
+ push(
2926
+ "responsive-viewport",
2927
+ `viewBox aspect ${worstAspect.toFixed(1)}:1 が RESPONSIVE_MAX_ASPECT ${RESPONSIVE_MAX_ASPECT}:1 超え`,
2928
+ "warn",
2929
+ );
2930
+ }
2931
+ }
2932
+
2933
+ // ───────────────────────────────────────────────────────────
2934
+ // Axis 24: accessibility-basics (図の代替説明が十分か)
2935
+ //
2936
+ // cdl の図は `<svg role="img">` で描く = 支援技術には **1 枚の絵** として渡り、 中の節や辺は
2937
+ // 個別に公開されない (WAI-ARIA の `img` role は子孫を公開しない)。 読み上げの質はこの 1 文で
2938
+ // 決まるため、 本軸はその 1 文が組み立つかを見る (#363)。
2939
+ //
2940
+ // 元は「節ごとに title があるか」 を見ていた。 節の文字は絵の中の点でしかなく、 支援技術が
2941
+ // 節ごとの名前として読む保証が無いため、 0 件でも実際の読み上げ可能性を表していなかった。
2942
+ //
2943
+ // 操作できる部品 (スライダー等) は `<svg>` の外の HTML なので、 絵として扱っても隠れない。
2944
+ //
2945
+ // 見るのは 2 点。 どちらも「有無」 で、 長さは見ない (長ければ良いという判定は空洞化する)。
2946
+ // 1. 図が何を示すか (`topic`)
2947
+ // 2. 今どの段かと、 その段が何を説明するか (段の `title` または `body`)
2948
+ // ───────────────────────────────────────────────────────────
2949
+ if (!(diag.topic?.trim() ?? "")) {
2950
+ push(
2951
+ "accessibility-basics",
2952
+ `diagram "${diag.id}" topic 空、 読み上げる説明が組み立たない`,
2953
+ "warn",
2954
+ );
2955
+ }
2956
+ // 段を足しても文が伸びない = その段は読み上げに何も足していない。
2957
+ const altTopicOnly = buildDiagramAltText(diag.topic, undefined);
2958
+ for (const p of diag.phases) {
2959
+ if (buildDiagramAltText(diag.topic, p) === altTopicOnly) {
2960
+ push(
2961
+ "accessibility-basics",
2962
+ `diagram "${diag.id}" phase "${p.id}" に title / body がなく、 段を読み上げても題名しか伝わらない`,
2963
+ "warn",
2964
+ );
2965
+ }
2966
+ }
2967
+
2968
+ // ───────────────────────────────────────────────────────────
2969
+ // Axis 25: animation-frame-integrity (phase tween の progress 単調性 + sets 一貫性)
2970
+ //
2971
+ // 判定 = 各 phase の tween について from → to が数値で単調 (from == to 例外)、
2972
+ // sets の重複 stateId 内競合 (同 phase 内で同 stateId が tween + sets 両方) を検知。
2973
+ // ───────────────────────────────────────────────────────────
2974
+ const stateIds2 = new Set(diag.states.map((s) => s.id));
2975
+ for (const p of diag.phases) {
2976
+ const tweenIds = new Map<string, { from: number; to: number }>();
2977
+ for (const t of p.tweens) {
2978
+ if (!stateIds2.has(t.stateId)) {
2979
+ push(
2980
+ "animation-frame-integrity",
2981
+ `phase "${p.id}" tween "${t.stateId}" が未定義 state を参照`,
2982
+ "warn",
2983
+ );
2984
+ continue;
2985
+ }
2986
+ if (!Number.isFinite(t.from) || !Number.isFinite(t.to)) {
2987
+ push(
2988
+ "animation-frame-integrity",
2989
+ `phase "${p.id}" tween "${t.stateId}" from/to が数値でない (from=${t.from}, to=${t.to})`,
2990
+ "warn",
2991
+ );
2992
+ continue;
2993
+ }
2994
+ if (tweenIds.has(t.stateId)) {
2995
+ push(
2996
+ "animation-frame-integrity",
2997
+ `phase "${p.id}" tween "${t.stateId}" が同 phase 内で重複定義`,
2998
+ "warn",
2999
+ );
3000
+ }
3001
+ tweenIds.set(t.stateId, { from: t.from, to: t.to });
3002
+ }
3003
+ for (const s of p.sets) {
3004
+ if (!stateIds2.has(s.stateId)) {
3005
+ push(
3006
+ "animation-frame-integrity",
3007
+ `phase "${p.id}" sets "${s.stateId}" が未定義 state を参照`,
3008
+ "warn",
3009
+ );
3010
+ continue;
3011
+ }
3012
+ if (tweenIds.has(s.stateId)) {
3013
+ push(
3014
+ "animation-frame-integrity",
3015
+ `phase "${p.id}" 同 stateId "${s.stateId}" が tween + sets 両方で定義 (競合)`,
3016
+ "warn",
3017
+ );
3018
+ }
3019
+ }
3020
+ }
3021
+
3022
+ // ───────────────────────────────────────────────────────────
3023
+ // Axis 26: i18n-cjk-detection (CJK 文字 + RTL 文字の検知)
3024
+ //
3025
+ // 判定 = label / title / sub / lane label に RTL 文字 (Arabic U+0600-06FF /
3026
+ // Hebrew U+0590-05FF) が含まれると現状の LTR-only 描画で崩れる。 CJK は
3027
+ // サポート済だが「未サポート script」 の early detection。
3028
+ // ───────────────────────────────────────────────────────────
3029
+ const rtlRe = /[֐-׿؀-ۿ]/;
3030
+ const rtlTexts: Array<{ where: string; text: string }> = [];
3031
+ for (const n of diag.nodes) {
3032
+ // 文字を描かない種別の title は画面に出ないので、 描画の崩れも起きない。
3033
+ if (n.title && rendersTitleText(n.kind) && rtlRe.test(n.title)) {
3034
+ rtlTexts.push({ where: `node "${n.id}" title`, text: n.title });
3035
+ }
3036
+ if (n.eyebrow && rtlRe.test(n.eyebrow))
3037
+ rtlTexts.push({ where: `node "${n.id}" eyebrow`, text: n.eyebrow });
3038
+ if (n.subtitle && rtlRe.test(n.subtitle))
3039
+ rtlTexts.push({ where: `node "${n.id}" subtitle`, text: n.subtitle });
3040
+ }
3041
+ for (const e of diag.edges) {
3042
+ if (e.label && rtlRe.test(e.label))
3043
+ rtlTexts.push({ where: `edge "${e.id}" label`, text: e.label });
3044
+ if (e.sub && rtlRe.test(e.sub)) rtlTexts.push({ where: `edge "${e.id}" sub`, text: e.sub });
3045
+ }
3046
+ for (const l of diag.lanes) {
3047
+ if (l.label && rtlRe.test(l.label))
3048
+ rtlTexts.push({ where: `lane "${l.id}" label`, text: l.label });
3049
+ }
3050
+ for (const r of rtlTexts) {
3051
+ push(
3052
+ "i18n-cjk-detection",
3053
+ `${r.where} に RTL 文字 (Arabic / Hebrew) 検出: "${r.text}" — 現状 LTR-only 描画で崩れる可能性`,
3054
+ "warn",
3055
+ );
3056
+ }
3057
+
3058
+ // ───────────────────────────────────────────────────────────
3059
+ // Axis 27: contrast-basics (edge label の文字と背景の対比)
3060
+ //
3061
+ // 判定 = 文字色と背景色の相対輝度から contrast ratio (最大 21) を出し、 WCAG AA の閾値未満なら
3062
+ // warn。 閾値は文字の大きさで分かれる (large text = 24px 以上 or 太字 18.66px 以上 → 3:1、
3063
+ // それ以外 → 4.5:1)。
3064
+ //
3065
+ // **状態が 2 つ、 行が 2 種**。 `render/edges.tsx` の edge label は 2 行を描き、 どちらも
3066
+ // `fill={active ? color : "var(--cdl-text-dim, ...)"}`。 大きさと太さと不透明度は
3067
+ // `label-text.ts` の `EDGE_LABEL_TEXT` が SSOT で、 ここには写さない (写すと片方だけが
3068
+ // 変わった時に検知できない)。
3069
+ //
3070
+ // **tone の色が文字になるのは活性時だけ**。 非活性時は tone に依らず `--cdl-text-dim`。
3071
+ // 旧実装は tone 対 `#ffffff` だけを見ており、 実際には最も多く画面に出る非活性時の組合せを
3072
+ // 一度も測っていなかった (#388)。
3073
+ //
3074
+ // **行ごとに閾値と合成を導く**。 旧実装は全 label を large text と見なしていた (#388)。
3075
+ // 現在はどちらの行も large text で不透明だが、 それは `EDGE_LABEL_TEXT` がそう定めている
3076
+ // からで、 判定は仕様から計算する (#391)。
3077
+ //
3078
+ // **どちらの状態が起きるかは phase が決める**。 `render/stage.tsx` は
3079
+ // `activeSet = new Set(currentPhase?.activate ?? [])` で、 phase が無ければ全て非活性。
3080
+ // 一度も activate されない辺の tone は label の文字として一度も描かれないので測らない。
3081
+ //
3082
+ // 非活性の pill は `opacity={0.92}` で背後の色が 8% 混ざるが、 判定では混ぜていない。
3083
+ // 既定の stage 背景 `#fafafa` との合成では対比 6.15 → 6.10 で、 背後が純黒でも 5.16。
3084
+ // 閾値 3.0 に対して十分離れており、 混ぜても判定は変わらない (実測値は test に固定)。
3085
+ //
3086
+ // **測るのは cdl の既定値どうしの対比だけ**。 背景は `var(--cdl-label-bg, #ffffff)`、 文字は
3087
+ // `var(--cdl-text-dim, #5a6270)` / `var(--cdl-tone-*, ...)` で、 いずれも下流が上書きできる。
3088
+ // さらに下流は `[data-cdl-role="edge-label"]` / `[data-cdl-role="edge-label-bg"]` に
3089
+ // `fill: ... !important` を当てて変数ごと迂回できる (dragon の 6 主題は実際にそうしている)。
3090
+ // 本軸は下流の CSS を知らないので、 下流側の対比は下流の test が見る。
3091
+ // ───────────────────────────────────────────────────────────
3092
+ // render/tone.ts SSOT を import して 2 度書き回避 (前 visual-validate 内 hardcode は drift 元凶)。
3093
+ const TONE_COLORS = TONE_SSOT;
3094
+ for (const f of evaluateLabelContrast({
3095
+ edges: laid.edges,
3096
+ phases: laid.phases,
3097
+ // 描画側と同じ解決。 `TONE` は CSS 変数を含む文字列なので、 対比計算には hex の SSOT を使う。
3098
+ toneColor: (tone) =>
3099
+ hasTone(tone) ? (TONE_COLORS as Record<string, string>)[tone]! : TONE_FALLBACK,
3100
+ labelBg: LABEL_BG_DEFAULT,
3101
+ inactiveText: LABEL_TEXT_INACTIVE_DEFAULT,
3102
+ })) {
3103
+ push("contrast-basics", f.detail, "warn");
3104
+ }
3105
+
3106
+ // ───────────────────────────────────────────────────────────
3107
+ // Axis 28: print-media-compat (mono / high-contrast 印刷で edge tone 別判別可能か)
3108
+ //
3109
+ // 判定 = mono print で edge tone を色抜きすると全て同色 (gray) になり判別不能。
3110
+ // edge style ("solid" / "dotted-flow") で最低限 2 種類使い分けているか、
3111
+ // edge が 3 本以上あるならその区別が必要。 単一 style だと mono で全 edge 同見え。
3112
+ // ───────────────────────────────────────────────────────────
3113
+ // print-media は「機能的差異が edge にある state machine / sequence」 系のみ判定対象。
3114
+ // 装飾的 flow / topology は edge 差異が意図的に均一な design が多く、 false positive 回避。
3115
+ // 判定対象 = edge に guard / cardinality / sub のいずれかが 1 本でもある = 機能差 diagram。
3116
+ const hasFunctionalEdge = laid.edges.some((e) => e.guard || e.cardinality || e.sub);
3117
+ if (hasFunctionalEdge && laid.edges.length >= 5) {
3118
+ const styles = new Set(laid.edges.map((e) => e.style ?? "solid"));
3119
+ const tones = new Set(laid.edges.map((e) => e.tone));
3120
+ if (styles.size === 1 && tones.size === 1) {
3121
+ push(
3122
+ "print-media-compat",
3123
+ `機能差 edges ${laid.edges.length} 本が全て単一 style "${[...styles][0]}" + 単一 tone "${[...tones][0]}"、 mono print で識別不能`,
3124
+ "warn",
3125
+ );
3126
+ }
3127
+ }
3128
+
3129
+ // ───────────────────────────────────────────────────────────
3130
+ // Axis 31: color-blind-safety (protanopia / deuteranopia シミュレーションで tone 判別可能)
3131
+ //
3132
+ // 判定 = 同 diagram 内で使用される tone ペアを protanopia (P-type) / deuteranopia (D-type)
3133
+ // 変換した後の CIE Lab 距離 (Δ_L) が COLOR_BLIND_MIN (10) 未満だと色覚異常下で
3134
+ // 判別困難。 実装 = 単純な RGB → Machado (2009) 系 protanopia matrix 変換 → RGB → 輝度差。
3135
+ // ───────────────────────────────────────────────────────────
3136
+ function applyProtanopia(rgb: [number, number, number]): [number, number, number] {
3137
+ // Machado (2009) protanopia matrix (0.85 severity)
3138
+ const [r, g, b] = rgb;
3139
+ return [
3140
+ r * 0.152 + g * 1.053 + b * -0.205,
3141
+ r * 0.115 + g * 0.786 + b * 0.099,
3142
+ r * -0.004 + g * -0.048 + b * 1.052,
3143
+ ];
3144
+ }
3145
+ function applyDeuteranopia(rgb: [number, number, number]): [number, number, number] {
3146
+ const [r, g, b] = rgb;
3147
+ return [
3148
+ r * 0.367 + g * 0.861 + b * -0.228,
3149
+ r * 0.28 + g * 0.673 + b * 0.047,
3150
+ r * -0.012 + g * 0.043 + b * 0.969,
3151
+ ];
3152
+ }
3153
+ function colorDiff(a: [number, number, number], b: [number, number, number]): number {
3154
+ return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
3155
+ }
3156
+ const COLOR_BLIND_MIN = 30;
3157
+ const tonesInUse = Array.from(new Set(laid.edges.map((e) => e.tone)));
3158
+ for (let i = 0; i < tonesInUse.length; i++) {
3159
+ for (let j = i + 1; j < tonesInUse.length; j++) {
3160
+ const tA = tonesInUse[i]!;
3161
+ const tB = tonesInUse[j]!;
3162
+ const cA = TONE_COLORS[tA];
3163
+ const cB = TONE_COLORS[tB];
3164
+ if (!cA || !cB) continue;
3165
+ const rgbA = hexToRgb(cA);
3166
+ const rgbB = hexToRgb(cB);
3167
+ const dP = colorDiff(applyProtanopia(rgbA), applyProtanopia(rgbB));
3168
+ const dD = colorDiff(applyDeuteranopia(rgbA), applyDeuteranopia(rgbB));
3169
+ const minDiff = Math.min(dP, dD);
3170
+ if (minDiff < COLOR_BLIND_MIN) {
3171
+ push(
3172
+ "color-blind-safety",
3173
+ `tone pair "${tA}" ↔ "${tB}" が protanopia/deuteranopia 下で色差 ${minDiff.toFixed(1)} (min ${COLOR_BLIND_MIN} 未満)、 判別困難`,
3174
+ "warn",
3175
+ );
3176
+ }
3177
+ }
3178
+ }
3179
+
3180
+ // ───────────────────────────────────────────────────────────
3181
+ // Axis 32: marker-gradient-def-integrity (marker id 参照と TONE 定義の整合)
3182
+ //
3183
+ // 判定 = edge の tone が TONE_COLORS に定義された key と一致するか。 tone 値が typo /
3184
+ // 追加漏れで marker id "cdl-arrow-${unknown}" になると SVG marker が hit しない dead ref。
3185
+ // 現状 edge.tone は型 (Tone union) で制約済だが、 runtime で拡張 preset が渡す string で
3186
+ // typo する余地があるので gate として先取り。
3187
+ // ───────────────────────────────────────────────────────────
3188
+ for (const e of laid.edges) {
3189
+ if (!TONE_COLORS[e.tone]) {
3190
+ push(
3191
+ "marker-gradient-def-integrity",
3192
+ `edge "${e.id}" tone "${e.tone}" が TONE_COLORS 定義に存在しない、 marker "cdl-arrow-${e.tone}" dead ref`,
3193
+ );
3194
+ }
3195
+ }
3196
+
3197
+ // ───────────────────────────────────────────────────────────
3198
+ // Axis 33: subpixel-precision (node / edge label 座標の subpixel drift 検知)
3199
+ //
3200
+ // 判定 = node cx / cy / edge labelX / labelY / lane x / y が SUBPIXEL_TOL (0.5 world) を
3201
+ // 超えて小数点を持つ (e.g. 100.7) と subpixel render 時に SVG 描画が blur を生む。
3202
+ // 整数化 or 0.5 刻みが理想。 label は shift 経路の結果として非整数が出やすい。
3203
+ // ───────────────────────────────────────────────────────────
3204
+ const SUBPIXEL_TOL = 0.5;
3205
+ function isSubpixel(v: number): boolean {
3206
+ // 整数と 0.5 刻みの両方を許容する。
3207
+ //
3208
+ // 0.5 は SVG では「にじむ座標」 ではない。 幅 1 の線は中心を .5 に置くと 1 pixel に
3209
+ // ちょうど収まって輪郭が立つ (逆に整数に置くと 2 pixel に半分ずつ跨いでぼやける)。
3210
+ // 本 axis の説明も「整数化 or 0.5 刻みが理想」 としており、 判定側だけが 0.5 を
3211
+ // 弾いていた。 説明に合わせる。
3212
+ //
3213
+ // 判定は「最も近い 0.5 の倍数からの距離」 で見る。 0.05 以下は float 演算の誤差として許容。
3214
+ const nearestHalf = Math.round(v * 2) / 2;
3215
+ return Math.abs(v - nearestHalf) > SUBPIXEL_TOL / 10;
3216
+ }
3217
+ for (const n of laid.nodes) {
3218
+ if (n.id.endsWith("-spacer") || /^s\d+-/.test(n.id)) continue;
3219
+ if (isSubpixel(n.cx) || isSubpixel(n.cy)) {
3220
+ push(
3221
+ "subpixel-precision",
3222
+ `node "${n.id}" 座標 (${n.cx.toFixed(2)}, ${n.cy.toFixed(2)}) が非整数、 subpixel blur リスク`,
3223
+ "warn",
3224
+ );
3225
+ }
3226
+ }
3227
+ for (const e of laid.edges) {
3228
+ if (isSubpixel(e.labelX) || isSubpixel(e.labelY)) {
3229
+ push(
3230
+ "subpixel-precision",
3231
+ `edge "${e.id}" label 座標 (${e.labelX.toFixed(2)}, ${e.labelY.toFixed(2)}) が非整数、 subpixel blur リスク`,
3232
+ "warn",
3233
+ );
3234
+ }
3235
+ }
3236
+ for (const l of laid.lanes) {
3237
+ if (isSubpixel(l.x) || isSubpixel(l.y)) {
3238
+ push(
3239
+ "subpixel-precision",
3240
+ `lane "${l.id}" 座標 (${l.x.toFixed(2)}, ${l.y.toFixed(2)}) が非整数、 subpixel blur リスク`,
3241
+ "warn",
3242
+ );
3243
+ }
3244
+ }
3245
+
3246
+ // ───────────────────────────────────────────────────────────
3247
+ // Axis 34: dom-complexity-budget (1 diagram あたりの element 総数)
3248
+ //
3249
+ // 判定 = node / edge / lane / phase の合計が DOM_COMPLEXITY_BUDGET (400) を超えると
3250
+ // render 遅延 / DOM node 過多で performance 破綻。 実 render は 1 element ≈ 5-10 SVG 要素
3251
+ // (rect + text + filter + marker 分) なので 400 element ≈ 2000-4000 SVG node。
3252
+ // ───────────────────────────────────────────────────────────
3253
+ const DOM_COMPLEXITY_BUDGET = 400;
3254
+ const totalElements =
3255
+ laid.nodes.length + laid.edges.length + laid.lanes.length + diag.phases.length;
3256
+ if (totalElements > DOM_COMPLEXITY_BUDGET) {
3257
+ push(
3258
+ "dom-complexity-budget",
3259
+ `diagram element 総数 ${totalElements} が budget ${DOM_COMPLEXITY_BUDGET} 超過 (nodes=${laid.nodes.length} edges=${laid.edges.length} lanes=${laid.lanes.length} phases=${diag.phases.length})`,
3260
+ "warn",
3261
+ );
3262
+ }
3263
+
3264
+ // ───────────────────────────────────────────────────────────
3265
+ // Axis 35: reduced-motion-compat (phase duration の最小値 / 累計動作時間)
3266
+ //
3267
+ // 判定 = phase の duration が MIN_PHASE_DURATION_MS (400ms) 未満だと prefers-reduced-motion
3268
+ // 非対応時に flicker / seizure リスク (WCAG 2.3.1 相当)。 かつ全 phase 累計が
3269
+ // MAX_TOTAL_ANIMATION_MS (60000ms=1min) 超だと user attention を過度に奪う。
3270
+ // ───────────────────────────────────────────────────────────
3271
+ const MIN_PHASE_DURATION_MS = 400;
3272
+ const MAX_TOTAL_ANIMATION_MS = 60000;
3273
+ let totalDuration = 0;
3274
+ for (const p of diag.phases) {
3275
+ if (p.duration < MIN_PHASE_DURATION_MS) {
3276
+ push(
3277
+ "reduced-motion-compat",
3278
+ `phase "${p.id}" duration ${p.duration}ms が MIN ${MIN_PHASE_DURATION_MS}ms 未満、 flicker / seizure リスク`,
3279
+ "warn",
3280
+ );
3281
+ }
3282
+ totalDuration += p.duration;
3283
+ }
3284
+ if (totalDuration > MAX_TOTAL_ANIMATION_MS) {
3285
+ push(
3286
+ "reduced-motion-compat",
3287
+ `diagram 全 phase 累計 ${totalDuration}ms が MAX ${MAX_TOTAL_ANIMATION_MS}ms 超え、 user attention 過負荷`,
3288
+ "warn",
3289
+ );
3290
+ }
3291
+
3292
+ // ───────────────────────────────────────────────────────────
3293
+ // Axis 36: touch-target-size (interactive element の最小 target size)
3294
+ //
3295
+ // 判定 = phase indicator (header の phase button 相当、 直接 render に data-cdl-* interactive
3296
+ // attribute はないが、 conceptual に header は user click で phase 切替可能な interactive element)。
3297
+ // 各 phase 分の button は横並びで幅 = viewBox width / phase count。 WCAG 2.5.5 Level AAA
3298
+ // 44 world 未満なら target size 不足、 mobile touch で誤タップ発生。
3299
+ //
3300
+ // 実装 = phase 数 * TOUCH_TARGET_MIN (44 world) を横並び時の最小要求幅と扱い、
3301
+ // viewBox width がそれ未満なら「target size 不足」 warn。
3302
+ // ───────────────────────────────────────────────────────────
3303
+ const TOUCH_TARGET_MIN = 44;
3304
+ if (diag.phases.length > 0) {
3305
+ const requiredW = diag.phases.length * TOUCH_TARGET_MIN;
3306
+ if (laid.viewBox.w < requiredW) {
3307
+ push(
3308
+ "touch-target-size",
3309
+ `phases ${diag.phases.length} 個 × TOUCH_TARGET_MIN ${TOUCH_TARGET_MIN} = ${requiredW} world 要求、 viewBox width ${laid.viewBox.w.toFixed(0)} が不足で touch target 過小`,
3310
+ "warn",
3311
+ );
3312
+ }
3313
+ }
3314
+
3315
+ // ───────────────────────────────────────────────────────────
3316
+ // Axis 37: row-content-typing (node.rows[] の値型別 validation)
3317
+ //
3318
+ // 判定 = row が "key: value" 形式のとき、 key 名から期待型を推測 (url / email /
3319
+ // duration / percent / count / rate / date) → value が期待 pattern に一致するか。
3320
+ // 型検査失敗は warn (docs / catalog で見せる例が実務用途に耐えるかの mini gate)。
3321
+ // ───────────────────────────────────────────────────────────
3322
+ const TYPE_PATTERNS: Array<{ keyRe: RegExp; label: string; valueRe: RegExp }> = [
3323
+ { keyRe: /^url$|url:|url_/i, label: "URL", valueRe: /^\{[^}]+\}$|^(https?:\/\/|\/)\S+/ },
3324
+ {
3325
+ keyRe: /^email$|email:|_email/i,
3326
+ label: "email",
3327
+ valueRe: /^\{[^}]+\}$|^[\w.+-]+@[\w-]+\.[\w.-]+$/,
3328
+ },
3329
+ {
3330
+ keyRe: /^(duration|timeout|ttl|latency|elapsed)/i,
3331
+ label: "duration",
3332
+ valueRe: /^\{[^}]+\}$|^\d+(\.\d+)?\s*(ms|s|m|h|d)$|^\d+$/,
3333
+ },
3334
+ {
3335
+ keyRe: /^(pct|percent|rate|ratio)$|^(pct|percent|rate|ratio):/i,
3336
+ label: "percent",
3337
+ valueRe: /^\{[^}]+\}$|^\d+(\.\d+)?%?$/,
3338
+ },
3339
+ { keyRe: /^count$|count:|^total$|^n$/i, label: "count", valueRe: /^\{[^}]+\}$|^\d+$/ },
3340
+ ];
3341
+ // ER / classDiagram の schema 宣言 (`email: string` / `total: number` 等) は実データでなく
3342
+ // 型名を書く design、 Axis 37 の型判定対象外。 value が schema 型キーワードなら skip。
3343
+ const SCHEMA_KEYWORDS =
3344
+ /^(string|number|boolean|text|enum|timestamp|date|datetime|json|blob|PK|FK|UUID|BIGINT|INT|FLOAT|VARCHAR|TEXT|DATETIME)$|^enum\(|^varchar\(/i;
3345
+ for (const n of diag.nodes) {
3346
+ if (!n.rows) continue;
3347
+ for (const row of n.rows) {
3348
+ const m = row.match(/^([^:]+):\s*(.+)$/);
3349
+ if (!m) continue;
3350
+ const [, key, value] = m;
3351
+ const keyTrim = key!.trim();
3352
+ const valTrim = value!.trim();
3353
+ if (SCHEMA_KEYWORDS.test(valTrim)) continue; // schema 宣言 skip
3354
+ for (const t of TYPE_PATTERNS) {
3355
+ if (t.keyRe.test(keyTrim)) {
3356
+ if (!t.valueRe.test(valTrim)) {
3357
+ push(
3358
+ "row-content-typing",
3359
+ `node "${n.id}" row "${row}" — key "${keyTrim}" は ${t.label} 型を期待するが value "${valTrim}" が pattern 不一致`,
3360
+ "warn",
3361
+ );
3362
+ }
3363
+ break;
3364
+ }
3365
+ }
3366
+ }
3367
+ }
3368
+
3369
+ // ───────────────────────────────────────────────────────────
3370
+ // Axis 38: terminal-safe-text (制御文字 / zero-width 混入検知)
3371
+ //
3372
+ // 判定 = title / label / eyebrow / subtitle / row / lane label に
3373
+ // - 制御文字 U+0000-001F (TAB / LF / CR は許容) → 実 render で無表示になり見えない bug
3374
+ // - zero-width chars U+200B (ZWSP) / U+200C (ZWNJ) / U+200D (ZWJ) → 文字列比較の
3375
+ // silent equality break、 label bbox 予測に含まれない
3376
+ // - Byte Order Mark U+FEFF → 先頭以外で表示崩れ
3377
+ // ───────────────────────────────────────────────────────────
3378
+ //
3379
+ // 本 regex は「制御文字 / zero-width 文字を検知する」 のが目的 SSOT。
3380
+ // 検知したい文字を regex literal に直接書くと source に NUL byte が残り、
3381
+ // ripgrep / ugrep が file 全体を binary と誤判定して検索対象から無言で外す。
3382
+ // 同じ code point を unicode escape で書く (match する集合は完全に等価)。
3383
+ // eslint-disable-next-line no-control-regex
3384
+ const CTRL_RE = /[\u0000-\u0008\u000b-\u000c\u000e-\u001f]/;
3385
+ const ZW_RE = /[\u200b-\u200d\ufeff]/;
3386
+ function checkTerminalSafe(text: string | undefined, where: string): void {
3387
+ if (!text) return;
3388
+ if (CTRL_RE.test(text)) {
3389
+ push(
3390
+ "terminal-safe-text",
3391
+ `${where} に制御文字 (U+0000-001F) 混入、 実 render で無表示 bug`,
3392
+ "warn",
3393
+ );
3394
+ }
3395
+ if (ZW_RE.test(text)) {
3396
+ push(
3397
+ "terminal-safe-text",
3398
+ `${where} に zero-width / BOM 文字 混入、 silent equality break + bbox 予測崩れ`,
3399
+ "warn",
3400
+ );
3401
+ }
3402
+ }
3403
+ for (const n of diag.nodes) {
3404
+ checkTerminalSafe(n.title, `node "${n.id}" title`);
3405
+ checkTerminalSafe(n.eyebrow, `node "${n.id}" eyebrow`);
3406
+ checkTerminalSafe(n.subtitle, `node "${n.id}" subtitle`);
3407
+ if (n.rows) for (const r of n.rows) checkTerminalSafe(r, `node "${n.id}" row`);
3408
+ }
3409
+ for (const e of diag.edges) {
3410
+ checkTerminalSafe(e.label, `edge "${e.id}" label`);
3411
+ checkTerminalSafe(e.sub, `edge "${e.id}" sub`);
3412
+ }
3413
+ for (const l of diag.lanes) {
3414
+ checkTerminalSafe(l.label, `lane "${l.id}" label`);
3415
+ }
3416
+
3417
+ // ───────────────────────────────────────────────────────────
3418
+ // Axis 39: gpu-layer-efficiency (edge style の GPU 加速可否)
3419
+ //
3420
+ // 判定 = dotted-flow style は particle animation を伴い、 transform / opacity で
3421
+ // GPU compositing 効果を得る前提。 実装上は engine SVG は transform / opacity のみ
3422
+ // 使うため gate は「dotted-flow 使用時に animation phase が存在するか」 を検査。
3423
+ // dotted-flow で phase 空 = animation なしで無駄 style。
3424
+ // ───────────────────────────────────────────────────────────
3425
+ const dottedEdges = laid.edges.filter((e) => e.style === "dotted-flow");
3426
+ if (dottedEdges.length > 0 && diag.phases.length === 0) {
3427
+ push(
3428
+ "gpu-layer-efficiency",
3429
+ `dotted-flow style edge ${dottedEdges.length} 本あるが phase 0、 particle animation 発火せず無駄 style (GPU layer promotion 不必要)`,
3430
+ "warn",
3431
+ );
3432
+ }
3433
+ // 5 edge 以上の dotted-flow は GPU layer が dot ごとに promotion → 30+ layer で GPU thrash
3434
+ const MAX_DOTTED_EDGES = 8;
3435
+ if (dottedEdges.length > MAX_DOTTED_EDGES) {
3436
+ push(
3437
+ "gpu-layer-efficiency",
3438
+ `dotted-flow edges ${dottedEdges.length} 本 > MAX ${MAX_DOTTED_EDGES}、 particle GPU layer 過多で thrash リスク`,
3439
+ "warn",
3440
+ );
3441
+ }
3442
+
3443
+ // ───────────────────────────────────────────────────────────
3444
+ // Axis 40: memory-budget (diagram size 総和による React re-render 負荷)
3445
+ //
3446
+ // 判定 = nodes×h×w + edges×path segment 数 + phases×(tweens + sets 数) の推定 memory 単位
3447
+ // が MEMORY_BUDGET (10^7) を超えると React re-render で GC pressure。 catalog 内 diagram は
3448
+ // 10^5-10^6 が実測、 10^7 で明確な破綻。
3449
+ // ───────────────────────────────────────────────────────────
3450
+ const MEMORY_BUDGET = 1_000_000;
3451
+ let nodeMemUnit = 0;
3452
+ for (const n of laid.nodes) nodeMemUnit += n.w * n.h;
3453
+ let edgeMemUnit = 0;
3454
+ for (const e of laid.edges) {
3455
+ const segs = extractPathSegments(e.d);
3456
+ edgeMemUnit += segs.length * 100; // 1 segment ≈ 100 memory unit (SVG path + marker + label)
3457
+ }
3458
+ let phaseMemUnit = 0;
3459
+ for (const p of diag.phases) phaseMemUnit += (p.tweens.length + p.sets.length) * 50;
3460
+ const totalMemUnit = nodeMemUnit + edgeMemUnit + phaseMemUnit;
3461
+ if (totalMemUnit > MEMORY_BUDGET) {
3462
+ push(
3463
+ "memory-budget",
3464
+ `diagram memory unit 推定 ${totalMemUnit.toFixed(0)} > budget ${MEMORY_BUDGET} (nodes=${nodeMemUnit.toFixed(0)} edges=${edgeMemUnit.toFixed(0)} phases=${phaseMemUnit.toFixed(0)})`,
3465
+ "warn",
3466
+ );
3467
+ }
3468
+
3469
+ // ───────────────────────────────────────────────────────────
3470
+ // Axis 41: svg-injection-safety (XSS / script injection 検知、 error severity)
3471
+ //
3472
+ // 判定 = label / title / eyebrow / subtitle / row / lane label / sub / topic に
3473
+ // XSS-shaped 文字列 (`<script>` / `javascript:` / `data:text/html` / `on\w+=`) が
3474
+ // 混入すると SVG DOM 内で実行可能 = security 破綻。 error severity で block。
3475
+ // ───────────────────────────────────────────────────────────
3476
+ const XSS_PATTERNS: Array<{ re: RegExp; label: string }> = [
3477
+ { re: /<script\b/i, label: "<script> tag" },
3478
+ { re: /javascript:/i, label: "javascript: URI" },
3479
+ { re: /data:text\/html/i, label: "data:text/html URI" },
3480
+ { re: /\bon\w+\s*=/i, label: "inline event handler (on*=)" },
3481
+ { re: /<iframe\b/i, label: "<iframe> tag" },
3482
+ { re: /<embed\b/i, label: "<embed> tag" },
3483
+ { re: /<object\b/i, label: "<object> tag" },
3484
+ ];
3485
+ function checkXss(text: string | undefined, where: string): void {
3486
+ if (!text) return;
3487
+ for (const p of XSS_PATTERNS) {
3488
+ if (p.re.test(text)) {
3489
+ push(
3490
+ "svg-injection-safety",
3491
+ `${where} に XSS pattern ${p.label} 検出、 実 render で script 実行の恐れ`,
3492
+ );
3493
+ }
3494
+ }
3495
+ }
3496
+ checkXss(diag.topic, `diagram "${diag.id}" topic`);
3497
+ for (const n of diag.nodes) {
3498
+ checkXss(n.title, `node "${n.id}" title`);
3499
+ checkXss(n.eyebrow, `node "${n.id}" eyebrow`);
3500
+ checkXss(n.subtitle, `node "${n.id}" subtitle`);
3501
+ if (n.rows) for (const r of n.rows) checkXss(r, `node "${n.id}" row`);
3502
+ }
3503
+ for (const e of diag.edges) {
3504
+ checkXss(e.label, `edge "${e.id}" label`);
3505
+ checkXss(e.sub, `edge "${e.id}" sub`);
3506
+ }
3507
+ for (const l of diag.lanes) {
3508
+ checkXss(l.label, `lane "${l.id}" label`);
3509
+ }
3510
+
3511
+ // ───────────────────────────────────────────────────────────
3512
+ // Axis 42: seo-metadata-quality (topic / id の SEO / OGP meta 品質)
3513
+ //
3514
+ // 判定 = SEO_MIN_TOPIC (3 char) / SEO_MAX_TOPIC (60 char) 範囲外は SEO meta title
3515
+ // として不適切。 id の長さ (2-40 char) / 命名 (kebab-case 推奨) も検査。
3516
+ // 空 id / 空 topic は明確な error、 長すぎは warn。
3517
+ // ───────────────────────────────────────────────────────────
3518
+ const SEO_MIN_TOPIC = 3;
3519
+ const SEO_MAX_TOPIC = 60;
3520
+ const SEO_ID_MIN = 2;
3521
+ const SEO_ID_MAX = 40;
3522
+ const topicLen = diag.topic?.trim().length ?? 0;
3523
+ if (topicLen === 0) {
3524
+ push("seo-metadata-quality", `diagram "${diag.id}" topic 空、 OGP meta title 生成不可`);
3525
+ } else if (topicLen < SEO_MIN_TOPIC) {
3526
+ push(
3527
+ "seo-metadata-quality",
3528
+ `diagram "${diag.id}" topic 長 ${topicLen} < ${SEO_MIN_TOPIC}、 SEO title として短すぎ`,
3529
+ "warn",
3530
+ );
3531
+ } else if (topicLen > SEO_MAX_TOPIC) {
3532
+ push(
3533
+ "seo-metadata-quality",
3534
+ `diagram "${diag.id}" topic 長 ${topicLen} > ${SEO_MAX_TOPIC}、 SEO title で truncate される`,
3535
+ "warn",
3536
+ );
3537
+ }
3538
+ const idLen = diag.id.length;
3539
+ if (idLen < SEO_ID_MIN || idLen > SEO_ID_MAX) {
3540
+ push(
3541
+ "seo-metadata-quality",
3542
+ `diagram id "${diag.id}" 長 ${idLen} が [${SEO_ID_MIN}, ${SEO_ID_MAX}] 外`,
3543
+ "warn",
3544
+ );
3545
+ }
3546
+
3547
+ // ───────────────────────────────────────────────────────────
3548
+ // Axis 43: bidi-hyphenation (bidi bracket 崩れ + 長 label 折返し)
3549
+ //
3550
+ // 判定 =
3551
+ // - LTR 内 RTL 単語混入 (Arabic / Hebrew) が bidi bracket 制御なしだと表示順崩れ
3552
+ // - 単一 label が LABEL_TOO_LONG (40 char) 超えかつ空白ゼロ = ハイフネーション補助不可
3553
+ // ───────────────────────────────────────────────────────────
3554
+ const bidiRtlWord = /[֐-׿؀-ۿ]+/;
3555
+ const bidiLtrWord = /[a-zA-Z]{3,}/;
3556
+ const LABEL_TOO_LONG = 40;
3557
+ function checkBidiHyphen(text: string | undefined, where: string): void {
3558
+ if (!text) return;
3559
+ const hasRtl = bidiRtlWord.test(text);
3560
+ const hasLtr = bidiLtrWord.test(text);
3561
+ if (hasRtl && hasLtr) {
3562
+ // LRM (U+200E) / RLM (U+200F) / Isolate (U+2066-2069) の 4 種のいずれもなければ bidi 崩れ潜在
3563
+ const hasBidiControl = /[‎‏⁦-⁩]/.test(text);
3564
+ if (!hasBidiControl) {
3565
+ push(
3566
+ "bidi-hyphenation",
3567
+ `${where} に LTR + RTL 単語混在、 bidi 制御文字なしで表示順崩れリスク`,
3568
+ "warn",
3569
+ );
3570
+ }
3571
+ }
3572
+ if (text.length > LABEL_TOO_LONG && !/[\s\-/,]/.test(text)) {
3573
+ push(
3574
+ "bidi-hyphenation",
3575
+ `${where} 長 ${text.length} 超で空白/ハイフン無し、 折返し補助不可で clip リスク`,
3576
+ "warn",
3577
+ );
3578
+ }
3579
+ }
3580
+ for (const n of diag.nodes) {
3581
+ // 文字を描かない種別の title は画面に出ないので、 折返しや表示順の崩れも起きない。
3582
+ if (rendersTitleText(n.kind)) checkBidiHyphen(n.title, `node "${n.id}" title`);
3583
+ checkBidiHyphen(n.eyebrow, `node "${n.id}" eyebrow`);
3584
+ checkBidiHyphen(n.subtitle, `node "${n.id}" subtitle`);
3585
+ }
3586
+ for (const e of diag.edges) {
3587
+ checkBidiHyphen(e.label, `edge "${e.id}" label`);
3588
+ checkBidiHyphen(e.sub, `edge "${e.id}" sub`);
3589
+ }
3590
+ for (const l of diag.lanes) {
3591
+ checkBidiHyphen(l.label, `lane "${l.id}" label`);
3592
+ }
3593
+
3594
+ // ───────────────────────────────────────────────────────────
3595
+ // Axis 44: structured-data-extraction (schema.org 抽出可能性)
3596
+ //
3597
+ // 判定 = diagram を schema.org DataFeed / TechArticle 抽出する際、
3598
+ // 必須 field (id / topic / node.title の非空 sample 3 個以上 / phase 1 個以上) が
3599
+ // 揃うか。 これらが揃えば SEO / OGP / structured-data JSON-LD 生成可能。
3600
+ // ───────────────────────────────────────────────────────────
3601
+ // docs 説明用 mini diagram (kind- / style- / tone- prefix / -demo suffix + 4 node 未満) は
3602
+ // 「単一 kind の見本」 として意図的に極小、 structured-data 抽出対象外に allowlist。
3603
+ // 5 節以下の primitive / pattern / lane / stack / k- (kind mini) / kind- は全て「説明用 mini」、
3604
+ // structured-data 抽出対象外。 実務 diagram (topology / sequence / cookbook 系) のみ対象化。
3605
+ // 全 showcase 型 prefix + 4 node 未満の text-dsl / animation showcase を allowlist に集約。
3606
+ // 実務 diagram = topology / sequence / cookbook / flow-demo 等の 4+ node は残す。
3607
+ const isMiniShowcase =
3608
+ /^(kind-|style-|tone-|edge-|primitive-|pattern-|lane-|stack-|k-|state-|tween-|set-|badge-|mixed-)/.test(
3609
+ diag.id,
3610
+ ) ||
3611
+ diag.id.endsWith("-dsl") ||
3612
+ (diag.nodes.length < 4 && /-demo$|-example$|-showcase$/.test(diag.id));
3613
+
3614
+ // 抽出対象外は **著者の宣言** で決める (`structuredData: "exclude"`)。
3615
+ //
3616
+ // 図の形から推測しようとすると必ず盲点ができる。 実際に試して分かったこと。
3617
+ // - id の prefix (`parts-` 等) で除外 ... 同じ prefix を持つ中身のある図まで巻き込む
3618
+ // (実測 = dragon の `parts-` 80 図のうち 18 図は node 複数、 うち 16 図は名前を持つ)
3619
+ // - 「名前が 1 つも無い図」 を除外 ... 名前の欠落は本 axis が検知すべきものそのもので、
3620
+ // 「意図的に文字を持たない図」 と「付け忘れた図」 を engine からは区別できない
3621
+ //
3622
+ // 意図は図の形に現れないため、 著者に宣言してもらう。 既定は `"extract"` で、
3623
+ // 宣言を忘れても検知が緩まない向きに倒している。
3624
+ //
3625
+ // 本条件は Axis 44 だけに効かせる。
3626
+ const excludedByAuthor = diag.structuredData === "exclude";
3627
+
3628
+ if (!isMiniShowcase && !excludedByAuthor) {
3629
+ const namedNodes = diag.nodes.filter((n) => (n.title?.trim() ?? "").length > 0);
3630
+ if (namedNodes.length < 3) {
3631
+ push(
3632
+ "structured-data-extraction",
3633
+ `diagram "${diag.id}" 有名 node 数 ${namedNodes.length} < 3、 structured-data 抽出に必要な最小要素不足`,
3634
+ "warn",
3635
+ );
3636
+ }
3637
+ if (diag.phases.length === 0 && diag.nodes.length > 0) {
3638
+ push(
3639
+ "structured-data-extraction",
3640
+ `diagram "${diag.id}" phase 0、 TechArticle "articleBody" 相当の time-series meta 不足`,
3641
+ "warn",
3642
+ );
3643
+ }
3644
+ }
3645
+
3646
+ // ───────────────────────────────────────────────────────────
3647
+ // Axis 45: diagram-version-semver (diagram.version field の semver 準拠)
3648
+ //
3649
+ // 判定 = CdlDiagram 型に version field は現状無いが、 拡張 diagram (as any) で
3650
+ // 追加している場合の semver 準拠検査。 major.minor.patch 3 数字形式、
3651
+ // pre-release / build-metadata 対応 (SemVer 2.0.0)。
3652
+ // ───────────────────────────────────────────────────────────
3653
+ const anyDiag = diag as unknown as { version?: string };
3654
+ if (typeof anyDiag.version === "string") {
3655
+ const SEMVER_RE =
3656
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
3657
+ if (!SEMVER_RE.test(anyDiag.version)) {
3658
+ push(
3659
+ "diagram-version-semver",
3660
+ `diagram "${diag.id}" version "${anyDiag.version}" が SemVer 2.0.0 準拠でない`,
3661
+ "warn",
3662
+ );
3663
+ }
3664
+ }
3665
+
3666
+ // ───────────────────────────────────────────────────────────
3667
+ // Axis 46: migration-path-consistency (旧 diagram id 参照の残存)
3668
+ //
3669
+ // 判定 = diagram に migratedFrom field (旧 id list) がある場合、 その旧 id が
3670
+ // edge の from / to や row placeholder で参照されていないか (breaking change 未反映) を検査。
3671
+ // ───────────────────────────────────────────────────────────
3672
+ const anyDiagMig = diag as unknown as { migratedFrom?: string[] };
3673
+ if (Array.isArray(anyDiagMig.migratedFrom)) {
3674
+ const oldIds = new Set(anyDiagMig.migratedFrom);
3675
+ for (const e of diag.edges) {
3676
+ if (oldIds.has(e.from)) {
3677
+ push(
3678
+ "migration-path-consistency",
3679
+ `edge "${e.id}" from "${e.from}" が旧 id (migratedFrom 対象)、 新 id へ update 必要`,
3680
+ );
3681
+ }
3682
+ if (oldIds.has(e.to)) {
3683
+ push(
3684
+ "migration-path-consistency",
3685
+ `edge "${e.id}" to "${e.to}" が旧 id (migratedFrom 対象)、 新 id へ update 必要`,
3686
+ );
3687
+ }
3688
+ }
3689
+ for (const n of diag.nodes) {
3690
+ if (!n.rows) continue;
3691
+ for (const row of n.rows) {
3692
+ for (const oid of oldIds) {
3693
+ if (row.includes(`{${oid}}`)) {
3694
+ push(
3695
+ "migration-path-consistency",
3696
+ `node "${n.id}" row "${row}" に旧 id placeholder "{${oid}}" 残存`,
3697
+ );
3698
+ }
3699
+ }
3700
+ }
3701
+ }
3702
+ }
3703
+
3704
+ // Axis 47 / 48 は「全 diagram を横断集計する engine-meta 判定」 で per-diagram 判定不可、
3705
+ // 本 file 末尾の validateMetaAxes() で計算し、 visualValidateAll の sweep 結果に含める。
3706
+
3707
+ // contain=false lane も「水平方向のみ」 検査対象。
3708
+ // swim lane / flow lane は縦方向 (h) は動的なので padTop / padBottom は検査不可、
3709
+ // 水平方向 (padLeft / padRight) のみ node.cx が lane 幅からはみ出したら warn。
3710
+ // spacer (id endsWith "-spacer" or /^s\d+-/) と lifeline (w<8) は intentional 極細 node で
3711
+ // 検査対象外。
3712
+ for (const lane of laid.lanes) {
3713
+ if (lane.contain) continue; // contain=true は上で処理済
3714
+ const laneNodes = laid.nodes.filter(
3715
+ (n) => n.lane === lane.id && n.w >= 8 && !n.id.endsWith("-spacer") && !/^s\d+-/.test(n.id),
3716
+ );
3717
+ for (const n of laneNodes) {
3718
+ const nodeLeft = n.cx - n.w / 2;
3719
+ const nodeRight = n.cx + n.w / 2;
3720
+ const padLeft = nodeLeft - lane.x;
3721
+ const padRight = lane.x + lane.width - nodeRight;
3722
+ const minHorPad = Math.min(padLeft, padRight);
3723
+ // 水平方向 padding が負 = node が lane 幅を左右にはみ出す視覚破綻
3724
+ if (minHorPad < 0) {
3725
+ push(
3726
+ "group-boundary-clearance",
3727
+ `lane "${lane.id}" 内 node "${n.id}" が lane 幅から水平方向に ${Math.abs(minHorPad).toFixed(0)} world はみ出し`,
3728
+ "warn",
3729
+ );
3730
+ }
3731
+ }
3732
+ }
3733
+
3734
+ // ───────────────────────────────────────────────────────────
3735
+ // Axis 62: detour-slot-distinct (CAR-422 positive-check)
3736
+ //
3737
+ // 判定 = 上方 or 下方 detour path 同士の peak Y (縦方向の極端点) が
3738
+ // DETOUR_SLOT_GAP 以上離れているか。
3739
+ //
3740
+ // detour path 定義 = ≥3 segments を持ち、 かつ start / end の中間点で
3741
+ // Y 軸に「山」 or 「谷」 を持つ path。 山 = peak Y < min(startY, endY)、
3742
+ // 谷 = peak Y > max(startY, endY)。 山 / 谷 は別 slot として棲み分け判定する。
3743
+ //
3744
+ // 同 obstacle 判定 = 以下 2 経路のいずれかで「同一 obstacle を迂回している視覚関係」
3745
+ // と見なす。 その場合 peak Y 差が DETOUR_SLOT_GAP 未満なら「上下重なり」 症状で fail。
3746
+ //
3747
+ // 経路 1 (peak 点) = 2 detour の peak X が ±60 world に収まる。
3748
+ // 経路 2 (crest 区間) = 2 detour の水平 crest 区間が X 方向に
3749
+ // DETOUR_CREST_OVERLAP_MIN world を超えて重なる。
3750
+ //
3751
+ // 経路 2 は経路 1 の穴を埋める。 peak 点は crest の端点 1 個しか見ないため、 peak X を
3752
+ // 60 world 超ずらすだけで crest が同 Y で完全に重なっていても素通りする (dragon #890、
3753
+ // interactive-kpi-dashboard で `side: "left"` が crest y=78 の重なりを残したまま
3754
+ // validator を通過した実例)。 crest を区間として比較すればこの穴が閉じる。
3755
+ //
3756
+ // crest 区間 = peak Y と同じ高さの水平 segment 全て。 最長の 1 本だけを持つと、 同じ高さに
3757
+ // crest が 2 本ある形 (段差を挟んで戻る折れ線) で短い方の重なりを見落とす。 逆に全区間を
3758
+ // min/max で 1 本に丸めると、 離れた 2 本の間の隙間まで重なり扱いになる。 区間の列として
3759
+ // 持ち、 列同士の全組合せで重なりを見る。 水平 segment を持たない detour (斜めの折れ線等)
3760
+ // は peak 点に縮退させ、 経路 1 と同じ判定に落ちる。
3761
+ //
3762
+ // 発火 0 = 通常状態 or detour 不在、 発火 = 複数 detour path が同 obstacle 上で
3763
+ // Y 分離されずに描画される regression 症状。
3764
+ // ───────────────────────────────────────────────────────────
3765
+ {
3766
+ interface DetourInfo {
3767
+ edgeId: string;
3768
+ peakX: number;
3769
+ peakY: number;
3770
+ /** peak Y と同じ高さの水平 crest 区間の列。 水平 segment 不在なら peak 点 1 個に縮退。 */
3771
+ crests: ReadonlyArray<{ minX: number; maxX: number }>;
3772
+ direction: "up" | "down"; // 山 (up) or 谷 (down)
3773
+ }
3774
+ /**
3775
+ * peak Y と同じ高さの水平 crest 区間を全て返す。
3776
+ *
3777
+ * 直線 (L) はそのまま判定するが、 曲線 (Q / C) は端点を結ぶ弦しか分からないため、 制御点も
3778
+ * 同じ高さにある場合だけ「真っ直ぐな水平線」 として扱う。 弦だけを見ると、 端点が同じ高さで
3779
+ * 途中が大きく膨らむ曲線を水平線と誤認する (実測 = `Q 250 300 400 100` の弦は y100 の水平線
3780
+ * に見えるが、 実曲線は中点で y200 まで垂れる)。
3781
+ *
3782
+ * 曲線の X 区間は端点の範囲で近似する。 制御点 X が端点の外にあると曲線は端点より外へ張り出す
3783
+ * (実測 = `Q 1000 100 400 100` は t=0.6 で x=640 まで届く) ため、 張り出した分の重なりは
3784
+ * 見落とす。 端点の範囲は必ず実際の到達範囲の部分集合なので、 見落とす方向にしか外れない
3785
+ * = 重なりを報告した時それは必ず実在する。 実 catalog (442 edge / Q 144 件) に端点も制御点も
3786
+ * 同じ高さの曲線は 0 件で、 張り出しを厳密に求める計算は現状どの図も通らない。
3787
+ */
3788
+ const crestSpans = (
3789
+ segs: ReadonlyArray<{
3790
+ x1: number;
3791
+ y1: number;
3792
+ x2: number;
3793
+ y2: number;
3794
+ cmd: "L" | "Q" | "C";
3795
+ ctrl?: ReadonlyArray<{ x: number; y: number }>;
3796
+ }>,
3797
+ peakY: number,
3798
+ ): Array<{ minX: number; maxX: number }> => {
3799
+ const out: Array<{ minX: number; maxX: number }> = [];
3800
+ for (const s of segs) {
3801
+ if (Math.abs(s.y1 - s.y2) > 1) continue;
3802
+ if (Math.abs(s.y1 - peakY) > 1) continue;
3803
+ if (s.cmd !== "L" && !(s.ctrl ?? []).every((c) => Math.abs(c.y - peakY) <= 1)) continue;
3804
+ out.push({ minX: Math.min(s.x1, s.x2), maxX: Math.max(s.x1, s.x2) });
3805
+ }
3806
+ return out;
3807
+ };
3808
+ const detours: DetourInfo[] = [];
3809
+ for (const e of laid.edges) {
3810
+ const segs = extractPathSegments(e.d);
3811
+ if (segs.length < 3) continue;
3812
+ // 全 point 列 (segments の x1,y1 + 最終 x2,y2) を取得
3813
+ const points: Array<{ x: number; y: number }> = [];
3814
+ points.push({ x: segs[0]!.x1, y: segs[0]!.y1 });
3815
+ for (const s of segs) points.push({ x: s.x2, y: s.y2 });
3816
+ const startY = points[0]!.y;
3817
+ const endY = points[points.length - 1]!.y;
3818
+ // 中間 point の中で Y 極端値を探す (start/end 以外)
3819
+ let peakUp: { x: number; y: number } | null = null; // 上方 detour = 最小 Y
3820
+ let peakDown: { x: number; y: number } | null = null; // 下方 detour = 最大 Y
3821
+ for (let i = 1; i < points.length - 1; i++) {
3822
+ const p = points[i]!;
3823
+ if (p.y < Math.min(startY, endY) - 4) {
3824
+ if (!peakUp || p.y < peakUp.y) peakUp = p;
3825
+ }
3826
+ if (p.y > Math.max(startY, endY) + 4) {
3827
+ if (!peakDown || p.y > peakDown.y) peakDown = p;
3828
+ }
3829
+ }
3830
+ for (const [peak, direction] of [
3831
+ [peakUp, "up"],
3832
+ [peakDown, "down"],
3833
+ ] as const) {
3834
+ if (!peak) continue;
3835
+ const spans = crestSpans(segs, peak.y);
3836
+ detours.push({
3837
+ edgeId: e.id,
3838
+ peakX: peak.x,
3839
+ peakY: peak.y,
3840
+ crests: spans.length > 0 ? spans : [{ minX: peak.x, maxX: peak.x }],
3841
+ direction,
3842
+ });
3843
+ }
3844
+ }
3845
+ // 同方向 pair で「peak X が ±60 world」 or 「crest 区間が重なる」 かつ
3846
+ // peak Y 差 < DETOUR_SLOT_GAP を検知。 1 pair につき 1 件だけ報告する。
3847
+ const OBSTACLE_X_OVERLAP = 60;
3848
+ for (let i = 0; i < detours.length; i++) {
3849
+ const a = detours[i]!;
3850
+ for (let j = i + 1; j < detours.length; j++) {
3851
+ const b = detours[j]!;
3852
+ if (a.direction !== b.direction) continue;
3853
+ if (a.edgeId === b.edgeId) continue;
3854
+ const yDiff = Math.abs(a.peakY - b.peakY);
3855
+ if (yDiff >= DETOUR_SLOT_GAP) continue;
3856
+ const nearPeak = Math.abs(a.peakX - b.peakX) <= OBSTACLE_X_OVERLAP;
3857
+ // crest 区間の列同士を全組合せで比較し、 最も長く重なる組を採る。
3858
+ let overlap = Number.NEGATIVE_INFINITY;
3859
+ for (const ca of a.crests) {
3860
+ for (const cb of b.crests) {
3861
+ const o = Math.min(ca.maxX, cb.maxX) - Math.max(ca.minX, cb.minX);
3862
+ if (o > overlap) overlap = o;
3863
+ }
3864
+ }
3865
+ const crestOverlap = overlap > DETOUR_CREST_OVERLAP_MIN;
3866
+ if (!nearPeak && !crestOverlap) continue;
3867
+ const reason = nearPeak
3868
+ ? `peak X 差 ${Math.abs(a.peakX - b.peakX).toFixed(1)} world`
3869
+ : `crest 区間の重なり ${overlap.toFixed(1)} world`;
3870
+ push(
3871
+ "detour-slot-distinct",
3872
+ `edge "${a.edgeId}" と "${b.edgeId}" が同 obstacle を ${a.direction === "up" ? "上方" : "下方"} detour (${reason})、 peak Y 差 ${yDiff.toFixed(1)} world が spec ${DETOUR_SLOT_GAP} 未満 (path 重なり)`,
3873
+ );
3874
+ }
3875
+ }
3876
+ }
3877
+
3878
+ // ───────────────────────────────────────────────────────────
3879
+ // Axis 63: arrow-endpoint-center (CAR-422 positive-check)
3880
+ //
3881
+ // 判定 = arrow 終点 が終点 node の toSide 辺中央 (±ARROW_ENDPOINT_CENTER_TOL) に
3882
+ // 収束しているか。
3883
+ //
3884
+ // Axis 8 (arrow-endpoint-anchoring) は「node bbox 縁に沿っているか / 内側 or 遠すぎ」
3885
+ // を判定するが、 本 axis は「その側の辺の 4 隅 (角) に近接していないか」 を判定する。
3886
+ // 4 隅 に矢頭 が刺さると 2 辺 のどちら由来か視覚判別困難、 中央着地が正しい。
3887
+ //
3888
+ // 対象 = 終点 (edge path 最終 point) のみ、 始点は fan-out で意図的に分散する場合が
3889
+ // あるため対象外 (fan-origin-single-point axis で別軸判定)。
3890
+ // ───────────────────────────────────────────────────────────
3891
+ {
3892
+ for (const e of laid.edges) {
3893
+ const segs = extractPathSegments(e.d);
3894
+ if (segs.length === 0) continue;
3895
+ const toNode = nodeById.get(e.to);
3896
+ if (!toNode) continue;
3897
+ const end = { x: segs[segs.length - 1]!.x2, y: segs[segs.length - 1]!.y2 };
3898
+ // toSide 辺中央を計算
3899
+ let sideCenterX = toNode.cx;
3900
+ let sideCenterY = toNode.cy;
3901
+ switch (e.toSide) {
3902
+ case "top":
3903
+ sideCenterY = toNode.cy - toNode.h / 2;
3904
+ break;
3905
+ case "bottom":
3906
+ sideCenterY = toNode.cy + toNode.h / 2;
3907
+ break;
3908
+ case "left":
3909
+ sideCenterX = toNode.cx - toNode.w / 2;
3910
+ break;
3911
+ case "right":
3912
+ sideCenterX = toNode.cx + toNode.w / 2;
3913
+ break;
3914
+ }
3915
+ // toSide が horizontal 側 (left/right) = Y 距離、 vertical 側 (top/bottom) = X 距離で判定
3916
+ const isHorizontalSide = e.toSide === "left" || e.toSide === "right";
3917
+ const centerDist = isHorizontalSide
3918
+ ? Math.abs(end.y - sideCenterY)
3919
+ : Math.abs(end.x - sideCenterX);
3920
+ // 辺の半分 - tolerance = 4 隅 判定閾値
3921
+ const halfSide = isHorizontalSide ? toNode.h / 2 : toNode.w / 2;
3922
+ const cornerZone = halfSide - ARROW_ENDPOINT_CENTER_TOL;
3923
+ if (centerDist > cornerZone && cornerZone > 0) {
3924
+ push(
3925
+ "arrow-endpoint-center",
3926
+ `edge "${e.id}" 終点 (${end.x.toFixed(0)},${end.y.toFixed(0)}) が node "${e.to}" toSide=${e.toSide} 辺中央 (${sideCenterX.toFixed(0)},${sideCenterY.toFixed(0)}) から ${centerDist.toFixed(1)} world 離れ、 4 隅 zone 判定 (許容 ${cornerZone.toFixed(1)})`,
3927
+ );
3928
+ }
3929
+ }
3930
+ }
3931
+
3932
+ // ───────────────────────────────────────────────────────────
3933
+ // Axis 64: lane-border-clearance (CAR-422 positive-check)
3934
+ //
3935
+ // 判定 = edge-label bbox の左右端が「その label 所属 edge が発着する lane 以外」 の
3936
+ // lane border (lane.x / lane.x+width) を貫通していないか。
3937
+ //
3938
+ // edge-label が lane 境界線と交差すると label が「別 lane に侵入している」 ように
3939
+ // 見えて論理誤読を招く。 発着 lane 以外の lane border と bbox が overlap する場合、
3940
+ // LANE_BORDER_CLEARANCE_TOL の余裕がなければ fail。
3941
+ //
3942
+ // 例外 = edge 発着 node の lane (from lane / to lane) 内は判定対象外、 その 2 lane 内なら
3943
+ // label が lane 縁近くにあっても違和感なし。
3944
+ // ───────────────────────────────────────────────────────────
3945
+ {
3946
+ for (const e of laid.edges) {
3947
+ if (!hasRenderedLabel(e)) continue;
3948
+ const fromNode = nodeById.get(e.from);
3949
+ const toNode = nodeById.get(e.to);
3950
+ // from / to どちらも解決不能な edge は「所属 lane 不明」 で判定対象外
3951
+ // (dangling edge = 通常 validate 側で検知される構造破綻、 本 axis で二重発火しない)
3952
+ if (!fromNode && !toNode) continue;
3953
+ const allowedLanes = new Set<string>();
3954
+ if (fromNode) allowedLanes.add(fromNode.lane);
3955
+ if (toNode) allowedLanes.add(toNode.lane);
3956
+ // label bbox 復元 (Axis 59 と同 logic、 lane border 判定は x 軸のみ使うので高さ不要)
3957
+ const boxW = computeLabelBoxW(e.label, e.sub);
3958
+ const anchor = e.labelAnchor;
3959
+ const boxLeftX = anchor === "start" ? 0 : anchor === "end" ? -boxW : -boxW / 2;
3960
+ const bx = e.labelX + boxLeftX;
3961
+ const bxRight = bx + boxW;
3962
+ for (const lane of laid.lanes) {
3963
+ if (allowedLanes.has(lane.id)) continue;
3964
+ const laneLeft = lane.x;
3965
+ const laneRight = lane.x + lane.width;
3966
+ // border 貫通 = bbox が border 線と交差 (border 左端 と bbox 左/右端 の関係)
3967
+ // 左 border 貫通 = bx < laneLeft && bxRight > laneLeft + TOL
3968
+ // 右 border 貫通 = bx < laneRight - TOL && bxRight > laneRight
3969
+ const crossesLeft =
3970
+ bx < laneLeft - LANE_BORDER_CLEARANCE_TOL &&
3971
+ bxRight > laneLeft + LANE_BORDER_CLEARANCE_TOL;
3972
+ const crossesRight =
3973
+ bx < laneRight - LANE_BORDER_CLEARANCE_TOL &&
3974
+ bxRight > laneRight + LANE_BORDER_CLEARANCE_TOL;
3975
+ if (crossesLeft || crossesRight) {
3976
+ push(
3977
+ "lane-border-clearance",
3978
+ `edge "${e.id}" label bbox (${bx.toFixed(0)}..${bxRight.toFixed(0)}) が非発着 lane "${lane.id}" (${laneLeft.toFixed(0)}..${laneRight.toFixed(0)}) の ${crossesLeft ? "左" : "右"} border を貫通`,
3979
+ );
3980
+ break; // 1 edge 1 発火に留める
3981
+ }
3982
+ }
3983
+ }
3984
+ }
3985
+
3986
+ // ───────────────────────────────────────────────────────────
3987
+ // Axis 65: row-gap-uniform (CAR-422 positive-check)
3988
+ //
3989
+ // 判定 = 同 stack index を持つ node 群を cx 昇順に並べたとき、 隣接 node cx 差
3990
+ // (= gap) の max - min が ROW_GAP_VARIANCE_TOL 以下か。
3991
+ //
3992
+ // Axis 57 (row-alignment) は cy 揃いを見るが、 本 axis は「横方向 gap の均一性」 を
3993
+ // 見る positive-check。 gap が片寄っている layout は「意図的分割」 or 「engine layout
3994
+ // 破綻」 のどちらかで、 通常 diagram は前者が発生しない前提で発火 = 後者検知。
3995
+ //
3996
+ // 対象 = 同 stack index 内 3 node 以上 (2 node なら gap 1 個で variance 概念なし)。
3997
+ // spacer / lifeline は cx 差を意図的に持つので判定対象外。
3998
+ // ───────────────────────────────────────────────────────────
3999
+ {
4000
+ const byStack = new Map<number, LaidDiagram["nodes"]>();
4001
+ for (const n of laid.nodes) {
4002
+ if (n.id.endsWith("-spacer") || /^s\d+-/.test(n.id)) continue;
4003
+ const list = byStack.get(n.stack) ?? [];
4004
+ list.push(n);
4005
+ byStack.set(n.stack, list);
4006
+ }
4007
+ // lane diagram では node.cx が lane 中心に固定されるため、 node が同じ lane 集合に載る
4008
+ // stack はすべて同一の cx 列を持つ。 stack ごとに報告すると同じ違反が stack 数だけ
4009
+ // 重複計上されるので、 cx 列が既出なら 1 度目のみ報告する。
4010
+ const reportedCxSignatures = new Set<string>();
4011
+ for (const [stack, group] of byStack) {
4012
+ if (group.length < 3) continue;
4013
+ const sorted = [...group].sort((a, b) => a.cx - b.cx);
4014
+ const gaps: number[] = [];
4015
+ for (let i = 1; i < sorted.length; i++) {
4016
+ gaps.push(sorted[i]!.cx - sorted[i - 1]!.cx);
4017
+ }
4018
+ const maxGap = Math.max(...gaps);
4019
+ const minGap = Math.min(...gaps);
4020
+ const variance = maxGap - minGap;
4021
+ if (variance > ROW_GAP_VARIANCE_TOL) {
4022
+ const cxSignature = sorted.map((n) => n.cx.toFixed(3)).join(",");
4023
+ if (reportedCxSignatures.has(cxSignature)) continue;
4024
+ reportedCxSignatures.add(cxSignature);
4025
+ push(
4026
+ "row-gap-uniform",
4027
+ `stack=${stack} 内 node 間 gap variance ${variance.toFixed(1)} world が spec ${ROW_GAP_VARIANCE_TOL} 超過 (min=${minGap.toFixed(0)} max=${maxGap.toFixed(0)})`,
4028
+ );
4029
+ }
4030
+ }
4031
+ }
4032
+
4033
+ // ───────────────────────────────────────────────────────────
4034
+ // Axis 66: column-gap-uniform (CAR-422 positive-check)
4035
+ //
4036
+ // 判定 = 同 lane 内 node 群を cy 昇順に並べたとき、 隣接 node の端間 gap
4037
+ // (下の node の上端 - 上の node の下端) の max - min が COLUMN_GAP_VARIANCE_TOL 以下か。
4038
+ //
4039
+ // Axis 58 (column-alignment) は cx 揃いを見るが、 本 axis は「縦方向 gap の均一性」 を
4040
+ // 見る positive-check。 縦積み layout で gap が不均等なら engine layout 破綻疑い。
4041
+ //
4042
+ // 端間 gap で測る理由 (Issue #202)。
4043
+ // 初版は cy 差 (中心間距離) を gap として扱っていた。 しかし node 高さは kind ごとに
4044
+ // 異なるため、 中心間距離は端間 gap が完全に均一でも node 高さ差の分だけばらつく。
4045
+ // 実測 = scene-nft-mint (node 高さ 220 / 280 / 320) で端間 variance 0、 中心間 variance 50。
4046
+ // engine が制御しているのは端間 gap であり、 検査もそれに合わせる。
4047
+ //
4048
+ // 対象 = 同 lane 内 3 node 以上。 spacer / lifeline は cy 差を意図的に持つので対象外。
4049
+ // ───────────────────────────────────────────────────────────
4050
+ {
4051
+ const byLane = new Map<string, LaidDiagram["nodes"]>();
4052
+ for (const n of laid.nodes) {
4053
+ if (n.id.endsWith("-spacer") || /^s\d+-/.test(n.id)) continue;
4054
+ const list = byLane.get(n.lane) ?? [];
4055
+ list.push(n);
4056
+ byLane.set(n.lane, list);
4057
+ }
4058
+ for (const [laneId, group] of byLane) {
4059
+ if (group.length < 3) continue;
4060
+ const sorted = [...group].sort((a, b) => a.cy - b.cy);
4061
+ const gaps: number[] = [];
4062
+ for (let i = 1; i < sorted.length; i++) {
4063
+ const prev = sorted[i - 1]!;
4064
+ const cur = sorted[i]!;
4065
+ gaps.push(cur.cy - cur.h / 2 - (prev.cy + prev.h / 2));
4066
+ }
4067
+ const maxGap = Math.max(...gaps);
4068
+ const minGap = Math.min(...gaps);
4069
+ const variance = maxGap - minGap;
4070
+ if (variance > COLUMN_GAP_VARIANCE_TOL) {
4071
+ push(
4072
+ "column-gap-uniform",
4073
+ `lane "${laneId}" 内 node 間の端間 gap variance ${variance.toFixed(1)} world が spec ${COLUMN_GAP_VARIANCE_TOL} 超過 (min=${minGap.toFixed(0)} max=${maxGap.toFixed(0)})`,
4074
+ );
4075
+ }
4076
+ }
4077
+ }
4078
+
4079
+ return {
4080
+ diagramId: diag.id,
4081
+ ok: violations.filter((v) => v.severity === "error").length === 0,
4082
+ violations,
4083
+ counts,
4084
+ profile,
4085
+ skippedAxes,
4086
+ };
4087
+ }
4088
+
4089
+ /**
4090
+ * 複数 diagram を sweep して合計 report を返す。
4091
+ */
4092
+ export interface SweepReport {
4093
+ total: number;
4094
+ pass: number;
4095
+ fail: number;
4096
+ reports: VisualValidationReport[];
4097
+ totalCounts: Record<VisualAxis, number>;
4098
+ /** Axis 47 / 48 meta 判定結果 (per-diagram でなく engine 全体) */
4099
+ metaViolations: Violation[];
4100
+ /** 検査した用途。 */
4101
+ profile: ValidationProfile;
4102
+ /** 用途によって見なかった軸。 `totalCounts` はこれらも 0 を返す。 */
4103
+ skippedAxes: VisualAxis[];
4104
+ }
4105
+
4106
+ export function visualValidateAll(diagrams: CdlDiagram[], opts?: ValidateOptions): SweepReport {
4107
+ // Axis 52 用 = 各 diagram の validate 実行時間 (ms) を計測。 performance budget 超過検知。
4108
+ const durations: number[] = [];
4109
+ const reports = diagrams.map((d) => {
4110
+ const start = Date.now();
4111
+ const r = visualValidate(d, opts);
4112
+ durations.push(Date.now() - start);
4113
+ return r;
4114
+ });
4115
+ const fail = reports.filter((r) => !r.ok).length;
4116
+ const totalCounts = emptyCounts();
4117
+ for (const r of reports) {
4118
+ for (const axis of Object.keys(r.counts) as VisualAxis[]) {
4119
+ totalCounts[axis] += r.counts[axis];
4120
+ }
4121
+ }
4122
+
4123
+ // Axis 47/48/49/50/52 = engine meta 判定
4124
+ const metaViolations = validateMetaAxes(totalCounts, diagrams, durations, reports);
4125
+
4126
+ return {
4127
+ total: reports.length,
4128
+ pass: reports.length - fail,
4129
+ fail,
4130
+ reports,
4131
+ totalCounts,
4132
+ profile: opts?.profile ?? "production",
4133
+ skippedAxes: [...SKIPPED_AXES[opts?.profile ?? "production"]],
4134
+ metaViolations,
4135
+ };
4136
+ }
4137
+
4138
+ /**
4139
+ * Axis 47 axis-coverage-meta + Axis 48 axis-documentation-completeness の meta 判定。
4140
+ *
4141
+ * 47: catalog 全 diagram 走査後の totalCounts で「発火経路が 0 の axis」 = dead axis 検知。
4142
+ * dead axis = catalog に該当 diagram が無い or 判定 logic の bug、 gate として実効性なし。
4143
+ * ただし svg-injection-safety / migration-path-consistency 等 catalog に自然発火なしの
4144
+ * security / migration 系は dead 許容 (allowlist)。
4145
+ *
4146
+ * 48: 手で並べた `allAxes` の要素数が `EXPECTED_AXIS_COUNT` と一致するかだけを見る。
4147
+ * 元は「全 axis が『Axis N: <name>』 コメントブロックを持つか」 を狙った軸だが、 実行時に
4148
+ * source を読めない (build 後は minified) ため、 実装は要素数の一致に留まる。
4149
+ * `VisualAxis` / `emptyCounts` / `allAxes` の 3 つが揃っているかは見ない (#381)。
4150
+ */
4151
+ function validateMetaAxes(
4152
+ totalCounts: Record<VisualAxis, number>,
4153
+ diagrams: CdlDiagram[] = [],
4154
+ durations: number[] = [],
4155
+ reports: VisualValidationReport[] = [],
4156
+ ): Violation[] {
4157
+ const out: Violation[] = [];
4158
+
4159
+ // ─── Axis 49: fixture-drift-detection (catalog 全体の error / warn 分布 baseline drift) ───
4160
+ // 判定 = 現状 catalog は全 axis error 0 が期待、 error > 0 なら drift。
4161
+ // 過去にあった warn は許容、 error は絶対 0 が SSOT。
4162
+ const totalErrors = reports.reduce(
4163
+ (sum, r) => sum + r.violations.filter((v) => v.severity === "error").length,
4164
+ 0,
4165
+ );
4166
+ const KNOWN_ERROR_BUDGET = 1; // pattern-passthrough / pattern-hook の edge-node-cross 1 件を allowlist
4167
+ if (totalErrors > KNOWN_ERROR_BUDGET) {
4168
+ out.push({
4169
+ axis: "fixture-drift-detection",
4170
+ diagramId: "__meta__",
4171
+ detail: `catalog 全 diagram error 総数 ${totalErrors} > KNOWN_ERROR_BUDGET ${KNOWN_ERROR_BUDGET}、 baseline drift`,
4172
+ severity: "warn",
4173
+ });
4174
+ }
4175
+
4176
+ // ─── Axis 50: locale-parity (JA/EN docs sample 数 parity は per-engine 判定不可) ───
4177
+ // 判定 = engine 内では diagram の locale field 有無を確認するのみ。 実際の JA/EN sample
4178
+ // 数 parity は dragon side docs-samples-validate.test.ts が担当。
4179
+ // 本 axis は 空 (engine 単体で判定材料なし)、 発火経路として先取り。
4180
+
4181
+ // ─── Axis 52: validate-performance-budget (validate 実行時間) ───
4182
+ const PERF_BUDGET_PER_DIAGRAM_MS = 100;
4183
+ const PERF_BUDGET_TOTAL_MS = 5000;
4184
+ const overBudgetIdx = durations.findIndex((d) => d > PERF_BUDGET_PER_DIAGRAM_MS);
4185
+ if (overBudgetIdx >= 0) {
4186
+ const did = diagrams[overBudgetIdx]?.id ?? "?";
4187
+ out.push({
4188
+ axis: "validate-performance-budget",
4189
+ diagramId: did,
4190
+ detail: `diagram "${did}" validate ${durations[overBudgetIdx]}ms > ${PERF_BUDGET_PER_DIAGRAM_MS}ms budget`,
4191
+ severity: "warn",
4192
+ });
4193
+ }
4194
+ const totalDur = durations.reduce((s, d) => s + d, 0);
4195
+ if (totalDur > PERF_BUDGET_TOTAL_MS) {
4196
+ out.push({
4197
+ axis: "validate-performance-budget",
4198
+ diagramId: "__meta__",
4199
+ detail: `visualValidateAll 全 diagram 累計 ${totalDur}ms > ${PERF_BUDGET_TOTAL_MS}ms budget`,
4200
+ severity: "warn",
4201
+ });
4202
+ }
4203
+
4204
+ // Axis 47: dead axis 検知
4205
+ // security / migration 系は自然発火なしを許容 (実データに XSS / migration 混入は稀)
4206
+ const DEAD_ALLOWLIST: VisualAxis[] = [
4207
+ // catalog で自然発火なしを許容する security / migration / performance / meta / dev-quality axis 群。
4208
+ // 実 diagram に該当データ (XSS / migration list / oversized / broken doc) が混入するのが稀なため。
4209
+ "node-visibility",
4210
+ "edge-label-overlap",
4211
+ "edge-label-proximity",
4212
+ "text-readability",
4213
+ "row-format",
4214
+ "alignment",
4215
+ "clearance",
4216
+ "arrow-endpoint-anchoring",
4217
+ "label-char-range",
4218
+ "node-overlap",
4219
+ "edge-crossing",
4220
+ "edge-node-cross",
4221
+ "edge-segment-orthogonality",
4222
+ "label-inside-viewbox",
4223
+ "lane-cx-consistency",
4224
+ "row-vertical-spacing",
4225
+ // 行を描かない種別に rows を書く形は稀。 正常な catalog では 0 件が期待値。
4226
+ "rows-not-rendered",
4227
+ // 正常な catalog は layout の出力をそのまま渡すので 0 件が期待値。 発火するのは
4228
+ // 型どおりでない入力を作った時 (test の変異 等)。
4229
+ "malformed-input",
4230
+ // 正常な入力かつ検査側に欠陥が無ければ 0 件。
4231
+ "validation-interrupted",
4232
+ "group-boundary-clearance",
4233
+ "node-vertical-clearance",
4234
+ "lane-lane-gap",
4235
+ "arrow-marker-clearance",
4236
+ "grid-alignment",
4237
+ "phase-layout-stability",
4238
+ "responsive-viewport",
4239
+ "accessibility-basics",
4240
+ "animation-frame-integrity",
4241
+ "i18n-cjk-detection",
4242
+ "contrast-basics",
4243
+ "print-media-compat",
4244
+ "color-blind-safety",
4245
+ "marker-gradient-def-integrity",
4246
+ "subpixel-precision",
4247
+ "dom-complexity-budget",
4248
+ "reduced-motion-compat",
4249
+ "touch-target-size",
4250
+ "row-content-typing",
4251
+ "terminal-safe-text",
4252
+ "gpu-layer-efficiency",
4253
+ "memory-budget",
4254
+ "svg-injection-safety",
4255
+ "seo-metadata-quality",
4256
+ "bidi-hyphenation",
4257
+ "structured-data-extraction",
4258
+ "diagram-version-semver",
4259
+ "migration-path-consistency",
4260
+ "axis-coverage-meta",
4261
+ "axis-documentation-completeness",
4262
+ "fixture-drift-detection",
4263
+ "locale-parity",
4264
+ "validate-performance-budget",
4265
+ "node-inside-viewbox",
4266
+ "node-inside-lane",
4267
+ "edge-inside-viewbox",
4268
+ "lane-label-inside-viewbox",
4269
+ "lane-label-overlap",
4270
+ // CAR-421 positive-check axis 群 = catalog で「常に pass」 が正常状態、 発火 = 実データ異常。
4271
+ // dead axis 判定は「発火 0 が異常 (= dead)」 の logic だが、 本 5 axis は発火 0 = 正常なので allowlist。
4272
+ "row-alignment",
4273
+ "column-alignment",
4274
+ "edge-stubout-min",
4275
+ "fan-origin-single-point",
4276
+ // CAR-422 positive-check axis 群 = 同上、 catalog で発火 0 が正常。
4277
+ "detour-slot-distinct",
4278
+ "arrow-endpoint-center",
4279
+ "lane-border-clearance",
4280
+ "row-gap-uniform",
4281
+ "column-gap-uniform",
4282
+ ];
4283
+ const allAxes: VisualAxis[] = [
4284
+ "node-visibility",
4285
+ "edge-label-overlap",
4286
+ "edge-label-proximity",
4287
+ "text-readability",
4288
+ "row-format",
4289
+ "alignment",
4290
+ "clearance",
4291
+ "arrow-endpoint-anchoring",
4292
+ "label-char-range",
4293
+ "node-overlap",
4294
+ "edge-crossing",
4295
+ "edge-node-cross",
4296
+ "edge-segment-orthogonality",
4297
+ "label-inside-viewbox",
4298
+ "lane-cx-consistency",
4299
+ "row-vertical-spacing",
4300
+ "group-boundary-clearance",
4301
+ "node-vertical-clearance",
4302
+ "lane-lane-gap",
4303
+ "arrow-marker-clearance",
4304
+ "grid-alignment",
4305
+ "phase-layout-stability",
4306
+ "responsive-viewport",
4307
+ "accessibility-basics",
4308
+ "animation-frame-integrity",
4309
+ "i18n-cjk-detection",
4310
+ "contrast-basics",
4311
+ "print-media-compat",
4312
+ "color-blind-safety",
4313
+ "marker-gradient-def-integrity",
4314
+ "subpixel-precision",
4315
+ "dom-complexity-budget",
4316
+ "reduced-motion-compat",
4317
+ "touch-target-size",
4318
+ "row-content-typing",
4319
+ "terminal-safe-text",
4320
+ "gpu-layer-efficiency",
4321
+ "memory-budget",
4322
+ "svg-injection-safety",
4323
+ "seo-metadata-quality",
4324
+ "bidi-hyphenation",
4325
+ "structured-data-extraction",
4326
+ "diagram-version-semver",
4327
+ "migration-path-consistency",
4328
+ "axis-coverage-meta",
4329
+ "axis-documentation-completeness",
4330
+ "fixture-drift-detection",
4331
+ "locale-parity",
4332
+ "validate-performance-budget",
4333
+ "node-inside-viewbox",
4334
+ "node-inside-lane",
4335
+ "edge-inside-viewbox",
4336
+ "lane-label-inside-viewbox",
4337
+ "lane-label-overlap",
4338
+ "row-alignment",
4339
+ "column-alignment",
4340
+ "edge-stubout-min",
4341
+ "fan-origin-single-point",
4342
+ "detour-slot-distinct",
4343
+ "arrow-endpoint-center",
4344
+ "lane-border-clearance",
4345
+ "row-gap-uniform",
4346
+ "column-gap-uniform",
4347
+ "rows-not-rendered",
4348
+ "malformed-input",
4349
+ "validation-interrupted",
4350
+ ];
4351
+ for (const axis of allAxes) {
4352
+ if (totalCounts[axis] === 0 && !DEAD_ALLOWLIST.includes(axis)) {
4353
+ out.push({
4354
+ axis: "axis-coverage-meta",
4355
+ diagramId: "__meta__",
4356
+ detail: `axis "${axis}" は catalog 全 diagram で発火 0 = dead axis 疑い`,
4357
+ severity: "warn",
4358
+ });
4359
+ }
4360
+ }
4361
+
4362
+ // Axis 48: 手で並べた `allAxes` の要素数が期待値と一致するか。
4363
+ // `VisualAxis` 型との網羅性や重複は見ない = `allAxes` への追記漏れは捕まらない (#381)。
4364
+ //
4365
+ // **数は定数側 (`EXPECTED_AXIS_COUNT`) が正で、 この comment に書き写さない**。
4366
+ // 以前は comment に 69、 定数に 68 と別の数が書かれており、 どちらが正か読めなかった。
4367
+ //
4368
+ // 削除済 = engine-meta 51 (mermaid-parity、 #366) / 軸 29 (svg-filter-integrity) /
4369
+ // 軸 30 (neumorphism-shadow-budget、 どちらも #426 で主題の廃止に伴い削除)。
4370
+ const EXPECTED_AXIS_COUNT = 66;
4371
+ if (allAxes.length !== EXPECTED_AXIS_COUNT) {
4372
+ out.push({
4373
+ axis: "axis-documentation-completeness",
4374
+ diagramId: "__meta__",
4375
+ detail: `期待 axis 数 ${EXPECTED_AXIS_COUNT} vs 実装 ${allAxes.length} で不一致、 SSOT 文書化 drift`,
4376
+ severity: "warn",
4377
+ });
4378
+ }
4379
+
4380
+ return out;
4381
+ }