@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,2498 @@
|
|
|
1
|
+
import type { CdlDiagram, CdlEdge, LaidEdge, LaidLane, LaidNode, Side } from "../types";
|
|
2
|
+
import {
|
|
3
|
+
CLEARANCE_LABEL_LABEL,
|
|
4
|
+
CLEARANCE_NODE_LABEL,
|
|
5
|
+
CLEARANCE_PATH_LABEL,
|
|
6
|
+
} from "./clearance-constants";
|
|
7
|
+
import {
|
|
8
|
+
extractPathSegments,
|
|
9
|
+
flattenPathSegments,
|
|
10
|
+
segmentsCrossRect,
|
|
11
|
+
segmentsExcludingOwnPath,
|
|
12
|
+
} from "./collisions";
|
|
13
|
+
import {
|
|
14
|
+
computeLabelBBoxWorld,
|
|
15
|
+
computeLabelBoxW,
|
|
16
|
+
EDGE_STUB_OUT,
|
|
17
|
+
hasRenderedLabel,
|
|
18
|
+
labelInitClearance,
|
|
19
|
+
LANE_BORDER_CLEARANCE_TOL,
|
|
20
|
+
labelPathClearanceHorizontal,
|
|
21
|
+
labelPillH,
|
|
22
|
+
spreadWithMinimalShift,
|
|
23
|
+
PARALLEL_EDGE_BOW,
|
|
24
|
+
SYMMETRIC_PAIR_SHIFT,
|
|
25
|
+
} from "./spec";
|
|
26
|
+
import { DEFAULT_LABEL_MARGIN } from "./tokens";
|
|
27
|
+
|
|
28
|
+
/** 縦 detour が obstacle の外側を通る時の余白 (world unit)。 */
|
|
29
|
+
const VERTICAL_DETOUR_GAP = 50;
|
|
30
|
+
/** 縦 detour の角の丸め半径 (world unit)。 */
|
|
31
|
+
const VERTICAL_DETOUR_CORNER = 14;
|
|
32
|
+
/** 縦 detour が source から曲がるまで直進する距離 (world unit)。 EDGE_STUB_OUT を上回る。 */
|
|
33
|
+
const VERTICAL_DETOUR_EXIT = 45;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 端点から進行方向へ進んだ時、 最初に当たる箱までの距離。 当たる箱が無ければ `Infinity`。
|
|
37
|
+
*
|
|
38
|
+
* 垂直に上がる (下がる) 線が走る Y 帯に掛からない箱は、 折れる位置がどこでも当たらないので
|
|
39
|
+
* 数えない。
|
|
40
|
+
*
|
|
41
|
+
* @param anchorX 折れる線が出る側の端点 X (起点なら `A.x`、 終点側なら `B.x`)
|
|
42
|
+
* @param dir 端点から折れる位置へ向かう向き (+1 で右、 -1 で左)
|
|
43
|
+
* @param verticalTop 垂直に走る区間の上端 Y
|
|
44
|
+
* @param verticalBottom 同じく下端 Y
|
|
45
|
+
*/
|
|
46
|
+
function nearestBoxDistance(
|
|
47
|
+
anchorX: number,
|
|
48
|
+
dir: 1 | -1,
|
|
49
|
+
verticalTop: number,
|
|
50
|
+
verticalBottom: number,
|
|
51
|
+
boxes: readonly Rect[],
|
|
52
|
+
): number {
|
|
53
|
+
let nearest = Infinity;
|
|
54
|
+
for (const box of boxes) {
|
|
55
|
+
if (box.y + box.h <= verticalTop || box.y >= verticalBottom) continue;
|
|
56
|
+
// 進行方向で手前になる辺までの距離。 0 以下なら進行方向に無い
|
|
57
|
+
const near = dir === 1 ? box.x : box.x + box.w;
|
|
58
|
+
const dist = (near - anchorX) * dir;
|
|
59
|
+
if (dist <= 0) continue;
|
|
60
|
+
nearest = Math.min(nearest, dist);
|
|
61
|
+
}
|
|
62
|
+
return nearest;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 迂回 edge が折れる位置 = 端点と、 進行方向で最初に当たる箱の **中間** (#418)。
|
|
67
|
+
*
|
|
68
|
+
* 折れる位置を `EDGE_STUB_OUT` の固定値に置くと、 走る距離が長い edge ほど「箱を出た瞬間に
|
|
69
|
+
* 折れる」 見え方になり、 線が箱に貼り付く。 実測 = `swimlane` の戻り矢印は 1166 走るのに
|
|
70
|
+
* 折れ始めが 26 だった。
|
|
71
|
+
*
|
|
72
|
+
* 中間に置くと、 折れる線が箱からも隣の札からも同じだけ離れる。 端点に寄せる案 (固定 40) と
|
|
73
|
+
* 箱の限界まで離す案 (比で決めて手前で止める) の両方を実際に描いて比べ、 中間を採った
|
|
74
|
+
* (呼び出し側 user の判断)。
|
|
75
|
+
*
|
|
76
|
+
* 当たる箱は node と、 別 edge の札の両方を見る。 札を見ないと垂直の線が札を貫く。
|
|
77
|
+
*
|
|
78
|
+
* 下限は `EDGE_STUB_OUT` を割らない。 隙間が 80 未満だと中間が下限を下回るが、 そこは比を
|
|
79
|
+
* 使う前 (固定 40) と同じ位置なので従来の判断を変えない。
|
|
80
|
+
*/
|
|
81
|
+
function detourStub(
|
|
82
|
+
anchorX: number,
|
|
83
|
+
dir: 1 | -1,
|
|
84
|
+
verticalTop: number,
|
|
85
|
+
verticalBottom: number,
|
|
86
|
+
nodeBoxes: readonly Rect[],
|
|
87
|
+
avoidRects: readonly Rect[],
|
|
88
|
+
): number {
|
|
89
|
+
const nearest = Math.min(
|
|
90
|
+
nearestBoxDistance(anchorX, dir, verticalTop, verticalBottom, nodeBoxes),
|
|
91
|
+
nearestBoxDistance(anchorX, dir, verticalTop, verticalBottom, avoidRects),
|
|
92
|
+
);
|
|
93
|
+
if (!Number.isFinite(nearest)) return EDGE_STUB_OUT;
|
|
94
|
+
return Math.max(EDGE_STUB_OUT, nearest / 2);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A → B の横線を塞ぐ node を全て返す。
|
|
99
|
+
*
|
|
100
|
+
* 1 個だけ避けると、 背の高い 2 個目を貫通する。 同じ行の node は行の高さを共有するので既定では
|
|
101
|
+
* 起きないが、 `n.h` を明示した node は行と独立に背が高くなる (Issue #229)。
|
|
102
|
+
*/
|
|
103
|
+
function horizontalDetourBlockers(
|
|
104
|
+
A: { x: number; y: number },
|
|
105
|
+
B: { x: number; y: number },
|
|
106
|
+
obstacles: LaidNode[],
|
|
107
|
+
): LaidNode[] {
|
|
108
|
+
const minX = Math.min(A.x, B.x);
|
|
109
|
+
const maxX = Math.max(A.x, B.x);
|
|
110
|
+
return obstacles.filter((n) => {
|
|
111
|
+
const nLeft = n.cx - n.w / 2;
|
|
112
|
+
const nRight = n.cx + n.w / 2;
|
|
113
|
+
const nTop = n.cy - n.h / 2;
|
|
114
|
+
const nBottom = n.cy + n.h / 2;
|
|
115
|
+
return A.y >= nTop && A.y <= nBottom && nLeft < maxX && nRight > minX;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A → B の縦線を塞ぐ node を全て返す。 `routePath` と迂回列の割当が同じ判定を共有する。
|
|
121
|
+
*
|
|
122
|
+
* 1 個だけ避けると、 複数 node を跨ぐ edge が 2 個目を貫通する (Issue #211)。
|
|
123
|
+
*/
|
|
124
|
+
function verticalDetourBlockers(
|
|
125
|
+
A: { x: number; y: number },
|
|
126
|
+
B: { x: number; y: number },
|
|
127
|
+
obstacles: LaidNode[],
|
|
128
|
+
): LaidNode[] {
|
|
129
|
+
const minY = Math.min(A.y, B.y);
|
|
130
|
+
const maxY = Math.max(A.y, B.y);
|
|
131
|
+
return obstacles.filter((n) => {
|
|
132
|
+
const nLeft = n.cx - n.w / 2;
|
|
133
|
+
const nRight = n.cx + n.w / 2;
|
|
134
|
+
const nTop = n.cy - n.h / 2;
|
|
135
|
+
const nBottom = n.cy + n.h / 2;
|
|
136
|
+
return A.x >= nLeft && A.x <= nRight && nTop < maxY && nBottom > minY;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 縦 detour が blocker のどちら側を回るか。 既定は右。 */
|
|
141
|
+
type DetourSide = "left" | "right";
|
|
142
|
+
|
|
143
|
+
/** 縦 detour が blocker の外側を通る列の X。 side に応じて左右どちらかに置く。 */
|
|
144
|
+
function detourColumnFor(blockers: LaidNode[], side: DetourSide): number {
|
|
145
|
+
return side === "left"
|
|
146
|
+
? Math.min(...blockers.map((n) => n.cx - n.w / 2)) - VERTICAL_DETOUR_GAP
|
|
147
|
+
: Math.max(...blockers.map((n) => n.cx + n.w / 2)) + VERTICAL_DETOUR_GAP;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane[] = []): LaidEdge[] {
|
|
151
|
+
// labelMargin = author 追加余白、 path 中点 → label の垂直距離に加算 (上向き label は -=、 下向きは +=)。
|
|
152
|
+
// viewport.labelMargin 明示時はその差分、 未指定なら viewport.gap → DEFAULT_LABEL_MARGIN の順で fallback。
|
|
153
|
+
const labelMarginExtra =
|
|
154
|
+
(diag.viewport?.labelMargin ?? diag.viewport?.gap ?? DEFAULT_LABEL_MARGIN) - DEFAULT_LABEL_MARGIN;
|
|
155
|
+
const nmap = new Map(nodes.map((n) => [n.id, n]));
|
|
156
|
+
const lmap = new Map(lanes.map((l) => [l.id, l]));
|
|
157
|
+
|
|
158
|
+
// canvas pivot 新 spec (dragon canvas pivot spec §layout-role-conversion)。
|
|
159
|
+
// posX1 / posY1 / posX2 / posY2 全て set 済 edge は「user が固定した絶対 path」 = 直線 path
|
|
160
|
+
// (posX1, posY1) → (posX2, posY2) を最終 path とする。 label 位置は path 中点、 fromSide / toSide
|
|
161
|
+
// は自動推定 (from/to node 中心対比)、 routing 再計算 skip。 catalog 100+ backward compat は
|
|
162
|
+
// 全 4 field 未指定なら従来通り orthogonal L 字 routing で保証。
|
|
163
|
+
const positionedEdges: LaidEdge[] = [];
|
|
164
|
+
const autoEdges: typeof diag.edges = [];
|
|
165
|
+
for (const e of diag.edges) {
|
|
166
|
+
if (e.posX1 !== undefined && e.posY1 !== undefined && e.posX2 !== undefined && e.posY2 !== undefined) {
|
|
167
|
+
const from = nmap.get(e.from);
|
|
168
|
+
const to = nmap.get(e.to);
|
|
169
|
+
if (!from) throw new Error(`cdl layout: edge "${e.id}" の from "${e.from}" が node に無い`);
|
|
170
|
+
if (!to) throw new Error(`cdl layout: edge "${e.id}" の to "${e.to}" が node に無い`);
|
|
171
|
+
const { fromSide, toSide } = inferSides(from, to, e.side);
|
|
172
|
+
const midX = (e.posX1 + e.posX2) / 2;
|
|
173
|
+
const midY = (e.posY1 + e.posY2) / 2;
|
|
174
|
+
positionedEdges.push({
|
|
175
|
+
...e,
|
|
176
|
+
d: `M ${e.posX1} ${e.posY1} L ${e.posX2} ${e.posY2}`,
|
|
177
|
+
fromSide,
|
|
178
|
+
toSide,
|
|
179
|
+
labelX: midX + (e.labelOffsetX ?? 0),
|
|
180
|
+
labelY: midY + (e.labelOffsetY ?? 0),
|
|
181
|
+
labelAnchor: "middle",
|
|
182
|
+
});
|
|
183
|
+
} else {
|
|
184
|
+
autoEdges.push(e);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const prepared = autoEdges.map((e) => {
|
|
189
|
+
const from = nmap.get(e.from);
|
|
190
|
+
const to = nmap.get(e.to);
|
|
191
|
+
if (!from) throw new Error(`cdl layout: edge "${e.id}" の from "${e.from}" が node に無い`);
|
|
192
|
+
if (!to) throw new Error(`cdl layout: edge "${e.id}" の to "${e.to}" が node に無い`);
|
|
193
|
+
const { fromSide, toSide } = inferSides(from, to, e.side);
|
|
194
|
+
return { e, from, to, fromSide, toSide };
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// 同 from node から複数 target へ向かう edges を方角別に分散
|
|
198
|
+
const byFromNode = new Map<string, typeof prepared>();
|
|
199
|
+
for (const p of prepared) {
|
|
200
|
+
if (p.e.side) continue;
|
|
201
|
+
const list = byFromNode.get(p.e.from) ?? [];
|
|
202
|
+
list.push(p);
|
|
203
|
+
byFromNode.set(p.e.from, list);
|
|
204
|
+
}
|
|
205
|
+
for (const list of byFromNode.values()) {
|
|
206
|
+
if (list.length <= 1) continue;
|
|
207
|
+
distributeBySides(list);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// CAR-429 (CAR-426-B) ... 全 edge の起点 / 終点 / L 字 corner は node 辺 Y 中央固定 (offset=0 SSOT)。
|
|
211
|
+
// 旧 fan-in / fan-out / parallel の sidepoint 分散 (±60/±120) は削除済で、
|
|
212
|
+
// path routing (d 属性) は offset=0 固定で描画される (視覚的に起点認識明確化)。
|
|
213
|
+
//
|
|
214
|
+
// CAR-430 ... label 位置の parallel / fan-in / fan-out 分散を本 file 内で完結させる SSOT。
|
|
215
|
+
// 旧 label-shift.ts の 16 方向 × 26 段階全探索を削除、 edges.ts routePath() が返す init 位置に
|
|
216
|
+
// parallel offset を加算して最終 label 位置とする 単一権限 の設計。
|
|
217
|
+
//
|
|
218
|
+
// 分散の phase order (idx 0 の edge は offset 0 で init 位置維持)。
|
|
219
|
+
// 1. parallel (同 from + 同 to + 同 fromSide + 同 toSide) ... 最強制、 全 idx 分散
|
|
220
|
+
// 2. fan-in (同 to のみ、 parallel でない) ... to の入口が同じ = label 集中しやすい
|
|
221
|
+
// 3. fan-out (同 from のみ、 parallel でない) ... from の出口が同じ = label 集中しやすい
|
|
222
|
+
|
|
223
|
+
// parallel / fan group 検出 + label offset 計算。
|
|
224
|
+
const labelOffsetsByEdgeId = new Map<string, { dx: number; dy: number }>();
|
|
225
|
+
// 同じ節の組を結ぶ辺は、 向きも側も見ずに 1 つの group にする (#372)。 label は向きに関係なく
|
|
226
|
+
// 同じ帯に落ちるため、 向きごとに分けると帯どうしが独立に中心を取って衝突する。
|
|
227
|
+
const parallelKey = (p: (typeof prepared)[number]) =>
|
|
228
|
+
p.e.from < p.e.to ? `${p.e.from}::${p.e.to}` : `${p.e.to}::${p.e.from}`;
|
|
229
|
+
const byParallel = new Map<string, typeof prepared>();
|
|
230
|
+
for (const p of prepared) {
|
|
231
|
+
const key = parallelKey(p);
|
|
232
|
+
const list = byParallel.get(key) ?? [];
|
|
233
|
+
list.push(p);
|
|
234
|
+
byParallel.set(key, list);
|
|
235
|
+
}
|
|
236
|
+
// parallel 分散は Y 軸のみ。 CAR-636 で fan-out / fan-in は Y shift ではなく Y 揃え + X 分散に
|
|
237
|
+
// 転換した (X 方向は下部 FAN_LABEL_GAP=20 で管理)。
|
|
238
|
+
//
|
|
239
|
+
// 分散量の決め方は #372 で「group の中心から ± 一定幅」 から「自分の線の上から最小移動」 に
|
|
240
|
+
// 変えた。 一定幅は本数と最大 pill 高だけで決まるため、 線ごとに高さが違う図で動かす必要の
|
|
241
|
+
// ない label まで動かし、 向きの違う group どうしの帯が衝突していた。 実装は routing 後の
|
|
242
|
+
// `spreadParallelLabels`。
|
|
243
|
+
// CAR-472 SSOT ... fan-in / fan-out の pre-check。 target / source が同じでも path routing pattern
|
|
244
|
+
// (horizontal straight / horizontal blocking detour / vertical) が edge 毎に異なるなら、
|
|
245
|
+
// label は自動的に別 Y region に配置されるため fan offset は不要 (むしろ SSOT clearance を破る)。
|
|
246
|
+
//
|
|
247
|
+
// routing pattern 予測 = obstacle 検出のみ実施 (完全 routePath 呼出は expensive)。
|
|
248
|
+
// horizontal straight vs horizontal detour の差は Y で 100 world+ (detourGap 50 + labelHalfH 18 +
|
|
249
|
+
// clearance 22 の合算)、 CAR-636 fan Y 揃え SSOT では pattern 分裂 group は fan dispersion 全体
|
|
250
|
+
// skip (X sweep も含めて base 位置維持) することで label 集中の混乱を避ける。
|
|
251
|
+
const detectsBlockingObstacle = (p: (typeof prepared)[number]): boolean => {
|
|
252
|
+
if (p.e.style === "dotted-flow") return false;
|
|
253
|
+
const from = p.from;
|
|
254
|
+
const to = p.to;
|
|
255
|
+
const horizontalPair =
|
|
256
|
+
(p.fromSide === "right" && p.toSide === "left") ||
|
|
257
|
+
(p.fromSide === "left" && p.toSide === "right");
|
|
258
|
+
const verticalPair =
|
|
259
|
+
(p.fromSide === "bottom" && p.toSide === "top") ||
|
|
260
|
+
(p.fromSide === "top" && p.toSide === "bottom");
|
|
261
|
+
const A = sidePoint(from, p.fromSide);
|
|
262
|
+
const B = sidePoint(to, p.toSide);
|
|
263
|
+
if (horizontalPair && Math.abs(A.y - B.y) < 1) {
|
|
264
|
+
const minX = Math.min(A.x, B.x);
|
|
265
|
+
const maxX = Math.max(A.x, B.x);
|
|
266
|
+
return nodes.some((n) => {
|
|
267
|
+
if (n.id === from.id || n.id === to.id) return false;
|
|
268
|
+
const nLeft = n.cx - n.w / 2;
|
|
269
|
+
const nRight = n.cx + n.w / 2;
|
|
270
|
+
const nTop = n.cy - n.h / 2;
|
|
271
|
+
const nBottom = n.cy + n.h / 2;
|
|
272
|
+
return A.y >= nTop && A.y <= nBottom && nLeft < maxX && nRight > minX;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
if (verticalPair && Math.abs(A.x - B.x) < 1) {
|
|
276
|
+
const minY = Math.min(A.y, B.y);
|
|
277
|
+
const maxY = Math.max(A.y, B.y);
|
|
278
|
+
return nodes.some((n) => {
|
|
279
|
+
if (n.id === from.id || n.id === to.id) return false;
|
|
280
|
+
const nLeft = n.cx - n.w / 2;
|
|
281
|
+
const nRight = n.cx + n.w / 2;
|
|
282
|
+
const nTop = n.cy - n.h / 2;
|
|
283
|
+
const nBottom = n.cy + n.h / 2;
|
|
284
|
+
return A.x >= nLeft && A.x <= nRight && nTop < maxY && nBottom > minY;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return false;
|
|
288
|
+
};
|
|
289
|
+
// (a) parallel = 同 (from, to, fromSide, toSide) 群 (2+ 本) を y 軸で分散。
|
|
290
|
+
//
|
|
291
|
+
// 分散方向 = y 軸統一 (path 中心 y との差)。 vertical pair でも label は path 中心と同 x で
|
|
292
|
+
// y ずらして「path 沿い」 に配置する方が視覚的関連が強い (旧 fan-in / fan-out logic 相当)。
|
|
293
|
+
// x 軸分散 (path 左右) は「path 中心線から離れて浮遊」 見えするため採用しない。
|
|
294
|
+
//
|
|
295
|
+
// 実際の分散量と対象は routing 後に決める (`spreadParallelLabels`)。 fan-in / fan-out は
|
|
296
|
+
// 実際に分散された label だけを除外する = 辺の本数だけで除外すると、 label を持たない
|
|
297
|
+
// 逆向きの辺を 1 本足しただけで無関係な fan の分散が止まる。
|
|
298
|
+
const parallelGroupedIds = new Set<string>();
|
|
299
|
+
for (const list of byParallel.values()) {
|
|
300
|
+
if (list.length < 2) continue;
|
|
301
|
+
for (const p of list) parallelGroupedIds.add(p.e.id);
|
|
302
|
+
}
|
|
303
|
+
const distributedInParallel = new Set<string>();
|
|
304
|
+
// CAR-472 SSOT ... fan-in / fan-out 共通の分散判定。 group 内 edge の routing pattern (obstacle 有無)
|
|
305
|
+
// が分裂しているなら fan offset を skip、 統一されているなら fan dispersion を適用。
|
|
306
|
+
// pattern 分裂 = label が既に空間分離済 (obstacle detour で Y 100 world+ ずれる) のため
|
|
307
|
+
// 追加 offset は不要 (SSOT 実距離を破るだけ)。
|
|
308
|
+
//
|
|
309
|
+
// 実装変遷 SSOT (時系列)。
|
|
310
|
+
// - CAR-430 = ±FAN_STEP/2 の対称 Y shift (centeredOffset) で fan-out group を上下分散、
|
|
311
|
+
// pill 下端が path を跨ぐ regression 発生 (SSOT LABEL_TO_PATH_CLEARANCE 破り)。
|
|
312
|
+
// - CAR-602 = 「path 上方向 -i * FAN_STEP の階段 shift」 に変更、 idx=0 base + idx≥1 上方階段。
|
|
313
|
+
// pill 貫通は解消したが、 同 group 内で pill 中央 Y が i * FAN_STEP world 差の階段状視覚に
|
|
314
|
+
// なり user 主観「label 高さが違う」 症状発生 (extends label = path 上 16 world / aggregates
|
|
315
|
+
// label = path 上 56 world = 40 world 差)。
|
|
316
|
+
// - CAR-636 = fan Y 揃え + X 分散 shift の SSOT に転換。 全 fan-out pill を dy=0 (Y 揃え、
|
|
317
|
+
// pill 下端 → path = LABEL_TO_PATH_CLEARANCE) に統一、 pill 同士の X 重なりを検出したら
|
|
318
|
+
// dx で右にずらして重なり回避する SSOT。 label 高さは group 全 pill 揃え、 X 方向のみ分散。
|
|
319
|
+
//
|
|
320
|
+
// 新 SSOT (CAR-636) の詳細。
|
|
321
|
+
// 1. 全 pill を dy=0 (base Y 位置維持) に固定 = fan-out group 内で pill 中央 Y が同一。
|
|
322
|
+
// 2. group 内の全 pill の base label X を (A.x + B.x) / 2 で近似計算 (horizontal straight /
|
|
323
|
+
// L-shape 共通の label 中心 X 近似、 CAR-472 で obstacle 分裂 case は既に除外済のため
|
|
324
|
+
// 本 branch は straight or clean L-shape のみ担当)。
|
|
325
|
+
// 3. base X で昇順 sort、 sweep で pill R + FAN_LABEL_GAP > 次 pill L なら次 pill を dx で
|
|
326
|
+
// 右 shift、 shift しない pill (X 重なりなし) は dx=0 で base 位置維持。
|
|
327
|
+
// 4. 結果 = 全 pill Y 揃え + X 方向のみ最小限 shift で重なり回避、 idx=0 pill (最左 X) は
|
|
328
|
+
// shift 0 で SSOT LABEL_TO_PATH_CLEARANCE 維持、 他 pill も全て同 Y (path から SSOT 距離)。
|
|
329
|
+
//
|
|
330
|
+
// CAR-636 fan dispersion X 分散の pill 間 gap (world unit)。 label bbox 端 + 20 world で
|
|
331
|
+
// 視覚的に明確な間隔を確保、 SSOT clearance-constants の CLEARANCE_NODE_LABEL (16 相当) より
|
|
332
|
+
// 大きめに設定 (pill 同士の可読性重視、 node 押し出しとは別軸の SSOT)。
|
|
333
|
+
const FAN_LABEL_GAP = 20;
|
|
334
|
+
|
|
335
|
+
// CAR-541 SSOT ... 同 node の対称対 edge (out + in が同 side) を上下配置ルールで分離。
|
|
336
|
+
//
|
|
337
|
+
// 対称対 = 同 node X の同 side S に out edge (from = X, effectiveFromSide = S) と in edge
|
|
338
|
+
// (to = X, effectiveToSide = S) が両方あるとき、 両 edge の endpoint が side 中央 (n.cy or n.cx)
|
|
339
|
+
// で完全に重なり stroke 色が混ざって色識別困難になる。 flowchart Review 左辺 (submit → review 入 +
|
|
340
|
+
// review → revise 出) が典型 case。
|
|
341
|
+
//
|
|
342
|
+
// CAR-532 の初版は 中央 ±16 world で両側 endpoint を単独 shift したが、 in edge の to 側だけ
|
|
343
|
+
// shift されると horizontal 直線 edge (A.y = B.y) が A.y != B.y に変化し L-corner branch に落ちて
|
|
344
|
+
// S 字風 corner (Q + L の折れ) が挿入される bug が発生 (fc-0 submit-review が典型)。
|
|
345
|
+
//
|
|
346
|
+
// CAR-541 fix = 上下配置ルール = 上側 (upper) edge = 動かさない、 下側 (lower) edge = 両 endpoint
|
|
347
|
+
// (from + to) を perpendicular 軸で +SYMMETRIC_PAIR_SHIFT (32 world) 平行 shift。 両端同量 shift
|
|
348
|
+
// なので直線 (A.y = B.y) の直進性 SSOT が保存され、 L-corner 挿入されない。
|
|
349
|
+
//
|
|
350
|
+
// 上下判定 = direction (out / in) 別に「相手 node の座標 min」 を集計、 min が小さい direction が
|
|
351
|
+
// upper、 大きい direction が lower。 座標軸 = horizontal side (left/right) → distant Y、
|
|
352
|
+
// vertical side (top/bottom) → distant X。 tie の場合は in edge を lower とする (CAR-532 test の
|
|
353
|
+
// out=Y-16, in=Y+16 挙動を out=Y, in=Y+32 に翻訳、 tie-break で決定論的挙動維持)。
|
|
354
|
+
//
|
|
355
|
+
// 発火条件 = 「同 node の同 side に out edge 1+ 本 と in edge 1+ 本 が両方存在」 のみ。 out 単独
|
|
356
|
+
// (fan-out) / in 単独 (fan-in) は対象外で従来通り CAR-489 endpoint grouping 維持。
|
|
357
|
+
//
|
|
358
|
+
// 判定 side = effectiveFromSide / effectiveToSide (back-detour 等 routing override 適用後)。
|
|
359
|
+
// 例 = stateMachine preset の retry (error → idle back-detour) は effectiveToSide = "top"、
|
|
360
|
+
// idle → loading (out) の effectiveFromSide = "right"、 side が別なので対称対不成立で分離しない。
|
|
361
|
+
const computeEffectiveSides = (p: (typeof prepared)[number]): { effFromSide: Side; effToSide: Side } => {
|
|
362
|
+
if (p.e.routing === "back-detour") {
|
|
363
|
+
return { effFromSide: "top", effToSide: "top" };
|
|
364
|
+
}
|
|
365
|
+
return { effFromSide: p.fromSide, effToSide: p.toSide };
|
|
366
|
+
};
|
|
367
|
+
const endpointOffsetsByEdgeId = new Map<string, { fromDx: number; fromDy: number; toDx: number; toDy: number }>();
|
|
368
|
+
{
|
|
369
|
+
// key = `${nodeId}::${side}` → out / in edge の分類
|
|
370
|
+
type Bucket = { outs: typeof prepared; ins: typeof prepared };
|
|
371
|
+
const buckets = new Map<string, Bucket>();
|
|
372
|
+
const getBucket = (nodeId: string, side: Side): Bucket => {
|
|
373
|
+
const key = `${nodeId}::${side}`;
|
|
374
|
+
let b = buckets.get(key);
|
|
375
|
+
if (!b) {
|
|
376
|
+
b = { outs: [], ins: [] };
|
|
377
|
+
buckets.set(key, b);
|
|
378
|
+
}
|
|
379
|
+
return b;
|
|
380
|
+
};
|
|
381
|
+
for (const p of prepared) {
|
|
382
|
+
const { effFromSide, effToSide } = computeEffectiveSides(p);
|
|
383
|
+
getBucket(p.e.from, effFromSide).outs.push(p);
|
|
384
|
+
getBucket(p.e.to, effToSide).ins.push(p);
|
|
385
|
+
}
|
|
386
|
+
// side に対する perpendicular 軸 = horizontal side (left/right) なら Y、 vertical side (top/bottom) なら X。
|
|
387
|
+
// distant 座標 = out edge (from = X) は target node、 in edge (to = X) は source node の
|
|
388
|
+
// perpendicular 軸 (Y or X) の値。 upper / lower 判定に使う。
|
|
389
|
+
const distantCoord = (p: (typeof prepared)[number], side: Side, direction: "out" | "in"): number => {
|
|
390
|
+
const isHorizontalSide = side === "left" || side === "right";
|
|
391
|
+
const distantNode = direction === "out" ? p.to : p.from;
|
|
392
|
+
return isHorizontalSide ? distantNode.cy : distantNode.cx;
|
|
393
|
+
};
|
|
394
|
+
// perpendicular 軸方向の shift 量 (world unit) を返す。
|
|
395
|
+
const shiftVector = (side: Side, amount: number): { dx: number; dy: number } =>
|
|
396
|
+
side === "left" || side === "right"
|
|
397
|
+
? { dx: 0, dy: amount }
|
|
398
|
+
: { dx: amount, dy: 0 };
|
|
399
|
+
// 各 edge に対して「lower か」 を bucket 横断で 1 回だけ判定 (2 node 双方向対称対で
|
|
400
|
+
// 両 edge が別 bucket の "in" 側になり両方 lower 判定される二重 shift 誤動作を防ぐ)。
|
|
401
|
+
const edgeIsLower = new Map<string, boolean>();
|
|
402
|
+
|
|
403
|
+
// CAR-421 fan-origin 保存の優先化 (CAR-1560、 dragon PR #395 で検知した visual defect の
|
|
404
|
+
// root cause fix)。 対称対 shift 対象 edge が別 bucket で fan-out / fan-in の一員 (>= 2 outs
|
|
405
|
+
// or >= 2 ins on same node/side) だと、 CAR-541 の両 endpoint shift が fan-origin の Y 揃え
|
|
406
|
+
// を破り「1 node から複数線が別 origin で発散する」 視覚 defect を生む。
|
|
407
|
+
//
|
|
408
|
+
// 判定 = 同 direction (outs/ins) の候補群のうち fan 参加者を含む direction は shift 対象と
|
|
409
|
+
// しない。 両 direction とも fan 参加者を含む場合は従来 tie-break (distant coord / id min)
|
|
410
|
+
// に fallback、 両 direction とも fan 非参加なら従来通り distant coord min で判定。
|
|
411
|
+
//
|
|
412
|
+
// trade-off = fan 非参加 edge を shift することで 認証 側の fan-origin は保存されるが、
|
|
413
|
+
// メール 側の color mixing (overlap) は本 fix 経路では解消できない。 実 visual では arrow
|
|
414
|
+
// marker の向きで方向識別可能、 fan-origin 破損の方が user 目視で顕著な defect のため
|
|
415
|
+
// 保存優先とする。
|
|
416
|
+
const participatesInFanElsewhere = (p: (typeof prepared)[number], currentKey: string): boolean => {
|
|
417
|
+
const { effFromSide, effToSide } = computeEffectiveSides(p);
|
|
418
|
+
const fromKey = `${p.e.from}::${effFromSide}`;
|
|
419
|
+
const toKey = `${p.e.to}::${effToSide}`;
|
|
420
|
+
if (fromKey !== currentKey) {
|
|
421
|
+
const b = buckets.get(fromKey);
|
|
422
|
+
if (b && b.outs.length >= 2 && b.outs.some((x) => x.e.id === p.e.id)) return true;
|
|
423
|
+
}
|
|
424
|
+
if (toKey !== currentKey) {
|
|
425
|
+
const b = buckets.get(toKey);
|
|
426
|
+
if (b && b.ins.length >= 2 && b.ins.some((x) => x.e.id === p.e.id)) return true;
|
|
427
|
+
}
|
|
428
|
+
return false;
|
|
429
|
+
};
|
|
430
|
+
// stable sort で bucket 決定的処理順、 tie-break 用に edge id 昇順 min を採用。
|
|
431
|
+
const bucketKeys = Array.from(buckets.keys()).sort();
|
|
432
|
+
for (const key of bucketKeys) {
|
|
433
|
+
const b = buckets.get(key)!;
|
|
434
|
+
if (b.outs.length === 0 || b.ins.length === 0) continue;
|
|
435
|
+
const side = key.split("::")[1]! as Side;
|
|
436
|
+
// direction 別 distant 座標 min + tie-break 用 edge id min。 min が小さい direction = upper
|
|
437
|
+
// (shift 0)、 大きい direction = lower (+32)。 distant tie なら edge id 昇順 min で
|
|
438
|
+
// globally 決定論的 tie-break (2 node 双方向対称対で edge 双方が別 bucket の "in" 側になり
|
|
439
|
+
// 両方が lower 判定される二重 shift 誤動作を防ぐ)。
|
|
440
|
+
const outMin = Math.min(...b.outs.map((p) => distantCoord(p, side, "out")));
|
|
441
|
+
const inMin = Math.min(...b.ins.map((p) => distantCoord(p, side, "in")));
|
|
442
|
+
const outIdMin = b.outs.reduce((m, p) => (m < p.e.id ? m : p.e.id), b.outs[0]!.e.id);
|
|
443
|
+
const inIdMin = b.ins.reduce((m, p) => (m < p.e.id ? m : p.e.id), b.ins[0]!.e.id);
|
|
444
|
+
// CAR-1560 = fan-origin 保存の優先化。 本 bucket に fan-out (>= 2 outs on same node/side)
|
|
445
|
+
// or fan-in (>= 2 ins on same node/side) の参加者が含まれる場合、 CAR-541 shift を bucket
|
|
446
|
+
// 全体で skip する。
|
|
447
|
+
//
|
|
448
|
+
// asymmetric 経路 (「参加者を含まない direction を shift」) は当初検討したが、 3-node
|
|
449
|
+
// topology で shift が別 bucket に propagate して A::right pair separation を collapse
|
|
450
|
+
// させる regression scenario (codex adversarial review 検出) があるため採用しない。
|
|
451
|
+
//
|
|
452
|
+
// 現行 approach = bucket 全体 skip、 fan-origin は保存、 対称対 overlap (color mixing) は
|
|
453
|
+
// 該当 bucket で許容。 実 visual では arrow marker の向きで方向識別可能、 fan-origin 破損
|
|
454
|
+
// の方が user 目視で顕著な defect のため保存優先とする。
|
|
455
|
+
const outsInFan = b.outs.some((p) => participatesInFanElsewhere(p, key));
|
|
456
|
+
const insInFan = b.ins.some((p) => participatesInFanElsewhere(p, key));
|
|
457
|
+
if (outsInFan || insInFan) continue;
|
|
458
|
+
let inIsLower: boolean;
|
|
459
|
+
if (inMin !== outMin) {
|
|
460
|
+
inIsLower = inMin > outMin;
|
|
461
|
+
} else {
|
|
462
|
+
// distant tie → 直感 (2 node 双方向対称対) では out edge が上 / in edge が下 の
|
|
463
|
+
// CAR-532 test 相当挙動を維持したい。 これを bucket 横断で決定論的にするため、
|
|
464
|
+
// direction 別に edge id 昇順 min を取り「小さい方の direction が lower」 の rule で
|
|
465
|
+
// 双方向 pair 両 bucket で同じ edge を lower 判定させる (逆 side bucket でも同じ
|
|
466
|
+
// id-min 比較結果になるため 2 node bidirectional pair で常に同一 edge が lower 選択)。
|
|
467
|
+
inIsLower = inIdMin < outIdMin;
|
|
468
|
+
}
|
|
469
|
+
const lowerEdges = inIsLower ? b.ins : b.outs;
|
|
470
|
+
for (const p of lowerEdges) {
|
|
471
|
+
// 既に別 bucket で lower 判定済みなら skip (idempotent、 2 node 双方向 pair の場合
|
|
472
|
+
// 一方の edge が bucket A で lower + bucket B で upper と割れる正常経路で double shift 回避)。
|
|
473
|
+
if (edgeIsLower.get(p.e.id)) continue;
|
|
474
|
+
edgeIsLower.set(p.e.id, true);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// 全 edge を走査、 lower 判定 edge にのみ perpendicular 軸 +32 平行 shift (両 endpoint 同量) を割当。
|
|
478
|
+
for (const p of prepared) {
|
|
479
|
+
if (!edgeIsLower.get(p.e.id)) continue;
|
|
480
|
+
// shift 方向 = 判定 side (effectiveFromSide か effectiveToSide の対称対該当 side)。
|
|
481
|
+
// 対称対 bucket に含まれる時点で from-side or to-side が対称 side に一致する、 実装上は
|
|
482
|
+
// どちらの side でも perpendicular 軸方向は同じ (horizontal side → Y、 vertical side → X)。
|
|
483
|
+
const { effFromSide, effToSide } = computeEffectiveSides(p);
|
|
484
|
+
// fromSide bucket 側 or toSide bucket 側どちらで lower 判定されたかを再走査。 lower 判定した
|
|
485
|
+
// bucket key の side を採用する。
|
|
486
|
+
const fromKey = `${p.e.from}::${effFromSide}`;
|
|
487
|
+
const toKey = `${p.e.to}::${effToSide}`;
|
|
488
|
+
const fromBucket = buckets.get(fromKey);
|
|
489
|
+
const toBucket = buckets.get(toKey);
|
|
490
|
+
const fromBucketQualifies = fromBucket && fromBucket.outs.length > 0 && fromBucket.ins.length > 0;
|
|
491
|
+
const toBucketQualifies = toBucket && toBucket.outs.length > 0 && toBucket.ins.length > 0;
|
|
492
|
+
// 優先順位 = from side bucket で lower 判定 → fromSide の perpendicular 軸で shift。
|
|
493
|
+
// それ以外 (to side bucket only) → toSide の perpendicular 軸で shift。
|
|
494
|
+
// horizontal side / vertical side が両側で一致するのが通常 (対称対の性質)、 例外時は
|
|
495
|
+
// fromSide 側を優先する (from 起点で軸判定安定)。
|
|
496
|
+
const shiftSide: Side = fromBucketQualifies ? effFromSide : (toBucketQualifies ? effToSide : effFromSide);
|
|
497
|
+
const off = shiftVector(shiftSide, SYMMETRIC_PAIR_SHIFT);
|
|
498
|
+
const prev = endpointOffsetsByEdgeId.get(p.e.id) ?? { fromDx: 0, fromDy: 0, toDx: 0, toDy: 0 };
|
|
499
|
+
endpointOffsetsByEdgeId.set(p.e.id, {
|
|
500
|
+
fromDx: prev.fromDx + off.dx,
|
|
501
|
+
fromDy: prev.fromDy + off.dy,
|
|
502
|
+
toDx: prev.toDx + off.dx,
|
|
503
|
+
toDy: prev.toDy + off.dy,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* 同じ通り道を上下に走る迂回のうち、 逆向きの方を blocker の反対側へ回す (Issue #211)。
|
|
510
|
+
*
|
|
511
|
+
* 迂回する列は「塞ぐ node の右端 + 余白」 で決まるので、 同じ node を避ける edge は必ず同じ列を
|
|
512
|
+
* 通り、 重なって 1 本の線に見える。
|
|
513
|
+
*
|
|
514
|
+
* 向きが揃っているならそれでよい。 幹線から順に降りる 1 本の線として読め、 読み手は分岐を辿れる。
|
|
515
|
+
* 分けると図幅が広がるだけで得るものがない (user 目視の判断、 2026-07-10)。
|
|
516
|
+
*
|
|
517
|
+
* 向きが混ざると読めない。 重なった 1 本の線の両端に矢印が付き、 どちらがどちらの経路か
|
|
518
|
+
* 判別できない。
|
|
519
|
+
*
|
|
520
|
+
* 同じ側で列だけ分けても解けない。 上へ戻る edge の戻り口は source より手前 (図の上) にあり、
|
|
521
|
+
* その水平部が下る edge の列と角を必ず横切る。 実測でも交差が 0 件から 2 件に増えた。
|
|
522
|
+
* 上下は反対側へ分けるしかない。
|
|
523
|
+
*
|
|
524
|
+
* 逆向き = 上へ戻る edge を左へ回す。 図は上から下へ読むので、 戻りの線を左に置くと流れと
|
|
525
|
+
* 逆行することが形からも分かる。 左が node で塞がっているなら回さず、 重なりを受け入れる。
|
|
526
|
+
*/
|
|
527
|
+
/**
|
|
528
|
+
* 左へ回した経路と label が置けるか。 列の X だけでなく、 幹から列へ抜ける水平部と、
|
|
529
|
+
* 列の外側に出る label まで見る。
|
|
530
|
+
*
|
|
531
|
+
* 列そのものが空いていても、 水平部の高さに node があれば貫通する。 label は列よりさらに外に
|
|
532
|
+
* 出るため、 非発着 lane の枠線を跨ぐことがある (`lane-border-clearance`)。 後段の chain 伝搬が
|
|
533
|
+
* 受け取る obstacle は node と lane-label だけで、 lane の枠線は誰も見ていない。
|
|
534
|
+
*
|
|
535
|
+
* 角の丸めは無視して折れ線で近似する。 丸めは経路を内側へ寄せるだけで、 外へは出さない。
|
|
536
|
+
*/
|
|
537
|
+
const leftRouteIsBlocked = (c: {
|
|
538
|
+
e: CdlEdge;
|
|
539
|
+
from: string;
|
|
540
|
+
to: string;
|
|
541
|
+
blockers: LaidNode[];
|
|
542
|
+
yDir: 1 | -1;
|
|
543
|
+
A: { x: number; y: number };
|
|
544
|
+
B: { x: number; y: number };
|
|
545
|
+
}): boolean => {
|
|
546
|
+
const columnX = detourColumnFor(c.blockers, "left");
|
|
547
|
+
const exitY = c.A.y + c.yDir * VERTICAL_DETOUR_EXIT;
|
|
548
|
+
const lastBlockerEdge =
|
|
549
|
+
c.yDir > 0
|
|
550
|
+
? Math.max(...c.blockers.map((n) => n.cy + n.h / 2))
|
|
551
|
+
: Math.min(...c.blockers.map((n) => n.cy - n.h / 2));
|
|
552
|
+
const returnY = lastBlockerEdge + c.yDir * VERTICAL_DETOUR_GAP;
|
|
553
|
+
const segs = [
|
|
554
|
+
{ x1: c.A.x, y1: c.A.y, x2: c.A.x, y2: exitY },
|
|
555
|
+
{ x1: c.A.x, y1: exitY, x2: columnX, y2: exitY },
|
|
556
|
+
{ x1: columnX, y1: exitY, x2: columnX, y2: returnY },
|
|
557
|
+
{ x1: columnX, y1: returnY, x2: c.B.x, y2: returnY },
|
|
558
|
+
{ x1: c.B.x, y1: returnY, x2: c.B.x, y2: c.B.y },
|
|
559
|
+
];
|
|
560
|
+
const others = nodes.filter((n) => n.id !== c.from && n.id !== c.to);
|
|
561
|
+
if (others.some((n) => segmentsCrossRect(segs, nodeRect(n)))) return true;
|
|
562
|
+
|
|
563
|
+
if (!hasRenderedLabel(c.e)) return false;
|
|
564
|
+
const boxW = computeLabelBoxW(c.e.label, c.e.sub);
|
|
565
|
+
const boxH = labelPillH(c.e.sub);
|
|
566
|
+
const centerX = columnX - (labelPathClearanceHorizontal(c.e) + boxW / 2);
|
|
567
|
+
const pill = { x: centerX - boxW / 2, y: (exitY + returnY) / 2 - boxH / 2, w: boxW, h: boxH };
|
|
568
|
+
if (others.some((n) => rectsOverlap(pill, nodeRect(n)))) return true;
|
|
569
|
+
|
|
570
|
+
// 発着 lane の枠線は跨いでよい (visual-validate の lane-border-clearance と同一規約)。
|
|
571
|
+
const endpointLanes = new Set(
|
|
572
|
+
nodes.filter((n) => n.id === c.from || n.id === c.to).map((n) => n.lane),
|
|
573
|
+
);
|
|
574
|
+
return lanes.some(
|
|
575
|
+
(l) =>
|
|
576
|
+
!endpointLanes.has(l.id) &&
|
|
577
|
+
(crossesVerticalLine(pill, l.x) || crossesVerticalLine(pill, l.x + l.width)),
|
|
578
|
+
);
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const assignDetourSides = (): Map<string, DetourSide> => {
|
|
582
|
+
const out = new Map<string, DetourSide>();
|
|
583
|
+
interface Candidate {
|
|
584
|
+
id: string;
|
|
585
|
+
e: CdlEdge;
|
|
586
|
+
yDir: 1 | -1;
|
|
587
|
+
blockers: LaidNode[];
|
|
588
|
+
from: string;
|
|
589
|
+
to: string;
|
|
590
|
+
A: { x: number; y: number };
|
|
591
|
+
B: { x: number; y: number };
|
|
592
|
+
minY: number;
|
|
593
|
+
maxY: number;
|
|
594
|
+
}
|
|
595
|
+
const corridors = new Map<string, Candidate[]>();
|
|
596
|
+
|
|
597
|
+
for (const p of prepared) {
|
|
598
|
+
// dotted-flow は経由 node を貫通する semantic なので迂回しない。
|
|
599
|
+
// back-detour は fromSide / toSide を top-top に強制するため縦 detour 分岐に落ちない。
|
|
600
|
+
if (p.e.style === "dotted-flow" || p.e.routing === "back-detour") continue;
|
|
601
|
+
const verticalPair =
|
|
602
|
+
(p.fromSide === "bottom" && p.toSide === "top") ||
|
|
603
|
+
(p.fromSide === "top" && p.toSide === "bottom");
|
|
604
|
+
if (!verticalPair) continue;
|
|
605
|
+
|
|
606
|
+
const off = endpointOffsetsByEdgeId.get(p.e.id) ?? { fromDx: 0, fromDy: 0, toDx: 0, toDy: 0 };
|
|
607
|
+
const Araw = sidePoint(p.from, p.fromSide);
|
|
608
|
+
const Braw = sidePoint(p.to, p.toSide);
|
|
609
|
+
const A = { x: Araw.x + off.fromDx, y: Araw.y + off.fromDy };
|
|
610
|
+
const B = { x: Braw.x + off.toDx, y: Braw.y + off.toDy };
|
|
611
|
+
if (Math.abs(A.x - B.x) >= 1) continue;
|
|
612
|
+
|
|
613
|
+
const obstacles = nodes.filter((n) => n.id !== p.from.id && n.id !== p.to.id);
|
|
614
|
+
const blockers = verticalDetourBlockers(A, B, obstacles);
|
|
615
|
+
if (blockers.length === 0) continue;
|
|
616
|
+
|
|
617
|
+
// 通り道 = 既定 (右) の列が一致する edge。 列は塞ぐ node から決まるので、 一致すれば同じ場所を走る。
|
|
618
|
+
const corridorKey = detourColumnFor(blockers, "right").toFixed(3);
|
|
619
|
+
const list = corridors.get(corridorKey) ?? [];
|
|
620
|
+
list.push({
|
|
621
|
+
id: p.e.id,
|
|
622
|
+
e: p.e,
|
|
623
|
+
yDir: B.y > A.y ? 1 : -1,
|
|
624
|
+
blockers,
|
|
625
|
+
from: p.from.id,
|
|
626
|
+
to: p.to.id,
|
|
627
|
+
A,
|
|
628
|
+
B,
|
|
629
|
+
minY: Math.min(A.y, B.y),
|
|
630
|
+
maxY: Math.max(A.y, B.y),
|
|
631
|
+
});
|
|
632
|
+
corridors.set(corridorKey, list);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
for (const list of corridors.values()) {
|
|
636
|
+
if (list.length < 2) continue;
|
|
637
|
+
|
|
638
|
+
for (const c of list) {
|
|
639
|
+
if (c.yDir !== -1) continue; // 下る edge は既定の右のまま
|
|
640
|
+
// 列が同じでも縦の範囲が重ならなければ、 実際には同じ場所を走らない。
|
|
641
|
+
// 逆向きの edge と縦に重なる時だけ回す。
|
|
642
|
+
const overlapsOpposite = list.some(
|
|
643
|
+
(o) => o.yDir === 1 && o.minY < c.maxY && o.maxY > c.minY,
|
|
644
|
+
);
|
|
645
|
+
if (!overlapsOpposite) continue;
|
|
646
|
+
|
|
647
|
+
// 左へ回した経路や label が置けないなら回さない。 重なりの方が貫通より軽い。
|
|
648
|
+
if (leftRouteIsBlocked(c)) continue;
|
|
649
|
+
out.set(c.id, "left");
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return out;
|
|
653
|
+
};
|
|
654
|
+
const detourSides = assignDetourSides();
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* 左へ回す edge の endpoint も、 対称対の shift を左向きに揃える (幹の付け根の接触)。
|
|
658
|
+
*
|
|
659
|
+
* 同じ node の同じ辺に 1 本入り 1 本出る時、 `SYMMETRIC_PAIR_SHIFT` は下側と判定した edge を
|
|
660
|
+
* 常に正方向 (下辺なら右) へ 32 ずらす。 その edge が左を回って戻ると、 戻りの角が辺の右側に
|
|
661
|
+
* 生まれ、 同じ辺から右へ出る edge の水平部がそこを横切る。
|
|
662
|
+
*
|
|
663
|
+
* 回る側が左なら、 endpoint も左へずらす。 2 本の間隔は 32 のまま変わらない。
|
|
664
|
+
*
|
|
665
|
+
* ずらした先で塞ぐ node の集合が変われば、 割り当てた側が根拠を失う。 その時は元の向きを保つ
|
|
666
|
+
* (接触の方が経路の破綻より軽い)。
|
|
667
|
+
*/
|
|
668
|
+
const alignSymmetricShiftWithDetourSide = (): void => {
|
|
669
|
+
for (const [edgeId, side] of detourSides) {
|
|
670
|
+
if (side !== "left") continue;
|
|
671
|
+
const off = endpointOffsetsByEdgeId.get(edgeId);
|
|
672
|
+
if (!off || off.fromDx <= 0) continue;
|
|
673
|
+
const p = prepared.find((x) => x.e.id === edgeId);
|
|
674
|
+
if (!p) continue;
|
|
675
|
+
|
|
676
|
+
const blockersFor = (dx: number): LaidNode[] => {
|
|
677
|
+
const Araw = sidePoint(p.from, p.fromSide);
|
|
678
|
+
const Braw = sidePoint(p.to, p.toSide);
|
|
679
|
+
const A = { x: Araw.x + dx, y: Araw.y + off.fromDy };
|
|
680
|
+
const B = { x: Braw.x + dx, y: Braw.y + off.toDy };
|
|
681
|
+
const obstacles = nodes.filter((n) => n.id !== p.from.id && n.id !== p.to.id);
|
|
682
|
+
return verticalDetourBlockers(A, B, obstacles);
|
|
683
|
+
};
|
|
684
|
+
const idsOf = (ns: LaidNode[]) => ns.map((n) => n.id).sort().join(",");
|
|
685
|
+
if (idsOf(blockersFor(-off.fromDx)) !== idsOf(blockersFor(off.fromDx))) continue;
|
|
686
|
+
|
|
687
|
+
// ずらした先が node の辺からはみ出すなら保つ。 端点は node の縁の上に載る必要がある。
|
|
688
|
+
const withinSide = (n: LaidNode, x: number) => Math.abs(x - n.cx) <= n.w / 2;
|
|
689
|
+
const fromX = sidePoint(p.from, p.fromSide).x - off.fromDx;
|
|
690
|
+
const toX = sidePoint(p.to, p.toSide).x - off.toDx;
|
|
691
|
+
if (!withinSide(p.from, fromX) || !withinSide(p.to, toX)) continue;
|
|
692
|
+
|
|
693
|
+
endpointOffsetsByEdgeId.set(edgeId, {
|
|
694
|
+
fromDx: -off.fromDx,
|
|
695
|
+
fromDy: off.fromDy,
|
|
696
|
+
toDx: -off.toDx,
|
|
697
|
+
toDy: off.toDy,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
alignSymmetricShiftWithDetourSide();
|
|
702
|
+
|
|
703
|
+
// routing pass ... 各 edge の path と label 初期位置を確定する。
|
|
704
|
+
//
|
|
705
|
+
// fan dispersion は routing の後に走らせる (Issue #211)。 label の base Y を
|
|
706
|
+
// (A.y + B.y) / 2 で近似すると horizontal straight route では labelInitClearance 分
|
|
707
|
+
// (34 world) ずれ、 upward L 字と混在した fan で Y 帯判定を誤る。 routePath が返す
|
|
708
|
+
// 実際の label 位置を使えば近似誤差そのものが消える。
|
|
709
|
+
const routed = prepared.map(({ e, from, to, fromSide, toSide }) => {
|
|
710
|
+
// routing 用 obstacles ... dotted-flow は経由 node 貫通 semantic なので blocking 迂回発火させない (空)。
|
|
711
|
+
// solid は blocking node を検知して detour する。
|
|
712
|
+
const routingObstacles = e.style === "dotted-flow"
|
|
713
|
+
? []
|
|
714
|
+
: nodes.filter((n) => n.id !== from.id && n.id !== to.id);
|
|
715
|
+
// dThrough 判定用 obstacles ... 常に全 node、 dotted-flow の 経由 node 貫通判定に使う。
|
|
716
|
+
const allOtherNodes = nodes.filter((n) => n.id !== from.id && n.id !== to.id);
|
|
717
|
+
const obstacles = routingObstacles;
|
|
718
|
+
const fromLane = lmap.get(from.lane);
|
|
719
|
+
const toLane = lmap.get(to.lane);
|
|
720
|
+
// routing="back-detour" は fromSide/toSide を強制的に top-top にして上方 detour を発火
|
|
721
|
+
let effectiveFromSide = fromSide;
|
|
722
|
+
let effectiveToSide = toSide;
|
|
723
|
+
if (e.routing === "back-detour") {
|
|
724
|
+
effectiveFromSide = "top";
|
|
725
|
+
effectiveToSide = "top";
|
|
726
|
+
}
|
|
727
|
+
// label retreat 用 obstacles = 全 other node。 dotted-flow でも label 位置は node と重ならない。
|
|
728
|
+
// path detour 用 obstacles と separate。
|
|
729
|
+
// CAR-541 SSOT ... endpointOffset = 同 node 対称対 lower edge の両 endpoint 平行 shift (world unit)。
|
|
730
|
+
// routePath は sidePoint(from, side) + fromOffset で起点、 sidePoint(to, side) + toOffset で
|
|
731
|
+
// 終点を計算する。 lower edge は fromOffset = toOffset (両端同量 shift、 直線 SSOT 保持)、
|
|
732
|
+
// upper edge / 非対称対 edge は entry なしで {0, 0} fallback。
|
|
733
|
+
const endpointOffset = endpointOffsetsByEdgeId.get(e.id) ?? { fromDx: 0, fromDy: 0, toDx: 0, toDy: 0 };
|
|
734
|
+
// 迂回の折れ位置は別 edge の label を避ける必要があるが、 1 巡目では label 位置が
|
|
735
|
+
// まだ無い。 同じ引数で呼び直せるようにして、 label が出そろってから `widenDetourStubs`
|
|
736
|
+
// が 2 巡目を回す (label 位置は折れ位置に依存しないので繰り返しても揺れない)。
|
|
737
|
+
const route = (avoidRects: readonly Rect[]) => routePath(
|
|
738
|
+
from,
|
|
739
|
+
to,
|
|
740
|
+
effectiveFromSide,
|
|
741
|
+
effectiveToSide,
|
|
742
|
+
obstacles,
|
|
743
|
+
detourSides.get(e.id),
|
|
744
|
+
fromLane,
|
|
745
|
+
toLane,
|
|
746
|
+
e,
|
|
747
|
+
allOtherNodes,
|
|
748
|
+
{ x: endpointOffset.fromDx, y: endpointOffset.fromDy },
|
|
749
|
+
{ x: endpointOffset.toDx, y: endpointOffset.toDy },
|
|
750
|
+
avoidRects,
|
|
751
|
+
);
|
|
752
|
+
const routed1 = route([]);
|
|
753
|
+
const { d, labelX, labelY, labelAnchor, labelAltX } = routed1;
|
|
754
|
+
|
|
755
|
+
// dotted-flow の glow は「経路が別 node を貫くか」 で出す / 出さないが決まる。 経路が
|
|
756
|
+
// 変われば判定も変わるため、 経路を出し直したら glow も出し直す (2 巡目、 #418)
|
|
757
|
+
const makeDThrough = (dNow: string): string | undefined => {
|
|
758
|
+
if (e.style !== "dotted-flow") return undefined;
|
|
759
|
+
const segments = extractPathSegments(dNow);
|
|
760
|
+
const hasPassThrough = allOtherNodes.some((n) => {
|
|
761
|
+
const rect = { x: n.cx - n.w / 2, y: n.cy - n.h / 2, w: n.w, h: n.h };
|
|
762
|
+
return segmentsCrossRect(segments, rect);
|
|
763
|
+
});
|
|
764
|
+
if (!hasPassThrough) return undefined;
|
|
765
|
+
// dThrough (dotted-flow glow) = source 中心 → target 中心 の直線。 CAR-429 で
|
|
766
|
+
// fan-in / fan-out offset 分散を削除したため、 中心線 (perpX/perpY = 0) 固定で描画する。
|
|
767
|
+
const dxAbs = Math.abs(to.cx - from.cx);
|
|
768
|
+
const dyAbs = Math.abs(to.cy - from.cy);
|
|
769
|
+
const isHorizontal = dxAbs > dyAbs;
|
|
770
|
+
let glowStartX = from.cx;
|
|
771
|
+
let glowStartY = from.cy;
|
|
772
|
+
let glowEndX = to.cx;
|
|
773
|
+
let glowEndY = to.cy;
|
|
774
|
+
if (isHorizontal) {
|
|
775
|
+
const dirFrom = from.cx < to.cx ? 1 : -1;
|
|
776
|
+
glowStartX = from.cx + dirFrom * (from.w / 2) - dirFrom * (from.w / 2) * 1.0;
|
|
777
|
+
glowEndX = to.cx - dirFrom * (to.w / 2) + dirFrom * (to.w / 2) * 1.0;
|
|
778
|
+
} else {
|
|
779
|
+
const dirFrom = from.cy < to.cy ? 1 : -1;
|
|
780
|
+
glowStartY = from.cy + dirFrom * (from.h / 2) - dirFrom * (from.h / 2) * 1.0;
|
|
781
|
+
glowEndY = to.cy - dirFrom * (to.h / 2) + dirFrom * (to.h / 2) * 1.0;
|
|
782
|
+
}
|
|
783
|
+
return `M ${glowStartX} ${glowStartY} L ${glowEndX} ${glowEndY}`;
|
|
784
|
+
};
|
|
785
|
+
const dThrough = makeDThrough(d);
|
|
786
|
+
|
|
787
|
+
// labelMarginExtra ... edge label を path から author 指定分だけ離す。
|
|
788
|
+
// 既存 route の label は path 中点から垂直方向にオフセット済 (上方 -36 / -50 等)、
|
|
789
|
+
// labelY < (path 中点 y) なら上向き label として扱い -=、 そうでなければ +=。
|
|
790
|
+
// path 中点 y は (A.y+B.y)/2 だが routePath からは受け取れないので、 from / to の cy 平均で近似。
|
|
791
|
+
const pathMidY = (from.cy + to.cy) / 2;
|
|
792
|
+
const marginDir = labelY < pathMidY ? -1 : 1;
|
|
793
|
+
// fan / parallel の分散 offset を除いた label 位置。 author 指定 offset と labelMargin は込み。
|
|
794
|
+
const baseLabelX = labelX + (e.labelOffsetX ?? 0);
|
|
795
|
+
const baseLabelY = labelY + (e.labelOffsetY ?? 0) + marginDir * labelMarginExtra;
|
|
796
|
+
return {
|
|
797
|
+
p: { e, from, to, fromSide, toSide },
|
|
798
|
+
e,
|
|
799
|
+
d,
|
|
800
|
+
dThrough,
|
|
801
|
+
effectiveFromSide,
|
|
802
|
+
effectiveToSide,
|
|
803
|
+
labelAnchor,
|
|
804
|
+
baseLabelX,
|
|
805
|
+
baseLabelY,
|
|
806
|
+
labelAltX,
|
|
807
|
+
route,
|
|
808
|
+
makeDThrough,
|
|
809
|
+
/** 経路が `avoidRects` を見るか。 見ない経路は 2 巡目を回しても結果が変わらない。 */
|
|
810
|
+
consultsAvoidRects: routed1.consultsAvoidRects === true,
|
|
811
|
+
};
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
type RoutedEdge = (typeof routed)[number];
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* 迂回する edge の折れ位置を、 別 edge の label を避けられる範囲まで外へ広げる (#418)。
|
|
818
|
+
*
|
|
819
|
+
* 1 巡目の routing は label 位置を知らないため、 走る距離の比だけで折れ位置を決める。 その
|
|
820
|
+
* ままだと隣の edge の label を垂直の線が貫くことがあるので、 label が出そろったここで
|
|
821
|
+
* 同じ routing を呼び直し、 label を避けた折れ位置に差し替える。
|
|
822
|
+
*
|
|
823
|
+
* label 位置は折れ位置に依存しない (`labelX` は端点の中点、 `labelY` は迂回帯からの距離) ため、
|
|
824
|
+
* 2 巡目で label が動くことはなく、 繰り返しても揺れない。
|
|
825
|
+
*
|
|
826
|
+
* 差し替えるのは `d` だけ。 label 位置は 1 巡目のものを保ち、 後段の post-pass に委ねる。
|
|
827
|
+
*/
|
|
828
|
+
const widenDetourStubs = (list: RoutedEdge[]): void => {
|
|
829
|
+
const pillById = new Map<string, Rect>();
|
|
830
|
+
for (const r of list) {
|
|
831
|
+
if (!hasRenderedLabel(r.e)) continue;
|
|
832
|
+
const w = computeLabelBoxW(r.e.label, r.e.sub);
|
|
833
|
+
const h = labelPillH(r.e.sub);
|
|
834
|
+
pillById.set(r.e.id, { x: r.baseLabelX - w / 2, y: r.baseLabelY - h / 2, w, h });
|
|
835
|
+
}
|
|
836
|
+
// 絶対座標で置いた edge は routing を通らないが、 label は描かれる。 集合から漏らすと
|
|
837
|
+
// 迂回線がその pill を貫く
|
|
838
|
+
for (const le of positionedEdges) {
|
|
839
|
+
if (!hasRenderedLabel(le)) continue;
|
|
840
|
+
const w = computeLabelBoxW(le.label, le.sub);
|
|
841
|
+
const h = labelPillH(le.sub);
|
|
842
|
+
pillById.set(le.id, { x: le.labelX - w / 2, y: le.labelY - h / 2, w, h });
|
|
843
|
+
}
|
|
844
|
+
if (pillById.size === 0) return;
|
|
845
|
+
for (const r of list) {
|
|
846
|
+
// 迂回しない経路は `avoidRects` を見ないので、 出し直しても同じ結果になる
|
|
847
|
+
if (!r.consultsAvoidRects) continue;
|
|
848
|
+
const avoid = [...pillById].filter(([id]) => id !== r.e.id).map(([, rect]) => rect);
|
|
849
|
+
if (avoid.length === 0) continue;
|
|
850
|
+
const next = r.route(avoid);
|
|
851
|
+
if (next.d === r.d) continue;
|
|
852
|
+
// label は 1 巡目の値を保つ。 折れ位置を変えても label 位置は同じ計算式で出るため、
|
|
853
|
+
// 差し替えるのは経路と、 経路から決まる glow だけでよい
|
|
854
|
+
r.d = next.d;
|
|
855
|
+
r.dThrough = r.makeDThrough(next.d);
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
widenDetourStubs(routed);
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* 左右対称に置ける label を、 別 edge の経路が占める側から空いている側へ移す (Issue #211)。
|
|
862
|
+
*
|
|
863
|
+
* 縦 straight route は label を自 path の右に置く。 同じ node の同じ辺から出る別 edge が
|
|
864
|
+
* 間の node を右へ迂回すると、 その出口の水平部は 2 node の隙間 (= label が置かれる帯) を
|
|
865
|
+
* 必ず横切る。 出口までの直進距離は EDGE_STUB_OUT を下回れないため迂回側は label の上を
|
|
866
|
+
* 通れず、 隙間を広げても両者は同じ帯に入り続ける。
|
|
867
|
+
*
|
|
868
|
+
* label は自 path に対して左右等距離に置けるので、 空いている側へ移して解く。 移した先も
|
|
869
|
+
* 別の経路に横切られるなら移さない。 両側とも塞がっている構図は後段の chain 伝搬に委ねる。
|
|
870
|
+
*
|
|
871
|
+
* node との間隔は移動先を移動元と比べて判定する。 絶対値で「32 world 未満なら移さない」 と
|
|
872
|
+
* すると、 元から 32 を割っている label (sub 付きの背の高い pill が狭い隙間に入る等) が、
|
|
873
|
+
* 間隔を 1 world も悪化させない鏡像へすら移れず、 経路の横断が残る。
|
|
874
|
+
*
|
|
875
|
+
* lane border だけは移動前に自前で見る。 後段の chain 伝搬が受け取る obstacle は node と
|
|
876
|
+
* lane-label だけで (layout.ts)、 lane の枠線は誰も見ていないため、 ここで通すと
|
|
877
|
+
* lane-border-clearance の違反がそのまま残る。
|
|
878
|
+
*
|
|
879
|
+
* 戻り値 = 実際に反転した edge id。 後段の fan dispersion が反転を押し戻さないために使う。
|
|
880
|
+
*/
|
|
881
|
+
const flipLabelToFreeSide = (list: RoutedEdge[]): Set<string> => {
|
|
882
|
+
const flipped = new Set<string>();
|
|
883
|
+
const flippable = list.filter(
|
|
884
|
+
// 作者が labelOffsetX で寄せた label は意図とみなし、 自動で左右を入れ替えない。
|
|
885
|
+
// pill が描かれる条件は render/edges.tsx と collectBBoxes の SSOT に合わせる
|
|
886
|
+
// (label が空でも sub があれば pill は描かれ、 bbox も衝突判定に載る)。
|
|
887
|
+
(r) => r.labelAltX !== undefined && hasRenderedLabel(r.e) && !r.e.labelOffsetX,
|
|
888
|
+
);
|
|
889
|
+
if (flippable.length === 0) return flipped;
|
|
890
|
+
|
|
891
|
+
const segsByEdgeId = new Map(list.map((r) => [r.e.id, extractPathSegments(r.d)]));
|
|
892
|
+
const pathBoxByEdgeId = new Map(list.map((r) => [r.e.id, segmentsBBox(segsByEdgeId.get(r.e.id)!)]));
|
|
893
|
+
const laneOfNode = new Map(nodes.map((n) => [n.id, n.lane]));
|
|
894
|
+
|
|
895
|
+
for (const r of flippable) {
|
|
896
|
+
const ownSegs = segsByEdgeId.get(r.e.id)!;
|
|
897
|
+
const boxW = computeLabelBoxW(r.e.label, r.e.sub);
|
|
898
|
+
const boxH = labelPillH(r.e.sub);
|
|
899
|
+
const pillAt = (centerX: number) => ({
|
|
900
|
+
x: centerX - boxW / 2,
|
|
901
|
+
y: r.baseLabelY - boxH / 2,
|
|
902
|
+
w: boxW,
|
|
903
|
+
h: boxH,
|
|
904
|
+
});
|
|
905
|
+
const cur = pillAt(r.baseLabelX);
|
|
906
|
+
const alt = pillAt(r.labelAltX!);
|
|
907
|
+
// cur と alt を覆う帯。 どちらかを横切る segment は必ずこの帯に入るので、 帯と交わらない
|
|
908
|
+
// path は segment 展開前に落とせる (全 edge の全 segment を毎回展開しない)。
|
|
909
|
+
const band = unionRect(cur, alt);
|
|
910
|
+
const otherSegs = list
|
|
911
|
+
.filter((o) => o.e.id !== r.e.id && rectsOverlap(pathBoxByEdgeId.get(o.e.id)!, band))
|
|
912
|
+
// 兄弟が自 path と共有する区間は 1 本の線として描かれるので衝突ではない (Issue #211)。
|
|
913
|
+
.flatMap((o) => segmentsExcludingOwnPath(segsByEdgeId.get(o.e.id)!, ownSegs));
|
|
914
|
+
if (otherSegs.length === 0) continue;
|
|
915
|
+
if (!segmentsCrossRect(otherSegs, cur)) continue;
|
|
916
|
+
if (segmentsCrossRect(otherSegs, alt)) continue;
|
|
917
|
+
|
|
918
|
+
// 許容する node 間隔 = SSOT (32) と 移動元の間隔 の小さい方。 これで「規約を満たす」 か
|
|
919
|
+
// 「元より悪くしない」 かのどちらかを常に満たす。
|
|
920
|
+
const minNodeGap = (box: Rect) =>
|
|
921
|
+
nodes.reduce((acc, n) => Math.min(acc, rectGap(box, nodeRect(n))), Infinity);
|
|
922
|
+
if (minNodeGap(alt) < Math.min(CLEARANCE_NODE_LABEL, minNodeGap(cur)) - 1e-6) continue;
|
|
923
|
+
|
|
924
|
+
// 発着 lane の枠線は跨いでよい (visual-validate の lane-border-clearance と同一規約)。
|
|
925
|
+
const endpointLanes = new Set(
|
|
926
|
+
[laneOfNode.get(r.e.from), laneOfNode.get(r.e.to)].filter((v): v is string => v !== undefined),
|
|
927
|
+
);
|
|
928
|
+
const crossesLaneBorder = lanes.some((l) => {
|
|
929
|
+
if (endpointLanes.has(l.id)) return false;
|
|
930
|
+
return crossesVerticalLine(alt, l.x) || crossesVerticalLine(alt, l.x + l.width);
|
|
931
|
+
});
|
|
932
|
+
if (crossesLaneBorder) continue;
|
|
933
|
+
|
|
934
|
+
r.baseLabelX = r.labelAltX!;
|
|
935
|
+
flipped.add(r.e.id);
|
|
936
|
+
}
|
|
937
|
+
return flipped;
|
|
938
|
+
};
|
|
939
|
+
const flippedLabelEdgeIds = flipLabelToFreeSide(routed);
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* parallel group の label を縦に分散する (#372)。 routing 後の実 label 位置を起点にする。
|
|
943
|
+
*
|
|
944
|
+
* 分ける単位は **節の組** で、 向きは見ない。 A → B と B → A は別々の group として routing
|
|
945
|
+
* されるが、 label は同じ帯に落ちるため、 向きごとに分けて分散すると帯どうしが衝突する
|
|
946
|
+
* (実測 = 往復 4 本で 3 本目と 4 本目が完全に同じ点、 往復 5 本で中心間 52 = pill 高 68 に
|
|
947
|
+
* 対して足りない)。
|
|
948
|
+
*
|
|
949
|
+
* 分散は `spreadWithMinimalShift` に任せる。 各 label は自分の線の上の位置に居たがり、 重なる
|
|
950
|
+
* 分だけ最小限ずれる。 旧実装の「group の中心から ± 一定幅」 は、 線ごとに高さが違う場合に
|
|
951
|
+
* 動かす必要のない label まで動かしていた。
|
|
952
|
+
*
|
|
953
|
+
* この時点では 4 本とも同じ直線で、 弧は後段 (`bowSharedStraightPaths`) で付く。 どの label が
|
|
954
|
+
* どの線のものかは並び順でしか読み取れないため、 弧の側を label の並びに合わせる (#374)。
|
|
955
|
+
*/
|
|
956
|
+
const spreadParallelLabels = (): void => {
|
|
957
|
+
const byPair = new Map<string, RoutedEdge[]>();
|
|
958
|
+
for (const r of routed) {
|
|
959
|
+
if (!parallelGroupedIds.has(r.e.id)) continue;
|
|
960
|
+
if (!hasRenderedLabel(r.e)) continue;
|
|
961
|
+
const key = r.e.from < r.e.to ? `${r.e.from}::${r.e.to}` : `${r.e.to}::${r.e.from}`;
|
|
962
|
+
const list = byPair.get(key) ?? [];
|
|
963
|
+
list.push(r);
|
|
964
|
+
byPair.set(key, list);
|
|
965
|
+
}
|
|
966
|
+
for (const list of byPair.values()) {
|
|
967
|
+
if (list.length < 2) continue;
|
|
968
|
+
// 縦にずらす必要があるのは、 横に重なっている label だけ。 同じ節の組でも左右に分かれて
|
|
969
|
+
// 走る route の label は既に離れているので、 まとめて動かすと自分の線から引き剥がす。
|
|
970
|
+
// 横の重なりで連結成分に割り、 成分ごとに縦を解く。
|
|
971
|
+
const boxes = list.map((r) => ({
|
|
972
|
+
cx: r.baseLabelX,
|
|
973
|
+
halfW: computeLabelBoxW(r.e.label, r.e.sub) / 2,
|
|
974
|
+
}));
|
|
975
|
+
const overlapsX = (i: number, j: number): boolean =>
|
|
976
|
+
Math.abs(boxes[i]!.cx - boxes[j]!.cx) < boxes[i]!.halfW + boxes[j]!.halfW + CLEARANCE_LABEL_LABEL;
|
|
977
|
+
const componentOf = list.map((_, i) => i);
|
|
978
|
+
for (let i = 0; i < list.length; i++) {
|
|
979
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
980
|
+
if (!overlapsX(i, j)) continue;
|
|
981
|
+
const from = componentOf[j]!;
|
|
982
|
+
const to = componentOf[i]!;
|
|
983
|
+
if (from === to) continue;
|
|
984
|
+
for (let k = 0; k < componentOf.length; k++) {
|
|
985
|
+
if (componentOf[k] === from) componentOf[k] = to;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
for (const root of new Set(componentOf)) {
|
|
990
|
+
const members = list.filter((_, i) => componentOf[i] === root);
|
|
991
|
+
if (members.length < 2) continue;
|
|
992
|
+
const placed = spreadWithMinimalShift(
|
|
993
|
+
members.map((r) => ({ want: r.baseLabelY, size: labelPillH(r.e.sub) })),
|
|
994
|
+
CLEARANCE_LABEL_LABEL,
|
|
995
|
+
);
|
|
996
|
+
for (let i = 0; i < members.length; i++) {
|
|
997
|
+
const r = members[i]!;
|
|
998
|
+
labelOffsetsByEdgeId.set(r.e.id, { dx: 0, dy: placed[i]! - r.baseLabelY });
|
|
999
|
+
distributedInParallel.add(r.e.id);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
spreadParallelLabels();
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* CAR-636 fan dispersion の X 分散。 routing 後の実 label 位置に対して走らせる (Issue #211)。
|
|
1008
|
+
*
|
|
1009
|
+
* pill 同士が横に重なるのは Y 帯が重なっている時だけ。 L 字 route の pill は各自の縦走の中点に
|
|
1010
|
+
* 置かれるため、 fan group でも pill の Y は揃わない。 Y 帯が CLEARANCE_LABEL_LABEL 以上離れた
|
|
1011
|
+
* pill まで横に押し退けると、 pill が自 path から外れて隣 lane の node に乗り上げる。
|
|
1012
|
+
*
|
|
1013
|
+
* flipLabelToFreeSide が反転させた pill は動かさない。 反転は「この側は別の経路が通っている」
|
|
1014
|
+
* という判定の結果で、 右へ押し退けるとその経路へ戻してしまう。 押し退ける先を失った兄弟は
|
|
1015
|
+
* さらに右へ回る。
|
|
1016
|
+
*/
|
|
1017
|
+
const applyFanDispersion = (list: RoutedEdge[], tracked: Set<string> | null): void => {
|
|
1018
|
+
if (list.length < 2) return;
|
|
1019
|
+
const obstaclePatterns = new Set(list.map((r) => detectsBlockingObstacle(r.p)));
|
|
1020
|
+
if (obstaclePatterns.size > 1) return;
|
|
1021
|
+
// CAR-615 SSOT ... group 内に overlay:true edge が 1 本でもあれば fan dispersion skip。
|
|
1022
|
+
// overlay:true (flowchart 等の true/false condition label、 CAR-549) は「label を path 上に
|
|
1023
|
+
// 重ねる」 semantic を持つため、 fan offset を加えると pill が path から離れて overlay 意図が
|
|
1024
|
+
// 破壊される。 fan 内 mixed (overlay 有無混在) case は overlay 優先で全体 skip、 non-overlay
|
|
1025
|
+
// edge も fan offset を諦めて base 位置維持。 group 内 label 集中は post-pass 2 chain shift が担当。
|
|
1026
|
+
if (list.some((r) => r.e.overlay)) return;
|
|
1027
|
+
const items = list.map((r) => ({
|
|
1028
|
+
r,
|
|
1029
|
+
baseX: r.baseLabelX,
|
|
1030
|
+
baseY: r.baseLabelY,
|
|
1031
|
+
w: computeLabelBoxW(r.e.label, r.e.sub),
|
|
1032
|
+
h: labelPillH(r.e.sub),
|
|
1033
|
+
}));
|
|
1034
|
+
items.sort((a, b) => a.baseX - b.baseX);
|
|
1035
|
+
// X sweep = 最左 pill を base 位置 (dx=0) 固定、 順次右隣 pill の左端が前 pill 右端 +
|
|
1036
|
+
// FAN_LABEL_GAP より小さければ dx で右にずらす。 押し退けの対象は Y 帯が重なる pill のみ。
|
|
1037
|
+
const placed: Array<{ right: number; baseY: number; h: number }> = [];
|
|
1038
|
+
for (const item of items) {
|
|
1039
|
+
const halfW = item.w / 2;
|
|
1040
|
+
const baseLeft = item.baseX - halfW;
|
|
1041
|
+
let shift = 0;
|
|
1042
|
+
if (!flippedLabelEdgeIds.has(item.r.e.id)) {
|
|
1043
|
+
for (const prev of placed) {
|
|
1044
|
+
const yGap = Math.abs(prev.baseY - item.baseY) - (prev.h + item.h) / 2;
|
|
1045
|
+
if (yGap >= CLEARANCE_LABEL_LABEL) continue;
|
|
1046
|
+
shift = Math.max(shift, prev.right + FAN_LABEL_GAP - baseLeft);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
labelOffsetsByEdgeId.set(item.r.e.id, { dx: shift, dy: 0 });
|
|
1050
|
+
tracked?.add(item.r.e.id);
|
|
1051
|
+
placed.push({ right: item.baseX + shift + halfW, baseY: item.baseY, h: item.h });
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
// (b) fan-in = 同 to (parallel でない) 群。
|
|
1056
|
+
const byToNode = new Map<string, RoutedEdge[]>();
|
|
1057
|
+
for (const r of routed) {
|
|
1058
|
+
if (distributedInParallel.has(r.e.id)) continue;
|
|
1059
|
+
const list = byToNode.get(r.e.to) ?? [];
|
|
1060
|
+
list.push(r);
|
|
1061
|
+
byToNode.set(r.e.to, list);
|
|
1062
|
+
}
|
|
1063
|
+
const distributedInFanIn = new Set<string>();
|
|
1064
|
+
for (const list of byToNode.values()) applyFanDispersion(list, distributedInFanIn);
|
|
1065
|
+
// (c) fan-out = 同 from (parallel / fan-in でない) 群。
|
|
1066
|
+
const byFromNodeFanOut = new Map<string, RoutedEdge[]>();
|
|
1067
|
+
for (const r of routed) {
|
|
1068
|
+
if (distributedInParallel.has(r.e.id) || distributedInFanIn.has(r.e.id)) continue;
|
|
1069
|
+
const list = byFromNodeFanOut.get(r.e.from) ?? [];
|
|
1070
|
+
list.push(r);
|
|
1071
|
+
byFromNodeFanOut.set(r.e.from, list);
|
|
1072
|
+
}
|
|
1073
|
+
for (const list of byFromNodeFanOut.values()) applyFanDispersion(list, null);
|
|
1074
|
+
|
|
1075
|
+
const autoResults = routed.map((r) => {
|
|
1076
|
+
// parallel / fan group の分散 offset (CAR-430 SSOT、 idx 0 は offset 0 で init 位置維持)。
|
|
1077
|
+
const parallelOffset = labelOffsetsByEdgeId.get(r.e.id) ?? { dx: 0, dy: 0 };
|
|
1078
|
+
return {
|
|
1079
|
+
...r.e,
|
|
1080
|
+
d: r.d,
|
|
1081
|
+
dThrough: r.dThrough,
|
|
1082
|
+
fromSide: r.effectiveFromSide,
|
|
1083
|
+
toSide: r.effectiveToSide,
|
|
1084
|
+
labelX: r.baseLabelX + parallelOffset.dx,
|
|
1085
|
+
labelY: r.baseLabelY + parallelOffset.dy,
|
|
1086
|
+
labelAnchor: r.labelAnchor,
|
|
1087
|
+
};
|
|
1088
|
+
});
|
|
1089
|
+
// canvas pivot 新 spec = positionedEdges (posX1/Y1/X2/Y2 set 済) を最終結果に merge。
|
|
1090
|
+
// 元 diag.edges の順序を保持するため id 順で lookup、 positioned は auto より先に列挙。
|
|
1091
|
+
const laidById = new Map<string, LaidEdge>();
|
|
1092
|
+
for (const le of autoResults) laidById.set(le.id, le);
|
|
1093
|
+
for (const le of positionedEdges) laidById.set(le.id, le);
|
|
1094
|
+
return diag.edges.map((e) => laidById.get(e.id)!).filter((e) => e !== undefined);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* `M x1 y1 L x2 y2` の 1 本だけの直線か判定し、 両端を返す。 それ以外は null。
|
|
1099
|
+
*
|
|
1100
|
+
* 数値は JavaScript の通常の文字列化に合わせる。 指数表記を拒むと、 極端に小さい座標
|
|
1101
|
+
* (`1e-7` 等) の図で分離が起きない (実測 = 指数表記の 2 本が重畳したまま残った)。
|
|
1102
|
+
*/
|
|
1103
|
+
const PATH_NUMBER = String.raw`-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?`;
|
|
1104
|
+
/** 分離対象の 1 本と、 その両端。 */
|
|
1105
|
+
type Entry = { e: LaidEdge; seg: { x1: number; y1: number; x2: number; y2: number } };
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* 同じ 2 点を結ぶ edge 群の並び順 (#430)。
|
|
1109
|
+
*
|
|
1110
|
+
* `bowSharedStraightPaths` が弧を振り分ける時と、 後段の置き直しが対応を辿る時で
|
|
1111
|
+
* **同じ順序を使う**。 揃えないと同じ `labelY` の 2 本で対応が入れ替わる。
|
|
1112
|
+
*
|
|
1113
|
+
* `labelY` が同じ時も `id` で決める = 同じ入力なら同じ割当になる。
|
|
1114
|
+
*
|
|
1115
|
+
* `#430` まで同じ式を 2 箇所に書いており、 片方だけ変えても誰も気付かなかった。
|
|
1116
|
+
*/
|
|
1117
|
+
export function compareSharedPairOrder(
|
|
1118
|
+
a: { id: string; labelY: number },
|
|
1119
|
+
b: { id: string; labelY: number },
|
|
1120
|
+
): number {
|
|
1121
|
+
return a.labelY - b.labelY || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
1122
|
+
}
|
|
1123
|
+
/** 曲線と label の間に空ける距離 (world unit)。 cdl の edge-label ↔ edge-path 規定と同じ。 */
|
|
1124
|
+
const BOW_LABEL_CLEARANCE = 14;
|
|
1125
|
+
/** 膨らみを縮める順。 どれも当たるなら分離しない。 */
|
|
1126
|
+
const BOW_SCALES = [1, 0.5] as const;
|
|
1127
|
+
/** node の縁ちょうどは当たりにしない余裕 (world unit)。 端点は縁に着くため。 */
|
|
1128
|
+
const BOW_NODE_MARGIN = 1;
|
|
1129
|
+
|
|
1130
|
+
/** 閉区間の列。 t の取りうる範囲を表す。 */
|
|
1131
|
+
type Interval = { lo: number; hi: number };
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* `a t^2 + b t + c < 0` を満たす t の範囲を [0, 1] の中で返す。
|
|
1135
|
+
*
|
|
1136
|
+
* 2 次 (a != 0) なら根の内側 or 外側、 1 次なら片側、 定数なら全部 or 空になる。
|
|
1137
|
+
*/
|
|
1138
|
+
function quadraticNegativeRanges(a: number, b: number, c: number): Interval[] {
|
|
1139
|
+
const clip = (lo: number, hi: number): Interval[] =>
|
|
1140
|
+
hi <= 0 || lo >= 1 || lo >= hi ? [] : [{ lo: Math.max(lo, 0), hi: Math.min(hi, 1) }];
|
|
1141
|
+
if (Math.abs(a) < 1e-12) {
|
|
1142
|
+
if (Math.abs(b) < 1e-12) return c < 0 ? [{ lo: 0, hi: 1 }] : [];
|
|
1143
|
+
const root = -c / b;
|
|
1144
|
+
return b > 0 ? clip(-Infinity, root) : clip(root, Infinity);
|
|
1145
|
+
}
|
|
1146
|
+
const disc = b * b - 4 * a * c;
|
|
1147
|
+
if (disc <= 0) return a < 0 ? [{ lo: 0, hi: 1 }] : [];
|
|
1148
|
+
const sq = Math.sqrt(disc);
|
|
1149
|
+
const r1 = (-b - sq) / (2 * a);
|
|
1150
|
+
const r2 = (-b + sq) / (2 * a);
|
|
1151
|
+
const lo = Math.min(r1, r2);
|
|
1152
|
+
const hi = Math.max(r1, r2);
|
|
1153
|
+
// a > 0 なら根の内側が負、 a < 0 なら外側が負
|
|
1154
|
+
return a > 0 ? clip(lo, hi) : [...clip(-Infinity, lo), ...clip(hi, Infinity)];
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/** 区間の列 2 つの共通部分。 */
|
|
1158
|
+
function intersectRanges(as: Interval[], bs: Interval[]): Interval[] {
|
|
1159
|
+
const out: Interval[] = [];
|
|
1160
|
+
for (const a of as) {
|
|
1161
|
+
for (const b of bs) {
|
|
1162
|
+
const lo = Math.max(a.lo, b.lo);
|
|
1163
|
+
const hi = Math.min(a.hi, b.hi);
|
|
1164
|
+
if (lo < hi) out.push({ lo, hi });
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return out;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* 2 次曲線が矩形の内部を通るかを厳密に判定する。
|
|
1172
|
+
*
|
|
1173
|
+
* 曲線 = `M (x1,y1) Q (cx,cy) (x2,y2)`。 x(t) / y(t) はどちらも t の 2 次式なので、
|
|
1174
|
+
* 「x が矩形の中」 「y が矩形の中」 の 4 つの不等式を t の範囲として出し、 共通部分が空でなければ
|
|
1175
|
+
* 内部を通る。 端点は矩形の縁に着くことがあるので、 端点だけの接触は除く。
|
|
1176
|
+
*/
|
|
1177
|
+
function quadEntersRect(
|
|
1178
|
+
seg: { x1: number; y1: number; x2: number; y2: number },
|
|
1179
|
+
ctrl: { x: number; y: number },
|
|
1180
|
+
rect: { x: number; y: number; w: number; h: number },
|
|
1181
|
+
): boolean {
|
|
1182
|
+
// x(t) = (x1 - 2cx + x2) t^2 + 2(cx - x1) t + x1
|
|
1183
|
+
const ax = seg.x1 - 2 * ctrl.x + seg.x2;
|
|
1184
|
+
const bx = 2 * (ctrl.x - seg.x1);
|
|
1185
|
+
const ay = seg.y1 - 2 * ctrl.y + seg.y2;
|
|
1186
|
+
const by = 2 * (ctrl.y - seg.y1);
|
|
1187
|
+
// x(t) > rect.x ⇔ -x(t) + rect.x < 0
|
|
1188
|
+
const gtX = quadraticNegativeRanges(-ax, -bx, rect.x - seg.x1);
|
|
1189
|
+
// x(t) < rect.x + w ⇔ x(t) - (rect.x + w) < 0
|
|
1190
|
+
const ltX = quadraticNegativeRanges(ax, bx, seg.x1 - (rect.x + rect.w));
|
|
1191
|
+
const gtY = quadraticNegativeRanges(-ay, -by, rect.y - seg.y1);
|
|
1192
|
+
const ltY = quadraticNegativeRanges(ay, by, seg.y1 - (rect.y + rect.h));
|
|
1193
|
+
const ranges = intersectRanges(intersectRanges(gtX, ltX), intersectRanges(gtY, ltY));
|
|
1194
|
+
// 端点だけが縁に触れる場合を除く = 幅を持つ範囲が残っているか
|
|
1195
|
+
return ranges.some((r) => r.hi - r.lo > 1e-9);
|
|
1196
|
+
}
|
|
1197
|
+
function singleStraightSegment(d: string): { x1: number; y1: number; x2: number; y2: number } | null {
|
|
1198
|
+
const m = new RegExp(
|
|
1199
|
+
`^\\s*M\\s*(${PATH_NUMBER})[\\s,]+(${PATH_NUMBER})\\s*L\\s*(${PATH_NUMBER})[\\s,]+(${PATH_NUMBER})\\s*$`,
|
|
1200
|
+
).exec(d);
|
|
1201
|
+
if (!m) return null;
|
|
1202
|
+
const [x1, y1, x2, y2] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
|
|
1203
|
+
if (![x1, y1, x2, y2].every(Number.isFinite)) return null;
|
|
1204
|
+
if (x1 === x2 && y1 === y2) return null;
|
|
1205
|
+
return { x1, y1, x2, y2 };
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* 同じ直線をそのまま共有している edge を、 途中だけ弓なりにして分ける (dragon #941)。
|
|
1210
|
+
*
|
|
1211
|
+
* 同じ 2 点を同じ向きに結ぶ edge が複数あると、 座標が完全に一致して 1 本に見える。 見えるのは
|
|
1212
|
+
* 最後に描いた 1 本だけで、 何本あるのか / どの label がどの線かを追えない。
|
|
1213
|
+
*
|
|
1214
|
+
* 端点は動かさない。 同じ node の同じ辺から出る edge は起点を 1 点に集める規約
|
|
1215
|
+
* (`fan-origin-single-point` 軸) と、 矢頭は辺の中央に着地する規約 (`arrow-endpoint-center` 軸)
|
|
1216
|
+
* があるため、 端点をずらすとどちらかを破る。 中点を垂直方向へ膨らませた 2 次曲線に置き換える。
|
|
1217
|
+
*
|
|
1218
|
+
* 対象は「1 本の直線」 だけに限る。 折れ線や迂回は既に列の割当で分かれており、 形を触ると
|
|
1219
|
+
* 迂回の判定 (`detour-slot-distinct` 等) に影響する。
|
|
1220
|
+
*
|
|
1221
|
+
* 衝突判定は曲線を弦 (両端を結ぶ直線) に落として見るため、 判定上の形は変更前と同じになる =
|
|
1222
|
+
* 本 pass で新しい error は出ない。 分かれていることは描画された曲線でのみ確認できる。
|
|
1223
|
+
*
|
|
1224
|
+
* 呼ぶ場所は label の重なり解消が終わった後 (`layout.ts` の終盤)。 `layoutEdges` の中で呼ぶと
|
|
1225
|
+
* label 位置が確定しておらず、 確定前の矩形で判定して分離を諦める (実測 = 4 本の label を
|
|
1226
|
+
* 離した図で、 確定前の位置を見て 1 本も分離しなくなった)。
|
|
1227
|
+
*/
|
|
1228
|
+
export function bowSharedStraightPaths(
|
|
1229
|
+
edges: LaidEdge[],
|
|
1230
|
+
nodes: LaidNode[],
|
|
1231
|
+
skipIds: ReadonlySet<string>,
|
|
1232
|
+
): void {
|
|
1233
|
+
/**
|
|
1234
|
+
* 向きを無視した線分の key。 A→B と B→A を同じ群にする。
|
|
1235
|
+
*
|
|
1236
|
+
* 向き別にまとめると、 逆向きの 2 本が同じ側に膨らんで world 座標で重なる (実測 = 4 本が
|
|
1237
|
+
* 同じ横線を通る図で、 向き別に振り分けた結果 2 組が一致した)。
|
|
1238
|
+
*/
|
|
1239
|
+
const groups = new Map<string, Entry[]>();
|
|
1240
|
+
for (const e of edges) {
|
|
1241
|
+
// 座標を明示した edge (canvas pivot) は「指定した直線が最終 path」 が公開契約なので触らない。
|
|
1242
|
+
if (skipIds.has(e.id)) continue;
|
|
1243
|
+
const seg = singleStraightSegment(e.d);
|
|
1244
|
+
if (!seg) continue;
|
|
1245
|
+
const a = `${seg.x1},${seg.y1}`;
|
|
1246
|
+
const b = `${seg.x2},${seg.y2}`;
|
|
1247
|
+
const key = a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
1248
|
+
// 毎回 spread すると同じ線分が k 本ある群で O(k^2) の複製になる。
|
|
1249
|
+
const list = groups.get(key);
|
|
1250
|
+
if (list) list.push({ e, seg });
|
|
1251
|
+
else groups.set(key, [{ e, seg }]);
|
|
1252
|
+
}
|
|
1253
|
+
for (const [key, group] of groups) {
|
|
1254
|
+
if (group.length < 2) continue;
|
|
1255
|
+
// 膨らむ向きは正規化した方向 (key の並び) から決める。 edge 自身の向きから決めると
|
|
1256
|
+
// 逆向きの 2 本が同じ側に寄る。
|
|
1257
|
+
const [pStr, qStr] = key.split("|");
|
|
1258
|
+
const [px, py] = pStr!.split(",").map(Number);
|
|
1259
|
+
const [qx, qy] = qStr!.split(",").map(Number);
|
|
1260
|
+
const len = Math.hypot(qx! - px!, qy! - py!);
|
|
1261
|
+
if (len === 0) continue;
|
|
1262
|
+
// 法線の縦成分を下向き (+Y) に正規化する (#374)。 key は座標を文字列で比べて作るため、
|
|
1263
|
+
// 数値の大小とは限らない (実測 = 端点 860 と 1148 では "1148" が先に来て法線が上向きになる)。
|
|
1264
|
+
// 正規化しないと膨らむ向きが図によって反転し、 label の並びに合わせて振り分けても対応が
|
|
1265
|
+
// 逆になる。
|
|
1266
|
+
//
|
|
1267
|
+
// 縦成分が 0 の線 (弧が横に分かれる) は触らない。 その場合は下の振り分けも label を見ない
|
|
1268
|
+
// ので、 向きを揃えても得るものが無く、 既存の図の弧が左右入れ替わるだけになる。
|
|
1269
|
+
let nx = -(qy! - py!) / len;
|
|
1270
|
+
let ny = (qx! - px!) / len;
|
|
1271
|
+
if (ny < 0) {
|
|
1272
|
+
nx = -nx;
|
|
1273
|
+
ny = -ny;
|
|
1274
|
+
}
|
|
1275
|
+
const midX = (px! + qx!) / 2;
|
|
1276
|
+
const midY = (py! + qy!) / 2;
|
|
1277
|
+
// **label の並び順** で振り分ける (#374)。 label は既に積まれており、 どの label がどの線の
|
|
1278
|
+
// ものかは並び順でしか読み取れない。 id 順で振ると label の順と食い違い、 図が誤読される
|
|
1279
|
+
// (実測 = 往復 4 本の見本で 2 番目と 3 番目の対応が入れ替わった)。
|
|
1280
|
+
//
|
|
1281
|
+
// 例外は **真横に分かれる線** (縦線) だけ。 label を積むのは縦なので (`spreadParallelLabels`
|
|
1282
|
+
// は Y 軸のみ)、 弧の縦成分が 0 だと label の並びと弧の並びが別の軸になり、 対応を保つ余地が
|
|
1283
|
+
// ない (実測 = 縦の 2 本で label の X 差は 5 = 文字数の差でしかない)。 その場合は従来どおり
|
|
1284
|
+
// id 順で振る。 斜めの線は縦成分を持つので label の並びに合わせる。
|
|
1285
|
+
//
|
|
1286
|
+
// label の位置が同じ時も id で決める = 同じ入力なら同じ割当になる。
|
|
1287
|
+
const byId = (a: Entry, b: Entry): number => (a.e.id < b.e.id ? -1 : a.e.id > b.e.id ? 1 : 0);
|
|
1288
|
+
const ordered = [...group].sort((a, b) =>
|
|
1289
|
+
// 縦成分が 0 の時だけ id 順 (上の comment)。 それ以外は置き直しと同じ順序を使う
|
|
1290
|
+
ny === 0 ? byId(a, b) : compareSharedPairOrder(a.e, b.e),
|
|
1291
|
+
);
|
|
1292
|
+
// 膨らませた曲線が他の要素に当たるなら幅を縮める。 どの幅でも当たるなら群ごと直線のまま残す
|
|
1293
|
+
// (重なって見えるのは残るが、 要素を貫くより軽い)。 衝突判定は弦しか見ないので、 ここで
|
|
1294
|
+
// 実曲線を刻んで確かめないと誰も気付けない。
|
|
1295
|
+
//
|
|
1296
|
+
// **この群自身の label は障害物に数えない** (#376)。 群の label は弧を確定させた後に
|
|
1297
|
+
// `repositionParallelLabels` が置き直すため、 今の位置は仮でしかない。 数えると
|
|
1298
|
+
// 「label が線から離れている ⇒ どの膨らみも当たる ⇒ 弧を諦める」 となり、 label を線から
|
|
1299
|
+
// 逃がすほど弧が消える (実測 = 平行 2-5 本の全ての図で弧が直線に戻った)。
|
|
1300
|
+
const groupIds = new Set(group.map((g) => g.e.id));
|
|
1301
|
+
const labelRects = edges
|
|
1302
|
+
.filter((x) => hasRenderedLabel(x) && !groupIds.has(x.id))
|
|
1303
|
+
.map((x) => computeLabelBBoxWorld(x));
|
|
1304
|
+
const nodeRects = nodes.map((n) => ({ x: n.cx - n.w / 2, y: n.cy - n.h / 2, w: n.w, h: n.h }));
|
|
1305
|
+
const build = (seg: Entry["seg"], offset: number): string => {
|
|
1306
|
+
// 2 次曲線は制御点の半分の高さを通るので、 狙いの膨らみの 2 倍を制御点に与える。
|
|
1307
|
+
const cx = midX + nx * offset * 2;
|
|
1308
|
+
const cy = midY + ny * offset * 2;
|
|
1309
|
+
return `M ${seg.x1} ${seg.y1} Q ${cx} ${cy}, ${seg.x2} ${seg.y2}`;
|
|
1310
|
+
};
|
|
1311
|
+
const inflate = (r: { x: number; y: number; w: number; h: number }, by: number) => ({
|
|
1312
|
+
x: r.x - by,
|
|
1313
|
+
y: r.y - by,
|
|
1314
|
+
w: r.w + by * 2,
|
|
1315
|
+
h: r.h + by * 2,
|
|
1316
|
+
});
|
|
1317
|
+
/**
|
|
1318
|
+
* label は「直線の時点で規定の隙間を満たしていたなら、 膨らませた後も満たす」 で見る。
|
|
1319
|
+
*
|
|
1320
|
+
* label は path の上に白い pill として乗るのが通常の配置で、 直線の時点で矩形の中を通って
|
|
1321
|
+
* いることがある。 そこへ規定の隙間を要求すると、 どの膨らみでも当たり判定になって分離が
|
|
1322
|
+
* 起きない (実測 = label ありだと弓なり 0 本になった)。
|
|
1323
|
+
*
|
|
1324
|
+
* 距離の判定は矩形を隙間の分だけ広げて交差で見る。 角では最大 (√2 - 1) × 隙間 だけ厳しく
|
|
1325
|
+
* 出るが、 厳しい側 = 分離を諦める側に外れるので安全。
|
|
1326
|
+
*/
|
|
1327
|
+
const labelGuards = labelRects.map((r) => inflate(r, BOW_LABEL_CLEARANCE));
|
|
1328
|
+
const hitsSomething = (seg: Entry["seg"], offset: number): boolean => {
|
|
1329
|
+
const ctrl = { x: midX + nx * offset * 2, y: midY + ny * offset * 2 };
|
|
1330
|
+
const straight = { x: (seg.x1 + seg.x2) / 2, y: (seg.y1 + seg.y2) / 2 };
|
|
1331
|
+
for (const r of nodeRects) {
|
|
1332
|
+
if (quadEntersRect(seg, ctrl, inflate(r, -BOW_NODE_MARGIN))) return true;
|
|
1333
|
+
}
|
|
1334
|
+
for (const g of labelGuards) {
|
|
1335
|
+
// 直線の時点で既に近い label は対象外 (今より悪くしない)
|
|
1336
|
+
if (quadEntersRect(seg, straight, g)) continue;
|
|
1337
|
+
if (quadEntersRect(seg, ctrl, g)) return true;
|
|
1338
|
+
}
|
|
1339
|
+
return false;
|
|
1340
|
+
};
|
|
1341
|
+
const scale = BOW_SCALES.find((k) =>
|
|
1342
|
+
ordered.every(({ seg }, i) => {
|
|
1343
|
+
const offset = (i - (ordered.length - 1) / 2) * PARALLEL_EDGE_BOW * k;
|
|
1344
|
+
return offset === 0 || !hitsSomething(seg, offset);
|
|
1345
|
+
}),
|
|
1346
|
+
);
|
|
1347
|
+
if (scale === undefined) continue;
|
|
1348
|
+
ordered.forEach(({ e, seg }, i) => {
|
|
1349
|
+
// 中心線から等間隔に振り分ける。 2 本なら ±16、 3 本なら -32 / 0 / +32。
|
|
1350
|
+
const offset = (i - (ordered.length - 1) / 2) * PARALLEL_EDGE_BOW * scale;
|
|
1351
|
+
if (offset === 0) return;
|
|
1352
|
+
e.d = build(seg, offset);
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
interface Rect {
|
|
1358
|
+
x: number;
|
|
1359
|
+
y: number;
|
|
1360
|
+
w: number;
|
|
1361
|
+
h: number;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function nodeRect(n: LaidNode): Rect {
|
|
1365
|
+
return { x: n.cx - n.w / 2, y: n.cy - n.h / 2, w: n.w, h: n.h };
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/** 2 rect の最短距離 (world unit)。 重なっていれば 0。 */
|
|
1369
|
+
function rectGap(a: Rect, b: Rect): number {
|
|
1370
|
+
const dx = Math.max(0, a.x - (b.x + b.w), b.x - (a.x + a.w));
|
|
1371
|
+
const dy = Math.max(0, a.y - (b.y + b.h), b.y - (a.y + a.h));
|
|
1372
|
+
return Math.hypot(dx, dy);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function rectsOverlap(a: Rect, b: Rect): boolean {
|
|
1376
|
+
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function unionRect(a: Rect, b: Rect): Rect {
|
|
1380
|
+
const x = Math.min(a.x, b.x);
|
|
1381
|
+
const y = Math.min(a.y, b.y);
|
|
1382
|
+
return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y };
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function segmentsBBox(segs: Array<{ x1: number; y1: number; x2: number; y2: number }>): Rect {
|
|
1386
|
+
if (segs.length === 0) return { x: 0, y: 0, w: 0, h: 0 };
|
|
1387
|
+
let minX = Infinity;
|
|
1388
|
+
let minY = Infinity;
|
|
1389
|
+
let maxX = -Infinity;
|
|
1390
|
+
let maxY = -Infinity;
|
|
1391
|
+
for (const s of segs) {
|
|
1392
|
+
minX = Math.min(minX, s.x1, s.x2);
|
|
1393
|
+
minY = Math.min(minY, s.y1, s.y2);
|
|
1394
|
+
maxX = Math.max(maxX, s.x1, s.x2);
|
|
1395
|
+
maxY = Math.max(maxY, s.y1, s.y2);
|
|
1396
|
+
}
|
|
1397
|
+
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* rect が縦線 x = lineX を跨ぐか。 `visual-validate` の lane-border-clearance と同一規約で、
|
|
1402
|
+
* 端が線に触れるだけ (許容差 LANE_BORDER_CLEARANCE_TOL 以内) は跨いだ扱いにしない。
|
|
1403
|
+
*/
|
|
1404
|
+
function crossesVerticalLine(rect: Rect, lineX: number): boolean {
|
|
1405
|
+
return rect.x < lineX - LANE_BORDER_CLEARANCE_TOL && rect.x + rect.w > lineX + LANE_BORDER_CLEARANCE_TOL;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function distributeBySides(
|
|
1409
|
+
list: Array<{
|
|
1410
|
+
e: CdlEdge;
|
|
1411
|
+
from: LaidNode;
|
|
1412
|
+
to: LaidNode;
|
|
1413
|
+
fromSide: Side;
|
|
1414
|
+
toSide: Side;
|
|
1415
|
+
}>,
|
|
1416
|
+
): void {
|
|
1417
|
+
const from = list[0]!.from;
|
|
1418
|
+
const tol = 20;
|
|
1419
|
+
for (const p of list) {
|
|
1420
|
+
const dx = p.to.cx - from.cx;
|
|
1421
|
+
const dy = p.to.cy - from.cy;
|
|
1422
|
+
if (Math.abs(dx) > Math.abs(dy)) {
|
|
1423
|
+
p.fromSide = dx > tol ? "right" : dx < -tol ? "left" : p.fromSide;
|
|
1424
|
+
p.toSide = oppositeSide(p.fromSide);
|
|
1425
|
+
} else {
|
|
1426
|
+
p.fromSide = dy > tol ? "bottom" : dy < -tol ? "top" : p.fromSide;
|
|
1427
|
+
p.toSide = oppositeSide(p.fromSide);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function inferSides(
|
|
1433
|
+
from: LaidNode,
|
|
1434
|
+
to: LaidNode,
|
|
1435
|
+
hint: Side | undefined,
|
|
1436
|
+
): { fromSide: Side; toSide: Side } {
|
|
1437
|
+
if (hint) {
|
|
1438
|
+
return { fromSide: hint, toSide: oppositeSide(hint) };
|
|
1439
|
+
}
|
|
1440
|
+
if (from.lane !== to.lane) {
|
|
1441
|
+
if (to.cx > from.cx) return { fromSide: "right", toSide: "left" };
|
|
1442
|
+
return { fromSide: "left", toSide: "right" };
|
|
1443
|
+
}
|
|
1444
|
+
if (to.cy > from.cy) return { fromSide: "bottom", toSide: "top" };
|
|
1445
|
+
return { fromSide: "top", toSide: "bottom" };
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function oppositeSide(s: Side): Side {
|
|
1449
|
+
switch (s) {
|
|
1450
|
+
case "top":
|
|
1451
|
+
return "bottom";
|
|
1452
|
+
case "bottom":
|
|
1453
|
+
return "top";
|
|
1454
|
+
case "left":
|
|
1455
|
+
return "right";
|
|
1456
|
+
case "right":
|
|
1457
|
+
return "left";
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* node の側 (top / bottom / left / right) の中央座標を返す。 CAR-429 で offset 分散を削除、
|
|
1463
|
+
* 全 edge の起点 / 終点は側の中央固定 (offset=0 SSOT)。
|
|
1464
|
+
*/
|
|
1465
|
+
function sidePoint(n: LaidNode, side: Side): { x: number; y: number } {
|
|
1466
|
+
switch (side) {
|
|
1467
|
+
case "top":
|
|
1468
|
+
return { x: n.cx, y: n.cy - n.h / 2 };
|
|
1469
|
+
case "bottom":
|
|
1470
|
+
return { x: n.cx, y: n.cy + n.h / 2 };
|
|
1471
|
+
case "left":
|
|
1472
|
+
return { x: n.cx - n.w / 2, y: n.cy };
|
|
1473
|
+
case "right":
|
|
1474
|
+
return { x: n.cx + n.w / 2, y: n.cy };
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* orthogonal routing。
|
|
1480
|
+
* - 横水平 (left/right pair で y 揃い) → 真横直線
|
|
1481
|
+
* - 横水平 (y 不一致) → S 字 (cubic bezier)
|
|
1482
|
+
* - 縦垂直 (top/bottom pair で x 揃い) → 真縦直線
|
|
1483
|
+
* - 縦垂直 (x 不一致) → L 字 (途中で 90° 曲げ + 角丸)
|
|
1484
|
+
* - その他 (top→right 等の交差) → L 字
|
|
1485
|
+
*/
|
|
1486
|
+
function routePath(
|
|
1487
|
+
from: LaidNode,
|
|
1488
|
+
to: LaidNode,
|
|
1489
|
+
fromSide: Side,
|
|
1490
|
+
toSide: Side,
|
|
1491
|
+
obstacles: LaidNode[] = [],
|
|
1492
|
+
/** 逆向きの edge と通り道を共有する時に割り当てられる側。 未指定なら右。 */
|
|
1493
|
+
detourSide?: DetourSide,
|
|
1494
|
+
_fromLane?: LaidLane,
|
|
1495
|
+
_toLane?: LaidLane,
|
|
1496
|
+
edge?: CdlEdge,
|
|
1497
|
+
labelObstacles: LaidNode[] = obstacles,
|
|
1498
|
+
// CAR-541 SSOT ... 同 node 対称対 lower edge の両 endpoint 平行 shift 量 (world unit)。
|
|
1499
|
+
// layoutEdges で対称対検出し lower edge に fromOffset = toOffset (両端同量) を割り当てる、
|
|
1500
|
+
// upper edge / 非対称対 edge は {x=0, y=0} で従来動作を維持。 両端同量 shift なので直線 SSOT
|
|
1501
|
+
// (A.y = B.y) が保存され corner 挿入されない。 side に対する perpendicular 軸 (left/right → Y、
|
|
1502
|
+
// top/bottom → X) 側の値のみ non-zero、 parallel 軸側は node 側縁上を維持するため 0。
|
|
1503
|
+
fromOffset: { x: number; y: number } = { x: 0, y: 0 },
|
|
1504
|
+
toOffset: { x: number; y: number } = { x: 0, y: 0 },
|
|
1505
|
+
/**
|
|
1506
|
+
* 迂回の折れ位置が入ってはいけない箱 (別 edge の label pill)。
|
|
1507
|
+
*
|
|
1508
|
+
* routing の 1 巡目では label 位置が未確定なので空で呼び、 label が出そろってから
|
|
1509
|
+
* `widenDetourStubs` が同じ引数に本 list を足して呼び直す。
|
|
1510
|
+
*/
|
|
1511
|
+
avoidRects: readonly Rect[] = [],
|
|
1512
|
+
): {
|
|
1513
|
+
d: string;
|
|
1514
|
+
labelX: number;
|
|
1515
|
+
labelY: number;
|
|
1516
|
+
labelAnchor: "start" | "middle" | "end";
|
|
1517
|
+
/**
|
|
1518
|
+
* 経路が `avoidRects` を見たか。 迂回経路だけが真になる。
|
|
1519
|
+
*
|
|
1520
|
+
* 見ない経路は札が出そろってから呼び直しても同じ結果を返すため、 呼出側が 2 巡目を省ける。
|
|
1521
|
+
*/
|
|
1522
|
+
consultsAvoidRects?: boolean;
|
|
1523
|
+
/**
|
|
1524
|
+
* 自 path を挟んで labelX と反対側にある、 等価な label 中心 X。
|
|
1525
|
+
*
|
|
1526
|
+
* 縦 straight route の label は path の左右どちらに置いても自 path からの距離が等しい。
|
|
1527
|
+
* 既定の右側が別 edge の経路で塞がっている時に限り、 layoutEdges の post-pass が
|
|
1528
|
+
* こちらへ移す (`flipLabelToFreeSide`)。 左右対称でない route は undefined。
|
|
1529
|
+
*/
|
|
1530
|
+
labelAltX?: number;
|
|
1531
|
+
} {
|
|
1532
|
+
// label 幅 = SSOT = spec.ts の computeLabelBoxW (CAR-431 で collections.ts labelBBox /
|
|
1533
|
+
// render/edges.tsx / predict-bbox.ts の 4 箇所計算を集約)。 L 字 midX / midY を label 収納可能な
|
|
1534
|
+
// 位置に押し出す判定で使う。 CLEARANCE_NODE_LABEL は clearance-constants.ts SSOT import。
|
|
1535
|
+
const labelBoxW = edge ? computeLabelBoxW(edge.label, edge.sub) : 0;
|
|
1536
|
+
const labelHalfW = labelBoxW / 2;
|
|
1537
|
+
// CAR-518 SSOT ... solid = LABEL_INIT_CLEARANCE (34、 CAR-630 で 26 → 34 復帰)、 dotted-flow = LABEL_INIT_CLEARANCE_DOTTED_FLOW (62、 CAR-630 で 54 → 62 復帰)。
|
|
1538
|
+
// routePath 内 labelY 計算 8 箇所で参照、 style 分岐を helper 経由で単一 SSOT 化する。
|
|
1539
|
+
const labelInit = labelInitClearance(edge);
|
|
1540
|
+
const verticalPair = (fromSide === "bottom" && toSide === "top") || (fromSide === "top" && toSide === "bottom");
|
|
1541
|
+
const horizontalPair = (fromSide === "right" && toSide === "left") || (fromSide === "left" && toSide === "right");
|
|
1542
|
+
// top-top routing ... back transition で fromSide=top + toSide=top で明示 detour
|
|
1543
|
+
const topTopPair = fromSide === "top" && toSide === "top";
|
|
1544
|
+
|
|
1545
|
+
// CAR-429 SSOT ... 起点 / 終点は side の中央固定 (旧実装の verticalPair sideOffsetA /
|
|
1546
|
+
// horizontalPair clampOffsetY による y 分散を削除)。 label 重複解消は post-pass 2 の
|
|
1547
|
+
// resolveEdgeLabelOverlapsWithChain が担当する。
|
|
1548
|
+
// CAR-541 SSOT ... 同 node 対称対 lower edge の両 endpoint 平行 shift 量 (fromOffset = toOffset)。
|
|
1549
|
+
// upper edge / 非対称対 edge は fromOffset = toOffset = {0, 0} で従来動作を維持。 両端同量 shift
|
|
1550
|
+
// なので直線 (A.y = B.y) の直進性 SSOT が保存され L-corner branch には落ちない。
|
|
1551
|
+
const Araw = sidePoint(from, fromSide);
|
|
1552
|
+
const Braw = sidePoint(to, toSide);
|
|
1553
|
+
const A = { x: Araw.x + fromOffset.x, y: Araw.y + fromOffset.y };
|
|
1554
|
+
const B = { x: Braw.x + toOffset.x, y: Braw.y + toOffset.y };
|
|
1555
|
+
|
|
1556
|
+
// edge label を path 中点に配置 (デフォルト位置)。
|
|
1557
|
+
// CAR-430 SSOT ... 本 routePath() が返す (labelX, labelY) が label 位置の SSOT。
|
|
1558
|
+
// 節に被る場合は post-pass 2 の resolveEdgeLabelOverlapsWithChain が chain 伝搬で
|
|
1559
|
+
// 連鎖 shift 解消する。 旧 label-shift.ts の 16 方向 × 26 段階全探索は削除済。
|
|
1560
|
+
if (horizontalPair && Math.abs(A.y - B.y) < 1) {
|
|
1561
|
+
// 直線経路上に別 node が立ち塞がる場合、 上方 or 下方に detour path で回避する。
|
|
1562
|
+
const blockers = horizontalDetourBlockers(A, B, obstacles);
|
|
1563
|
+
if (blockers.length > 0) {
|
|
1564
|
+
// node 上方 or 下方の近い方を選ぶ。 A.y が最初の obstacle 中心より上なら上方、 下なら下方。
|
|
1565
|
+
const detourGap = 50;
|
|
1566
|
+
const isUp = A.y <= blockers[0]!.cy;
|
|
1567
|
+
// 迂回する高さ = 全 blocker の外端のうち最も遠いもの + gap。 1 個だけ避けると、 背の高い
|
|
1568
|
+
// 2 個目を貫通する (Issue #211 の縦 detour と同じ欠陥、 横にも残っていた)。
|
|
1569
|
+
const detourY = isUp
|
|
1570
|
+
? Math.min(...blockers.map((n) => n.cy - n.h / 2)) - detourGap
|
|
1571
|
+
: Math.max(...blockers.map((n) => n.cy + n.h / 2)) + detourGap;
|
|
1572
|
+
const corner = 14;
|
|
1573
|
+
const xDir = B.x > A.x ? 1 : -1;
|
|
1574
|
+
// CAR-482 SSOT ... corner 方向は「Y 進行方向」 で決定 (旧 isUp 単一 flag では 4 corner
|
|
1575
|
+
// で同方向の S 字風変な折れが発生していた)。
|
|
1576
|
+
// exit 側 (A.y → detourY) の corner 方向 = detourY - A.y の符号、 enter 側 (detourY → B.y)
|
|
1577
|
+
// は B.y - detourY の符号。 水平 pair (A.y ≈ B.y) では exit / enter が opposite 符号になる。
|
|
1578
|
+
// isUp (detourY < A.y) case = exit corner 上向き (cornerDirA = -1)、 enter corner 下向き (cornerDirB = 1)。
|
|
1579
|
+
// isDown (detourY > A.y) case = exit corner 下向き (1)、 enter corner 上向き (-1)。
|
|
1580
|
+
const cornerDirA: 1 | -1 = detourY < A.y ? -1 : 1;
|
|
1581
|
+
const cornerDirB: 1 | -1 = B.y < detourY ? -1 : 1;
|
|
1582
|
+
// CAR-472 SSOT ... 起点 / 終点で水平 EDGE_STUB_OUT (40 world) 直進を保証。
|
|
1583
|
+
// 旧実装 = M A.x A.y L A.x detourY (X 変化 0 で即上折れ / 即下折れ)、
|
|
1584
|
+
// 起点即折れは「起点がどの node 由来か視覚判別困難」 症状を起こすため spec.ts SSOT に統一。
|
|
1585
|
+
// 新 path = 起点から水平 stubOut → 垂直 → 水平 detour → 垂直 → 終点手前で水平 stubOut → 終点。
|
|
1586
|
+
//
|
|
1587
|
+
// #418 ... 固定 40 だと走る距離が長い edge で「箱を出た瞬間に折れる」 見え方になるため、
|
|
1588
|
+
// 走る距離の比で決める (`detourStubOut`)。 折れる位置に node が居る場合は手前へ引き戻す。
|
|
1589
|
+
const vTop = Math.min(A.y, B.y, detourY);
|
|
1590
|
+
const vBottom = Math.max(A.y, B.y, detourY);
|
|
1591
|
+
const nodeBoxes = obstacles.filter((o) => o.id !== from.id && o.id !== to.id).map(nodeRect);
|
|
1592
|
+
const exitX = A.x + xDir * detourStub(A.x, xDir, vTop, vBottom, nodeBoxes, avoidRects);
|
|
1593
|
+
const enterX = B.x - xDir * detourStub(B.x, (-xDir) as 1 | -1, vTop, vBottom, nodeBoxes, avoidRects);
|
|
1594
|
+
return {
|
|
1595
|
+
d: `M ${A.x} ${A.y}
|
|
1596
|
+
L ${exitX - xDir * corner} ${A.y}
|
|
1597
|
+
Q ${exitX} ${A.y}, ${exitX} ${A.y + cornerDirA * corner}
|
|
1598
|
+
L ${exitX} ${detourY - cornerDirA * corner}
|
|
1599
|
+
Q ${exitX} ${detourY}, ${exitX + xDir * corner} ${detourY}
|
|
1600
|
+
L ${enterX - xDir * corner} ${detourY}
|
|
1601
|
+
Q ${enterX} ${detourY}, ${enterX} ${detourY + cornerDirB * corner}
|
|
1602
|
+
L ${enterX} ${B.y - cornerDirB * corner}
|
|
1603
|
+
Q ${enterX} ${B.y}, ${enterX + xDir * corner} ${B.y}
|
|
1604
|
+
L ${B.x} ${B.y}`,
|
|
1605
|
+
labelX: (A.x + B.x) / 2,
|
|
1606
|
+
// CAR-468 SSOT ... 旧 ±30 magic number を LABEL_INIT_CLEARANCE (26 world、 CAR-482 で 22 → 26、 CAR-483 で 26 → 34、 CAR-570 で 34 → 26 縮小) に置換。
|
|
1607
|
+
// CAR-518 SSOT ... dotted-flow style edge は 62 world (marker glow 分拡張) を labelInit で参照。
|
|
1608
|
+
// isUp = 上方 detour → label は detour path の さらに上方 (-labelInit)、 isDown = 下方 detour → 下方 (+labelInit)。
|
|
1609
|
+
labelY: detourY + (isUp ? -labelInit : labelInit),
|
|
1610
|
+
labelAnchor: "middle",
|
|
1611
|
+
consultsAvoidRects: true,
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
// horizontal straight の label 初期位置 (CAR-430 SSOT)。
|
|
1615
|
+
// 旧 v7 は A.y - 30 で path 直上に置き shift 探索に逃がしていたが、 label-shift.ts 削除で
|
|
1616
|
+
// init 位置が SSOT になったため、 label が from / to node bbox と label 幅方向で overlap するかを
|
|
1617
|
+
// 幾何的に判定して 2 経路に分岐する。
|
|
1618
|
+
//
|
|
1619
|
+
// (1) label x 帯が from / to node bbox と重ならない = label center が gap 中央、 label 半幅が
|
|
1620
|
+
// min(from.right, to.left) - max(A.x + label_hw, B.x - label_hw) 内 = path 直上 30 で安全
|
|
1621
|
+
// (2) label x 帯が bbox と重なる = label は node 上端の外側に置く必要あり
|
|
1622
|
+
// = A.y - max(from.h, to.h) / 2 - CLEARANCE_NODE_LABEL - label_h / 2
|
|
1623
|
+
// (v6 の nodeMaxH/2 + clearance + label_hh と同じ formula、 v10 で大型 entity の視覚関連
|
|
1624
|
+
// 喪失問題は今 chain shift + parallel offset で緩和されるため、 安全側に振る)
|
|
1625
|
+
const labelCx = (A.x + B.x) / 2;
|
|
1626
|
+
const fromLeft = from.cx - from.w / 2;
|
|
1627
|
+
const fromRight = from.cx + from.w / 2;
|
|
1628
|
+
const toLeft = to.cx - to.w / 2;
|
|
1629
|
+
const toRight = to.cx + to.w / 2;
|
|
1630
|
+
const labelLeft = labelCx - labelHalfW;
|
|
1631
|
+
const labelRight = labelCx + labelHalfW;
|
|
1632
|
+
// label x 帯が from / to bbox と overlap するか。 CLEARANCE_NODE_LABEL を含めた安全境界。
|
|
1633
|
+
const labelXOverlapsNode =
|
|
1634
|
+
(labelLeft < fromRight + CLEARANCE_NODE_LABEL && labelRight > fromLeft - CLEARANCE_NODE_LABEL) ||
|
|
1635
|
+
(labelLeft < toRight + CLEARANCE_NODE_LABEL && labelRight > toLeft - CLEARANCE_NODE_LABEL);
|
|
1636
|
+
const nodeMaxH = Math.max(from.h, to.h);
|
|
1637
|
+
const labelHalfH = 18;
|
|
1638
|
+
// CAR-468 SSOT ... labelXOverlapsNode false case は path 直上、 旧 -30 magic number を
|
|
1639
|
+
// LABEL_INIT_CLEARANCE (26 world、 CAR-482 で 22 → 26、 CAR-483 で 26 → 34、 CAR-570 で 34 → 26 縮小) に置換
|
|
1640
|
+
// (label center と path center の距離)。
|
|
1641
|
+
// CAR-518 SSOT ... dotted-flow は 62 world (marker glow radius 分拡張) を labelInit で参照。
|
|
1642
|
+
// CAR-549 SSOT ... overlay:true (flowchart condition label 等) は label center Y = edge Y で
|
|
1643
|
+
// path 上に重ねる (edge の semantic 強調)。 true case (node bbox 内 overlap) は overlay より
|
|
1644
|
+
// node 押し出し優先 (label 可読性維持)。
|
|
1645
|
+
// true case (node bbox 内 overlap) は node 外に押し出す geometry で SSOT 対象外。
|
|
1646
|
+
const labelY = labelXOverlapsNode
|
|
1647
|
+
? A.y - nodeMaxH / 2 - CLEARANCE_NODE_LABEL - labelHalfH
|
|
1648
|
+
: edge?.overlay
|
|
1649
|
+
? A.y
|
|
1650
|
+
: A.y - labelInit;
|
|
1651
|
+
// CAR-604 SSOT ... 水平 dotted-flow edge の marker (dotted-flow の動く点) が edge 中央
|
|
1652
|
+
// (label 直下) を通過する時、 label pill と marker が視覚的に「同一縦線上」 で交差する。
|
|
1653
|
+
// Y 方向は既に 36 world (LABEL_TO_PATH_CLEARANCE 8 + MARKER_GLOW_RADIUS 28) 離しているが、
|
|
1654
|
+
// X 中央一致で marker と pill が交差する症状を解消する。
|
|
1655
|
+
//
|
|
1656
|
+
// dotted-flow ... labelX は path 内部の source 側に配置。 A→B が左→右 (B.x > A.x) なら
|
|
1657
|
+
// pill 左端 = A.x + labelPathClearanceHorizontal(edge)、 A→B が右→左 (A.x > B.x) なら
|
|
1658
|
+
// pill 左端 = B.x + labelPathClearanceHorizontal(edge) (path 端 = min(A.x, B.x) から
|
|
1659
|
+
// 内部方向に offset)。 垂直 dotted-flow edge の label X (CAR-573 + CAR-601) と対称 SSOT。
|
|
1660
|
+
// solid ... labelX = (A.x + B.x) / 2 (中央維持、 marker なしなので中央上配置で問題ない)。
|
|
1661
|
+
//
|
|
1662
|
+
// CAR-618 SSOT ... A.x > B.x (右→左方向、 sequence rows / 反射 edge 等) の case で label
|
|
1663
|
+
// pill が path 右端 (B.x = source 右隣 anchor が反転側) の外側に飛び出す regression を解消。
|
|
1664
|
+
// path range [min(A.x, B.x), max(A.x, B.x)] 内側に pill を保つため、 anchor 側 (min(A.x, B.x))
|
|
1665
|
+
// から内部方向に offset を積む SSOT に統一する。
|
|
1666
|
+
//
|
|
1667
|
+
// node overlap 判定 (labelXOverlapsNode) は中央 X 前提の bbox 重なり判定、 source 右側 shift
|
|
1668
|
+
// 後は node 押し出しなしで済む幾何を想定 (source node right side から離れる方向で shift)。
|
|
1669
|
+
const isDottedFlow = edge?.style === "dotted-flow";
|
|
1670
|
+
const anchorX = Math.min(A.x, B.x);
|
|
1671
|
+
const labelXFinal = isDottedFlow ? anchorX + labelPathClearanceHorizontal(edge) + labelHalfW : labelCx;
|
|
1672
|
+
return {
|
|
1673
|
+
d: `M ${A.x} ${A.y} L ${B.x} ${B.y}`,
|
|
1674
|
+
labelX: labelXFinal,
|
|
1675
|
+
labelY,
|
|
1676
|
+
labelAnchor: "middle",
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
if (horizontalPair) {
|
|
1680
|
+
// A.y != B.y 時、 L 字 orthogonal routing で交差回避。
|
|
1681
|
+
// CAR-429 で fan-in / fan-out offset 分散を削除、 L 字 corner は A/B 中央固定。
|
|
1682
|
+
// 複数 edge の label 重複は post-pass 2 (resolveEdgeLabelOverlapsWithChain) で解消。
|
|
1683
|
+
const corner = 14;
|
|
1684
|
+
const xDir = B.x > A.x ? 1 : -1;
|
|
1685
|
+
const yDir = B.y > A.y ? 1 : -1;
|
|
1686
|
+
// routing v8 = A / B 間の水平 gap が label 幅 + clearance より狭く、 obstacle で挟まれた
|
|
1687
|
+
// 幾何の場合、 L 字を諦めて水平 detour 経路を採用する。
|
|
1688
|
+
// detour 判定 = A.right (or A.x) と B.left (or B.x) の間の gap を測り、 gap < label 幅 +
|
|
1689
|
+
// clearance × 2 なら obstacle 帯を上迂回 or 下迂回。 上下方向はどちらが svg 内側かで決定。
|
|
1690
|
+
// 例 = pattern-rollback で op.right → db.left の 71.69px 隙間に label 幅 78 + clearance 64
|
|
1691
|
+
// が入らず、 tx (op の上、 db の左隣) と衝突不可避。 下迂回で commit.bottom + gap まで
|
|
1692
|
+
// 水平 detour segment を伸ばし、 label をそこに配置する。
|
|
1693
|
+
if (labelHalfW > 0) {
|
|
1694
|
+
const gapX = Math.abs(B.x - A.x);
|
|
1695
|
+
// routing v8 = engine world 座標系と DOM 描画 px スケールの乖離を吸収する経験係数。
|
|
1696
|
+
//
|
|
1697
|
+
// 根拠 (実測 SSOT) ...
|
|
1698
|
+
// - dragon の CdlDiagramThumbnail は display 幅 CSS 490 px、 engine 計算後 viewBox 幅 1820 world unit
|
|
1699
|
+
// - display px per world unit = 490 / 1820 = 0.269 (world unit 1 = display px 0.269)
|
|
1700
|
+
// - measureTextWidth("tentative write") = 145 world unit を返す (fontSize=17 世界単位 と同 pt スケール)
|
|
1701
|
+
// - 実 DOM 描画 label 幅 = 78 px、 world unit 換算 = 78 / 0.269 = 290 → engine 過小評価 2 倍
|
|
1702
|
+
// - CLEARANCE_NODE_LABEL=32 world unit は DOM 8.6 px 相当、 実 clearance 32 px 保証には world 119 unit 必要
|
|
1703
|
+
// - 総合逆比 ≈ 3.7-4.0、 実装では 4.0 で detour 過剰なし + border case 7 件全解消
|
|
1704
|
+
//
|
|
1705
|
+
// 前提 (壊れると再検証必要) ...
|
|
1706
|
+
// - dragon の CdlDiagramThumbnail display 幅を 490 px 相当に維持
|
|
1707
|
+
// - diagram viewBox 幅を 1500-2000 world unit 相当に維持 (author lane 定数 SSOT)
|
|
1708
|
+
// - font (Inter / Newsreader) が変更されて char 幅が大きくずれない
|
|
1709
|
+
//
|
|
1710
|
+
// dynamic 化を検討したが (viewBox 幅 / display 幅 の比を engine で自動算出)、 (a) engine は
|
|
1711
|
+
// DOM display 幅を知らず author 宣言必要 (b) viewBox 幅は edges.ts 呼び出し中には未確定
|
|
1712
|
+
// (label 配置に依存する chicken-and-egg) で engineering cost に見合わない、 hard-code 4.0
|
|
1713
|
+
// で SSOT comment 強化する方針を採用。
|
|
1714
|
+
//
|
|
1715
|
+
// 大幅な display / viewBox 比変更 (thumbnail size 変更等) が発生したら本ブロックの実測 SSOT
|
|
1716
|
+
// を再測定して係数を再チューニングする。
|
|
1717
|
+
const WORLD_SCALE = 4.0;
|
|
1718
|
+
const needX = labelBoxW * WORLD_SCALE + CLEARANCE_NODE_LABEL * 2 * WORLD_SCALE;
|
|
1719
|
+
// A / B 間の x 帯 [Math.min(A.x, B.x), Math.max(A.x, B.x)] に obstacle が挟まれてる場合
|
|
1720
|
+
// 判定 x 帯は label 幅 world scale 分を加算して obstacle の隣接 (tx.right = A.x のような
|
|
1721
|
+
// ぴったり境界 case) も detour 対象に含める。
|
|
1722
|
+
const bandExtend = labelBoxW * WORLD_SCALE / 2 + CLEARANCE_NODE_LABEL * WORLD_SCALE;
|
|
1723
|
+
const xMinAB = Math.min(A.x, B.x) - bandExtend;
|
|
1724
|
+
const xMaxAB = Math.max(A.x, B.x) + bandExtend;
|
|
1725
|
+
const yMinAB = Math.min(A.y, B.y);
|
|
1726
|
+
const yMaxAB = Math.max(A.y, B.y);
|
|
1727
|
+
const midObstacles = labelObstacles.filter((o) => {
|
|
1728
|
+
if (o.id === from.id || o.id === to.id) return false;
|
|
1729
|
+
const oLeft = o.cx - o.w / 2;
|
|
1730
|
+
const oRight = o.cx + o.w / 2;
|
|
1731
|
+
const oTop = o.cy - o.h / 2;
|
|
1732
|
+
const oBottom = o.cy + o.h / 2;
|
|
1733
|
+
// A/B の x 帯 (label 幅 world scale 分拡張済) を跨ぐ + y も A / B 帯周辺
|
|
1734
|
+
return oRight > xMinAB && oLeft < xMaxAB && oTop < yMaxAB + 20 && oBottom > yMinAB - 20;
|
|
1735
|
+
});
|
|
1736
|
+
// CAR-491 SSOT ... horizontal detour 発火条件を「L 字 midX の垂直 segment or L 字 corner 帯を
|
|
1737
|
+
// 実際に obstacle が塞ぐ」 場合に限定する。 従来は yMinAB..yMaxAB の A/B 帯にある obstacle を
|
|
1738
|
+
// 一律 midObstacle 扱いしていたため、 L 字の垂直 segment (midX = (A.x+B.x)/2 前後) から遠い
|
|
1739
|
+
// obstacle でも detour が発火して mindMap root→ui / flowchart Review→Revise のような
|
|
1740
|
+
// viewport 下端まで届く大迂回が発生していた。
|
|
1741
|
+
//
|
|
1742
|
+
// 判定 = L 字 corner の x 座標帯 (midX ± labelHalfW ± CLEARANCE_NODE_LABEL) に垂直 segment が
|
|
1743
|
+
// 通り、 その帯内で midObstacle が Y 方向 A..B 帯と交差するか。 midX 上に obstacle が無いなら
|
|
1744
|
+
// 通常 L (line 552 以降 midX retreat 経路) で自然に回避できるので horizontal detour は不要。
|
|
1745
|
+
//
|
|
1746
|
+
// A.y ≈ B.y の完全水平は既に line 347 分岐で処理済のため、 本 branch (A.y != B.y) では
|
|
1747
|
+
// L 字の垂直 segment 帯を実際に obstacle が塞ぐ幾何のみ detour する。
|
|
1748
|
+
const midXCandidate = A.x + (B.x - A.x) * 0.5;
|
|
1749
|
+
const lBandLeft = midXCandidate - labelHalfW - CLEARANCE_NODE_LABEL;
|
|
1750
|
+
const lBandRight = midXCandidate + labelHalfW + CLEARANCE_NODE_LABEL;
|
|
1751
|
+
const lBlockingObstacles = midObstacles.filter((o) => {
|
|
1752
|
+
const oLeft = o.cx - o.w / 2;
|
|
1753
|
+
const oRight = o.cx + o.w / 2;
|
|
1754
|
+
const oTop = o.cy - o.h / 2;
|
|
1755
|
+
const oBottom = o.cy + o.h / 2;
|
|
1756
|
+
// 垂直 segment 帯 (midX ± labelHalfW ± clearance) と obstacle が X で重なる、 かつ
|
|
1757
|
+
// 垂直 segment の Y range (yMinAB..yMaxAB) と Y で重なる
|
|
1758
|
+
return oRight > lBandLeft && oLeft < lBandRight && oTop < yMaxAB && oBottom > yMinAB;
|
|
1759
|
+
});
|
|
1760
|
+
if (gapX < needX && lBlockingObstacles.length > 0) {
|
|
1761
|
+
// 下迂回 detour = 全 midObstacle bottom + gap で水平 segment を伸ばす。
|
|
1762
|
+
// label はその水平 segment 上に配置 (中心 x = (A.x + B.x) / 2)。
|
|
1763
|
+
// routing v10.1 = gap 44 → 94 に拡張 (+50)。 obstacle.bottom + clearance 32 world
|
|
1764
|
+
// (DOM ~8px) では label bottom が obstacle bottom を 24 world 割り込む
|
|
1765
|
+
// (pattern-rollback e3 の label × commit 20 px² overlap の root cause)。 gap +50 で
|
|
1766
|
+
// 最終 label 位置が obstacle bottom から 66 world (DOM ~16px) 離れ、
|
|
1767
|
+
// pre-existing G2/G3 gate に影響与えずに pattern-rollback を engine 側解消。
|
|
1768
|
+
const gap = 94;
|
|
1769
|
+
const labelBoxH = 36;
|
|
1770
|
+
const labelHalfH = labelBoxH / 2;
|
|
1771
|
+
// detour Y は label bbox が全 obstacle (A/B 自身含む) + clearance と干渉しない位置に置く。
|
|
1772
|
+
// 全 obstacle の bottom / top の最大 / 最小を取り、 全部の下 / 上 に迂回する。
|
|
1773
|
+
const labelBoxHalfHFinal = labelHalfH + CLEARANCE_NODE_LABEL;
|
|
1774
|
+
const allNodes: LaidNode[] = [from, to, ...labelObstacles];
|
|
1775
|
+
const allBottomsMax = allNodes.reduce((m, n) => Math.max(m, n.cy + n.h / 2), -Infinity);
|
|
1776
|
+
const allTopsMin = allNodes.reduce((m, n) => Math.min(m, n.cy - n.h / 2), Infinity);
|
|
1777
|
+
const detourYDown = allBottomsMax + gap + labelBoxHalfHFinal;
|
|
1778
|
+
const detourYUp = allTopsMin - gap - labelBoxHalfHFinal;
|
|
1779
|
+
// svg viewport 下限は自動拡張されるので下迂回 default。 上迂回は allTopsMin が
|
|
1780
|
+
// viewport top (経験値 y=68 相当) より label 収納幅以上余裕がある場合のみ。
|
|
1781
|
+
// pattern-rollback tx.top=128 で detourYUp=34 は viewBox y=68 の上、 clip されるため
|
|
1782
|
+
// upMargin 判定で下迂回を選ぶ。
|
|
1783
|
+
const upMargin = labelBoxHalfHFinal * 2 + gap + 40;
|
|
1784
|
+
// CAR-429 SSOT ... offset=0 固定になったため、 fan-in slot 別 detour Y 分散
|
|
1785
|
+
// (旧: detourYAdjust = |offset|/fanGap * detourSlotSize) は削除。 label 重複解消は
|
|
1786
|
+
// post-pass 2 の resolveEdgeLabelOverlapsWithChain が chain 伝搬で解消する。
|
|
1787
|
+
const isDownDetour = allTopsMin <= upMargin;
|
|
1788
|
+
const detourY = isDownDetour ? detourYDown : detourYUp;
|
|
1789
|
+
// path = A→A.right の少し外→垂直で detourY→水平で B の少し外→垂直で B.y→B
|
|
1790
|
+
// sidepoint A/B の xDir 方向に少しだけ出て垂直に折れ、 detourY 帯で水平にする。
|
|
1791
|
+
// CAR-472 SSOT ... 起点 stub は EDGE_STUB_OUT (40 world) 統一 (旧 30 magic number)。
|
|
1792
|
+
// #418 ... 走る距離の比で決める (上の水平 detour と同じ規則)。
|
|
1793
|
+
const vTop = Math.min(A.y, B.y, detourY);
|
|
1794
|
+
const vBottom = Math.max(A.y, B.y, detourY);
|
|
1795
|
+
const nodeBoxes = obstacles.filter((o) => o.id !== from.id && o.id !== to.id).map(nodeRect);
|
|
1796
|
+
const exitX = A.x + xDir * detourStub(A.x, xDir, vTop, vBottom, nodeBoxes, avoidRects);
|
|
1797
|
+
const enterX = B.x - xDir * detourStub(B.x, (-xDir) as 1 | -1, vTop, vBottom, nodeBoxes, avoidRects);
|
|
1798
|
+
// CAR-472 SSOT ... label は detour segment 中央ではなく LABEL_INIT_CLEARANCE (26、 CAR-570 で 34 → 26 縮小) 上方に配置。
|
|
1799
|
+
// CAR-518 SSOT ... dotted-flow は 62 world (marker glow radius 分拡張) を labelInit で参照。
|
|
1800
|
+
// isDownDetour = true 時 label は detour segment の下方 +labelInit、 false (上方 detour) 時 -labelInit。
|
|
1801
|
+
// detour segment 上に label pill を直接置くと path と label が重なる (旧 labelY: detourY 実装)。
|
|
1802
|
+
const labelDy = isDownDetour ? labelInit : -labelInit;
|
|
1803
|
+
return {
|
|
1804
|
+
d: `M ${A.x} ${A.y}
|
|
1805
|
+
L ${exitX - xDir * corner} ${A.y}
|
|
1806
|
+
Q ${exitX} ${A.y}, ${exitX} ${A.y + (detourY > A.y ? 1 : -1) * corner}
|
|
1807
|
+
L ${exitX} ${detourY - (detourY > A.y ? 1 : -1) * corner}
|
|
1808
|
+
Q ${exitX} ${detourY}, ${exitX + xDir * corner} ${detourY}
|
|
1809
|
+
L ${enterX - xDir * corner} ${detourY}
|
|
1810
|
+
Q ${enterX} ${detourY}, ${enterX} ${detourY + (B.y > detourY ? 1 : -1) * corner}
|
|
1811
|
+
L ${enterX} ${B.y - (B.y > detourY ? 1 : -1) * corner}
|
|
1812
|
+
Q ${enterX} ${B.y}, ${enterX + xDir * corner} ${B.y}
|
|
1813
|
+
L ${B.x} ${B.y}`,
|
|
1814
|
+
labelX: (A.x + B.x) / 2,
|
|
1815
|
+
labelY: detourY + labelDy,
|
|
1816
|
+
labelAnchor: "middle",
|
|
1817
|
+
consultsAvoidRects: true,
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
// midX の基準 = A と B の 50% (中央)。 CAR-429 で offset=0 固定になったため、
|
|
1822
|
+
// 旧実装の slot 別 midX 分散 (offset * 0.5) は削除、 L 字 corner は A/B の中央固定。
|
|
1823
|
+
let midX = A.x + (B.x - A.x) * 0.5;
|
|
1824
|
+
// routing v8 = label 幅が obstacle 間 gap より広い場合、 midX を label 収納可能位置に押し出す。
|
|
1825
|
+
// 例 = pattern-rollback の op → db 水平 route で、 midX = 319 (tx.right=283, db.left=355 の中央)
|
|
1826
|
+
// に label 幅 78 半分 = 39 で label 左端 = 280、 tx.right = 283 に 3px 食い込む。
|
|
1827
|
+
// fix = midX 垂直 segment 帯 [midX - labelHalfW - clearance, midX + labelHalfW + clearance]
|
|
1828
|
+
// と交差する node を検出して midX を obstacle 外へ retreat。 A / B node 自身は除外。
|
|
1829
|
+
if (labelHalfW > 0) {
|
|
1830
|
+
const yMinRoute = Math.min(A.y, B.y);
|
|
1831
|
+
const yMaxRoute = Math.max(A.y, B.y);
|
|
1832
|
+
const labelBand = (mx: number): [number, number] => [
|
|
1833
|
+
mx - labelHalfW - CLEARANCE_NODE_LABEL,
|
|
1834
|
+
mx + labelHalfW + CLEARANCE_NODE_LABEL,
|
|
1835
|
+
];
|
|
1836
|
+
const intersects = (obs: LaidNode, band: [number, number]): boolean => {
|
|
1837
|
+
if (obs.id === from.id || obs.id === to.id) return false;
|
|
1838
|
+
const oLeft = obs.cx - obs.w / 2;
|
|
1839
|
+
const oRight = obs.cx + obs.w / 2;
|
|
1840
|
+
const oTop = obs.cy - obs.h / 2;
|
|
1841
|
+
const oBottom = obs.cy + obs.h / 2;
|
|
1842
|
+
// 垂直 segment (midX, yMinRoute → yMaxRoute) と node bbox の交差判定に加えて、
|
|
1843
|
+
// label bbox (垂直 segment 中点上下) の x 帯とも交差する node を検出。
|
|
1844
|
+
if (oRight <= band[0] || oLeft >= band[1]) return false;
|
|
1845
|
+
// 垂直 segment 通過帯と交差する y (obs.top < yMaxRoute && obs.bottom > yMinRoute) or
|
|
1846
|
+
// label 中点 (yMid) が obs 帯に含まれる。 label 中点は verticalMidY = (A.y+B.y)/2。
|
|
1847
|
+
const yMidLabel = (A.y + B.y) / 2;
|
|
1848
|
+
const labelTop = yMidLabel - 25;
|
|
1849
|
+
const labelBottom = yMidLabel + 25;
|
|
1850
|
+
const segIntersect = oTop < yMaxRoute && oBottom > yMinRoute;
|
|
1851
|
+
const labelIntersect = oTop < labelBottom && oBottom > labelTop;
|
|
1852
|
+
return segIntersect || labelIntersect;
|
|
1853
|
+
};
|
|
1854
|
+
// xDir 方向に obstacle を避けて midX を retreat する。
|
|
1855
|
+
let iter = 0;
|
|
1856
|
+
while (iter < 12) {
|
|
1857
|
+
const band = labelBand(midX);
|
|
1858
|
+
const hit = labelObstacles.find((o) => intersects(o, band));
|
|
1859
|
+
if (!hit) break;
|
|
1860
|
+
// hit を xDir 方向に回避、 obstacle right/left から labelHalfW + clearance を確保
|
|
1861
|
+
// xDir=1 (右向き) なら hit.right + labelHalfW + clearance より右へ、
|
|
1862
|
+
// xDir=-1 (左向き) なら hit.left - labelHalfW - clearance より左へ。
|
|
1863
|
+
// ただし xDir 側に既に押し出す余地が無い場合 (B の反対) は逆方向に retreat する。
|
|
1864
|
+
const hitLeft = hit.cx - hit.w / 2;
|
|
1865
|
+
const hitRight = hit.cx + hit.w / 2;
|
|
1866
|
+
const retreatRight = hitRight + labelHalfW + CLEARANCE_NODE_LABEL + 4;
|
|
1867
|
+
const retreatLeft = hitLeft - labelHalfW - CLEARANCE_NODE_LABEL - 4;
|
|
1868
|
+
// 現 midX と近い方の retreat 側を選ぶ。 midX を大きく動かさない方向優先。
|
|
1869
|
+
const dRight = Math.abs(retreatRight - midX);
|
|
1870
|
+
const dLeft = Math.abs(retreatLeft - midX);
|
|
1871
|
+
midX = dRight <= dLeft ? retreatRight : retreatLeft;
|
|
1872
|
+
iter++;
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
// label 初期位置 = L 字 corner の垂直 segment 中点、 A.y と B.y の中間 高さ (routing v6)。
|
|
1876
|
+
// 旧実装は labelX=(A.x+midX)/2, labelY=A.y の水平 segment 上に置いていたが、 label 幅の広い
|
|
1877
|
+
// edge (幅 230px 等) では A 側 node bbox に食い込んで overlap 発生 (rollback pattern e3 の
|
|
1878
|
+
// op node × label overlap=1278 が典型例)。 routing v6 では垂直 segment 中点に置いて、
|
|
1879
|
+
// A 側 node からの水平距離 |midX - A.x| で node 外に確実に離す。
|
|
1880
|
+
// routing v8 = verticalMidY を obstacle 帯を避ける y に調整。 label bbox 縦帯 (verticalMidY ± 25)
|
|
1881
|
+
// が obstacle と交差する場合、 A.y または B.y に近い側の label-safe 位置に移動する。
|
|
1882
|
+
let verticalMidY = (A.y + B.y) / 2;
|
|
1883
|
+
if (labelHalfW > 0) {
|
|
1884
|
+
const labelBoxH = 36;
|
|
1885
|
+
const labelHalfH = labelBoxH / 2;
|
|
1886
|
+
const labelCX = midX;
|
|
1887
|
+
const labelBandXLeft = labelCX - labelHalfW - CLEARANCE_NODE_LABEL;
|
|
1888
|
+
const labelBandXRight = labelCX + labelHalfW + CLEARANCE_NODE_LABEL;
|
|
1889
|
+
const isLabelHit = (my: number): boolean => {
|
|
1890
|
+
for (const o of labelObstacles) {
|
|
1891
|
+
if (o.id === from.id || o.id === to.id) continue;
|
|
1892
|
+
const oLeft = o.cx - o.w / 2;
|
|
1893
|
+
const oRight = o.cx + o.w / 2;
|
|
1894
|
+
const oTop = o.cy - o.h / 2;
|
|
1895
|
+
const oBottom = o.cy + o.h / 2;
|
|
1896
|
+
if (oRight <= labelBandXLeft || oLeft >= labelBandXRight) continue;
|
|
1897
|
+
const labelTop = my - labelHalfH - CLEARANCE_NODE_LABEL;
|
|
1898
|
+
const labelBottom = my + labelHalfH + CLEARANCE_NODE_LABEL;
|
|
1899
|
+
if (oBottom <= labelTop || oTop >= labelBottom) continue;
|
|
1900
|
+
return true;
|
|
1901
|
+
}
|
|
1902
|
+
return false;
|
|
1903
|
+
};
|
|
1904
|
+
if (isLabelHit(verticalMidY)) {
|
|
1905
|
+
// A.y ± 40 と B.y ± 40 を試して label-safe な位置を見つける
|
|
1906
|
+
const candidates = [
|
|
1907
|
+
A.y + yDir * 40, // A.y から yDir 方向に 40px shift
|
|
1908
|
+
A.y - yDir * 40,
|
|
1909
|
+
B.y - yDir * 40,
|
|
1910
|
+
B.y + yDir * 40,
|
|
1911
|
+
Math.min(A.y, B.y) - 40,
|
|
1912
|
+
Math.max(A.y, B.y) + 40,
|
|
1913
|
+
];
|
|
1914
|
+
const safe = candidates.find((y) => !isLabelHit(y));
|
|
1915
|
+
if (safe !== undefined) verticalMidY = safe;
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
// CAR-615 SSOT ... L-shape orthogonal branch (horizontalPair + A.y != B.y) の overlay:true 対応。
|
|
1919
|
+
// 水平 straight branch (line 634 の `edge?.overlay ? A.y`) と垂直 straight branch (line 972 の
|
|
1920
|
+
// `edge?.overlay ? A.x`) は既に overlay 実装済だが、 L 字 orthogonal branch は verticalMidY の
|
|
1921
|
+
// path midpoint 経路のみで overlay flag を無視していた (flowchart fc-2-review-revise で
|
|
1922
|
+
// pill 4 corner → path 最短距離 = 51 world = SSOT 8 に対し +43 の regression)。
|
|
1923
|
+
//
|
|
1924
|
+
// 実装 = L 字 の 3 直線 segment (exit 水平 → mid 垂直 → enter 水平、 Q corner は無視) の
|
|
1925
|
+
// 長さを算出し、 最長 segment の中点に label 中心を置く。 pill 中心が最長 segment 上に載る、
|
|
1926
|
+
// pill 4 corner → path 実距離 ≈ 0 world (完全 overlay) = 水平 / 垂直 straight overlay と同 SSOT。
|
|
1927
|
+
//
|
|
1928
|
+
// fc-2 例 ... exit 水平 (706.22→567.11) 139 world / mid 垂直 (274→546) 272 world /
|
|
1929
|
+
// enter 水平 (539.11→400) 139 world、 最長 = mid 垂直、 中点 = (553.11, 410)、
|
|
1930
|
+
// pill (label 幅 102 × 高さ 36) 中心 = (553.11, 410) で垂直 segment 上 straddle。
|
|
1931
|
+
if (edge?.overlay) {
|
|
1932
|
+
const exitEndX = midX - xDir * corner;
|
|
1933
|
+
const enterStartX = midX + xDir * corner;
|
|
1934
|
+
const midStartY = A.y + yDir * corner;
|
|
1935
|
+
const midEndY = B.y - yDir * corner;
|
|
1936
|
+
const segments = [
|
|
1937
|
+
{ len: Math.abs(exitEndX - A.x), midXVal: (A.x + exitEndX) / 2, midYVal: A.y },
|
|
1938
|
+
{ len: Math.abs(midEndY - midStartY), midXVal: midX, midYVal: (midStartY + midEndY) / 2 },
|
|
1939
|
+
{ len: Math.abs(B.x - enterStartX), midXVal: (enterStartX + B.x) / 2, midYVal: B.y },
|
|
1940
|
+
];
|
|
1941
|
+
const longest = segments.reduce((a, b) => (b.len > a.len ? b : a));
|
|
1942
|
+
return {
|
|
1943
|
+
d: `M ${A.x} ${A.y}
|
|
1944
|
+
L ${midX - xDir * corner} ${A.y}
|
|
1945
|
+
Q ${midX} ${A.y}, ${midX} ${A.y + yDir * corner}
|
|
1946
|
+
L ${midX} ${B.y - yDir * corner}
|
|
1947
|
+
Q ${midX} ${B.y}, ${midX + xDir * corner} ${B.y}
|
|
1948
|
+
L ${B.x} ${B.y}`,
|
|
1949
|
+
labelX: longest.midXVal,
|
|
1950
|
+
labelY: longest.midYVal,
|
|
1951
|
+
labelAnchor: "middle",
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
return {
|
|
1955
|
+
d: `M ${A.x} ${A.y}
|
|
1956
|
+
L ${midX - xDir * corner} ${A.y}
|
|
1957
|
+
Q ${midX} ${A.y}, ${midX} ${A.y + yDir * corner}
|
|
1958
|
+
L ${midX} ${B.y - yDir * corner}
|
|
1959
|
+
Q ${midX} ${B.y}, ${midX + xDir * corner} ${B.y}
|
|
1960
|
+
L ${B.x} ${B.y}`,
|
|
1961
|
+
labelX: midX,
|
|
1962
|
+
labelY: verticalMidY,
|
|
1963
|
+
labelAnchor: "middle",
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
if (verticalPair && Math.abs(A.x - B.x) < 1) {
|
|
1967
|
+
const blockers = verticalDetourBlockers(A, B, obstacles);
|
|
1968
|
+
if (blockers.length > 0) {
|
|
1969
|
+
const detourGap = VERTICAL_DETOUR_GAP;
|
|
1970
|
+
const corner = VERTICAL_DETOUR_CORNER;
|
|
1971
|
+
const yDir = B.y > A.y ? 1 : -1;
|
|
1972
|
+
// 迂回列 X = 全 blocker の外端 ± gap。 これで全 obstacle の外を通る。
|
|
1973
|
+
// 逆向きの edge が同じ通り道を使う時だけ左へ回す (assignDetourSides、 既定は右)。
|
|
1974
|
+
const side: DetourSide = detourSide ?? "right";
|
|
1975
|
+
const xDir = side === "left" ? -1 : 1;
|
|
1976
|
+
const column = detourColumnFor(blockers, side);
|
|
1977
|
+
// A source node の 出口 = source 下端付近 (yDir 方向に h の一部進んだ場所) から出る
|
|
1978
|
+
const exitY = A.y + yDir * VERTICAL_DETOUR_EXIT;
|
|
1979
|
+
// 戻り点 = 進行方向で最後の (最も target 寄りの) blocker を越えた位置 + gap。
|
|
1980
|
+
// 1 個目だけで折り返すと、 戻りの縦線が 2 個目の obstacle を貫く。
|
|
1981
|
+
const lastBlockerEdge =
|
|
1982
|
+
yDir > 0
|
|
1983
|
+
? Math.max(...blockers.map((n) => n.cy + n.h / 2))
|
|
1984
|
+
: Math.min(...blockers.map((n) => n.cy - n.h / 2));
|
|
1985
|
+
const returnY = lastBlockerEdge + yDir * detourGap;
|
|
1986
|
+
// CAR-573 SSOT ... labelAnchor = "middle" に統一 (pill 内文字中央寄せ)。
|
|
1987
|
+
// pill 中心 X = column + xDir * (labelPathClearanceHorizontal(edge) + labelHalfW)、
|
|
1988
|
+
// pill の内側の端 = column + xDir * labelPathClearanceHorizontal(edge) で detour 垂直 segment X から
|
|
1989
|
+
// solid=16 / dotted-flow=44 (glow radius 28 加算、 CAR-630 で 8 → 16 復帰) world 離れ、 水平 edge (pill 下端 → path Y = solid 16 / dotted-flow 44)
|
|
1990
|
+
// と同一 SSOT。 旧実装 (CAR-549) は goRight + labelInit (=26) で pill 左端 → path X = 26 world、
|
|
1991
|
+
// 水平 8 world と非対称だった。 CAR-573 で全方向 pill 端 → path 実距離を LABEL_TO_PATH_CLEARANCE (solid) に統一。
|
|
1992
|
+
// CAR-601 で dotted-flow の MARKER_GLOW_RADIUS 加算漏れ regression を解消
|
|
1993
|
+
// (`labelPathClearanceHorizontal(edge)` helper 経由で solid / dotted-flow 2 分岐を SSOT 化)。
|
|
1994
|
+
// CAR-630 で LABEL_TO_PATH_CLEARANCE 8 → 16 復帰、 solid=16 / dotted-flow=44 world に変更。
|
|
1995
|
+
// 旧実装 (CAR-472) は labelAnchor = "start" + labelX = goRight + EDGE_STUB_OUT * 2 (magic 80) で
|
|
1996
|
+
// pill 内文字が左寄り + offset が SSOT 外 (80 world) だった。
|
|
1997
|
+
// detour path 経路では垂直 segment が label 位置基準になるため overlay flag は無視、
|
|
1998
|
+
// detour segment 中央 (exitY と returnY の中点) を label Y とする (path 上重ね不可能な迂回経路)。
|
|
1999
|
+
return {
|
|
2000
|
+
d: `M ${A.x} ${A.y}
|
|
2001
|
+
L ${A.x} ${exitY - yDir * corner}
|
|
2002
|
+
Q ${A.x} ${exitY}, ${A.x + xDir * corner} ${exitY}
|
|
2003
|
+
L ${column - xDir * corner} ${exitY}
|
|
2004
|
+
Q ${column} ${exitY}, ${column} ${exitY + yDir * corner}
|
|
2005
|
+
L ${column} ${returnY - yDir * corner}
|
|
2006
|
+
Q ${column} ${returnY}, ${column - xDir * corner} ${returnY}
|
|
2007
|
+
L ${B.x + xDir * corner} ${returnY}
|
|
2008
|
+
Q ${B.x} ${returnY}, ${B.x} ${returnY + yDir * corner}
|
|
2009
|
+
L ${B.x} ${B.y}`,
|
|
2010
|
+
labelX: column + xDir * (labelPathClearanceHorizontal(edge) + labelHalfW),
|
|
2011
|
+
labelY: (exitY + returnY) / 2,
|
|
2012
|
+
labelAnchor: "middle",
|
|
2013
|
+
};
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
// vertical straight edge の label Y = path の中点 (A.y + B.y) / 2 (Issue #202)。
|
|
2017
|
+
// A.y = 上 node の下端、 B.y = 下 node の上端なので、 これは 2 node の隙間の中点でもある。
|
|
2018
|
+
// label は隙間の中央に置かれ、 上下の余白が必ず等しくなる。
|
|
2019
|
+
//
|
|
2020
|
+
// CAR-549 は「上下 node の中心平均」 (from.cy + to.cy) / 2 に変更していた。 node の高さが
|
|
2021
|
+
// 等しければ両者は一致するが、 違うと中心平均は隙間の中点から (lower.h - upper.h) / 4 だけ
|
|
2022
|
+
// ずれる。 狭い側の余白が CLEARANCE_NODE_LABEL (32) を下回り、 catalog で clearance error を
|
|
2023
|
+
// 11 件発生させていた (実測。 例 = topology TCP 5432 で ECS 側 21 / RDS 側 11)。
|
|
2024
|
+
//
|
|
2025
|
+
// 隙間そのものが label を収めるだけの高さを持つことは、 layoutNodes の
|
|
2026
|
+
// `requiredStackGapForEdgeLabels` が stack gap を先手拡張して保証する。
|
|
2027
|
+
const midY = (A.y + B.y) / 2;
|
|
2028
|
+
// CAR-573 SSOT ... vertical straight edge の label X = edge X + labelPathClearanceHorizontal(edge) + labelHalfW、
|
|
2029
|
+
// labelAnchor = "middle" に統一 (pill 内文字中央寄せ)。
|
|
2030
|
+
// pill 中心 X = A.x + labelPathClearanceHorizontal(edge) + labelHalfW、
|
|
2031
|
+
// pill 左端 = A.x + labelPathClearanceHorizontal(edge) で edge から solid=16 / dotted-flow=44 world 離れ (CAR-630 復帰)、
|
|
2032
|
+
// 水平 edge (pill 下端 → path Y = solid=16 / dotted-flow=44) と同一 SSOT を全方向適用。
|
|
2033
|
+
// 旧実装 (CAR-549) は A.x + labelInit (=26) で pill 左端 → path X = 26 world、 水平 8 world と非対称だった。
|
|
2034
|
+
// CAR-573 で全方向 pill 端 → path 実距離を LABEL_TO_PATH_CLEARANCE (solid) に統一、
|
|
2035
|
+
// CAR-601 で dotted-flow の MARKER_GLOW_RADIUS (28 world) 加算漏れ regression を
|
|
2036
|
+
// `labelPathClearanceHorizontal(edge)` helper で解消 (solid / dotted-flow の 2 分岐 SSOT 化)。
|
|
2037
|
+
// CAR-630 で SSOT 8 → 16 復帰、 solid=16 / dotted-flow=44 world に更新。
|
|
2038
|
+
// overlay:true (flowchart condition label 等) は label center を edge X に重ねる (labelX = A.x)、
|
|
2039
|
+
// 垂直 dotted-flow edge は pill 左端 → path X = 44 world (glow radius 28 加算) で marker 通過帯を確保。
|
|
2040
|
+
return {
|
|
2041
|
+
d: `M ${A.x} ${A.y} L ${B.x} ${B.y}`,
|
|
2042
|
+
labelX: edge?.overlay ? A.x : A.x + labelPathClearanceHorizontal(edge) + labelHalfW,
|
|
2043
|
+
labelY: midY,
|
|
2044
|
+
labelAnchor: "middle",
|
|
2045
|
+
// overlay は path 上に重ねる semantic なので左右の概念を持たない。
|
|
2046
|
+
labelAltX: edge?.overlay ? undefined : A.x - labelPathClearanceHorizontal(edge) - labelHalfW,
|
|
2047
|
+
};
|
|
2048
|
+
}
|
|
2049
|
+
if (verticalPair) {
|
|
2050
|
+
// 中間 node と水平 segment (y=midY) が衝突するなら midY を上下にずらす。
|
|
2051
|
+
// mind-demo で root(x=180,y=203) → launch(x=620,y=703) の path midY=453 が
|
|
2052
|
+
// 中間 node ui(cx=620, cy=453) を貫通する crashing bug の解消 (routing v7 追加)。
|
|
2053
|
+
let midY = (A.y + B.y) / 2;
|
|
2054
|
+
const xDir = B.x > A.x ? 1 : -1;
|
|
2055
|
+
const xRange = [Math.min(A.x, B.x), Math.max(A.x, B.x)];
|
|
2056
|
+
/** 水平部の高さ `my` を帯に含み、 かつ水平部の X 範囲と重なる node。 */
|
|
2057
|
+
const midBlockersAt = (my: number): LaidNode[] =>
|
|
2058
|
+
obstacles.filter((n) => {
|
|
2059
|
+
const nLeft = n.cx - n.w / 2;
|
|
2060
|
+
const nRight = n.cx + n.w / 2;
|
|
2061
|
+
const nTop = n.cy - n.h / 2;
|
|
2062
|
+
const nBottom = n.cy + n.h / 2;
|
|
2063
|
+
return nRight > xRange[0]! && nLeft < xRange[1]! && my >= nTop && my <= nBottom;
|
|
2064
|
+
});
|
|
2065
|
+
/**
|
|
2066
|
+
* 中間 node と衝突しなくなるまで、 node 下端 + gap へ midY を退避する。
|
|
2067
|
+
*
|
|
2068
|
+
* 1 個だけ避けると、 背の高い 2 個目を貫通する (Issue #231)。 全 blocker の下端の最大値まで
|
|
2069
|
+
* 退避し、 退避先で新たに重なった node があれば繰り返す。
|
|
2070
|
+
*
|
|
2071
|
+
* 退避前の midY は必ずいずれかの blocker の帯の中にあるので、 退避後はその下端を越える。
|
|
2072
|
+
* midY は毎回いずれかの下端を越えて単調に下がり、 同じ下端を 2 度越えることはない。
|
|
2073
|
+
* したがって反復回数は相異なる下端の数を超えない。 obstacles の数で縛れば必ず止まる。
|
|
2074
|
+
*/
|
|
2075
|
+
const retreatBelowMidBlockers = (my: number): number => {
|
|
2076
|
+
const gap = 40;
|
|
2077
|
+
for (let iter = 0; iter <= obstacles.length; iter++) {
|
|
2078
|
+
const hits = midBlockersAt(my);
|
|
2079
|
+
if (hits.length === 0) break;
|
|
2080
|
+
my = Math.max(...hits.map((n) => n.cy + n.h / 2)) + gap;
|
|
2081
|
+
}
|
|
2082
|
+
return my;
|
|
2083
|
+
};
|
|
2084
|
+
midY = retreatBelowMidBlockers(midY);
|
|
2085
|
+
// 経路が node を貫かない高さ。 後段の label 退避が範囲外へ出た時の戻り先に使う。
|
|
2086
|
+
const pathSafeMidY = midY;
|
|
2087
|
+
// routing v8 = label 高さ (label anchor middle + midY 帯 sideShift) が obstacle 縦帯と交差する場合、
|
|
2088
|
+
// midY を obstacle 外へ retreat。 horizontalPair の midX retreat の 90 度回転版。
|
|
2089
|
+
if (labelHalfW > 0) {
|
|
2090
|
+
const yDir = B.y > A.y ? 1 : -1;
|
|
2091
|
+
const labelBoxH = 36;
|
|
2092
|
+
const labelHalfH = labelBoxH / 2;
|
|
2093
|
+
const intersectsV = (obs: LaidNode, my: number): boolean => {
|
|
2094
|
+
if (obs.id === from.id || obs.id === to.id) return false;
|
|
2095
|
+
const oLeft = obs.cx - obs.w / 2;
|
|
2096
|
+
const oRight = obs.cx + obs.w / 2;
|
|
2097
|
+
const oTop = obs.cy - obs.h / 2;
|
|
2098
|
+
const oBottom = obs.cy + obs.h / 2;
|
|
2099
|
+
// label 中点は水平 segment 中央付近 (A.x → B.x の間) に配置される。
|
|
2100
|
+
// label bbox = 中心 ((A.x+B.x)/2, my) + width labelBoxW / height labelBoxH。
|
|
2101
|
+
const labelCX = (A.x + B.x) / 2;
|
|
2102
|
+
const labelLeft = labelCX - labelHalfW - CLEARANCE_NODE_LABEL;
|
|
2103
|
+
const labelRight = labelCX + labelHalfW + CLEARANCE_NODE_LABEL;
|
|
2104
|
+
const labelTop = my - labelHalfH - CLEARANCE_NODE_LABEL;
|
|
2105
|
+
const labelBottom = my + labelHalfH + CLEARANCE_NODE_LABEL;
|
|
2106
|
+
if (oRight <= labelLeft || oLeft >= labelRight) return false;
|
|
2107
|
+
if (oBottom <= labelTop || oTop >= labelBottom) return false;
|
|
2108
|
+
return true;
|
|
2109
|
+
};
|
|
2110
|
+
let iter = 0;
|
|
2111
|
+
while (iter < 12) {
|
|
2112
|
+
const hit = labelObstacles.find((o) => intersectsV(o, midY));
|
|
2113
|
+
if (!hit) break;
|
|
2114
|
+
const hitTop = hit.cy - hit.h / 2;
|
|
2115
|
+
const hitBottom = hit.cy + hit.h / 2;
|
|
2116
|
+
const retreatDown = hitBottom + labelHalfH + CLEARANCE_NODE_LABEL + 4;
|
|
2117
|
+
const retreatUp = hitTop - labelHalfH - CLEARANCE_NODE_LABEL - 4;
|
|
2118
|
+
const dDown = Math.abs(retreatDown - midY);
|
|
2119
|
+
const dUp = Math.abs(retreatUp - midY);
|
|
2120
|
+
// yDir 側 (target 方向) 優先で retreat
|
|
2121
|
+
midY = yDir > 0
|
|
2122
|
+
? (dDown <= dUp ? retreatDown : retreatUp)
|
|
2123
|
+
: (dUp <= dDown ? retreatUp : retreatDown);
|
|
2124
|
+
iter++;
|
|
2125
|
+
}
|
|
2126
|
+
// A.y と B.y の中間から大きく離れると path 全体が折れて視覚的に不自然、
|
|
2127
|
+
// A / B 間の外に retreat した場合は clamp (path 上下端 + gap 内)
|
|
2128
|
+
const yMin = Math.min(A.y, B.y) + 30;
|
|
2129
|
+
const yMax = Math.max(A.y, B.y) - 30;
|
|
2130
|
+
if (midY < yMin || midY > yMax) {
|
|
2131
|
+
// 経路が node を貫かない高さへ戻す (残 overlap は post-pass 2 chain shift が解消)。
|
|
2132
|
+
//
|
|
2133
|
+
// 中点へ戻すと、 前段で避けた node の帯に水平部が入り直す (Issue #231)。 label が
|
|
2134
|
+
// 中心からずれることより、 経路が node を貫くことの方が重い。
|
|
2135
|
+
midY = pathSafeMidY;
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
// label 退避が水平部を node の帯へ戻した可能性がある。 経路の安全を最後に確定させる。
|
|
2139
|
+
midY = retreatBelowMidBlockers(midY);
|
|
2140
|
+
const corner = 14;
|
|
2141
|
+
// CAR-429 で offset=0 固定 (旧: offset 符号で midY - 50 / midY - 90 分岐) 削除。
|
|
2142
|
+
// CAR-468 SSOT ... 旧 midY - 50 magic number を LABEL_INIT_CLEARANCE (26、 CAR-570 で 34 → 26 縮小) に置換、
|
|
2143
|
+
// path 中点上方 34 world = pill 端 と path 実距離 16 world (spec 準拠)。
|
|
2144
|
+
// CAR-518 SSOT ... dotted-flow は 62 world (marker glow radius 分拡張) を labelInit で参照。
|
|
2145
|
+
// fan-in / fan-out の分散は chain 伝搬 shift が担当。
|
|
2146
|
+
const labelY = midY - labelInit;
|
|
2147
|
+
return {
|
|
2148
|
+
d: `M ${A.x} ${A.y}
|
|
2149
|
+
L ${A.x} ${midY - corner}
|
|
2150
|
+
Q ${A.x} ${midY}, ${A.x + xDir * corner} ${midY}
|
|
2151
|
+
L ${B.x - xDir * corner} ${midY}
|
|
2152
|
+
Q ${B.x} ${midY}, ${B.x} ${midY + corner}
|
|
2153
|
+
L ${B.x} ${B.y}`,
|
|
2154
|
+
labelX: (A.x + B.x) / 2,
|
|
2155
|
+
labelY,
|
|
2156
|
+
labelAnchor: "middle",
|
|
2157
|
+
};
|
|
2158
|
+
}
|
|
2159
|
+
// top-top routing ... back transition (FSM 等で fromSide=top + toSide=top で明示 detour 経路を作る)。
|
|
2160
|
+
// 両 node 上端から path を上方 detour させ、 forward path (node 中心線上) と重ならないよう描画。
|
|
2161
|
+
// routing v7 = detour 高さを 200 → 100 に縮小。 200px 上に飛ぶと state から遠く label が「浮いて」 見える。
|
|
2162
|
+
// 100px = state h/2 (75) + margin 25、 forward path (state 中心線) からは h/2 + 100 = 175px 離れて overlap 回避。
|
|
2163
|
+
if (topTopPair) {
|
|
2164
|
+
const detourY = Math.min(A.y, B.y) - 100;
|
|
2165
|
+
const corner = 14;
|
|
2166
|
+
const xDir = B.x > A.x ? 1 : -1;
|
|
2167
|
+
return {
|
|
2168
|
+
d: `M ${A.x} ${A.y}
|
|
2169
|
+
L ${A.x} ${detourY + corner}
|
|
2170
|
+
Q ${A.x} ${detourY}, ${A.x + xDir * corner} ${detourY}
|
|
2171
|
+
L ${B.x - xDir * corner} ${detourY}
|
|
2172
|
+
Q ${B.x} ${detourY}, ${B.x} ${detourY + corner}
|
|
2173
|
+
L ${B.x} ${B.y}`,
|
|
2174
|
+
labelX: (A.x + B.x) / 2,
|
|
2175
|
+
// CAR-468 SSOT ... 旧 detourY - 30 magic number を LABEL_INIT_CLEARANCE (26、 CAR-570 で 34 → 26 縮小) に置換。
|
|
2176
|
+
// CAR-518 SSOT ... dotted-flow は 62 world (marker glow radius 分拡張) を labelInit で参照。
|
|
2177
|
+
// top-top detour の水平 segment 上方 labelInit world = spec 準拠距離。
|
|
2178
|
+
labelY: detourY - labelInit,
|
|
2179
|
+
labelAnchor: "middle",
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
const midX = fromSide === "left" || fromSide === "right" ? B.x : A.x;
|
|
2183
|
+
const midY = fromSide === "top" || fromSide === "bottom" ? B.y : A.y;
|
|
2184
|
+
return {
|
|
2185
|
+
d: `M ${A.x} ${A.y} L ${midX} ${midY} L ${B.x} ${B.y}`,
|
|
2186
|
+
labelX: (A.x + B.x) / 2,
|
|
2187
|
+
// CAR-468 SSOT ... 旧 - 32 magic number を LABEL_INIT_CLEARANCE (26、 CAR-570 で 34 → 26 縮小) に置換。
|
|
2188
|
+
// CAR-518 SSOT ... dotted-flow は 62 world (marker glow radius 分拡張) を labelInit で参照。
|
|
2189
|
+
// fallback route の label は path 最上端から labelInit world 上方 = spec 準拠距離。
|
|
2190
|
+
labelY: Math.min(A.y, B.y) - labelInit,
|
|
2191
|
+
labelAnchor: "middle",
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/**
|
|
2196
|
+
* 弧を確定させた後に、 同じ節の組を結ぶ辺の label を「兄弟の線を跨がない位置」 へ置き直す (#376)。
|
|
2197
|
+
*
|
|
2198
|
+
* `layoutEdges` の中では線がまだ直線で、 弧は `bowSharedStraightPaths` が後から付ける。 直線を
|
|
2199
|
+
* 前提に置いた label は、 弧が付いた後に貫かれる (実測 = 往復 2 本 / 4 本、 片方向 2 本 / 4 本)。
|
|
2200
|
+
*
|
|
2201
|
+
* 逆に label を先に線から離すと、 弧を膨らませる判定が label を障害物と見なして弧を諦める。
|
|
2202
|
+
* そのため順序は「弧を先に確定 (自群の label は障害物に数えない) → label を置き直す」 にする。
|
|
2203
|
+
* 呼ぶ場所は `layout.ts` の `bowSharedStraightPaths` の直後。
|
|
2204
|
+
*
|
|
2205
|
+
* 動かす単位と避ける対象は分ける。
|
|
2206
|
+
*
|
|
2207
|
+
* - **動かす単位** = 横に重なる label の塊。 横に離れている label を一緒に動かすと、 動かす
|
|
2208
|
+
* 必要のない label まで自分の線から引き剥がす
|
|
2209
|
+
* - **避ける対象** = 同じ節の組の **全ての線**。 塊の中の線だけ見ると、 `labelOffsetX` で横に
|
|
2210
|
+
* 逃がした兄弟の線を跨いだままになる
|
|
2211
|
+
*
|
|
2212
|
+
* 1 つしかない塊も対象にする。 兄弟の線を跨いでいれば動かす余地がある。
|
|
2213
|
+
*
|
|
2214
|
+
* 置く先の良し悪しは 2 段で見る。 まず **跨る線の数**、 同じなら **動かす量**。 跨りを直すのが
|
|
2215
|
+
* 目的なので数を先に見る。 数が同じなら動かさない置き方が選ばれる。
|
|
2216
|
+
*
|
|
2217
|
+
* 以下 2 つは満たさない置き方を捨てる。
|
|
2218
|
+
*
|
|
2219
|
+
* - **節との間隔を今より悪くしない**。 跨りを直すために節へ乗り上げるのは差し引きで損
|
|
2220
|
+
* (実測 = 縦の平行 2 本で節との間隔が 32 の要求に対して 21 まで詰まった)
|
|
2221
|
+
* - **節の組の中での並び順を変えない**。 `bowSharedStraightPaths` は label の並び順で弧を振り
|
|
2222
|
+
* 分けるため ([#374](https://github.com/cardene777/cdl/issues/374))、 順序が変わると弧と
|
|
2223
|
+
* label の対応が崩れる。 塊ごとに独立して動かすと塊をまたいで順序が入れ替わり得る
|
|
2224
|
+
*
|
|
2225
|
+
* @param edges 確定済 path を持つ辺。 `labelY` のみを書き換える
|
|
2226
|
+
* @param nodes 節。 間隔が悪化する置き方を捨てるために使う
|
|
2227
|
+
*/
|
|
2228
|
+
export function repositionParallelLabels(edges: LaidEdge[], nodes: LaidNode[]): void {
|
|
2229
|
+
// 図の全ての線 (実際に描かれる形)。 1 度だけ開いて使い回す。
|
|
2230
|
+
const allSegs = new Map(
|
|
2231
|
+
edges.map((e) => [e.id, flattenPathSegments(extractPathSegments(e.d))] as const),
|
|
2232
|
+
);
|
|
2233
|
+
/** 線の X 範囲。 label の横幅と重ならない線は判定から落とすのに使う。 */
|
|
2234
|
+
const xRange = new Map<string, { min: number; max: number }>();
|
|
2235
|
+
for (const [id, segs] of allSegs) {
|
|
2236
|
+
let min = Infinity;
|
|
2237
|
+
let max = -Infinity;
|
|
2238
|
+
for (const s of segs) {
|
|
2239
|
+
min = Math.min(min, s.x1, s.x2);
|
|
2240
|
+
max = Math.max(max, s.x1, s.x2);
|
|
2241
|
+
}
|
|
2242
|
+
xRange.set(id, { min, max });
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
const byPair = new Map<string, LaidEdge[]>();
|
|
2246
|
+
for (const e of edges) {
|
|
2247
|
+
if (!hasRenderedLabel(e)) continue;
|
|
2248
|
+
const key = e.from < e.to ? `${e.from}::${e.to}` : `${e.to}::${e.from}`;
|
|
2249
|
+
const list = byPair.get(key) ?? [];
|
|
2250
|
+
list.push(e);
|
|
2251
|
+
byPair.set(key, list);
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
for (const pair of byPair.values()) {
|
|
2255
|
+
if (pair.length < 2) continue;
|
|
2256
|
+
// 弧を振り分けた時の並び順。 置き直しでこれを変えない。 順序の定義は
|
|
2257
|
+
// `compareSharedPairOrder` に集めてあり、 `bowSharedStraightPaths` も同じものを使う。
|
|
2258
|
+
const pairOrder = [...pair].sort(compareSharedPairOrder);
|
|
2259
|
+
|
|
2260
|
+
// 横に重なる label ごとに塊を作る (1 つだけの塊も含む)。
|
|
2261
|
+
const boxes = pair.map((e) => computeLabelBBoxWorld(e));
|
|
2262
|
+
const componentOf = pair.map((_, i) => i);
|
|
2263
|
+
for (let i = 0; i < pair.length; i++) {
|
|
2264
|
+
for (let j = i + 1; j < pair.length; j++) {
|
|
2265
|
+
const a = boxes[i]!;
|
|
2266
|
+
const b = boxes[j]!;
|
|
2267
|
+
const apart =
|
|
2268
|
+
Math.abs(a.x + a.w / 2 - (b.x + b.w / 2)) >= (a.w + b.w) / 2 + CLEARANCE_LABEL_LABEL;
|
|
2269
|
+
if (apart) continue;
|
|
2270
|
+
const from = componentOf[j]!;
|
|
2271
|
+
const to = componentOf[i]!;
|
|
2272
|
+
if (from === to) continue;
|
|
2273
|
+
for (let k = 0; k < componentOf.length; k++) {
|
|
2274
|
+
if (componentOf[k] === from) componentOf[k] = to;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
for (const root of new Set(componentOf)) {
|
|
2280
|
+
const members = pair.filter((_, i) => componentOf[i] === root);
|
|
2281
|
+
const placed = placeAvoidingSiblingPaths({
|
|
2282
|
+
members,
|
|
2283
|
+
pair,
|
|
2284
|
+
pairOrder,
|
|
2285
|
+
nodes,
|
|
2286
|
+
allEdges: edges,
|
|
2287
|
+
allSegs,
|
|
2288
|
+
xRange,
|
|
2289
|
+
});
|
|
2290
|
+
for (let i = 0; i < members.length; i++) members[i]!.labelY = placed[i]!;
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
/**
|
|
2296
|
+
* 兄弟の線を跨がない位置に label を積む (#376)。
|
|
2297
|
+
*
|
|
2298
|
+
* 各 label に「兄弟の線が通る帯」 を禁止区間として与える。 自分の線は除く = label が自分の線の
|
|
2299
|
+
* 上に乗るのは通常の配置で、 描画側も衝突扱いしない。
|
|
2300
|
+
*
|
|
2301
|
+
* 帯に数えるのは **label の横幅の下を通る部分だけ**。 線を label の X 区間で切ってから Y を取る。
|
|
2302
|
+
* 切らずに端点の Y を取ると、 label の外まで伸びた斜線が帯を過大にする (実測 = 同じ形の斜線を
|
|
2303
|
+
* 範囲外へ伸ばしただけで配置が [132,232] から [-32,200] に動いた)。
|
|
2304
|
+
*
|
|
2305
|
+
* 解き方は「帯の上に何本、 下に何本」 の分け方を全通り試す。 分け目は並び順を保つ (上側は先頭
|
|
2306
|
+
* から、 下側は末尾まで) ので候補は本数 + 1 通りしかない。 各側は `spreadWithMinimalShift` で
|
|
2307
|
+
* 間隔を満たしてから、 帯に入らない位置まで塊ごと平行移動する。 塊を動かしても間隔は変わらない
|
|
2308
|
+
* ので、 移動後も間隔は満たしたままになる。
|
|
2309
|
+
*
|
|
2310
|
+
* 「禁止区間に入った label を近い縁へ動かして置き直す」 反復は採らない。 置き直すたびに
|
|
2311
|
+
* `spreadWithMinimalShift` が塊を帯の中心へ引き戻すため、 4 本の図で振動して収束しない (実測)。
|
|
2312
|
+
*/
|
|
2313
|
+
function placeAvoidingSiblingPaths(args: {
|
|
2314
|
+
members: LaidEdge[];
|
|
2315
|
+
pair: LaidEdge[];
|
|
2316
|
+
pairOrder: LaidEdge[];
|
|
2317
|
+
nodes: LaidNode[];
|
|
2318
|
+
allEdges: LaidEdge[];
|
|
2319
|
+
allSegs: Map<string, Array<{ x1: number; y1: number; x2: number; y2: number }>>;
|
|
2320
|
+
xRange: Map<string, { min: number; max: number }>;
|
|
2321
|
+
}): number[] {
|
|
2322
|
+
const { members, pair, pairOrder, nodes, allEdges, allSegs, xRange } = args;
|
|
2323
|
+
const sizes = members.map((e) => labelPillH(e.sub));
|
|
2324
|
+
const wants = members.map((e) => e.labelY);
|
|
2325
|
+
const boxes = members.map((e) => computeLabelBBoxWorld(e));
|
|
2326
|
+
const all = members.map((_, i) => i);
|
|
2327
|
+
const spread = (idx: number[]): number[] =>
|
|
2328
|
+
spreadWithMinimalShift(
|
|
2329
|
+
idx.map((i) => ({ want: wants[i]!, size: sizes[i]! })),
|
|
2330
|
+
CLEARANCE_LABEL_LABEL,
|
|
2331
|
+
);
|
|
2332
|
+
|
|
2333
|
+
/** 線分を X 区間 [left, right] で切った時の Y 範囲。 交わらなければ null。 */
|
|
2334
|
+
const clipY = (
|
|
2335
|
+
s: { x1: number; y1: number; x2: number; y2: number },
|
|
2336
|
+
left: number,
|
|
2337
|
+
right: number,
|
|
2338
|
+
): { lo: number; hi: number } | null => {
|
|
2339
|
+
const sMin = Math.min(s.x1, s.x2);
|
|
2340
|
+
const sMax = Math.max(s.x1, s.x2);
|
|
2341
|
+
if (sMax < left || sMin > right) return null;
|
|
2342
|
+
const dx = s.x2 - s.x1;
|
|
2343
|
+
if (dx === 0) return { lo: Math.min(s.y1, s.y2), hi: Math.max(s.y1, s.y2) };
|
|
2344
|
+
const at = (x: number): number => s.y1 + ((x - s.x1) / dx) * (s.y2 - s.y1);
|
|
2345
|
+
const a = at(Math.max(sMin, left));
|
|
2346
|
+
const b = at(Math.min(sMax, right));
|
|
2347
|
+
return { lo: Math.min(a, b), hi: Math.max(a, b) };
|
|
2348
|
+
};
|
|
2349
|
+
|
|
2350
|
+
/** 各 label にとっての禁止区間。 節の組の全ての線を見る (塊の中だけでは足りない)。 */
|
|
2351
|
+
const bands = members.map((e, i) => {
|
|
2352
|
+
const box = boxes[i]!;
|
|
2353
|
+
const left = box.x;
|
|
2354
|
+
const right = box.x + box.w;
|
|
2355
|
+
let lo = Infinity;
|
|
2356
|
+
let hi = -Infinity;
|
|
2357
|
+
for (const other of pair) {
|
|
2358
|
+
if (other.id === e.id) continue;
|
|
2359
|
+
for (const seg of allSegs.get(other.id) ?? []) {
|
|
2360
|
+
const clipped = clipY(seg, left, right);
|
|
2361
|
+
if (!clipped) continue;
|
|
2362
|
+
lo = Math.min(lo, clipped.lo);
|
|
2363
|
+
hi = Math.max(hi, clipped.hi);
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
if (lo > hi) return null;
|
|
2367
|
+
const margin = sizes[i]! / 2 + CLEARANCE_PATH_LABEL;
|
|
2368
|
+
return { lo: lo - margin, hi: hi + margin };
|
|
2369
|
+
});
|
|
2370
|
+
|
|
2371
|
+
const nodeRects = nodes.map((n) => ({ x: n.cx - n.w / 2, y: n.cy - n.h / 2, w: n.w, h: n.h }));
|
|
2372
|
+
/** label を y に置いた時の、 最も近い節との隙間。 */
|
|
2373
|
+
const nodeGapAt = (i: number, y: number): number => {
|
|
2374
|
+
const box = boxes[i]!;
|
|
2375
|
+
const top = y - box.h / 2;
|
|
2376
|
+
let min = Infinity;
|
|
2377
|
+
for (const r of nodeRects) {
|
|
2378
|
+
const dx = Math.max(0, r.x - (box.x + box.w), box.x - (r.x + r.w));
|
|
2379
|
+
const dy = Math.max(0, r.y - (top + box.h), top - (r.y + r.h));
|
|
2380
|
+
min = Math.min(min, Math.hypot(dx, dy));
|
|
2381
|
+
}
|
|
2382
|
+
return min;
|
|
2383
|
+
};
|
|
2384
|
+
|
|
2385
|
+
// 判定に関わり得る線だけを残す (label の横幅と X が重なるもの)。
|
|
2386
|
+
const nearby = members.map((e, i) => {
|
|
2387
|
+
const box = boxes[i]!;
|
|
2388
|
+
return allEdges.filter((o) => {
|
|
2389
|
+
if (o.id === e.id) return false;
|
|
2390
|
+
const r = xRange.get(o.id);
|
|
2391
|
+
return r !== undefined && r.max >= box.x && r.min <= box.x + box.w;
|
|
2392
|
+
});
|
|
2393
|
+
});
|
|
2394
|
+
// 自分と共有する区間を除いた線分。 候補ごとに作り直すと同じ計算を繰り返すので先に作る。
|
|
2395
|
+
const probeSegs = members.map((e, i) => {
|
|
2396
|
+
const own = allSegs.get(e.id) ?? [];
|
|
2397
|
+
return nearby[i]!.map((other) => ({
|
|
2398
|
+
id: other.id,
|
|
2399
|
+
segs: segmentsExcludingOwnPath(allSegs.get(other.id) ?? [], own),
|
|
2400
|
+
})).filter((x) => x.segs.length > 0);
|
|
2401
|
+
});
|
|
2402
|
+
/** label を y に置いた時に貫いてくる線の id 集合。 自分の線と、 共有する区間は数えない。 */
|
|
2403
|
+
const crossSetAt = (i: number, y: number): Set<string> => {
|
|
2404
|
+
const box = boxes[i]!;
|
|
2405
|
+
const rect = { x: box.x, y: y - box.h / 2, w: box.w, h: box.h };
|
|
2406
|
+
const out = new Set<string>();
|
|
2407
|
+
for (const probe of probeSegs[i]!) {
|
|
2408
|
+
if (segmentsCrossRect(probe.segs, rect)) out.add(probe.id);
|
|
2409
|
+
}
|
|
2410
|
+
return out;
|
|
2411
|
+
};
|
|
2412
|
+
|
|
2413
|
+
const beforeGap = all.map((i) => nodeGapAt(i, wants[i]!));
|
|
2414
|
+
const beforeCross = all.map((i) => crossSetAt(i, wants[i]!));
|
|
2415
|
+
const beforeCrossTotal = beforeCross.reduce((acc, set) => acc + set.size, 0);
|
|
2416
|
+
// 節の組のうち、 この塊に含まれない label の現在位置 (並び順の確認に使う)。
|
|
2417
|
+
const outside = pair.filter((e) => !members.includes(e));
|
|
2418
|
+
|
|
2419
|
+
const order = [...all].sort((a, b) => wants[a]! - wants[b]!);
|
|
2420
|
+
const orderPreserved = (ys: number[]): boolean => {
|
|
2421
|
+
const merged = [
|
|
2422
|
+
...members.map((e, i) => ({ id: e.id, y: ys[i]! })),
|
|
2423
|
+
...outside.map((e) => ({ id: e.id, y: e.labelY })),
|
|
2424
|
+
].sort((a, b) => a.y - b.y || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
2425
|
+
return merged.every((m, i) => m.id === pairOrder[i]!.id);
|
|
2426
|
+
};
|
|
2427
|
+
|
|
2428
|
+
let best: { cross: number; move: number; ys: number[] } | null = null;
|
|
2429
|
+
const consider = (ys: number[]): void => {
|
|
2430
|
+
if (
|
|
2431
|
+
all.some((i) => {
|
|
2432
|
+
const gap = nodeGapAt(i, ys[i]!);
|
|
2433
|
+
return gap < CLEARANCE_NODE_LABEL && gap < beforeGap[i]!;
|
|
2434
|
+
})
|
|
2435
|
+
) {
|
|
2436
|
+
return;
|
|
2437
|
+
}
|
|
2438
|
+
if (!orderPreserved(ys)) return;
|
|
2439
|
+
// 元は跨っていなかった線を新たに跨ぐ置き方は捨てる。 数が減っていても、 跨る相手が
|
|
2440
|
+
// 入れ替わっただけなら直したことにならない。
|
|
2441
|
+
let cross = 0;
|
|
2442
|
+
for (const i of all) {
|
|
2443
|
+
const after = crossSetAt(i, ys[i]!);
|
|
2444
|
+
for (const id of after) if (!beforeCross[i]!.has(id)) return;
|
|
2445
|
+
cross += after.size;
|
|
2446
|
+
}
|
|
2447
|
+
const move = all.reduce((acc, i) => acc + (ys[i]! - wants[i]!) ** 2, 0);
|
|
2448
|
+
if (!best || cross < best.cross || (cross === best.cross && move < best.move)) {
|
|
2449
|
+
best = { cross, move, ys };
|
|
2450
|
+
}
|
|
2451
|
+
};
|
|
2452
|
+
|
|
2453
|
+
// 動かさない置き方も候補に入れる (跨りが無いならこれが最善)。
|
|
2454
|
+
consider([...wants]);
|
|
2455
|
+
if (bands.some((b) => b !== null)) {
|
|
2456
|
+
for (let split = 0; split <= order.length; split++) {
|
|
2457
|
+
const above = order.slice(0, split);
|
|
2458
|
+
const below = order.slice(split);
|
|
2459
|
+
const out = new Array<number>(members.length);
|
|
2460
|
+
for (const [idx, side] of [
|
|
2461
|
+
[above, "above"],
|
|
2462
|
+
[below, "below"],
|
|
2463
|
+
] as const) {
|
|
2464
|
+
if (idx.length === 0) continue;
|
|
2465
|
+
const ys = spread(idx);
|
|
2466
|
+
// 帯に入らない位置まで塊ごと動かす。 必要な移動量が最大の label に合わせる。
|
|
2467
|
+
let shift = 0;
|
|
2468
|
+
for (let k = 0; k < idx.length; k++) {
|
|
2469
|
+
const band = bands[idx[k]!];
|
|
2470
|
+
if (!band) continue;
|
|
2471
|
+
shift =
|
|
2472
|
+
side === "above" ? Math.min(shift, band.lo - ys[k]!) : Math.max(shift, band.hi - ys[k]!);
|
|
2473
|
+
}
|
|
2474
|
+
for (let k = 0; k < idx.length; k++) out[idx[k]!] = ys[k]! + shift;
|
|
2475
|
+
}
|
|
2476
|
+
// 上側の末尾と下側の先頭が足りなければ、 下側を塊ごとさらに下げる。 帯を挟んでいても
|
|
2477
|
+
// pill どうしの間隔は要る (実測 = 帯を挟んで 100 しか空かず、 要求の 104 に届かない図)。
|
|
2478
|
+
if (above.length > 0 && below.length > 0) {
|
|
2479
|
+
const lastIdx = above[above.length - 1]!;
|
|
2480
|
+
const firstIdx = below[0]!;
|
|
2481
|
+
const need = (sizes[lastIdx]! + sizes[firstIdx]!) / 2 + CLEARANCE_LABEL_LABEL;
|
|
2482
|
+
const deficit = need - (out[firstIdx]! - out[lastIdx]!);
|
|
2483
|
+
if (deficit > 0) for (const i of below) out[i]! += deficit;
|
|
2484
|
+
}
|
|
2485
|
+
consider(out);
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
// 間隔だけを満たす置き方も候補に入れる (帯が無い / 帯を避ける置き方が全て捨てられた時)。
|
|
2489
|
+
const plain = spread(all);
|
|
2490
|
+
const byIdx = new Array<number>(members.length);
|
|
2491
|
+
for (let k = 0; k < all.length; k++) byIdx[all[k]!] = plain[k]!;
|
|
2492
|
+
consider(byIdx);
|
|
2493
|
+
|
|
2494
|
+
if (!best) return [...wants];
|
|
2495
|
+
const chosen: { cross: number; move: number; ys: number[] } = best;
|
|
2496
|
+
// 跨りが減らないなら動かさない (無駄に動かして自分の線から離さない)。
|
|
2497
|
+
return chosen.cross < beforeCrossTotal || chosen.move === 0 ? chosen.ys : [...wants];
|
|
2498
|
+
}
|