@kgdiff/scene 0.0.0-stage → 0.2.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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -2
  3. package/dist/build.d.ts +23 -0
  4. package/dist/build.d.ts.map +1 -0
  5. package/dist/build.js +407 -0
  6. package/dist/build.js.map +1 -0
  7. package/dist/choreography.d.ts +98 -0
  8. package/dist/choreography.d.ts.map +1 -0
  9. package/dist/choreography.js +271 -0
  10. package/dist/choreography.js.map +1 -0
  11. package/dist/color.d.ts +42 -0
  12. package/dist/color.d.ts.map +1 -0
  13. package/dist/color.js +169 -0
  14. package/dist/color.js.map +1 -0
  15. package/dist/constants.d.ts +89 -0
  16. package/dist/constants.d.ts.map +1 -0
  17. package/dist/constants.js +89 -0
  18. package/dist/constants.js.map +1 -0
  19. package/dist/easing.d.ts +28 -0
  20. package/dist/easing.d.ts.map +1 -0
  21. package/dist/easing.js +103 -0
  22. package/dist/easing.js.map +1 -0
  23. package/dist/focus.d.ts +45 -0
  24. package/dist/focus.d.ts.map +1 -0
  25. package/dist/focus.js +317 -0
  26. package/dist/focus.js.map +1 -0
  27. package/dist/frame.d.ts +13 -0
  28. package/dist/frame.d.ts.map +1 -0
  29. package/dist/frame.js +32 -0
  30. package/dist/frame.js.map +1 -0
  31. package/dist/geometry.d.ts +61 -0
  32. package/dist/geometry.d.ts.map +1 -0
  33. package/dist/geometry.js +122 -0
  34. package/dist/geometry.js.map +1 -0
  35. package/dist/index.d.ts +16 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +16 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/resolver.d.ts +9 -0
  40. package/dist/resolver.d.ts.map +1 -0
  41. package/dist/resolver.js +1621 -0
  42. package/dist/resolver.js.map +1 -0
  43. package/dist/roles.d.ts +46 -0
  44. package/dist/roles.d.ts.map +1 -0
  45. package/dist/roles.js +197 -0
  46. package/dist/roles.js.map +1 -0
  47. package/dist/styles.d.ts +116 -0
  48. package/dist/styles.d.ts.map +1 -0
  49. package/dist/styles.js +391 -0
  50. package/dist/styles.js.map +1 -0
  51. package/dist/theme.d.ts +14 -0
  52. package/dist/theme.d.ts.map +1 -0
  53. package/dist/theme.js +154 -0
  54. package/dist/theme.js.map +1 -0
  55. package/dist/timeline.d.ts +69 -0
  56. package/dist/timeline.d.ts.map +1 -0
  57. package/dist/timeline.js +362 -0
  58. package/dist/timeline.js.map +1 -0
  59. package/dist/types.d.ts +493 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +2 -0
  62. package/dist/types.js.map +1 -0
  63. package/dist/view.d.ts +11 -0
  64. package/dist/view.d.ts.map +1 -0
  65. package/dist/view.js +22 -0
  66. package/dist/view.js.map +1 -0
  67. package/package.json +46 -3
  68. package/src/build.ts +450 -0
  69. package/src/choreography.ts +342 -0
  70. package/src/color.ts +194 -0
  71. package/src/constants.ts +97 -0
  72. package/src/easing.ts +104 -0
  73. package/src/focus.ts +335 -0
  74. package/src/frame.ts +37 -0
  75. package/src/geometry.ts +162 -0
  76. package/src/index.ts +15 -0
  77. package/src/resolver.ts +1710 -0
  78. package/src/roles.ts +203 -0
  79. package/src/styles.ts +513 -0
  80. package/src/theme.ts +185 -0
  81. package/src/timeline.ts +391 -0
  82. package/src/types.ts +544 -0
  83. package/src/view.ts +27 -0
