@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,190 @@
1
+ import { requiredRowsWidth } from "../kinds/row-align";
2
+ import type { CdlDiagram, LaidLane, LaidNode } from "../types";
3
+ import {
4
+ hasRenderedLabel,
5
+ labelPillH,
6
+ requiredVerticalLabelGap,
7
+ requiredRowsHeight,
8
+ } from "./spec";
9
+ import { DEFAULT_NODE_GAP, HEADER_RESERVE, LANE_TOP_PAD, NODE_SIZE, STACK_GAP } from "./tokens";
10
+
11
+ /**
12
+ * kind=storage で rows[] を持つ node の自動 h を計算する。
13
+ *
14
+ * rows 数に応じて h が足りないと最後の rows が node 枠外にはみ出す (ER preset User entity 等)。
15
+ * 最終行の baseline (`rowBaselineY`、 描画側の式の SSOT) に下余白を足した値を default h と
16
+ * max() して必要 h を確保する。
17
+ *
18
+ * 本関数が扱うのは `storage` だけ。 `GenericNode` 側の 21 種も rows を描くが、 そちらは
19
+ * 自動で h を伸ばさない (著者が指定した h をそのまま使う)。 行が枠外に出ていないかは
20
+ * `row-vertical-spacing` (軸 16) が同じ `rowBaselineY` を見て検査する。
21
+ */
22
+ function autoStorageHeight(n: { kind: string; rows?: string[]; h?: number }, defaultH: number): number {
23
+ if (n.kind !== "storage") return n.h ?? defaultH;
24
+ if (n.h != null) return n.h;
25
+ // rows なし storage は title + divider のみで render されるため、 default h=240 では空白過大。
26
+ // title block (130px) + bottom padding (20px) = 150px に縮める。
27
+ if (!n.rows || n.rows.length === 0) return 150;
28
+ // 元の式 `130 + rows * 56 + 20` と同値。 式の SSOT は `requiredRowsHeight` (spec.ts)。
29
+ return Math.max(defaultH, requiredRowsHeight("storage", n.rows.length) ?? defaultH);
30
+ }
31
+
32
+ /**
33
+ * rows[] を持つ node (kind=storage 主、 generic 系も同 logic) の必要 width を rows 内容から算出。
34
+ *
35
+ * 旧実装は w 固定 (token NODE_SIZE.storage.w=400) で、 長い row (例 "charlie: 999999")
36
+ * が node 枠右端を割って詰まって見える問題があった。 rows の左 / 右 列 max 幅を char width 換算で
37
+ * 合計し、 default w と max() で確保する。 結果として lane 側 expandLanesForNodes も連動拡張、
38
+ * label 侵食を防ぐ。
39
+ *
40
+ * 著者が n.w を明示している場合は尊重 (snapshot 互換 / catalog thumbnail 縮小指定を壊さない)。
41
+ */
42
+ function autoRowsWidth(n: { kind: string; rows?: string[]; w?: number }, defaultW: number): number {
43
+ if (n.w != null) return n.w;
44
+ if (!n.rows || n.rows.length === 0) return defaultW;
45
+ const required = requiredRowsWidth(n.rows, n.kind);
46
+ return Math.max(defaultW, required);
47
+ }
48
+
49
+ /**
50
+ * 同 lane 内で隣り合う row を繋ぐ edge の label が収まる stack gap を求める (Issue #202)。
51
+ *
52
+ * 横方向は `expandLaneGapsForEdgeLabels` (CAR-470) が lane 間隔を
53
+ * `labelBoxW + CLEARANCE_NODE_LABEL * 2 + EDGE_STUB_OUT * 2` で先手拡張する。
54
+ * 縦方向にはこれに対応する経路が無く、 STACK_GAP の固定値しか無かった。
55
+ *
56
+ * 必要な隙間は `requiredVerticalLabelGap` (spec.ts SSOT)。 同じ隙間を複数の edge が通る場合
57
+ * (parallel edge) は label が縦に積まれるので、 その帯の高さを使う。
58
+ *
59
+ * 実測。
60
+ * - sub 付き label (pill 高 68) は 132 を要するが STACK_GAP は 100 (topology の TCP 5432)
61
+ * - parallel 2 本 (pill 高 36 x 2) は 172 を要する (pattern-call-rw の read / write)
62
+ *
63
+ * 「隣り合う row」 の判定は stack 番号の差ではなく、 diagram 全体で実在する stack 列
64
+ * (`allStacks`) 上の隣接で行う。 stack 0 と 2 しか存在しない lane では両者の間に row が無く、
65
+ * その隙間に label が置かれるため (Codex review 指摘、 stack 番号の差で判定すると見落とす)。
66
+ *
67
+ * 全 stack gap を同じ値に揃える。 lane 内の縦間隔が不揃いになると visualValidate の
68
+ * column-gap-uniform が発火するため (lane pitch を全対で揃えるのと同じ理由)。
69
+ *
70
+ * @param diag layout 前の diagram
71
+ * @param allStacks diagram 全体で実在する stack 番号の昇順列
72
+ * @returns 要求 stack gap (world unit)。 縦 edge に label が無ければ 0
73
+ */
74
+ function requiredStackGapForEdgeLabels(diag: CdlDiagram, allStacks: readonly number[]): number {
75
+ const byId = new Map(diag.nodes.map((n) => [n.id, n]));
76
+ const rankOf = new Map(allStacks.map((stack, i) => [stack, i]));
77
+
78
+ // 同じ隙間 (lane + 上側 row の rank) を通る label の pill 高を集める。
79
+ const pillHsByGap = new Map<string, number[]>();
80
+ for (const e of diag.edges) {
81
+ // `label` が空でも `sub` があれば pill は描かれ、 その高さ (68) の分だけ隙間が要る (Issue #224)。
82
+ if (!hasRenderedLabel(e)) continue;
83
+ const from = byId.get(e.from);
84
+ const to = byId.get(e.to);
85
+ if (!from || !to) continue;
86
+ if (from.lane !== to.lane) continue;
87
+ const fromRank = rankOf.get(from.stack);
88
+ const toRank = rankOf.get(to.stack);
89
+ if (fromRank === undefined || toRank === undefined) continue;
90
+ if (Math.abs(fromRank - toRank) !== 1) continue;
91
+ const key = `${from.lane}::${Math.min(fromRank, toRank)}`;
92
+ pillHsByGap.set(key, [...(pillHsByGap.get(key) ?? []), labelPillH(e.sub)]);
93
+ }
94
+
95
+ let required = 0;
96
+ for (const pillHs of pillHsByGap.values()) {
97
+ required = Math.max(required, requiredVerticalLabelGap(pillHs));
98
+ }
99
+ return required;
100
+ }
101
+
102
+ export function layoutNodes(diag: CdlDiagram, lanes: LaidLane[]): LaidNode[] {
103
+ const laneMap = new Map(lanes.map((l) => [l.id, l]));
104
+
105
+ // 全 lane を跨いで stack 番号で row を共有する。
106
+ // 同 stack 番号の node は全 lane で同じ y 中心になり、 lane 間 edge が水平で揃う。
107
+ // 各 row の h は、 その row に存在する全 lane の node h の最大値。
108
+ const allStacks = Array.from(new Set(diag.nodes.map((n) => n.stack))).sort((a, b) => a - b);
109
+ const rowH = new Map<number, number>();
110
+ for (const stack of allStacks) {
111
+ const nodesAtStack = diag.nodes.filter((n) => n.stack === stack);
112
+ const h = Math.max(...nodesAtStack.map((n) => autoStorageHeight(n, NODE_SIZE[n.kind].h)));
113
+ rowH.set(stack, h);
114
+ }
115
+ const rowCy = new Map<number, number>();
116
+ // nodeGap = author 追加 padding (DEFAULT_NODE_GAP=24)、 STACK_GAP=100 (edge label + 余白の base) に加算。
117
+ // viewport.nodeGap 明示時はその差分を加算、 未指定なら viewport.gap → DEFAULT_NODE_GAP の順で fallback。
118
+ const nodeGapExtra =
119
+ (diag.viewport?.nodeGap ?? diag.viewport?.gap ?? DEFAULT_NODE_GAP) - DEFAULT_NODE_GAP;
120
+ const stackGap = Math.max(STACK_GAP + nodeGapExtra, requiredStackGapForEdgeLabels(diag, allStacks));
121
+ let cursor = (lanes[0]?.y ?? HEADER_RESERVE) + LANE_TOP_PAD;
122
+ for (const stack of allStacks) {
123
+ const h = rowH.get(stack)!;
124
+ const cy = cursor + h / 2;
125
+ rowCy.set(stack, cy);
126
+ cursor = cy + h / 2 + stackGap;
127
+ }
128
+
129
+ // 各 lane 内で stack 番号重複チェック
130
+ const byLane = new Map<string, typeof diag.nodes>();
131
+ for (const n of diag.nodes) {
132
+ const list = byLane.get(n.lane) ?? [];
133
+ list.push(n);
134
+ byLane.set(n.lane, list);
135
+ }
136
+ for (const [laneId, list] of byLane) {
137
+ const seen = new Set<number>();
138
+ for (const n of list) {
139
+ if (seen.has(n.stack)) {
140
+ throw new Error(
141
+ `cdl layout: lane "${laneId}" の stack=${n.stack} に node が重複 (node "${n.id}")`,
142
+ );
143
+ }
144
+ seen.add(n.stack);
145
+ }
146
+ }
147
+
148
+ // node を出力 (各 node は所属 lane の x 中央 + 所属 stack の cy)
149
+ //
150
+ // canvas pivot 新 spec (dragon canvas pivot spec §layout-role-conversion)。
151
+ // posX / posY set 済 node は「user が固定した絶対座標」 = auto layout の stack / lane 中央
152
+ // 計算を skip し、 posX / posY をそのまま cx / cy として採用。 posW / posH set 済なら
153
+ // 該当値を w / h に採用、 未指定なら NODE_SIZE default + rows/kind 依存の auto 拡張を継承。
154
+ // free-form Miro 相当 UX の SSOT。
155
+ const out: LaidNode[] = [];
156
+ for (const n of diag.nodes) {
157
+ const lane = laneMap.get(n.lane);
158
+ if (!lane) {
159
+ throw new Error(`cdl layout: node "${n.id}" の lane "${n.lane}" が定義されていない`);
160
+ }
161
+ const size = NODE_SIZE[n.kind];
162
+ if (n.posX !== undefined && n.posY !== undefined) {
163
+ const w = n.posW ?? n.w ?? autoRowsWidth(n, size.w);
164
+ const h = n.posH ?? n.h ?? rowH.get(n.stack) ?? size.h;
165
+ out.push({
166
+ ...n,
167
+ cx: n.posX,
168
+ cy: n.posY,
169
+ w,
170
+ h,
171
+ insideContainedLane: !!lane.contain,
172
+ });
173
+ continue;
174
+ }
175
+ const cx = lane.x + lane.width / 2;
176
+ const cy = rowCy.get(n.stack)!;
177
+ const h = n.h ?? rowH.get(n.stack)!;
178
+ // n.w 明示時は尊重、 未指定で rows[] を持つ node は内容長に合わせて auto 拡張。
179
+ const w = autoRowsWidth(n, size.w);
180
+ out.push({
181
+ ...n,
182
+ cx,
183
+ cy,
184
+ w,
185
+ h,
186
+ insideContainedLane: !!lane.contain,
187
+ });
188
+ }
189
+ return out;
190
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Engine が予測する label / node bbox の world 単位 public API。
3
+ *
4
+ * CAR-430 で label-shift.ts の shift 探索は削除、 label 位置 SSOT は edges.ts routePath() +
5
+ * collisions.ts resolveEdgeLabelOverlapsWithChain に移管。 CAR-431 で pill h/w/padding SSOT は
6
+ * spec.ts に集約 (render / layout / predict-bbox 3 箇所の drift 解消)、 本 file は spec.ts の
7
+ * `computeLabelBBoxWorld` を re-export する public API 層として engine 予測値と DOM 実測値を
8
+ * diff で assert する用途に特化した stable interface を提供する。
9
+ */
10
+
11
+ import { computeLabelBBoxWorld } from "./spec";
12
+ import type { LaidEdge, LaidNode } from "../types";
13
+
14
+ /**
15
+ * Edge label の world 単位 bbox 予測 (spec.ts SSOT の re-export)。
16
+ *
17
+ * SSOT は `spec.ts` の `computeLabelBBoxWorld` に集約 (CAR-431)、 本 function は名前互換のため
18
+ * delegate wrapper。 render (render/edges.tsx) / layout (layout/collisions.ts) / predict-bbox (本 file)
19
+ * の 3 箇所で個別実装されていた bbox 計算の drift (boxH 64 vs 68、 labelY 位置の centered vs shifted)
20
+ * を解消。
21
+ *
22
+ * @param edge LaidEdge (labelX, labelY, labelAnchor, label, sub 既確定)
23
+ * @returns { x, y, w, h } の world 座標 bbox (render 実 rect 位置と一致)
24
+ */
25
+ export function predictLabelBBoxWorld(edge: LaidEdge): { x: number; y: number; w: number; h: number } {
26
+ return computeLabelBBoxWorld(edge);
27
+ }
28
+
29
+ /**
30
+ * Node の world 単位 bbox 予測。
31
+ */
32
+ export function predictNodeBBoxWorld(n: LaidNode): { x: number; y: number; w: number; h: number } {
33
+ return {
34
+ x: n.cx - n.w / 2,
35
+ y: n.cy - n.h / 2,
36
+ w: n.w,
37
+ h: n.h,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * SVG font rendering の実測誤差 tolerance (world unit)。
43
+ *
44
+ * 用途別 2 種類。
45
+ * - `FONT_RENDER_TOLERANCE_WORLD` = 一般 bbox 予測との tolerance (label position / node size)、
46
+ * 80 world (実 DOM 約 22 px @ scale 3.7)
47
+ * - `LABEL_SIZE_TOLERANCE_WORLD` = **label bbox size 専用**、 150 world (約 40 px @ scale 3.7)
48
+ *
49
+ * label bbox tolerance を大きく取る理由 = getBoundingClientRect() は SVG `<g>` の外接矩形を
50
+ * 返し、 内部 `<text>` の glyph render (kerning / overhang / letter-spacing) が `<rect>` の
51
+ * pad から数-十 world 単位はみ出す。 measureTextWidth (advance width 集計) と DOM 実測
52
+ * (外接矩形) は本質的に別測定なので、 label size は 150 world で妥当な近似。
53
+ *
54
+ * engine measureTextWidth と SVG 実 render の乖離 breakdown:
55
+ * - Inter font の hinting 差 ≈ ±0.5 px / char
56
+ * - subpixel rendering の rounding ≈ ±1 px / bbox
57
+ * - Playwright DOM 実測 と engine world 座標系の scale 差 ≈ ±10-15 world
58
+ * - text glyph の rect padding 外 overhang ≈ ±20-40 world (label のみ)
59
+ */
60
+ export const FONT_RENDER_TOLERANCE_WORLD = 80;
61
+ export const LABEL_SIZE_TOLERANCE_WORLD = 150;
62
+
63
+ /**
64
+ * bbox diff 計算 (world 単位)。 各辺の差の Math.abs の Math.max を返す。
65
+ * tolerance 内なら「engine 予測と実測が一致」 と判定。
66
+ */
67
+ export function bboxDiffWorld(
68
+ predicted: { x: number; y: number; w: number; h: number },
69
+ actual: { x: number; y: number; w: number; h: number },
70
+ ): { dx: number; dy: number; dw: number; dh: number; max: number } {
71
+ const dx = Math.abs(predicted.x - actual.x);
72
+ const dy = Math.abs(predicted.y - actual.y);
73
+ const dw = Math.abs(predicted.w - actual.w);
74
+ const dh = Math.abs(predicted.h - actual.h);
75
+ return { dx, dy, dw, dh, max: Math.max(dx, dy, dw, dh) };
76
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * World 座標系 (engine viewBox 内) と DOM 実測 px の相互変換を提供する pixel-perfect projection layer。
3
+ *
4
+ * 背景 ...
5
+ * engine は viewBox world 単位で全 layout (node.cx / cy / w / h、 edge.d、 label.x / y) を確定する。
6
+ * 実 DOM 描画は SVG viewBox → CSS width の scale で 縮小 / 拡大され、 world 単位と DOM px は
7
+ * 一致しない (dragon 実測 scale = displayWidth 490 / viewBox 幅 1820 ≒ 0.269)。
8
+ *
9
+ * 従来 routing v8 / v10.1 では WORLD_SCALE = 4.0 の経験係数で world 座標の
10
+ * label 幅 / clearance を 「DOM px 相当」 に補正していたが、 実は viewBox 幅 / displayWidth の
11
+ * 比率で数式的に算出可能。 本 module で計算を SSOT 化し、 全 downstream (edges routing / gate 判定 /
12
+ * visual regression) が正確な変換を使う。
13
+ *
14
+ * 提供する変換 ...
15
+ * 1. worldToPx(worldValue, scale) ... 単一値 world → px
16
+ * 2. pxToWorld(pxValue, scale) ... 単一値 px → world (WORLD_SCALE 逆算)
17
+ * 3. computeViewportScale(viewBoxWidth, displayWidth) ... scale factor = viewBoxWidth / displayWidth
18
+ * 4. projectNode / projectEdgeLabel / projectPathSegment ... 各要素の px 予測 bbox / point
19
+ *
20
+ * SSOT ...
21
+ * 本 module の変換式は SVG viewBox の projection と完全に一致する (SVG spec `preserveAspectRatio`
22
+ * default = xMidYMid meet で uniform scale)。 fontSize は viewBox 単位で描画時に scale される
23
+ * ため、 world font 幅 × scale = DOM 実測 幅。
24
+ */
25
+
26
+ import type { LaidEdge, LaidNode } from "../types";
27
+
28
+ /**
29
+ * viewBox width と CSS display width から scale factor を計算する。
30
+ *
31
+ * scale factor = viewBoxWidth / displayWidth
32
+ * 例 = dragon CdlDiagramThumbnail は viewBox width 1820 world unit、 display width 490 CSS px
33
+ * → scale = 1820 / 490 ≒ 3.714
34
+ * world 座標を DOM px に落とす場合は `world / scale`、
35
+ * DOM px を world に戻す場合は `px * scale`。
36
+ *
37
+ * @param viewBoxWidth SVG viewBox の width 属性値 (world 単位)
38
+ * @param displayWidth CSS 描画時の width (px)
39
+ * @returns world unit per DOM px (常に > 0)
40
+ */
41
+ export function computeViewportScale(viewBoxWidth: number, displayWidth: number): number {
42
+ if (!Number.isFinite(viewBoxWidth) || viewBoxWidth <= 0) {
43
+ throw new Error(`computeViewportScale: viewBoxWidth must be > 0 (got ${viewBoxWidth})`);
44
+ }
45
+ if (!Number.isFinite(displayWidth) || displayWidth <= 0) {
46
+ throw new Error(`computeViewportScale: displayWidth must be > 0 (got ${displayWidth})`);
47
+ }
48
+ return viewBoxWidth / displayWidth;
49
+ }
50
+
51
+ /**
52
+ * world 座標値を DOM px に変換する。 scale = viewBoxWidth / displayWidth。
53
+ */
54
+ export function worldToPx(worldValue: number, scale: number): number {
55
+ return worldValue / scale;
56
+ }
57
+
58
+ /**
59
+ * DOM px を world 座標に変換する。 scale = viewBoxWidth / displayWidth。
60
+ */
61
+ export function pxToWorld(pxValue: number, scale: number): number {
62
+ return pxValue * scale;
63
+ }
64
+
65
+ /**
66
+ * Node の px 予測 bbox。 world 座標 (cx, cy, w, h) を DOM px 実測相当に変換する。
67
+ *
68
+ * SVG viewBox は uniform scale (preserveAspectRatio xMidYMid meet)、 x / y 両軸同一 scale。
69
+ * node cx / cy / w / h は viewBox 内 world 座標そのまま、 SVG viewBox → CSS 描画時に scale で
70
+ * 縮小される。 実 DOM getBoundingClientRect().width == worldW / scale。
71
+ *
72
+ * @param n LaidNode (engine 計算後の world 座標)
73
+ * @param scale viewBox width / display width (world unit per DOM px)
74
+ * @param svgOffsetPx SVG 要素の CSS 位置 offset (通常 svgClientRect.left / top、 省略時 0)
75
+ * @returns 予測 DOM px bbox { x, y, w, h }
76
+ */
77
+ export function projectNode(
78
+ n: LaidNode,
79
+ scale: number,
80
+ svgOffsetPx: { x: number; y: number } = { x: 0, y: 0 },
81
+ viewBoxOrigin: { x: number; y: number } = { x: 0, y: 0 },
82
+ ): { x: number; y: number; w: number; h: number } {
83
+ const worldLeft = n.cx - n.w / 2 - viewBoxOrigin.x;
84
+ const worldTop = n.cy - n.h / 2 - viewBoxOrigin.y;
85
+ return {
86
+ x: svgOffsetPx.x + worldToPx(worldLeft, scale),
87
+ y: svgOffsetPx.y + worldToPx(worldTop, scale),
88
+ w: worldToPx(n.w, scale),
89
+ h: worldToPx(n.h, scale),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Edge label の px 予測 bbox。 label.x / y は world 座標での中心 (anchor middle 時) or
95
+ * 起点 / 終点 (anchor start / end 時)、 labelBoxW を world 単位で受け取り px 換算する。
96
+ *
97
+ * @param edge LaidEdge (labelX, labelY, labelAnchor 既確定)
98
+ * @param labelBoxWorldW world 単位での label bbox 幅 (measureTextWidth + padding)
99
+ * @param labelBoxWorldH world 単位での label bbox 高 (36 default、 sub あり時 64)
100
+ * @param scale viewBox width / display width
101
+ * @param svgOffsetPx SVG 要素の CSS 位置 offset
102
+ * @param viewBoxOrigin viewBox の (x_min, y_min)、 auto viewBox で 0 でない
103
+ */
104
+ export function projectEdgeLabel(
105
+ edge: LaidEdge,
106
+ labelBoxWorldW: number,
107
+ labelBoxWorldH: number,
108
+ scale: number,
109
+ svgOffsetPx: { x: number; y: number } = { x: 0, y: 0 },
110
+ viewBoxOrigin: { x: number; y: number } = { x: 0, y: 0 },
111
+ ): { x: number; y: number; w: number; h: number } {
112
+ const anchor = edge.labelAnchor;
113
+ const boxLeftOffset = anchor === "start" ? 0 : anchor === "end" ? -labelBoxWorldW : -labelBoxWorldW / 2;
114
+ const worldLeft = edge.labelX + boxLeftOffset - viewBoxOrigin.x;
115
+ const worldTop = edge.labelY - labelBoxWorldH / 2 - viewBoxOrigin.y;
116
+ return {
117
+ x: svgOffsetPx.x + worldToPx(worldLeft, scale),
118
+ y: svgOffsetPx.y + worldToPx(worldTop, scale),
119
+ w: worldToPx(labelBoxWorldW, scale),
120
+ h: worldToPx(labelBoxWorldH, scale),
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Path segment (start point → end point) を DOM px に投影する。
126
+ *
127
+ * @param seg { x1, y1, x2, y2 } の world 座標 segment
128
+ * @param scale viewBox width / display width
129
+ * @param svgOffsetPx SVG 要素の CSS 位置 offset
130
+ * @param viewBoxOrigin viewBox の (x_min, y_min)
131
+ */
132
+ export function projectPathSegment(
133
+ seg: { x1: number; y1: number; x2: number; y2: number },
134
+ scale: number,
135
+ svgOffsetPx: { x: number; y: number } = { x: 0, y: 0 },
136
+ viewBoxOrigin: { x: number; y: number } = { x: 0, y: 0 },
137
+ ): { x1: number; y1: number; x2: number; y2: number } {
138
+ return {
139
+ x1: svgOffsetPx.x + worldToPx(seg.x1 - viewBoxOrigin.x, scale),
140
+ y1: svgOffsetPx.y + worldToPx(seg.y1 - viewBoxOrigin.y, scale),
141
+ x2: svgOffsetPx.x + worldToPx(seg.x2 - viewBoxOrigin.x, scale),
142
+ y2: svgOffsetPx.y + worldToPx(seg.y2 - viewBoxOrigin.y, scale),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * 2 bbox の距離 (rectangular gap、 交差時 0)。 px 単位で受け取り px 単位で返す。
148
+ * label と node の clearance 判定に使う。
149
+ */
150
+ export function bboxClearance(
151
+ a: { x: number; y: number; w: number; h: number },
152
+ b: { x: number; y: number; w: number; h: number },
153
+ ): number {
154
+ const dx = Math.max(0, Math.max(a.x - (b.x + b.w), b.x - (a.x + a.w)));
155
+ const dy = Math.max(0, Math.max(a.y - (b.y + b.h), b.y - (a.y + a.h)));
156
+ return Math.hypot(dx, dy);
157
+ }
158
+
159
+ /**
160
+ * 点と segment の最短距離 (px 単位)。 label center → path segment 判定に使う。
161
+ */
162
+ export function pointToSegmentDistance(
163
+ px: number,
164
+ py: number,
165
+ seg: { x1: number; y1: number; x2: number; y2: number },
166
+ ): number {
167
+ const dx = seg.x2 - seg.x1;
168
+ const dy = seg.y2 - seg.y1;
169
+ const lenSq = dx * dx + dy * dy;
170
+ if (lenSq === 0) return Math.hypot(px - seg.x1, py - seg.y1);
171
+ let t = ((px - seg.x1) * dx + (py - seg.y1) * dy) / lenSq;
172
+ t = Math.max(0, Math.min(1, t));
173
+ const cx = seg.x1 + t * dx;
174
+ const cy = seg.y1 + t * dy;
175
+ return Math.hypot(px - cx, py - cy);
176
+ }