@kgdiff/layout 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -2
  3. package/dist/components.d.ts +11 -0
  4. package/dist/components.d.ts.map +1 -0
  5. package/dist/components.js +56 -0
  6. package/dist/components.js.map +1 -0
  7. package/dist/engine.d.ts +20 -0
  8. package/dist/engine.d.ts.map +1 -0
  9. package/dist/engine.js +336 -0
  10. package/dist/engine.js.map +1 -0
  11. package/dist/extend.d.ts +7 -0
  12. package/dist/extend.d.ts.map +1 -0
  13. package/dist/extend.js +68 -0
  14. package/dist/extend.js.map +1 -0
  15. package/dist/forces/collide-rect.d.ts +50 -0
  16. package/dist/forces/collide-rect.d.ts.map +1 -0
  17. package/dist/forces/collide-rect.js +310 -0
  18. package/dist/forces/collide-rect.js.map +1 -0
  19. package/dist/forces/free-spot.d.ts +40 -0
  20. package/dist/forces/free-spot.d.ts.map +1 -0
  21. package/dist/forces/free-spot.js +189 -0
  22. package/dist/forces/free-spot.js.map +1 -0
  23. package/dist/forces/params.d.ts +38 -0
  24. package/dist/forces/params.d.ts.map +1 -0
  25. package/dist/forces/params.js +55 -0
  26. package/dist/forces/params.js.map +1 -0
  27. package/dist/forces/springs.d.ts +20 -0
  28. package/dist/forces/springs.d.ts.map +1 -0
  29. package/dist/forces/springs.js +34 -0
  30. package/dist/forces/springs.js.map +1 -0
  31. package/dist/geometry.d.ts +21 -0
  32. package/dist/geometry.d.ts.map +1 -0
  33. package/dist/geometry.js +39 -0
  34. package/dist/geometry.js.map +1 -0
  35. package/dist/graph.d.ts +38 -0
  36. package/dist/graph.d.ts.map +1 -0
  37. package/dist/graph.js +82 -0
  38. package/dist/graph.js.map +1 -0
  39. package/dist/index.d.ts +162 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +87 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/init.d.ts +28 -0
  44. package/dist/init.d.ts.map +1 -0
  45. package/dist/init.js +196 -0
  46. package/dist/init.js.map +1 -0
  47. package/dist/json.d.ts +4 -0
  48. package/dist/json.d.ts.map +1 -0
  49. package/dist/json.js +112 -0
  50. package/dist/json.js.map +1 -0
  51. package/dist/layout.d.ts +7 -0
  52. package/dist/layout.d.ts.map +1 -0
  53. package/dist/layout.js +70 -0
  54. package/dist/layout.js.map +1 -0
  55. package/dist/math.d.ts +29 -0
  56. package/dist/math.d.ts.map +1 -0
  57. package/dist/math.js +91 -0
  58. package/dist/math.js.map +1 -0
  59. package/dist/options.d.ts +43 -0
  60. package/dist/options.d.ts.map +1 -0
  61. package/dist/options.js +76 -0
  62. package/dist/options.js.map +1 -0
  63. package/dist/pack.d.ts +43 -0
  64. package/dist/pack.d.ts.map +1 -0
  65. package/dist/pack.js +168 -0
  66. package/dist/pack.js.map +1 -0
  67. package/dist/prng.d.ts +20 -0
  68. package/dist/prng.d.ts.map +1 -0
  69. package/dist/prng.js +53 -0
  70. package/dist/prng.js.map +1 -0
  71. package/dist/result.d.ts +20 -0
  72. package/dist/result.d.ts.map +1 -0
  73. package/dist/result.js +40 -0
  74. package/dist/result.js.map +1 -0
  75. package/dist/scope.d.ts +25 -0
  76. package/dist/scope.d.ts.map +1 -0
  77. package/dist/scope.js +71 -0
  78. package/dist/scope.js.map +1 -0
  79. package/dist/simulate.d.ts +64 -0
  80. package/dist/simulate.d.ts.map +1 -0
  81. package/dist/simulate.js +247 -0
  82. package/dist/simulate.js.map +1 -0
  83. package/dist/text-metrics.d.ts +32 -0
  84. package/dist/text-metrics.d.ts.map +1 -0
  85. package/dist/text-metrics.js +153 -0
  86. package/dist/text-metrics.js.map +1 -0
  87. package/dist/union.d.ts +32 -0
  88. package/dist/union.d.ts.map +1 -0
  89. package/dist/union.js +106 -0
  90. package/dist/union.js.map +1 -0
  91. package/package.json +52 -3
  92. package/src/components.ts +56 -0
  93. package/src/engine.ts +444 -0
  94. package/src/extend.ts +75 -0
  95. package/src/forces/collide-rect.ts +385 -0
  96. package/src/forces/free-spot.ts +243 -0
  97. package/src/forces/params.ts +88 -0
  98. package/src/forces/springs.ts +50 -0
  99. package/src/geometry.ts +66 -0
  100. package/src/graph.ts +120 -0
  101. package/src/index.ts +193 -0
  102. package/src/init.ts +246 -0
  103. package/src/json.ts +131 -0
  104. package/src/layout.ts +87 -0
  105. package/src/math.ts +91 -0
  106. package/src/options.ts +153 -0
  107. package/src/pack.ts +228 -0
  108. package/src/prng.ts +59 -0
  109. package/src/result.ts +63 -0
  110. package/src/scope.ts +86 -0
  111. package/src/simulate.ts +328 -0
  112. package/src/text-metrics.ts +157 -0
  113. package/src/union.ts +127 -0