package/src/easing.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Easing and interpolation helpers for the replay choreography. All functions are pure and
3
+ * allocation-free once created (curves are built once at module load). The per-frame helpers are
4
+ * deliberately tiny so the JIT inlines them: a non-inlined call returning a double boxes it.
5
+ */
6
+ import { KgdiffError } from '@kgdiff/core';
7
+
8
+ export const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
9
+
10
+ export const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
11
+
12
+ /** Local progress of `u` inside the sub-interval [a, b], clamped to [0, 1]. */
13
+ export const segment = (u: number, a: number, b: number): number => {
14
+ if (b <= a) return u >= b ? 1 : 0;
15
+ const v = (u - a) / (b - a);
16
+ return Math.min(1, Math.max(0, v));
17
+ };
18
+
19
+ /** 0 → 1 → 0 bump over [0, 1] (flashes, pulses). */
20
+ export const bump = (t: number): number => (t <= 0 || t >= 1 ? 0 : Math.sin(Math.PI * t));
21
+
22
+ /**
23
+ * CSS `cubic-bezier(x1, y1, x2, y2)` as a function of progress in [0, 1]: Newton–Raphson on
24
+ * the x polynomial with a bisection fallback (WebKit's UnitBezier approach), precise to 1e-7.
25
+ *
26
+ * @throws KgdiffError(`invalid-input`) when x1 or x2 lie outside [0, 1] (x must be monotonic).
27
+ */
28
+ export function cubicBezier(x1: number, y1: number, x2: number, y2: number): (t: number) => number {
29
+ if (!(x1 >= 0 && x1 <= 1 && x2 >= 0 && x2 <= 1)) {
30
+ throw new KgdiffError(
31
+ 'invalid-input',
32
+ `cubicBezier: x1 and x2 must be in [0, 1], got ${x1}, ${x2}.`,
33
+ );
34
+ }
35
+ const cx = 3 * x1;
36
+ const bx = 3 * (x2 - x1) - cx;
37
+ const ax = 1 - cx - bx;
38
+ const cy = 3 * y1;
39
+ const by = 3 * (y2 - y1) - cy;
40
+ const ay = 1 - cy - by;
41
+ const sampleX = (t: number): number => ((ax * t + bx) * t + cx) * t;
42
+ const sampleY = (t: number): number => ((ay * t + by) * t + cy) * t;
43
+ const slopeX = (t: number): number => (3 * ax * t + 2 * bx) * t + cx;
44
+ const solveX = (x: number): number => {
45
+ let t = x;
46
+ for (let i = 0; i < 8; i++) {
47
+ const err = sampleX(t) - x;
48
+ if (Math.abs(err) < 1e-7) return t;
49
+ const d = slopeX(t);
50
+ if (Math.abs(d) < 1e-6) break;
51
+ t -= err / d;
52
+ }
53
+ let lo = 0;
54
+ let hi = 1;
55
+ t = x;
56
+ for (let i = 0; i < 64 && hi - lo > 1e-7; i++) {
57
+ const v = sampleX(t);
58
+ if (Math.abs(v - x) < 1e-7) return t;
59
+ if (x > v) lo = t;
60
+ else hi = t;
61
+ t = (lo + hi) / 2;
62
+ }
63
+ return t;
64
+ };
65
+ return (t: number): number => (t <= 0 ? 0 : t >= 1 ? 1 : sampleY(solveX(t)));
66
+ }
67
+
68
+ const STANDARD = cubicBezier(0.2, 0, 0, 1);
69
+ const LUT_STEPS = 1024;
70
+ const LUT = new Float64Array(LUT_STEPS + 2);
71
+ for (let i = 0; i <= LUT_STEPS; i++) LUT[i] = STANDARD(i / LUT_STEPS);
72
+ LUT[LUT_STEPS + 1] = 1;
73
+
74
+ /**
75
+ * The house easing `cubic-bezier(0.2, 0, 0, 1)` (theme.motion.easing), sampled into a
76
+ * 1024-step table with linear interpolation (max error ≈ 1e-5). Kept tiny on purpose: the
77
+ * replay calls it many times per frame, and only functions small enough to be inlined return
78
+ * doubles without boxing them on the heap.
79
+ */
80
+ export const easeStandard = (t: number): number => {
81
+ if (!(t > 0)) return 0;
82
+ if (t >= 1) return 1;
83
+ const x = t * LUT_STEPS;
84
+ const i = x | 0;
85
+ const a = LUT[i] as number;
86
+ return a + ((LUT[i + 1] as number) - a) * (x - i);
87
+ };
88
+
89
+ /** Symmetric cubic ease-in-out, for travel along arcs. */
90
+ export const easeInOut = (t: number): number => {
91
+ const u = Math.min(1, Math.max(0, t));
92
+ if (u < 0.5) return 4 * u * u * u;
93
+ const v = 2 - 2 * u;
94
+ return 1 - (v * v * v) / 2;
95
+ };
96
+
97
+ /**
98
+ * Back-out easing: overshoots then settles at 1. The peak is `1 + 4s³ / (27 (s + 1)²)`, so the
99
+ * default `s = 1.5` peaks at exactly 1.08 (the node pop: scale 0 → 1.08 → 1).
100
+ */
101
+ export const backOut = (t: number, s = 1.5): number => {
102
+ const u = Math.min(1, Math.max(0, t)) - 1;
103
+ return 1 + (s + 1) * u * u * u + s * u * u;
104
+ };
package/src/focus.ts ADDED
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Changes-only visibility (docs/algorithms.md §11).
3
+ */
4
+ import { KgdiffError, type Locale } from '@kgdiff/core';
5
+ import type { CollapsedGroup, FocusState, Scene } from './types.js';
6
+
7
+ /** Undirected adjacency over every drawn form (after s–t and before sb–tb), CSR layout. */
8
+ interface Adjacency {
9
+ readonly start: Int32Array;
10
+ readonly list: Int32Array;
11
+ }
12
+
13
+ const adjacencyCache = new WeakMap<Scene, Adjacency>();
14
+
15
+ function adjacency(scene: Scene): Adjacency {
16
+ const cached = adjacencyCache.get(scene);
17
+ if (cached !== undefined) return cached;
18
+ const N = scene.nodes.length;
19
+ const deg = new Int32Array(N + 1);
20
+ const pairs: number[] = [];
21
+ const add = (a: number, b: number): void => {
22
+ if (a === b) return;
23
+ pairs.push(a, b);
24
+ deg[a + 1] = (deg[a + 1] as number) + 1;
25
+ deg[b + 1] = (deg[b + 1] as number) + 1;
26
+ };
27
+ for (const e of scene.edges) {
28
+ add(e.s, e.t);
29
+ if (e.sb !== e.s || e.tb !== e.t) add(e.sb, e.tb);
30
+ }
31
+ for (let i = 0; i < N; i++) deg[i + 1] = (deg[i + 1] as number) + (deg[i] as number);
32
+ const fill = deg.slice(0, N);
33
+ const list = new Int32Array(pairs.length);
34
+ for (let k = 0; k < pairs.length; k += 2) {
35
+ const a = pairs[k] as number;
36
+ const b = pairs[k + 1] as number;
37
+ list[fill[a] as number] = b;
38
+ fill[a] = (fill[a] as number) + 1;
39
+ list[fill[b] as number] = a;
40
+ fill[b] = (fill[b] as number) + 1;
41
+ }
42
+ // Sorted neighbour lists make every traversal below independent of edge order.
43
+ for (let i = 0; i < N; i++) list.subarray(deg[i] as number, deg[i + 1] as number).sort();
44
+ const adj = { start: deg, list };
45
+ adjacencyCache.set(scene, adj);
46
+ return adj;
47
+ }
48
+
49
+ const numberFormats = new Map<Locale, Intl.NumberFormat>();
50
+
51
+ /** “+38 unchanged” / “+38 个未变节点”; “hidden” when the pill also hides changed nodes. */
52
+ export function pillLabel(count: number, unchangedOnly: boolean, locale: Locale): string {
53
+ let nf = numberFormats.get(locale);
54
+ if (nf === undefined) {
55
+ nf = new Intl.NumberFormat(locale);
56
+ numberFormats.set(locale, nf);
57
+ }
58
+ const n = nf.format(count);
59
+ if (locale === 'zh-CN') return unchangedOnly ? `+${n} 个未变节点` : `+${n} 个隐藏节点`;
60
+ return unchangedOnly ? `+${n} unchanged` : `+${n} hidden`;
61
+ }
62
+
63
+ /** World-unit distance of a pill that has no in-scene member from its node's boundary. */
64
+ const PILL_OFFSET = 28;
65
+
66
+ /** Default hub threshold, as in layout's `changes` scope (docs/algorithms.md §12). */
67
+ export const DEFAULT_HUB_DEGREE = 40;
68
+
69
+ /**
70
+ * Changes-only visibility: every changed element plus context, with the same rule as the layout's
71
+ * `changes` scope (docs/algorithms.md §12), so the changes-only view and a `changes` layout agree:
72
+ *
73
+ * - Changed nodes are visible (distance 0); both versions' endpoints of every changed edge are
74
+ * visible at distance 1 (needed to draw the edge), even with `hops: 0`.
75
+ * - Context is a breadth-first search over both versions' edges from the changed nodes, `hops`
76
+ * deep (default 1; edge endpoints continue from hop 1), that never expands **from** a hub — a
77
+ * node whose degree (max over both versions) exceeds `hubDegree` (default 40; `Infinity`
78
+ * disables). A hub is shown when it is changed or reached; its skipped neighbours collapse.
79
+ * - `expanded` nodes are added (not expanded from). With `hunk`, the hunk's nodes and edges are
80
+ * the changes.
81
+ * - `context` marks visible nodes outside the change set (rendered quieter).
82
+ * - Hidden nodes are grouped: next to a visible hub that was not expanded from (its skipped
83
+ * neighbours), else next to the lowest-index visible neighbour; nodes further out join the
84
+ * group of the frontier node that reaches them first (BFS through hidden nodes); hidden
85
+ * components touching nothing visible form one group each. Ids are stable across rebuilds:
86
+ * `near:<union id>` / `component:<smallest union id>`.
87
+ * - `count` also includes neighbours left out of the layout (`SceneNode.hidden`, scope
88
+ * `changes`); `nodes` lists only in-scene members. A group with no in-scene member sits
89
+ * just outside its node, away from its visible neighbours.
90
+ * - `label` is the localized pill text in `scene.locale` (“+38 unchanged”, “+38 个未变节点”;
91
+ * “hidden” instead of “unchanged” when a hunk-restricted focus hides changed nodes too).
92
+ *
93
+ * O(N + E).
94
+ *
95
+ * @throws KgdiffError(`invalid-input`) for a negative / non-finite `hops`, a negative / NaN
96
+ * `hubDegree` or an unknown hunk.
97
+ */
98
+ export function computeFocus(
99
+ scene: Scene,
100
+ options: {
101
+ readonly hops?: number;
102
+ readonly hunk?: number;
103
+ readonly expanded?: ReadonlySet<number>;
104
+ readonly hubDegree?: number;
105
+ } = {},
106
+ ): FocusState {
107
+ const hops = options.hops ?? 1;
108
+ if (!(Number.isFinite(hops) && hops >= 0)) {
109
+ throw new KgdiffError(
110
+ 'invalid-input',
111
+ `computeFocus: hops must be a finite number ≥ 0, got ${hops}.`,
112
+ );
113
+ }
114
+ const hubDegree = options.hubDegree ?? DEFAULT_HUB_DEGREE;
115
+ if (!(hubDegree >= 0)) {
116
+ throw new KgdiffError(
117
+ 'invalid-input',
118
+ `computeFocus: hubDegree must be a number ≥ 0 (Infinity disables), got ${hubDegree}.`,
119
+ );
120
+ }
121
+ const hunk = options.hunk;
122
+ if (hunk !== undefined && !(Number.isInteger(hunk) && hunk >= 0 && hunk < scene.hunks.length)) {
123
+ throw new KgdiffError(
124
+ 'invalid-input',
125
+ `computeFocus: hunk ${hunk} is out of range [0, ${scene.hunks.length}).`,
126
+ );
127
+ }
128
+ const N = scene.nodes.length;
129
+ const M = scene.edges.length;
130
+ const { start, list } = adjacency(scene);
131
+ const visible = new Uint8Array(N);
132
+ const primary = new Uint8Array(N);
133
+ const dist = new Int32Array(N).fill(-1);
134
+ const queue = new Int32Array(N);
135
+ let qHead = 0;
136
+ let qTail = 0;
137
+ const reach = (i: number, d: number): void => {
138
+ if (i < 0 || i >= N || dist[i] !== -1) return;
139
+ dist[i] = d;
140
+ visible[i] = 1;
141
+ queue[qTail++] = i;
142
+ };
143
+
144
+ // Distance 0 first, then distance 1, so the FIFO queue stays ordered by distance.
145
+ const changedEdges: number[] = [];
146
+ if (hunk !== undefined) {
147
+ const h = scene.hunks[hunk];
148
+ for (const i of h?.nodes ?? []) {
149
+ primary[i] = 1;
150
+ reach(i, 0);
151
+ }
152
+ for (const j of h?.edges ?? []) changedEdges.push(j);
153
+ } else {
154
+ for (const n of scene.nodes) {
155
+ if (n.status === 'unchanged') continue;
156
+ primary[n.index] = 1;
157
+ reach(n.index, 0);
158
+ }
159
+ for (const e of scene.edges) if (e.status !== 'unchanged') changedEdges.push(e.index);
160
+ }
161
+ for (const j of changedEdges) {
162
+ const e = scene.edges[j];
163
+ if (e === undefined) continue;
164
+ reach(e.s, 1);
165
+ reach(e.t, 1);
166
+ reach(e.sb, 1);
167
+ reach(e.tb, 1);
168
+ }
169
+ // Context: breadth-first within `hops`, never expanding from a hub.
170
+ const maxHops = Math.floor(hops);
171
+ const skippedHub = new Uint8Array(N);
172
+ while (qHead < qTail) {
173
+ const i = queue[qHead++] as number;
174
+ const d = dist[i] as number;
175
+ if (d >= maxHops) continue;
176
+ if ((scene.nodes[i]?.degree ?? 0) > hubDegree) {
177
+ skippedHub[i] = 1;
178
+ continue;
179
+ }
180
+ for (let k = start[i] as number; k < (start[i + 1] as number); k++) {
181
+ reach(list[k] as number, d + 1);
182
+ }
183
+ }
184
+ for (const i of options.expanded ?? []) if (i >= 0 && i < N) visible[i] = 1;
185
+
186
+ const context = new Uint8Array(N);
187
+ for (let i = 0; i < N; i++) if (visible[i] === 1 && primary[i] === 0) context[i] = 1;
188
+ const edges = new Uint8Array(M);
189
+ for (let j = 0; j < M; j++) {
190
+ const e = scene.edges[j];
191
+ if (e !== undefined && visible[e.s] === 1 && visible[e.t] === 1) edges[j] = 1;
192
+ }
193
+ return {
194
+ nodes: visible,
195
+ edges,
196
+ context,
197
+ collapsed: collapse(scene, visible, skippedHub, start, list),
198
+ };
199
+ }
200
+
201
+ function collapse(
202
+ scene: Scene,
203
+ visible: Uint8Array,
204
+ skippedHub: Uint8Array,
205
+ start: Int32Array,
206
+ list: Int32Array,
207
+ ): CollapsedGroup[] {
208
+ const N = scene.nodes.length;
209
+ // owner[i] = the node whose group i joins: a visible frontier node, or a component root.
210
+ const owner = new Int32Array(N).fill(-1);
211
+ const queue = new Int32Array(N);
212
+ let head = 0;
213
+ let tail = 0;
214
+ for (let i = 0; i < N; i++) {
215
+ if (visible[i] === 1) continue;
216
+ // A hub that was not expanded from claims its skipped neighbours; else the lowest index.
217
+ let first = -1;
218
+ let hub = -1;
219
+ for (let k = start[i] as number; k < (start[i + 1] as number); k++) {
220
+ const n = list[k] as number;
221
+ if (visible[n] !== 1) continue;
222
+ if (first < 0) first = n;
223
+ if (skippedHub[n] === 1) {
224
+ hub = n;
225
+ break;
226
+ }
227
+ }
228
+ if (first >= 0) {
229
+ owner[i] = hub >= 0 ? hub : first;
230
+ queue[tail++] = i;
231
+ }
232
+ }
233
+ const spread = (): void => {
234
+ while (head < tail) {
235
+ const i = queue[head++] as number;
236
+ for (let k = start[i] as number; k < (start[i + 1] as number); k++) {
237
+ const n = list[k] as number;
238
+ if (visible[n] === 1 || owner[n] !== -1) continue;
239
+ owner[n] = owner[i] as number;
240
+ queue[tail++] = n;
241
+ }
242
+ }
243
+ };
244
+ spread();
245
+ const isComponent = new Uint8Array(N);
246
+ for (let i = 0; i < N; i++) {
247
+ if (visible[i] === 1 || owner[i] !== -1) continue;
248
+ owner[i] = i;
249
+ isComponent[i] = 1;
250
+ queue[tail++] = i;
251
+ spread();
252
+ }
253
+
254
+ interface Acc {
255
+ readonly owner: number;
256
+ readonly members: number[];
257
+ extra: number;
258
+ changed: boolean;
259
+ sx: number;
260
+ sy: number;
261
+ }
262
+ const groups = new Map<number, Acc>();
263
+ const acc = (o: number): Acc => {
264
+ let g = groups.get(o);
265
+ if (g === undefined) {
266
+ g = { owner: o, members: [], extra: 0, changed: false, sx: 0, sy: 0 };
267
+ groups.set(o, g);
268
+ }
269
+ return g;
270
+ };
271
+ for (let i = 0; i < N; i++) {
272
+ if (visible[i] === 1) continue;
273
+ const g = acc(owner[i] as number);
274
+ const n = scene.nodes[i];
275
+ g.members.push(i);
276
+ if (n !== undefined && n.status !== 'unchanged') g.changed = true;
277
+ g.sx += n?.x ?? 0;
278
+ g.sy += n?.y ?? 0;
279
+ }
280
+ // Neighbours outside the layout belong behind the node that has them.
281
+ for (const n of scene.nodes) {
282
+ if (n.hidden <= 0) continue;
283
+ acc(visible[n.index] === 1 ? n.index : (owner[n.index] as number)).extra += n.hidden;
284
+ }
285
+
286
+ const out: CollapsedGroup[] = [];
287
+ for (const g of groups.values()) {
288
+ const ownerNode = scene.nodes[g.owner];
289
+ if (ownerNode === undefined) continue;
290
+ const count = g.members.length + g.extra;
291
+ if (count === 0) continue;
292
+ let x: number;
293
+ let y: number;
294
+ if (g.members.length > 0) {
295
+ x = g.sx / g.members.length;
296
+ y = g.sy / g.members.length;
297
+ } else {
298
+ // Outside the node, away from the centroid of its visible neighbours.
299
+ let mx = 0;
300
+ let my = 0;
301
+ let k0 = 0;
302
+ for (let k = start[g.owner] as number; k < (start[g.owner + 1] as number); k++) {
303
+ const n = scene.nodes[list[k] as number];
304
+ if (n === undefined || visible[n.index] !== 1) continue;
305
+ mx += n.x;
306
+ my += n.y;
307
+ k0++;
308
+ }
309
+ let dx = 0;
310
+ let dy = -1;
311
+ if (k0 > 0) {
312
+ const vx = ownerNode.x - mx / k0;
313
+ const vy = ownerNode.y - my / k0;
314
+ const len = Math.hypot(vx, vy);
315
+ if (len > 1e-9) {
316
+ dx = vx / len;
317
+ dy = vy / len;
318
+ }
319
+ }
320
+ const d = ownerNode.r + PILL_OFFSET;
321
+ x = ownerNode.x + dx * d;
322
+ y = ownerNode.y + dy * d;
323
+ }
324
+ out.push({
325
+ id: isComponent[g.owner] === 1 ? `component:${ownerNode.id}` : `near:${ownerNode.id}`,
326
+ x,
327
+ y,
328
+ count,
329
+ nodes: g.members,
330
+ label: pillLabel(count, !g.changed, scene.locale),
331
+ });
332
+ }
333
+ out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
334
+ return out;
335
+ }
package/src/frame.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Camera targets.
3
+ */
4
+ import type { Rect } from '@kgdiff/core';
5
+ import { BoundsBuilder } from './geometry.js';
6
+ import type { Scene } from './types.js';
7
+
8
+ /** Relative breathing room added on each side (labels already sit inside hunk bounds). */
9
+ const PAD_RATIO = 0.06;
10
+ /** Minimum padding on each side, world units. */
11
+ const MIN_PAD = 16;
12
+ /** Smallest framed extent, world units: a one-node hunk still shows its neighbourhood. */
13
+ const MIN_EXTENT = 160;
14
+
15
+ /**
16
+ * Camera target for a hunk / chapter / whole scene: the union of the hunks' bounds (or the
17
+ * scene bounds when `hunks` is omitted, empty or has no valid index), padded by
18
+ * `max(16, 6% of the larger side)` per side and grown to at least 160 × 160 world units,
19
+ * centered. UI chrome is reserved by the renderer's `safeArea`.
20
+ */
21
+ export function frameBounds(scene: Scene, hunks?: readonly number[]): Rect {
22
+ let rect = scene.bounds;
23
+ if (hunks !== undefined && hunks.length > 0) {
24
+ const b = new BoundsBuilder();
25
+ for (const h of hunks) {
26
+ const hunk = scene.hunks[h];
27
+ if (hunk !== undefined) b.addRect(hunk.bounds);
28
+ }
29
+ rect = b.rect(scene.bounds);
30
+ }
31
+ const pad = Math.max(MIN_PAD, PAD_RATIO * Math.max(rect.width, rect.height));
32
+ const width = Math.max(MIN_EXTENT, rect.width + 2 * pad);
33
+ const height = Math.max(MIN_EXTENT, rect.height + 2 * pad);
34
+ const cx = rect.x + rect.width / 2;
35
+ const cy = rect.y + rect.height / 2;
36
+ return { x: cx - width / 2, y: cy - height / 2, width, height };
37
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Geometry shared by scene building, framing and the resolver: label boxes (widths from
3
+ * `@kgdiff/layout`'s `estimateLabelWidth`, so scene bounds match the layout's label boxes),
4
+ * rectangles, parallel-edge curvature and curve conventions.
5
+ *
6
+ * Curve convention (all renderers): an edge or glyph with curvature `c` from P1 to P2 is the
7
+ * quadratic Bézier whose control point is `mid(P1, P2) + c · |P2 − P1| · n`, where
8
+ * `n = (−dy, dx) / |P2 − P1|` (world space, y down). `c = 0` is a straight segment.
9
+ */
10
+ import type { Rect } from '@kgdiff/core';
11
+ import { estimateLabelWidth } from '@kgdiff/layout';
12
+ import { LABEL, PARALLEL_CURVATURE } from './constants.js';
13
+
14
+ export const EMPTY_RECT: Rect = Object.freeze({ x: 0, y: 0, width: 0, height: 0 });
15
+
16
+ /** Mutable bounds accumulator (min / max), cheap to extend. */
17
+ export class BoundsBuilder {
18
+ minX = Number.POSITIVE_INFINITY;
19
+ minY = Number.POSITIVE_INFINITY;
20
+ maxX = Number.NEGATIVE_INFINITY;
21
+ maxY = Number.NEGATIVE_INFINITY;
22
+
23
+ get empty(): boolean {
24
+ return this.minX > this.maxX;
25
+ }
26
+
27
+ add(x0: number, y0: number, x1: number, y1: number): this {
28
+ if (x0 < this.minX) this.minX = x0;
29
+ if (y0 < this.minY) this.minY = y0;
30
+ if (x1 > this.maxX) this.maxX = x1;
31
+ if (y1 > this.maxY) this.maxY = y1;
32
+ return this;
33
+ }
34
+
35
+ addRect(r: Rect): this {
36
+ return this.add(r.x, r.y, r.x + r.width, r.y + r.height);
37
+ }
38
+
39
+ rect(fallback: Rect = EMPTY_RECT): Rect {
40
+ if (this.empty) return fallback;
41
+ return {
42
+ x: this.minX,
43
+ y: this.minY,
44
+ width: this.maxX - this.minX,
45
+ height: this.maxY - this.minY,
46
+ };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * World-space box of a node and its label(s) at zoom 1: the circle, plus the label centered
52
+ * below it (`r + 5`), plus the struck old name 14 px lower when present.
53
+ */
54
+ export function addNodeBox(
55
+ b: BoundsBuilder,
56
+ x: number,
57
+ y: number,
58
+ r: number,
59
+ label: string,
60
+ secondary: string | undefined,
61
+ ): void {
62
+ const half = Math.max(
63
+ r,
64
+ estimateLabelWidth(label, LABEL.size) / 2,
65
+ secondary === undefined ? 0 : estimateLabelWidth(secondary, LABEL.oldSize) / 2,
66
+ );
67
+ const below =
68
+ r +
69
+ LABEL.offset +
70
+ LABEL.size * LABEL.lineHeight +
71
+ (secondary === undefined ? 0 : LABEL.secondaryGap);
72
+ b.add(x - half, y - r, x + half, y + below);
73
+ }
74
+
75
+ export function rectCenter(r: Rect): { x: number; y: number } {
76
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
77
+ }
78
+
79
+ /** One drawn line between two nodes, for parallel-curvature assignment. */
80
+ export interface EdgeForm {
81
+ readonly a: number;
82
+ readonly b: number;
83
+ /** Ghost forms (an edge's before form) take slots only after every real line of the pair. */
84
+ readonly ghost?: boolean;
85
+ }
86
+
87
+ /**
88
+ * Slot `k` of a fan of lines: with an odd number of real lines the first is straight and the
89
+ * rest alternate ±0.18·1, ±0.18·2, …; with an even number all are curved ±0.18·1, ±0.18·2, ….
90
+ */
91
+ function slot(k: number, odd: boolean): number {
92
+ if (odd) return k === 0 ? 0 : Math.ceil(k / 2) * PARALLEL_CURVATURE * (k % 2 === 1 ? 1 : -1);
93
+ return (Math.floor(k / 2) + 1) * PARALLEL_CURVATURE * (k % 2 === 0 ? 1 : -1);
94
+ }
95
+
96
+ /**
97
+ * Curvature for every form so that lines sharing an (unordered) node pair fan out. Real lines
98
+ * are slotted first as if alone (a lone line stays straight), so their geometry never depends
99
+ * on ghosts; ghosts continue the fan. Signs are expressed in each form's own direction so
100
+ * A→B and B→A never overlap. Order within a group is the input order (deterministic). O(F).
101
+ */
102
+ export function parallelCurvatures(forms: readonly EdgeForm[]): Float64Array {
103
+ const out = new Float64Array(forms.length);
104
+ const groups = new Map<string, number[]>();
105
+ for (let i = 0; i < forms.length; i++) {
106
+ const f = forms[i] as EdgeForm;
107
+ const key = f.a <= f.b ? `${f.a},${f.b}` : `${f.b},${f.a}`;
108
+ const g = groups.get(key);
109
+ if (g === undefined) groups.set(key, [i]);
110
+ else g.push(i);
111
+ }
112
+ for (const members of groups.values()) {
113
+ if (members.length < 2) continue;
114
+ const real = members.filter((i) => forms[i]?.ghost !== true);
115
+ const ghosts = members.filter((i) => forms[i]?.ghost === true);
116
+ const odd = (real.length > 0 ? real.length : ghosts.length) % 2 === 1;
117
+ const ordered = [...real, ...ghosts];
118
+ for (let k = 0; k < ordered.length; k++) {
119
+ const idx = ordered[k] as number;
120
+ const f = forms[idx] as EdgeForm;
121
+ const c = slot(k, odd);
122
+ out[idx] = f.a <= f.b ? c : -c;
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /** Endpoints of a scene edge (after form s → t, before form sb → tb). */
129
+ export interface FormEdge {
130
+ readonly s: number;
131
+ readonly t: number;
132
+ readonly sb: number;
133
+ readonly tb: number;
134
+ }
135
+
136
+ /**
137
+ * Curvatures of every edge's drawn form (after endpoints) and of its before form when that
138
+ * differs (retargeted / carried ghosts; 0 otherwise).
139
+ */
140
+ export function edgeCurvatures(edges: readonly FormEdge[]): {
141
+ readonly primary: Float64Array;
142
+ readonly ghost: Float64Array;
143
+ } {
144
+ const forms: EdgeForm[] = [];
145
+ for (const e of edges) forms.push({ a: e.s, b: e.t });
146
+ const ghostSlot = new Int32Array(edges.length).fill(-1);
147
+ for (let i = 0; i < edges.length; i++) {
148
+ const e = edges[i] as FormEdge;
149
+ if (e.sb !== e.s || e.tb !== e.t) {
150
+ ghostSlot[i] = forms.length;
151
+ forms.push({ a: e.sb, b: e.tb, ghost: true });
152
+ }
153
+ }
154
+ const all = parallelCurvatures(forms);
155
+ const primary = all.slice(0, edges.length);
156
+ const ghost = new Float64Array(edges.length);
157
+ for (let i = 0; i < edges.length; i++) {
158
+ const k = ghostSlot[i] as number;
159
+ if (k >= 0) ghost[i] = all[k] as number;
160
+ }
161
+ return { primary, ghost };
162
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @kgdiff/scene: diff semantics → visual primitives.
3
+ *
4
+ * The visual language these functions implement is specified in docs/visual-language.md
5
+ * (tokens, encodings, motion); ordering, timeline and focus in docs/algorithms.md §10–11.
6
+ */
7
+ export { buildScene } from './build.js';
8
+ export { contrastRatio } from './color.js';
9
+ export { computeFocus } from './focus.js';
10
+ export { frameBounds } from './frame.js';
11
+ export { createVisualResolver } from './resolver.js';
12
+ export { hunkFamily } from './roles.js';
13
+ export { getTheme } from './theme.js';
14
+ export * from './types.js';
15
+ export { createViewState } from './view.js';