@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.
- package/LICENSE +21 -0
- package/README.md +343 -0
- package/SPEC.md +374 -0
- package/dist/index.cjs +28085 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3415 -0
- package/dist/index.d.ts +3415 -0
- package/dist/index.js +27962 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +23043 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +2 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +23041 -0
- package/dist/react.js.map +1 -0
- package/dist/render-C78lIXeC.d.cts +2449 -0
- package/dist/render-C78lIXeC.d.ts +2449 -0
- package/examples/quick-start.md +88 -0
- package/package.json +79 -0
- package/src/anim/core/easing.ts +77 -0
- package/src/anim/core/timeline.ts +335 -0
- package/src/anim/core/types.ts +42 -0
- package/src/anim/index.ts +20 -0
- package/src/anim/react/index.ts +3 -0
- package/src/anim/react/useReducedMotion.ts +27 -0
- package/src/anim/react/useTimeline.ts +48 -0
- package/src/assert-never.ts +16 -0
- package/src/author-intent-verify.ts +587 -0
- package/src/builder.ts +3740 -0
- package/src/compile.ts +12 -0
- package/src/dom-verify-core.ts +121 -0
- package/src/dom-verify-types.ts +21 -0
- package/src/dom-verify.ts +948 -0
- package/src/event-handler/index.ts +184 -0
- package/src/formula/ast.ts +46 -0
- package/src/formula/evaluator.ts +163 -0
- package/src/formula/formula-computeds.ts +83 -0
- package/src/formula/index.ts +5 -0
- package/src/formula/parser.ts +399 -0
- package/src/index.ts +284 -0
- package/src/input-signals.ts +74 -0
- package/src/kinds/actor.tsx +79 -0
- package/src/kinds/card.tsx +61 -0
- package/src/kinds/chart-bar.tsx +121 -0
- package/src/kinds/chart-line.tsx +163 -0
- package/src/kinds/chart-pie.tsx +107 -0
- package/src/kinds/compact-title.ts +164 -0
- package/src/kinds/dyn-shape.tsx +394 -0
- package/src/kinds/event.tsx +70 -0
- package/src/kinds/function.tsx +53 -0
- package/src/kinds/funnel.tsx +122 -0
- package/src/kinds/gantt.tsx +193 -0
- package/src/kinds/generic.tsx +368 -0
- package/src/kinds/mind-map.tsx +367 -0
- package/src/kinds/mind-radial.tsx +209 -0
- package/src/kinds/node-tone.ts +18 -0
- package/src/kinds/quadrant.tsx +164 -0
- package/src/kinds/row-align.ts +179 -0
- package/src/kinds/shape-basement.tsx +683 -0
- package/src/kinds/shape-blockchain.tsx +750 -0
- package/src/kinds/shape-commerce.tsx +553 -0
- package/src/kinds/shape-finance.tsx +706 -0
- package/src/kinds/shape-hardware.tsx +862 -0
- package/src/kinds/shape-people.tsx +439 -0
- package/src/kinds/shape-region.tsx +383 -0
- package/src/kinds/shape-software.tsx +859 -0
- package/src/kinds/storage.tsx +180 -0
- package/src/kinds/text-width.ts +73 -0
- package/src/kinds/tree.tsx +275 -0
- package/src/kinds/user-journey.tsx +240 -0
- package/src/label-text.ts +71 -0
- package/src/layout/clearance-constants.ts +47 -0
- package/src/layout/collisions.ts +2078 -0
- package/src/layout/edges.ts +2498 -0
- package/src/layout/footer-shape.ts +97 -0
- package/src/layout/geometry.ts +135 -0
- package/src/layout/label-shift.ts +28 -0
- package/src/layout/lanes.ts +328 -0
- package/src/layout/nodes.ts +190 -0
- package/src/layout/predict-bbox.ts +76 -0
- package/src/layout/px-projection.ts +176 -0
- package/src/layout/spec.ts +872 -0
- package/src/layout/text-width.ts +133 -0
- package/src/layout/tokens.ts +181 -0
- package/src/layout/viewbox.ts +47 -0
- package/src/layout-with-validation.ts +175 -0
- package/src/layout.ts +381 -0
- package/src/presets.ts +2108 -0
- package/src/reactive/batch.ts +48 -0
- package/src/reactive/computed.ts +85 -0
- package/src/reactive/effect.ts +145 -0
- package/src/reactive/index.ts +6 -0
- package/src/reactive/internal.ts +109 -0
- package/src/reactive/signal.ts +113 -0
- package/src/render/edges.tsx +318 -0
- package/src/render/header.tsx +146 -0
- package/src/render/interactive-panel.tsx +9620 -0
- package/src/render/nodes.tsx +319 -0
- package/src/render/stage.tsx +377 -0
- package/src/render/tone.ts +64 -0
- package/src/render/utils.ts +238 -0
- package/src/render.tsx +221 -0
- package/src/scroll-trigger/index.ts +109 -0
- package/src/scroll-trigger/progress.ts +49 -0
- package/src/thumbnail.tsx +114 -0
- package/src/types.ts +2371 -0
- package/src/validate.ts +268 -0
- package/src/visual-validate.ts +4381 -0
|
@@ -0,0 +1,2078 @@
|
|
|
1
|
+
import type { BBox, LaidEdge, LaidLane, LaidNode } from "../types";
|
|
2
|
+
import { CHAIN_SHIFT_MAX_ACCUMULATED, computeLabelBBoxWorld, EDGE_STUB_OUT, hasRenderedLabel, LABEL_TO_PATH_CLEARANCE, requiredNearClearance } from "./spec";
|
|
3
|
+
import { measureTextWidth } from "./text-width";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* lane-label 横方向 near miss を解消する時に、 要求 clearance ちょうどではなく
|
|
7
|
+
* わずかに超えて押し出すための余裕幅。 浮動小数の丸めで再び閾値 undershoot するのを防ぐ。
|
|
8
|
+
*/
|
|
9
|
+
const LANE_LABEL_CLEARANCE_MARGIN = 2;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 全要素の AABB を集約。 engine が「キャンバスに何があるか」 を全部把握する SSOT。
|
|
13
|
+
*/
|
|
14
|
+
export function collectBBoxes(lanes: LaidLane[], nodes: LaidNode[], edges: LaidEdge[]): BBox[] {
|
|
15
|
+
const out: BBox[] = [];
|
|
16
|
+
|
|
17
|
+
for (const lane of lanes) {
|
|
18
|
+
if (!lane.label) continue;
|
|
19
|
+
out.push({
|
|
20
|
+
kind: "lane-label",
|
|
21
|
+
id: lane.id,
|
|
22
|
+
x: lane.x + 16,
|
|
23
|
+
y: lane.y + 8,
|
|
24
|
+
w: measureTextWidth(lane.label, { fontSize: 20, letterSpacing: 2 }) + 24,
|
|
25
|
+
h: 28,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const n of nodes) {
|
|
30
|
+
out.push({
|
|
31
|
+
kind: "node",
|
|
32
|
+
id: n.id,
|
|
33
|
+
x: n.cx - n.w / 2,
|
|
34
|
+
y: n.cy - n.h / 2,
|
|
35
|
+
w: n.w,
|
|
36
|
+
h: n.h,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const e of edges) {
|
|
41
|
+
if (!hasRenderedLabel(e)) continue;
|
|
42
|
+
// SSOT = spec.ts の computeLabelBBoxWorld (CAR-431 で render / layout / predict-bbox 3 箇所の
|
|
43
|
+
// bbox 計算 drift を解消)。 旧実装は boxH = e.sub ? 64 : 36 + y = labelY - boxH/2 (centered)
|
|
44
|
+
// だったが、 render 側は boxH = 68 + yOffset shifted で描画するため実測 bbox と layout 予測が
|
|
45
|
+
// 乖離、 near-collision detection の偽陰性を生んでいた。 本 SSOT 統一で render 実 rect 位置と
|
|
46
|
+
// 一致する。
|
|
47
|
+
const bbox = computeLabelBBoxWorld(e);
|
|
48
|
+
out.push({
|
|
49
|
+
kind: "edge-label",
|
|
50
|
+
id: e.id,
|
|
51
|
+
x: bbox.x,
|
|
52
|
+
y: bbox.y,
|
|
53
|
+
w: bbox.w,
|
|
54
|
+
h: bbox.h,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const e of edges) {
|
|
59
|
+
const points = extractPathPoints(e.d);
|
|
60
|
+
if (points.length === 0) continue;
|
|
61
|
+
const xs = points.map((p) => p.x);
|
|
62
|
+
const ys = points.map((p) => p.y);
|
|
63
|
+
const minX = Math.min(...xs);
|
|
64
|
+
const maxX = Math.max(...xs);
|
|
65
|
+
const minY = Math.min(...ys);
|
|
66
|
+
const maxY = Math.max(...ys);
|
|
67
|
+
out.push({
|
|
68
|
+
kind: "edge-path",
|
|
69
|
+
id: e.id,
|
|
70
|
+
x: minX - 2,
|
|
71
|
+
y: minY - 2,
|
|
72
|
+
w: maxX - minX + 4,
|
|
73
|
+
h: maxY - minY + 4,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** SVG path d 文字列から M/L/C/Q コマンドの座標点を抽出する軽量 parser */
|
|
81
|
+
export function extractPathPoints(d: string): Array<{ x: number; y: number }> {
|
|
82
|
+
const out: Array<{ x: number; y: number }> = [];
|
|
83
|
+
const tokens = d.replace(/[A-Za-z]/g, " ").trim().split(/[\s,]+/).filter(Boolean);
|
|
84
|
+
for (let i = 0; i + 1 < tokens.length; i += 2) {
|
|
85
|
+
const x = parseFloat(tokens[i]!);
|
|
86
|
+
const y = parseFloat(tokens[i + 1]!);
|
|
87
|
+
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
88
|
+
out.push({ x, y });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* SVG path d 文字列を (dx, dy) 平行移動する (CAR-485 導入)。
|
|
96
|
+
*
|
|
97
|
+
* chain 伝搬 shift で `label ↔ edge-path (別 edge) near-collision` を解消するとき、
|
|
98
|
+
* label 側は labelX / labelY 加算で済むが、 path 側は d 文字列内の全 (x, y) coord ペアに
|
|
99
|
+
* (dx, dy) を加算する必要がある。 edges.ts の routePath() は M / L / Q コマンドで
|
|
100
|
+
* 全 coord を absolute 表記するため、 command tokenizer で数値 pair を舐めて加算する。
|
|
101
|
+
*
|
|
102
|
+
* 対応 command = M / L / Q (Q は controlX/Y + endX/Y の 2 pair)。 C は 3 pair (control1 +
|
|
103
|
+
* control2 + end)。 Z (closepath) は座標なしで pass through。 大文字 / 小文字は routePath()
|
|
104
|
+
* の出力に合わせて大文字のみ想定。
|
|
105
|
+
*
|
|
106
|
+
* dx = 0 かつ dy = 0 は no-op (元 d を返す)。
|
|
107
|
+
*/
|
|
108
|
+
export function translatePathD(d: string, dx: number, dy: number): string {
|
|
109
|
+
if (dx === 0 && dy === 0) return d;
|
|
110
|
+
return rebuildPathD(d, (x, y) => [x + dx, y + dy]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `d` の座標 pair を `map` で置換して組み直す。 command 構造と順序は保つ。
|
|
115
|
+
*
|
|
116
|
+
* `idx` は path 先頭からの座標 pair の通し番号 (Q の control 点も 1 pair として数える)。
|
|
117
|
+
*/
|
|
118
|
+
function rebuildPathD(d: string, map: (x: number, y: number, idx: number) => [number, number]): string {
|
|
119
|
+
const commandRegex = /([MLCQZmlcqz])([^MLCQZmlcqz]*)/g;
|
|
120
|
+
let out = "";
|
|
121
|
+
let idx = 0;
|
|
122
|
+
let m: RegExpExecArray | null;
|
|
123
|
+
while ((m = commandRegex.exec(d)) !== null) {
|
|
124
|
+
const cmd = m[1]!;
|
|
125
|
+
const argsStr = (m[2] ?? "").trim();
|
|
126
|
+
if (argsStr === "" || cmd === "Z" || cmd === "z") {
|
|
127
|
+
out += cmd + " ";
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const args = argsStr.split(/[\s,]+/).filter(Boolean).map(parseFloat);
|
|
131
|
+
const translated: number[] = [];
|
|
132
|
+
for (let i = 0; i + 1 < args.length; i += 2) {
|
|
133
|
+
const [nx, ny] = map(args[i]!, args[i + 1]!, idx);
|
|
134
|
+
translated.push(nx, ny);
|
|
135
|
+
idx += 1;
|
|
136
|
+
}
|
|
137
|
+
out += cmd + " " + translated.join(" ") + " ";
|
|
138
|
+
}
|
|
139
|
+
return out.trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `d` 内の座標 pair のうち、 先頭の Q の終点 index と 末尾の Q の制御点 index を返す。
|
|
144
|
+
*
|
|
145
|
+
* Q は `control` と `end` の 2 pair を持つ。 corner が無ければ両方 -1。
|
|
146
|
+
*/
|
|
147
|
+
function cornerBoundaryIndices(d: string): { firstCornerEnd: number; lastCornerStart: number } {
|
|
148
|
+
const commandRegex = /([MLCQZmlcqz])([^MLCQZmlcqz]*)/g;
|
|
149
|
+
let idx = 0;
|
|
150
|
+
let firstCornerEnd = -1;
|
|
151
|
+
let lastCornerStart = -1;
|
|
152
|
+
let m: RegExpExecArray | null;
|
|
153
|
+
while ((m = commandRegex.exec(d)) !== null) {
|
|
154
|
+
const cmd = m[1]!;
|
|
155
|
+
const argsStr = (m[2] ?? "").trim();
|
|
156
|
+
if (argsStr === "" || cmd === "Z" || cmd === "z") continue;
|
|
157
|
+
const pairs = argsStr.split(/[\s,]+/).filter(Boolean).length >> 1;
|
|
158
|
+
if ((cmd === "Q" || cmd === "q") && pairs >= 2) {
|
|
159
|
+
if (firstCornerEnd < 0) firstCornerEnd = idx + 1;
|
|
160
|
+
lastCornerStart = idx;
|
|
161
|
+
}
|
|
162
|
+
idx += pairs;
|
|
163
|
+
}
|
|
164
|
+
return { firstCornerEnd, lastCornerStart };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 端点領域を index で一体固定して path を平行移動する (Issue #216)。
|
|
169
|
+
*
|
|
170
|
+
* ## 背景
|
|
171
|
+
*
|
|
172
|
+
* `CAR-489` は「端点の Y は動かさない」 ために **座標値の一致** で固定点を決めていた。
|
|
173
|
+
* 端点と同じ Y を持つ点が全て固定されるため、 端点から出る直線は水平に保たれる。
|
|
174
|
+
* しかし corner (`Q control end`) の control 点は直線の延長上にあり端点と同じ Y を持つ一方、
|
|
175
|
+
* end 点は持たない。 control だけが取り残されて曲がりの向きが反転する。
|
|
176
|
+
*
|
|
177
|
+
* ```
|
|
178
|
+
* M 100 200 L 126 200 Q 140 200, 140 214 L 140 286 に dy = -30
|
|
179
|
+
*
|
|
180
|
+
* 座標値ベース ... control (140,200) 固定 / end (140,214) → 184 Δy が +14 から -16 に反転
|
|
181
|
+
* ```
|
|
182
|
+
*
|
|
183
|
+
* ## 設計
|
|
184
|
+
*
|
|
185
|
+
* corner を分断せず、 **端点から出る直線と それに続く corner を「端点領域」 として一体固定** する。
|
|
186
|
+
* ずれは corner の先の直線が長さを変えて吸収する。
|
|
187
|
+
*
|
|
188
|
+
* ```
|
|
189
|
+
* M 100 200 L 126 200 Q 140 200, 140 214 L 140 286
|
|
190
|
+
* └──────── 端点領域 (固定) ─────────┘ └ 長さで吸収 ┘
|
|
191
|
+
* ```
|
|
192
|
+
*
|
|
193
|
+
* 固定範囲は **軸ごとに変わる**。 端点から出る直線の向きを基準に決める。
|
|
194
|
+
*
|
|
195
|
+
* - shift が直線と **平行** ... 端点だけ固定する。 直線が長さを変えて吸収する
|
|
196
|
+
* - shift が直線と **直交** ... 端点領域 (端点 → 最初の corner の終点) を固定する。
|
|
197
|
+
* corner の先の直線 (shift と平行) が長さを変えて吸収する
|
|
198
|
+
*
|
|
199
|
+
* 末尾も同様に、 最後の corner の制御点から終点までを端点領域とする。
|
|
200
|
+
*
|
|
201
|
+
* corner を持たない直線だけの path は、 直交方向に動かすと必ず端点が node の辺から外れる。
|
|
202
|
+
* よって両端が固定されて 1 点も動かない。 これは正しい挙動で、 呼び元 (`pathShiftIsSafe`) が
|
|
203
|
+
* 「1 点も動かない向き」 として検出し別の向きを試す。
|
|
204
|
+
*
|
|
205
|
+
* `dx = 0` かつ `dy = 0` は no-op。
|
|
206
|
+
*/
|
|
207
|
+
export function translatePathDPreservingEndpoints(d: string, dx: number, dy: number): string {
|
|
208
|
+
if (dx === 0 && dy === 0) return d;
|
|
209
|
+
const pts = extractPathPoints(d);
|
|
210
|
+
if (pts.length < 2) return d;
|
|
211
|
+
|
|
212
|
+
const { firstCornerEnd, lastCornerStart } = cornerBoundaryIndices(d);
|
|
213
|
+
const last = pts.length - 1;
|
|
214
|
+
|
|
215
|
+
/** 2 点を結ぶ向きが指定軸に沿っているか (直交する軸方向の差が小さいか)。 */
|
|
216
|
+
const alongAxis = (a: { x: number; y: number }, b: { x: number; y: number }, axis: "x" | "y"): boolean =>
|
|
217
|
+
axis === "x"
|
|
218
|
+
? Math.abs(b.x - a.x) >= Math.abs(b.y - a.y)
|
|
219
|
+
: Math.abs(b.y - a.y) > Math.abs(b.x - a.x);
|
|
220
|
+
|
|
221
|
+
/** 指定軸で固定する index 範囲 (head は 0..headEnd、 tail は tailStart..last)。 */
|
|
222
|
+
const frozenRange = (axis: "x" | "y"): { headEnd: number; tailStart: number } => {
|
|
223
|
+
const headParallel = alongAxis(pts[0]!, pts[1]!, axis);
|
|
224
|
+
const tailParallel = alongAxis(pts[last - 1]!, pts[last]!, axis);
|
|
225
|
+
// 平行なら端点だけ、 直交なら端点領域 (corner ごと) を固定する。
|
|
226
|
+
// corner が無い path は直交方向に動かせないので全点を固定する。
|
|
227
|
+
//
|
|
228
|
+
// head の端点領域は「端点 → 直線 → corner の終点」、 tail は「corner の入口点 → corner → 端点」。
|
|
229
|
+
// corner の入口点は `Q` の制御点の 1 つ前の座標 pair なので `lastCornerStart - 1` になる。
|
|
230
|
+
// 入口点を外すと corner の始点だけが動いて曲がりが引き伸ばされる。
|
|
231
|
+
const headEnd = headParallel ? 0 : firstCornerEnd >= 0 ? firstCornerEnd : last;
|
|
232
|
+
const tailStart = tailParallel ? last : lastCornerStart >= 1 ? lastCornerStart - 1 : 0;
|
|
233
|
+
return { headEnd, tailStart };
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const rangeX = frozenRange("x");
|
|
237
|
+
const rangeY = frozenRange("y");
|
|
238
|
+
const frozen = (idx: number, r: { headEnd: number; tailStart: number }): boolean =>
|
|
239
|
+
idx <= r.headEnd || idx >= r.tailStart;
|
|
240
|
+
|
|
241
|
+
return rebuildPathD(d, (x, y, idx) => [
|
|
242
|
+
frozen(idx, rangeX) ? x : x + dx,
|
|
243
|
+
frozen(idx, rangeY) ? y : y + dy,
|
|
244
|
+
]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* node-node overlap を保守的に解消する。
|
|
249
|
+
*
|
|
250
|
+
* 設計指針 ... sequence preset の lifeline / spacer / 同 stack 配置を壊さないため
|
|
251
|
+
* 「同じ kind 同士の collision のみ resolve、 異なる kind は skip」 の保守的方針。
|
|
252
|
+
* BBox kind = "node" 同士の overlap だけを対象、 edge-path / edge-label / lane-label
|
|
253
|
+
* 絡みの overlap は元の routing 結果に任せる (それらは layout 側 detour で別途吸収)。
|
|
254
|
+
*
|
|
255
|
+
* 後勝ち戦略 ... overlap pair (a, b) のうち BBox 配列で後ろに居る方 (= diag.nodes の後 declare)
|
|
256
|
+
* を node h + 16px 下に shift。 同 node が複数 collision で被ヒットする場合は累積 shift し、
|
|
257
|
+
* shift 量を Map で管理する。
|
|
258
|
+
*
|
|
259
|
+
* sequence preset の golden 100 件は node-node overlap = 0 のため、 既存 snapshot 不変。
|
|
260
|
+
* author が manual lane.x で意図的に lane を被せた / lane.width 過小指定 etc. の場合のみ発火。
|
|
261
|
+
*/
|
|
262
|
+
export function resolveOverlaps(
|
|
263
|
+
nodes: LaidNode[],
|
|
264
|
+
collisions: Array<{ a: BBox; b: BBox; overlap_area: number }>,
|
|
265
|
+
): LaidNode[] {
|
|
266
|
+
if (collisions.length === 0) return nodes;
|
|
267
|
+
const nodeOrder = new Map<string, number>();
|
|
268
|
+
const isAbsolute = new Map<string, boolean>();
|
|
269
|
+
nodes.forEach((n, idx) => {
|
|
270
|
+
nodeOrder.set(n.id, idx);
|
|
271
|
+
// 2026-07-24 fix (dragon editor canvas pivot、 parts merge trophy が seq preset step boxes に
|
|
272
|
+
// 累積 shift されて cy=2076 に飛ぶ bug の root fix) = posX/posY 両方 set 済の node は
|
|
273
|
+
// user 明示絶対配置と判定して shift 対象から除外する。 従来 resolveOverlaps は「後勝ち一発 shift」
|
|
274
|
+
// で全 node 対象、 絶対配置 node (parts drop + canvas pivot) も強制下方 shift されていた。
|
|
275
|
+
isAbsolute.set(n.id, n.posX !== undefined && n.posY !== undefined);
|
|
276
|
+
});
|
|
277
|
+
const additionalY = new Map<string, number>();
|
|
278
|
+
for (const c of collisions) {
|
|
279
|
+
if (c.a.kind !== "node" || c.b.kind !== "node") continue;
|
|
280
|
+
if (c.a.id === c.b.id) continue;
|
|
281
|
+
const orderA = nodeOrder.get(c.a.id);
|
|
282
|
+
const orderB = nodeOrder.get(c.b.id);
|
|
283
|
+
if (orderA == null || orderB == null) continue;
|
|
284
|
+
// 後勝ち ... 後 declare の node を下方 shift、 先 declare は据え置き。
|
|
285
|
+
//
|
|
286
|
+
// 但し座標を明示した node は動かさない (指定した位置に置くのが仕様)。 その場合は
|
|
287
|
+
// 相手が自動配置なら相手を動かして重なりを解く。 両方とも明示なら、 どちらを動かしても
|
|
288
|
+
// 指定を破ることになるので重なりを残す。
|
|
289
|
+
const later = orderA > orderB ? c.a : c.b;
|
|
290
|
+
const earlier = orderA > orderB ? c.b : c.a;
|
|
291
|
+
const move = !isAbsolute.get(later.id)
|
|
292
|
+
? later
|
|
293
|
+
: !isAbsolute.get(earlier.id)
|
|
294
|
+
? earlier
|
|
295
|
+
: null;
|
|
296
|
+
if (!move) continue;
|
|
297
|
+
const prev = additionalY.get(move.id) ?? 0;
|
|
298
|
+
additionalY.set(move.id, prev + move.h + 16);
|
|
299
|
+
}
|
|
300
|
+
if (additionalY.size === 0) return nodes;
|
|
301
|
+
return nodes.map((n) => {
|
|
302
|
+
const dy = additionalY.get(n.id);
|
|
303
|
+
if (!dy) return n;
|
|
304
|
+
return { ...n, cy: n.cy + dy };
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* chain 伝搬 shift で node-node overlap を解消する (CAR-421 導入)。
|
|
310
|
+
*
|
|
311
|
+
* `resolveOverlaps` (後勝ち一発 shift) との違い ...
|
|
312
|
+
* 1. 検出 → shift → 再検出 → 影響先 shift の loop、 CHAIN_SHIFT_MAX_ITER で強制収束
|
|
313
|
+
* 2. shift 方向 = 後 declare node を「overlap 軸の逆方向」 に押し出す
|
|
314
|
+
* 3. shift 後に 他 node と新規 overlap した場合、 その他 node も 同方向同量 shift 連鎖
|
|
315
|
+
* 4. 収束条件 = 1 iter で shift が 1 件も発生しない、 or MAX_ITER 到達
|
|
316
|
+
*
|
|
317
|
+
* 保守性 = `resolveOverlaps` と同じく node-node のみ対象。 edge-path / edge-label
|
|
318
|
+
* との衝突は下流の resolveEdgeLabelOverlapsWithChain / detour routing に任せる。
|
|
319
|
+
*
|
|
320
|
+
* shift 量 = overlap 距離 (軸方向) + minClearance。 shift 後の gap は
|
|
321
|
+
* 少なくとも minClearance 保証、 shift 累積で「押し切った」 状態を作る。
|
|
322
|
+
*
|
|
323
|
+
* 適用範囲 = `layout()` の pipeline で `resolveOverlaps` の後に opt-in で呼ぶ経路を
|
|
324
|
+
* 想定。 sequence preset (lifeline stacking) を壊さないため、 現状 default OFF、
|
|
325
|
+
* 呼び出し側で明示的に有効化する。
|
|
326
|
+
*
|
|
327
|
+
* @param nodes 元 LaidNode 列 (順序保存で返す)
|
|
328
|
+
* @param minClearance shift 後の最小 gap (world unit、 default 16)
|
|
329
|
+
* @param maxIter 収束上限 iteration 数 (default 20)
|
|
330
|
+
* @returns shift 済 LaidNode 列 (input の順序を保持)
|
|
331
|
+
*/
|
|
332
|
+
export function resolveOverlapsWithChain(
|
|
333
|
+
nodes: LaidNode[],
|
|
334
|
+
minClearance = 16,
|
|
335
|
+
maxIter = 20,
|
|
336
|
+
): LaidNode[] {
|
|
337
|
+
if (nodes.length < 2) return nodes;
|
|
338
|
+
const nodeOrder = new Map<string, number>();
|
|
339
|
+
nodes.forEach((n, idx) => nodeOrder.set(n.id, idx));
|
|
340
|
+
|
|
341
|
+
// 累積 shift 量を Map で管理、 nodes 配列は immutable 扱いで最後に 1 回 apply。
|
|
342
|
+
const shiftMap = new Map<string, { dx: number; dy: number }>();
|
|
343
|
+
const getCx = (n: LaidNode): number => n.cx + (shiftMap.get(n.id)?.dx ?? 0);
|
|
344
|
+
const getCy = (n: LaidNode): number => n.cy + (shiftMap.get(n.id)?.dy ?? 0);
|
|
345
|
+
|
|
346
|
+
// 全 pair の overlap 検出 + 軸決定 (Y or X)、 shift 方向 = overlap 軸の逆方向。
|
|
347
|
+
// 軸判定 = |dxOverlap| vs |dyOverlap| の小さい方が「押し出しやすい軸」 とみなす。
|
|
348
|
+
type Pair = { moveId: string; axis: "x" | "y"; sign: 1 | -1; amount: number };
|
|
349
|
+
const detectPairs = (): Pair[] => {
|
|
350
|
+
const out: Pair[] = [];
|
|
351
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
352
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
353
|
+
const a = nodes[i]!;
|
|
354
|
+
const b = nodes[j]!;
|
|
355
|
+
const aCx = getCx(a);
|
|
356
|
+
const aCy = getCy(a);
|
|
357
|
+
const bCx = getCx(b);
|
|
358
|
+
const bCy = getCy(b);
|
|
359
|
+
// AABB overlap
|
|
360
|
+
const dxOverlap = Math.min(aCx + a.w / 2, bCx + b.w / 2) - Math.max(aCx - a.w / 2, bCx - b.w / 2);
|
|
361
|
+
const dyOverlap = Math.min(aCy + a.h / 2, bCy + b.h / 2) - Math.max(aCy - a.h / 2, bCy - b.h / 2);
|
|
362
|
+
if (dxOverlap <= 0 || dyOverlap <= 0) continue;
|
|
363
|
+
|
|
364
|
+
// 後勝ち = declare 後の node を shift、 先 declare は据え置き。
|
|
365
|
+
const orderA = nodeOrder.get(a.id)!;
|
|
366
|
+
const orderB = nodeOrder.get(b.id)!;
|
|
367
|
+
const moveIsA = orderA > orderB;
|
|
368
|
+
const moveId = moveIsA ? a.id : b.id;
|
|
369
|
+
// 軸選択 = overlap 面積が小さい方の軸 (= 押し出しが少ない = 影響最小)
|
|
370
|
+
const axis: "x" | "y" = dxOverlap < dyOverlap ? "x" : "y";
|
|
371
|
+
// shift 方向 = static から move へのベクトル (move を static から遠ざける)。
|
|
372
|
+
// axis=x なら Cx 差分、 axis=y なら Cy 差分の符号 で決定。
|
|
373
|
+
const moveVal = axis === "x" ? (moveIsA ? aCx : bCx) : (moveIsA ? aCy : bCy);
|
|
374
|
+
const staticVal = axis === "x" ? (moveIsA ? bCx : aCx) : (moveIsA ? bCy : aCy);
|
|
375
|
+
const sign: 1 | -1 = moveVal >= staticVal ? 1 : -1;
|
|
376
|
+
const amount = (axis === "x" ? dxOverlap : dyOverlap) + minClearance;
|
|
377
|
+
out.push({ moveId, axis, sign, amount });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// chain 伝搬 = shift された node と新規に overlap した「別 node」 も同方向同量 shift。
|
|
384
|
+
// 単純 loop で全 pair 再検出 + shift 適用、 shift 発生 0 で収束判定。
|
|
385
|
+
for (let iter = 0; iter < maxIter; iter++) {
|
|
386
|
+
const pairs = detectPairs();
|
|
387
|
+
if (pairs.length === 0) return applyShifts(nodes, shiftMap);
|
|
388
|
+
|
|
389
|
+
// 同 node に複数 shift 命令が来る場合、 各軸 max を採用 (累積せず「必要最小限で1回」)。
|
|
390
|
+
const shiftThisIter = new Map<string, { dx: number; dy: number }>();
|
|
391
|
+
for (const p of pairs) {
|
|
392
|
+
const prev = shiftThisIter.get(p.moveId) ?? { dx: 0, dy: 0 };
|
|
393
|
+
if (p.axis === "x") {
|
|
394
|
+
prev.dx = p.sign > 0 ? Math.max(prev.dx, p.amount) : Math.min(prev.dx, -p.amount);
|
|
395
|
+
} else {
|
|
396
|
+
prev.dy = p.sign > 0 ? Math.max(prev.dy, p.amount) : Math.min(prev.dy, -p.amount);
|
|
397
|
+
}
|
|
398
|
+
shiftThisIter.set(p.moveId, prev);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 累積 shift Map に足し込み
|
|
402
|
+
for (const [id, s] of shiftThisIter) {
|
|
403
|
+
const prev = shiftMap.get(id) ?? { dx: 0, dy: 0 };
|
|
404
|
+
shiftMap.set(id, { dx: prev.dx + s.dx, dy: prev.dy + s.dy });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// MAX_ITER 到達 = 最大 iter でも収束せず (稀な densely packed layout)、 現時点 shift を返す。
|
|
409
|
+
return applyShifts(nodes, shiftMap);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function applyShifts(
|
|
413
|
+
nodes: LaidNode[],
|
|
414
|
+
shiftMap: Map<string, { dx: number; dy: number }>,
|
|
415
|
+
): LaidNode[] {
|
|
416
|
+
if (shiftMap.size === 0) return nodes;
|
|
417
|
+
return nodes.map((n) => {
|
|
418
|
+
const s = shiftMap.get(n.id);
|
|
419
|
+
if (!s || (s.dx === 0 && s.dy === 0)) return n;
|
|
420
|
+
return { ...n, cx: n.cx + s.dx, cy: n.cy + s.dy };
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function detectCollisions(
|
|
425
|
+
bboxes: BBox[],
|
|
426
|
+
edges: LaidEdge[],
|
|
427
|
+
): Array<{ a: BBox; b: BBox; overlap_area: number }> {
|
|
428
|
+
const edgeMap = new Map(edges.map((e) => [e.id, e]));
|
|
429
|
+
const out: Array<{ a: BBox; b: BBox; overlap_area: number }> = [];
|
|
430
|
+
// path 別の segments cache (同じ edge を多 bbox と比較するので 1 回展開)。
|
|
431
|
+
const segmentsCache = new Map<string, Array<{ x1: number; y1: number; x2: number; y2: number }>>();
|
|
432
|
+
const getSegments = (edgeId: string): Array<{ x1: number; y1: number; x2: number; y2: number }> | null => {
|
|
433
|
+
if (segmentsCache.has(edgeId)) return segmentsCache.get(edgeId)!;
|
|
434
|
+
const edge = edgeMap.get(edgeId);
|
|
435
|
+
if (!edge) return null;
|
|
436
|
+
const segs = extractPathSegments(edge.d);
|
|
437
|
+
segmentsCache.set(edgeId, segs);
|
|
438
|
+
return segs;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* label と比べる時だけ使う、 曲線を **実際に描かれる形に開いた** segment 列 (#376)。
|
|
443
|
+
*
|
|
444
|
+
* 弦のままだと 2 つの見落としが出る。
|
|
445
|
+
* 1. 膨らんだ弧が pill を貫いても、 弦が pill の外なら見えない
|
|
446
|
+
* 2. 端点が同じ弧は弦が完全に一致するため、 `segmentsExcludingOwnPath` が兄弟の弧を丸ごと
|
|
447
|
+
* 「自分の線」 として取り除く (実測 = 往復 4 本の図で 3 本の弧が label を貫いても 0 件)
|
|
448
|
+
*
|
|
449
|
+
* path どうしの交差判定には使わない。 兄弟が共有する区間は 1 本の線として描かれるのが設計
|
|
450
|
+
* (CAR-429) で、 開いた形で比べると端点付近のほぼ平行な小片が交差として出る。
|
|
451
|
+
*/
|
|
452
|
+
const curveCache = new Map<string, Array<{ x1: number; y1: number; x2: number; y2: number }>>();
|
|
453
|
+
const getCurveSegments = (edgeId: string): Array<{ x1: number; y1: number; x2: number; y2: number }> | null => {
|
|
454
|
+
if (curveCache.has(edgeId)) return curveCache.get(edgeId)!;
|
|
455
|
+
const edge = edgeMap.get(edgeId);
|
|
456
|
+
if (!edge) return null;
|
|
457
|
+
const segs = flattenPathSegments(extractPathSegments(edge.d));
|
|
458
|
+
curveCache.set(edgeId, segs);
|
|
459
|
+
return segs;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
// overlap 検出は spatial hash で隣接 cell のみ比較 (O(n²) → O(n * k))。
|
|
463
|
+
const visit = (a: BBox, b: BBox): void => {
|
|
464
|
+
if (a.id === b.id) return;
|
|
465
|
+
if (isLegitContact(a, b, edgeMap)) return;
|
|
466
|
+
|
|
467
|
+
const pathSide = a.kind === "edge-path" ? a : b.kind === "edge-path" ? b : null;
|
|
468
|
+
const otherSide = pathSide === a ? b : pathSide === b ? a : null;
|
|
469
|
+
if (pathSide && otherSide && (otherSide.kind === "edge-label" || otherSide.kind === "node")) {
|
|
470
|
+
const isLabel = otherSide.kind === "edge-label";
|
|
471
|
+
const segments = isLabel ? getCurveSegments(pathSide.id) : getSegments(pathSide.id);
|
|
472
|
+
if (segments) {
|
|
473
|
+
// label 側は自 path と重なる segment を除外する (Issue #211、 segmentsExcludingOwnPath SSOT)。
|
|
474
|
+
const ownSegs = isLabel ? getCurveSegments(otherSide.id) : null;
|
|
475
|
+
const effective = ownSegs ? segmentsExcludingOwnPath(segments, ownSegs) : segments;
|
|
476
|
+
const rect = { x: otherSide.x, y: otherSide.y, w: otherSide.w, h: otherSide.h };
|
|
477
|
+
if (segmentsCrossRect(effective, rect)) {
|
|
478
|
+
out.push({ a, b, overlap_area: -1 });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// edge-path × edge-path は segments 同士の実 line 交差判定。
|
|
485
|
+
// 同 from / 同 to の別 edge は sidepoint 一致で交点計算 t=0 or u=0 になるが、 legit 分岐なので除外。
|
|
486
|
+
if (a.kind === "edge-path" && b.kind === "edge-path") {
|
|
487
|
+
const edgeA = edgeMap.get(a.id);
|
|
488
|
+
const edgeB = edgeMap.get(b.id);
|
|
489
|
+
const shareFrom = edgeA && edgeB && edgeA.from === edgeB.from;
|
|
490
|
+
const shareTo = edgeA && edgeB && edgeA.to === edgeB.to;
|
|
491
|
+
const segsA = getSegments(a.id);
|
|
492
|
+
const segsB = getSegments(b.id);
|
|
493
|
+
if (segsA && segsB) {
|
|
494
|
+
for (const sa of segsA) {
|
|
495
|
+
for (const sb of segsB) {
|
|
496
|
+
if (segmentsIntersect(sa.x1, sa.y1, sa.x2, sa.y2, sb.x1, sb.y1, sb.x2, sb.y2)) {
|
|
497
|
+
// 同 from / 同 to 起因の 端点接触は legit として除外
|
|
498
|
+
if (shareFrom || shareTo) {
|
|
499
|
+
const eps = 0.5;
|
|
500
|
+
const isEndpointTouch =
|
|
501
|
+
(Math.abs(sa.x1 - sb.x1) < eps && Math.abs(sa.y1 - sb.y1) < eps) ||
|
|
502
|
+
(Math.abs(sa.x2 - sb.x2) < eps && Math.abs(sa.y2 - sb.y2) < eps) ||
|
|
503
|
+
(Math.abs(sa.x1 - sb.x2) < eps && Math.abs(sa.y1 - sb.y2) < eps) ||
|
|
504
|
+
(Math.abs(sa.x2 - sb.x1) < eps && Math.abs(sa.y2 - sb.y1) < eps);
|
|
505
|
+
if (isEndpointTouch) continue;
|
|
506
|
+
}
|
|
507
|
+
out.push({ a, b, overlap_area: -1 });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const overlapArea = computeOverlapArea(a, b);
|
|
517
|
+
if (overlapArea > 0) {
|
|
518
|
+
out.push({ a, b, overlap_area: overlapArea });
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
forEachCandidatePair(bboxes, 0, visit);
|
|
523
|
+
return out;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* path d 文字列を、 描画する線分の列に分解
|
|
528
|
+
*
|
|
529
|
+
* 曲線 (Q / C) は端点を結ぶ弦に落とす。 交差判定には十分だが、 弦は実際の曲線が通る場所とは
|
|
530
|
+
* ずれる。 「この線分は本当に真っ直ぐか」 を知る必要がある呼出側のために、 由来の command
|
|
531
|
+
* (`cmd`) と制御点 (`ctrl`、 曲線のみ) を併せて返す。 弦を直線として扱うと、 端点が同じ高さで
|
|
532
|
+
* 大きく膨らむ曲線を「水平な直線」 と誤認する。
|
|
533
|
+
*/
|
|
534
|
+
export function extractPathSegments(
|
|
535
|
+
d: string,
|
|
536
|
+
): Array<{
|
|
537
|
+
x1: number;
|
|
538
|
+
y1: number;
|
|
539
|
+
x2: number;
|
|
540
|
+
y2: number;
|
|
541
|
+
cmd: "L" | "Q" | "C";
|
|
542
|
+
ctrl?: Array<{ x: number; y: number }>;
|
|
543
|
+
}> {
|
|
544
|
+
const segs: Array<{
|
|
545
|
+
x1: number;
|
|
546
|
+
y1: number;
|
|
547
|
+
x2: number;
|
|
548
|
+
y2: number;
|
|
549
|
+
cmd: "L" | "Q" | "C";
|
|
550
|
+
ctrl?: Array<{ x: number; y: number }>;
|
|
551
|
+
}> = [];
|
|
552
|
+
const commandRegex = /([MLCQZmlcqz])([^MLCQZmlcqz]*)/g;
|
|
553
|
+
let cur: { x: number; y: number } = { x: 0, y: 0 };
|
|
554
|
+
let m: RegExpExecArray | null;
|
|
555
|
+
while ((m = commandRegex.exec(d)) !== null) {
|
|
556
|
+
const cmd = m[1]!;
|
|
557
|
+
const args = (m[2] ?? "").trim().split(/[\s,]+/).filter(Boolean).map(parseFloat);
|
|
558
|
+
// 引数が足りない command は読み飛ばす。 `args[n]!` で埋めると undefined が座標に入り、
|
|
559
|
+
// 以降の segment が全て NaN になる = 距離判定が黙って対象外になる (#370)。
|
|
560
|
+
const need = cmd === "M" || cmd === "L" ? 2 : cmd === "Q" ? 4 : cmd === "C" ? 6 : 0;
|
|
561
|
+
if (need > 0 && (args.length < need || !args.slice(0, need).every(Number.isFinite))) continue;
|
|
562
|
+
if (cmd === "M") {
|
|
563
|
+
cur = { x: args[0]!, y: args[1]! };
|
|
564
|
+
} else if (cmd === "L") {
|
|
565
|
+
const next = { x: args[0]!, y: args[1]! };
|
|
566
|
+
segs.push({ x1: cur.x, y1: cur.y, x2: next.x, y2: next.y, cmd: "L" });
|
|
567
|
+
cur = next;
|
|
568
|
+
} else if (cmd === "Q") {
|
|
569
|
+
const next = { x: args[2]!, y: args[3]! };
|
|
570
|
+
segs.push({
|
|
571
|
+
x1: cur.x, y1: cur.y, x2: next.x, y2: next.y, cmd: "Q",
|
|
572
|
+
ctrl: [{ x: args[0]!, y: args[1]! }],
|
|
573
|
+
});
|
|
574
|
+
cur = next;
|
|
575
|
+
} else if (cmd === "C") {
|
|
576
|
+
const next = { x: args[4]!, y: args[5]! };
|
|
577
|
+
segs.push({
|
|
578
|
+
x1: cur.x, y1: cur.y, x2: next.x, y2: next.y, cmd: "C",
|
|
579
|
+
ctrl: [{ x: args[0]!, y: args[1]! }, { x: args[2]!, y: args[3]! }],
|
|
580
|
+
});
|
|
581
|
+
cur = next;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return segs;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* 曲線 (Q / C) を、 実際に描かれる線に沿った折れ線に開く。
|
|
589
|
+
*
|
|
590
|
+
* `extractPathSegments` は曲線を端点どうしの弦に落とす。 交差判定はそれで足りるが、
|
|
591
|
+
* 「線からどれだけ離れているか」 を測る用途では弦は使えない = 弦は描かれていないため、
|
|
592
|
+
* 膨らむ側にある物は実際より遠く、 へこむ側にある物は実際より近く出る。
|
|
593
|
+
*
|
|
594
|
+
* 分割数は曲がり方から決める。 等分割した折れ線と真の曲線の差には閉じた形の上限がある。
|
|
595
|
+
* 二次曲線は `B(t) − 弦(t) = 2t(1−t) · (P1 − (P0+P2)/2)` なので、 `2t(1−t)` の最大 1/2 から
|
|
596
|
+
* `‖P0 − 2P1 + P2‖ / 4`。 n 等分すると各片の 2 階差分が 1/n² になるので `/(4n²)`。 三次曲線は
|
|
597
|
+
* `3 · max(‖P0 − 2P1 + P2‖, ‖P1 − 2P2 + P3‖) / (4n²)`。
|
|
598
|
+
*
|
|
599
|
+
* 分子は制御点の振れ幅で上限が無い。 そのため分割数を固定すると誤差も青天井になる
|
|
600
|
+
* (#384 実測 = `M 0 0 Q 50 -320, 100 9600` は 16 分割だと t=1/32 の y=-10 を両隣の分割点 y=0 が
|
|
601
|
+
* 挟んで消す)。 そこで上の式を n について解き、 誤差が `FLATTEN_TOLERANCE` 以下になる最小の n を
|
|
602
|
+
* 取る。 許容差 0.25 world は、 この折れ線を使う判定の閾値 (14 / 16 / 32 / 80 world) の 1/50 以下。
|
|
603
|
+
*
|
|
604
|
+
* 係数 4 は `test/label-proximity.test.ts` が確かめている。 対称な弧では標本探索の値が式の
|
|
605
|
+
* 上限の 9 割を超える = 式が緩すぎない。 8 で置くと n が √2 倍小さく出るので、 この形で
|
|
606
|
+
* 許容差を超える。
|
|
607
|
+
*
|
|
608
|
+
* 式が押さえるのは「同じ t での曲線と弦の差」 で、 実際に要る「曲線から折れ線までの距離」 は
|
|
609
|
+
* それ以下になる。 安全側にずれる。 どれだけ余裕が出るかは形で変わり、 対称な弧では上限に迫る。
|
|
610
|
+
* 上限からどれだけ下に来るかは形しだいで、 保証には使えない。
|
|
611
|
+
*
|
|
612
|
+
* 下限 `FLATTEN_MIN_DIVISIONS` = 16 を置く。 式どおりに取ると緩い弧では 16 を下回り
|
|
613
|
+
* (膨らみ 8 world の弧なら 4 分割)、 許容差の範囲では正しくても **これまでより粗くなる**。
|
|
614
|
+
* 交差判定のように閾値 0 で使う呼出元 (`collisions.ts` の label × 線、 `edges.ts` の兄弟 path
|
|
615
|
+
* 回避) は許容差を織り込んでいないので、 粗くなった分がそのまま判定の変化になる
|
|
616
|
+
* (#384 実測 = `M 0 0 Q 500 -8, 1000 0` と矩形 `{x:121, y:-1.8, w:8, h:0.1}` は 16 分割なら
|
|
617
|
+
* 交差、 4 分割だと素通り)。 精度を落とす変更にはしない。
|
|
618
|
+
*
|
|
619
|
+
* 上限 `FLATTEN_MAX_DIVISIONS` は、 極端な制御点で分割数が爆発して判定が止まるのを防ぐ安全弁。
|
|
620
|
+
* **上限に当たると許容差の保証は切れる**。 保証が効くのは `dev ≤ 4 · 許容差 · 上限²`
|
|
621
|
+
* = 16,777,216 まで。 これを超えるには二次曲線で制御点を 8,000,000 world 振る必要があり、
|
|
622
|
+
* `edges.ts` が出す弧 (辺の本数に比例、 1026 本並べても `dev` は 65,600 = 257 分割) では届かない。
|
|
623
|
+
* 上限に当たった時にどうなるかは `test/label-proximity.test.ts` が形を 2 つ並べて固定している。
|
|
624
|
+
*
|
|
625
|
+
* 制御点の数が command と合わない segment は弦に落とす。 足りない分を補うと、 元の d 文字列に
|
|
626
|
+
* 無い形を勝手に作ることになる。
|
|
627
|
+
*
|
|
628
|
+
* @param segs `extractPathSegments()` の出力
|
|
629
|
+
* @returns 直線のみからなる segment 列。 `L` はそのまま。 曲線は `FLATTEN_MIN_DIVISIONS` 以上
|
|
630
|
+
* `FLATTEN_MAX_DIVISIONS` 以下の本数に開く。 誤差が `FLATTEN_TOLERANCE` 以下に収まるのは、
|
|
631
|
+
* 必要な分割数が上限以下に収まる曲線 (`dev ≤ 4 · 許容差 · 上限²`) に限る。 上限を超える
|
|
632
|
+
* 曲線では保証されず、 許容差を超えることがある (幅 4e7 の対称な弧では式の上限が 0.596
|
|
633
|
+
* world で、 標本探索の値も許容差を超える)
|
|
634
|
+
*/
|
|
635
|
+
export const FLATTEN_TOLERANCE = 0.25;
|
|
636
|
+
export const FLATTEN_MIN_DIVISIONS = 16;
|
|
637
|
+
export const FLATTEN_MAX_DIVISIONS = 4096;
|
|
638
|
+
|
|
639
|
+
export function flattenPathSegments(
|
|
640
|
+
segs: ReadonlyArray<{
|
|
641
|
+
x1: number;
|
|
642
|
+
y1: number;
|
|
643
|
+
x2: number;
|
|
644
|
+
y2: number;
|
|
645
|
+
cmd: "L" | "Q" | "C";
|
|
646
|
+
ctrl?: ReadonlyArray<{ x: number; y: number }>;
|
|
647
|
+
}>,
|
|
648
|
+
): Array<{ x1: number; y1: number; x2: number; y2: number }> {
|
|
649
|
+
const out: Array<{ x1: number; y1: number; x2: number; y2: number }> = [];
|
|
650
|
+
for (const s of segs) {
|
|
651
|
+
const ctrl = s.ctrl ?? [];
|
|
652
|
+
const need = s.cmd === "Q" ? 1 : s.cmd === "C" ? 2 : 0;
|
|
653
|
+
if (need === 0 || ctrl.length < need) {
|
|
654
|
+
out.push({ x1: s.x1, y1: s.y1, x2: s.x2, y2: s.y2 });
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
// 2 階差分の大きさ = 曲がり具合。 誤差上限 dev / (4n²) ≤ tol を n について解く。
|
|
658
|
+
const secondDiff = (
|
|
659
|
+
p0: { x: number; y: number },
|
|
660
|
+
p1: { x: number; y: number },
|
|
661
|
+
p2: { x: number; y: number },
|
|
662
|
+
): number => Math.hypot(p0.x - 2 * p1.x + p2.x, p0.y - 2 * p1.y + p2.y);
|
|
663
|
+
const p0 = { x: s.x1, y: s.y1 };
|
|
664
|
+
const p3 = { x: s.x2, y: s.y2 };
|
|
665
|
+
const dev =
|
|
666
|
+
s.cmd === "Q"
|
|
667
|
+
? secondDiff(p0, ctrl[0]!, p3)
|
|
668
|
+
: 3 * Math.max(secondDiff(p0, ctrl[0]!, ctrl[1]!), secondDiff(ctrl[0]!, ctrl[1]!, p3));
|
|
669
|
+
const DIVISIONS = Math.min(
|
|
670
|
+
FLATTEN_MAX_DIVISIONS,
|
|
671
|
+
Math.max(FLATTEN_MIN_DIVISIONS, Math.ceil(Math.sqrt(dev / (4 * FLATTEN_TOLERANCE)))),
|
|
672
|
+
);
|
|
673
|
+
const at = (t: number): { x: number; y: number } => {
|
|
674
|
+
const u = 1 - t;
|
|
675
|
+
if (s.cmd === "Q") {
|
|
676
|
+
const c = ctrl[0]!;
|
|
677
|
+
return {
|
|
678
|
+
x: u * u * s.x1 + 2 * u * t * c.x + t * t * s.x2,
|
|
679
|
+
y: u * u * s.y1 + 2 * u * t * c.y + t * t * s.y2,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
const c1 = ctrl[0]!;
|
|
683
|
+
const c2 = ctrl[1]!;
|
|
684
|
+
return {
|
|
685
|
+
x: u * u * u * s.x1 + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * s.x2,
|
|
686
|
+
y: u * u * u * s.y1 + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * s.y2,
|
|
687
|
+
};
|
|
688
|
+
};
|
|
689
|
+
let prev = { x: s.x1, y: s.y1 };
|
|
690
|
+
for (let i = 1; i <= DIVISIONS; i++) {
|
|
691
|
+
const p = at(i / DIVISIONS);
|
|
692
|
+
out.push({ x1: prev.x, y1: prev.y, x2: p.x, y2: p.y });
|
|
693
|
+
prev = p;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return out;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* path 全 segment の total length と、 中央 t=0.5 地点の座標 + tangent + normal を返す。
|
|
701
|
+
* routing v10 (2026-07-02) = label 配置の hard 基準として、 label は中心 ± 20% の t 範囲に限定する。
|
|
702
|
+
* tangent = path 進行方向、 normal = tangent の 90° 左 (SVG y 軸下方向を正とする座標系での up 方向)。
|
|
703
|
+
* normal は tangent を反時計 90° 回転 = (-dy, dx) / len。 SVG 座標では画面上向き。
|
|
704
|
+
*/
|
|
705
|
+
export function pathCenterFrame(
|
|
706
|
+
segs: Array<{ x1: number; y1: number; x2: number; y2: number }>,
|
|
707
|
+
): { cx: number; cy: number; tx: number; ty: number; nx: number; ny: number; totalLen: number } | null {
|
|
708
|
+
if (segs.length === 0) return null;
|
|
709
|
+
const lens = segs.map((s) => Math.hypot(s.x2 - s.x1, s.y2 - s.y1));
|
|
710
|
+
const totalLen = lens.reduce((a, b) => a + b, 0);
|
|
711
|
+
if (totalLen === 0) return null;
|
|
712
|
+
const halfLen = totalLen / 2;
|
|
713
|
+
let acc = 0;
|
|
714
|
+
for (let i = 0; i < segs.length; i++) {
|
|
715
|
+
const l = lens[i]!;
|
|
716
|
+
if (acc + l >= halfLen) {
|
|
717
|
+
const t = (halfLen - acc) / l;
|
|
718
|
+
const s = segs[i]!;
|
|
719
|
+
const cx = s.x1 + (s.x2 - s.x1) * t;
|
|
720
|
+
const cy = s.y1 + (s.y2 - s.y1) * t;
|
|
721
|
+
const dx = s.x2 - s.x1;
|
|
722
|
+
const dy = s.y2 - s.y1;
|
|
723
|
+
const tx = dx / l;
|
|
724
|
+
const ty = dy / l;
|
|
725
|
+
// normal = tangent 反時計 90° = (-ty, tx)、 SVG 座標系では画面上向き
|
|
726
|
+
const nx = -ty;
|
|
727
|
+
const ny = tx;
|
|
728
|
+
return { cx, cy, tx, ty, nx, ny, totalLen };
|
|
729
|
+
}
|
|
730
|
+
acc += l;
|
|
731
|
+
}
|
|
732
|
+
const last = segs[segs.length - 1]!;
|
|
733
|
+
return { cx: last.x2, cy: last.y2, tx: 0, ty: 0, nx: 0, ny: 0, totalLen };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* label center が「path 中央 t=0.5 ± 20% の範囲」 かつ「normal 方向 ± 20°」 に配置されているか判定。
|
|
738
|
+
* anchor 範囲外 = label が edge の端寄り or 側面に外れて「どの edge の label か視覚上不明」 状態。
|
|
739
|
+
* routing v10 hard 制約 = このいずれか false なら label 位置は無効候補。
|
|
740
|
+
*/
|
|
741
|
+
export function isLabelInCenterAnchor(
|
|
742
|
+
labelCx: number,
|
|
743
|
+
labelCy: number,
|
|
744
|
+
frame: { cx: number; cy: number; tx: number; ty: number; nx: number; ny: number; totalLen: number },
|
|
745
|
+
opts: { tTolerance?: number; normalAngleTolDeg?: number } = {},
|
|
746
|
+
): boolean {
|
|
747
|
+
const tTol = opts.tTolerance ?? 0.2; // ±20% of totalLen
|
|
748
|
+
const angleTol = opts.normalAngleTolDeg ?? 30; // ±30° from normal
|
|
749
|
+
const rx = labelCx - frame.cx;
|
|
750
|
+
const ry = labelCy - frame.cy;
|
|
751
|
+
// tangent 方向成分 = path center からの弧上変位、 |t 成分| ≤ tTol * totalLen なら中央 anchor 内
|
|
752
|
+
const tComp = rx * frame.tx + ry * frame.ty;
|
|
753
|
+
if (Math.abs(tComp) > tTol * frame.totalLen) return false;
|
|
754
|
+
// normal 方向成分 = path から離れた距離 (符号あり)、 tangent 方向より normal 方向が dominant なら OK
|
|
755
|
+
const nComp = rx * frame.nx + ry * frame.ny;
|
|
756
|
+
const totalDist = Math.hypot(rx, ry);
|
|
757
|
+
if (totalDist === 0) return true;
|
|
758
|
+
// normal 方向との角度差 (0°=真上 or 真下、 90°=真横)
|
|
759
|
+
const cosAngle = Math.abs(nComp) / totalDist;
|
|
760
|
+
const angleDeg = Math.acos(Math.max(-1, Math.min(1, cosAngle))) * (180 / Math.PI);
|
|
761
|
+
return angleDeg <= angleTol;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** 線分の列が矩形と 1 つでも交差するか判定 (Liang-Barsky) */
|
|
765
|
+
export function segmentsCrossRect(
|
|
766
|
+
segs: Array<{ x1: number; y1: number; x2: number; y2: number }>,
|
|
767
|
+
rect: { x: number; y: number; w: number; h: number },
|
|
768
|
+
): boolean {
|
|
769
|
+
for (const s of segs) {
|
|
770
|
+
if (segmentIntersectsRect(s, rect)) return true;
|
|
771
|
+
}
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function segmentIntersectsRect(
|
|
776
|
+
seg: { x1: number; y1: number; x2: number; y2: number },
|
|
777
|
+
rect: { x: number; y: number; w: number; h: number },
|
|
778
|
+
): boolean {
|
|
779
|
+
if (pointInRect(seg.x1, seg.y1, rect)) return true;
|
|
780
|
+
if (pointInRect(seg.x2, seg.y2, rect)) return true;
|
|
781
|
+
const x1 = rect.x;
|
|
782
|
+
const y1 = rect.y;
|
|
783
|
+
const x2 = rect.x + rect.w;
|
|
784
|
+
const y2 = rect.y + rect.h;
|
|
785
|
+
return (
|
|
786
|
+
segmentsIntersect(seg.x1, seg.y1, seg.x2, seg.y2, x1, y1, x2, y1) ||
|
|
787
|
+
segmentsIntersect(seg.x1, seg.y1, seg.x2, seg.y2, x2, y1, x2, y2) ||
|
|
788
|
+
segmentsIntersect(seg.x1, seg.y1, seg.x2, seg.y2, x2, y2, x1, y2) ||
|
|
789
|
+
segmentsIntersect(seg.x1, seg.y1, seg.x2, seg.y2, x1, y2, x1, y1)
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function pointInRect(x: number, y: number, rect: { x: number; y: number; w: number; h: number }): boolean {
|
|
794
|
+
return x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function segmentsIntersect(
|
|
798
|
+
x1: number, y1: number, x2: number, y2: number,
|
|
799
|
+
x3: number, y3: number, x4: number, y4: number,
|
|
800
|
+
): boolean {
|
|
801
|
+
const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
|
|
802
|
+
if (denom === 0) return false;
|
|
803
|
+
const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
|
|
804
|
+
const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
|
|
805
|
+
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function isLegitContact(a: BBox, b: BBox, edgeMap: Map<string, LaidEdge>): boolean {
|
|
809
|
+
const pathSide = a.kind === "edge-path" ? a : b.kind === "edge-path" ? b : null;
|
|
810
|
+
const nodeSide = a.kind === "node" ? a : b.kind === "node" ? b : null;
|
|
811
|
+
if (pathSide && nodeSide) {
|
|
812
|
+
const edge = edgeMap.get(pathSide.id);
|
|
813
|
+
if (edge && (edge.from === nodeSide.id || edge.to === nodeSide.id)) {
|
|
814
|
+
return true;
|
|
815
|
+
}
|
|
816
|
+
// dotted-flow は経由 node 貫通 semantic なので path が中間 node と接触するのは legit。
|
|
817
|
+
if (edge && edge.style === "dotted-flow") {
|
|
818
|
+
return true;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
type Seg = { x1: number; y1: number; x2: number; y2: number };
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* 同一直線とみなす垂直距離の誤差 (world unit)。
|
|
828
|
+
*
|
|
829
|
+
* 兄弟 edge が共有する segment は `routePath` が同じ式で計算するため座標が完全に一致する。
|
|
830
|
+
* よって許容するのは浮動小数の端数だけでよい。 これを視覚的に無視できる距離 (1 world 等) まで
|
|
831
|
+
* 緩めると、 長い segment が終端で 1 world ずれているだけで「同一直線」 と誤判定し、
|
|
832
|
+
* 別の path が判定から丸ごと消える。
|
|
833
|
+
*/
|
|
834
|
+
const COINCIDENT_SEGMENT_EPSILON = 1e-6;
|
|
835
|
+
|
|
836
|
+
/** 点に潰れた segment とみなす長さ (world unit)。 浮動小数の端数だけを落とす。 */
|
|
837
|
+
const DEGENERATE_SEGMENT_EPSILON = 1e-9;
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* `base` 上で `other` と同一直線上に重なる区間を、 `base` の媒介変数 [0, 1] で返す。
|
|
841
|
+
* 同一直線でない場合と、 重なりが 1 点 (端点接触) しかない場合は null。
|
|
842
|
+
*/
|
|
843
|
+
function coincidentInterval(
|
|
844
|
+
base: Seg,
|
|
845
|
+
other: Seg,
|
|
846
|
+
eps = COINCIDENT_SEGMENT_EPSILON,
|
|
847
|
+
): [number, number] | null {
|
|
848
|
+
const adx = base.x2 - base.x1;
|
|
849
|
+
const ady = base.y2 - base.y1;
|
|
850
|
+
const bdx = other.x2 - other.x1;
|
|
851
|
+
const bdy = other.y2 - other.y1;
|
|
852
|
+
const aLen = Math.hypot(adx, ady);
|
|
853
|
+
const bLen = Math.hypot(bdx, bdy);
|
|
854
|
+
if (aLen < eps || bLen < eps) return null;
|
|
855
|
+
const cross = (ux: number, uy: number, vx: number, vy: number): number => ux * vy - uy * vx;
|
|
856
|
+
// 平行か
|
|
857
|
+
if (Math.abs(cross(adx, ady, bdx, bdy)) > eps * Math.max(aLen, bLen)) return null;
|
|
858
|
+
// other の両端点が base の直線上に載るか
|
|
859
|
+
if (Math.abs(cross(adx, ady, other.x1 - base.x1, other.y1 - base.y1)) > eps * aLen) return null;
|
|
860
|
+
if (Math.abs(cross(adx, ady, other.x2 - base.x1, other.y2 - base.y1)) > eps * aLen) return null;
|
|
861
|
+
const lenSq = aLen * aLen;
|
|
862
|
+
const t1 = ((other.x1 - base.x1) * adx + (other.y1 - base.y1) * ady) / lenSq;
|
|
863
|
+
const t2 = ((other.x2 - base.x1) * adx + (other.y2 - base.y1) * ady) / lenSq;
|
|
864
|
+
const lo = Math.max(0, Math.min(t1, t2));
|
|
865
|
+
const hi = Math.min(1, Math.max(t1, t2));
|
|
866
|
+
if (hi - lo <= eps / aLen) return null;
|
|
867
|
+
return [lo, hi];
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** `base` の媒介変数区間 [t0, t1] を切り出した部分 segment。 */
|
|
871
|
+
function subSegment(base: Seg, t0: number, t1: number): Seg {
|
|
872
|
+
const adx = base.x2 - base.x1;
|
|
873
|
+
const ady = base.y2 - base.y1;
|
|
874
|
+
return {
|
|
875
|
+
x1: base.x1 + adx * t0,
|
|
876
|
+
y1: base.y1 + ady * t0,
|
|
877
|
+
x2: base.x1 + adx * t1,
|
|
878
|
+
y2: base.y1 + ady * t1,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Issue #211 SSOT ... 別 edge の segments から「自 path と重なっている区間」 を切り落として返す。
|
|
884
|
+
*
|
|
885
|
+
* L 字経路の label pill は `routePath` の設計で自分の縦走に跨って置かれる (`labelX = midX`)。
|
|
886
|
+
* fan-out / fan-in の兄弟 edge は同じ node から出る (入る) ため、 corner までの水平部と
|
|
887
|
+
* 縦走の共通部分が座標レベルで一致する。 pill が自 path に跨れば必然的に兄弟の path にも跨るが、
|
|
888
|
+
* 描画上は 1 本の線に pill が載っているだけで視覚欠陥ではない。
|
|
889
|
+
*
|
|
890
|
+
* よって「自 path と同一直線上で重なる区間」 は自 path の一部として扱い、 label ↔ path の
|
|
891
|
+
* 衝突判定と押し出しの対象から外す。
|
|
892
|
+
*
|
|
893
|
+
* 差し引くのは **共有区間だけ** で、 その先へ伸びた非共有区間は残す。 兄弟の縦走が自分より先まで
|
|
894
|
+
* 伸びていて、 その延長部分が pill を貫くなら、 1 本の線に pill が載っているのではなく別の線が
|
|
895
|
+
* pill を横切っている状態であり、 視覚欠陥として検出しなければならない。
|
|
896
|
+
*/
|
|
897
|
+
export function segmentsExcludingOwnPath(otherSegs: Seg[], ownSegs: Seg[]): Seg[] {
|
|
898
|
+
if (ownSegs.length === 0) return otherSegs;
|
|
899
|
+
const out: Seg[] = [];
|
|
900
|
+
for (const s of otherSegs) {
|
|
901
|
+
const covered: Array<[number, number]> = [];
|
|
902
|
+
for (const own of ownSegs) {
|
|
903
|
+
const iv = coincidentInterval(s, own);
|
|
904
|
+
if (iv) covered.push(iv);
|
|
905
|
+
}
|
|
906
|
+
if (covered.length === 0) {
|
|
907
|
+
out.push(s);
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
covered.sort((a, b) => a[0] - b[0]);
|
|
911
|
+
let cursor = 0;
|
|
912
|
+
for (const [lo, hi] of covered) {
|
|
913
|
+
if (lo > cursor) out.push(subSegment(s, cursor, lo));
|
|
914
|
+
cursor = Math.max(cursor, hi);
|
|
915
|
+
if (cursor >= 1) break;
|
|
916
|
+
}
|
|
917
|
+
if (cursor < 1) out.push(subSegment(s, cursor, 1));
|
|
918
|
+
}
|
|
919
|
+
// 切り落とした残りが点に潰れたものだけを捨てる。 長さを持つ残差は、 たとえ 1 world 未満でも
|
|
920
|
+
// 「兄弟が自 path より先へ伸びた区間」 なので判定対象に残す (COINCIDENT_SEGMENT_EPSILON は
|
|
921
|
+
// 同一直線の判定誤差であって、 残差の採否の閾値ではない)。
|
|
922
|
+
return out.filter((s) => Math.hypot(s.x2 - s.x1, s.y2 - s.y1) > DEGENERATE_SEGMENT_EPSILON);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function isSameEdgePathLabelPair(a: BBox, b: BBox): boolean {
|
|
926
|
+
return (
|
|
927
|
+
((a.kind === "edge-path" && b.kind === "edge-label") ||
|
|
928
|
+
(a.kind === "edge-label" && b.kind === "edge-path")) &&
|
|
929
|
+
a.id === b.id
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function computeOverlapArea(a: BBox, b: BBox): number {
|
|
934
|
+
const xOverlap = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
|
|
935
|
+
const yOverlap = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
|
|
936
|
+
return xOverlap * yOverlap;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* near-collision 用 clearance を返す。 SSOT は `spec.ts` `NEAR_COLLISION_POLICY`。
|
|
941
|
+
*
|
|
942
|
+
* CAR-424 で local `CLEARANCE_POLICY` を `spec.ts` に集約、 本関数は BBox pair を
|
|
943
|
+
* kind 文字列に変換して spec 側 `requiredNearClearance()` に委譲する薄い adapter。
|
|
944
|
+
*/
|
|
945
|
+
function requiredClearance(a: BBox, b: BBox): number {
|
|
946
|
+
return requiredNearClearance(a.kind, b.kind);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function computeGap(a: BBox, b: BBox): number {
|
|
950
|
+
const dx = Math.max(0, Math.max(a.x - (b.x + b.w), b.x - (a.x + a.w)));
|
|
951
|
+
const dy = Math.max(0, Math.max(a.y - (b.y + b.h), b.y - (a.y + a.h)));
|
|
952
|
+
return Math.hypot(dx, dy);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** edge-path × edge-label pair 用の実 segment 距離 (AABB gap は detour path で false negative) */
|
|
956
|
+
function computePathLabelGap(pathBox: BBox, labelBox: BBox, edgeMap: Map<string, LaidEdge>): number {
|
|
957
|
+
const edge = edgeMap.get(pathBox.id);
|
|
958
|
+
if (!edge) return computeGap(pathBox, labelBox);
|
|
959
|
+
const segs = extractPathSegments(edge.d);
|
|
960
|
+
if (segs.length === 0) return computeGap(pathBox, labelBox);
|
|
961
|
+
return measurePathLabelGapSegs(segs, labelBox);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* label の矩形から path segment 列までの最短距離を返す (CAR-485 export)。
|
|
966
|
+
*
|
|
967
|
+
* private `computePathLabelGap` の実 gap 計算部を独立化。 chain 伝搬 shift (CAR-485)
|
|
968
|
+
* で label ↔ edge-path (別 edge) near-collision 判定を BBox AABB ではなく segment 実距離で
|
|
969
|
+
* 実施するために本 helper を使う (detour path で AABB は false negative を出しやすい)。
|
|
970
|
+
*
|
|
971
|
+
* segment が pill を貫いている場合は 0 を返す (Issue #216)。 判定側 (`segmentsCrossRect`) は
|
|
972
|
+
* 貫通を衝突と見なすので、 押し出し側も同じ基準に揃える。
|
|
973
|
+
*
|
|
974
|
+
* **矩形を面として扱う** (#370)。 元は矩形の 4 隅 + 4 辺中央の 8 点だけを probe にしていたが、
|
|
975
|
+
* 8 点は矩形の代表点でしかなく、 線が probe と probe の間に寄ると実際より遠い値を返す。 幅 438
|
|
976
|
+
* の pill の上辺中央から 144 ずれた位置に線の端点があると、 真の距離 79 に対して 109 を返した。
|
|
977
|
+
* 誤差は矩形が大きいほど開くため、 「長い label ほど遠く出る」 という本来消したい歪みが残る。
|
|
978
|
+
*
|
|
979
|
+
* 線分と凸多角形の最短距離は「線分の端点 → 多角形」 と「多角形の頂点 → 線分」 のどちらかで
|
|
980
|
+
* 達する。 矩形は凸なので、 この 2 通りを取れば厳密値になる。
|
|
981
|
+
*
|
|
982
|
+
* @param segs `extractPathSegments()` で分解した line segment 列
|
|
983
|
+
* @param labelBox { x, y, w, h } 形式の label 矩形 (BBox でも同 shape の object でも可)
|
|
984
|
+
* @returns 貫通していれば 0、 それ以外は矩形と segment 列の厳密な最短ユークリッド距離
|
|
985
|
+
*/
|
|
986
|
+
export function measurePathLabelGapSegs(
|
|
987
|
+
segs: Array<{ x1: number; y1: number; x2: number; y2: number }>,
|
|
988
|
+
labelBox: { x: number; y: number; w: number; h: number },
|
|
989
|
+
): number {
|
|
990
|
+
if (segs.length === 0) return Infinity;
|
|
991
|
+
if (segmentsCrossRect(segs, labelBox)) return 0;
|
|
992
|
+
const x2 = labelBox.x + labelBox.w;
|
|
993
|
+
const y2 = labelBox.y + labelBox.h;
|
|
994
|
+
const corners = [
|
|
995
|
+
{ x: labelBox.x, y: labelBox.y },
|
|
996
|
+
{ x: x2, y: labelBox.y },
|
|
997
|
+
{ x: labelBox.x, y: y2 },
|
|
998
|
+
{ x: x2, y: y2 },
|
|
999
|
+
];
|
|
1000
|
+
/** 点から矩形 (面) までの距離。 内側なら 0。 */
|
|
1001
|
+
const pointToRect = (px: number, py: number): number =>
|
|
1002
|
+
Math.hypot(Math.max(labelBox.x - px, 0, px - x2), Math.max(labelBox.y - py, 0, py - y2));
|
|
1003
|
+
let minDist = Infinity;
|
|
1004
|
+
for (const s of segs) {
|
|
1005
|
+
const dx = s.x2 - s.x1;
|
|
1006
|
+
const dy = s.y2 - s.y1;
|
|
1007
|
+
const lenSq = dx * dx + dy * dy;
|
|
1008
|
+
for (const p of corners) {
|
|
1009
|
+
let t = lenSq === 0 ? 0 : ((p.x - s.x1) * dx + (p.y - s.y1) * dy) / lenSq;
|
|
1010
|
+
t = Math.max(0, Math.min(1, t));
|
|
1011
|
+
const d = Math.hypot(p.x - (s.x1 + t * dx), p.y - (s.y1 + t * dy));
|
|
1012
|
+
if (d < minDist) minDist = d;
|
|
1013
|
+
}
|
|
1014
|
+
const dEnd = Math.min(pointToRect(s.x1, s.y1), pointToRect(s.x2, s.y2));
|
|
1015
|
+
if (dEnd < minDist) minDist = dEnd;
|
|
1016
|
+
}
|
|
1017
|
+
return minDist;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
export function detectNearCollisions(
|
|
1021
|
+
bboxes: BBox[],
|
|
1022
|
+
edges: LaidEdge[],
|
|
1023
|
+
): Array<{ a: BBox; b: BBox; gap: number; required: number }> {
|
|
1024
|
+
const edgeMap = new Map(edges.map((e) => [e.id, e]));
|
|
1025
|
+
const out: Array<{ a: BBox; b: BBox; gap: number; required: number }> = [];
|
|
1026
|
+
// max clearance 70px (node|node) を超える距離の pair は near 判定不要、
|
|
1027
|
+
// bbox を 70px margin 膨張させて spatial hash に積めば隣接 cell のみで網羅できる。
|
|
1028
|
+
const NEAR_MARGIN = MAX_CLEARANCE;
|
|
1029
|
+
const visit = (a: BBox, b: BBox): void => {
|
|
1030
|
+
if (a.id === b.id) return;
|
|
1031
|
+
if (isLegitContact(a, b, edgeMap)) return;
|
|
1032
|
+
if (isSameEdgePathLabelPair(a, b)) return;
|
|
1033
|
+
if (computeOverlapArea(a, b) > 0) return;
|
|
1034
|
+
// edge-path × edge-label pair は segment 距離で判定 (AABB gap は detour path で false negative)
|
|
1035
|
+
const isPathLabelPair =
|
|
1036
|
+
(a.kind === "edge-path" && b.kind === "edge-label") ||
|
|
1037
|
+
(a.kind === "edge-label" && b.kind === "edge-path");
|
|
1038
|
+
let gap: number;
|
|
1039
|
+
if (isPathLabelPair) {
|
|
1040
|
+
const path = a.kind === "edge-path" ? a : b;
|
|
1041
|
+
const label = a.kind === "edge-label" ? a : b;
|
|
1042
|
+
gap = computePathLabelGap(path, label, edgeMap);
|
|
1043
|
+
} else {
|
|
1044
|
+
gap = computeGap(a, b);
|
|
1045
|
+
}
|
|
1046
|
+
const required = requiredClearance(a, b);
|
|
1047
|
+
if (required > 0 && gap < required) {
|
|
1048
|
+
out.push({ a, b, gap, required });
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
forEachCandidatePair(bboxes, NEAR_MARGIN, visit);
|
|
1052
|
+
return out;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* spatial hash で「同 cell or 隣接 cell に居る pair」 のみ visit に流す helper。
|
|
1057
|
+
* - cell size は CELL_SIZE (200px)、 box が複数 cell に跨る場合は全 cell に挿入。
|
|
1058
|
+
* - margin > 0 では box を margin 分膨張させて挿入 (near 検出で gap < margin の pair を網羅)。
|
|
1059
|
+
* - 大きい box (lane-label / edge-path) は cell 数が多くなるので、 一定 cell 数を超える box は
|
|
1060
|
+
* 全件 fallback ループに回す (huge case で edge-path が長 1000px+ になっても安全)。
|
|
1061
|
+
*
|
|
1062
|
+
* 重複 visit 防止 ... 同 pair が複数 cell で出会うことがあるので、 (i, j) の dedupe set を使う。
|
|
1063
|
+
*/
|
|
1064
|
+
const CELL_SIZE = 200;
|
|
1065
|
+
const MAX_CLEARANCE = 70;
|
|
1066
|
+
const CELL_FALLBACK_THRESHOLD = 64;
|
|
1067
|
+
|
|
1068
|
+
function forEachCandidatePair(
|
|
1069
|
+
bboxes: BBox[],
|
|
1070
|
+
margin: number,
|
|
1071
|
+
visit: (a: BBox, b: BBox) => void,
|
|
1072
|
+
): void {
|
|
1073
|
+
const n = bboxes.length;
|
|
1074
|
+
if (n < 2) return;
|
|
1075
|
+
// 小サイズは spatial hash overhead が逆効果、 直接全比較。
|
|
1076
|
+
if (n < 64) {
|
|
1077
|
+
for (let i = 0; i < n; i++) {
|
|
1078
|
+
for (let j = i + 1; j < n; j++) {
|
|
1079
|
+
visit(bboxes[i]!, bboxes[j]!);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const grid = new Map<number, number[]>();
|
|
1086
|
+
const oversized: number[] = [];
|
|
1087
|
+
|
|
1088
|
+
const cellKey = (cx: number, cy: number): number => cx * 1_000_003 + cy;
|
|
1089
|
+
|
|
1090
|
+
for (let i = 0; i < n; i++) {
|
|
1091
|
+
const b = bboxes[i]!;
|
|
1092
|
+
const minCx = Math.floor((b.x - margin) / CELL_SIZE);
|
|
1093
|
+
const maxCx = Math.floor((b.x + b.w + margin) / CELL_SIZE);
|
|
1094
|
+
const minCy = Math.floor((b.y - margin) / CELL_SIZE);
|
|
1095
|
+
const maxCy = Math.floor((b.y + b.h + margin) / CELL_SIZE);
|
|
1096
|
+
const cellCount = (maxCx - minCx + 1) * (maxCy - minCy + 1);
|
|
1097
|
+
if (cellCount > CELL_FALLBACK_THRESHOLD) {
|
|
1098
|
+
oversized.push(i);
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
for (let cx = minCx; cx <= maxCx; cx++) {
|
|
1102
|
+
for (let cy = minCy; cy <= maxCy; cy++) {
|
|
1103
|
+
const key = cellKey(cx, cy);
|
|
1104
|
+
let bucket = grid.get(key);
|
|
1105
|
+
if (!bucket) {
|
|
1106
|
+
bucket = [];
|
|
1107
|
+
grid.set(key, bucket);
|
|
1108
|
+
}
|
|
1109
|
+
bucket.push(i);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// 同 cell 内 pair (重複 cell に同 pair が乗るので Set で dedupe)。
|
|
1115
|
+
const seen = new Set<number>();
|
|
1116
|
+
for (const bucket of grid.values()) {
|
|
1117
|
+
if (bucket.length < 2) continue;
|
|
1118
|
+
for (let i = 0; i < bucket.length; i++) {
|
|
1119
|
+
for (let j = i + 1; j < bucket.length; j++) {
|
|
1120
|
+
const ia = bucket[i]!;
|
|
1121
|
+
const ib = bucket[j]!;
|
|
1122
|
+
const lo = ia < ib ? ia : ib;
|
|
1123
|
+
const hi = ia < ib ? ib : ia;
|
|
1124
|
+
const pairKey = lo * 1_000_003 + hi;
|
|
1125
|
+
if (seen.has(pairKey)) continue;
|
|
1126
|
+
seen.add(pairKey);
|
|
1127
|
+
visit(bboxes[lo]!, bboxes[hi]!);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// 巨大 bbox は全件と pair を作る (cell 数膨張回避)。
|
|
1133
|
+
if (oversized.length > 0) {
|
|
1134
|
+
for (const oi of oversized) {
|
|
1135
|
+
for (let j = 0; j < n; j++) {
|
|
1136
|
+
if (oi === j) continue;
|
|
1137
|
+
const lo = oi < j ? oi : j;
|
|
1138
|
+
const hi = oi < j ? j : oi;
|
|
1139
|
+
const pairKey = lo * 1_000_003 + hi;
|
|
1140
|
+
if (seen.has(pairKey)) continue;
|
|
1141
|
+
seen.add(pairKey);
|
|
1142
|
+
visit(bboxes[lo]!, bboxes[hi]!);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* chain 伝搬 shift で edge-label overlap を解消する (CAR-426 導入、 CAR-482 で node / lane-label
|
|
1150
|
+
* 伝搬拡張)。
|
|
1151
|
+
*
|
|
1152
|
+
* `resolveOverlapsWithChain` の edge-label 拡張版。 node-node overlap は
|
|
1153
|
+
* `resolveOverlapsWithChain` が担当、 本 function は edge-label ↔ edge-label /
|
|
1154
|
+
* edge-label ↔ node / edge-label ↔ lane-label の overlap を chain 伝搬解消する。
|
|
1155
|
+
*
|
|
1156
|
+
* 設計指針。
|
|
1157
|
+
* 1. edge-label が obstacles (node / lane-label) と overlap する場合、
|
|
1158
|
+
* label を path 側 normal 方向に押し出す (path から遠ざける方向)。
|
|
1159
|
+
* 2. edge-label 同士が overlap する場合、 後 declare の label を normal 方向 shift。
|
|
1160
|
+
* 3. shift 後に新規 overlap が生じた場合、 同方向で連鎖 shift (chain propagation)。
|
|
1161
|
+
* 4. CAR-482 拡張 ... label が shift 方向に node / lane-label と衝突する場合、 その node /
|
|
1162
|
+
* lane-label も同方向同量 shift 連鎖 (旧 fixedObstacles → 可変 obstacles)。
|
|
1163
|
+
* 5. 収束条件 = 1 iter で shift が 1 件も発生しない、 or MAX_ITER 到達。
|
|
1164
|
+
*
|
|
1165
|
+
* label 位置権限フロー (CAR-430 SSOT)。
|
|
1166
|
+
* 1. `edges.ts routePath()` = 初期 label 位置を SSOT で決定
|
|
1167
|
+
* 2. 本 function = overlap 検出時のみ chain 伝搬 shift で連鎖解消
|
|
1168
|
+
*
|
|
1169
|
+
* pipeline 配線 (`layout.ts` post-pass、 escape hatch = env `CDL_DISABLE_CHAIN_EDGE_LABEL=1`
|
|
1170
|
+
* で OFF にすると edges.ts init 位置がそのまま最終 label 位置)。 CAR-429 で edges.ts の
|
|
1171
|
+
* fan-in / fan-out offset 分散削除、 CAR-430 で label-shift.ts の shift 探索削除に伴い、
|
|
1172
|
+
* label overlap 解消の単一 SSOT になった。
|
|
1173
|
+
*
|
|
1174
|
+
* @param edges 元 LaidEdge 列 (順序保存で返す)
|
|
1175
|
+
* @param fixedObstacles 障害物 (node / lane-label の BBox 列)。 CAR-482 以前は不動、
|
|
1176
|
+
* 現在は shift 対象 (CAR-482 chain 伝搬拡張)。 後方互換のため名前は fixedObstacles のまま。
|
|
1177
|
+
* @param minClearance shift 後の最小 gap (world unit、 default 16)
|
|
1178
|
+
* @param maxIter 収束上限 iter 数 (default 20、 spec.CHAIN_SHIFT_MAX_ITER と同値)
|
|
1179
|
+
* @returns shift 済 LaidEdge 列 (input 順序を保持、 label 位置のみ変更、 path d は不変)
|
|
1180
|
+
*/
|
|
1181
|
+
export function resolveEdgeLabelOverlapsWithChain(
|
|
1182
|
+
edges: LaidEdge[],
|
|
1183
|
+
fixedObstacles: BBox[],
|
|
1184
|
+
minClearance = 16,
|
|
1185
|
+
maxIter = 20,
|
|
1186
|
+
): LaidEdge[] {
|
|
1187
|
+
if (edges.length === 0) return edges;
|
|
1188
|
+
|
|
1189
|
+
// label bbox 生成は SSOT = spec.ts の computeLabelBBoxWorld に集約 (CAR-431 で collectBBoxes /
|
|
1190
|
+
// 本 function / render/edges.tsx / predict-bbox.ts 4 箇所の drift を解消)。 shift 探索中の
|
|
1191
|
+
// offsetX / offsetY 追加は label 位置探索の temporal offset なので helper 呼出後に加算する。
|
|
1192
|
+
const labelBBox = (e: LaidEdge, offsetX: number, offsetY: number): BBox => {
|
|
1193
|
+
const base = computeLabelBBoxWorld(e);
|
|
1194
|
+
return {
|
|
1195
|
+
kind: "edge-label",
|
|
1196
|
+
id: e.id,
|
|
1197
|
+
x: base.x + offsetX,
|
|
1198
|
+
y: base.y + offsetY,
|
|
1199
|
+
w: base.w,
|
|
1200
|
+
h: base.h,
|
|
1201
|
+
};
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
const edgeOrder = new Map<string, number>();
|
|
1205
|
+
edges.forEach((e, idx) => edgeOrder.set(e.id, idx));
|
|
1206
|
+
|
|
1207
|
+
// label 空 (視覚上何も描画されない) edge は shift 対象外 = original 位置固定。
|
|
1208
|
+
const labeledIds = new Set(
|
|
1209
|
+
edges.filter((e) => e.label?.trim() || e.sub?.trim()).map((e) => e.id),
|
|
1210
|
+
);
|
|
1211
|
+
|
|
1212
|
+
// 累積 shift Map (label 位置のみ、 path d は不変)。
|
|
1213
|
+
const shiftMap = new Map<string, { dx: number; dy: number }>();
|
|
1214
|
+
const getShift = (id: string): { dx: number; dy: number } =>
|
|
1215
|
+
shiftMap.get(id) ?? { dx: 0, dy: 0 };
|
|
1216
|
+
|
|
1217
|
+
// 各 iter で edge-label bbox を再構築 (累積 shift を反映)。
|
|
1218
|
+
const buildLabelBBoxes = (): BBox[] => {
|
|
1219
|
+
const out: BBox[] = [];
|
|
1220
|
+
for (const e of edges) {
|
|
1221
|
+
if (!labeledIds.has(e.id)) continue;
|
|
1222
|
+
const s = getShift(e.id);
|
|
1223
|
+
out.push(labelBBox(e, s.dx, s.dy));
|
|
1224
|
+
}
|
|
1225
|
+
return out;
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
// detect: label ↔ fixed obstacle と label ↔ label の overlap を列挙。
|
|
1229
|
+
// return: shift 命令 (moveId + axis + sign + amount)。
|
|
1230
|
+
type ShiftInstr = { moveId: string; axis: "x" | "y"; sign: 1 | -1; amount: number };
|
|
1231
|
+
|
|
1232
|
+
const detectShifts = (): ShiftInstr[] => {
|
|
1233
|
+
const out: ShiftInstr[] = [];
|
|
1234
|
+
const labels = buildLabelBBoxes();
|
|
1235
|
+
|
|
1236
|
+
// label ↔ fixed obstacle (node / lane-label)。 label を fixed 逆方向に押し出す。
|
|
1237
|
+
for (const label of labels) {
|
|
1238
|
+
for (const obs of fixedObstacles) {
|
|
1239
|
+
if (obs.kind !== "node" && obs.kind !== "lane-label") continue;
|
|
1240
|
+
const dxOverlap = Math.min(label.x + label.w, obs.x + obs.w) - Math.max(label.x, obs.x);
|
|
1241
|
+
const dyOverlap = Math.min(label.y + label.h, obs.y + obs.h) - Math.max(label.y, obs.y);
|
|
1242
|
+
if (dxOverlap <= 0 || dyOverlap <= 0) continue;
|
|
1243
|
+
const axis: "x" | "y" = dxOverlap < dyOverlap ? "x" : "y";
|
|
1244
|
+
const labelCenter = axis === "x" ? label.x + label.w / 2 : label.y + label.h / 2;
|
|
1245
|
+
const obsCenter = axis === "x" ? obs.x + obs.w / 2 : obs.y + obs.h / 2;
|
|
1246
|
+
const sign: 1 | -1 = labelCenter >= obsCenter ? 1 : -1;
|
|
1247
|
+
const amount = (axis === "x" ? dxOverlap : dyOverlap) + minClearance;
|
|
1248
|
+
out.push({ moveId: label.id, axis, sign, amount });
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// label ↔ label。 後 declare の label を先 declare から離れる方向に shift。
|
|
1253
|
+
for (let i = 0; i < labels.length; i++) {
|
|
1254
|
+
for (let j = i + 1; j < labels.length; j++) {
|
|
1255
|
+
const a = labels[i]!;
|
|
1256
|
+
const b = labels[j]!;
|
|
1257
|
+
const dxOverlap = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
|
|
1258
|
+
const dyOverlap = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
|
|
1259
|
+
if (dxOverlap <= 0 || dyOverlap <= 0) continue;
|
|
1260
|
+
const orderA = edgeOrder.get(a.id)!;
|
|
1261
|
+
const orderB = edgeOrder.get(b.id)!;
|
|
1262
|
+
const moveIsA = orderA > orderB;
|
|
1263
|
+
const moveBox = moveIsA ? a : b;
|
|
1264
|
+
const staticBox = moveIsA ? b : a;
|
|
1265
|
+
const axis: "x" | "y" = dxOverlap < dyOverlap ? "x" : "y";
|
|
1266
|
+
const moveCenter = axis === "x" ? moveBox.x + moveBox.w / 2 : moveBox.y + moveBox.h / 2;
|
|
1267
|
+
const staticCenter = axis === "x" ? staticBox.x + staticBox.w / 2 : staticBox.y + staticBox.h / 2;
|
|
1268
|
+
const sign: 1 | -1 = moveCenter >= staticCenter ? 1 : -1;
|
|
1269
|
+
const amount = (axis === "x" ? dxOverlap : dyOverlap) + minClearance;
|
|
1270
|
+
out.push({ moveId: moveBox.id, axis, sign, amount });
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
return out;
|
|
1275
|
+
};
|
|
1276
|
+
|
|
1277
|
+
// chain 伝搬 loop = shift → 再検出 → 再 shift の iterative、 MAX_ITER で強制収束。
|
|
1278
|
+
for (let iter = 0; iter < maxIter; iter++) {
|
|
1279
|
+
const instrs = detectShifts();
|
|
1280
|
+
if (instrs.length === 0) return applyEdgeLabelShifts(edges, shiftMap);
|
|
1281
|
+
|
|
1282
|
+
// 同 label に複数命令が来る場合、 各軸 max (符号別) を採用。
|
|
1283
|
+
const iterShift = new Map<string, { dx: number; dy: number }>();
|
|
1284
|
+
for (const p of instrs) {
|
|
1285
|
+
const prev = iterShift.get(p.moveId) ?? { dx: 0, dy: 0 };
|
|
1286
|
+
if (p.axis === "x") {
|
|
1287
|
+
prev.dx = p.sign > 0 ? Math.max(prev.dx, p.amount) : Math.min(prev.dx, -p.amount);
|
|
1288
|
+
} else {
|
|
1289
|
+
prev.dy = p.sign > 0 ? Math.max(prev.dy, p.amount) : Math.min(prev.dy, -p.amount);
|
|
1290
|
+
}
|
|
1291
|
+
iterShift.set(p.moveId, prev);
|
|
1292
|
+
}
|
|
1293
|
+
// 累積 shift Map に足し込み。
|
|
1294
|
+
for (const [id, s] of iterShift) {
|
|
1295
|
+
const prev = getShift(id);
|
|
1296
|
+
shiftMap.set(id, { dx: prev.dx + s.dx, dy: prev.dy + s.dy });
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// MAX_ITER 到達 = 発散防止で現時点 shift を返す。
|
|
1301
|
+
return applyEdgeLabelShifts(edges, shiftMap);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function applyEdgeLabelShifts(
|
|
1305
|
+
edges: LaidEdge[],
|
|
1306
|
+
shiftMap: Map<string, { dx: number; dy: number }>,
|
|
1307
|
+
): LaidEdge[] {
|
|
1308
|
+
if (shiftMap.size === 0) return edges;
|
|
1309
|
+
return edges.map((e) => {
|
|
1310
|
+
const s = shiftMap.get(e.id);
|
|
1311
|
+
if (!s || (s.dx === 0 && s.dy === 0)) return e;
|
|
1312
|
+
// labelX / labelY は path 上 anchor 位置 (edges.ts SSOT)、 shift を加算しても
|
|
1313
|
+
// path d は不変。 label pill の視覚位置のみが移動する。
|
|
1314
|
+
return { ...e, labelX: e.labelX + s.dx, labelY: e.labelY + s.dy };
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* CAR-508 SSOT = 累積 shift を各軸方向 `maxMagnitude` 内に clamp する helper。
|
|
1320
|
+
*
|
|
1321
|
+
* chain 伝搬 loop で label / node / lane-label / edge-path の shift 累積が
|
|
1322
|
+
* `maxMagnitude` を超えないよう、 軸別に絶対値を clip する。 |shift.dx| > maxMagnitude
|
|
1323
|
+
* なら符号を保って maxMagnitude に丸め、 dy も同様。
|
|
1324
|
+
*
|
|
1325
|
+
* 発散防止の SSOT gate、 通常の chain 伝搬 shift (30-80 world 程度) には影響せず、
|
|
1326
|
+
* Fan-out / Fan-in / Rollback pattern の暴走 shift (数百 world) を止める。
|
|
1327
|
+
*/
|
|
1328
|
+
function clampShiftMagnitude(
|
|
1329
|
+
shift: { dx: number; dy: number },
|
|
1330
|
+
maxMagnitude: number,
|
|
1331
|
+
): { dx: number; dy: number } {
|
|
1332
|
+
const clampAxis = (v: number): number => {
|
|
1333
|
+
if (v > maxMagnitude) return maxMagnitude;
|
|
1334
|
+
if (v < -maxMagnitude) return -maxMagnitude;
|
|
1335
|
+
return v;
|
|
1336
|
+
};
|
|
1337
|
+
return { dx: clampAxis(shift.dx), dy: clampAxis(shift.dy) };
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* chain 伝搬 shift の拡張版 (CAR-482 導入、 CAR-485 で edge-path 拡張)。 label だけでなく node /
|
|
1342
|
+
* lane-label / edge-path (別 edge) も同方向同量 shift 連鎖。
|
|
1343
|
+
*
|
|
1344
|
+
* `resolveEdgeLabelOverlapsWithChain` は edge-label のみ shift、 obstacles (node / lane-label) は不動。
|
|
1345
|
+
* 本 function は「label が shift 方向にある obstacle / edge-path に衝突するなら、 その要素も同方向同量 shift 連鎖」
|
|
1346
|
+
* を実装、 「押した先にある要素も押し出す」 user 期待に応える。
|
|
1347
|
+
*
|
|
1348
|
+
* shift 対象。
|
|
1349
|
+
* 1. edge-label ... `resolveEdgeLabelOverlapsWithChain` と同経路
|
|
1350
|
+
* 2. node ... label shift 方向にある node は同方向同量 shift、 chain 伝搬で再検出 → 再 shift
|
|
1351
|
+
* 3. lane-label ... 同上
|
|
1352
|
+
* 4. edge-path (別 edge、 CAR-485) ... label 近接 (LABEL_TO_PATH_CLEARANCE 未達) or 直接 overlap で
|
|
1353
|
+
* label を path 逆方向に shift + shift 方向にある edge-path も同方向連鎖 shift (path.d 平行移動)
|
|
1354
|
+
*
|
|
1355
|
+
* 保守性 = 収束条件は maxIter で強制、 発散防止。 shift 累積は各要素ごと Map で管理。
|
|
1356
|
+
* 既存 preset で label overlap 発生しないなら shift 0 で edges / nodes / laneLabels / edgePathShifts 素通し。
|
|
1357
|
+
*
|
|
1358
|
+
* 自 edge の label × path は legit として shift 対象外 (routePath init 位置 = LABEL_INIT_CLEARANCE 34 world (CAR-630 で 26 → 34 復帰)
|
|
1359
|
+
* で自 path から離した設計、 自 pair の近接判定はここでは無視する)。
|
|
1360
|
+
*
|
|
1361
|
+
* @param edges 元 LaidEdge 列 (順序保存で返す)
|
|
1362
|
+
* @param obstacles 障害物 (node / lane-label の BBox 列)。 shift 対象。
|
|
1363
|
+
* @param minClearance shift 後の最小 gap (default 16)
|
|
1364
|
+
* @param maxIter 収束上限 iter 数 (default 20)
|
|
1365
|
+
* @returns { edges, nodeShifts, laneLabelShifts, edgePathShifts } shift 済 edges + kind 別 shift map。
|
|
1366
|
+
* edgePathShifts は edge id → (dx, dy) の shift 量。 caller は `translatePathD(edge.d, dx, dy)` で
|
|
1367
|
+
* path.d を平行移動する。
|
|
1368
|
+
*/
|
|
1369
|
+
export function resolveEdgeLabelOverlapsWithChainAndPropagate(
|
|
1370
|
+
edges: LaidEdge[],
|
|
1371
|
+
obstacles: BBox[],
|
|
1372
|
+
minClearance = 16,
|
|
1373
|
+
maxIter = 20,
|
|
1374
|
+
/**
|
|
1375
|
+
* CAR-485 = label × edge-path (別 edge) near-collision 判定に使う clearance (world unit)。
|
|
1376
|
+
* `minClearance` は label × node / lane-label / label 用 (大きめ 32-36)、 本値は path 用
|
|
1377
|
+
* (SSOT `LABEL_TO_PATH_CLEARANCE` = 16)、 pair 別 clearance を分離する事で fan-in / fan-out
|
|
1378
|
+
* などで label が sibling path 近傍 (LABEL_INIT_CLEARANCE 26) にある正常状態を誤検知しない。
|
|
1379
|
+
*/
|
|
1380
|
+
pathLabelClearance = LABEL_TO_PATH_CLEARANCE,
|
|
1381
|
+
): {
|
|
1382
|
+
edges: LaidEdge[];
|
|
1383
|
+
nodeShifts: Map<string, { dx: number; dy: number }>;
|
|
1384
|
+
laneLabelShifts: Map<string, { dx: number; dy: number }>;
|
|
1385
|
+
edgePathShifts: Map<string, { dx: number; dy: number }>;
|
|
1386
|
+
} {
|
|
1387
|
+
if (edges.length === 0) {
|
|
1388
|
+
return {
|
|
1389
|
+
edges,
|
|
1390
|
+
nodeShifts: new Map<string, { dx: number; dy: number }>(),
|
|
1391
|
+
laneLabelShifts: new Map<string, { dx: number; dy: number }>(),
|
|
1392
|
+
edgePathShifts: new Map<string, { dx: number; dy: number }>(),
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// CAR-508 SSOT (spec.ts CHAIN_SHIFT_MAX_ACCUMULATED)。 累積 shift 上限 = 発散防止 guard。
|
|
1397
|
+
const MAX_ACCUMULATED_SHIFT = CHAIN_SHIFT_MAX_ACCUMULATED;
|
|
1398
|
+
|
|
1399
|
+
// label bbox 生成 helper (spec.ts SSOT、 shift offset 込み)。
|
|
1400
|
+
const labelBBox = (e: LaidEdge, offsetX: number, offsetY: number): BBox => {
|
|
1401
|
+
const base = computeLabelBBoxWorld(e);
|
|
1402
|
+
return {
|
|
1403
|
+
kind: "edge-label",
|
|
1404
|
+
id: e.id,
|
|
1405
|
+
x: base.x + offsetX,
|
|
1406
|
+
y: base.y + offsetY,
|
|
1407
|
+
w: base.w,
|
|
1408
|
+
h: base.h,
|
|
1409
|
+
};
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
const edgeOrder = new Map<string, number>();
|
|
1413
|
+
edges.forEach((e, idx) => edgeOrder.set(e.id, idx));
|
|
1414
|
+
|
|
1415
|
+
// label 空 (視覚上何も描画されない) edge は shift 対象外。
|
|
1416
|
+
const labeledIds = new Set(
|
|
1417
|
+
edges.filter((e) => e.label?.trim() || e.sub?.trim()).map((e) => e.id),
|
|
1418
|
+
);
|
|
1419
|
+
|
|
1420
|
+
// 累積 shift Map (edge-label / node / lane-label / edge-path ごとに別 Map)。
|
|
1421
|
+
const labelShiftMap = new Map<string, { dx: number; dy: number }>();
|
|
1422
|
+
const nodeShiftMap = new Map<string, { dx: number; dy: number }>();
|
|
1423
|
+
const laneLabelShiftMap = new Map<string, { dx: number; dy: number }>();
|
|
1424
|
+
const edgePathShiftMap = new Map<string, { dx: number; dy: number }>();
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* chain 伝搬の起点として数える label shift だけを別に累積する (Issue #211)。
|
|
1428
|
+
*
|
|
1429
|
+
* label ↔ node の「近接」 解消 (rule 1c) は数 world の微調整で、 周囲の node や path を
|
|
1430
|
+
* 巻き込む必要がない。 一方「重なり」 解消 (rule 1) は label の逃げ先に空間を作る必要が
|
|
1431
|
+
* あるため node / path を同方向へ送る。
|
|
1432
|
+
*
|
|
1433
|
+
* 両者を 1 つの累積 map で扱うと近接の微調整まで chain 伝搬の起点になり、 node が連鎖して
|
|
1434
|
+
* 動いて node 間 gap や lane pitch が崩れる (catalog 実測 = 1 → 11 件に悪化)。
|
|
1435
|
+
*/
|
|
1436
|
+
const labelChainShiftMap = new Map<string, { dx: number; dy: number }>();
|
|
1437
|
+
|
|
1438
|
+
const getLabelShift = (id: string): { dx: number; dy: number } =>
|
|
1439
|
+
labelShiftMap.get(id) ?? { dx: 0, dy: 0 };
|
|
1440
|
+
const getLabelChainShift = (id: string): { dx: number; dy: number } =>
|
|
1441
|
+
labelChainShiftMap.get(id) ?? { dx: 0, dy: 0 };
|
|
1442
|
+
const getObstacleShift = (obs: BBox): { dx: number; dy: number } => {
|
|
1443
|
+
if (obs.kind === "node") return nodeShiftMap.get(obs.id) ?? { dx: 0, dy: 0 };
|
|
1444
|
+
if (obs.kind === "lane-label") return laneLabelShiftMap.get(obs.id) ?? { dx: 0, dy: 0 };
|
|
1445
|
+
return { dx: 0, dy: 0 };
|
|
1446
|
+
};
|
|
1447
|
+
const getEdgePathShift = (id: string): { dx: number; dy: number } =>
|
|
1448
|
+
edgePathShiftMap.get(id) ?? { dx: 0, dy: 0 };
|
|
1449
|
+
|
|
1450
|
+
// shift 済 bbox 位置を返す (元 bbox に累積 shift を反映)。
|
|
1451
|
+
const shiftedObstacleBBox = (obs: BBox): BBox => {
|
|
1452
|
+
const s = getObstacleShift(obs);
|
|
1453
|
+
if (s.dx === 0 && s.dy === 0) return obs;
|
|
1454
|
+
return { ...obs, x: obs.x + s.dx, y: obs.y + s.dy };
|
|
1455
|
+
};
|
|
1456
|
+
|
|
1457
|
+
const edgeById = new Map<string, LaidEdge>();
|
|
1458
|
+
for (const e of edges) edgeById.set(e.id, e);
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* 累積 edge-path shift を適用した後の segments (一様平行移動)。
|
|
1462
|
+
*
|
|
1463
|
+
* 適用側 (`applyEdgeLabelAndPathShifts`) は端点固定 (`translatePathDPreservingEndpoints`) を通すため、
|
|
1464
|
+
* 端点と同じ座標を持つ点は動かない。 つまり本関数が返す geometry と描画される geometry は
|
|
1465
|
+
* 形状によって乖離する。
|
|
1466
|
+
*
|
|
1467
|
+
* 判定側も端点固定を通して統一すると、 CAR-485 の押し出し量 (`(clearance - gap) + clearance`) が
|
|
1468
|
+
* 「動かない点」 を基準に計算されるようになり、 累積上限まで押し続けて描画が悪化する
|
|
1469
|
+
* (catalog 実測 = infra-demo の `edge-label ↔ edge-path` が再発)。
|
|
1470
|
+
*
|
|
1471
|
+
* 押し出しは一様平行移動を前提に設計されており (CAR-485)、 端点固定 (CAR-489) と本質的に
|
|
1472
|
+
* 両立しない。 根本解決は端点固定を座標値ベースから index ベースに変えること (Issue #216)。
|
|
1473
|
+
* それまでは判定を一様平行移動のままに保ち、 `pathShiftIsSafe` が「1 点も動かない向き」 と
|
|
1474
|
+
* 「path を壊す向き」 を弾くことで実害を抑える。
|
|
1475
|
+
*/
|
|
1476
|
+
const shiftedSegments = (edgeId: string, edgeD: string): Seg[] => {
|
|
1477
|
+
const s = getEdgePathShift(edgeId);
|
|
1478
|
+
if (s.dx === 0 && s.dy === 0) return extractPathSegments(edgeD);
|
|
1479
|
+
return extractPathSegments(translatePathDPreservingEndpoints(edgeD, s.dx, s.dy));
|
|
1480
|
+
};
|
|
1481
|
+
|
|
1482
|
+
// shift 命令 = 対象要素 (label / node / lane-label / edge-path) + 軸 + 符号 + 量。
|
|
1483
|
+
type ShiftInstr = {
|
|
1484
|
+
target: { kind: "label" | "node" | "lane-label" | "edge-path"; id: string };
|
|
1485
|
+
axis: "x" | "y";
|
|
1486
|
+
sign: 1 | -1;
|
|
1487
|
+
amount: number;
|
|
1488
|
+
/** false なら chain 伝搬の起点に数えない (label だけを動かす、 Issue #211)。 default = true。 */
|
|
1489
|
+
chain?: boolean;
|
|
1490
|
+
};
|
|
1491
|
+
|
|
1492
|
+
// 各 iter で label bboxes を再構築 (累積 label shift を反映)。
|
|
1493
|
+
const buildLabelBBoxes = (): BBox[] => {
|
|
1494
|
+
const out: BBox[] = [];
|
|
1495
|
+
for (const e of edges) {
|
|
1496
|
+
if (!labeledIds.has(e.id)) continue;
|
|
1497
|
+
const s = getLabelShift(e.id);
|
|
1498
|
+
out.push(labelBBox(e, s.dx, s.dy));
|
|
1499
|
+
}
|
|
1500
|
+
return out;
|
|
1501
|
+
};
|
|
1502
|
+
|
|
1503
|
+
// bbox overlap 検出 + shift 方向計算の共通 helper。 move box を static box から遠ざける方向に押し出す。
|
|
1504
|
+
// axis 選択 = overlap 面積が小さい方 (= 押し出しが少ない = 影響最小)。
|
|
1505
|
+
const computeOverlapShift = (
|
|
1506
|
+
moveBox: BBox,
|
|
1507
|
+
staticBox: BBox,
|
|
1508
|
+
): { axis: "x" | "y"; sign: 1 | -1; amount: number } | null => {
|
|
1509
|
+
const dxOverlap = Math.min(moveBox.x + moveBox.w, staticBox.x + staticBox.w) - Math.max(moveBox.x, staticBox.x);
|
|
1510
|
+
const dyOverlap = Math.min(moveBox.y + moveBox.h, staticBox.y + staticBox.h) - Math.max(moveBox.y, staticBox.y);
|
|
1511
|
+
if (dxOverlap <= 0 || dyOverlap <= 0) return null;
|
|
1512
|
+
const axis: "x" | "y" = dxOverlap < dyOverlap ? "x" : "y";
|
|
1513
|
+
const moveCenter = axis === "x" ? moveBox.x + moveBox.w / 2 : moveBox.y + moveBox.h / 2;
|
|
1514
|
+
const staticCenter = axis === "x" ? staticBox.x + staticBox.w / 2 : staticBox.y + staticBox.h / 2;
|
|
1515
|
+
const sign: 1 | -1 = moveCenter >= staticCenter ? 1 : -1;
|
|
1516
|
+
const amount = (axis === "x" ? dxOverlap : dyOverlap) + minClearance;
|
|
1517
|
+
return { axis, sign, amount };
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
const detectShifts = (): ShiftInstr[] => {
|
|
1521
|
+
const out: ShiftInstr[] = [];
|
|
1522
|
+
const labels = buildLabelBBoxes();
|
|
1523
|
+
const shiftedObstacles = obstacles.map(shiftedObstacleBBox);
|
|
1524
|
+
|
|
1525
|
+
// (1) label ↔ obstacle (node / lane-label) 直接 overlap。 label を obstacle 逆方向に shift。
|
|
1526
|
+
const labelsWithOverlap = new Set<string>();
|
|
1527
|
+
for (const label of labels) {
|
|
1528
|
+
for (const obs of shiftedObstacles) {
|
|
1529
|
+
if (obs.kind !== "node" && obs.kind !== "lane-label") continue;
|
|
1530
|
+
const shift = computeOverlapShift(label, obs);
|
|
1531
|
+
if (!shift) continue;
|
|
1532
|
+
labelsWithOverlap.add(label.id);
|
|
1533
|
+
out.push({ target: { kind: "label", id: label.id }, ...shift });
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// (1c) label ↔ node の近接 (重なってはいないが requiredNearClearance 未満、 Issue #211)。
|
|
1538
|
+
//
|
|
1539
|
+
// visualValidate の clearance 軸は node ↔ edge-label に 32 world を要求するが、 engine は
|
|
1540
|
+
// (1) の重なり解消しか持たず、 角に斜めから寄った pill (実測 gap 29.8) を残していた。
|
|
1541
|
+
// 判定側と押し出し側で同じ policy 表 (requiredNearClearance) を引き、 閾値の drift を防ぐ。
|
|
1542
|
+
//
|
|
1543
|
+
// node を requiredNearClearance だけ四方に膨らませた矩形との重なり量を押し出し量とする。
|
|
1544
|
+
// 膨張は矩形中心を変えないので押し出す向きは (1) と同じ。
|
|
1545
|
+
//
|
|
1546
|
+
// 重なりが残っている label は対象外。 重なりの押し出しと逆向きの近接調整が同 iter に並ぶと
|
|
1547
|
+
// label が上下に振れて収束しない。 重なりを (1) と (3) で先に解消し、 残った近接を本 rule が拾う。
|
|
1548
|
+
//
|
|
1549
|
+
// chain: false = この微調整は node / path の連鎖を起こさない。 label だけが動く。
|
|
1550
|
+
for (const label of labels) {
|
|
1551
|
+
if (labelsWithOverlap.has(label.id)) continue;
|
|
1552
|
+
for (const obs of shiftedObstacles) {
|
|
1553
|
+
if (obs.kind !== "node") continue;
|
|
1554
|
+
const required = requiredNearClearance(label.kind, obs.kind);
|
|
1555
|
+
const inflated: BBox = {
|
|
1556
|
+
...obs,
|
|
1557
|
+
x: obs.x - required,
|
|
1558
|
+
y: obs.y - required,
|
|
1559
|
+
w: obs.w + required * 2,
|
|
1560
|
+
h: obs.h + required * 2,
|
|
1561
|
+
};
|
|
1562
|
+
const shift = computeOverlapShift(label, inflated);
|
|
1563
|
+
if (!shift) continue;
|
|
1564
|
+
// computeOverlapShift は minClearance を上乗せするが、 膨張分で確保済なので差し引く。
|
|
1565
|
+
const amount = shift.amount - minClearance;
|
|
1566
|
+
if (amount <= 0) continue;
|
|
1567
|
+
out.push({
|
|
1568
|
+
target: { kind: "label", id: label.id },
|
|
1569
|
+
axis: shift.axis,
|
|
1570
|
+
sign: shift.sign,
|
|
1571
|
+
amount,
|
|
1572
|
+
chain: false,
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// (1b) label ↔ lane-label の横方向 near miss (重なってはいないが CLEARANCE_LANE_LABEL 未満)。
|
|
1578
|
+
//
|
|
1579
|
+
// 動機 = lane を横断する edge (例 loading → error で done を跨ぐ) の label は中間 lane の
|
|
1580
|
+
// 上端 band に置かれることがあり、 その lane の title と横に並ぶ。 重なっていないので (1) は
|
|
1581
|
+
// 発火せず、 しかし clearance 軸は lane-label ↔ edge-label の gap 不足を error 判定する。
|
|
1582
|
+
//
|
|
1583
|
+
// lane-label は lane 左上角に固定 anchor で横移動できない (動かすと lane 外にはみ出る)。
|
|
1584
|
+
// 縦方向は (3) chain 伝搬が lane-label ごと下方へ送る経路を既に持つ。
|
|
1585
|
+
// よって横方向の自由度は edge-label 側にしかない、 ここでは label を lane-label から離す。
|
|
1586
|
+
//
|
|
1587
|
+
// raw overlap 済 pair は (1) が処理するため除外、 既存 diagram の label 位置は変えない。
|
|
1588
|
+
for (const label of labels) {
|
|
1589
|
+
for (const obs of shiftedObstacles) {
|
|
1590
|
+
if (obs.kind !== "lane-label") continue;
|
|
1591
|
+
if (computeOverlapShift(label, obs)) continue;
|
|
1592
|
+
const yOverlap = Math.min(label.y + label.h, obs.y + obs.h) - Math.max(label.y, obs.y);
|
|
1593
|
+
if (yOverlap <= 0) continue;
|
|
1594
|
+
const gapX =
|
|
1595
|
+
label.x >= obs.x + obs.w
|
|
1596
|
+
? label.x - (obs.x + obs.w)
|
|
1597
|
+
: obs.x >= label.x + label.w
|
|
1598
|
+
? obs.x - (label.x + label.w)
|
|
1599
|
+
: 0;
|
|
1600
|
+
// 要求値は clearance 定数を直接読まず、 near-collision 判定と同じ policy 表から引く。
|
|
1601
|
+
// 判定側 (visualValidate) と押し出し側 (本 loop) の閾値が drift しないことを保証する。
|
|
1602
|
+
const required = requiredNearClearance(label.kind, obs.kind);
|
|
1603
|
+
if (gapX <= 0 || gapX >= required) continue;
|
|
1604
|
+
const sign: 1 | -1 = label.x >= obs.x + obs.w ? 1 : -1;
|
|
1605
|
+
out.push({
|
|
1606
|
+
target: { kind: "label", id: label.id },
|
|
1607
|
+
axis: "x",
|
|
1608
|
+
sign,
|
|
1609
|
+
amount: required - gapX + LANE_LABEL_CLEARANCE_MARGIN,
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// (2) label ↔ label overlap。 後 declare の label を先 declare から離れる方向に shift。
|
|
1615
|
+
for (let i = 0; i < labels.length; i++) {
|
|
1616
|
+
for (let j = i + 1; j < labels.length; j++) {
|
|
1617
|
+
const a = labels[i]!;
|
|
1618
|
+
const b = labels[j]!;
|
|
1619
|
+
const orderA = edgeOrder.get(a.id)!;
|
|
1620
|
+
const orderB = edgeOrder.get(b.id)!;
|
|
1621
|
+
const moveBox = orderA > orderB ? a : b;
|
|
1622
|
+
const staticBox = orderA > orderB ? b : a;
|
|
1623
|
+
const shift = computeOverlapShift(moveBox, staticBox);
|
|
1624
|
+
if (!shift) continue;
|
|
1625
|
+
out.push({ target: { kind: "label", id: moveBox.id }, ...shift });
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// (3) CAR-482 chain 伝搬 = label が shift 方向にある obstacle と衝突するなら、
|
|
1630
|
+
// obstacle も同方向同量 shift 連鎖。 label ↔ obstacle が既に (1) で処理されているため、
|
|
1631
|
+
// ここでは「shift 済 label の shift 累積方向にある obstacle」 を検出、 obstacle 側 shift 発火。
|
|
1632
|
+
//
|
|
1633
|
+
// 判定 = label 累積 shift 量ベクトル方向にある obstacle (中心距離が近接、 直交軸で band 重なり)。
|
|
1634
|
+
for (const label of labels) {
|
|
1635
|
+
const currentShift = getLabelChainShift(label.id);
|
|
1636
|
+
if (currentShift.dx === 0 && currentShift.dy === 0) continue;
|
|
1637
|
+
for (const obs of shiftedObstacles) {
|
|
1638
|
+
if (obs.kind !== "node" && obs.kind !== "lane-label") continue;
|
|
1639
|
+
propagateAlongAxis(out, label, obs, currentShift, "y");
|
|
1640
|
+
propagateAlongAxis(out, label, obs, currentShift, "x");
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// (4) CAR-485 = label ↔ edge-path (別 edge) 近接検出 → 別 edge の path を label から遠ざける方向に shift。
|
|
1645
|
+
// 基本方針 = label の位置は routePath init SSOT (LABEL_INIT_CLEARANCE 34 world、 CAR-630 で 26 → 34 復帰) で自 path と適切に離しているため、
|
|
1646
|
+
// 別 edge の path 近接時は label を動かさず path 側を shift する経路を採用する (own-path invariant を守るため)。
|
|
1647
|
+
// 判定 = segment 実距離ベース (AABB gap は detour path で false negative)。
|
|
1648
|
+
// 自 edge (label と path が同 id) は routePath 設計 SSOT で init 位置に離しているため対象外。
|
|
1649
|
+
//
|
|
1650
|
+
// 除外 = 同 (from, to) parallel edge pair (CAR-429 SSOT で意図的に path 完全重畳)。
|
|
1651
|
+
// parallel edge の label 分散は label ↔ label overlap 経路 (step 2) で解消される。
|
|
1652
|
+
//
|
|
1653
|
+
// shift 方向 = label bbox 4 辺のうち path segments まで最短距離の辺の外側方向。
|
|
1654
|
+
// top 辺が最短 ⇒ path は label 上方 ⇒ path shift 上方 (Y-)、 label 上方向に path を追いやる。
|
|
1655
|
+
// bottom 辺 ⇒ 下方 (Y+)、 left ⇒ 左方 (X-)、 right ⇒ 右方 (X+)。
|
|
1656
|
+
// shift 量 = 現 gap から pathLabelClearance まで押し + 余裕 (pathLabelClearance)。
|
|
1657
|
+
// 軸選択 = 中心距離ではなく、 上記 4 辺 probe 最小距離辺の軸 (horizontal 長 path で center 距離 X 優位でも Y 方向 collision 検知可能)。
|
|
1658
|
+
// label が跨っている自 path の segments (Issue #211)。 兄弟 edge と共有する segment の除外に使う。
|
|
1659
|
+
const ownSegsOf = (labelId: string): Seg[] => {
|
|
1660
|
+
const own = edgeById.get(labelId);
|
|
1661
|
+
return own ? shiftedSegments(own.id, own.d) : [];
|
|
1662
|
+
};
|
|
1663
|
+
// Issue #211 SSOT ... path を node の中へ押し込む shift 命令を捨てる。
|
|
1664
|
+
//
|
|
1665
|
+
// 4 辺 probe が left / right で同距離になると、 押しても label から離れない向き (label を
|
|
1666
|
+
// 横切る水平 segment を横へずらす等) が選ばれ、 累積上限 (100 world) まで path が引きずられる。
|
|
1667
|
+
// 実測 = T06 microservice mesh で detour が x=440 から x=340 へ動き、 svc1 / svc2 を貫いた。
|
|
1668
|
+
//
|
|
1669
|
+
// 従来は chain 伝搬が node も動かし `layoutEdges` を再実行していたため、 押し込まれた path が
|
|
1670
|
+
// 捨てられて症状が出なかった。 label shift を保持する設計では path 側で判断する必要がある。
|
|
1671
|
+
const nodeObstacles = shiftedObstacles.filter((o) => o.kind === "node");
|
|
1672
|
+
|
|
1673
|
+
/** 端点領域の固定を含めて実際に適用される形で path を平行移動し、 segments を返す。 */
|
|
1674
|
+
const simulateShiftedSegments = (e: LaidEdge, dx: number, dy: number): Seg[] =>
|
|
1675
|
+
extractPathSegments(translatePathDPreservingEndpoints(e.d, dx, dy));
|
|
1676
|
+
|
|
1677
|
+
/** 2 つの segment 列が座標として異なるか (浮動小数の端数は無視)。 */
|
|
1678
|
+
const segmentsDiffer = (a: Seg[], b: Seg[]): boolean => {
|
|
1679
|
+
if (a.length !== b.length) return true;
|
|
1680
|
+
for (let i = 0; i < a.length; i++) {
|
|
1681
|
+
const p = a[i]!;
|
|
1682
|
+
const q = b[i]!;
|
|
1683
|
+
if (
|
|
1684
|
+
Math.abs(p.x1 - q.x1) > DEGENERATE_SEGMENT_EPSILON ||
|
|
1685
|
+
Math.abs(p.y1 - q.y1) > DEGENERATE_SEGMENT_EPSILON ||
|
|
1686
|
+
Math.abs(p.x2 - q.x2) > DEGENERATE_SEGMENT_EPSILON ||
|
|
1687
|
+
Math.abs(p.y2 - q.y2) > DEGENERATE_SEGMENT_EPSILON
|
|
1688
|
+
) {
|
|
1689
|
+
return true;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
return false;
|
|
1693
|
+
};
|
|
1694
|
+
|
|
1695
|
+
/** 端点から出る最初 / 最後の直線 segment の長さ。 stub の長さに相当する。 */
|
|
1696
|
+
const stubLengths = (segs: Seg[]): [number, number] => {
|
|
1697
|
+
const len = (s: Seg): number => Math.hypot(s.x2 - s.x1, s.y2 - s.y1);
|
|
1698
|
+
if (segs.length === 0) return [0, 0];
|
|
1699
|
+
return [len(segs[0]!), len(segs[segs.length - 1]!)];
|
|
1700
|
+
};
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* Issue #211 SSOT ... path を指定方向へ押しても壊れないか判定する。
|
|
1704
|
+
*
|
|
1705
|
+
* 3 条件を全て満たす向きだけを採用する。
|
|
1706
|
+
*
|
|
1707
|
+
* 1. **1 点も動かない向きは採らない**。 端点領域の固定 (Issue #216) により、 corner を持たない
|
|
1708
|
+
* 直線 path は直交方向へ動かせない。 「壊れないから安全」 と判定して探索を止めると、
|
|
1709
|
+
* 実際に動かせる別の軸を試さないまま近接が残る。
|
|
1710
|
+
* 2. **起点 stub が `EDGE_STUB_OUT` を割らない**。 端点が固定されたまま path を押すと、 端点に
|
|
1711
|
+
* 続く直線が長さを変える。 縮む向きに押し続けると stub が潰れる。
|
|
1712
|
+
* 3. **無関係な node を新たに貫かない**。 label の近接より path の貫通の方が視覚的に重い。
|
|
1713
|
+
*
|
|
1714
|
+
* 押し出し量は累積するため、 累積後の総量で判定する。 実際の適用と同じ関数を通すので、
|
|
1715
|
+
* 素の平行移動を模擬した場合に生じる端点まわりのずれがない。
|
|
1716
|
+
*
|
|
1717
|
+
* 端点が node の辺から外れないことは `translatePathDPreservingEndpoints` が構造的に保証する
|
|
1718
|
+
* (両端の座標 pair は常に固定される)。 呼び元で `side` を見る必要はない。
|
|
1719
|
+
*/
|
|
1720
|
+
const pathShiftIsSafe = (
|
|
1721
|
+
edgeId: string,
|
|
1722
|
+
axis: "x" | "y",
|
|
1723
|
+
sign: 1 | -1,
|
|
1724
|
+
amount: number,
|
|
1725
|
+
): boolean => {
|
|
1726
|
+
const edge = edgeById.get(edgeId);
|
|
1727
|
+
if (!edge) return false;
|
|
1728
|
+
const acc = getEdgePathShift(edgeId);
|
|
1729
|
+
const dx = acc.dx + (axis === "x" ? sign * amount : 0);
|
|
1730
|
+
const dy = acc.dy + (axis === "y" ? sign * amount : 0);
|
|
1731
|
+
const before = simulateShiftedSegments(edge, acc.dx, acc.dy);
|
|
1732
|
+
const after = simulateShiftedSegments(edge, dx, dy);
|
|
1733
|
+
if (after.length === 0) return false;
|
|
1734
|
+
|
|
1735
|
+
// path が 1 点も動かない向きは採らない。 corner を持たない直線 path は直交方向へ動かせず、
|
|
1736
|
+
// これを「安全」 と判定して採用すると、 実際に動かせる別の軸を試さないまま止まる。
|
|
1737
|
+
if (!segmentsDiffer(before, after)) return false;
|
|
1738
|
+
|
|
1739
|
+
const [beforeHead, beforeTail] = stubLengths(before);
|
|
1740
|
+
const [afterHead, afterTail] = stubLengths(after);
|
|
1741
|
+
if (afterHead < EDGE_STUB_OUT && afterHead < beforeHead) return false;
|
|
1742
|
+
if (afterTail < EDGE_STUB_OUT && afterTail < beforeTail) return false;
|
|
1743
|
+
|
|
1744
|
+
for (const o of nodeObstacles) {
|
|
1745
|
+
if (edge.from === o.id || edge.to === o.id) continue; // 端点 node への接触は legit
|
|
1746
|
+
const rect = { x: o.x, y: o.y, w: o.w, h: o.h };
|
|
1747
|
+
if (!segmentsCrossRect(before, rect) && segmentsCrossRect(after, rect)) return false;
|
|
1748
|
+
}
|
|
1749
|
+
return true;
|
|
1750
|
+
};
|
|
1751
|
+
for (const label of labels) {
|
|
1752
|
+
const labelEdge = edgeById.get(label.id);
|
|
1753
|
+
for (const e of edges) {
|
|
1754
|
+
if (label.id === e.id) continue; // 自 edge の label × path は shift 対象外 (legit)
|
|
1755
|
+
// parallel edge (同 from + 同 to) は path 完全重畳が CAR-429 SSOT、 shift 対象外。
|
|
1756
|
+
if (labelEdge && labelEdge.from === e.from && labelEdge.to === e.to) continue;
|
|
1757
|
+
// fan 兄弟と共有する segment は自 path 扱い (Issue #211)。 除外後に残る segment のみで測る。
|
|
1758
|
+
const segs = segmentsExcludingOwnPath(shiftedSegments(e.id, e.d), ownSegsOf(label.id));
|
|
1759
|
+
if (segs.length === 0) continue;
|
|
1760
|
+
const gap = measurePathLabelGapSegs(segs, label);
|
|
1761
|
+
// 直接 overlap or 近接 (pathLabelClearance 未達) の判定 = gap < pathLabelClearance
|
|
1762
|
+
// 正常 fan-in / fan-out で label が sibling path 近傍 (LABEL_INIT_CLEARANCE 26) にある case は
|
|
1763
|
+
// 誤検知しない (gap=16-34 → pathLabelClearance=16 なら pass、 minClearance=36 なら誤発火)。
|
|
1764
|
+
if (gap >= pathLabelClearance) continue;
|
|
1765
|
+
// 軸選択 = label bbox 4 辺 (top/bottom/left/right) の中央 probe から path segments までの最短距離を求め、
|
|
1766
|
+
// 最小距離の辺を選ぶ (辺方向 = penetration 方向)。 例 = 上辺が最も近い ⇒ path は上方 ⇒ path shift 上方 (Y-)。
|
|
1767
|
+
// 単純に中心距離の大小で軸選ぶと horizontal 長 path で X 軸が dominant になり Y 方向 collision を見逃す。
|
|
1768
|
+
const labelCenterY = label.y + label.h / 2;
|
|
1769
|
+
const labelCenterX = label.x + label.w / 2;
|
|
1770
|
+
const edgeProbes = [
|
|
1771
|
+
{ side: "top", x: labelCenterX, y: label.y, axis: "y", sign: -1 as 1 | -1 },
|
|
1772
|
+
{ side: "bottom", x: labelCenterX, y: label.y + label.h, axis: "y", sign: 1 as 1 | -1 },
|
|
1773
|
+
{ side: "left", x: label.x, y: labelCenterY, axis: "x", sign: -1 as 1 | -1 },
|
|
1774
|
+
{ side: "right", x: label.x + label.w, y: labelCenterY, axis: "x", sign: 1 as 1 | -1 },
|
|
1775
|
+
] as const;
|
|
1776
|
+
let bestSideIdx = -1;
|
|
1777
|
+
let bestSideDist = Infinity;
|
|
1778
|
+
const probeDist: number[] = [];
|
|
1779
|
+
for (let idx = 0; idx < edgeProbes.length; idx++) {
|
|
1780
|
+
const p = edgeProbes[idx]!;
|
|
1781
|
+
let minD = Infinity;
|
|
1782
|
+
for (const s of segs) {
|
|
1783
|
+
const dx = s.x2 - s.x1;
|
|
1784
|
+
const dy = s.y2 - s.y1;
|
|
1785
|
+
const lenSq = dx * dx + dy * dy;
|
|
1786
|
+
let t = lenSq === 0 ? 0 : ((p.x - s.x1) * dx + (p.y - s.y1) * dy) / lenSq;
|
|
1787
|
+
t = Math.max(0, Math.min(1, t));
|
|
1788
|
+
const cx = s.x1 + t * dx;
|
|
1789
|
+
const cy = s.y1 + t * dy;
|
|
1790
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
1791
|
+
if (d < minD) minD = d;
|
|
1792
|
+
}
|
|
1793
|
+
probeDist.push(minD);
|
|
1794
|
+
if (minD < bestSideDist) {
|
|
1795
|
+
bestSideDist = minD;
|
|
1796
|
+
bestSideIdx = idx;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
if (bestSideIdx < 0) continue;
|
|
1800
|
+
const amount = (pathLabelClearance - gap) + pathLabelClearance;
|
|
1801
|
+
// Issue #211 SSOT ... probe を近い順に見て、 path を壊さない最初の向きを採る。
|
|
1802
|
+
// どの向きも安全でないなら path を動かさない (label の近接より path の破損の方が重い)。
|
|
1803
|
+
const ordered = edgeProbes
|
|
1804
|
+
.map((p, idx) => ({ p, idx }))
|
|
1805
|
+
.sort((a, b) => probeDist[a.idx]! - probeDist[b.idx]!);
|
|
1806
|
+
for (const { p } of ordered) {
|
|
1807
|
+
const axis = p.axis as "x" | "y";
|
|
1808
|
+
if (!pathShiftIsSafe(e.id, axis, p.sign, amount)) continue;
|
|
1809
|
+
out.push({ target: { kind: "edge-path", id: e.id }, axis, sign: p.sign, amount });
|
|
1810
|
+
break;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
// (5) CAR-485 edge-path chain propagate = shift 済 label の shift 方向にある別 edge の path も
|
|
1816
|
+
// 同方向連鎖 shift (CAR-482 semantic を edge-path 対象に拡張)。
|
|
1817
|
+
// 判定 = label 累積 shift 方向にある path (segment 実距離ベースで近接) → path 側 shift 発火。
|
|
1818
|
+
// path shift amount = pathLabelClearance (label × path の clearance と対称)。
|
|
1819
|
+
// 除外 = parallel edge (同 from + 同 to) は path 完全重畳が CAR-429 SSOT、 chain 対象外。
|
|
1820
|
+
for (const label of labels) {
|
|
1821
|
+
const currentShift = getLabelChainShift(label.id);
|
|
1822
|
+
if (currentShift.dx === 0 && currentShift.dy === 0) continue;
|
|
1823
|
+
const labelEdge = edgeById.get(label.id);
|
|
1824
|
+
for (const e of edges) {
|
|
1825
|
+
if (label.id === e.id) continue; // 自 edge は対象外
|
|
1826
|
+
if (labelEdge && labelEdge.from === e.from && labelEdge.to === e.to) continue;
|
|
1827
|
+
// fan 兄弟と共有する segment は自 path 扱い (Issue #211)。
|
|
1828
|
+
const segs = segmentsExcludingOwnPath(shiftedSegments(e.id, e.d), ownSegsOf(label.id));
|
|
1829
|
+
if (segs.length === 0) continue;
|
|
1830
|
+
const before = out.length;
|
|
1831
|
+
propagateEdgePathAlongAxis(out, label, e.id, segs, currentShift, "y", pathLabelClearance);
|
|
1832
|
+
propagateEdgePathAlongAxis(out, label, e.id, segs, currentShift, "x", pathLabelClearance);
|
|
1833
|
+
// path を壊す向きの命令は捨てる (Issue #211、 pathShiftIsSafe SSOT)。
|
|
1834
|
+
for (let i = out.length - 1; i >= before; i--) {
|
|
1835
|
+
const instr = out[i]!;
|
|
1836
|
+
if (!pathShiftIsSafe(e.id, instr.axis, instr.sign, instr.amount)) out.splice(i, 1);
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
return out;
|
|
1842
|
+
};
|
|
1843
|
+
|
|
1844
|
+
// chain 伝搬 helper。 label 累積 shift の指定軸 (x or y) 方向にある obstacle への propagate 命令発行。
|
|
1845
|
+
// - shift 累積 0 の軸なら no-op
|
|
1846
|
+
// - 直交軸で label bbox と obstacle bbox の band 重なりなし (すれ違い) なら no-op
|
|
1847
|
+
// - obstacle 中心が label 中心の shift 方向反対側 (逆方向) なら no-op
|
|
1848
|
+
// - 中心距離が半径合計 + margin (2 * minClearance) 超なら no-op (chain 伝搬圏外)
|
|
1849
|
+
// 通過時に obstacle 側 shift 命令 (amount = minClearance) を out に push。
|
|
1850
|
+
function propagateAlongAxis(
|
|
1851
|
+
out: ShiftInstr[],
|
|
1852
|
+
label: BBox,
|
|
1853
|
+
obs: BBox,
|
|
1854
|
+
currentShift: { dx: number; dy: number },
|
|
1855
|
+
axis: "x" | "y",
|
|
1856
|
+
): void {
|
|
1857
|
+
const shiftVal = axis === "y" ? currentShift.dy : currentShift.dx;
|
|
1858
|
+
if (shiftVal === 0) return;
|
|
1859
|
+
// 直交軸 band 重なり判定
|
|
1860
|
+
const bandOverlap = axis === "y"
|
|
1861
|
+
? label.x < obs.x + obs.w && label.x + label.w > obs.x
|
|
1862
|
+
: label.y < obs.y + obs.h && label.y + label.h > obs.y;
|
|
1863
|
+
if (!bandOverlap) return;
|
|
1864
|
+
const shiftSign: 1 | -1 = shiftVal > 0 ? 1 : -1;
|
|
1865
|
+
// 中心座標 (指定軸)
|
|
1866
|
+
const labelCenter = axis === "y" ? label.y + label.h / 2 : label.x + label.w / 2;
|
|
1867
|
+
const obsCenter = axis === "y" ? obs.y + obs.h / 2 : obs.x + obs.w / 2;
|
|
1868
|
+
// obstacle が shift 方向にあるか
|
|
1869
|
+
const obsIsInShiftDirection =
|
|
1870
|
+
(shiftSign === 1 && obsCenter > labelCenter) || (shiftSign === -1 && obsCenter < labelCenter);
|
|
1871
|
+
if (!obsIsInShiftDirection) return;
|
|
1872
|
+
// 近接判定
|
|
1873
|
+
const centerDist = Math.abs(obsCenter - labelCenter);
|
|
1874
|
+
const halfSum = axis === "y" ? label.h / 2 + obs.h / 2 : label.w / 2 + obs.w / 2;
|
|
1875
|
+
if (centerDist >= halfSum + 2 * minClearance) return;
|
|
1876
|
+
// shift 命令 = 同方向、 amount は minClearance
|
|
1877
|
+
out.push({
|
|
1878
|
+
target: { kind: obs.kind as "node" | "lane-label", id: obs.id },
|
|
1879
|
+
axis,
|
|
1880
|
+
sign: shiftSign,
|
|
1881
|
+
amount: minClearance,
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/**
|
|
1886
|
+
* CAR-485 edge-path chain propagate 用 helper。 label 累積 shift の指定軸方向にある edge-path
|
|
1887
|
+
* segment 集合が近接している場合、 その edge-path も同方向 shift 命令を発行する。
|
|
1888
|
+
*
|
|
1889
|
+
* 判定条件 (`propagateAlongAxis` の edge-path 版)。
|
|
1890
|
+
* - label 累積 shift の指定軸成分が 0 なら no-op
|
|
1891
|
+
* - segment 集合の bbox 中心が label 中心の shift 方向反対側 (逆方向) なら no-op
|
|
1892
|
+
* - label と path segments の距離 (segment 実距離ベース) が近接 (< 2 * minClearance) なら shift 発火
|
|
1893
|
+
*/
|
|
1894
|
+
function propagateEdgePathAlongAxis(
|
|
1895
|
+
out: ShiftInstr[],
|
|
1896
|
+
label: BBox,
|
|
1897
|
+
edgeId: string,
|
|
1898
|
+
segs: Array<{ x1: number; y1: number; x2: number; y2: number }>,
|
|
1899
|
+
currentShift: { dx: number; dy: number },
|
|
1900
|
+
axis: "x" | "y",
|
|
1901
|
+
pathClearance: number,
|
|
1902
|
+
): void {
|
|
1903
|
+
const shiftVal = axis === "y" ? currentShift.dy : currentShift.dx;
|
|
1904
|
+
if (shiftVal === 0) return;
|
|
1905
|
+
const shiftSign: 1 | -1 = shiftVal > 0 ? 1 : -1;
|
|
1906
|
+
// path segments の中心 (端点平均) を近似
|
|
1907
|
+
let sumPx = 0;
|
|
1908
|
+
let sumPy = 0;
|
|
1909
|
+
let n = 0;
|
|
1910
|
+
for (const s of segs) {
|
|
1911
|
+
sumPx += s.x1 + s.x2;
|
|
1912
|
+
sumPy += s.y1 + s.y2;
|
|
1913
|
+
n += 2;
|
|
1914
|
+
}
|
|
1915
|
+
if (n === 0) return;
|
|
1916
|
+
const pathCenter = axis === "y" ? sumPy / n : sumPx / n;
|
|
1917
|
+
const labelCenter = axis === "y" ? label.y + label.h / 2 : label.x + label.w / 2;
|
|
1918
|
+
// path が shift 方向にあるか (shift 方向 = label が動く方向、 その方向にある path も動かす)
|
|
1919
|
+
const pathIsInShiftDirection =
|
|
1920
|
+
(shiftSign === 1 && pathCenter > labelCenter) || (shiftSign === -1 && pathCenter < labelCenter);
|
|
1921
|
+
if (!pathIsInShiftDirection) return;
|
|
1922
|
+
// 近接判定 = segment 実距離が閾値内なら shift 対象。 閾値 = 2 * pathClearance で余裕。
|
|
1923
|
+
const gap = measurePathLabelGapSegs(segs, label);
|
|
1924
|
+
if (gap >= 2 * pathClearance) return;
|
|
1925
|
+
// shift 命令 = 同方向、 amount は pathClearance
|
|
1926
|
+
out.push({
|
|
1927
|
+
target: { kind: "edge-path", id: edgeId },
|
|
1928
|
+
axis,
|
|
1929
|
+
sign: shiftSign,
|
|
1930
|
+
amount: pathClearance,
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
// chain 伝搬 loop = shift → 再検出 → 再 shift の iterative、 MAX_ITER で強制収束。
|
|
1935
|
+
for (let iter = 0; iter < maxIter; iter++) {
|
|
1936
|
+
const instrs = detectShifts();
|
|
1937
|
+
if (instrs.length === 0) {
|
|
1938
|
+
return {
|
|
1939
|
+
edges: applyEdgeLabelAndPathShifts(edges, labelShiftMap, edgePathShiftMap),
|
|
1940
|
+
nodeShifts: nodeShiftMap,
|
|
1941
|
+
laneLabelShifts: laneLabelShiftMap,
|
|
1942
|
+
edgePathShifts: edgePathShiftMap,
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// 同 target に複数命令が来る場合、 各軸 max (符号別) を採用。
|
|
1947
|
+
const iterShift = new Map<string, { kind: "label" | "node" | "lane-label" | "edge-path"; dx: number; dy: number }>();
|
|
1948
|
+
// chain 伝搬の起点として数える label shift だけを別集計する (Issue #211、 labelChainShiftMap SSOT)。
|
|
1949
|
+
const iterChainLabelShift = new Map<string, { dx: number; dy: number }>();
|
|
1950
|
+
const merge = (target: { dx: number; dy: number }, p: ShiftInstr): void => {
|
|
1951
|
+
if (p.axis === "x") {
|
|
1952
|
+
target.dx = p.sign > 0 ? Math.max(target.dx, p.amount) : Math.min(target.dx, -p.amount);
|
|
1953
|
+
} else {
|
|
1954
|
+
target.dy = p.sign > 0 ? Math.max(target.dy, p.amount) : Math.min(target.dy, -p.amount);
|
|
1955
|
+
}
|
|
1956
|
+
};
|
|
1957
|
+
for (const p of instrs) {
|
|
1958
|
+
const key = `${p.target.kind}::${p.target.id}`;
|
|
1959
|
+
const entry = iterShift.get(key) ?? { kind: p.target.kind, dx: 0, dy: 0 };
|
|
1960
|
+
merge(entry, p);
|
|
1961
|
+
iterShift.set(key, entry);
|
|
1962
|
+
if (p.target.kind === "label" && p.chain !== false) {
|
|
1963
|
+
const chainEntry = iterChainLabelShift.get(p.target.id) ?? { dx: 0, dy: 0 };
|
|
1964
|
+
merge(chainEntry, p);
|
|
1965
|
+
iterChainLabelShift.set(p.target.id, chainEntry);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
for (const [id, delta] of iterChainLabelShift) {
|
|
1969
|
+
const prev = labelChainShiftMap.get(id) ?? { dx: 0, dy: 0 };
|
|
1970
|
+
labelChainShiftMap.set(
|
|
1971
|
+
id,
|
|
1972
|
+
clampShiftMagnitude({ dx: prev.dx + delta.dx, dy: prev.dy + delta.dy }, MAX_ACCUMULATED_SHIFT),
|
|
1973
|
+
);
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// 累積 shift Map に足し込み (kind::id key から id 抽出、 kind 別 Map に加算)。
|
|
1977
|
+
// CAR-508 = accumulate 後に |dx| / |dy| ≤ MAX_ACCUMULATED_SHIFT で clamp、 発散抑止。
|
|
1978
|
+
// clamp で「shift 差分 = 0」 になると anyShift 更新せず、 loop 終了条件を満たす。
|
|
1979
|
+
//
|
|
1980
|
+
// kind 別に累積先 Map が異なるが、 accumulate + clamp + Map.set の共通処理を
|
|
1981
|
+
// `accumulate` closure に集約、 4 kind 分岐は Map 選択と edge-path の label sync のみ。
|
|
1982
|
+
// 戻り値 = 実際に累積が更新されたかどうか (clamp で no-op なら false)。
|
|
1983
|
+
const accumulate = (
|
|
1984
|
+
map: Map<string, { dx: number; dy: number }>,
|
|
1985
|
+
id: string,
|
|
1986
|
+
delta: { dx: number; dy: number },
|
|
1987
|
+
): { prev: { dx: number; dy: number }; next: { dx: number; dy: number }; changed: boolean } => {
|
|
1988
|
+
const prev = map.get(id) ?? { dx: 0, dy: 0 };
|
|
1989
|
+
const next = clampShiftMagnitude(
|
|
1990
|
+
{ dx: prev.dx + delta.dx, dy: prev.dy + delta.dy },
|
|
1991
|
+
MAX_ACCUMULATED_SHIFT,
|
|
1992
|
+
);
|
|
1993
|
+
const changed = next.dx !== prev.dx || next.dy !== prev.dy;
|
|
1994
|
+
if (changed) map.set(id, next);
|
|
1995
|
+
return { prev, next, changed };
|
|
1996
|
+
};
|
|
1997
|
+
let anyShift = false;
|
|
1998
|
+
for (const [key, s] of iterShift) {
|
|
1999
|
+
if (s.dx === 0 && s.dy === 0) continue;
|
|
2000
|
+
const [kind, id] = key.split("::") as [
|
|
2001
|
+
"label" | "node" | "lane-label" | "edge-path",
|
|
2002
|
+
string,
|
|
2003
|
+
];
|
|
2004
|
+
if (kind === "label") {
|
|
2005
|
+
if (accumulate(labelShiftMap, id, s).changed) anyShift = true;
|
|
2006
|
+
} else if (kind === "node") {
|
|
2007
|
+
// node / lane-label も同 clamp 適用。 chain 伝搬で node が発散的に shift されるのを防ぐ。
|
|
2008
|
+
if (accumulate(nodeShiftMap, id, s).changed) anyShift = true;
|
|
2009
|
+
} else if (kind === "lane-label") {
|
|
2010
|
+
if (accumulate(laneLabelShiftMap, id, s).changed) anyShift = true;
|
|
2011
|
+
} else if (kind === "edge-path") {
|
|
2012
|
+
// CAR-508 review (MAJOR) = path shift 時は自 label の headroom で先に cap してから apply。
|
|
2013
|
+
// 旧 = path を先に累積 → label へ同 delta apply、 label 側 clamp で吸収、 これでは path と
|
|
2014
|
+
// label の相対距離が LABEL_INIT_CLEARANCE から逸脱 (CAR-485 same-delta invariant 違反)。
|
|
2015
|
+
// 新 = 自 label の残 headroom で先に path delta を cap、 両者に同じ effective delta を apply。
|
|
2016
|
+
if (labeledIds.has(id)) {
|
|
2017
|
+
const labelPrev = labelShiftMap.get(id) ?? { dx: 0, dy: 0 };
|
|
2018
|
+
const labelNext = clampShiftMagnitude(
|
|
2019
|
+
{ dx: labelPrev.dx + s.dx, dy: labelPrev.dy + s.dy },
|
|
2020
|
+
MAX_ACCUMULATED_SHIFT,
|
|
2021
|
+
);
|
|
2022
|
+
const effectiveDelta = { dx: labelNext.dx - labelPrev.dx, dy: labelNext.dy - labelPrev.dy };
|
|
2023
|
+
if (effectiveDelta.dx === 0 && effectiveDelta.dy === 0) continue;
|
|
2024
|
+
const path = accumulate(edgePathShiftMap, id, effectiveDelta);
|
|
2025
|
+
if (path.changed) {
|
|
2026
|
+
anyShift = true;
|
|
2027
|
+
accumulate(labelShiftMap, id, effectiveDelta);
|
|
2028
|
+
}
|
|
2029
|
+
} else {
|
|
2030
|
+
const path = accumulate(edgePathShiftMap, id, s);
|
|
2031
|
+
if (path.changed) anyShift = true;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
if (!anyShift) break;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
return {
|
|
2039
|
+
edges: applyEdgeLabelAndPathShifts(edges, labelShiftMap, edgePathShiftMap),
|
|
2040
|
+
nodeShifts: nodeShiftMap,
|
|
2041
|
+
laneLabelShifts: laneLabelShiftMap,
|
|
2042
|
+
edgePathShifts: edgePathShiftMap,
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/**
|
|
2047
|
+
* CAR-485 = label shift + edge-path shift を同時 apply する helper。
|
|
2048
|
+
* label shift は labelX / labelY 加算、 edge-path shift は path.d の shift。
|
|
2049
|
+
*
|
|
2050
|
+
* edge path shift は端点領域保持型 `translatePathDPreservingEndpoints` を通す (Issue #216)。
|
|
2051
|
+
* 両端の座標 pair は常に固定されるため、 起点 / 終点が node の辺から外れない。
|
|
2052
|
+
*
|
|
2053
|
+
* CAR-489 以前 (CAR-485) は `translatePathD` (全 point 平行移動) を採用していたため、
|
|
2054
|
+
* horizontal detour path (retry 相当) で起点 / 終点が node bbox 外に飛ぶ regression があった。
|
|
2055
|
+
*/
|
|
2056
|
+
function applyEdgeLabelAndPathShifts(
|
|
2057
|
+
edges: LaidEdge[],
|
|
2058
|
+
labelShiftMap: Map<string, { dx: number; dy: number }>,
|
|
2059
|
+
edgePathShiftMap: Map<string, { dx: number; dy: number }>,
|
|
2060
|
+
): LaidEdge[] {
|
|
2061
|
+
if (labelShiftMap.size === 0 && edgePathShiftMap.size === 0) return edges;
|
|
2062
|
+
return edges.map((e) => {
|
|
2063
|
+
const ls = labelShiftMap.get(e.id);
|
|
2064
|
+
const ps = edgePathShiftMap.get(e.id);
|
|
2065
|
+
const hasLabelShift = ls && (ls.dx !== 0 || ls.dy !== 0);
|
|
2066
|
+
const hasPathShift = ps && (ps.dx !== 0 || ps.dy !== 0);
|
|
2067
|
+
if (!hasLabelShift && !hasPathShift) return e;
|
|
2068
|
+
let next = e;
|
|
2069
|
+
if (hasLabelShift) {
|
|
2070
|
+
next = { ...next, labelX: next.labelX + ls!.dx, labelY: next.labelY + ls!.dy };
|
|
2071
|
+
}
|
|
2072
|
+
if (hasPathShift) {
|
|
2073
|
+
next = { ...next, d: translatePathDPreservingEndpoints(next.d, ps!.dx, ps!.dy) };
|
|
2074
|
+
}
|
|
2075
|
+
return next;
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
|