@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/init.ts ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Deterministic initial placement (docs/algorithms.md §12 "Simulation").
3
+ *
4
+ * Fresh components start on a phyllotaxis (sunflower) spiral filled in BFS order from the
5
+ * highest-degree node, so graph distance ≈ radial distance and the simulation never starts from
6
+ * a hairball. Each BFS layer occupies a contiguous run of spiral slots; within that annulus the
7
+ * nodes are assigned to slots by angle, aligned with the angle of their BFS parent, so subtrees
8
+ * start in the same sector as their parent and parent–child links start out short and uncrossed.
9
+ *
10
+ * Nodes added around fixed ones start at the barycenter of their placed neighbours (or next to
11
+ * their merge / split partner), and a lone new leaf sprouts on the open side of its neighbour.
12
+ */
13
+ import type { LayoutGraph } from './graph.js';
14
+ import { pseudoAngle, pseudoAngleDistance } from './math.js';
15
+ import { nodeRandom, type Random, randomDirection } from './prng.js';
16
+
17
+ /** Golden angle π(3 − √5) as exact literals: the spiral is built by rotation, without trig. */
18
+ const COS_GOLDEN = -0.7373688780783197;
19
+ const SIN_GOLDEN = 0.6754902942615238;
20
+ /** Offsets tried when aligning a BFS layer with its parents (more are sampled with a stride). */
21
+ const MAX_ALIGN_OFFSETS = 48;
22
+
23
+ /** Merge / split partners of every node, both directions, ascending. */
24
+ export function partnerLists(graph: LayoutGraph): readonly (readonly number[])[] {
25
+ const lists: number[][] = Array.from({ length: graph.n }, () => []);
26
+ for (const { mover, anchor } of graph.partners) {
27
+ lists[mover]!.push(anchor);
28
+ lists[anchor]!.push(mover);
29
+ }
30
+ for (const list of lists) list.sort((a, b) => a - b);
31
+ return lists;
32
+ }
33
+
34
+ function forEachNeighbour(
35
+ graph: LayoutGraph,
36
+ partners: readonly (readonly number[])[],
37
+ u: number,
38
+ visit: (v: number) => void,
39
+ ): void {
40
+ for (let k = graph.offsets[u]!; k < graph.offsets[u + 1]!; k++) visit(graph.adjacency[k]!);
41
+ for (const v of partners[u]!) visit(v);
42
+ }
43
+
44
+ /**
45
+ * Place one component (ascending indices) on a seeded, BFS-ordered phyllotaxis spiral around the
46
+ * origin. `visited` is shared across components (they are disjoint).
47
+ */
48
+ export function placeOnSpiral(
49
+ graph: LayoutGraph,
50
+ partners: readonly (readonly number[])[],
51
+ members: readonly number[],
52
+ spacing: number,
53
+ random: Random,
54
+ visited: Uint8Array,
55
+ x: Float64Array,
56
+ y: Float64Array,
57
+ ): void {
58
+ let root = members[0];
59
+ if (root === undefined) return;
60
+ for (const m of members) if (graph.degree[m]! > graph.degree[root]!) root = m;
61
+
62
+ // The whole spiral is rotated by a seeded angle and each slot jittered a little, so different
63
+ // seeds give different (equally good) layouts.
64
+ const phase = randomDirection(random);
65
+ let c = phase.x;
66
+ let s = phase.y;
67
+ const jitter = spacing * 0.3;
68
+ let slot = 1;
69
+
70
+ visited[root] = 1;
71
+ x[root] = 0;
72
+ y[root] = 0;
73
+ let layer = [root];
74
+
75
+ while (layer.length > 0) {
76
+ const next: number[] = [];
77
+ const parentAngle: number[] = [];
78
+ for (const u of layer) {
79
+ const children: number[] = [];
80
+ forEachNeighbour(graph, partners, u, (v) => {
81
+ if (!visited[v]) {
82
+ visited[v] = 1;
83
+ children.push(v);
84
+ }
85
+ });
86
+ children.sort((a, b) => graph.degree[b]! - graph.degree[a]! || a - b);
87
+ const angle = pseudoAngle(x[u]!, y[u]!);
88
+ for (const v of children) {
89
+ next.push(v);
90
+ parentAngle.push(angle);
91
+ }
92
+ }
93
+ if (next.length === 0) break;
94
+
95
+ const slots: { x: number; y: number; angle: number }[] = [];
96
+ for (let j = 0; j < next.length; j++, slot++) {
97
+ const r = spacing * Math.sqrt(slot);
98
+ slots.push({ x: r * c, y: r * s, angle: pseudoAngle(c, s) });
99
+ const cr = c * COS_GOLDEN - s * SIN_GOLDEN;
100
+ s = c * SIN_GOLDEN + s * COS_GOLDEN;
101
+ c = cr;
102
+ }
103
+ slots.sort((a, b) => a.angle - b.angle);
104
+
105
+ const count = next.length;
106
+ const stride = Math.max(1, Math.ceil(count / MAX_ALIGN_OFFSETS));
107
+ let bestOffset = 0;
108
+ let bestCost = Number.POSITIVE_INFINITY;
109
+ for (let o = 0; o < count; o += stride) {
110
+ let cost = 0;
111
+ for (let i = 0; i < count; i++) {
112
+ cost += pseudoAngleDistance(parentAngle[i]!, slots[(i + o) % count]!.angle);
113
+ }
114
+ if (cost < bestCost) {
115
+ bestCost = cost;
116
+ bestOffset = o;
117
+ }
118
+ }
119
+ for (let i = 0; i < count; i++) {
120
+ const v = next[i]!;
121
+ const target = slots[(i + bestOffset) % count]!;
122
+ x[v] = target.x + (random() - 0.5) * jitter;
123
+ y[v] = target.y + (random() - 0.5) * jitter;
124
+ }
125
+
126
+ layer = next
127
+ .map((v) => ({ v, angle: pseudoAngle(x[v]!, y[v]!) }))
128
+ .sort((a, b) => a.angle - b.angle || a.v - b.v)
129
+ .map((e) => e.v);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Give every unplaced node of `members` that is connected to a placed one a start position,
135
+ * layer by layer outwards from the placed set. Within a layer, positions depend only on earlier
136
+ * layers and on the node's own id, never on processing order.
137
+ */
138
+ export function placeAroundFixed(
139
+ graph: LayoutGraph,
140
+ partners: readonly (readonly number[])[],
141
+ members: readonly number[],
142
+ placed: Uint8Array,
143
+ x: Float64Array,
144
+ y: Float64Array,
145
+ linkDistance: number,
146
+ seed: number,
147
+ ): void {
148
+ const hasPlacedNeighbour = (u: number): boolean => {
149
+ let found = false;
150
+ forEachNeighbour(graph, partners, u, (v) => {
151
+ if (placed[v]) found = true;
152
+ });
153
+ return found;
154
+ };
155
+ let layer = members.filter((u) => !placed[u] && hasPlacedNeighbour(u));
156
+
157
+ while (layer.length > 0) {
158
+ const starts = layer.map((u) =>
159
+ startNear(graph, partners, u, placed, x, y, linkDistance, seed),
160
+ );
161
+ const queued = new Set<number>();
162
+ for (let i = 0; i < layer.length; i++) {
163
+ const u = layer[i]!;
164
+ const p = starts[i]!;
165
+ x[u] = p.x;
166
+ y[u] = p.y;
167
+ placed[u] = 1;
168
+ }
169
+ for (const u of layer) {
170
+ forEachNeighbour(graph, partners, u, (v) => {
171
+ if (!placed[v]) queued.add(v);
172
+ });
173
+ }
174
+ layer = [...queued].sort((a, b) => a - b);
175
+ }
176
+ }
177
+
178
+ function startNear(
179
+ graph: LayoutGraph,
180
+ partners: readonly (readonly number[])[],
181
+ u: number,
182
+ placed: Uint8Array,
183
+ x: Float64Array,
184
+ y: Float64Array,
185
+ linkDistance: number,
186
+ seed: number,
187
+ ): { x: number; y: number } {
188
+ const random = nodeRandom(seed, graph.nodes[u]!.id);
189
+ const centroid = (list: readonly number[]): { x: number; y: number } => {
190
+ let sx = 0;
191
+ let sy = 0;
192
+ for (const v of list) {
193
+ sx += x[v]!;
194
+ sy += y[v]!;
195
+ }
196
+ return { x: sx / list.length, y: sy / list.length };
197
+ };
198
+
199
+ const placedPartners = partners[u]!.filter((v) => placed[v]);
200
+ if (placedPartners.length > 0) {
201
+ const base = centroid(placedPartners);
202
+ const dir = randomDirection(random);
203
+ return { x: base.x + dir.x * linkDistance * 0.5, y: base.y + dir.y * linkDistance * 0.5 };
204
+ }
205
+
206
+ const neighbours: number[] = [];
207
+ for (let k = graph.offsets[u]!; k < graph.offsets[u + 1]!; k++) {
208
+ const v = graph.adjacency[k]!;
209
+ if (placed[v]) neighbours.push(v);
210
+ }
211
+ if (neighbours.length >= 2) {
212
+ const base = centroid(neighbours);
213
+ const dir = randomDirection(random);
214
+ return { x: base.x + dir.x * linkDistance * 0.25, y: base.y + dir.y * linkDistance * 0.25 };
215
+ }
216
+
217
+ // A single placed neighbour p: sprout on p's open side (away from p's other placed
218
+ // neighbours), turned by up to ±55° so siblings fan out.
219
+ const p = neighbours[0]!;
220
+ const others: number[] = [];
221
+ for (let k = graph.offsets[p]!; k < graph.offsets[p + 1]!; k++) {
222
+ const v = graph.adjacency[k]!;
223
+ if (v !== u && placed[v]) others.push(v);
224
+ }
225
+ let ax = 0;
226
+ let ay = 0;
227
+ if (others.length > 0) {
228
+ const o = centroid(others);
229
+ ax = x[p]! - o.x;
230
+ ay = y[p]! - o.y;
231
+ }
232
+ const len = Math.sqrt(ax * ax + ay * ay);
233
+ if (len < 1e-9) {
234
+ const dir = randomDirection(random);
235
+ ax = dir.x;
236
+ ay = dir.y;
237
+ } else {
238
+ ax /= len;
239
+ ay /= len;
240
+ }
241
+ const sin = (random() * 2 - 1) * 0.82;
242
+ const cos = Math.sqrt(1 - sin * sin);
243
+ const dx = ax * cos - ay * sin;
244
+ const dy = ax * sin + ay * cos;
245
+ return { x: x[p]! + dx * linkDistance, y: y[p]! + dy * linkDistance };
246
+ }
package/src/json.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Deterministic JSON transport for layouts (no timing information).
3
+ */
4
+ import { KgdiffError, type Point, type Rect, type UnionId } from '@kgdiff/core';
5
+ import type {
6
+ LayoutComponent,
7
+ LayoutJSON,
8
+ LayoutResult,
9
+ LayoutScope,
10
+ LayoutStrategy,
11
+ } from './index.js';
12
+ import { round2 } from './math.js';
13
+
14
+ const byKey = <T>([a]: readonly [string, T], [b]: readonly [string, T]): number =>
15
+ a < b ? -1 : a > b ? 1 : 0;
16
+
17
+ function sortedEntries<T>(entries: Iterable<readonly [UnionId, T]>): (readonly [UnionId, T])[] {
18
+ return [...entries].sort(byKey);
19
+ }
20
+
21
+ const rect = (r: Rect): Rect => ({
22
+ x: round2(r.x),
23
+ y: round2(r.y),
24
+ width: round2(r.width),
25
+ height: round2(r.height),
26
+ });
27
+
28
+ export function toJSON(layout: LayoutResult): LayoutJSON {
29
+ return {
30
+ format: 'kgdiff.layout',
31
+ version: 1,
32
+ strategy: layout.stats.strategy,
33
+ scope: layout.scope,
34
+ iterations: layout.stats.iterations,
35
+ positions: Object.fromEntries(
36
+ sortedEntries(layout.positions).map(([id, p]) => [id, [round2(p.x), round2(p.y)] as const]),
37
+ ),
38
+ radii: Object.fromEntries(sortedEntries(layout.radii).map(([id, r]) => [id, round2(r)])),
39
+ hidden: Object.fromEntries(sortedEntries(layout.hidden)),
40
+ bounds: rect(layout.bounds),
41
+ components: layout.components.map((c) => ({ nodes: [...c.nodes], bounds: rect(c.bounds) })),
42
+ };
43
+ }
44
+
45
+ function fail(message: string, details?: Readonly<Record<string, unknown>>): never {
46
+ throw new KgdiffError('invalid-input', `layoutFromJSON: ${message}`, details);
47
+ }
48
+
49
+ const isObject = (v: unknown): v is Record<string, unknown> =>
50
+ typeof v === 'object' && v !== null && !Array.isArray(v);
51
+
52
+ const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
53
+
54
+ function readRect(value: unknown, where: string): Rect {
55
+ if (!isObject(value) || ![value.x, value.y, value.width, value.height].every(finite)) {
56
+ fail(`${where} must be a rectangle`, { where });
57
+ }
58
+ return Object.freeze({
59
+ x: value.x as number,
60
+ y: value.y as number,
61
+ width: value.width as number,
62
+ height: value.height as number,
63
+ });
64
+ }
65
+
66
+ function readNumbers(value: unknown, where: string): Map<UnionId, number> {
67
+ if (!isObject(value)) fail(`${where} must be an object`, { where });
68
+ const out = new Map<UnionId, number>();
69
+ for (const [id, n] of sortedEntries(Object.entries(value))) {
70
+ if (!finite(n)) fail(`${where}["${id}"] must be a finite number`, { where, id });
71
+ out.set(id, n);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ export function fromJSON(json: LayoutJSON): LayoutResult {
77
+ const raw: unknown = json;
78
+ if (!isObject(raw) || raw.format !== 'kgdiff.layout') fail('not a kgdiff.layout document');
79
+ if (raw.version !== 1) {
80
+ throw new KgdiffError(
81
+ 'unsupported-version',
82
+ `Unsupported kgdiff.layout version ${String(raw.version)}`,
83
+ {
84
+ version: raw.version,
85
+ },
86
+ );
87
+ }
88
+ const strategy = raw.strategy;
89
+ if (strategy !== 'union' && strategy !== 'anchored') fail('strategy must be union or anchored');
90
+ const scope = raw.scope;
91
+ if (scope !== 'all' && scope !== 'changes') fail('scope must be all or changes');
92
+ const iterations = raw.iterations;
93
+ if (!finite(iterations) || iterations < 0) fail('iterations must be a number ≥ 0');
94
+
95
+ if (!isObject(raw.positions)) fail('positions must be an object');
96
+ const positions = new Map<UnionId, Point>();
97
+ for (const [id, p] of sortedEntries(Object.entries(raw.positions))) {
98
+ if (!Array.isArray(p) || p.length !== 2 || !finite(p[0]) || !finite(p[1])) {
99
+ fail(`positions["${id}"] must be [x, y]`, { id });
100
+ }
101
+ positions.set(id, Object.freeze({ x: p[0] as number, y: p[1] as number }));
102
+ }
103
+ const radii = readNumbers(raw.radii, 'radii');
104
+ const hidden = readNumbers(raw.hidden, 'hidden');
105
+
106
+ if (!Array.isArray(raw.components)) fail('components must be an array');
107
+ const components: LayoutComponent[] = raw.components.map((c: unknown, k: number) => {
108
+ if (!isObject(c) || !Array.isArray(c.nodes) || !c.nodes.every((id) => typeof id === 'string')) {
109
+ fail(`components[${k}] must have a nodes array`, { index: k });
110
+ }
111
+ return Object.freeze({
112
+ nodes: Object.freeze([...(c.nodes as string[])]),
113
+ bounds: readRect(c.bounds, `components[${k}].bounds`),
114
+ });
115
+ });
116
+
117
+ return Object.freeze({
118
+ positions,
119
+ radii,
120
+ bounds: readRect(raw.bounds, 'bounds'),
121
+ scope: scope as LayoutScope,
122
+ hidden,
123
+ components: Object.freeze(components),
124
+ stats: Object.freeze({
125
+ strategy: strategy as LayoutStrategy,
126
+ iterations,
127
+ nodes: positions.size,
128
+ ms: 0,
129
+ }),
130
+ });
131
+ }
package/src/layout.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `layoutDiff`: scope → layout graph → strategy → simulation → packing → result.
3
+ */
4
+ import type { DiffResult, Point, UnionId } from '@kgdiff/core';
5
+ import { type EngineContext, placeAnchored, placeFree } from './engine.js';
6
+ import { buildLayoutGraph, type LayoutGraph } from './graph.js';
7
+ import type { LayoutOptions, LayoutResult } from './index.js';
8
+ import { round2 } from './math.js';
9
+ import { type ResolvedLayoutOptions, resolveLayoutOptions } from './options.js';
10
+ import { assemble, now } from './result.js';
11
+ import { selectScope } from './scope.js';
12
+ import { buildUnionGraph } from './union.js';
13
+
14
+ const finitePoint = (p: Point | undefined): p is Point =>
15
+ p !== undefined && Number.isFinite(p.x) && Number.isFinite(p.y);
16
+
17
+ /**
18
+ * Known positions, by priority: explicit pins, then the before version's `position` (the mental
19
+ * map the reviewer already has), then the after version's.
20
+ */
21
+ function knownPositions(
22
+ graph: LayoutGraph,
23
+ options: ResolvedLayoutOptions,
24
+ ): { fixed: Uint8Array; x: Float64Array; y: Float64Array; count: number } {
25
+ const fixed = new Uint8Array(graph.n);
26
+ const x = new Float64Array(graph.n);
27
+ const y = new Float64Array(graph.n);
28
+ let count = 0;
29
+ for (let i = 0; i < graph.n; i++) {
30
+ const node = graph.nodes[i]!;
31
+ const candidates = [options.pinned.get(node.id), node.before?.position, node.after?.position];
32
+ const p = candidates.find(finitePoint);
33
+ if (p === undefined) continue;
34
+ fixed[i] = 1;
35
+ x[i] = p.x;
36
+ y[i] = p.y;
37
+ count++;
38
+ }
39
+ return { fixed, x, y, count };
40
+ }
41
+
42
+ export function computeLayout(
43
+ diff: DiffResult,
44
+ options: LayoutOptions | undefined,
45
+ nodeRadius: (degree: number) => number,
46
+ ): LayoutResult {
47
+ const started = now();
48
+ const opts = resolveLayoutOptions(options);
49
+ const union = buildUnionGraph(diff);
50
+ const scope = opts.scope ?? (union.nodes.length > opts.largeGraphThreshold ? 'changes' : 'all');
51
+ const selection = selectScope(union, scope, opts.contextHops, opts.hubDegree);
52
+ const graph = buildLayoutGraph(union, selection.visible, { fontSize: opts.fontSize, nodeRadius });
53
+
54
+ const known = knownPositions(graph, opts);
55
+ const strategy = opts.strategy ?? (known.count > 0 ? 'anchored' : 'union');
56
+ const anchored = strategy === 'anchored' && known.count > 0;
57
+ const ctx: EngineContext = {
58
+ graph,
59
+ options: opts,
60
+ x: anchored ? known.x : new Float64Array(graph.n),
61
+ y: anchored ? known.y : new Float64Array(graph.n),
62
+ };
63
+ const iterations = anchored ? placeAnchored(ctx, known.fixed) : placeFree(ctx);
64
+
65
+ const positions = new Map<UnionId, Point>();
66
+ const radii = new Map<UnionId, number>();
67
+ for (let i = 0; i < graph.n; i++) {
68
+ const id = graph.nodes[i]!.id;
69
+ // Known positions are preserved exactly; computed ones are rounded to 0.01.
70
+ const exact = anchored && known.fixed[i] === 1;
71
+ const x = exact ? ctx.x[i]! : round2(ctx.x[i]!);
72
+ const y = exact ? ctx.y[i]! : round2(ctx.y[i]!);
73
+ positions.set(id, Object.freeze({ x, y }));
74
+ radii.set(id, graph.radius[i]!);
75
+ }
76
+
77
+ return assemble({
78
+ graph,
79
+ positions,
80
+ radii,
81
+ hidden: selection.hidden,
82
+ scope,
83
+ strategy,
84
+ iterations,
85
+ started,
86
+ });
87
+ }
package/src/math.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Deterministic arithmetic.
3
+ *
4
+ * ECMAScript only guarantees exact (correctly rounded) results for + − × ÷ and, in every engine
5
+ * in practice, `Math.sqrt`. `Math.log2`, `Math.pow`, `Math.sin`… are implementation-approximated
6
+ * and can differ in the last bit between V8, SpiderMonkey and JavaScriptCore. A force simulation
7
+ * amplifies such differences, so everything that feeds it goes through these helpers to keep
8
+ * layouts byte-identical between the CLI (Node) and the browser.
9
+ */
10
+
11
+ export const clamp = (value: number, min: number, max: number): number =>
12
+ Math.min(max, Math.max(min, value));
13
+
14
+ /** Round to 0.01 (the layout's output precision); never returns `-0`. */
15
+ export function round2(value: number): number {
16
+ const rounded = Math.round(value * 100) / 100;
17
+ return rounded === 0 ? 0 : rounded;
18
+ }
19
+
20
+ /** log₂(x) for x > 0, using only exactly rounded operations. Accurate to ~1e-15. */
21
+ export function log2(x: number): number {
22
+ if (!(x > 0)) return Number.NEGATIVE_INFINITY;
23
+ if (x === Number.POSITIVE_INFINITY) return x;
24
+ // x = m·2^e with m ∈ [1, 2); scaling by 2 is exact.
25
+ let m = x;
26
+ let e = 0;
27
+ while (m >= 2) {
28
+ m /= 2;
29
+ e++;
30
+ }
31
+ while (m < 1) {
32
+ m *= 2;
33
+ e--;
34
+ }
35
+ // ln(m) = 2·atanh(s), s = (m − 1)/(m + 1) ∈ [0, 1/3): the series converges by 1/9 per term.
36
+ const s = (m - 1) / (m + 1);
37
+ const s2 = s * s;
38
+ let term = s;
39
+ let sum = 0;
40
+ for (let k = 1; k < 40; k += 2) {
41
+ sum += term / k;
42
+ term *= s2;
43
+ }
44
+ return e + (2 * sum) / Math.LN2;
45
+ }
46
+
47
+ /** bⁿ for integer n ≥ 0 by repeated squaring (exact operation sequence). */
48
+ export function powInt(base: number, exponent: number): number {
49
+ let result = 1;
50
+ let b = base;
51
+ let n = exponent;
52
+ while (n > 0) {
53
+ if (n & 1) result *= b;
54
+ b *= b;
55
+ n >>>= 1;
56
+ }
57
+ return result;
58
+ }
59
+
60
+ /**
61
+ * The per-tick factor `r` with `r^ticks = ratio` (ratio ∈ (0, 1]), found by bisection over
62
+ * `powInt` so it is reproducible everywhere. Used for the alpha cooling schedule.
63
+ */
64
+ export function coolingFactor(ratio: number, ticks: number): number {
65
+ if (ticks <= 0 || ratio >= 1) return 1;
66
+ let lo = 0;
67
+ let hi = 1;
68
+ for (let i = 0; i < 64; i++) {
69
+ const mid = (lo + hi) / 2;
70
+ if (powInt(mid, ticks) < ratio) lo = mid;
71
+ else hi = mid;
72
+ }
73
+ return (lo + hi) / 2;
74
+ }
75
+
76
+ /**
77
+ * Monotonic stand-in for the polar angle of (x, y), in [0, 4). Sorting by it equals sorting by
78
+ * `atan2` without any transcendental function.
79
+ */
80
+ export function pseudoAngle(x: number, y: number): number {
81
+ const d = Math.abs(x) + Math.abs(y);
82
+ if (d === 0) return 0;
83
+ const p = x / d;
84
+ return y >= 0 ? 1 - p : 3 + p;
85
+ }
86
+
87
+ /** Shortest distance between two pseudo-angles on the [0, 4) circle. */
88
+ export function pseudoAngleDistance(a: number, b: number): number {
89
+ const d = Math.abs(a - b);
90
+ return d > 2 ? 4 - d : d;
91
+ }