@diagrammo/dgmo 0.26.0 → 0.28.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/README.md +3 -3
- package/dist/advanced.cjs +5651 -3193
- package/dist/advanced.d.cts +272 -58
- package/dist/advanced.d.ts +272 -58
- package/dist/advanced.js +5650 -3186
- package/dist/auto.cjs +5511 -3070
- package/dist/auto.js +116 -137
- package/dist/auto.mjs +5510 -3069
- package/dist/cli.cjs +168 -189
- package/dist/editor.cjs +4 -0
- package/dist/editor.js +4 -0
- package/dist/highlight.cjs +4 -0
- package/dist/highlight.js +4 -0
- package/dist/index.cjs +5536 -3072
- package/dist/index.d.cts +33 -8
- package/dist/index.d.ts +33 -8
- package/dist/index.js +5535 -3071
- package/dist/internal.cjs +5651 -3193
- package/dist/internal.d.cts +272 -58
- package/dist/internal.d.ts +272 -58
- package/dist/internal.js +5650 -3186
- package/dist/map-data/PROVENANCE.json +1 -1
- package/dist/map-data/airport-collisions.json +1 -0
- package/dist/map-data/airports.json +1 -0
- package/docs/language-reference.md +68 -18
- package/gallery/fixtures/boxes-and-lines-diverging.dgmo +15 -0
- package/gallery/fixtures/map-choropleth-diverging.dgmo +9 -0
- package/gallery/fixtures/map-region-values.dgmo +13 -0
- package/gallery/fixtures/map-subnational-zoom.dgmo +12 -0
- package/gallery/fixtures/map-tagged-legs.dgmo +16 -0
- package/gallery/fixtures/map-undirected-edges.dgmo +12 -0
- package/package.json +7 -3
- package/src/advanced.ts +1 -6
- package/src/auto/index.ts +1 -1
- package/src/boxes-and-lines/layout-layered.ts +722 -0
- package/src/boxes-and-lines/layout-search.ts +1200 -0
- package/src/boxes-and-lines/layout.ts +202 -571
- package/src/boxes-and-lines/parser.ts +43 -8
- package/src/boxes-and-lines/renderer.ts +223 -96
- package/src/boxes-and-lines/types.ts +9 -2
- package/src/c4/layout.ts +14 -32
- package/src/c4/parser.ts +9 -5
- package/src/c4/renderer.ts +34 -39
- package/src/class/layout.ts +118 -18
- package/src/class/parser.ts +35 -0
- package/src/class/renderer.ts +58 -2
- package/src/class/types.ts +3 -0
- package/src/cli.ts +4 -4
- package/src/completion.ts +26 -12
- package/src/cycle/layout.ts +55 -72
- package/src/cycle/renderer.ts +11 -6
- package/src/d3.ts +78 -117
- package/src/diagnostics.ts +16 -0
- package/src/echarts.ts +46 -33
- package/src/editor/keywords.ts +4 -0
- package/src/er/layout.ts +114 -22
- package/src/er/parser.ts +28 -0
- package/src/er/renderer.ts +55 -2
- package/src/er/types.ts +3 -0
- package/src/gantt/renderer.ts +46 -38
- package/src/gantt/resolver.ts +9 -2
- package/src/graph/edge-spline.ts +29 -0
- package/src/graph/flowchart-parser.ts +34 -1
- package/src/graph/flowchart-renderer.ts +78 -64
- package/src/graph/layout.ts +206 -23
- package/src/graph/notes.ts +21 -0
- package/src/graph/state-parser.ts +26 -1
- package/src/graph/state-renderer.ts +78 -64
- package/src/graph/types.ts +13 -0
- package/src/index.ts +1 -1
- package/src/infra/layout.ts +46 -26
- package/src/infra/renderer.ts +16 -7
- package/src/journey-map/layout.ts +38 -49
- package/src/journey-map/renderer.ts +22 -45
- package/src/kanban/renderer.ts +15 -6
- package/src/label-layout.ts +3 -3
- package/src/map/completion.ts +77 -22
- package/src/map/context-labels.ts +101 -25
- package/src/map/data/PROVENANCE.json +1 -1
- package/src/map/data/airport-collisions.json +1 -0
- package/src/map/data/airports.json +1 -0
- package/src/map/data/types.ts +19 -0
- package/src/map/layout.ts +1212 -96
- package/src/map/legend-band.ts +2 -2
- package/src/map/load-data.ts +10 -1
- package/src/map/parser.ts +61 -32
- package/src/map/renderer.ts +284 -12
- package/src/map/resolved-types.ts +15 -1
- package/src/map/resolver.ts +132 -12
- package/src/map/types.ts +28 -8
- package/src/migrate/embedded.ts +9 -7
- package/src/mindmap/text-wrap.ts +13 -14
- package/src/org/layout.ts +19 -17
- package/src/org/renderer.ts +11 -4
- package/src/palettes/color-utils.ts +82 -21
- package/src/palettes/index.ts +0 -19
- package/src/palettes/registry.ts +1 -1
- package/src/palettes/types.ts +2 -2
- package/src/pert/layout.ts +48 -40
- package/src/pert/renderer.ts +30 -43
- package/src/pyramid/renderer.ts +4 -5
- package/src/raci/renderer.ts +34 -68
- package/src/render.ts +1 -1
- package/src/ring/renderer.ts +1 -2
- package/src/sequence/parser.ts +100 -22
- package/src/sequence/renderer.ts +75 -50
- package/src/sitemap/layout.ts +27 -19
- package/src/sitemap/renderer.ts +12 -5
- package/src/tech-radar/renderer.ts +11 -35
- package/src/utils/arrow-markers.ts +51 -0
- package/src/utils/fit-canvas.ts +64 -0
- package/src/utils/legend-constants.ts +8 -54
- package/src/utils/legend-d3.ts +10 -7
- package/src/utils/legend-layout.ts +7 -4
- package/src/utils/legend-types.ts +10 -4
- package/src/utils/note-box/constants.ts +25 -0
- package/src/utils/note-box/index.ts +11 -0
- package/src/utils/note-box/metrics.ts +90 -0
- package/src/utils/note-box/svg.ts +331 -0
- package/src/utils/notes/bounds.ts +30 -0
- package/src/utils/notes/build.ts +131 -0
- package/src/utils/notes/index.ts +18 -0
- package/src/utils/notes/model.ts +19 -0
- package/src/utils/notes/parse.ts +131 -0
- package/src/utils/notes/place.ts +177 -0
- package/src/utils/notes/resolve.ts +88 -0
- package/src/utils/number-format.ts +36 -0
- package/src/utils/parsing.ts +41 -0
- package/src/utils/reserved-key-registry.ts +4 -0
- package/src/utils/text-measure.ts +122 -0
- package/src/wireframe/layout.ts +4 -2
- package/src/wireframe/renderer.ts +8 -6
- package/src/palettes/dracula.ts +0 -68
- package/src/palettes/gruvbox.ts +0 -85
- package/src/palettes/monokai.ts +0 -68
- package/src/palettes/one-dark.ts +0 -70
- package/src/palettes/rose-pine.ts +0 -84
- package/src/palettes/solarized.ts +0 -77
|
@@ -0,0 +1,1200 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Boxes and Lines — experimental "search" layout (behind a flag)
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// dagre placement + spline routing, with a multi-seed search over node
|
|
6
|
+
// orderings, scored on the actual spline geometry (curveBasis, sampled
|
|
7
|
+
// headlessly — no DOM) plus an optional stability term (drift from the
|
|
8
|
+
// previous layout). Picks the lowest combined-score ordering.
|
|
9
|
+
//
|
|
10
|
+
// Rationale: dagre placement reads better than orthogonal routing; crossings
|
|
11
|
+
// come from within-layer ordering, so we search orderings rather than reroute.
|
|
12
|
+
// Sync (dagre) — no ELK, no async.
|
|
13
|
+
|
|
14
|
+
import dagre from '@dagrejs/dagre';
|
|
15
|
+
import { line as d3line, curveBasis } from 'd3-shape';
|
|
16
|
+
import type { ParsedBoxesAndLines, BLGroup } from './types';
|
|
17
|
+
import {
|
|
18
|
+
computeNodeSize,
|
|
19
|
+
NODE_WIDTH,
|
|
20
|
+
NODE_HEIGHT,
|
|
21
|
+
type BLLayoutResult,
|
|
22
|
+
type BLLayoutEdge,
|
|
23
|
+
} from './layout';
|
|
24
|
+
import { layeredCandidates } from './layout-layered';
|
|
25
|
+
|
|
26
|
+
type Pt = { x: number; y: number };
|
|
27
|
+
|
|
28
|
+
// Default stability weight: combined = crossings + lambda · (meanDriftPx / 100).
|
|
29
|
+
// Only applies when previousPositions is supplied (re-layout on edit/collapse).
|
|
30
|
+
const DEFAULT_LAMBDA = 4;
|
|
31
|
+
|
|
32
|
+
// Adaptive escalation thresholds (see layoutBoxesAndLinesSearch). Only graphs
|
|
33
|
+
// whose base-budget badness is at least ESCALATE_THRESHOLD spend an extra seed
|
|
34
|
+
// batch; ESCALATE_MAX_N bounds it so huge graphs don't blow the time budget.
|
|
35
|
+
const ESCALATE_THRESHOLD = 4;
|
|
36
|
+
const ESCALATE_MAX_N = 45;
|
|
37
|
+
const ESCALATE_SEEDS = 18;
|
|
38
|
+
const ESCALATE_REFINE = 10;
|
|
39
|
+
|
|
40
|
+
function rng(s: number) {
|
|
41
|
+
return () => {
|
|
42
|
+
s |= 0;
|
|
43
|
+
s = (s + 0x6d2b79f5) | 0;
|
|
44
|
+
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
45
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
46
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function shuffle<T>(a: readonly T[], r: () => number): T[] {
|
|
50
|
+
const x = a.slice();
|
|
51
|
+
for (let i = x.length - 1; i > 0; i--) {
|
|
52
|
+
const j = Math.floor(r() * (i + 1));
|
|
53
|
+
[x[i], x[j]] = [x[j]!, x[i]!];
|
|
54
|
+
}
|
|
55
|
+
return x;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const splineGen = d3line<Pt>()
|
|
59
|
+
.x((d) => d.x)
|
|
60
|
+
.y((d) => d.y)
|
|
61
|
+
.curve(curveBasis);
|
|
62
|
+
|
|
63
|
+
// flatten an SVG path "d" (M/L/Q/C) into a polyline for crossing detection
|
|
64
|
+
function flatten(d: string): Pt[] {
|
|
65
|
+
const toks = d.match(/[MLQC]|-?\d*\.?\d+(?:e-?\d+)?/gi) ?? [];
|
|
66
|
+
const pts: Pt[] = [];
|
|
67
|
+
let i = 0,
|
|
68
|
+
cx = 0,
|
|
69
|
+
cy = 0,
|
|
70
|
+
cmd = '';
|
|
71
|
+
const num = () => parseFloat(toks[i++]!);
|
|
72
|
+
const samp = (p0: Pt, c1: Pt, c2: Pt | null, p1: Pt) => {
|
|
73
|
+
for (let t = 0; t <= 1; t += 0.12) {
|
|
74
|
+
const u = 1 - t;
|
|
75
|
+
if (c2)
|
|
76
|
+
pts.push({
|
|
77
|
+
x:
|
|
78
|
+
u * u * u * p0.x +
|
|
79
|
+
3 * u * u * t * c1.x +
|
|
80
|
+
3 * u * t * t * c2.x +
|
|
81
|
+
t * t * t * p1.x,
|
|
82
|
+
y:
|
|
83
|
+
u * u * u * p0.y +
|
|
84
|
+
3 * u * u * t * c1.y +
|
|
85
|
+
3 * u * t * t * c2.y +
|
|
86
|
+
t * t * t * p1.y,
|
|
87
|
+
});
|
|
88
|
+
else
|
|
89
|
+
pts.push({
|
|
90
|
+
x: u * u * p0.x + 2 * u * t * c1.x + t * t * p1.x,
|
|
91
|
+
y: u * u * p0.y + 2 * u * t * c1.y + t * t * p1.y,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
while (i < toks.length) {
|
|
96
|
+
const tk = toks[i]!;
|
|
97
|
+
if (/[MLQC]/i.test(tk)) {
|
|
98
|
+
cmd = tk;
|
|
99
|
+
i++;
|
|
100
|
+
}
|
|
101
|
+
if (cmd === 'M' || cmd === 'L') {
|
|
102
|
+
const x = num(),
|
|
103
|
+
y = num();
|
|
104
|
+
pts.push({ x, y });
|
|
105
|
+
cx = x;
|
|
106
|
+
cy = y;
|
|
107
|
+
} else if (cmd === 'Q') {
|
|
108
|
+
const c1 = { x: num(), y: num() },
|
|
109
|
+
p1 = { x: num(), y: num() };
|
|
110
|
+
samp({ x: cx, y: cy }, c1, null, p1);
|
|
111
|
+
cx = p1.x;
|
|
112
|
+
cy = p1.y;
|
|
113
|
+
} else if (cmd === 'C') {
|
|
114
|
+
const c1 = { x: num(), y: num() },
|
|
115
|
+
c2 = { x: num(), y: num() },
|
|
116
|
+
p1 = { x: num(), y: num() };
|
|
117
|
+
samp({ x: cx, y: cy }, c1, c2, p1);
|
|
118
|
+
cx = p1.x;
|
|
119
|
+
cy = p1.y;
|
|
120
|
+
} else i++;
|
|
121
|
+
}
|
|
122
|
+
return pts;
|
|
123
|
+
}
|
|
124
|
+
function segPoint(p1: Pt, p2: Pt, p3: Pt, p4: Pt): Pt | null {
|
|
125
|
+
const den = (p2.x - p1.x) * (p4.y - p3.y) - (p2.y - p1.y) * (p4.x - p3.x);
|
|
126
|
+
if (Math.abs(den) < 1e-9) return null;
|
|
127
|
+
const t =
|
|
128
|
+
((p3.x - p1.x) * (p4.y - p3.y) - (p3.y - p1.y) * (p4.x - p3.x)) / den,
|
|
129
|
+
u = ((p3.x - p1.x) * (p2.y - p1.y) - (p3.y - p1.y) * (p2.x - p1.x)) / den;
|
|
130
|
+
return t > 0 && t < 1 && u > 0 && u < 1
|
|
131
|
+
? { x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y) }
|
|
132
|
+
: null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// trustworthy crossing count on the spline geometry: exclude intersections
|
|
136
|
+
// near a genuinely shared endpoint node; cluster near-duplicate hits.
|
|
137
|
+
// Exported so the playground + benchmark score with the SAME counter the
|
|
138
|
+
// engine optimizes against.
|
|
139
|
+
export function countSplineCrossings(layout: BLLayoutResult): number {
|
|
140
|
+
const center = new Map<string, Pt>();
|
|
141
|
+
for (const n of layout.nodes) center.set(n.label, { x: n.x, y: n.y });
|
|
142
|
+
// collapsed group boxes are edge endpoints too (`__group_<label>`); without
|
|
143
|
+
// them, edges meeting AT a collapsed box are miscounted as crossings.
|
|
144
|
+
for (const g of layout.groups)
|
|
145
|
+
if (g.collapsed) center.set('__group_' + g.label, { x: g.x, y: g.y });
|
|
146
|
+
const polys = layout.edges.map((e) => {
|
|
147
|
+
const pts =
|
|
148
|
+
e.points.length >= 2 ? flatten(splineGen(e.points as Pt[]) ?? '') : [];
|
|
149
|
+
let x0 = Infinity,
|
|
150
|
+
y0 = Infinity,
|
|
151
|
+
x1 = -Infinity,
|
|
152
|
+
y1 = -Infinity;
|
|
153
|
+
for (const p of pts) {
|
|
154
|
+
if (p.x < x0) x0 = p.x;
|
|
155
|
+
if (p.x > x1) x1 = p.x;
|
|
156
|
+
if (p.y < y0) y0 = p.y;
|
|
157
|
+
if (p.y > y1) y1 = p.y;
|
|
158
|
+
}
|
|
159
|
+
return { pts, s: e.source, t: e.target, x0, y0, x1, y1 };
|
|
160
|
+
});
|
|
161
|
+
const R = 34;
|
|
162
|
+
let total = 0;
|
|
163
|
+
for (let a = 0; a < polys.length; a++)
|
|
164
|
+
for (let b = a + 1; b < polys.length; b++) {
|
|
165
|
+
const A = polys[a]!,
|
|
166
|
+
B = polys[b]!;
|
|
167
|
+
if (A.pts.length < 2 || B.pts.length < 2) continue;
|
|
168
|
+
if (A.x1 < B.x0 || B.x1 < A.x0 || A.y1 < B.y0 || B.y1 < A.y0) continue; // bbox disjoint
|
|
169
|
+
const shared = [A.s, A.t]
|
|
170
|
+
.filter((n) => n === B.s || n === B.t)
|
|
171
|
+
.map((n) => center.get(n))
|
|
172
|
+
.filter(Boolean) as Pt[];
|
|
173
|
+
const hits: Pt[] = [];
|
|
174
|
+
for (let i = 1; i < A.pts.length; i++)
|
|
175
|
+
for (let j = 1; j < B.pts.length; j++) {
|
|
176
|
+
const p = segPoint(
|
|
177
|
+
A.pts[i - 1]!,
|
|
178
|
+
A.pts[i]!,
|
|
179
|
+
B.pts[j - 1]!,
|
|
180
|
+
B.pts[j]!
|
|
181
|
+
);
|
|
182
|
+
if (!p) continue;
|
|
183
|
+
if (shared.some((c) => Math.hypot(p.x - c.x, p.y - c.y) < R))
|
|
184
|
+
continue;
|
|
185
|
+
if (!hits.some((h) => Math.hypot(h.x - p.x, h.y - p.y) < 6))
|
|
186
|
+
hits.push(p);
|
|
187
|
+
}
|
|
188
|
+
total += hits.length;
|
|
189
|
+
}
|
|
190
|
+
return total;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// distance from point p to segment a–b
|
|
194
|
+
function pointSegDist(p: Pt, a: Pt, b: Pt): number {
|
|
195
|
+
const dx = b.x - a.x,
|
|
196
|
+
dy = b.y - a.y;
|
|
197
|
+
const len2 = dx * dx + dy * dy;
|
|
198
|
+
if (len2 < 1e-9) return Math.hypot(p.x - a.x, p.y - a.y);
|
|
199
|
+
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
|
|
200
|
+
t = Math.max(0, Math.min(1, t));
|
|
201
|
+
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
|
|
202
|
+
}
|
|
203
|
+
function distToPoly(p: Pt, poly: readonly Pt[]): number {
|
|
204
|
+
let m = Infinity;
|
|
205
|
+
for (let i = 1; i < poly.length; i++)
|
|
206
|
+
m = Math.min(m, pointSegDist(p, poly[i - 1]!, poly[i]!));
|
|
207
|
+
return m;
|
|
208
|
+
}
|
|
209
|
+
type Rect = { x: number; y: number; w: number; h: number };
|
|
210
|
+
// distance from point p to an axis-aligned rectangle (0 if inside)
|
|
211
|
+
function pointRectDist(p: Pt, r: Rect): number {
|
|
212
|
+
const dx = Math.max(r.x - r.w / 2 - p.x, 0, p.x - (r.x + r.w / 2));
|
|
213
|
+
const dy = Math.max(r.y - r.h / 2 - p.y, 0, p.y - (r.y + r.h / 2));
|
|
214
|
+
return Math.hypot(dx, dy);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A stretch where one edge runs ALONG another (within `dist`, for at least
|
|
218
|
+
* `minLen` of length) — i.e. two lines "stepping on" each other. Distinct from
|
|
219
|
+
* a true X-crossing (which is a momentary touch, not a sustained run). */
|
|
220
|
+
export interface OverlapRun {
|
|
221
|
+
mid: Pt;
|
|
222
|
+
length: number;
|
|
223
|
+
pts: Pt[];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Detect edge-overlap runs on the rendered spline geometry. Two edges sharing
|
|
228
|
+
* an endpoint legitimately CONVERGE at that node's port — runs within `nodeClear`
|
|
229
|
+
* of a shared node centre are excluded; only overlap along the open path counts.
|
|
230
|
+
*/
|
|
231
|
+
export function detectEdgeOverlaps(
|
|
232
|
+
layout: BLLayoutResult,
|
|
233
|
+
opts?: { dist?: number; minLen?: number; nodeClear?: number }
|
|
234
|
+
): OverlapRun[] {
|
|
235
|
+
const dist = opts?.dist ?? 8;
|
|
236
|
+
const minLen = opts?.minLen ?? 16;
|
|
237
|
+
// Margin BEYOND the shared node's box that still counts as "converging to the
|
|
238
|
+
// port" (excluded). Edges legitimately meet at a node — only overlap out in
|
|
239
|
+
// the open, away from any shared node, is a real "stepping on another line".
|
|
240
|
+
const nodeClear = opts?.nodeClear ?? 12;
|
|
241
|
+
|
|
242
|
+
const rect = new Map<string, Rect>();
|
|
243
|
+
for (const n of layout.nodes)
|
|
244
|
+
rect.set(n.label, { x: n.x, y: n.y, w: n.width, h: n.height });
|
|
245
|
+
for (const g of layout.groups)
|
|
246
|
+
if (g.collapsed)
|
|
247
|
+
rect.set('__group_' + g.label, {
|
|
248
|
+
x: g.x,
|
|
249
|
+
y: g.y,
|
|
250
|
+
w: g.width,
|
|
251
|
+
h: g.height,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const polys = layout.edges.map((e) => {
|
|
255
|
+
const pts =
|
|
256
|
+
e.points.length >= 2 ? flatten(splineGen(e.points as Pt[]) ?? '') : [];
|
|
257
|
+
let x0 = Infinity,
|
|
258
|
+
y0 = Infinity,
|
|
259
|
+
x1 = -Infinity,
|
|
260
|
+
y1 = -Infinity;
|
|
261
|
+
for (const p of pts) {
|
|
262
|
+
if (p.x < x0) x0 = p.x;
|
|
263
|
+
if (p.x > x1) x1 = p.x;
|
|
264
|
+
if (p.y < y0) y0 = p.y;
|
|
265
|
+
if (p.y > y1) y1 = p.y;
|
|
266
|
+
}
|
|
267
|
+
return { pts, s: e.source, t: e.target, x0, y0, x1, y1 };
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const runs: OverlapRun[] = [];
|
|
271
|
+
for (let a = 0; a < polys.length; a++)
|
|
272
|
+
for (let b = a + 1; b < polys.length; b++) {
|
|
273
|
+
const A = polys[a]!,
|
|
274
|
+
B = polys[b]!;
|
|
275
|
+
if (A.pts.length < 2 || B.pts.length < 2) continue;
|
|
276
|
+
if (
|
|
277
|
+
A.x1 + dist < B.x0 ||
|
|
278
|
+
B.x1 + dist < A.x0 ||
|
|
279
|
+
A.y1 + dist < B.y0 ||
|
|
280
|
+
B.y1 + dist < A.y0
|
|
281
|
+
)
|
|
282
|
+
continue;
|
|
283
|
+
const shared = [A.s, A.t]
|
|
284
|
+
.filter((n) => n === B.s || n === B.t)
|
|
285
|
+
.map((n) => rect.get(n))
|
|
286
|
+
.filter(Boolean) as Rect[];
|
|
287
|
+
// Walk A; accumulate contiguous "covered" runs (close to B, off any shared
|
|
288
|
+
// node). A run counts once if it reaches minLen.
|
|
289
|
+
let run: Pt[] = [];
|
|
290
|
+
let runLen = 0;
|
|
291
|
+
const flush = (): void => {
|
|
292
|
+
if (runLen >= minLen && run.length >= 2)
|
|
293
|
+
runs.push({
|
|
294
|
+
mid: run[Math.floor(run.length / 2)]!,
|
|
295
|
+
length: runLen,
|
|
296
|
+
pts: run.slice(),
|
|
297
|
+
});
|
|
298
|
+
run = [];
|
|
299
|
+
runLen = 0;
|
|
300
|
+
};
|
|
301
|
+
for (const p of A.pts) {
|
|
302
|
+
const nearShared = shared.some((r) => pointRectDist(p, r) < nodeClear);
|
|
303
|
+
const covered = !nearShared && distToPoly(p, B.pts) < dist;
|
|
304
|
+
if (covered) {
|
|
305
|
+
if (run.length)
|
|
306
|
+
runLen += Math.hypot(
|
|
307
|
+
p.x - run[run.length - 1]!.x,
|
|
308
|
+
p.y - run[run.length - 1]!.y
|
|
309
|
+
);
|
|
310
|
+
run.push(p);
|
|
311
|
+
} else flush();
|
|
312
|
+
}
|
|
313
|
+
flush();
|
|
314
|
+
}
|
|
315
|
+
return runs;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Count of edge-overlap runs — the "stepping on another line" metric. */
|
|
319
|
+
export function countEdgeOverlaps(
|
|
320
|
+
layout: BLLayoutResult,
|
|
321
|
+
opts?: { dist?: number; minLen?: number; nodeClear?: number }
|
|
322
|
+
): number {
|
|
323
|
+
return detectEdgeOverlaps(layout, opts).length;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** An edge routing THROUGH a node box it doesn't connect to. Counts as a
|
|
327
|
+
* crossing — the line is where it shouldn't be. */
|
|
328
|
+
export interface NodePierce {
|
|
329
|
+
edgeIdx: number;
|
|
330
|
+
node: string;
|
|
331
|
+
pts: Pt[];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Detect edges that pass through (substantially inside, by `inset`) the box of a
|
|
336
|
+
* node that is NOT one of their endpoints. Endpoints — including collapsed group
|
|
337
|
+
* boxes (`__group_<label>`) — are excluded; an edge legitimately meets those.
|
|
338
|
+
*/
|
|
339
|
+
export function detectEdgeNodePierces(
|
|
340
|
+
layout: BLLayoutResult,
|
|
341
|
+
opts?: { inset?: number; minPts?: number }
|
|
342
|
+
): NodePierce[] {
|
|
343
|
+
const inset = opts?.inset ?? 6;
|
|
344
|
+
const minPts = opts?.minPts ?? 2;
|
|
345
|
+
const rects: (Rect & { key: string })[] = [];
|
|
346
|
+
for (const n of layout.nodes)
|
|
347
|
+
rects.push({ key: n.label, x: n.x, y: n.y, w: n.width, h: n.height });
|
|
348
|
+
for (const g of layout.groups)
|
|
349
|
+
if (g.collapsed)
|
|
350
|
+
rects.push({
|
|
351
|
+
key: '__group_' + g.label,
|
|
352
|
+
x: g.x,
|
|
353
|
+
y: g.y,
|
|
354
|
+
w: g.width,
|
|
355
|
+
h: g.height,
|
|
356
|
+
});
|
|
357
|
+
const inside = (p: Pt, r: Rect): boolean =>
|
|
358
|
+
Math.abs(p.x - r.x) < r.w / 2 - inset &&
|
|
359
|
+
Math.abs(p.y - r.y) < r.h / 2 - inset;
|
|
360
|
+
const out: NodePierce[] = [];
|
|
361
|
+
layout.edges.forEach((e, idx) => {
|
|
362
|
+
if (e.points.length < 2) return;
|
|
363
|
+
const poly = flatten(splineGen(e.points as Pt[]) ?? '');
|
|
364
|
+
for (const r of rects) {
|
|
365
|
+
if (
|
|
366
|
+
r.key === e.source ||
|
|
367
|
+
r.key === e.target ||
|
|
368
|
+
'__group_' + r.key === e.source ||
|
|
369
|
+
'__group_' + r.key === e.target
|
|
370
|
+
)
|
|
371
|
+
continue;
|
|
372
|
+
const hits = poly.filter((p) => inside(p, r));
|
|
373
|
+
if (hits.length >= minPts)
|
|
374
|
+
out.push({ edgeIdx: idx, node: r.key, pts: hits });
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Count of edges routing through unrelated node boxes — the "line going through
|
|
381
|
+
* a node" metric. */
|
|
382
|
+
export function countEdgeNodePierces(
|
|
383
|
+
layout: BLLayoutResult,
|
|
384
|
+
opts?: { inset?: number; minPts?: number }
|
|
385
|
+
): number {
|
|
386
|
+
return detectEdgeNodePierces(layout, opts).length;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Re-routed copy of a layout that bends edges AROUND any node box they pierce:
|
|
391
|
+
* for each pierced (edge, node) it inserts a waypoint pushing the edge out past
|
|
392
|
+
* the node, perpendicular to the edge, on the side it's already leaning. The
|
|
393
|
+
* curveBasis spline then bows around the node instead of through it. Returned as
|
|
394
|
+
* an ALTERNATIVE candidate — the caller keeps it only if total badness drops, so
|
|
395
|
+
* a detour that trades a pierce for a crossing is simply rejected.
|
|
396
|
+
*/
|
|
397
|
+
export function deroutePierces(layout: BLLayoutResult): BLLayoutResult {
|
|
398
|
+
const pierces = detectEdgeNodePierces(layout);
|
|
399
|
+
if (!pierces.length) return layout;
|
|
400
|
+
const rect = new Map<string, Rect>();
|
|
401
|
+
for (const n of layout.nodes)
|
|
402
|
+
rect.set(n.label, { x: n.x, y: n.y, w: n.width, h: n.height });
|
|
403
|
+
for (const g of layout.groups)
|
|
404
|
+
if (g.collapsed)
|
|
405
|
+
rect.set('__group_' + g.label, {
|
|
406
|
+
x: g.x,
|
|
407
|
+
y: g.y,
|
|
408
|
+
w: g.width,
|
|
409
|
+
h: g.height,
|
|
410
|
+
});
|
|
411
|
+
const byEdge = new Map<number, string[]>();
|
|
412
|
+
for (const p of pierces) {
|
|
413
|
+
const arr = byEdge.get(p.edgeIdx);
|
|
414
|
+
if (arr) arr.push(p.node);
|
|
415
|
+
else byEdge.set(p.edgeIdx, [p.node]);
|
|
416
|
+
}
|
|
417
|
+
const edges = layout.edges.map((e, idx) => {
|
|
418
|
+
const nodes = byEdge.get(idx);
|
|
419
|
+
if (!nodes) return e;
|
|
420
|
+
let pts: Pt[] = e.points.map((p) => ({ x: p.x, y: p.y }));
|
|
421
|
+
for (const label of nodes) {
|
|
422
|
+
const r = rect.get(label);
|
|
423
|
+
if (r) pts = detourAround(pts, r);
|
|
424
|
+
}
|
|
425
|
+
return { ...e, points: pts };
|
|
426
|
+
});
|
|
427
|
+
return { ...layout, edges };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Bend a waypoint polyline around a rectangle it passes through: find the
|
|
431
|
+
// segment whose closest point to the rect centre is nearest, and insert a
|
|
432
|
+
// waypoint there pushed out past the rect corner (+margin) on the leaning side.
|
|
433
|
+
function detourAround(pts: Pt[], r: Rect): Pt[] {
|
|
434
|
+
if (pts.length < 2) return pts;
|
|
435
|
+
const c = { x: r.x, y: r.y };
|
|
436
|
+
let bestSeg = -1;
|
|
437
|
+
let bestT = 0;
|
|
438
|
+
let bestD = Infinity;
|
|
439
|
+
let bestPt: Pt = pts[0]!;
|
|
440
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
441
|
+
const a = pts[i]!,
|
|
442
|
+
b = pts[i + 1]!;
|
|
443
|
+
const dx = b.x - a.x,
|
|
444
|
+
dy = b.y - a.y;
|
|
445
|
+
const len2 = dx * dx + dy * dy || 1e-9;
|
|
446
|
+
let t = ((c.x - a.x) * dx + (c.y - a.y) * dy) / len2;
|
|
447
|
+
t = Math.max(0, Math.min(1, t));
|
|
448
|
+
const q = { x: a.x + t * dx, y: a.y + t * dy };
|
|
449
|
+
const d = Math.hypot(q.x - c.x, q.y - c.y);
|
|
450
|
+
if (d < bestD) {
|
|
451
|
+
bestD = d;
|
|
452
|
+
bestSeg = i;
|
|
453
|
+
bestT = t;
|
|
454
|
+
bestPt = q;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (bestSeg < 0) return pts;
|
|
458
|
+
// push direction: away from rect centre, on the side the edge already leans;
|
|
459
|
+
// if the closest point is dead-centre, pick the box's shorter axis to exit.
|
|
460
|
+
let nx = bestPt.x - c.x;
|
|
461
|
+
let ny = bestPt.y - c.y;
|
|
462
|
+
if (Math.hypot(nx, ny) < 1) {
|
|
463
|
+
if (r.w <= r.h) {
|
|
464
|
+
nx = 1;
|
|
465
|
+
ny = 0;
|
|
466
|
+
} else {
|
|
467
|
+
nx = 0;
|
|
468
|
+
ny = 1;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const nlen = Math.hypot(nx, ny) || 1;
|
|
472
|
+
nx /= nlen;
|
|
473
|
+
ny /= nlen;
|
|
474
|
+
// Local edge direction (along the closest segment), for shaping a SMOOTH hump
|
|
475
|
+
// around the node instead of a single spike: ease out → peak → ease back in.
|
|
476
|
+
const a = pts[bestSeg]!,
|
|
477
|
+
b = pts[bestSeg + 1]!;
|
|
478
|
+
let ex = b.x - a.x,
|
|
479
|
+
ey = b.y - a.y;
|
|
480
|
+
const elen = Math.hypot(ex, ey) || 1;
|
|
481
|
+
ex /= elen;
|
|
482
|
+
ey /= elen;
|
|
483
|
+
const clear = Math.hypot(r.w, r.h) / 2 + 18;
|
|
484
|
+
const hw = Math.hypot(r.w, r.h) / 2; // hump half-width along the edge
|
|
485
|
+
void bestT;
|
|
486
|
+
const before = {
|
|
487
|
+
x: bestPt.x - ex * hw + nx * clear * 0.5,
|
|
488
|
+
y: bestPt.y - ey * hw + ny * clear * 0.5,
|
|
489
|
+
};
|
|
490
|
+
const peak = { x: c.x + nx * clear, y: c.y + ny * clear };
|
|
491
|
+
const after = {
|
|
492
|
+
x: bestPt.x + ex * hw + nx * clear * 0.5,
|
|
493
|
+
y: bestPt.y + ey * hw + ny * clear * 0.5,
|
|
494
|
+
};
|
|
495
|
+
const out = pts.slice(0, bestSeg + 1);
|
|
496
|
+
out.push(before, peak, after);
|
|
497
|
+
out.push(...pts.slice(bestSeg + 1));
|
|
498
|
+
return out;
|
|
499
|
+
}
|
|
500
|
+
/** Count of edges that come CLOSE to another edge without overlapping — lines
|
|
501
|
+
* that "almost touch". Runs within `near` px (but not already overlapping within
|
|
502
|
+
* `tight` px). Same shared-node exclusion as overlaps. */
|
|
503
|
+
export function countEdgeNearMiss(
|
|
504
|
+
layout: BLLayoutResult,
|
|
505
|
+
opts?: { near?: number; tight?: number; minLen?: number }
|
|
506
|
+
): number {
|
|
507
|
+
const near = opts?.near ?? 14;
|
|
508
|
+
const tight = opts?.tight ?? 8;
|
|
509
|
+
const minLen = opts?.minLen ?? 18;
|
|
510
|
+
const close = detectEdgeOverlaps(layout, { dist: near, minLen }).length;
|
|
511
|
+
const over = detectEdgeOverlaps(layout, { dist: tight, minLen }).length;
|
|
512
|
+
return Math.max(0, close - over);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Renderer extends a parent group's box UPWARD by this label zone (mirrors
|
|
516
|
+
// GROUP_LABEL_ZONE in renderer.ts) — so the collision metric must use the
|
|
517
|
+
// RENDERED box, or near-touching parents read as fine when they actually overlap.
|
|
518
|
+
const GROUP_LABEL_ZONE = 32;
|
|
519
|
+
|
|
520
|
+
/** Labels of GROUP boxes that touch/overlap a non-nested sibling group. Proper
|
|
521
|
+
* nesting (one box fully inside another) is excluded; only sibling/unrelated
|
|
522
|
+
* collisions count. Uses the RENDERED box (parent groups grow up by the label
|
|
523
|
+
* zone, which is what actually makes near-touching parents collide). */
|
|
524
|
+
export function detectGroupOverlaps(
|
|
525
|
+
layout: BLLayoutResult,
|
|
526
|
+
opts?: { margin?: number }
|
|
527
|
+
): string[] {
|
|
528
|
+
const margin = opts?.margin ?? 4;
|
|
529
|
+
const raw = layout.groups.map((g) => ({
|
|
530
|
+
label: g.label,
|
|
531
|
+
l: g.x - g.width / 2,
|
|
532
|
+
r: g.x + g.width / 2,
|
|
533
|
+
t: g.y - g.height / 2,
|
|
534
|
+
b: g.y + g.height / 2,
|
|
535
|
+
}));
|
|
536
|
+
const contains = (
|
|
537
|
+
outer: (typeof raw)[number],
|
|
538
|
+
inner: (typeof raw)[number]
|
|
539
|
+
): boolean =>
|
|
540
|
+
outer.l <= inner.l + margin &&
|
|
541
|
+
outer.r >= inner.r - margin &&
|
|
542
|
+
outer.t <= inner.t + margin &&
|
|
543
|
+
outer.b >= inner.b - margin;
|
|
544
|
+
const rend = raw.map((a, i) => {
|
|
545
|
+
const parent = raw.some((b, j) => j !== i && contains(a, b));
|
|
546
|
+
return parent ? { ...a, t: a.t - GROUP_LABEL_ZONE } : a;
|
|
547
|
+
});
|
|
548
|
+
const hit = new Set<string>();
|
|
549
|
+
for (let i = 0; i < raw.length; i++)
|
|
550
|
+
for (let j = i + 1; j < raw.length; j++) {
|
|
551
|
+
if (contains(raw[i]!, raw[j]!) || contains(raw[j]!, raw[i]!)) continue; // nesting
|
|
552
|
+
const a = rend[i]!,
|
|
553
|
+
b = rend[j]!;
|
|
554
|
+
const dx = Math.max(a.l - b.r, b.l - a.r);
|
|
555
|
+
const dy = Math.max(a.t - b.b, b.t - a.b);
|
|
556
|
+
if (Math.max(dx, dy) < margin) {
|
|
557
|
+
hit.add(raw[i]!.label);
|
|
558
|
+
hit.add(raw[j]!.label);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return [...hit];
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Count of group-box collisions (pairs touching/overlapping). */
|
|
565
|
+
export function countGroupOverlaps(
|
|
566
|
+
layout: BLLayoutResult,
|
|
567
|
+
opts?: { margin?: number }
|
|
568
|
+
): number {
|
|
569
|
+
const margin = opts?.margin ?? 4;
|
|
570
|
+
const raw = layout.groups.map((g) => ({
|
|
571
|
+
l: g.x - g.width / 2,
|
|
572
|
+
r: g.x + g.width / 2,
|
|
573
|
+
t: g.y - g.height / 2,
|
|
574
|
+
b: g.y + g.height / 2,
|
|
575
|
+
}));
|
|
576
|
+
const contains = (
|
|
577
|
+
outer: (typeof raw)[number],
|
|
578
|
+
inner: (typeof raw)[number]
|
|
579
|
+
): boolean =>
|
|
580
|
+
outer.l <= inner.l + margin &&
|
|
581
|
+
outer.r >= inner.r - margin &&
|
|
582
|
+
outer.t <= inner.t + margin &&
|
|
583
|
+
outer.b >= inner.b - margin;
|
|
584
|
+
const rend = raw.map((a, i) => {
|
|
585
|
+
const parent = raw.some((b, j) => j !== i && contains(a, b));
|
|
586
|
+
return parent ? { ...a, t: a.t - GROUP_LABEL_ZONE } : a;
|
|
587
|
+
});
|
|
588
|
+
let count = 0;
|
|
589
|
+
for (let i = 0; i < raw.length; i++)
|
|
590
|
+
for (let j = i + 1; j < raw.length; j++) {
|
|
591
|
+
if (contains(raw[i]!, raw[j]!) || contains(raw[j]!, raw[i]!)) continue;
|
|
592
|
+
const a = rend[i]!,
|
|
593
|
+
b = rend[j]!;
|
|
594
|
+
const dx = Math.max(a.l - b.r, b.l - a.r);
|
|
595
|
+
const dy = Math.max(a.t - b.b, b.t - a.b);
|
|
596
|
+
if (Math.max(dx, dy) < margin) count++;
|
|
597
|
+
}
|
|
598
|
+
return count;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Separated copy of a layout that pushes overlapping TOP-LEVEL group bands apart
|
|
602
|
+
* along the cross-axis. dagre's compound layout sometimes wedges a small group
|
|
603
|
+
* into a too-tight channel between two others (the multi-cloud On-Prem box lands
|
|
604
|
+
* in AWS's label zone). Translating each top-level group rigidly along the
|
|
605
|
+
* cross-axis preserves every node's rank/column, so the only thing that changes
|
|
606
|
+
* is the gap between bands. Member nodes + nested sub-group boxes move by their
|
|
607
|
+
* band's delta; edge waypoints blend smoothly between their endpoints' deltas
|
|
608
|
+
* (intra-band edges keep their shape exactly; cross-band edges ease across).
|
|
609
|
+
*
|
|
610
|
+
* Returned as an ALTERNATIVE candidate — the caller keeps it only if total
|
|
611
|
+
* badness drops, so a separation that introduces a crossing/pierce is rejected.
|
|
612
|
+
* Fully-expanded graphs only (skips when any group is collapsed).
|
|
613
|
+
*/
|
|
614
|
+
export function separateGroupBands(
|
|
615
|
+
layout: BLLayoutResult,
|
|
616
|
+
parsed: ParsedBoxesAndLines
|
|
617
|
+
): BLLayoutResult {
|
|
618
|
+
if (layout.groups.some((g) => g.collapsed)) return layout;
|
|
619
|
+
if (countGroupOverlaps(layout) === 0) return layout;
|
|
620
|
+
|
|
621
|
+
// Resolve each group label to its top-level ancestor (walk parentGroup).
|
|
622
|
+
const parentOf = new Map<string, string | undefined>();
|
|
623
|
+
for (const g of parsed.groups) parentOf.set(g.label, g.parentGroup);
|
|
624
|
+
const topOf = (label: string): string => {
|
|
625
|
+
let cur = label;
|
|
626
|
+
const seen = new Set<string>();
|
|
627
|
+
for (;;) {
|
|
628
|
+
const p = parentOf.get(cur);
|
|
629
|
+
if (!p || seen.has(p)) return cur;
|
|
630
|
+
seen.add(cur);
|
|
631
|
+
cur = p;
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
const topLabels = parsed.groups
|
|
635
|
+
.filter((g) => !g.parentGroup)
|
|
636
|
+
.map((g) => g.label);
|
|
637
|
+
if (topLabels.length < 2) return layout;
|
|
638
|
+
|
|
639
|
+
// Each node's top-level group (a node sits in exactly one leaf group).
|
|
640
|
+
const nodeTop = new Map<string, string>();
|
|
641
|
+
for (const g of parsed.groups)
|
|
642
|
+
for (const child of g.children)
|
|
643
|
+
if (!parsed.groups.some((gg) => gg.label === child))
|
|
644
|
+
nodeTop.set(child, topOf(g.label));
|
|
645
|
+
|
|
646
|
+
// Cross-axis: LR stacks bands vertically (Y); TB stacks them horizontally (X).
|
|
647
|
+
// The label zone always extends a parent's box upward (Y), so it only lands on
|
|
648
|
+
// the cross-axis for LR — for TB this pass can still separate plain X overlaps.
|
|
649
|
+
const axis: 'x' | 'y' = parsed.direction === 'TB' ? 'x' : 'y';
|
|
650
|
+
const boxByLabel = new Map(layout.groups.map((g) => [g.label, g]));
|
|
651
|
+
|
|
652
|
+
// Rendered cross-interval per top-level group (label zone folded in on Y).
|
|
653
|
+
type Band = { label: string; lo: number; hi: number; c: number };
|
|
654
|
+
const bands: Band[] = topLabels.map((label) => {
|
|
655
|
+
const g = boxByLabel.get(label)!;
|
|
656
|
+
const half = (axis === 'y' ? g.height : g.width) / 2;
|
|
657
|
+
let lo = (axis === 'y' ? g.y : g.x) - half;
|
|
658
|
+
const hi = (axis === 'y' ? g.y : g.x) + half;
|
|
659
|
+
// A top-level group with children is a parent → its rendered box grows up.
|
|
660
|
+
const isParent = parsed.groups.some((c) => c.parentGroup === label);
|
|
661
|
+
if (axis === 'y' && isParent) lo -= GROUP_LABEL_ZONE;
|
|
662
|
+
return { label, lo, hi, c: (lo + hi) / 2 };
|
|
663
|
+
});
|
|
664
|
+
bands.sort((a, b) => a.c - b.c);
|
|
665
|
+
|
|
666
|
+
// 1D block-merge (PAV) separation: keep each band's centre as close to where
|
|
667
|
+
// it is as possible, subject to a minimum gap between consecutive bands.
|
|
668
|
+
const GAP = 16;
|
|
669
|
+
const half = bands.map((b) => (b.hi - b.lo) / 2);
|
|
670
|
+
const off: number[] = [0];
|
|
671
|
+
for (let i = 1; i < bands.length; i++)
|
|
672
|
+
off[i] = off[i - 1]! + half[i - 1]! + GAP + half[i]!;
|
|
673
|
+
const desired = bands.map((b, i) => b.c - off[i]!);
|
|
674
|
+
type Blk = { pos: number; count: number; sum: number; first: number };
|
|
675
|
+
const blocks: Blk[] = [];
|
|
676
|
+
for (let i = 0; i < bands.length; i++) {
|
|
677
|
+
let blk: Blk = { pos: desired[i]!, count: 1, sum: desired[i]!, first: i };
|
|
678
|
+
while (blocks.length && blocks[blocks.length - 1]!.pos >= blk.pos) {
|
|
679
|
+
const prev = blocks.pop()!;
|
|
680
|
+
const count = prev.count + blk.count;
|
|
681
|
+
const sum = prev.sum + blk.sum;
|
|
682
|
+
blk = { pos: sum / count, count, sum, first: prev.first };
|
|
683
|
+
}
|
|
684
|
+
blocks.push(blk);
|
|
685
|
+
}
|
|
686
|
+
const newC = new Array<number>(bands.length);
|
|
687
|
+
for (const blk of blocks)
|
|
688
|
+
for (let k = 0; k < blk.count; k++)
|
|
689
|
+
newC[blk.first + k] = blk.pos + off[blk.first + k]!;
|
|
690
|
+
|
|
691
|
+
// Delta per top-level group; bail if nothing actually moves.
|
|
692
|
+
const delta = new Map<string, number>();
|
|
693
|
+
let moved = false;
|
|
694
|
+
bands.forEach((b, i) => {
|
|
695
|
+
const d = newC[i]! - b.c;
|
|
696
|
+
delta.set(b.label, d);
|
|
697
|
+
if (Math.abs(d) > 0.5) moved = true;
|
|
698
|
+
});
|
|
699
|
+
if (!moved) return layout;
|
|
700
|
+
|
|
701
|
+
const nodeDelta = (label: string): number => {
|
|
702
|
+
const top = nodeTop.get(label);
|
|
703
|
+
return top ? (delta.get(top) ?? 0) : 0;
|
|
704
|
+
};
|
|
705
|
+
const shift = (p: Pt, d: number): Pt =>
|
|
706
|
+
axis === 'y' ? { x: p.x, y: p.y + d } : { x: p.x + d, y: p.y };
|
|
707
|
+
|
|
708
|
+
const nodes = layout.nodes.map((n) => {
|
|
709
|
+
const d = nodeDelta(n.label);
|
|
710
|
+
return d
|
|
711
|
+
? { ...n, ...(axis === 'y' ? { y: n.y + d } : { x: n.x + d }) }
|
|
712
|
+
: n;
|
|
713
|
+
});
|
|
714
|
+
const groups = layout.groups.map((g) => {
|
|
715
|
+
const d = delta.get(topOf(g.label)) ?? 0;
|
|
716
|
+
return d
|
|
717
|
+
? { ...g, ...(axis === 'y' ? { y: g.y + d } : { x: g.x + d }) }
|
|
718
|
+
: g;
|
|
719
|
+
});
|
|
720
|
+
const edges = layout.edges.map((e) => {
|
|
721
|
+
const ds = nodeDelta(e.source);
|
|
722
|
+
const dt = nodeDelta(e.target);
|
|
723
|
+
if (!ds && !dt) return e;
|
|
724
|
+
const N = e.points.length;
|
|
725
|
+
const points = e.points.map((p, i) => {
|
|
726
|
+
const f = N > 1 ? i / (N - 1) : 0;
|
|
727
|
+
return shift(p, ds * (1 - f) + dt * f);
|
|
728
|
+
});
|
|
729
|
+
return { ...e, points };
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// Recompute canvas bbox (+ margin) over the shifted content.
|
|
733
|
+
let minX = Infinity,
|
|
734
|
+
minY = Infinity,
|
|
735
|
+
maxX = -Infinity,
|
|
736
|
+
maxY = -Infinity;
|
|
737
|
+
const acc = (x: number, y: number) => {
|
|
738
|
+
if (x < minX) minX = x;
|
|
739
|
+
if (x > maxX) maxX = x;
|
|
740
|
+
if (y < minY) minY = y;
|
|
741
|
+
if (y > maxY) maxY = y;
|
|
742
|
+
};
|
|
743
|
+
for (const n of nodes) {
|
|
744
|
+
acc(n.x - n.width / 2, n.y - n.height / 2);
|
|
745
|
+
acc(n.x + n.width / 2, n.y + n.height / 2);
|
|
746
|
+
}
|
|
747
|
+
for (const g of groups) {
|
|
748
|
+
acc(g.x - g.width / 2, g.y - g.height / 2 - GROUP_LABEL_ZONE);
|
|
749
|
+
acc(g.x + g.width / 2, g.y + g.height / 2);
|
|
750
|
+
}
|
|
751
|
+
for (const e of edges) for (const p of e.points) acc(p.x, p.y);
|
|
752
|
+
const M = 40;
|
|
753
|
+
const sx = M - minX,
|
|
754
|
+
sy = M - minY;
|
|
755
|
+
const reshift = sx !== 0 || sy !== 0;
|
|
756
|
+
return {
|
|
757
|
+
nodes: reshift
|
|
758
|
+
? nodes.map((n) => ({ ...n, x: n.x + sx, y: n.y + sy }))
|
|
759
|
+
: nodes,
|
|
760
|
+
groups: reshift
|
|
761
|
+
? groups.map((g) => ({ ...g, x: g.x + sx, y: g.y + sy }))
|
|
762
|
+
: groups,
|
|
763
|
+
edges: reshift
|
|
764
|
+
? edges.map((e) => ({
|
|
765
|
+
...e,
|
|
766
|
+
points: e.points.map((p) => ({ x: p.x + sx, y: p.y + sy })),
|
|
767
|
+
}))
|
|
768
|
+
: edges,
|
|
769
|
+
width: maxX - minX + 2 * M,
|
|
770
|
+
height: maxY - minY + 2 * M,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Fast crossing estimate for RANKING candidates: straight segments on raw
|
|
775
|
+
// waypoints (no curveBasis flatten) + bbox pruning + early-out per pair.
|
|
776
|
+
// ~10× cheaper than countSplineCrossings; topology-equivalent for ranking.
|
|
777
|
+
function segCross(p1: Pt, p2: Pt, p3: Pt, p4: Pt): boolean {
|
|
778
|
+
const d1x = p2.x - p1.x,
|
|
779
|
+
d1y = p2.y - p1.y,
|
|
780
|
+
d2x = p4.x - p3.x,
|
|
781
|
+
d2y = p4.y - p3.y;
|
|
782
|
+
const den = d1x * d2y - d1y * d2x;
|
|
783
|
+
if (Math.abs(den) < 1e-9) return false;
|
|
784
|
+
const t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / den;
|
|
785
|
+
const s = ((p3.x - p1.x) * d1y - (p3.y - p1.y) * d1x) / den;
|
|
786
|
+
return t > 0.001 && t < 0.999 && s > 0.001 && s < 0.999;
|
|
787
|
+
}
|
|
788
|
+
function countCrossingsFast(layout: BLLayoutResult): number {
|
|
789
|
+
const E = layout.edges.filter((e) => e.points.length >= 2);
|
|
790
|
+
const bb = E.map((e) => {
|
|
791
|
+
let x0 = Infinity,
|
|
792
|
+
y0 = Infinity,
|
|
793
|
+
x1 = -Infinity,
|
|
794
|
+
y1 = -Infinity;
|
|
795
|
+
for (const p of e.points) {
|
|
796
|
+
if (p.x < x0) x0 = p.x;
|
|
797
|
+
if (p.x > x1) x1 = p.x;
|
|
798
|
+
if (p.y < y0) y0 = p.y;
|
|
799
|
+
if (p.y > y1) y1 = p.y;
|
|
800
|
+
}
|
|
801
|
+
return { x0, y0, x1, y1 };
|
|
802
|
+
});
|
|
803
|
+
let count = 0;
|
|
804
|
+
for (let i = 0; i < E.length; i++)
|
|
805
|
+
for (let j = i + 1; j < E.length; j++) {
|
|
806
|
+
const A = E[i]!,
|
|
807
|
+
B = E[j]!;
|
|
808
|
+
if (
|
|
809
|
+
A.source === B.source ||
|
|
810
|
+
A.source === B.target ||
|
|
811
|
+
A.target === B.source ||
|
|
812
|
+
A.target === B.target
|
|
813
|
+
)
|
|
814
|
+
continue;
|
|
815
|
+
const a = bb[i]!,
|
|
816
|
+
b = bb[j]!;
|
|
817
|
+
if (a.x1 < b.x0 || b.x1 < a.x0 || a.y1 < b.y0 || b.y1 < a.y0) continue;
|
|
818
|
+
const pa = A.points,
|
|
819
|
+
pb = B.points;
|
|
820
|
+
let hit = false;
|
|
821
|
+
for (let ai = 0; ai < pa.length - 1 && !hit; ai++)
|
|
822
|
+
for (let bi = 0; bi < pb.length - 1; bi++) {
|
|
823
|
+
if (segCross(pa[ai]!, pa[ai + 1]!, pb[bi]!, pb[bi + 1]!)) {
|
|
824
|
+
hit = true;
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (hit) count++;
|
|
829
|
+
}
|
|
830
|
+
return count;
|
|
831
|
+
}
|
|
832
|
+
function meanDrift(
|
|
833
|
+
layout: BLLayoutResult,
|
|
834
|
+
prev: ReadonlyMap<string, Pt> | undefined
|
|
835
|
+
): number {
|
|
836
|
+
if (!prev?.size) return 0;
|
|
837
|
+
let sum = 0,
|
|
838
|
+
n = 0;
|
|
839
|
+
for (const node of layout.nodes) {
|
|
840
|
+
const p = prev.get(node.label);
|
|
841
|
+
if (p) {
|
|
842
|
+
sum += Math.hypot(node.x - p.x, node.y - p.y);
|
|
843
|
+
n++;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return n ? sum / n : 0;
|
|
847
|
+
}
|
|
848
|
+
// total edge length — positioning tiebreaker (shorter/straighter reads better)
|
|
849
|
+
function edgeLength(layout: BLLayoutResult): number {
|
|
850
|
+
let total = 0;
|
|
851
|
+
for (const e of layout.edges)
|
|
852
|
+
for (let i = 1; i < e.points.length; i++)
|
|
853
|
+
total += Math.hypot(
|
|
854
|
+
e.points[i]!.x - e.points[i - 1]!.x,
|
|
855
|
+
e.points[i]!.y - e.points[i - 1]!.y
|
|
856
|
+
);
|
|
857
|
+
return total;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export function layoutBoxesAndLinesSearch(
|
|
861
|
+
parsed: ParsedBoxesAndLines,
|
|
862
|
+
collapseInfo?: {
|
|
863
|
+
collapsedChildCounts: Map<string, number>;
|
|
864
|
+
originalGroups: readonly BLGroup[];
|
|
865
|
+
},
|
|
866
|
+
opts?: {
|
|
867
|
+
hideDescriptions?: boolean;
|
|
868
|
+
previousPositions?: ReadonlyMap<string, Pt>;
|
|
869
|
+
/** Number of seed orderings to search (default: adaptive by node count). */
|
|
870
|
+
seeds?: number;
|
|
871
|
+
/** Stability weight (default 4). */
|
|
872
|
+
lambda?: number;
|
|
873
|
+
/** How many top candidates to re-rank with the exact counter (default 6). */
|
|
874
|
+
refineK?: number;
|
|
875
|
+
}
|
|
876
|
+
): BLLayoutResult {
|
|
877
|
+
const hideDescriptions = opts?.hideDescriptions ?? false;
|
|
878
|
+
|
|
879
|
+
// collapsed group labels (shown as plain boxes) — mirrors the ELK path
|
|
880
|
+
const collapsedGroupLabels = new Set<string>();
|
|
881
|
+
if (collapseInfo) {
|
|
882
|
+
const missing = new Set<string>();
|
|
883
|
+
for (const og of collapseInfo.originalGroups)
|
|
884
|
+
if (!parsed.groups.some((g) => g.label === og.label))
|
|
885
|
+
missing.add(og.label);
|
|
886
|
+
for (const label of missing) {
|
|
887
|
+
const og = collapseInfo.originalGroups.find((g) => g.label === label);
|
|
888
|
+
const parent = og?.parentGroup;
|
|
889
|
+
if (!parent || !missing.has(parent)) collapsedGroupLabels.add(label);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// node sizes (computeNodeSize + uniform-height pass) — identical to ELK path
|
|
894
|
+
const sizes = new Map<string, { width: number; height: number }>();
|
|
895
|
+
let maxDescH = 0;
|
|
896
|
+
for (const node of parsed.nodes) {
|
|
897
|
+
const s = hideDescriptions
|
|
898
|
+
? { width: NODE_WIDTH, height: NODE_HEIGHT }
|
|
899
|
+
: computeNodeSize(node, parsed.showValues === true);
|
|
900
|
+
sizes.set(node.label, s);
|
|
901
|
+
if (!hideDescriptions && node.description && node.description.length > 0)
|
|
902
|
+
maxDescH = Math.max(maxDescH, s.height);
|
|
903
|
+
}
|
|
904
|
+
if (maxDescH > 0)
|
|
905
|
+
for (const node of parsed.nodes)
|
|
906
|
+
if (node.description && node.description.length > 0) {
|
|
907
|
+
const s = sizes.get(node.label)!;
|
|
908
|
+
sizes.set(node.label, { width: s.width, height: maxDescH });
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const gid = (label: string) => `__group_${label}`;
|
|
912
|
+
const rankdir = parsed.direction === 'TB' ? 'TB' : 'LR';
|
|
913
|
+
|
|
914
|
+
function place(cfg: {
|
|
915
|
+
ranker: string;
|
|
916
|
+
nodesep: number;
|
|
917
|
+
ranksep: number;
|
|
918
|
+
seed?: number;
|
|
919
|
+
}): BLLayoutResult {
|
|
920
|
+
const r = cfg.seed === undefined ? null : rng(cfg.seed + 1);
|
|
921
|
+
const ord = <T>(a: readonly T[]): T[] => (r ? shuffle(a, r) : a.slice());
|
|
922
|
+
const g = new dagre.graphlib.Graph({ compound: true, multigraph: true });
|
|
923
|
+
g.setGraph({
|
|
924
|
+
rankdir,
|
|
925
|
+
ranker: cfg.ranker,
|
|
926
|
+
nodesep: cfg.nodesep,
|
|
927
|
+
ranksep: cfg.ranksep,
|
|
928
|
+
edgesep: 20,
|
|
929
|
+
marginx: 40,
|
|
930
|
+
marginy: 40,
|
|
931
|
+
});
|
|
932
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
933
|
+
for (const grp of ord(parsed.groups))
|
|
934
|
+
g.setNode(gid(grp.label), { label: grp.label });
|
|
935
|
+
for (const node of ord(parsed.nodes)) {
|
|
936
|
+
const s = sizes.get(node.label)!;
|
|
937
|
+
g.setNode(node.label, { width: s.width, height: s.height });
|
|
938
|
+
}
|
|
939
|
+
for (const label of collapsedGroupLabels)
|
|
940
|
+
g.setNode(gid(label), { width: NODE_WIDTH, height: NODE_HEIGHT });
|
|
941
|
+
for (const grp of parsed.groups) {
|
|
942
|
+
if (grp.parentGroup && g.hasNode(gid(grp.parentGroup)))
|
|
943
|
+
g.setParent(gid(grp.label), gid(grp.parentGroup));
|
|
944
|
+
for (const c of ord(grp.children)) {
|
|
945
|
+
if (g.hasNode(c)) g.setParent(c, gid(grp.label));
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (collapseInfo)
|
|
949
|
+
for (const label of collapsedGroupLabels) {
|
|
950
|
+
const og = collapseInfo.originalGroups.find((x) => x.label === label);
|
|
951
|
+
if (
|
|
952
|
+
og?.parentGroup &&
|
|
953
|
+
!collapsedGroupLabels.has(og.parentGroup) &&
|
|
954
|
+
g.hasNode(gid(og.parentGroup))
|
|
955
|
+
)
|
|
956
|
+
g.setParent(gid(label), gid(og.parentGroup));
|
|
957
|
+
}
|
|
958
|
+
for (const e of ord(parsed.edges))
|
|
959
|
+
if (g.hasNode(e.source) && g.hasNode(e.target))
|
|
960
|
+
g.setEdge(e.source, e.target, {});
|
|
961
|
+
dagre.layout(g);
|
|
962
|
+
|
|
963
|
+
const nodes = parsed.nodes.map((n) => {
|
|
964
|
+
const p = g.node(n.label);
|
|
965
|
+
return {
|
|
966
|
+
label: n.label,
|
|
967
|
+
x: p.x,
|
|
968
|
+
y: p.y,
|
|
969
|
+
width: p.width,
|
|
970
|
+
height: p.height,
|
|
971
|
+
};
|
|
972
|
+
});
|
|
973
|
+
const groups: BLLayoutResult['groups'][number][] = parsed.groups.map(
|
|
974
|
+
(grp) => {
|
|
975
|
+
const p = g.node(gid(grp.label));
|
|
976
|
+
return {
|
|
977
|
+
label: grp.label,
|
|
978
|
+
lineNumber: grp.lineNumber,
|
|
979
|
+
x: p.x,
|
|
980
|
+
y: p.y,
|
|
981
|
+
width: p.width,
|
|
982
|
+
height: p.height,
|
|
983
|
+
collapsed: false,
|
|
984
|
+
childCount: grp.children.length,
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
);
|
|
988
|
+
for (const label of collapsedGroupLabels) {
|
|
989
|
+
const p = g.node(gid(label));
|
|
990
|
+
groups.push({
|
|
991
|
+
label,
|
|
992
|
+
lineNumber: 0,
|
|
993
|
+
x: p.x,
|
|
994
|
+
y: p.y,
|
|
995
|
+
width: p.width,
|
|
996
|
+
height: p.height,
|
|
997
|
+
collapsed: true,
|
|
998
|
+
childCount: collapseInfo?.collapsedChildCounts.get(label) ?? 0,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
const edges: BLLayoutEdge[] = parsed.edges
|
|
1002
|
+
.filter((e) => g.hasEdge(e.source, e.target))
|
|
1003
|
+
.map((e) => {
|
|
1004
|
+
const ed = g.edge(e.source, e.target) as { points?: Pt[] };
|
|
1005
|
+
return {
|
|
1006
|
+
source: e.source,
|
|
1007
|
+
target: e.target,
|
|
1008
|
+
...(e.label !== undefined && { label: e.label }),
|
|
1009
|
+
bidirectional: e.bidirectional,
|
|
1010
|
+
lineNumber: e.lineNumber,
|
|
1011
|
+
points: ed?.points ?? [],
|
|
1012
|
+
yOffset: 0,
|
|
1013
|
+
parallelCount: 1,
|
|
1014
|
+
metadata: e.metadata,
|
|
1015
|
+
};
|
|
1016
|
+
});
|
|
1017
|
+
const gg = g.graph() as { width?: number; height?: number };
|
|
1018
|
+
return {
|
|
1019
|
+
nodes,
|
|
1020
|
+
edges,
|
|
1021
|
+
groups,
|
|
1022
|
+
width: gg.width ?? 800,
|
|
1023
|
+
height: gg.height ?? 600,
|
|
1024
|
+
} as BLLayoutResult;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const n = parsed.nodes.length;
|
|
1028
|
+
// ~500ms budget: search a larger pool, then refine the top few exactly.
|
|
1029
|
+
const seedCount =
|
|
1030
|
+
opts?.seeds ?? (n <= 12 ? 80 : n <= 22 ? 40 : n <= 35 ? 22 : 10);
|
|
1031
|
+
const REFINE_K = opts?.refineK ?? 6;
|
|
1032
|
+
const lambda = opts?.lambda ?? DEFAULT_LAMBDA;
|
|
1033
|
+
const prev = opts?.previousPositions;
|
|
1034
|
+
|
|
1035
|
+
// Candidate configs: every (ranker × spacing) combo + seed-shuffles of the
|
|
1036
|
+
// default. Diverse candidates lower the crossing floor; seed-shuffles vary
|
|
1037
|
+
// dagre's within-layer ordering.
|
|
1038
|
+
const RANKERS = ['network-simplex', 'tight-tree', 'longest-path'];
|
|
1039
|
+
const SPACINGS = [
|
|
1040
|
+
{ nodesep: 50, ranksep: 60 },
|
|
1041
|
+
{ nodesep: 34, ranksep: 46 },
|
|
1042
|
+
{ nodesep: 66, ranksep: 82 },
|
|
1043
|
+
];
|
|
1044
|
+
const configs: {
|
|
1045
|
+
ranker: string;
|
|
1046
|
+
nodesep: number;
|
|
1047
|
+
ranksep: number;
|
|
1048
|
+
seed?: number;
|
|
1049
|
+
}[] = [];
|
|
1050
|
+
for (const ranker of RANKERS)
|
|
1051
|
+
for (const sp of SPACINGS) configs.push({ ranker, ...sp });
|
|
1052
|
+
for (let s = 0; s < seedCount; s++)
|
|
1053
|
+
configs.push({
|
|
1054
|
+
ranker: 'network-simplex',
|
|
1055
|
+
nodesep: 50,
|
|
1056
|
+
ranksep: 60,
|
|
1057
|
+
seed: s,
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
// Honest "badness" — every kind of line-in-the-wrong-place counts equally:
|
|
1061
|
+
// X true crossings + O overlap runs (lines stepping on each other)
|
|
1062
|
+
// + P edges piercing unrelated node boxes.
|
|
1063
|
+
// (A line through a node and two lines sharing a path are crossings too.)
|
|
1064
|
+
// `floor` lets callers skip the expensive O/P passes once X alone already
|
|
1065
|
+
// exceeds the best badness found so far (it can't win, return Infinity).
|
|
1066
|
+
const badness = (lay: BLLayoutResult, floor: number): number => {
|
|
1067
|
+
const x = countSplineCrossings(lay);
|
|
1068
|
+
if (x > floor) return Infinity;
|
|
1069
|
+
return (
|
|
1070
|
+
x +
|
|
1071
|
+
countEdgeOverlaps(lay) +
|
|
1072
|
+
countEdgeNodePierces(lay) +
|
|
1073
|
+
countGroupOverlaps(lay)
|
|
1074
|
+
);
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
// Objective: badness dominates (×1e6, strictly fewer wins); ties broken by
|
|
1078
|
+
// total edge length (positioning) + stability drift (only when prev given).
|
|
1079
|
+
const objective = (lay: BLLayoutResult, viol: number) =>
|
|
1080
|
+
viol * 1e6 + edgeLength(lay) + lambda * meanDrift(lay, prev) * 10;
|
|
1081
|
+
|
|
1082
|
+
// Build the candidate pool.
|
|
1083
|
+
const pool: BLLayoutResult[] = [];
|
|
1084
|
+
for (const cfg of configs) {
|
|
1085
|
+
try {
|
|
1086
|
+
pool.push(place(cfg));
|
|
1087
|
+
} catch {
|
|
1088
|
+
/* some rankers choke on odd graphs */
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
if (!pool.length)
|
|
1092
|
+
return place({ ranker: 'network-simplex', nodesep: 50, ranksep: 60 });
|
|
1093
|
+
|
|
1094
|
+
// Home-grown layered candidates (flat graphs only). These own the
|
|
1095
|
+
// crossing-minimization stage AND route back-edges around the periphery, so
|
|
1096
|
+
// they can reach layouts below dagre's ordering+routing floor (e.g. the
|
|
1097
|
+
// pirate-fleet K2,2). Their peripheral back-edges are curved loops that the
|
|
1098
|
+
// cheap straight-segment ranker mis-scores, so they bypass stage-1 and are
|
|
1099
|
+
// ALWAYS exact-scored in stage 2. Best-effort: never block the dagre pool.
|
|
1100
|
+
let layered: BLLayoutResult[] = [];
|
|
1101
|
+
try {
|
|
1102
|
+
layered = layeredCandidates(parsed, sizes);
|
|
1103
|
+
} catch {
|
|
1104
|
+
/* ignore */
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Stage 1: rank the dagre pool with the cheap straight-segment counter — a
|
|
1108
|
+
// cheap proxy to pick which candidates are worth the expensive exact scoring.
|
|
1109
|
+
// Widen REFINE_K a little since the proxy only sees crossings, not O/P.
|
|
1110
|
+
pool.sort(
|
|
1111
|
+
(a, b) =>
|
|
1112
|
+
objective(a, countCrossingsFast(a)) - objective(b, countCrossingsFast(b))
|
|
1113
|
+
);
|
|
1114
|
+
const refineK = Math.min(REFINE_K, pool.length);
|
|
1115
|
+
|
|
1116
|
+
// Stage 2: exact-score the top-K dagre candidates on the FULL badness (X+O+P)
|
|
1117
|
+
// and pick the best — so the placement search avoids overlaps and node-pierces,
|
|
1118
|
+
// not just crossings.
|
|
1119
|
+
let best = pool[0]!;
|
|
1120
|
+
let bestObj = Infinity;
|
|
1121
|
+
let bestBad = Infinity;
|
|
1122
|
+
const consider = (lay: BLLayoutResult): void => {
|
|
1123
|
+
const bad = badness(lay, bestBad);
|
|
1124
|
+
if (bad === Infinity) return;
|
|
1125
|
+
const sc = objective(lay, bad);
|
|
1126
|
+
if (sc < bestObj) {
|
|
1127
|
+
bestObj = sc;
|
|
1128
|
+
bestBad = bad;
|
|
1129
|
+
best = lay;
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
for (const lay of pool.slice(0, refineK)) consider(lay);
|
|
1133
|
+
|
|
1134
|
+
// Adaptive escalation: a still-high badness after the base seed budget means
|
|
1135
|
+
// the graph is genuinely hard — dense layouts (e.g. the marketplace) need many
|
|
1136
|
+
// more random restarts to stumble onto a low-crossing layer ordering, and the
|
|
1137
|
+
// good ordering is rare enough that refining the existing pool can't find it
|
|
1138
|
+
// (only generating fresh seeds can). Spend an extra seed batch — but ONLY when
|
|
1139
|
+
// the result is actually bad, so the easy 0-badness majority of the corpus
|
|
1140
|
+
// never pays the latency. Bounded by node count to keep the worst case ~1s.
|
|
1141
|
+
if (bestBad >= ESCALATE_THRESHOLD && n <= ESCALATE_MAX_N) {
|
|
1142
|
+
const extra: BLLayoutResult[] = [];
|
|
1143
|
+
for (let s = seedCount; s < seedCount + ESCALATE_SEEDS; s++) {
|
|
1144
|
+
try {
|
|
1145
|
+
extra.push(
|
|
1146
|
+
place({
|
|
1147
|
+
ranker: 'network-simplex',
|
|
1148
|
+
nodesep: 50,
|
|
1149
|
+
ranksep: 60,
|
|
1150
|
+
seed: s,
|
|
1151
|
+
})
|
|
1152
|
+
);
|
|
1153
|
+
} catch {
|
|
1154
|
+
/* ignore choking rankers */
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
extra.sort(
|
|
1158
|
+
(a, b) =>
|
|
1159
|
+
objective(a, countCrossingsFast(a)) -
|
|
1160
|
+
objective(b, countCrossingsFast(b))
|
|
1161
|
+
);
|
|
1162
|
+
for (const lay of extra.slice(0, ESCALATE_REFINE)) consider(lay);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Layered candidates (and their better-routed, de-pierced variants) replace
|
|
1166
|
+
// the dagre winner ONLY on a STRICT total-badness reduction — never on an
|
|
1167
|
+
// edge-length tiebreak. They're few, so de-piercing each is cheap.
|
|
1168
|
+
for (const lay of layered) {
|
|
1169
|
+
const variants = [lay];
|
|
1170
|
+
const dp = deroutePierces(lay);
|
|
1171
|
+
if (dp !== lay) variants.push(dp);
|
|
1172
|
+
for (const v of variants) {
|
|
1173
|
+
const bad = badness(v, bestBad - 1);
|
|
1174
|
+
if (bad < bestBad) {
|
|
1175
|
+
bestBad = bad;
|
|
1176
|
+
best = v;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Feed the objective one more BETTER-ROUTED alternative: bend the current
|
|
1182
|
+
// winner's edges around any node they still pierce. Kept only if it strictly
|
|
1183
|
+
// lowers total badness (a detour that trades a pierce for a crossing/overlap
|
|
1184
|
+
// is rejected).
|
|
1185
|
+
if (bestBad > 0) {
|
|
1186
|
+
const rerouted = deroutePierces(best);
|
|
1187
|
+
if (rerouted !== best && badness(rerouted, bestBad - 1) < bestBad)
|
|
1188
|
+
best = rerouted;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Better-placed alternative: if the winner still has overlapping group bands
|
|
1192
|
+
// (dagre wedged a small group into a tight channel), push the bands apart along
|
|
1193
|
+
// the cross-axis. Kept only on strict total-badness drop.
|
|
1194
|
+
if (bestBad > 0 && countGroupOverlaps(best) > 0) {
|
|
1195
|
+
const separated = separateGroupBands(best, parsed);
|
|
1196
|
+
if (separated !== best && badness(separated, bestBad - 1) < bestBad)
|
|
1197
|
+
best = separated;
|
|
1198
|
+
}
|
|
1199
|
+
return best;
|
|
1200
|
+
}
|