package/src/extend.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `extendLayout`: add nodes to an existing layout without moving anything already placed.
3
+ */
4
+ import { type DiffResult, KgdiffError, type Point, type UnionId } from '@kgdiff/core';
5
+ import { type EngineContext, placeAnchored } from './engine.js';
6
+ import { buildLayoutGraph } from './graph.js';
7
+ import type { LayoutOptions, LayoutResult } from './index.js';
8
+ import { round2 } from './math.js';
9
+ import { resolveLayoutOptions } from './options.js';
10
+ import { assemble, now } from './result.js';
11
+ import { countHidden } from './scope.js';
12
+ import { buildUnionGraph } from './union.js';
13
+
14
+ export function extend(
15
+ diff: DiffResult,
16
+ layout: LayoutResult,
17
+ nodes: readonly UnionId[],
18
+ options: LayoutOptions | undefined,
19
+ nodeRadius: (degree: number) => number,
20
+ ): LayoutResult {
21
+ const started = now();
22
+ const opts = resolveLayoutOptions(options);
23
+ const union = buildUnionGraph(diff);
24
+
25
+ const added = new Set<UnionId>();
26
+ for (const id of nodes) {
27
+ if (!union.indexOf.has(id)) {
28
+ throw new KgdiffError('invalid-input', `extendLayout: node "${id}" is not in the diff`, {
29
+ node: id,
30
+ });
31
+ }
32
+ if (!layout.positions.has(id)) added.add(id);
33
+ }
34
+ if (added.size === 0) return layout;
35
+
36
+ const visible = new Uint8Array(union.nodes.length);
37
+ for (const id of layout.positions.keys()) {
38
+ const u = union.indexOf.get(id);
39
+ if (u !== undefined) visible[u] = 1;
40
+ }
41
+ for (const id of added) visible[union.indexOf.get(id)!] = 1;
42
+ const graph = buildLayoutGraph(union, visible, { fontSize: opts.fontSize, nodeRadius });
43
+
44
+ const fixed = new Uint8Array(graph.n);
45
+ const x = new Float64Array(graph.n);
46
+ const y = new Float64Array(graph.n);
47
+ for (let i = 0; i < graph.n; i++) {
48
+ const p = layout.positions.get(graph.nodes[i]!.id);
49
+ if (p === undefined) continue;
50
+ fixed[i] = 1;
51
+ x[i] = p.x;
52
+ y[i] = p.y;
53
+ }
54
+ const ctx: EngineContext = { graph, options: opts, x, y };
55
+ placeAnchored(ctx, fixed);
56
+
57
+ const positions = new Map<UnionId, Point>(layout.positions);
58
+ const radii = new Map<UnionId, number>(layout.radii);
59
+ for (const id of added) {
60
+ const i = graph.indexOf.get(id)!;
61
+ positions.set(id, Object.freeze({ x: round2(x[i]!), y: round2(y[i]!) }));
62
+ radii.set(id, graph.radius[i]!);
63
+ }
64
+
65
+ return assemble({
66
+ graph,
67
+ positions,
68
+ radii,
69
+ hidden: layout.scope === 'changes' ? countHidden(union, visible) : layout.hidden,
70
+ scope: layout.scope,
71
+ strategy: layout.stats.strategy,
72
+ iterations: layout.stats.iterations,
73
+ started,
74
+ });
75
+ }
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Rectangle-vs-rectangle collision over node + label boxes, accelerated with a quadtree.
3
+ *
4
+ * Every node owns one axis-aligned box (its circle plus the label block centered below it).
5
+ * Overlapping pairs are separated along the axis of least penetration (the minimum translation
6
+ * vector), shared in proportion to inverse mass so hubs and fixed nodes stay put. Wide label
7
+ * boxes therefore slide sideways past each other while stacked nodes separate vertically.
8
+ *
9
+ * Box centers go into a d3 quadtree; every quadtree cell records the largest half extents below
10
+ * it, so a query for box i only descends into cells whose content could reach it. Traversal uses
11
+ * an explicit, reused stack (no per-cell allocation, unlike `quadtree.visit`).
12
+ *
13
+ * Two entry points share the pair search:
14
+ * - `collideVelocities`: a force acting on predicted positions (x + vx) during the simulation;
15
+ * - `removeOverlaps`: position-level passes after the simulation until no pair overlaps.
16
+ */
17
+ import { type QuadtreeInternalNode, type QuadtreeLeaf, quadtree } from 'd3-quadtree';
18
+ import type { SimNode } from '../simulate.js';
19
+
20
+ export interface CollisionBoxes {
21
+ /** Half extents of each node's box, by `SimNode.index`. */
22
+ readonly halfWidth: Float64Array;
23
+ readonly halfHeight: Float64Array;
24
+ /** Box center minus node center (labels sit below, so usually positive). */
25
+ readonly offsetY: Float64Array;
26
+ /** 0 = immovable (fixed node). */
27
+ readonly invMass: Float64Array;
28
+ }
29
+
30
+ /** Max half extents of the boxes stored below a quadtree cell. */
31
+ interface Extents {
32
+ mw: number;
33
+ mh: number;
34
+ }
35
+ type Leaf = QuadtreeLeaf<number> & Partial<Extents>;
36
+ type Cell = (QuadtreeInternalNode<number> | QuadtreeLeaf<number>) & Partial<Extents>;
37
+
38
+ const isLeaf = (cell: Cell): cell is Leaf => 'data' in cell;
39
+
40
+ /** Post-order: record max half extents on every cell. */
41
+ function prepare(cell: Cell, hw: Float64Array, hh: Float64Array): void {
42
+ let mw = 0;
43
+ let mh = 0;
44
+ if (isLeaf(cell)) {
45
+ let leaf: QuadtreeLeaf<number> | undefined = cell;
46
+ while (leaf !== undefined) {
47
+ mw = Math.max(mw, hw[leaf.data]!);
48
+ mh = Math.max(mh, hh[leaf.data]!);
49
+ leaf = leaf.next;
50
+ }
51
+ } else {
52
+ for (let k = 0; k < 4; k++) {
53
+ const child = cell[k] as Cell | undefined;
54
+ if (child === undefined) continue;
55
+ prepare(child, hw, hh);
56
+ mw = Math.max(mw, child.mw ?? 0);
57
+ mh = Math.max(mh, child.mh ?? 0);
58
+ }
59
+ }
60
+ cell.mw = mw;
61
+ cell.mh = mh;
62
+ }
63
+
64
+ /** A quadtree over box centers with per-cell max half extents (see `prepare`). */
65
+ interface BoxIndex {
66
+ readonly root: Cell;
67
+ readonly x0: number;
68
+ readonly y0: number;
69
+ readonly x1: number;
70
+ readonly y1: number;
71
+ }
72
+
73
+ function buildIndex(
74
+ indices: readonly number[],
75
+ cx: Float64Array,
76
+ cy: Float64Array,
77
+ boxes: CollisionBoxes,
78
+ ): BoxIndex | undefined {
79
+ const tree = quadtree<number>()
80
+ .x((i) => cx[i]!)
81
+ .y((i) => cy[i]!)
82
+ .addAll(indices as number[]);
83
+ const root = tree.root() as Cell | undefined;
84
+ const extent = tree.extent();
85
+ if (root === undefined || extent === undefined) return undefined;
86
+ prepare(root, boxes.halfWidth, boxes.halfHeight);
87
+ return { root, x0: extent[0][0], y0: extent[0][1], x1: extent[1][0], y1: extent[1][1] };
88
+ }
89
+
90
+ /** Reused traversal stacks (no allocation per query). */
91
+ const cellStack: Cell[] = [];
92
+ const rectStack: number[] = [];
93
+
94
+ /**
95
+ * Visit every pair (i, j) whose padded boxes overlap, each pair once. Box centers are the
96
+ * current `cx`, `cy`; `index` may have been built from older centers, in which case `slack` must
97
+ * bound how far any center has moved since (it widens the pruning test, so the search stays
98
+ * exact). With `active`, only pairs with at least one active node are visited.
99
+ */
100
+ function forEachOverlap(
101
+ index: BoxIndex,
102
+ n: number,
103
+ cx: Float64Array,
104
+ cy: Float64Array,
105
+ boxes: CollisionBoxes,
106
+ padding: number,
107
+ slack: number,
108
+ visit: (i: number, j: number, ox: number, oy: number, dx: number, dy: number) => void,
109
+ active?: Uint8Array,
110
+ ): void {
111
+ const { halfWidth: hw, halfHeight: hh } = boxes;
112
+ const cells = cellStack;
113
+ const rect = rectStack;
114
+ for (let i = 0; i < n; i++) {
115
+ if (active !== undefined && !active[i]) continue;
116
+ const xi = cx[i]!;
117
+ const yi = cy[i]!;
118
+ const hwi = hw[i]! + padding;
119
+ const hhi = hh[i]! + padding;
120
+ cells.push(index.root);
121
+ rect.push(index.x0, index.y0, index.x1, index.y1);
122
+ while (cells.length > 0) {
123
+ const cell = cells.pop()!;
124
+ const y1 = rect.pop()!;
125
+ const x1 = rect.pop()!;
126
+ const y0 = rect.pop()!;
127
+ const x0 = rect.pop()!;
128
+ if (isLeaf(cell)) {
129
+ let leaf: QuadtreeLeaf<number> | undefined = cell;
130
+ while (leaf !== undefined) {
131
+ const j = leaf.data;
132
+ leaf = leaf.next;
133
+ // Each pair once: from its lower index, or from its only active member.
134
+ if (j === i || (j < i && (active === undefined || active[j]))) continue;
135
+ const dx = cx[j]! - xi;
136
+ const ox = hwi + hw[j]! - Math.abs(dx);
137
+ if (ox <= 0) continue;
138
+ const dy = cy[j]! - yi;
139
+ const oy = hhi + hh[j]! - Math.abs(dy);
140
+ if (oy <= 0) continue;
141
+ visit(i, j, ox, oy, dx, dy);
142
+ }
143
+ continue;
144
+ }
145
+ const rx = hwi + (cell.mw ?? 0) + slack;
146
+ const ry = hhi + (cell.mh ?? 0) + slack;
147
+ if (x0 > xi + rx || x1 < xi - rx || y0 > yi + ry || y1 < yi - ry) continue;
148
+ const xm = (x0 + x1) / 2;
149
+ const ym = (y0 + y1) / 2;
150
+ const c0 = cell[0] as Cell | undefined;
151
+ const c1 = cell[1] as Cell | undefined;
152
+ const c2 = cell[2] as Cell | undefined;
153
+ const c3 = cell[3] as Cell | undefined;
154
+ if (c0 !== undefined) {
155
+ cells.push(c0);
156
+ rect.push(x0, y0, xm, ym);
157
+ }
158
+ if (c1 !== undefined) {
159
+ cells.push(c1);
160
+ rect.push(xm, y0, x1, ym);
161
+ }
162
+ if (c2 !== undefined) {
163
+ cells.push(c2);
164
+ rect.push(x0, ym, xm, y1);
165
+ }
166
+ if (c3 !== undefined) {
167
+ cells.push(c3);
168
+ rect.push(xm, ym, x1, y1);
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ /** Reusable buffers for one node set. */
175
+ export interface CollisionScratch {
176
+ readonly indices: readonly number[];
177
+ readonly cx: Float64Array;
178
+ readonly cy: Float64Array;
179
+ readonly posX: Float64Array;
180
+ readonly negX: Float64Array;
181
+ readonly posY: Float64Array;
182
+ readonly negY: Float64Array;
183
+ }
184
+
185
+ export function collisionScratch(n: number): CollisionScratch {
186
+ return {
187
+ indices: Array.from({ length: n }, (_, i) => i),
188
+ cx: new Float64Array(n),
189
+ cy: new Float64Array(n),
190
+ posX: new Float64Array(n),
191
+ negX: new Float64Array(n),
192
+ posY: new Float64Array(n),
193
+ negY: new Float64Array(n),
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Collision as a simulation force: overlapping boxes (at predicted positions x + vx) get their
199
+ * velocities pushed apart by `strength` × the penetration along the separating axis.
200
+ */
201
+ export function collideVelocities(
202
+ nodes: readonly SimNode[],
203
+ boxes: CollisionBoxes,
204
+ padding: number,
205
+ strength: number,
206
+ scratch: CollisionScratch = collisionScratch(nodes.length),
207
+ ): void {
208
+ const { cx, cy } = scratch;
209
+ const { invMass, offsetY } = boxes;
210
+ for (const node of nodes) {
211
+ cx[node.index] = node.x + node.vx;
212
+ cy[node.index] = node.y + node.vy + offsetY[node.index]!;
213
+ }
214
+ const index = buildIndex(scratch.indices, cx, cy, boxes);
215
+ if (index === undefined) return;
216
+ forEachOverlap(index, nodes.length, cx, cy, boxes, padding, 0, (i, j, ox, oy, dx, dy) => {
217
+ const total = invMass[i]! + invMass[j]!;
218
+ if (total === 0) return;
219
+ const shareI = invMass[i]! / total;
220
+ const shareJ = invMass[j]! / total;
221
+ const a = nodes[i]!;
222
+ const b = nodes[j]!;
223
+ // Push along the axis of least penetration; coincident centers: i goes left / up.
224
+ if (ox < oy) {
225
+ const push = (dx < 0 ? -ox : ox) * strength;
226
+ a.vx -= push * shareI;
227
+ b.vx += push * shareJ;
228
+ } else {
229
+ const push = (dy < 0 ? -oy : oy) * strength;
230
+ a.vy -= push * shareI;
231
+ b.vy += push * shareJ;
232
+ }
233
+ });
234
+ }
235
+
236
+ export interface OverlapResult {
237
+ readonly passes: number;
238
+ /** 1 for nodes that took part in an overlap in the last pass. */
239
+ readonly unresolved: Uint8Array;
240
+ }
241
+
242
+ /** Jacobi passes between two checks for a stalled jam. */
243
+ const STALL_WINDOW = 8;
244
+ /** Passes between two expansions while the jam is heavy. */
245
+ const HEAVY_WINDOW = 3;
246
+ /** Rebuild the quadtree once some node has drifted this far since the last build. */
247
+ const REBUILD_DRIFT = 8;
248
+
249
+ /**
250
+ * Separate overlapping boxes by moving positions, pass after pass, until none overlap or
251
+ * `maxPasses` is reached. Each pass is a Jacobi step: every node moves by its largest push in
252
+ * each direction (not the sum, which would overshoot when several neighbours push the same way).
253
+ * Only nodes that moved in the previous pass (or `active` ones, initially) are queried; the
254
+ * quadtree is rebuilt only when some node has drifted more than a few units since the last build
255
+ * (the query is widened by that drift), so late passes over a few stubborn pairs are cheap.
256
+ *
257
+ * Local pushes clear scattered overlaps in a few passes but diffuse slowly through a jam (a
258
+ * crowded region that needs more area). When nothing is fixed and the overlap count stalls, the
259
+ * layout is scaled up about its centroid (by 3–15%, more for heavier jams; every 3 passes by up
260
+ * to 25% while more pairs than half the nodes overlap): uniform scaling keeps the picture and
261
+ * frees room everywhere. With fixed nodes, overlaps may remain (a free node wedged between fixed
262
+ * ones is left to the caller).
263
+ *
264
+ * Returns the passes used and the nodes still involved in an overlap (empty when all clear).
265
+ */
266
+ export function removeOverlaps(
267
+ nodes: readonly SimNode[],
268
+ boxes: CollisionBoxes,
269
+ padding: number,
270
+ maxPasses: number,
271
+ initiallyActive?: Uint8Array,
272
+ ): OverlapResult {
273
+ const n = nodes.length;
274
+ const scratch = collisionScratch(n);
275
+ const { cx, cy, posX, negX, posY, negY } = scratch;
276
+ const { invMass, offsetY } = boxes;
277
+ const expandable = invMass.every((m) => m > 0);
278
+ let active: Uint8Array = initiallyActive ?? new Uint8Array(n).fill(1);
279
+ let moved: Uint8Array = new Uint8Array(n);
280
+
281
+ const drift = new Float64Array(n);
282
+ let slack = 0;
283
+ let index: BoxIndex | undefined;
284
+ let lastCheck = Number.POSITIVE_INFINITY;
285
+ // A hair more than the overlap, so rounding to 0.01 cannot leave boxes touching.
286
+ const epsilon = 0.02;
287
+ for (let pass = 0; pass < maxPasses; pass++) {
288
+ for (const node of nodes) {
289
+ cx[node.index] = node.x;
290
+ cy[node.index] = node.y + offsetY[node.index]!;
291
+ }
292
+ if (index === undefined || slack > REBUILD_DRIFT) {
293
+ index = buildIndex(scratch.indices, cx, cy, boxes);
294
+ if (index === undefined) return { passes: pass, unresolved: new Uint8Array(n) };
295
+ drift.fill(0);
296
+ slack = 0;
297
+ }
298
+ posX.fill(0);
299
+ negX.fill(0);
300
+ posY.fill(0);
301
+ negY.fill(0);
302
+ let overlaps = 0;
303
+ forEachOverlap(
304
+ index,
305
+ n,
306
+ cx,
307
+ cy,
308
+ boxes,
309
+ padding,
310
+ slack,
311
+ (i, j, ox, oy, dx, dy) => {
312
+ const total = invMass[i]! + invMass[j]!;
313
+ if (total === 0) return;
314
+ overlaps++;
315
+ const shareI = invMass[i]! / total;
316
+ const shareJ = invMass[j]! / total;
317
+ if (ox < oy) {
318
+ const push = ox + epsilon;
319
+ // j right of i (or coincident and i < j): i moves left, j moves right.
320
+ const jRight = dx > 0 || (dx === 0 && i < j);
321
+ const left = jRight ? i : j;
322
+ const right = jRight ? j : i;
323
+ negX[left] = Math.min(negX[left]!, -push * (left === i ? shareI : shareJ));
324
+ posX[right] = Math.max(posX[right]!, push * (right === i ? shareI : shareJ));
325
+ } else {
326
+ const push = oy + epsilon;
327
+ const jDown = dy > 0 || (dy === 0 && i < j);
328
+ const up = jDown ? i : j;
329
+ const down = jDown ? j : i;
330
+ negY[up] = Math.min(negY[up]!, -push * (up === i ? shareI : shareJ));
331
+ posY[down] = Math.max(posY[down]!, push * (down === i ? shareI : shareJ));
332
+ }
333
+ },
334
+ active,
335
+ );
336
+ if (overlaps === 0) return { passes: pass, unresolved: new Uint8Array(n) };
337
+ moved.fill(0);
338
+ for (const node of nodes) {
339
+ const k = node.index;
340
+ // Pushes from opposite sides may cancel: the node is still involved, so it stays active.
341
+ if (posX[k] !== 0 || negX[k] !== 0 || posY[k] !== 0 || negY[k] !== 0) {
342
+ const mx = posX[k]! + negX[k]!;
343
+ const my = posY[k]! + negY[k]!;
344
+ node.x += mx;
345
+ node.y += my;
346
+ moved[k] = 1;
347
+ const d = drift[k]! + Math.max(Math.abs(mx), Math.abs(my));
348
+ drift[k] = d;
349
+ if (d > slack) slack = d;
350
+ }
351
+ }
352
+ [active, moved] = [moved, active];
353
+ if (expandable && overlaps > n / 2 && (pass + 1) % HEAVY_WINDOW === 0) {
354
+ // A heavy jam (more overlapping pairs than half the nodes): do not wait for a stall.
355
+ expand(nodes, 1 + Math.min(0.25, (0.05 * overlaps) / n));
356
+ active.fill(1);
357
+ index = undefined;
358
+ lastCheck = Number.POSITIVE_INFINITY;
359
+ } else if ((pass + 1) % STALL_WINDOW === 0) {
360
+ // A jam: local moves stopped making progress.
361
+ if (expandable && overlaps > 0.7 * lastCheck) {
362
+ expand(nodes, 1 + Math.min(0.15, Math.max(0.03, (0.05 * overlaps) / n)));
363
+ active.fill(1);
364
+ index = undefined;
365
+ }
366
+ lastCheck = overlaps;
367
+ }
368
+ }
369
+ return { passes: maxPasses, unresolved: active };
370
+ }
371
+
372
+ function expand(nodes: readonly SimNode[], factor: number): void {
373
+ let mx = 0;
374
+ let my = 0;
375
+ for (const node of nodes) {
376
+ mx += node.x;
377
+ my += node.y;
378
+ }
379
+ mx /= nodes.length;
380
+ my /= nodes.length;
381
+ for (const node of nodes) {
382
+ node.x = mx + (node.x - mx) * factor;
383
+ node.y = my + (node.y - my) * factor;
384
+ }
385
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Final placement touches that look for free spots instead of pushing.
3
+ *
4
+ * - `dockPartners`: the weak partner springs (docs/algorithms.md §12) bring merge sources towards
5
+ * their target, but many-body repulsion and label boxes keep the pair roughly an edge length
6
+ * apart. For replay (sources travel into the target, split targets emerge from their source)
7
+ * and for reading the overlay (the ghost sits right next to what it became), a mover farther
8
+ * than `maxDistance` from its anchor moves to the best free spot around the anchor.
9
+ * - `settleOverlapping`: around fixed nodes, a free node can end up wedged where pushing cannot
10
+ * free it. It moves to the free spot nearest to its neighbours instead.
11
+ *
12
+ * Both test candidate spots (16 directions × a few rings) against the boxes near the searched
13
+ * area, collected once per node; they only run for the few nodes that need them.
14
+ */
15
+ import type { SimNode } from '../simulate.js';
16
+ import type { CollisionBoxes } from './collide-rect.js';
17
+ import type { Spring } from './springs.js';
18
+
19
+ /** Unit vectors every 22.5°, starting straight down (below the anchor's label), exact literals. */
20
+ const C1 = 0.9238795325112867;
21
+ const S1 = 0.3826834323650898;
22
+ const D = Math.SQRT1_2;
23
+ const DIRECTIONS: readonly (readonly [number, number])[] = [
24
+ [0, 1],
25
+ [S1, C1],
26
+ [D, D],
27
+ [C1, S1],
28
+ [1, 0],
29
+ [C1, -S1],
30
+ [D, -D],
31
+ [S1, -C1],
32
+ [0, -1],
33
+ [-S1, -C1],
34
+ [-D, -D],
35
+ [-C1, -S1],
36
+ [-1, 0],
37
+ [-C1, S1],
38
+ [-D, D],
39
+ [-S1, C1],
40
+ ];
41
+ const DOCK_RINGS = [1, 1.35, 1.7, 2.1, 2.6];
42
+ /** Extra score (× maxDistance) charged to a spot that other nodes must vacate. */
43
+ const OCCUPIED_PENALTY = 0.4;
44
+ /** Square lattice rings searched around a wedged node (ring k at k × step from home). */
45
+ const SETTLE_RINGS = 32;
46
+
47
+ const distance = (ax: number, ay: number, bx: number, by: number): number =>
48
+ Math.sqrt((ax - bx) * (ax - bx) + (ay - by) * (ay - by));
49
+
50
+ /**
51
+ * The nodes whose boxes could touch node `m`'s box anywhere within `radius` of (cx, cy): the
52
+ * candidate search then only tests these (O(n) once per searched node, not per candidate).
53
+ */
54
+ function nearby(
55
+ nodes: readonly SimNode[],
56
+ boxes: CollisionBoxes,
57
+ padding: number,
58
+ m: number,
59
+ cx: number,
60
+ cy: number,
61
+ radius: number,
62
+ ): SimNode[] {
63
+ const { halfWidth: hw, halfHeight: hh, offsetY: oy } = boxes;
64
+ const out: SimNode[] = [];
65
+ for (const other of nodes) {
66
+ const j = other.index;
67
+ if (j === m) continue;
68
+ if (Math.abs(other.x - cx) > radius + hw[m]! + hw[j]! + padding) continue;
69
+ const dy = other.y + oy[j]! - (cy + oy[m]!);
70
+ if (Math.abs(dy) > radius + hh[m]! + hh[j]! + padding) continue;
71
+ out.push(other);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * True when node `m`'s padded box centered at (x, y) overlaps none of `others`' boxes, or (with
78
+ * `blocking`) none for which `blocking(j)` holds.
79
+ */
80
+ function isFree(
81
+ others: readonly SimNode[],
82
+ boxes: CollisionBoxes,
83
+ padding: number,
84
+ m: number,
85
+ x: number,
86
+ y: number,
87
+ blocking?: (j: number) => boolean,
88
+ ): boolean {
89
+ const { halfWidth: hw, halfHeight: hh, offsetY: oy } = boxes;
90
+ const cy = y + oy[m]!;
91
+ for (const other of others) {
92
+ const j = other.index;
93
+ if (j === m || (blocking !== undefined && !blocking(j))) continue;
94
+ if (Math.abs(other.x - x) >= hw[m]! + hw[j]! + padding) continue;
95
+ if (Math.abs(other.y + oy[j]! - cy) >= hh[m]! + hh[j]! + padding) continue;
96
+ return false;
97
+ }
98
+ return true;
99
+ }
100
+
101
+ /**
102
+ * Dock merge sources / split targets (`mover`) farther than `maxDistance` from their partner.
103
+ * Candidate spots touch the anchor's box in 16 directions, on 5 rings; the winner minimizes the
104
+ * distance to the anchor plus 0.3 × the distance travelled (so the mover stays on the side its
105
+ * edges come from). A spot occupied only by movable nodes competes with a penalty of
106
+ * 0.4 × `maxDistance`; when it wins, the caller pushes those nodes aside (`removeOverlaps` with
107
+ * docked pairs pinned). Only spots closer to the anchor than the mover's current position count.
108
+ *
109
+ * Returns the sim indices of docked movers and their anchors (to pin while making room).
110
+ */
111
+ export function dockPartners(
112
+ nodes: readonly SimNode[],
113
+ springs: readonly Spring[],
114
+ boxes: CollisionBoxes,
115
+ padding: number,
116
+ maxDistance: number,
117
+ ): number[] {
118
+ const { halfWidth: hw, halfHeight: hh, offsetY: oy, invMass } = boxes;
119
+ const top = (i: number): number => oy[i]! - hh[i]!;
120
+ const bottom = (i: number): number => oy[i]! + hh[i]!;
121
+ const gap = padding + 0.05;
122
+ const pinned = new Set<number>();
123
+ const immovable = (j: number): boolean => invMass[j] === 0 || pinned.has(j);
124
+
125
+ for (const { mover, anchor } of springs) {
126
+ const m = nodes[mover];
127
+ const a = nodes[anchor];
128
+ if (m === undefined || a === undefined || immovable(mover)) continue;
129
+ const current = distance(m.x, m.y, a.x, a.y);
130
+ if (current <= maxDistance) continue;
131
+
132
+ const reachX = (hw[anchor]! + hw[mover]! + gap) * DOCK_RINGS[DOCK_RINGS.length - 1]!;
133
+ const reachY =
134
+ (Math.max(bottom(anchor) - top(mover), bottom(mover) - top(anchor)) + gap) *
135
+ DOCK_RINGS[DOCK_RINGS.length - 1]!;
136
+ const others = nearby(nodes, boxes, padding, mover, a.x, a.y, Math.max(reachX, reachY));
137
+ let free: { x: number; y: number; score: number } | undefined;
138
+ let occupied: { x: number; y: number; score: number } | undefined;
139
+ for (const ring of DOCK_RINGS) {
140
+ for (const [ux, uy] of DIRECTIONS) {
141
+ const tx =
142
+ ux === 0 ? Number.POSITIVE_INFINITY : (hw[anchor]! + hw[mover]! + gap) / Math.abs(ux);
143
+ const ty =
144
+ uy === 0
145
+ ? Number.POSITIVE_INFINITY
146
+ : uy > 0
147
+ ? (bottom(anchor) + gap - top(mover)) / uy
148
+ : (bottom(mover) + gap - top(anchor)) / -uy;
149
+ const t = Math.min(tx, ty) * ring;
150
+ const x = a.x + ux * t;
151
+ const y = a.y + uy * t;
152
+ const toAnchor = distance(x, y, a.x, a.y);
153
+ if (toAnchor >= current) continue;
154
+ const score = toAnchor + 0.3 * distance(x, y, m.x, m.y);
155
+ if (isFree(others, boxes, padding, mover, x, y)) {
156
+ if (free === undefined || score < free.score - 1e-9) free = { x, y, score };
157
+ } else if (
158
+ (occupied === undefined || score < occupied.score - 1e-9) &&
159
+ isFree(others, boxes, padding, mover, x, y, immovable)
160
+ ) {
161
+ occupied = { x, y, score };
162
+ }
163
+ }
164
+ }
165
+ // An occupied spot must be clearly better to be worth pushing the neighbours aside.
166
+ const penalty = OCCUPIED_PENALTY * maxDistance;
167
+ const best =
168
+ occupied !== undefined && (free === undefined || occupied.score + penalty < free.score)
169
+ ? occupied
170
+ : free;
171
+ if (best !== undefined) {
172
+ m.x = best.x;
173
+ m.y = best.y;
174
+ m.vx = 0;
175
+ m.vy = 0;
176
+ pinned.add(mover);
177
+ pinned.add(anchor);
178
+ }
179
+ }
180
+ return [...pinned].sort((p, q) => p - q);
181
+ }
182
+
183
+ /**
184
+ * Move every movable node whose box still overlaps another to the free spot nearest to `home(k)`
185
+ * (its neighbours' barycenter). Candidates lie on square rings of a lattice around home (spacing
186
+ * ≈ the node's box height, so gaps between neighbours are found at any radius; exact arithmetic
187
+ * only); the first ring with a free spot wins, and on it the spot closest to home. Nodes are
188
+ * processed in index order.
189
+ */
190
+ export function settleOverlapping(
191
+ nodes: readonly SimNode[],
192
+ boxes: CollisionBoxes,
193
+ padding: number,
194
+ home: (index: number) => { readonly x: number; readonly y: number },
195
+ ): void {
196
+ const { halfHeight: hh, invMass } = boxes;
197
+ for (const node of nodes) {
198
+ const k = node.index;
199
+ if (invMass[k] === 0) continue;
200
+ if (
201
+ isFree(nearby(nodes, boxes, padding, k, node.x, node.y, 0), boxes, padding, k, node.x, node.y)
202
+ ) {
203
+ continue;
204
+ }
205
+ const center = home(k);
206
+ const step = Math.max(12, hh[k]! + padding);
207
+ // Nearby nodes for the next 8 rings at a time, so the search stays local.
208
+ let others: SimNode[] = [];
209
+ for (let ring = 1; ring <= SETTLE_RINGS; ring++) {
210
+ if ((ring - 1) % 8 === 0) {
211
+ others = nearby(
212
+ nodes,
213
+ boxes,
214
+ padding,
215
+ k,
216
+ center.x,
217
+ center.y,
218
+ step * (ring + 7) * Math.SQRT2,
219
+ );
220
+ }
221
+ let best: { x: number; y: number; d: number } | undefined;
222
+ const consider = (x: number, y: number): void => {
223
+ const d = distance(x, y, center.x, center.y);
224
+ if (best !== undefined && d >= best.d - 1e-9) return;
225
+ if (isFree(others, boxes, padding, k, x, y)) best = { x, y, d };
226
+ };
227
+ const r = ring * step;
228
+ for (let i = -ring; i <= ring; i++) {
229
+ consider(center.x + i * step, center.y - r);
230
+ consider(center.x + i * step, center.y + r);
231
+ }
232
+ for (let j = 1 - ring; j < ring; j++) {
233
+ consider(center.x - r, center.y + j * step);
234
+ consider(center.x + r, center.y + j * step);
235
+ }
236
+ if (best !== undefined) {
237
+ node.x = best.x;
238
+ node.y = best.y;
239
+ break;
240
+ }
241
+ }
242
+ }
243
+ }