@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,88 @@
1
+ /**
2
+ * Force parameters (docs/algorithms.md §12 "Simulation") and their label-aware adjustments.
3
+ *
4
+ * Spec values:
5
+ * - link distance `L·(1 + 0.15·log₂(1 + min(deg_s, deg_t)))`, strength `1 / min(deg_s, deg_t)`;
6
+ * - many-body strength `−(220 + 40·√degree)` (θ 0.9, distanceMax 900, see simulate.ts), scaled
7
+ * by `(linkDistance / 56)²` so the picture scales with the `linkDistance` option.
8
+ *
9
+ * Label-aware adjustments (only when `labelAware`), both of which reduce to the spec values for
10
+ * nodes with ordinary labels and ordinary neighbourhoods:
11
+ * - charge × `clamp(boxArea / 1.5·L², 1, 3)`: a node whose label block needs more room than the
12
+ * ~L² the spec charge buys pushes proportionally harder, so the layout makes room for long
13
+ * labels globally instead of jamming them locally;
14
+ * - link strength × `min(1, 2·distance / crowding)`, where `crowding` is the radius of a disc
15
+ * holding the label blocks of a node's neighbours: the links of a hub with hundreds of
16
+ * labelled leaves relax, so the leaves spread into a disc instead of being squeezed into a
17
+ * ring of radius L that cannot hold them.
18
+ */
19
+ import type { LayoutGraph } from '../graph.js';
20
+ import { log2 } from '../math.js';
21
+
22
+ const DEFAULT_LINK_DISTANCE = 56;
23
+ const LABEL_CHARGE_MAX = 3;
24
+ const LABEL_AREA_REF = 1.5;
25
+ const CROWDING_RATIO = 2;
26
+ /** Gravity of a component with ≤ 16 nodes; larger components get less (see `gravityFor`). */
27
+ const GRAVITY = 0.3;
28
+ /** 400 nodes, in units of 16. */
29
+ const GRAVITY_KNEE = 25;
30
+
31
+ export function linkDistance(linkDistanceOption: number, minDegree: number): number {
32
+ return linkDistanceOption * (1 + 0.15 * log2(1 + minDegree));
33
+ }
34
+
35
+ export function linkStrength(
36
+ graph: LayoutGraph,
37
+ a: number,
38
+ b: number,
39
+ distance: number,
40
+ labelAware: boolean,
41
+ ): number {
42
+ const minDegree = Math.max(1, Math.min(graph.degree[a]!, graph.degree[b]!));
43
+ const strength = 1 / minDegree;
44
+ if (!labelAware) return strength;
45
+ const crowding = Math.max(graph.crowding[a]!, graph.crowding[b]!);
46
+ const limit = CROWDING_RATIO * distance;
47
+ return crowding > limit ? (strength * limit) / crowding : strength;
48
+ }
49
+
50
+ export function chargeStrength(
51
+ graph: LayoutGraph,
52
+ i: number,
53
+ linkDistanceOption: number,
54
+ labelAware: boolean,
55
+ ): number {
56
+ // The spec constants are tuned for the default link distance; scaling by (L / 56)² keeps the
57
+ // equilibrium shape, so a larger `linkDistance` gives a proportionally larger picture.
58
+ const scale = linkDistanceOption / DEFAULT_LINK_DISTANCE;
59
+ const base = -(220 + 40 * Math.sqrt(graph.degree[i]!)) * scale * scale;
60
+ if (!labelAware) return base;
61
+ const ratio = graph.boxArea[i]! / (LABEL_AREA_REF * linkDistanceOption * linkDistanceOption);
62
+ return base * Math.min(LABEL_CHARGE_MAX, Math.max(1, ratio));
63
+ }
64
+
65
+ /**
66
+ * Pull towards the component center (not in the spec): keeps components round and compact.
67
+ * A linear pull compresses the core of a large component (its pressure grows with the radius), so
68
+ * it decays with size: `0.3 / √(n/16)` up to 400 nodes, then as `(n/400)^−0.75` beyond.
69
+ * Powers are built from `Math.sqrt` only (deterministic across engines).
70
+ */
71
+ export function gravityFor(size: number): number {
72
+ const s = Math.max(1, size / 16);
73
+ if (s <= GRAVITY_KNEE) return GRAVITY / Math.sqrt(s);
74
+ const r = s / GRAVITY_KNEE;
75
+ return GRAVITY / Math.sqrt(GRAVITY_KNEE) / (Math.sqrt(r) * Math.sqrt(Math.sqrt(r)));
76
+ }
77
+
78
+ /** Many-body cut-off of the spec. */
79
+ export const CHARGE_RANGE = 900;
80
+
81
+ /**
82
+ * Many-body cut-off when refining around fixed nodes: when most of the picture is fixed, new
83
+ * nodes should settle into local gaps next to their neighbours instead of being blown out of the
84
+ * graph by the summed repulsion of everything that is already placed.
85
+ */
86
+ export function anchoredChargeRange(freeShare: number, linkDistanceOption: number): number {
87
+ return freeShare >= 0.5 ? CHARGE_RANGE : Math.min(CHARGE_RANGE, 4 * linkDistanceOption);
88
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Small custom forces that complement d3's link and many-body forces.
3
+ */
4
+ import type { SimNode } from '../simulate.js';
5
+
6
+ export interface Spring {
7
+ /** Sim-local index of the node that is pulled (merge source / split target). */
8
+ readonly mover: number;
9
+ /** Sim-local index of its partner (merge target / split source). */
10
+ readonly anchor: number;
11
+ }
12
+
13
+ /**
14
+ * Weak one-sided springs (docs/algorithms.md §12: strength 0.08) that keep merge sources near
15
+ * their target and split targets near their source, so replay travel stays short. They only
16
+ * pull (collision keeps the pair apart), and the anchor feels a small share of the reaction so
17
+ * it is not dragged around by its ghosts.
18
+ */
19
+ export function applySprings(
20
+ nodes: readonly SimNode[],
21
+ springs: readonly Spring[],
22
+ alpha: number,
23
+ strength: number,
24
+ restLength: number,
25
+ ): void {
26
+ const anchorShare = 0.2;
27
+ for (const { mover, anchor } of springs) {
28
+ const m = nodes[mover];
29
+ const a = nodes[anchor];
30
+ if (m === undefined || a === undefined) continue;
31
+ const dx = a.x + a.vx - m.x - m.vx;
32
+ const dy = a.y + a.vy - m.y - m.vy;
33
+ const l = Math.sqrt(dx * dx + dy * dy);
34
+ if (l <= restLength) continue;
35
+ const k = ((l - restLength) / l) * alpha * strength;
36
+ m.vx += dx * k * (1 - anchorShare);
37
+ m.vy += dy * k * (1 - anchorShare);
38
+ a.vx -= dx * k * anchorShare;
39
+ a.vy -= dy * k * anchorShare;
40
+ }
41
+ }
42
+
43
+ /** Linear pull towards the origin (d3 forceX / forceY at 0): keeps components round and compact. */
44
+ export function applyGravity(nodes: readonly SimNode[], alpha: number, strength: number): void {
45
+ const k = strength * alpha;
46
+ for (const node of nodes) {
47
+ node.vx -= node.x * k;
48
+ node.vy -= node.y * k;
49
+ }
50
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Extents and rectangles of node + label boxes.
3
+ */
4
+ import type { Rect } from '@kgdiff/core';
5
+ import type { LayoutGraph } from './graph.js';
6
+
7
+ /** Gap kept between node / label boxes (world units). */
8
+ export const BOX_PADDING = 4;
9
+
10
+ export interface Extent {
11
+ minX: number;
12
+ minY: number;
13
+ maxX: number;
14
+ maxY: number;
15
+ }
16
+
17
+ export const emptyExtent = (): Extent => ({
18
+ minX: Number.POSITIVE_INFINITY,
19
+ minY: Number.POSITIVE_INFINITY,
20
+ maxX: Number.NEGATIVE_INFINITY,
21
+ maxY: Number.NEGATIVE_INFINITY,
22
+ });
23
+
24
+ /** Grow `extent` by node `i`'s visual box (circle + label block) centered at (x, y). */
25
+ export function includeNode(
26
+ extent: Extent,
27
+ graph: LayoutGraph,
28
+ i: number,
29
+ x: number,
30
+ y: number,
31
+ ): void {
32
+ const hw = graph.boxHalfWidth[i]!;
33
+ extent.minX = Math.min(extent.minX, x - hw);
34
+ extent.maxX = Math.max(extent.maxX, x + hw);
35
+ extent.minY = Math.min(extent.minY, y + graph.boxTop[i]!);
36
+ extent.maxY = Math.max(extent.maxY, y + graph.boxBottom[i]!);
37
+ }
38
+
39
+ export function extentOf(
40
+ graph: LayoutGraph,
41
+ members: Iterable<number>,
42
+ x: ArrayLike<number>,
43
+ y: ArrayLike<number>,
44
+ ): Extent {
45
+ const extent = emptyExtent();
46
+ for (const i of members) includeNode(extent, graph, i, x[i]!, y[i]!);
47
+ return extent;
48
+ }
49
+
50
+ export const isEmpty = (e: Extent): boolean => !(e.maxX >= e.minX);
51
+
52
+ const floor2 = (v: number): number => Math.floor(v * 100) / 100 || 0;
53
+ const ceil2 = (v: number): number => Math.ceil(v * 100) / 100 || 0;
54
+
55
+ /** The smallest 0.01-aligned rectangle containing `extent` (zero rect when empty). */
56
+ export function toRect(extent: Extent): Rect {
57
+ if (isEmpty(extent)) return Object.freeze({ x: 0, y: 0, width: 0, height: 0 });
58
+ const x = floor2(extent.minX);
59
+ const y = floor2(extent.minY);
60
+ return Object.freeze({
61
+ x,
62
+ y,
63
+ width: Math.round((ceil2(extent.maxX) - x) * 100) / 100,
64
+ height: Math.round((ceil2(extent.maxY) - y) * 100) / 100,
65
+ });
66
+ }
package/src/graph.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The graph actually laid out: the union nodes in scope (re-indexed 0..n−1 in id order), their
3
+ * radii and label blocks, and the links / partner springs among them.
4
+ */
5
+ import type { UnionId, UnionNode } from '@kgdiff/core';
6
+ import { BOX_PADDING } from './geometry.js';
7
+ import { LABEL_GAP, labelBox } from './text-metrics.js';
8
+ import { buildAdjacency, type Partner, type UnionGraph } from './union.js';
9
+
10
+ export interface LayoutGraph {
11
+ readonly n: number;
12
+ readonly nodes: readonly UnionNode[];
13
+ readonly indexOf: ReadonlyMap<UnionId, number>;
14
+ /** `nodeRadius(max(degree.before, degree.after))`. */
15
+ readonly radius: Float64Array;
16
+ /**
17
+ * Visual box of node + label relative to the node center: half width, top (negative) and
18
+ * bottom offsets. Used for bounds, and for collision when the layout is label-aware.
19
+ */
20
+ readonly boxHalfWidth: Float64Array;
21
+ readonly boxTop: Float64Array;
22
+ readonly boxBottom: Float64Array;
23
+ /** Area of the padded visual box. */
24
+ readonly boxArea: Float64Array;
25
+ /** Radius of a disc holding the padded boxes of all neighbours (label-aware link relaxation). */
26
+ readonly crowding: Float64Array;
27
+ /** Distinct neighbours within the layout (drives link and charge strengths). */
28
+ readonly degree: Int32Array;
29
+ /** Distinct undirected links [a, b], a < b. */
30
+ readonly pairs: Int32Array;
31
+ readonly offsets: Int32Array;
32
+ readonly adjacency: Int32Array;
33
+ readonly partners: readonly Partner[];
34
+ }
35
+
36
+ export interface GraphGeometry {
37
+ readonly fontSize: number;
38
+ readonly nodeRadius: (degree: number) => number;
39
+ }
40
+
41
+ function labelBefore(node: UnionNode): string | undefined {
42
+ for (const facet of node.facets) if (facet.kind === 'rename') return facet.from;
43
+ return undefined;
44
+ }
45
+
46
+ /** Induced subgraph of `union` over `visible`. */
47
+ export function buildLayoutGraph(
48
+ union: UnionGraph,
49
+ visible: Uint8Array,
50
+ geometry: GraphGeometry,
51
+ ): LayoutGraph {
52
+ const local = new Int32Array(union.nodes.length).fill(-1);
53
+ const nodes: UnionNode[] = [];
54
+ for (let u = 0; u < union.nodes.length; u++) {
55
+ if (!visible[u]) continue;
56
+ local[u] = nodes.length;
57
+ nodes.push(union.nodes[u]!);
58
+ }
59
+ const n = nodes.length;
60
+ const indexOf = new Map<UnionId, number>();
61
+ const radius = new Float64Array(n);
62
+ const boxHalfWidth = new Float64Array(n);
63
+ const boxTop = new Float64Array(n);
64
+ const boxBottom = new Float64Array(n);
65
+ for (let i = 0; i < n; i++) {
66
+ const node = nodes[i]!;
67
+ indexOf.set(node.id, i);
68
+ const r = geometry.nodeRadius(Math.max(node.degree.before, node.degree.after));
69
+ const label = labelBox(node.label, labelBefore(node), geometry.fontSize);
70
+ radius[i] = r;
71
+ boxHalfWidth[i] = Math.max(r, label.width / 2);
72
+ boxTop[i] = -r;
73
+ boxBottom[i] = r + LABEL_GAP + label.height;
74
+ }
75
+
76
+ const flat: number[] = [];
77
+ for (let k = 0; k < union.pairs.length; k += 2) {
78
+ const a = local[union.pairs[k]!]!;
79
+ const b = local[union.pairs[k + 1]!]!;
80
+ if (a >= 0 && b >= 0) flat.push(Math.min(a, b), Math.max(a, b));
81
+ }
82
+ const pairs = Int32Array.from(flat);
83
+ const { offsets, adjacency } = buildAdjacency(n, pairs);
84
+ const degree = new Int32Array(n);
85
+ const boxArea = new Float64Array(n);
86
+ for (let i = 0; i < n; i++) {
87
+ degree[i] = offsets[i + 1]! - offsets[i]!;
88
+ boxArea[i] = (2 * boxHalfWidth[i]! + BOX_PADDING) * (boxBottom[i]! - boxTop[i]! + BOX_PADDING);
89
+ }
90
+ const crowding = new Float64Array(n);
91
+ for (let i = 0; i < n; i++) {
92
+ let area = 0;
93
+ for (let k = offsets[i]!; k < offsets[i + 1]!; k++) area += boxArea[adjacency[k]!]!;
94
+ crowding[i] = Math.sqrt(area / Math.PI);
95
+ }
96
+
97
+ const partners: Partner[] = [];
98
+ for (const p of union.partners) {
99
+ const mover = local[p.mover]!;
100
+ const anchor = local[p.anchor]!;
101
+ if (mover >= 0 && anchor >= 0) partners.push({ mover, anchor });
102
+ }
103
+
104
+ return {
105
+ n,
106
+ nodes,
107
+ indexOf,
108
+ radius,
109
+ boxHalfWidth,
110
+ boxTop,
111
+ boxBottom,
112
+ boxArea,
113
+ crowding,
114
+ degree,
115
+ pairs,
116
+ offsets,
117
+ adjacency,
118
+ partners,
119
+ };
120
+ }
package/src/index.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * @kgdiff/layout: stable, deterministic, label-aware layout for graph diffs.
3
+ *
4
+ * Both versions are drawn on ONE set of coordinates (the union layout), so
5
+ * nothing jumps when switching between before / after / overlay / replay.
6
+ * See docs/algorithms.md §Layout for the algorithm and quality bars.
7
+ *
8
+ * Signatures are the package contract; bodies live in the modules imported below.
9
+ */
10
+ import type { DiffResult, Point, Rect, UnionId } from '@kgdiff/core';
11
+ import { extend } from './extend.js';
12
+ import { fromJSON, toJSON } from './json.js';
13
+ import { computeLayout } from './layout.js';
14
+ import { labelWidth } from './text-metrics.js';
15
+
16
+ export type LayoutStrategy = 'union' | 'anchored';
17
+ export type LayoutScope = 'all' | 'changes';
18
+
19
+ export interface LayoutOptions {
20
+ /**
21
+ * - `union`: lay out the union graph so neither version is favoured (default when no node is pinned).
22
+ * - `anchored`: keep existing positions (pins, or `position` on before-version nodes) fixed and
23
+ * place only the remaining nodes (default when at least one position is known).
24
+ */
25
+ readonly strategy?: LayoutStrategy;
26
+ /**
27
+ * - `all`: every union node gets a position.
28
+ * - `changes`: only changed nodes, the endpoints of changed edges and `contextHops` of unchanged
29
+ * context around changed nodes, not expanded through hubs (see `hubDegree`). Default when the
30
+ * union has more than `largeGraphThreshold` nodes. Hidden neighbours are counted in `hidden`.
31
+ */
32
+ readonly scope?: LayoutScope;
33
+ /** Context hops kept around changes when `scope` is `changes`. Default 1. */
34
+ readonly contextHops?: number;
35
+ /**
36
+ * Scope `changes`: context is never expanded FROM a node whose degree (max over both
37
+ * versions) exceeds this, so one changed hub (a class with thousands of instances) does not pull
38
+ * its whole neighbourhood into the layout. The hub itself stays in scope when it is a seed or
39
+ * within reach; neighbours that could only enter through it are counted in `hidden`. Changed
40
+ * nodes are always in scope. Default 40; `Infinity` disables the rule.
41
+ */
42
+ readonly hubDegree?: number;
43
+ /** Default 3000 nodes. */
44
+ readonly largeGraphThreshold?: number;
45
+ /** PRNG seed. Same seed + same input → identical positions. Default 1. */
46
+ readonly seed?: number;
47
+ /** Simulation ticks. Default: chosen from graph size (docs/algorithms.md). */
48
+ readonly iterations?: number;
49
+ /** Target edge length in world units. Default 56. */
50
+ readonly linkDistance?: number;
51
+ /** Reserve room for labels so they do not overlap. Default true. */
52
+ readonly labelAware?: boolean;
53
+ /** Font size used to estimate label boxes (world units at zoom 1). Default 12. */
54
+ readonly fontSize?: number;
55
+ /** Explicit positions; they win over `position` found on graph nodes. */
56
+ readonly pinned?: ReadonlyMap<UnionId, Point>;
57
+ /** Gap between packed connected components. Default 96. */
58
+ readonly componentGap?: number;
59
+ }
60
+
61
+ export interface LayoutComponent {
62
+ readonly nodes: readonly UnionId[];
63
+ readonly bounds: Rect;
64
+ }
65
+
66
+ export interface LayoutResult {
67
+ /** Positions of every node in scope (world units, y down). */
68
+ readonly positions: ReadonlyMap<UnionId, Point>;
69
+ /** Radius of every node in scope (`nodeRadius` of its max degree across versions). */
70
+ readonly radii: ReadonlyMap<UnionId, number>;
71
+ /** Bounds of all nodes in scope, including estimated label boxes. */
72
+ readonly bounds: Rect;
73
+ readonly scope: LayoutScope;
74
+ /** For scope `changes`: per visible node, number of neighbours left out of the layout. */
75
+ readonly hidden: ReadonlyMap<UnionId, number>;
76
+ /** Connected components after packing, largest first. */
77
+ readonly components: readonly LayoutComponent[];
78
+ readonly stats: {
79
+ readonly strategy: LayoutStrategy;
80
+ readonly iterations: number;
81
+ readonly nodes: number;
82
+ /** Wall-clock time. The only non-deterministic field; never serialized. */
83
+ readonly ms: number;
84
+ };
85
+ }
86
+
87
+ /** Deterministic JSON transport (no timing information). */
88
+ export interface LayoutJSON {
89
+ readonly format: 'kgdiff.layout';
90
+ readonly version: 1;
91
+ readonly strategy: LayoutStrategy;
92
+ readonly scope: LayoutScope;
93
+ readonly iterations: number;
94
+ /** Coordinates rounded to 0.01. */
95
+ readonly positions: Readonly<Record<UnionId, readonly [number, number]>>;
96
+ readonly radii: Readonly<Record<UnionId, number>>;
97
+ readonly hidden: Readonly<Record<UnionId, number>>;
98
+ readonly bounds: Rect;
99
+ readonly components: readonly LayoutComponent[];
100
+ }
101
+
102
+ /**
103
+ * Compute the shared layout for a diff. Pure and deterministic: same diff + same options →
104
+ * identical `layoutToJSON` output, in Node and in every browser.
105
+ *
106
+ * Steps (docs/algorithms.md §12): select the scope (`changes` above `largeGraphThreshold`
107
+ * nodes), pick the strategy (`anchored` when any position is known), lay out every connected
108
+ * component with a seeded force simulation (BFS phyllotaxis start, degree-aware links, many-body,
109
+ * label-aware rectangle collision, weak merge / split springs), pack components to ~16:10
110
+ * (isolated nodes in a compact grid last), center at the origin (union only) and round to 0.01.
111
+ *
112
+ * - The graph laid out has one link per node pair connected in either version (retargeted and
113
+ * carried edges contribute their before form too), so neither version is favoured.
114
+ * - Scope `changes` keeps changed nodes, both endpoints of changed edges (either version) and
115
+ * the nodes within `contextHops` of a changed node, never expanding from hubs (degree >
116
+ * `hubDegree`); `hidden` only lists visible nodes with ≥ 1 hidden neighbour.
117
+ * - Known positions (`pinned`, then the before node's `position`, then the after node's) are kept
118
+ * exactly (unrounded) in `anchored` mode; `union` ignores them. Nothing is centered when
119
+ * anchored, and components without any known node are packed beside the anchored region.
120
+ * - Label boxes: centered below the node (`r + 5` below its center), `estimateLabelWidth` wide
121
+ * (capped at 32 em), one line of 1.25 em, plus the struck old name for renamed nodes. Without
122
+ * `labelAware` only node circles collide, but bounds still include the labels.
123
+ * - Label-aware extras beyond the spec values (docs in `forces/params.ts`): many-body strength
124
+ * scaled by `(linkDistance / 56)²` and, for long labels, by their box area; links of hubs
125
+ * crowded with labelled neighbours relax; a weak size-dependent pull keeps components compact;
126
+ * merge sources / split targets left far from their partner are docked next to it.
127
+ *
128
+ * Ticks per simulation (one per connected component, or the window around new nodes): the spec
129
+ * formula up to 2000 nodes, then `max(30, 120 · 2000 / N)` so large components stay within
130
+ * budget (`iterations` overrides both; `stats.iterations` reports the largest simulation's).
131
+ *
132
+ * Complexity: O(I · N log N) for I iterations (Barnes–Hut charge, quadtree collision).
133
+ * Budget: 2k nodes < 800 ms (measured ~0.6–0.7 s on an M-class laptop; 5k ≈ 1 s, 10k ≈ 1.6 s).
134
+ *
135
+ * @throws KgdiffError `invalid-input` for invalid options (negative distances, non-integer
136
+ * iterations, non-finite pins…).
137
+ */
138
+ export function layoutDiff(diff: DiffResult, options?: LayoutOptions): LayoutResult {
139
+ return computeLayout(diff, options, nodeRadius);
140
+ }
141
+
142
+ /**
143
+ * Add `nodes` (currently outside the layout) next to their placed neighbours without moving any
144
+ * node already placed. Used when the user expands a collapsed group in changes-only views.
145
+ *
146
+ * Already placed ids are ignored (returns `layout` itself when nothing is new). New nodes with no
147
+ * path to a placed node are laid out on their own and packed beside the existing bounds. `hidden`
148
+ * is recounted for scope `changes`; `stats.strategy` / `stats.iterations` are kept from `layout`.
149
+ *
150
+ * @throws KgdiffError `invalid-input` when an id is not a node of `diff`.
151
+ */
152
+ export function extendLayout(
153
+ diff: DiffResult,
154
+ layout: LayoutResult,
155
+ nodes: readonly UnionId[],
156
+ options?: LayoutOptions,
157
+ ): LayoutResult {
158
+ return extend(diff, layout, nodes, options, nodeRadius);
159
+ }
160
+
161
+ /** Node radius in world units from degree: `clamp(4 + 2.2·√degree, 4, 14)`. Shared by every renderer. */
162
+ export function nodeRadius(degree: number): number {
163
+ return Math.min(14, Math.max(4, 4 + 2.2 * Math.sqrt(Math.max(0, degree))));
164
+ }
165
+
166
+ /**
167
+ * Deterministic label width estimate (no DOM): CJK/full-width = 1em, Latin ≈ 0.56em, digits 0.6em.
168
+ *
169
+ * Sum of per-code-point advances × `fontSize`: CJK / full-width / emoji 1.0em, digits 0.6em,
170
+ * uppercase 0.68em, space 0.3em, other characters 0.56em; combining marks, joiners and variation
171
+ * selectors 0em; a regional-indicator pair (flag) 1em. O(length).
172
+ */
173
+ export function estimateLabelWidth(text: string, fontSize: number): number {
174
+ return labelWidth(text, fontSize);
175
+ }
176
+
177
+ /**
178
+ * Deterministic JSON form: keys sorted by id, coordinates and radii rounded to 0.01, no timing.
179
+ * `layoutToJSON(layoutFromJSON(json))` reproduces `json`.
180
+ */
181
+ export function layoutToJSON(layout: LayoutResult): LayoutJSON {
182
+ return toJSON(layout);
183
+ }
184
+
185
+ /**
186
+ * Rebuild a `LayoutResult` from `layoutToJSON` output (`stats.ms` is 0).
187
+ *
188
+ * @throws KgdiffError `invalid-input` for malformed input, `unsupported-version` for an unknown
189
+ * version.
190
+ */
191
+ export function layoutFromJSON(json: LayoutJSON): LayoutResult {
192
+ return fromJSON(json);
193
+ }