@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
@@ -0,0 +1,328 @@
1
+ /**
2
+ * The force simulation. d3-force supplies the link and many-body (Barnes–Hut) forces; the tick
3
+ * loop is our own so that it is synchronous and timer-free (no d3-timer, safe in workers and
4
+ * Node), runs a fixed number of ticks (no time-based termination) and can change forces between
5
+ * phases:
6
+ *
7
+ * 1. structure (first ~35% of ticks): links + charge + node-only collision. The graph unfolds
8
+ * without label boxes jamming it.
9
+ * 2. orientation (union layouts): the component is rotated onto its principal axis (wide
10
+ * layouts suit screens and horizontal labels) and mirrored to a canonical handedness; if its
11
+ * label boxes could not fit in its current extent, it is scaled up once (`makeRoom`).
12
+ * 3. labels: label boxes are inflated from 50% to 100% over ~25% of the ticks, then held.
13
+ * Collision is not scaled by alpha: it keeps working while the other forces cool down.
14
+ * 4. a position-level pass removes any overlap that is left, and merge / split partners that
15
+ * ended up far apart are docked next to each other.
16
+ */
17
+ import {
18
+ forceLink,
19
+ forceManyBody,
20
+ type SimulationLinkDatum,
21
+ type SimulationNodeDatum,
22
+ } from 'd3-force';
23
+ import {
24
+ type CollisionBoxes,
25
+ collideVelocities,
26
+ collisionScratch,
27
+ removeOverlaps,
28
+ } from './forces/collide-rect.js';
29
+ import { dockPartners } from './forces/free-spot.js';
30
+ import { applyGravity, applySprings, type Spring } from './forces/springs.js';
31
+ import { coolingFactor } from './math.js';
32
+ import type { Random } from './prng.js';
33
+
34
+ export interface SimNode extends SimulationNodeDatum {
35
+ index: number;
36
+ x: number;
37
+ y: number;
38
+ vx: number;
39
+ vy: number;
40
+ }
41
+
42
+ export interface SimLink extends SimulationLinkDatum<SimNode> {
43
+ source: SimNode;
44
+ target: SimNode;
45
+ distance: number;
46
+ strength: number;
47
+ }
48
+
49
+ export interface SimulationSpec {
50
+ /** Initial positions; fixed nodes carry `fx` / `fy`. Indices must be 0..n−1. */
51
+ readonly nodes: SimNode[];
52
+ readonly links: SimLink[];
53
+ readonly springs: readonly Spring[];
54
+ /** Many-body strength per node (negative = repulsion). */
55
+ readonly charge: Float64Array;
56
+ /** Node-only boxes (structure phase). */
57
+ readonly nodeBoxes: CollisionBoxes;
58
+ /** Full node + label boxes (label phase and final overlap removal). */
59
+ readonly labelBoxes: CollisionBoxes;
60
+ readonly ticks: number;
61
+ /** Starting alpha (1 for a fresh layout, lower to refine around fixed nodes). */
62
+ readonly alpha: number;
63
+ /** Pull towards the origin; 0 disables it (anchored layouts). */
64
+ readonly gravity: number;
65
+ /** Rotate / mirror into canonical orientation after the structure phase. */
66
+ readonly orient: boolean;
67
+ readonly springStrength: number;
68
+ readonly springLength: number;
69
+ /** Partners farther apart than this are docked next to each other at the end (0 = never). */
70
+ readonly dockDistance: number;
71
+ /** Many-body cut-off (docs/algorithms.md: 900; shorter when refining around fixed nodes). */
72
+ readonly chargeRange: number;
73
+ /** Gap kept between boxes. */
74
+ readonly padding: number;
75
+ readonly random: Random;
76
+ }
77
+
78
+ const ALPHA_MIN = 0.001;
79
+ /** d3's default: velocities keep 60% per tick. */
80
+ const VELOCITY_RETAIN = 0.6;
81
+ const STRUCTURE_SHARE = 0.35;
82
+ const RAMP_SHARE = 0.25;
83
+ /** Label boxes start the label phase at this fraction of their size. */
84
+ const RAMP_FROM = 0.5;
85
+ const COLLIDE_STRENGTH = 0.7;
86
+ /** Label boxes may cover at most this share of the nodes' bounding box after the structure phase. */
87
+ const ROOM_DENSITY = 0.4;
88
+ const MAX_OVERLAP_PASSES = 150;
89
+
90
+ /** Run the simulation in place. Returns the number of overlap-removal passes used. */
91
+ export function simulate(spec: SimulationSpec): number {
92
+ const { nodes, ticks } = spec;
93
+ const n = nodes.length;
94
+ if (n === 0) return 0;
95
+
96
+ if (ticks > 0) {
97
+ const link = forceLink<SimNode, SimLink>(spec.links)
98
+ .distance((l) => l.distance)
99
+ .strength((l) => l.strength);
100
+ link.initialize(nodes, spec.random);
101
+ const charge = forceManyBody<SimNode>()
102
+ .strength((d) => spec.charge[d.index] ?? 0)
103
+ .theta(0.9)
104
+ .distanceMax(spec.chargeRange);
105
+ charge.initialize(nodes, spec.random);
106
+
107
+ const decay = coolingFactor(ALPHA_MIN / spec.alpha, ticks);
108
+ const structureEnd = Math.round(ticks * STRUCTURE_SHARE);
109
+ const rampTicks = Math.max(1, Math.round(ticks * RAMP_SHARE));
110
+ const ramp = scratchBoxes(spec.nodeBoxes);
111
+ const scratch = collisionScratch(n);
112
+ let alpha = spec.alpha;
113
+
114
+ for (let tick = 0; tick < ticks; tick++) {
115
+ alpha *= decay;
116
+ link(alpha);
117
+ charge(alpha);
118
+ if (spec.springs.length > 0) {
119
+ applySprings(nodes, spec.springs, alpha, spec.springStrength, spec.springLength);
120
+ }
121
+ if (spec.gravity > 0) applyGravity(nodes, alpha, spec.gravity);
122
+
123
+ let boxes = spec.nodeBoxes;
124
+ if (tick >= structureEnd) {
125
+ const t = Math.min(1, (tick - structureEnd + 1) / rampTicks);
126
+ boxes =
127
+ t >= 1
128
+ ? spec.labelBoxes
129
+ : blendBoxes(spec.nodeBoxes, spec.labelBoxes, RAMP_FROM + (1 - RAMP_FROM) * t, ramp);
130
+ }
131
+ if (boxes !== undefined)
132
+ collideVelocities(nodes, boxes, spec.padding, COLLIDE_STRENGTH, scratch);
133
+
134
+ for (const node of nodes) {
135
+ if (node.fx == null) {
136
+ node.vx *= VELOCITY_RETAIN;
137
+ node.x += node.vx;
138
+ } else {
139
+ node.x = node.fx;
140
+ node.vx = 0;
141
+ }
142
+ if (node.fy == null) {
143
+ node.vy *= VELOCITY_RETAIN;
144
+ node.y += node.vy;
145
+ } else {
146
+ node.y = node.fy;
147
+ node.vy = 0;
148
+ }
149
+ }
150
+
151
+ if (tick + 1 === structureEnd) {
152
+ if (spec.orient) orient(nodes);
153
+ makeRoom(nodes, spec.labelBoxes, spec.padding, ROOM_DENSITY);
154
+ }
155
+ }
156
+ }
157
+
158
+ const cleared = removeOverlaps(nodes, spec.labelBoxes, spec.padding, MAX_OVERLAP_PASSES);
159
+ let passes = cleared.passes;
160
+ if (spec.springs.length > 0 && spec.dockDistance > 0) {
161
+ const pinned = dockPartners(
162
+ nodes,
163
+ spec.springs,
164
+ spec.labelBoxes,
165
+ spec.padding,
166
+ spec.dockDistance,
167
+ );
168
+ if (pinned.length > 0) {
169
+ // Make room around docked pairs: they hold still, their new neighbours step aside. Only
170
+ // the docked nodes (and whatever was left over) need to be looked at.
171
+ const invMass = Float64Array.from(spec.labelBoxes.invMass);
172
+ const active = Uint8Array.from(cleared.unresolved);
173
+ for (const k of pinned) {
174
+ invMass[k] = 0;
175
+ active[k] = 1;
176
+ }
177
+ const boxes = { ...spec.labelBoxes, invMass };
178
+ const room = removeOverlaps(nodes, boxes, spec.padding, MAX_OVERLAP_PASSES, active);
179
+ // Whatever the pinned pass could not clear (a pinned pair in a tight spot) is cleared with
180
+ // everyone movable again.
181
+ const rest = Uint8Array.from(room.unresolved);
182
+ for (const k of pinned) rest[k] = 1;
183
+ passes += room.passes;
184
+ passes += removeOverlaps(
185
+ nodes,
186
+ spec.labelBoxes,
187
+ spec.padding,
188
+ MAX_OVERLAP_PASSES,
189
+ rest,
190
+ ).passes;
191
+ }
192
+ }
193
+ return passes;
194
+ }
195
+
196
+ function scratchBoxes(like: CollisionBoxes): CollisionBoxes {
197
+ const n = like.halfWidth.length;
198
+ return {
199
+ halfWidth: new Float64Array(n),
200
+ halfHeight: new Float64Array(n),
201
+ offsetY: new Float64Array(n),
202
+ invMass: like.invMass,
203
+ };
204
+ }
205
+
206
+ /** Boxes with the label part scaled by `s` (node part kept), written into `out`. */
207
+ function blendBoxes(
208
+ node: CollisionBoxes,
209
+ full: CollisionBoxes,
210
+ s: number,
211
+ out: CollisionBoxes,
212
+ ): CollisionBoxes {
213
+ const n = node.halfWidth.length;
214
+ for (let i = 0; i < n; i++) {
215
+ const top = node.offsetY[i]! - node.halfHeight[i]!;
216
+ const bottom = node.offsetY[i]! + node.halfHeight[i]!;
217
+ const fullBottom = full.offsetY[i]! + full.halfHeight[i]!;
218
+ const b = bottom + (fullBottom - bottom) * s;
219
+ out.halfWidth[i] = node.halfWidth[i]! + (full.halfWidth[i]! - node.halfWidth[i]!) * s;
220
+ out.halfHeight[i] = (b - top) / 2;
221
+ out.offsetY[i] = (b + top) / 2;
222
+ }
223
+ return out;
224
+ }
225
+
226
+ /**
227
+ * Rotate the (free) layout so its principal axis is horizontal, then mirror so the heavier tail
228
+ * points right / down. Only exact operations: the eigenvector of the 2×2 covariance matrix gives
229
+ * cos / sin directly.
230
+ */
231
+ function orient(nodes: SimNode[]): void {
232
+ let cx = 0;
233
+ let cy = 0;
234
+ for (const d of nodes) {
235
+ cx += d.x;
236
+ cy += d.y;
237
+ }
238
+ cx /= nodes.length;
239
+ cy /= nodes.length;
240
+ let sxx = 0;
241
+ let syy = 0;
242
+ let sxy = 0;
243
+ for (const d of nodes) {
244
+ const dx = d.x - cx;
245
+ const dy = d.y - cy;
246
+ sxx += dx * dx;
247
+ syy += dy * dy;
248
+ sxy += dx * dy;
249
+ }
250
+ const half = (sxx - syy) / 2;
251
+ const lambda = (sxx + syy) / 2 + Math.sqrt(half * half + sxy * sxy);
252
+ let ux = sxy;
253
+ let uy = lambda - sxx;
254
+ if (Math.abs(ux) + Math.abs(uy) < 1e-12 * (sxx + syy + 1)) {
255
+ ux = lambda - syy;
256
+ uy = sxy;
257
+ }
258
+ const len = Math.sqrt(ux * ux + uy * uy);
259
+ const c = len > 0 ? ux / len : 1;
260
+ const s = len > 0 ? uy / len : 0;
261
+
262
+ let m3x = 0;
263
+ let m3y = 0;
264
+ for (const d of nodes) {
265
+ const dx = d.x - cx;
266
+ const dy = d.y - cy;
267
+ const rx = c * dx + s * dy;
268
+ const ry = -s * dx + c * dy;
269
+ d.x = rx;
270
+ d.y = ry;
271
+ const vx = c * d.vx + s * d.vy;
272
+ d.vy = -s * d.vx + c * d.vy;
273
+ d.vx = vx;
274
+ m3x += rx * rx * rx;
275
+ m3y += ry * ry * ry;
276
+ }
277
+ const fx = m3x < 0 ? -1 : 1;
278
+ const fy = m3y < 0 ? -1 : 1;
279
+ if (fx === 1 && fy === 1) return;
280
+ for (const d of nodes) {
281
+ d.x *= fx;
282
+ d.vx *= fx;
283
+ d.y *= fy;
284
+ d.vy *= fy;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Scale the (free) layout up about its centroid when its label boxes could not possibly fit:
290
+ * when their total area exceeds `density` × the area of the nodes' bounding box. A dense graph
291
+ * pulls itself together tighter than its labels allow, and pushing boxes apart one pair at a time
292
+ * through such a jam takes many passes; a uniform scale keeps the picture and makes the room in
293
+ * one step. Fixed nodes, if any, rule this out.
294
+ */
295
+ function makeRoom(
296
+ nodes: readonly SimNode[],
297
+ boxes: CollisionBoxes,
298
+ padding: number,
299
+ density: number,
300
+ ): void {
301
+ if (nodes.length < 3 || boxes.invMass.some((m) => m === 0)) return;
302
+ let minX = Number.POSITIVE_INFINITY;
303
+ let minY = Number.POSITIVE_INFINITY;
304
+ let maxX = Number.NEGATIVE_INFINITY;
305
+ let maxY = Number.NEGATIVE_INFINITY;
306
+ let area = 0;
307
+ let cx = 0;
308
+ let cy = 0;
309
+ for (const d of nodes) {
310
+ minX = Math.min(minX, d.x);
311
+ maxX = Math.max(maxX, d.x);
312
+ minY = Math.min(minY, d.y);
313
+ maxY = Math.max(maxY, d.y);
314
+ cx += d.x;
315
+ cy += d.y;
316
+ area += (2 * boxes.halfWidth[d.index]! + padding) * (2 * boxes.halfHeight[d.index]! + padding);
317
+ }
318
+ const span = Math.max(1, maxX - minX) * Math.max(1, maxY - minY);
319
+ const ratio = area / (density * span);
320
+ if (ratio <= 1) return;
321
+ const scale = Math.sqrt(ratio);
322
+ cx /= nodes.length;
323
+ cy /= nodes.length;
324
+ for (const d of nodes) {
325
+ d.x = cx + (d.x - cx) * scale;
326
+ d.y = cy + (d.y - cy) * scale;
327
+ }
328
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * DOM-free text metrics: label widths are estimated from per-character advances so the layout
3
+ * is identical in Node and in every browser (docs/algorithms.md §12 `estimateLabelWidth`).
4
+ */
5
+
6
+ /** Advance widths in em. */
7
+ const EM_WIDE = 1;
8
+ const EM_DIGIT = 0.6;
9
+ const EM_UPPER = 0.68;
10
+ const EM_OTHER = 0.56;
11
+ const EM_SPACE = 0.3;
12
+ /** One regional indicator is half of a flag emoji (a flag is a pair). */
13
+ const EM_REGIONAL = 0.5;
14
+
15
+ /**
16
+ * Code point ranges rendered full-width: East Asian Wide / Fullwidth blocks (CJK, kana, Hangul,
17
+ * full-width forms) and emoji. Sorted, inclusive, non-overlapping (binary searched).
18
+ */
19
+ const WIDE_RANGES: readonly (readonly [number, number])[] = [
20
+ [0x1100, 0x115f], // Hangul Jamo initials
21
+ [0x231a, 0x231b],
22
+ [0x2329, 0x232a],
23
+ [0x23e9, 0x23ec],
24
+ [0x23f0, 0x23f0],
25
+ [0x23f3, 0x23f3],
26
+ [0x25fd, 0x25fe],
27
+ [0x2600, 0x27bf], // misc symbols, dingbats (mostly emoji presentation)
28
+ [0x2b1b, 0x2b1c],
29
+ [0x2b50, 0x2b50],
30
+ [0x2b55, 0x2b55],
31
+ [0x2e80, 0x303e], // CJK radicals, Kangxi, ideographic description, CJK symbols & punctuation
32
+ [0x3041, 0x33ff], // kana, Bopomofo, Hangul compatibility, Kanbun, enclosed CJK, CJK compat
33
+ [0x3400, 0x4dbf], // CJK Ext. A
34
+ [0x4e00, 0x9fff], // CJK Unified Ideographs
35
+ [0xa000, 0xa4cf], // Yi
36
+ [0xa960, 0xa97f], // Hangul Jamo Ext. A
37
+ [0xac00, 0xd7a3], // Hangul syllables
38
+ [0xf900, 0xfaff], // CJK compatibility ideographs
39
+ [0xfe10, 0xfe19], // vertical forms
40
+ [0xfe30, 0xfe6f], // CJK compatibility forms, small forms
41
+ [0xff00, 0xff60], // full-width forms
42
+ [0xffe0, 0xffe6],
43
+ [0x16fe0, 0x18aff], // Tangut, Khitan…
44
+ [0x1b000, 0x1b2ff], // kana supplements
45
+ [0x1f004, 0x1f004],
46
+ [0x1f0cf, 0x1f0cf],
47
+ [0x1f18e, 0x1f18e],
48
+ [0x1f191, 0x1f19a],
49
+ [0x1f200, 0x1f2ff], // enclosed ideographic supplement
50
+ [0x1f300, 0x1f64f], // pictographs, emoticons
51
+ [0x1f680, 0x1f6ff], // transport & map
52
+ [0x1f7e0, 0x1f7eb],
53
+ [0x1f900, 0x1f9ff], // supplemental symbols & pictographs
54
+ [0x1fa70, 0x1faff],
55
+ [0x20000, 0x2fffd], // CJK Ext. B–F, compatibility supplement
56
+ [0x30000, 0x3fffd], // CJK Ext. G+
57
+ ];
58
+
59
+ const ZERO_WIDTH = /[\p{Mn}\p{Me}\p{Cf}\p{Cc}]/u;
60
+ const SPACE = /\s/u;
61
+ const DIGIT = /\p{Nd}/u;
62
+ const UPPER = /\p{Lu}/u;
63
+
64
+ function inRanges(cp: number, ranges: readonly (readonly [number, number])[]): boolean {
65
+ let lo = 0;
66
+ let hi = ranges.length - 1;
67
+ while (lo <= hi) {
68
+ const mid = (lo + hi) >>> 1;
69
+ const range = ranges[mid];
70
+ if (range === undefined) return false;
71
+ if (cp < range[0]) hi = mid - 1;
72
+ else if (cp > range[1]) lo = mid + 1;
73
+ else return true;
74
+ }
75
+ return false;
76
+ }
77
+
78
+ function asciiAdvance(cp: number): number {
79
+ if (cp === 0x20 || cp === 0x09) return EM_SPACE;
80
+ if (cp < 0x20 || cp === 0x7f) return 0;
81
+ if (cp >= 0x30 && cp <= 0x39) return EM_DIGIT;
82
+ if (cp >= 0x41 && cp <= 0x5a) return EM_UPPER;
83
+ return EM_OTHER;
84
+ }
85
+
86
+ /** Advance of one code point, in em. */
87
+ export function charAdvance(char: string): number {
88
+ const cp = char.codePointAt(0) ?? 0;
89
+ if (cp < 0x80) return asciiAdvance(cp);
90
+ // Combining marks, joiners, variation selectors, emoji skin-tone modifiers draw no advance.
91
+ if (ZERO_WIDTH.test(char) || (cp >= 0x1f3fb && cp <= 0x1f3ff)) return 0;
92
+ if (cp >= 0x1f1e6 && cp <= 0x1f1ff) return EM_REGIONAL;
93
+ if (inRanges(cp, WIDE_RANGES)) return EM_WIDE;
94
+ if (SPACE.test(char)) return EM_SPACE;
95
+ if (DIGIT.test(char)) return EM_DIGIT;
96
+ if (UPPER.test(char)) return EM_UPPER;
97
+ return EM_OTHER;
98
+ }
99
+
100
+ /** Sum of per-character advances × font size. See the public `estimateLabelWidth`. */
101
+ export function labelWidth(text: string, fontSize: number): number {
102
+ let em = 0;
103
+ for (let i = 0; i < text.length; i++) {
104
+ const unit = text.charCodeAt(i);
105
+ if (unit < 0x80) {
106
+ em += asciiAdvance(unit);
107
+ continue;
108
+ }
109
+ const cp = text.codePointAt(i) ?? unit;
110
+ const char = String.fromCodePoint(cp);
111
+ if (cp > 0xffff) i++;
112
+ em += charAdvance(char);
113
+ }
114
+ return em * fontSize;
115
+ }
116
+
117
+ // ─── Label geometry (shared with bounds and collision) ───────────────────────
118
+
119
+ /** Gap between the node circle and the top of its label (world units, visual-language §Typography). */
120
+ export const LABEL_GAP = 5;
121
+ /** Height of one label line in em (12 px text + its 3 px halo). */
122
+ export const LINE_HEIGHT_EM = 1.25;
123
+ /** Renamed nodes show the old name 14 px (at 12 px text) below the new one, at 11/12 size. */
124
+ export const SECONDARY_OFFSET_EM = 14 / 12;
125
+ export const SECONDARY_SIZE_RATIO = 11 / 12;
126
+ /**
127
+ * Reserved label width is capped so a pathological label (a 300-character IRI) cannot blow up
128
+ * the layout; renderers truncate long labels anyway. 32 em ≈ 55 Latin characters.
129
+ */
130
+ export const MAX_LABEL_EM = 32;
131
+
132
+ export interface LabelBox {
133
+ /** Full width of the label block (world units). */
134
+ readonly width: number;
135
+ /** Full height of the label block, all lines. */
136
+ readonly height: number;
137
+ }
138
+
139
+ /**
140
+ * The block of text drawn centered below a node: its label and, for renamed nodes, the struck
141
+ * old label on a second, slightly smaller line.
142
+ */
143
+ export function labelBox(
144
+ label: string,
145
+ labelBefore: string | undefined,
146
+ fontSize: number,
147
+ ): LabelBox {
148
+ const max = MAX_LABEL_EM * fontSize;
149
+ let width = Math.min(max, labelWidth(label, fontSize));
150
+ let height = LINE_HEIGHT_EM * fontSize;
151
+ if (labelBefore !== undefined && labelBefore !== label) {
152
+ const secondary = fontSize * SECONDARY_SIZE_RATIO;
153
+ width = Math.max(width, Math.min(max, labelWidth(labelBefore, secondary)));
154
+ height += SECONDARY_OFFSET_EM * fontSize;
155
+ }
156
+ return { width, height };
157
+ }
package/src/union.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The union graph as the layout sees it: nodes in id order, undirected neighbour pairs drawn
3
+ * from BOTH versions of every edge (so neither version is favoured), and merge / split partner
4
+ * pairs. Built once per call in O(N + E).
5
+ */
6
+ import type { DiffResult, UnionId, UnionNode } from '@kgdiff/core';
7
+
8
+ export interface Partner {
9
+ /** The node that should travel little during replay (merge source / split target). */
10
+ readonly mover: number;
11
+ /** Where it travels to / from (merge target / split source). */
12
+ readonly anchor: number;
13
+ }
14
+
15
+ export interface UnionGraph {
16
+ /** Union nodes sorted by id (UTF-16 code unit order). */
17
+ readonly nodes: readonly UnionNode[];
18
+ readonly indexOf: ReadonlyMap<UnionId, number>;
19
+ /** Distinct undirected neighbour pairs [a, b] with a < b, in first-seen edge order. */
20
+ readonly pairs: Int32Array;
21
+ /** CSR adjacency over `pairs`: neighbours of i are `adjacency[offsets[i] .. offsets[i + 1])`, ascending. */
22
+ readonly offsets: Int32Array;
23
+ readonly adjacency: Int32Array;
24
+ readonly partners: readonly Partner[];
25
+ /** 1 for changed nodes and for endpoints (either version) of changed edges. */
26
+ readonly changed: Uint8Array;
27
+ }
28
+
29
+ const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
30
+
31
+ function sortedById<T extends { readonly id: string }>(items: readonly T[]): readonly T[] {
32
+ for (let i = 1; i < items.length; i++) {
33
+ const prev = items[i - 1];
34
+ const cur = items[i];
35
+ if (prev !== undefined && cur !== undefined && byCodeUnit(prev.id, cur.id) > 0) {
36
+ return [...items].sort((a, b) => byCodeUnit(a.id, b.id));
37
+ }
38
+ }
39
+ return items;
40
+ }
41
+
42
+ /** Build CSR adjacency (ascending neighbour lists) from flat undirected pairs. */
43
+ export function buildAdjacency(
44
+ n: number,
45
+ pairs: Int32Array,
46
+ ): { offsets: Int32Array; adjacency: Int32Array } {
47
+ const offsets = new Int32Array(n + 1);
48
+ for (let k = 0; k < pairs.length; k++) offsets[(pairs[k] ?? 0) + 1]!++;
49
+ for (let i = 0; i < n; i++) offsets[i + 1]! += offsets[i]!;
50
+ const fill = offsets.slice(0, n);
51
+ const adjacency = new Int32Array(pairs.length);
52
+ for (let k = 0; k < pairs.length; k += 2) {
53
+ const a = pairs[k]!;
54
+ const b = pairs[k + 1]!;
55
+ adjacency[fill[a]!++] = b;
56
+ adjacency[fill[b]!++] = a;
57
+ }
58
+ for (let i = 0; i < n; i++) adjacency.subarray(offsets[i]!, offsets[i + 1]!).sort();
59
+ return { offsets, adjacency };
60
+ }
61
+
62
+ export function buildUnionGraph(diff: DiffResult): UnionGraph {
63
+ const nodes = sortedById(diff.nodes);
64
+ const n = nodes.length;
65
+ const indexOf = new Map<UnionId, number>();
66
+ for (let i = 0; i < n; i++) indexOf.set(nodes[i]!.id, i);
67
+
68
+ const changed = new Uint8Array(n);
69
+ for (let i = 0; i < n; i++) if (nodes[i]!.status !== 'unchanged') changed[i] = 1;
70
+
71
+ const seen = new Set<number>();
72
+ const flat: number[] = [];
73
+ const addPair = (a: number | undefined, b: number | undefined): void => {
74
+ if (a === undefined || b === undefined || a === b) return;
75
+ const lo = a < b ? a : b;
76
+ const hi = a < b ? b : a;
77
+ const key = lo * n + hi;
78
+ if (seen.has(key)) return;
79
+ seen.add(key);
80
+ flat.push(lo, hi);
81
+ };
82
+
83
+ for (const edge of sortedById(diff.edges)) {
84
+ const s = indexOf.get(edge.source);
85
+ const t = indexOf.get(edge.target);
86
+ addPair(s, t);
87
+ const sb = edge.sourceBefore === undefined ? s : indexOf.get(edge.sourceBefore);
88
+ const tb = edge.targetBefore === undefined ? t : indexOf.get(edge.targetBefore);
89
+ if (sb !== s || tb !== t) addPair(sb, tb);
90
+ if (edge.status !== 'unchanged') {
91
+ for (const end of [s, t, sb, tb]) if (end !== undefined) changed[end] = 1;
92
+ }
93
+ }
94
+
95
+ const pairs = Int32Array.from(flat);
96
+ const { offsets, adjacency } = buildAdjacency(n, pairs);
97
+
98
+ const partners: Partner[] = [];
99
+ const partnerKeys = new Set<number>();
100
+ const addPartner = (mover: number, anchor: number | undefined): void => {
101
+ if (anchor === undefined || anchor === mover) return;
102
+ const key = mover * n + anchor;
103
+ if (partnerKeys.has(key)) return;
104
+ partnerKeys.add(key);
105
+ partners.push({ mover, anchor });
106
+ };
107
+ for (let i = 0; i < n; i++) {
108
+ for (const facet of nodes[i]!.facets) {
109
+ if (facet.kind === 'mergedInto') addPartner(i, indexOf.get(facet.target));
110
+ else if (facet.kind === 'splitFrom') addPartner(i, indexOf.get(facet.source));
111
+ else if (facet.kind === 'mergeTarget') {
112
+ for (const source of facet.sources) {
113
+ const s = indexOf.get(source);
114
+ if (s !== undefined) addPartner(s, i);
115
+ }
116
+ } else if (facet.kind === 'splitInto') {
117
+ for (const target of facet.targets) {
118
+ const t = indexOf.get(target);
119
+ if (t !== undefined) addPartner(t, i);
120
+ }
121
+ }
122
+ }
123
+ }
124
+ partners.sort((a, b) => a.mover - b.mover || a.anchor - b.anchor);
125
+
126
+ return { nodes, indexOf, pairs, offsets, adjacency, partners, changed };
127
+ }