@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,722 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Boxes and Lines — home-grown layered (Sugiyama) candidate generator
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// A from-scratch layered layout used to produce EXTRA candidates for the
|
|
6
|
+
// placement-search pool (see layout-search.ts). dagre's single wmedian+transpose
|
|
7
|
+
// gets stuck at an ordering local-optimum on some graphs (e.g. the pirate-fleet
|
|
8
|
+
// K2,2 fan-out: dagre floor = 2 crossings, the true minimum = 1). Owning the
|
|
9
|
+
// crossing-minimization stage — many random restarts of wmedian + transpose over
|
|
10
|
+
// a properly dummied layered graph — lets us reach orderings dagre never tries.
|
|
11
|
+
//
|
|
12
|
+
// This is ADDITIVE: the caller throws these candidates into the same pool as the
|
|
13
|
+
// dagre ones and the existing two-stage spline-crossing scorer keeps the best.
|
|
14
|
+
// If a candidate is worse it is simply never chosen — so there is no regression
|
|
15
|
+
// risk. Flat graphs only (no expanded containers); returns [] otherwise.
|
|
16
|
+
|
|
17
|
+
import type { ParsedBoxesAndLines } from './types';
|
|
18
|
+
import {
|
|
19
|
+
NODE_WIDTH,
|
|
20
|
+
NODE_HEIGHT,
|
|
21
|
+
type BLLayoutResult,
|
|
22
|
+
type BLLayoutEdge,
|
|
23
|
+
type BLLayoutNode,
|
|
24
|
+
} from './layout';
|
|
25
|
+
|
|
26
|
+
type Size = { width: number; height: number };
|
|
27
|
+
type Pt = { x: number; y: number };
|
|
28
|
+
|
|
29
|
+
const NODESEP = 50; // cross-axis gap between siblings in a rank
|
|
30
|
+
const RANKSEP = 60; // rank-axis gap between layers
|
|
31
|
+
const DUMMY_THICK = 10; // cross-axis footprint of a long-edge dummy
|
|
32
|
+
const MARGIN = 40;
|
|
33
|
+
|
|
34
|
+
// Edge-straightening weights for coordinate assignment (dagre-style): keep long
|
|
35
|
+
// edges (dummy chains) ruler-straight so they hug one side instead of sweeping
|
|
36
|
+
// across the diagram and manufacturing spline crossings.
|
|
37
|
+
const W_REAL_REAL = 1;
|
|
38
|
+
const W_REAL_DUMMY = 2;
|
|
39
|
+
const W_DUMMY_DUMMY = 8;
|
|
40
|
+
|
|
41
|
+
function rng(s: number) {
|
|
42
|
+
return () => {
|
|
43
|
+
s |= 0;
|
|
44
|
+
s = (s + 0x6d2b79f5) | 0;
|
|
45
|
+
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
46
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
47
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Graph model ────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
interface LNode {
|
|
54
|
+
id: string; // real node label, or `__d<edgeIdx>_<k>` for dummies
|
|
55
|
+
dummy: boolean;
|
|
56
|
+
rank: number;
|
|
57
|
+
cross: number; // cross-axis center coordinate
|
|
58
|
+
thick: number; // cross-axis footprint (width for TB, height for LR)
|
|
59
|
+
depth: number; // rank-axis footprint (height for TB, width for LR)
|
|
60
|
+
realW: number;
|
|
61
|
+
realH: number;
|
|
62
|
+
}
|
|
63
|
+
// A layered edge segment connects two nodes in adjacent ranks.
|
|
64
|
+
interface LSeg {
|
|
65
|
+
a: string; // upper-rank node id
|
|
66
|
+
b: string; // lower-rank node id
|
|
67
|
+
}
|
|
68
|
+
// The realized routing chain for one parsed edge (in source→target order).
|
|
69
|
+
interface EChain {
|
|
70
|
+
edgeIdx: number;
|
|
71
|
+
chain: string[]; // node ids, source ... target
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build several layered-layout candidates for a FLAT boxes-and-lines graph.
|
|
76
|
+
* Returns [] when the graph has expanded containers (groups), self-loops only,
|
|
77
|
+
* or is too trivial to benefit.
|
|
78
|
+
*/
|
|
79
|
+
export function layeredCandidates(
|
|
80
|
+
parsed: ParsedBoxesAndLines,
|
|
81
|
+
sizes: ReadonlyMap<string, Size>,
|
|
82
|
+
opts?: {
|
|
83
|
+
restarts?: number;
|
|
84
|
+
keepK?: number;
|
|
85
|
+
/** Cross-axis gap between siblings in a rank (default 50). */
|
|
86
|
+
nodesep?: number;
|
|
87
|
+
/** Rank-axis gap between layers (default 60). */
|
|
88
|
+
ranksep?: number;
|
|
89
|
+
/** Which side a back-edge loops around (default 'nearest'). */
|
|
90
|
+
backEdgeSide?: 'nearest' | 'left' | 'right';
|
|
91
|
+
}
|
|
92
|
+
): BLLayoutResult[] {
|
|
93
|
+
// Flat graphs only — containers need a group-aware variant (future work).
|
|
94
|
+
if (parsed.groups.length > 0) return [];
|
|
95
|
+
const nCount = parsed.nodes.length;
|
|
96
|
+
if (nCount < 3 || nCount > 40) return [];
|
|
97
|
+
|
|
98
|
+
const isTB = parsed.direction === 'TB';
|
|
99
|
+
const nodesep = opts?.nodesep ?? NODESEP;
|
|
100
|
+
const ranksep = opts?.ranksep ?? RANKSEP;
|
|
101
|
+
const backEdgeSide = opts?.backEdgeSide ?? 'nearest';
|
|
102
|
+
// Restart count scales down with size to stay inside the search budget; the
|
|
103
|
+
// engine is additive, so fewer restarts only risks missing a win, never a
|
|
104
|
+
// regression.
|
|
105
|
+
const restarts =
|
|
106
|
+
opts?.restarts ?? (nCount <= 14 ? 28 : nCount <= 25 ? 14 : 6);
|
|
107
|
+
const keepK = opts?.keepK ?? 3;
|
|
108
|
+
|
|
109
|
+
const labels = parsed.nodes.map((n) => n.label);
|
|
110
|
+
const labelSet = new Set(labels);
|
|
111
|
+
// Real, non-self edges with both endpoints present.
|
|
112
|
+
const edges = parsed.edges
|
|
113
|
+
.map((e, i) => ({ e, i }))
|
|
114
|
+
.filter(
|
|
115
|
+
({ e }) =>
|
|
116
|
+
labelSet.has(e.source) &&
|
|
117
|
+
labelSet.has(e.target) &&
|
|
118
|
+
e.source !== e.target
|
|
119
|
+
);
|
|
120
|
+
if (edges.length === 0) return [];
|
|
121
|
+
|
|
122
|
+
// ── Stage 1: make acyclic (DFS; reverse edges that close a cycle) ──
|
|
123
|
+
const adj = new Map<string, { to: string; idx: number }[]>();
|
|
124
|
+
for (const l of labels) adj.set(l, []);
|
|
125
|
+
for (const { e, i } of edges)
|
|
126
|
+
adj.get(e.source)!.push({ to: e.target, idx: i });
|
|
127
|
+
const reversed = new Set<number>(); // edge indices treated as reversed
|
|
128
|
+
const state = new Map<string, 0 | 1 | 2>(); // 0 unseen,1 on-stack,2 done
|
|
129
|
+
for (const l of labels) state.set(l, 0);
|
|
130
|
+
const dfs = (u: string): void => {
|
|
131
|
+
state.set(u, 1);
|
|
132
|
+
for (const { to, idx } of adj.get(u)!) {
|
|
133
|
+
const st = state.get(to)!;
|
|
134
|
+
if (st === 1)
|
|
135
|
+
reversed.add(idx); // back-edge
|
|
136
|
+
else if (st === 0) dfs(to);
|
|
137
|
+
}
|
|
138
|
+
state.set(u, 2);
|
|
139
|
+
};
|
|
140
|
+
for (const l of labels) if (state.get(l) === 0) dfs(l);
|
|
141
|
+
|
|
142
|
+
// DAG orientation of each edge: (from → to) with from-rank < to-rank.
|
|
143
|
+
const dag = edges.map(({ e, i }) =>
|
|
144
|
+
reversed.has(i)
|
|
145
|
+
? { from: e.target, to: e.source, idx: i, rev: true }
|
|
146
|
+
: { from: e.source, to: e.target, idx: i, rev: false }
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// ── Stage 2: longest-path ranking on the DAG ──
|
|
150
|
+
const outDag = new Map<string, string[]>();
|
|
151
|
+
const inDeg = new Map<string, number>();
|
|
152
|
+
for (const l of labels) {
|
|
153
|
+
outDag.set(l, []);
|
|
154
|
+
inDeg.set(l, 0);
|
|
155
|
+
}
|
|
156
|
+
for (const d of dag) {
|
|
157
|
+
outDag.get(d.from)!.push(d.to);
|
|
158
|
+
inDeg.set(d.to, (inDeg.get(d.to) ?? 0) + 1);
|
|
159
|
+
}
|
|
160
|
+
// Topo order (Kahn) then longest-path rank.
|
|
161
|
+
const rank = new Map<string, number>();
|
|
162
|
+
for (const l of labels) rank.set(l, 0);
|
|
163
|
+
const queue = labels.filter((l) => (inDeg.get(l) ?? 0) === 0);
|
|
164
|
+
const indeg2 = new Map(inDeg);
|
|
165
|
+
const topo: string[] = [];
|
|
166
|
+
while (queue.length) {
|
|
167
|
+
const u = queue.shift()!;
|
|
168
|
+
topo.push(u);
|
|
169
|
+
for (const v of outDag.get(u)!) {
|
|
170
|
+
rank.set(v, Math.max(rank.get(v)!, rank.get(u)! + 1));
|
|
171
|
+
indeg2.set(v, indeg2.get(v)! - 1);
|
|
172
|
+
if (indeg2.get(v) === 0) queue.push(v);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (topo.length !== labels.length) return []; // cycle slipped through — bail
|
|
176
|
+
// Pull sources down so single-child sources sit just above their child
|
|
177
|
+
// (avoids needless tall first layers). ALAP for zero-out-degree-safe nodes:
|
|
178
|
+
// keep as-is; longest-path is a fine, predictable baseline.
|
|
179
|
+
const maxRank = Math.max(...labels.map((l) => rank.get(l)!));
|
|
180
|
+
|
|
181
|
+
// ── Stage 3: dummy chains for edges spanning >1 rank ──
|
|
182
|
+
const node = new Map<string, LNode>();
|
|
183
|
+
for (const n of parsed.nodes) {
|
|
184
|
+
const s = sizes.get(n.label) ?? { width: NODE_WIDTH, height: NODE_HEIGHT };
|
|
185
|
+
node.set(n.label, {
|
|
186
|
+
id: n.label,
|
|
187
|
+
dummy: false,
|
|
188
|
+
rank: rank.get(n.label)!,
|
|
189
|
+
cross: 0,
|
|
190
|
+
thick: isTB ? s.width : s.height,
|
|
191
|
+
depth: isTB ? s.height : s.width,
|
|
192
|
+
realW: s.width,
|
|
193
|
+
realH: s.height,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const segs: LSeg[] = [];
|
|
197
|
+
const chains: EChain[] = [];
|
|
198
|
+
// Reversed (cycle-closing) edges are routed AROUND the periphery, not threaded
|
|
199
|
+
// through the core ranks as dummy chains. Threading a back-edge straight up
|
|
200
|
+
// through the layers makes it cross the forward fan it loops back over (this is
|
|
201
|
+
// exactly pirate-fleet's avoidable 2nd crossing). Keeping them out of the
|
|
202
|
+
// ordering problem lets the forward graph reach its true minimum.
|
|
203
|
+
const backEdges: { edgeIdx: number; src: string; tgt: string }[] = [];
|
|
204
|
+
for (const d of dag) {
|
|
205
|
+
if (d.rev) {
|
|
206
|
+
// d.from is the upper-rank endpoint (original target); d.to is the lower.
|
|
207
|
+
// Original orientation is source→target = d.to → d.from.
|
|
208
|
+
backEdges.push({ edgeIdx: d.idx, src: d.to, tgt: d.from });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const r0 = rank.get(d.from)!;
|
|
212
|
+
const r1 = rank.get(d.to)!;
|
|
213
|
+
const dagChain: string[] = [d.from];
|
|
214
|
+
let prev = d.from;
|
|
215
|
+
for (let r = r0 + 1; r < r1; r++) {
|
|
216
|
+
const id = `__d${d.idx}_${r}`;
|
|
217
|
+
node.set(id, {
|
|
218
|
+
id,
|
|
219
|
+
dummy: true,
|
|
220
|
+
rank: r,
|
|
221
|
+
cross: 0,
|
|
222
|
+
thick: DUMMY_THICK,
|
|
223
|
+
depth: 0,
|
|
224
|
+
realW: 0,
|
|
225
|
+
realH: 0,
|
|
226
|
+
});
|
|
227
|
+
segs.push({ a: prev, b: id });
|
|
228
|
+
dagChain.push(id);
|
|
229
|
+
prev = id;
|
|
230
|
+
}
|
|
231
|
+
segs.push({ a: prev, b: d.to });
|
|
232
|
+
dagChain.push(d.to);
|
|
233
|
+
// Output chain in original source→target orientation.
|
|
234
|
+
chains.push({
|
|
235
|
+
edgeIdx: d.idx,
|
|
236
|
+
chain: d.rev ? dagChain.slice().reverse() : dagChain,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Rank buckets.
|
|
241
|
+
const ranks: string[][] = Array.from({ length: maxRank + 1 }, () => []);
|
|
242
|
+
for (const n of node.values()) ranks[n.rank]!.push(n.id);
|
|
243
|
+
|
|
244
|
+
// Adjacency between adjacent ranks (for ordering + crossing count).
|
|
245
|
+
// up[id] = ids in rank-1 connected; down[id] = ids in rank+1 connected.
|
|
246
|
+
const up = new Map<string, string[]>();
|
|
247
|
+
const down = new Map<string, string[]>();
|
|
248
|
+
for (const id of node.keys()) {
|
|
249
|
+
up.set(id, []);
|
|
250
|
+
down.set(id, []);
|
|
251
|
+
}
|
|
252
|
+
for (const s of segs) {
|
|
253
|
+
down.get(s.a)!.push(s.b);
|
|
254
|
+
up.get(s.b)!.push(s.a);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Stage 4: ordering — many restarts of wmedian + transpose ──
|
|
258
|
+
const orderOf = new Map<string, number>();
|
|
259
|
+
const applyOrder = (ord: ReadonlyMap<string, number>): void => {
|
|
260
|
+
for (const id of node.keys()) orderOf.set(id, ord.get(id)!);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// Crossings between ranks r and r+1 given current orderOf (quadratic in the
|
|
264
|
+
// segment count of that gap — graphs here are tiny).
|
|
265
|
+
const gapCrossings = (r: number): number => {
|
|
266
|
+
const lower = ranks[r + 1];
|
|
267
|
+
if (!lower) return 0;
|
|
268
|
+
const es: { p: number; q: number }[] = [];
|
|
269
|
+
for (const a of ranks[r]!) {
|
|
270
|
+
const pa = orderOf.get(a)!;
|
|
271
|
+
for (const b of down.get(a)!) es.push({ p: pa, q: orderOf.get(b)! });
|
|
272
|
+
}
|
|
273
|
+
let c = 0;
|
|
274
|
+
for (let i = 0; i < es.length; i++)
|
|
275
|
+
for (let j = i + 1; j < es.length; j++) {
|
|
276
|
+
const A = es[i]!,
|
|
277
|
+
B = es[j]!;
|
|
278
|
+
if ((A.p - B.p) * (A.q - B.q) < 0) c++;
|
|
279
|
+
}
|
|
280
|
+
return c;
|
|
281
|
+
};
|
|
282
|
+
const totalCrossings = (): number => {
|
|
283
|
+
let c = 0;
|
|
284
|
+
for (let r = 0; r < maxRank; r++) c += gapCrossings(r);
|
|
285
|
+
return c;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const median = (vals: number[]): number => {
|
|
289
|
+
if (vals.length === 0) return -1;
|
|
290
|
+
vals.sort((x, y) => x - y);
|
|
291
|
+
const m = Math.floor(vals.length / 2);
|
|
292
|
+
if (vals.length % 2 === 1) return vals[m]!;
|
|
293
|
+
if (vals.length === 2) return (vals[0]! + vals[1]!) / 2;
|
|
294
|
+
const left = vals[m - 1]! - vals[0]!;
|
|
295
|
+
const right = vals[vals.length - 1]! - vals[m]!;
|
|
296
|
+
return left + right === 0
|
|
297
|
+
? (vals[m - 1]! + vals[m]!) / 2
|
|
298
|
+
: (vals[m - 1]! * right + vals[m]! * left) / (left + right);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// One median sweep over a rank using the neighbour rank's current order.
|
|
302
|
+
// No-neighbour nodes (median = -1) stay pinned at their current slot; the rest
|
|
303
|
+
// are sorted by median and fill the remaining slots in order.
|
|
304
|
+
const wmedianRank = (r: number, useUp: boolean): void => {
|
|
305
|
+
const ids = ranks[r]!;
|
|
306
|
+
const neigh = useUp ? up : down;
|
|
307
|
+
const med = new Map<string, number>();
|
|
308
|
+
for (const id of ids)
|
|
309
|
+
med.set(id, median(neigh.get(id)!.map((x) => orderOf.get(x)!)));
|
|
310
|
+
const movable = ids
|
|
311
|
+
.filter((id) => med.get(id)! >= 0)
|
|
312
|
+
.sort((a, b) => med.get(a)! - med.get(b)!);
|
|
313
|
+
const out = new Array<string | null>(ids.length).fill(null);
|
|
314
|
+
for (const id of ids) if (med.get(id)! < 0) out[orderOf.get(id)!] = id;
|
|
315
|
+
let mi = 0;
|
|
316
|
+
for (let slot = 0; slot < out.length; slot++)
|
|
317
|
+
if (out[slot] === null) out[slot] = movable[mi++]!;
|
|
318
|
+
const final = out as string[];
|
|
319
|
+
final.forEach((id, i) => orderOf.set(id, i));
|
|
320
|
+
ranks[r] = final;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// Greedy adjacent-swap transpose until no rank pair improves.
|
|
324
|
+
const transpose = (): void => {
|
|
325
|
+
let improved = true;
|
|
326
|
+
let guard = 0;
|
|
327
|
+
while (improved && guard++ < 12) {
|
|
328
|
+
improved = false;
|
|
329
|
+
for (let r = 0; r <= maxRank; r++) {
|
|
330
|
+
const ids = ranks[r]!;
|
|
331
|
+
for (let i = 0; i < ids.length - 1; i++) {
|
|
332
|
+
const before =
|
|
333
|
+
(r > 0 ? gapCrossings(r - 1) : 0) +
|
|
334
|
+
(r < maxRank ? gapCrossings(r) : 0);
|
|
335
|
+
// swap i, i+1
|
|
336
|
+
const a = ids[i]!,
|
|
337
|
+
b = ids[i + 1]!;
|
|
338
|
+
ids[i] = b;
|
|
339
|
+
ids[i + 1] = a;
|
|
340
|
+
orderOf.set(b, i);
|
|
341
|
+
orderOf.set(a, i + 1);
|
|
342
|
+
const after =
|
|
343
|
+
(r > 0 ? gapCrossings(r - 1) : 0) +
|
|
344
|
+
(r < maxRank ? gapCrossings(r) : 0);
|
|
345
|
+
if (after < before) {
|
|
346
|
+
improved = true;
|
|
347
|
+
} else {
|
|
348
|
+
// revert
|
|
349
|
+
ids[i] = a;
|
|
350
|
+
ids[i + 1] = b;
|
|
351
|
+
orderOf.set(a, i);
|
|
352
|
+
orderOf.set(b, i + 1);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const initOrder = (seed: number): void => {
|
|
360
|
+
const r = seed === 0 ? null : rng(seed);
|
|
361
|
+
for (let rr = 0; rr <= maxRank; rr++) {
|
|
362
|
+
const ids = ranks[rr]!.slice();
|
|
363
|
+
if (r) {
|
|
364
|
+
for (let i = ids.length - 1; i > 0; i--) {
|
|
365
|
+
const j = Math.floor(r() * (i + 1));
|
|
366
|
+
[ids[i], ids[j]] = [ids[j]!, ids[i]!];
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
ranks[rr] = ids;
|
|
370
|
+
ids.forEach((id, i) => orderOf.set(id, i));
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// Run restarts; keep the best few DISTINCT orderings (by signature) to realize.
|
|
375
|
+
const found: { sig: string; cross: number; order: Map<string, number> }[] =
|
|
376
|
+
[];
|
|
377
|
+
for (let s = 0; s < restarts; s++) {
|
|
378
|
+
initOrder(s);
|
|
379
|
+
let best = totalCrossings();
|
|
380
|
+
let bestSnap = new Map(orderOf);
|
|
381
|
+
for (let iter = 0; iter < 8; iter++) {
|
|
382
|
+
const down1 = iter % 2 === 0;
|
|
383
|
+
if (down1) for (let r = 1; r <= maxRank; r++) wmedianRank(r, true);
|
|
384
|
+
else for (let r = maxRank - 1; r >= 0; r--) wmedianRank(r, false);
|
|
385
|
+
transpose();
|
|
386
|
+
const c = totalCrossings();
|
|
387
|
+
if (c < best) {
|
|
388
|
+
best = c;
|
|
389
|
+
bestSnap = new Map(orderOf);
|
|
390
|
+
// re-pin ranks to the snapshot
|
|
391
|
+
}
|
|
392
|
+
if (c === 0) break;
|
|
393
|
+
}
|
|
394
|
+
applyOrder(bestSnap);
|
|
395
|
+
// rebuild ranks arrays from bestSnap order
|
|
396
|
+
for (let r = 0; r <= maxRank; r++)
|
|
397
|
+
ranks[r]!.sort((a, b) => orderOf.get(a)! - orderOf.get(b)!);
|
|
398
|
+
const sig = ranks.map((r) => r.join(',')).join('|');
|
|
399
|
+
if (!found.some((f) => f.sig === sig))
|
|
400
|
+
found.push({ sig, cross: best, order: new Map(bestSnap) });
|
|
401
|
+
}
|
|
402
|
+
found.sort((a, b) => a.cross - b.cross);
|
|
403
|
+
const keep = found.slice(0, keepK);
|
|
404
|
+
if (keep.length === 0) return [];
|
|
405
|
+
|
|
406
|
+
// ── Stage 5+6: coordinate assignment & realization, per kept ordering ──
|
|
407
|
+
const results: BLLayoutResult[] = [];
|
|
408
|
+
for (const cand of keep) {
|
|
409
|
+
applyOrder(cand.order);
|
|
410
|
+
// rebuild rank arrays from this ordering
|
|
411
|
+
const rk: string[][] = Array.from({ length: maxRank + 1 }, () => []);
|
|
412
|
+
for (const n of node.values()) rk[n.rank]!.push(n.id);
|
|
413
|
+
for (let r = 0; r <= maxRank; r++)
|
|
414
|
+
rk[r]!.sort((a, b) => cand.order.get(a)! - cand.order.get(b)!);
|
|
415
|
+
results.push(realize(rk));
|
|
416
|
+
}
|
|
417
|
+
return results;
|
|
418
|
+
|
|
419
|
+
// Assign rank-axis & cross-axis coordinates, then build the layout result.
|
|
420
|
+
function realize(rk: string[][]): BLLayoutResult {
|
|
421
|
+
// Rank-axis band centers: stack by max depth in each rank + RANKSEP.
|
|
422
|
+
const bandDepth = rk.map((ids) =>
|
|
423
|
+
Math.max(0, ...ids.map((id) => node.get(id)!.depth))
|
|
424
|
+
);
|
|
425
|
+
const bandCenter: number[] = [];
|
|
426
|
+
let acc = MARGIN;
|
|
427
|
+
for (let r = 0; r <= maxRank; r++) {
|
|
428
|
+
bandCenter[r] = acc + bandDepth[r]! / 2;
|
|
429
|
+
acc += bandDepth[r]! + ranksep;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Cross-axis: init evenly spaced, then iterate median alignment with a
|
|
433
|
+
// separation-preserving placement (minimize displacement subject to gaps).
|
|
434
|
+
for (let r = 0; r <= maxRank; r++) {
|
|
435
|
+
let x = MARGIN;
|
|
436
|
+
for (const id of rk[r]!) {
|
|
437
|
+
const n = node.get(id)!;
|
|
438
|
+
n.cross = x + n.thick / 2;
|
|
439
|
+
x += n.thick + nodesep;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const ITER = 18;
|
|
443
|
+
for (let it = 0; it < ITER; it++) {
|
|
444
|
+
const downPass = it % 2 === 0;
|
|
445
|
+
const order = downPass
|
|
446
|
+
? Array.from({ length: maxRank + 1 }, (_, i) => i)
|
|
447
|
+
: Array.from({ length: maxRank + 1 }, (_, i) => maxRank - i);
|
|
448
|
+
for (const r of order) {
|
|
449
|
+
const ids = rk[r]!;
|
|
450
|
+
const desired = ids.map((id) => {
|
|
451
|
+
const n = node.get(id)!;
|
|
452
|
+
// weighted average of neighbour cross-coords (both directions)
|
|
453
|
+
let sw = 0,
|
|
454
|
+
sx = 0;
|
|
455
|
+
for (const nb of up.get(id)!) {
|
|
456
|
+
const w = weight(n, node.get(nb)!);
|
|
457
|
+
sw += w;
|
|
458
|
+
sx += w * node.get(nb)!.cross;
|
|
459
|
+
}
|
|
460
|
+
for (const nb of down.get(id)!) {
|
|
461
|
+
const w = weight(n, node.get(nb)!);
|
|
462
|
+
sw += w;
|
|
463
|
+
sx += w * node.get(nb)!.cross;
|
|
464
|
+
}
|
|
465
|
+
return sw > 0 ? sx / sw : n.cross;
|
|
466
|
+
});
|
|
467
|
+
placeWithSeparation(ids, desired);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Normalize so min content x = MARGIN.
|
|
472
|
+
let minC = Infinity;
|
|
473
|
+
for (const n of node.values()) minC = Math.min(minC, n.cross - n.thick / 2);
|
|
474
|
+
const shift = MARGIN - minC;
|
|
475
|
+
for (const n of node.values()) n.cross += shift;
|
|
476
|
+
|
|
477
|
+
// Map (rank-axis, cross-axis) → (x, y).
|
|
478
|
+
const coord = (n: LNode): Pt =>
|
|
479
|
+
isTB
|
|
480
|
+
? { x: n.cross, y: bandCenter[n.rank]! }
|
|
481
|
+
: { x: bandCenter[n.rank]!, y: n.cross };
|
|
482
|
+
|
|
483
|
+
const layoutNodes: BLLayoutNode[] = parsed.nodes.map((pn) => {
|
|
484
|
+
const n = node.get(pn.label)!;
|
|
485
|
+
const c = coord(n);
|
|
486
|
+
return {
|
|
487
|
+
label: pn.label,
|
|
488
|
+
x: c.x,
|
|
489
|
+
y: c.y,
|
|
490
|
+
width: n.realW,
|
|
491
|
+
height: n.realH,
|
|
492
|
+
};
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const chainById = new Map<number, string[]>();
|
|
496
|
+
for (const ch of chains) chainById.set(ch.edgeIdx, ch.chain);
|
|
497
|
+
|
|
498
|
+
// Peripheral routing for back-edges. Each loops around the nearer side; the
|
|
499
|
+
// lane offset (k) nests multiple back-edges on the same side so they don't
|
|
500
|
+
// overlap. Repeated lane corner points pin the curveBasis spline outward.
|
|
501
|
+
let minCross = Infinity,
|
|
502
|
+
maxCross = -Infinity;
|
|
503
|
+
for (const n of node.values()) {
|
|
504
|
+
if (n.dummy) continue;
|
|
505
|
+
minCross = Math.min(minCross, n.cross - n.thick / 2);
|
|
506
|
+
maxCross = Math.max(maxCross, n.cross + n.thick / 2);
|
|
507
|
+
}
|
|
508
|
+
const GAP = 34;
|
|
509
|
+
const LANE = 28; // sibling back-edge lane spacing — wide enough not to "almost touch"
|
|
510
|
+
// Pick the loop side by the SOURCE's position — it's the endpoint that must
|
|
511
|
+
// escape sideways to a lane; the target is typically central (a hub). A
|
|
512
|
+
// left-of-centre source loops left, a right-of-centre source loops right, so
|
|
513
|
+
// returns don't all pile onto one side and stack.
|
|
514
|
+
const sideOf = (b: { src: string; tgt: string }): number => {
|
|
515
|
+
if (backEdgeSide === 'left') return -1;
|
|
516
|
+
if (backEdgeSide === 'right') return 1;
|
|
517
|
+
// Loop on the side the source sits relative to its (usually central) target
|
|
518
|
+
// — splits returns left/right instead of piling them on one side.
|
|
519
|
+
const d = node.get(b.src)!.cross - node.get(b.tgt)!.cross;
|
|
520
|
+
return d >= 0 ? 1 : -1;
|
|
521
|
+
};
|
|
522
|
+
// Assign lanes: per side, longer rank-spans go further out (lower k = inner).
|
|
523
|
+
const backSorted = backEdges
|
|
524
|
+
.map((b) => ({
|
|
525
|
+
...b,
|
|
526
|
+
side: sideOf(b),
|
|
527
|
+
span: Math.abs(node.get(b.src)!.rank - node.get(b.tgt)!.rank),
|
|
528
|
+
}))
|
|
529
|
+
.sort((a, b) => a.side - b.side || a.span - b.span);
|
|
530
|
+
const sideCounts = new Map<number, number>();
|
|
531
|
+
for (const b of backSorted)
|
|
532
|
+
sideCounts.set(b.side, (sideCounts.get(b.side) ?? 0) + 1);
|
|
533
|
+
const laneCounter = new Map<number, number>();
|
|
534
|
+
const backPoints = new Map<number, Pt[]>();
|
|
535
|
+
const faceLim = (depth: number): number => Math.max(0, depth / 2 - 6);
|
|
536
|
+
// Group same-side returns by shared TARGET. Siblings converging on one hub
|
|
537
|
+
// would otherwise stack their final approach along the same node edge (the
|
|
538
|
+
// pirate-fleet residual: two right-side returns into Captain). Give the inner
|
|
539
|
+
// one the side face and route the outer one(s) over the target's rank-axis
|
|
540
|
+
// top face instead, so the two converging arcs enter on different faces.
|
|
541
|
+
const hubIndex = new Map<number, number>(); // edgeIdx → order among (side,tgt)
|
|
542
|
+
const hubSize = new Map<string, number>();
|
|
543
|
+
{
|
|
544
|
+
const counter = new Map<string, number>();
|
|
545
|
+
for (const b of backSorted) {
|
|
546
|
+
const key = `${b.side}|${b.tgt}`;
|
|
547
|
+
const idx = counter.get(key) ?? 0;
|
|
548
|
+
counter.set(key, idx + 1);
|
|
549
|
+
hubIndex.set(b.edgeIdx, idx);
|
|
550
|
+
}
|
|
551
|
+
for (const [key, v] of counter) hubSize.set(key, v);
|
|
552
|
+
}
|
|
553
|
+
for (const b of backSorted) {
|
|
554
|
+
const s = b.side;
|
|
555
|
+
const k = laneCounter.get(s) ?? 0;
|
|
556
|
+
laneCounter.set(s, k + 1);
|
|
557
|
+
const K = sideCounts.get(s)!;
|
|
558
|
+
const src = node.get(b.src)!;
|
|
559
|
+
const tgt = node.get(b.tgt)!;
|
|
560
|
+
const laneC =
|
|
561
|
+
s > 0 ? maxCross + GAP + k * LANE : minCross - GAP - k * LANE;
|
|
562
|
+
const srcCtr = bandCenter[src.rank]!;
|
|
563
|
+
const tgtCtr = bandCenter[tgt.rank]!;
|
|
564
|
+
// Nested attachments for same-side siblings: outer lane attaches further
|
|
565
|
+
// out at BOTH ends so the arcs nest (never cross). Enter the (upper) target
|
|
566
|
+
// from its top half — away from its downward forward edges; leave the
|
|
567
|
+
// (lower) source from its bottom half. Distributed by lane order across the
|
|
568
|
+
// node face, so siblings into a shared hub fan apart instead of stacking.
|
|
569
|
+
const frac = K > 1 ? (k + 1) / (K + 1) : 0;
|
|
570
|
+
const srcR = srcCtr + frac * faceLim(src.depth);
|
|
571
|
+
const tgtR = tgtCtr - frac * faceLim(tgt.depth);
|
|
572
|
+
const m = (c: number, r: number): Pt =>
|
|
573
|
+
isTB ? { x: c, y: r } : { x: r, y: c };
|
|
574
|
+
// Aesthetic swoop: a single smooth loop, not a pinned rectangle. The curve
|
|
575
|
+
// eases off the source's side, bows out to its lane (single control points
|
|
576
|
+
// → curveBasis rounds the corners gracefully, no overshoot-and-return), and
|
|
577
|
+
// eases back into the target's side. Whatever node a softer curve now clips
|
|
578
|
+
// is bent away by the de-pierce pass downstream — so routing stays pretty
|
|
579
|
+
// and obstacle-avoidance is handled separately.
|
|
580
|
+
const off = 10;
|
|
581
|
+
const srcEdgeC = src.cross + s * (src.thick / 2 + off);
|
|
582
|
+
const tgtEdgeC = tgt.cross + s * (tgt.thick / 2 + off);
|
|
583
|
+
const hubK = hubIndex.get(b.edgeIdx)!;
|
|
584
|
+
const hubN = hubSize.get(`${s}|${b.tgt}`)!;
|
|
585
|
+
if (hubN > 1 && hubK > 0) {
|
|
586
|
+
// Outer sibling of a shared hub: come over the target's top (rank-axis)
|
|
587
|
+
// face. Rise up the lane past the node's top, cross above it, then drop
|
|
588
|
+
// straight into the centre — a clean loop that never shares the side
|
|
589
|
+
// edge the inner sibling hugs. Nested by hubK so 3+ returns stay apart.
|
|
590
|
+
const tgtTop = tgtCtr - (tgt.depth / 2 + GAP + (hubK - 1) * LANE);
|
|
591
|
+
const entryCross = tgt.cross + s * faceLim(tgt.thick) * (hubK / hubN);
|
|
592
|
+
backPoints.set(b.edgeIdx, [
|
|
593
|
+
m(src.cross, srcCtr),
|
|
594
|
+
m(srcEdgeC, srcR),
|
|
595
|
+
m(laneC, srcR),
|
|
596
|
+
m(laneC, tgtTop),
|
|
597
|
+
m(entryCross, tgtTop),
|
|
598
|
+
m(tgt.cross, tgtCtr),
|
|
599
|
+
]);
|
|
600
|
+
} else {
|
|
601
|
+
// Aesthetic swoop into the side face (the inner / sole sibling).
|
|
602
|
+
backPoints.set(b.edgeIdx, [
|
|
603
|
+
m(src.cross, srcCtr),
|
|
604
|
+
m(srcEdgeC, srcR),
|
|
605
|
+
m(laneC, srcR),
|
|
606
|
+
m(laneC, tgtR),
|
|
607
|
+
m(tgtEdgeC, tgtR),
|
|
608
|
+
m(tgt.cross, tgtCtr),
|
|
609
|
+
]);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const layoutEdges: BLLayoutEdge[] = [];
|
|
614
|
+
for (let i = 0; i < parsed.edges.length; i++) {
|
|
615
|
+
const e = parsed.edges[i]!;
|
|
616
|
+
if (!labelSet.has(e.source) || !labelSet.has(e.target)) continue;
|
|
617
|
+
let points: Pt[];
|
|
618
|
+
if (e.source === e.target) {
|
|
619
|
+
const c = coord(node.get(e.source)!);
|
|
620
|
+
points = [c, c];
|
|
621
|
+
} else if (backPoints.has(i)) {
|
|
622
|
+
points = backPoints.get(i)!;
|
|
623
|
+
} else {
|
|
624
|
+
const chain = chainById.get(i);
|
|
625
|
+
if (!chain) continue;
|
|
626
|
+
points = chain.map((id) => coord(node.get(id)!));
|
|
627
|
+
}
|
|
628
|
+
layoutEdges.push({
|
|
629
|
+
source: e.source,
|
|
630
|
+
target: e.target,
|
|
631
|
+
...(e.label !== undefined && { label: e.label }),
|
|
632
|
+
bidirectional: e.bidirectional,
|
|
633
|
+
lineNumber: e.lineNumber,
|
|
634
|
+
points,
|
|
635
|
+
yOffset: 0,
|
|
636
|
+
parallelCount: 1,
|
|
637
|
+
metadata: e.metadata,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Content bbox over nodes AND edge points (peripheral lanes can poke past
|
|
642
|
+
// the node extent, or left of x=0). Shift so everything clears the margin.
|
|
643
|
+
let minX = Infinity,
|
|
644
|
+
minY = Infinity,
|
|
645
|
+
maxX = -Infinity,
|
|
646
|
+
maxY = -Infinity;
|
|
647
|
+
for (const n of layoutNodes) {
|
|
648
|
+
minX = Math.min(minX, n.x - n.width / 2);
|
|
649
|
+
minY = Math.min(minY, n.y - n.height / 2);
|
|
650
|
+
maxX = Math.max(maxX, n.x + n.width / 2);
|
|
651
|
+
maxY = Math.max(maxY, n.y + n.height / 2);
|
|
652
|
+
}
|
|
653
|
+
for (const e of layoutEdges)
|
|
654
|
+
for (const p of e.points) {
|
|
655
|
+
minX = Math.min(minX, p.x);
|
|
656
|
+
minY = Math.min(minY, p.y);
|
|
657
|
+
maxX = Math.max(maxX, p.x);
|
|
658
|
+
maxY = Math.max(maxY, p.y);
|
|
659
|
+
}
|
|
660
|
+
const sx = MARGIN - minX;
|
|
661
|
+
const sy = MARGIN - minY;
|
|
662
|
+
const shifted =
|
|
663
|
+
sx !== 0 || sy !== 0
|
|
664
|
+
? {
|
|
665
|
+
nodes: layoutNodes.map((n) => ({ ...n, x: n.x + sx, y: n.y + sy })),
|
|
666
|
+
edges: layoutEdges.map((e) => ({
|
|
667
|
+
...e,
|
|
668
|
+
points: e.points.map((p) => ({ x: p.x + sx, y: p.y + sy })),
|
|
669
|
+
})),
|
|
670
|
+
}
|
|
671
|
+
: { nodes: layoutNodes, edges: layoutEdges };
|
|
672
|
+
return {
|
|
673
|
+
nodes: shifted.nodes,
|
|
674
|
+
edges: shifted.edges,
|
|
675
|
+
groups: [],
|
|
676
|
+
width: maxX + sx + MARGIN,
|
|
677
|
+
height: maxY + sy + MARGIN,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function weight(a: LNode, b: LNode): number {
|
|
682
|
+
if (a.dummy && b.dummy) return W_DUMMY_DUMMY;
|
|
683
|
+
if (a.dummy || b.dummy) return W_REAL_DUMMY;
|
|
684
|
+
return W_REAL_REAL;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Place ordered nodes at desired cross-coords, preserving order and minimum
|
|
688
|
+
// separation, minimizing squared displacement (block-merging / PAV).
|
|
689
|
+
function placeWithSeparation(ids: string[], desired: number[]): void {
|
|
690
|
+
const n = ids.length;
|
|
691
|
+
if (n === 0) return;
|
|
692
|
+
// gap[i] = required center-to-center min between i-1 and i
|
|
693
|
+
const half = ids.map((id) => node.get(id)!.thick / 2);
|
|
694
|
+
// Blocks of nodes pinned together; each stores weighted desired sum.
|
|
695
|
+
type Block = {
|
|
696
|
+
pos: number;
|
|
697
|
+
count: number;
|
|
698
|
+
sumOffset: number;
|
|
699
|
+
first: number;
|
|
700
|
+
};
|
|
701
|
+
// Convert desired to "left-anchored" desired (subtract cumulative min gaps)
|
|
702
|
+
const off: number[] = [0];
|
|
703
|
+
for (let i = 1; i < n; i++)
|
|
704
|
+
off[i] = off[i - 1]! + half[i - 1]! + nodesep + half[i]!;
|
|
705
|
+
const d = desired.map((v, i) => v - off[i]!);
|
|
706
|
+
const blocks: Block[] = [];
|
|
707
|
+
for (let i = 0; i < n; i++) {
|
|
708
|
+
let b: Block = { pos: d[i]!, count: 1, sumOffset: d[i]!, first: i };
|
|
709
|
+
while (blocks.length && blocks[blocks.length - 1]!.pos >= b.pos) {
|
|
710
|
+
const prev = blocks.pop()!;
|
|
711
|
+
const count = prev.count + b.count;
|
|
712
|
+
const sumOffset = prev.sumOffset + b.sumOffset;
|
|
713
|
+
b = { pos: sumOffset / count, count, sumOffset, first: prev.first };
|
|
714
|
+
}
|
|
715
|
+
blocks.push(b);
|
|
716
|
+
}
|
|
717
|
+
const xs = new Array<number>(n);
|
|
718
|
+
for (const b of blocks)
|
|
719
|
+
for (let k = 0; k < b.count; k++) xs[b.first + k] = b.pos;
|
|
720
|
+
for (let i = 0; i < n; i++) node.get(ids[i]!)!.cross = xs[i]! + off[i]!;
|
|
721
|
+
}
|
|
722
|
+
}
|