@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/build.ts ADDED
@@ -0,0 +1,450 @@
1
+ /**
2
+ * `buildScene`: diff + layout → the renderer-agnostic scene (indices, roles, families, labels,
3
+ * badges, curvature, importance, hunks in tour order, timeline).
4
+ */
5
+ import {
6
+ type DiffResult,
7
+ formatHunk,
8
+ type GraphMeta,
9
+ type Hunk,
10
+ type Id,
11
+ KgdiffError,
12
+ type UnionEdge,
13
+ type UnionId,
14
+ type UnionNode,
15
+ } from '@kgdiff/core';
16
+ import { type LayoutResult, nodeRadius } from '@kgdiff/layout';
17
+ import { TIMELINE } from './constants.js';
18
+ import { addNodeBox, BoundsBuilder, edgeCurvatures, rectCenter } from './geometry.js';
19
+ import {
20
+ edgeFamily,
21
+ edgeLabelBefore,
22
+ edgeRole,
23
+ hunkFamily,
24
+ nodeBadge,
25
+ nodeFamily,
26
+ nodeFamilyBefore,
27
+ nodeLabelBefore,
28
+ nodeRole,
29
+ } from './roles.js';
30
+ import { getTheme } from './theme.js';
31
+ import { buildTimeline, chapterPlan, type TourHunk, timelineMode, tourOrder } from './timeline.js';
32
+ import type {
33
+ ChangeFamily,
34
+ Scene,
35
+ SceneEdge,
36
+ SceneHunk,
37
+ SceneNode,
38
+ SceneOptions,
39
+ SceneSuggestion,
40
+ Theme,
41
+ } from './types.js';
42
+
43
+ function resolveTheme(options: SceneOptions): Theme {
44
+ const t = options.theme;
45
+ if (t !== undefined && typeof t === 'object') return t;
46
+ return getTheme(t ?? 'dark', options.palette ?? 'default');
47
+ }
48
+
49
+ function resolveReplay(options: SceneOptions): { pace: number; chapterThreshold: number } {
50
+ const pace = options.replay?.pace ?? TIMELINE.pace;
51
+ const chapterThreshold = options.replay?.chapterThreshold ?? TIMELINE.chapterThreshold;
52
+ if (!(Number.isFinite(pace) && pace > 0)) {
53
+ throw new KgdiffError(
54
+ 'invalid-input',
55
+ `replay.pace must be a positive number of ms, got ${pace}.`,
56
+ );
57
+ }
58
+ if (!(chapterThreshold >= 0)) {
59
+ throw new KgdiffError(
60
+ 'invalid-input',
61
+ `replay.chapterThreshold must be ≥ 0, got ${chapterThreshold}.`,
62
+ );
63
+ }
64
+ return { pace, chapterThreshold };
65
+ }
66
+
67
+ /** Importance in [0, 1]: changed nodes 0.8–1, unchanged 0–0.75, both by degree percentile. */
68
+ function importances(nodes: readonly UnionNode[]): Float64Array {
69
+ const n = nodes.length;
70
+ const degree = new Float64Array(n);
71
+ for (let i = 0; i < n; i++) {
72
+ const d = (nodes[i] as UnionNode).degree;
73
+ degree[i] = Math.max(d.before, d.after);
74
+ }
75
+ const order = Array.from({ length: n }, (_, i) => i).sort(
76
+ (a, b) => (degree[a] as number) - (degree[b] as number),
77
+ );
78
+ const at = (k: number): number => order[k] as number;
79
+ // rank = share of nodes with a strictly lower degree (equal degrees share a rank).
80
+ const rank = new Float64Array(n);
81
+ for (let k = 0; k < n; ) {
82
+ let j = k;
83
+ while (j < n && degree[at(j)] === degree[at(k)]) j++;
84
+ const r = n > 1 ? k / (n - 1) : 0;
85
+ for (let m = k; m < j; m++) rank[at(m)] = r;
86
+ k = j;
87
+ }
88
+ const out = new Float64Array(n);
89
+ for (let i = 0; i < n; i++) {
90
+ const changed = (nodes[i] as UnionNode).status !== 'unchanged';
91
+ const r = rank[i] as number;
92
+ out[i] = changed ? 0.8 + 0.2 * r : 0.75 * r;
93
+ }
94
+ return out;
95
+ }
96
+
97
+ function facetTarget(node: UnionNode, kind: 'mergedInto' | 'splitFrom'): UnionId | undefined {
98
+ for (const f of node.facets) {
99
+ if (f.kind === 'mergedInto' && kind === 'mergedInto') return f.target;
100
+ if (f.kind === 'splitFrom' && kind === 'splitFrom') return f.source;
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ /** `meta.predicateLabels` of a graph when it is a plain object (formats: predicate → label). */
106
+ function predicateLabelTable(meta: GraphMeta): Readonly<Record<string, unknown>> | null {
107
+ const table = meta.predicateLabels;
108
+ return typeof table === 'object' && table !== null && !Array.isArray(table)
109
+ ? (table as Readonly<Record<string, unknown>>)
110
+ : null;
111
+ }
112
+
113
+ /**
114
+ * Display labels of edges. A predicate's label comes from the files' own property labels
115
+ * (`after.meta.predicateLabels`, then `before.meta.predicateLabels`; own non-empty strings only),
116
+ * else the edge's readable predicate name. Edges with a `label` facet carry explicit labels
117
+ * that changed, so those are kept as they are.
118
+ */
119
+ function edgeDisplayLabels(
120
+ diff: DiffResult,
121
+ ): (e: UnionEdge) => { label: string; labelBefore: string | undefined } {
122
+ const tables = [predicateLabelTable(diff.after.meta), predicateLabelTable(diff.before.meta)];
123
+ const lookup = (predicate: string | undefined): string | undefined => {
124
+ if (predicate === undefined) return undefined;
125
+ for (const table of tables) {
126
+ if (table === null || !Object.hasOwn(table, predicate)) continue;
127
+ const v = table[predicate];
128
+ if (typeof v === 'string' && v !== '') return v;
129
+ }
130
+ return undefined;
131
+ };
132
+ return (e) => {
133
+ if (e.facets.some((f) => f.kind === 'label')) {
134
+ return { label: e.label, labelBefore: edgeLabelBefore(e) };
135
+ }
136
+ const predicateFacet = e.facets.find((f) => f.kind === 'predicate');
137
+ const before =
138
+ predicateFacet?.kind === 'predicate'
139
+ ? (lookup(e.predicateBefore ?? predicateFacet.from) ?? edgeLabelBefore(e))
140
+ : undefined;
141
+ return { label: lookup(e.predicate) ?? e.label, labelBefore: before };
142
+ };
143
+ }
144
+
145
+ /** Family of a hunk: the kind's default, refined for `modify` by its node's primary facet. */
146
+ function sceneHunkFamily(hunk: Hunk, byId: ReadonlyMap<UnionId, UnionNode>): ChangeFamily {
147
+ if (hunk.kind !== 'modify') return hunkFamily(hunk.kind);
148
+ const anchor = byId.get(hunk.anchor);
149
+ if (anchor === undefined) return 'content';
150
+ const f = nodeFamily(anchor, nodeRole(anchor));
151
+ return f === 'neutral' ? 'content' : f;
152
+ }
153
+
154
+ /**
155
+ * Build the visual model. Pure and deterministic.
156
+ *
157
+ * - Nodes absent from `layout.positions` (scope `changes`) are left out; edges need both
158
+ * endpoints. Before-version endpoints outside the layout fall back to the after ones.
159
+ * - Node / edge order follows the diff (sorted by union id); indices are dense.
160
+ * - Hunks are reordered as a spatial tour (docs/algorithms.md §10); titles come from core
161
+ * `formatHunk` in `options.locale`.
162
+ * - `degree` is the max of the before / after degree (as used for the radius).
163
+ *
164
+ * O(N log N + E + H √H). See `SceneOptions` for defaults.
165
+ *
166
+ * @throws KgdiffError(`invalid-input`) for invalid replay options or theme names.
167
+ */
168
+ export function buildScene(
169
+ diff: DiffResult,
170
+ layout: LayoutResult,
171
+ options: SceneOptions = {},
172
+ ): Scene {
173
+ const theme = resolveTheme(options);
174
+ const locale = options.locale ?? 'en';
175
+ const { pace, chapterThreshold } = resolveReplay(options);
176
+
177
+ // ── Nodes in scope ──
178
+ const unionNodes: UnionNode[] = [];
179
+ const nodeIndex = new Map<UnionId, number>();
180
+ const byId = new Map<UnionId, UnionNode>();
181
+ for (const n of diff.nodes) {
182
+ byId.set(n.id, n);
183
+ const p = layout.positions.get(n.id);
184
+ if (p === undefined || !Number.isFinite(p.x) || !Number.isFinite(p.y)) continue;
185
+ nodeIndex.set(n.id, unionNodes.length);
186
+ unionNodes.push(n);
187
+ }
188
+ const byAfter = new Map<Id, number>();
189
+ const byBefore = new Map<Id, number>();
190
+ for (let i = 0; i < unionNodes.length; i++) {
191
+ const n = unionNodes[i] as UnionNode;
192
+ if (n.afterId !== undefined) byAfter.set(n.afterId, i);
193
+ if (n.beforeId !== undefined) byBefore.set(n.beforeId, i);
194
+ }
195
+
196
+ // ── Edges in scope ──
197
+ const unionEdges: UnionEdge[] = [];
198
+ const ends: { s: number; t: number; sb: number; tb: number }[] = [];
199
+ for (const e of diff.edges) {
200
+ const s = nodeIndex.get(e.source);
201
+ const t = nodeIndex.get(e.target);
202
+ if (s === undefined || t === undefined) continue;
203
+ const sb = e.sourceBefore === undefined ? s : (nodeIndex.get(e.sourceBefore) ?? s);
204
+ const tb = e.targetBefore === undefined ? t : (nodeIndex.get(e.targetBefore) ?? t);
205
+ unionEdges.push(e);
206
+ ends.push({ s, t, sb, tb });
207
+ }
208
+
209
+ // ── Positions ──
210
+ const xs = new Float64Array(unionNodes.length);
211
+ const ys = new Float64Array(unionNodes.length);
212
+ const rs = new Float64Array(unionNodes.length);
213
+ for (let i = 0; i < unionNodes.length; i++) {
214
+ const n = unionNodes[i] as UnionNode;
215
+ const p = layout.positions.get(n.id) as { x: number; y: number };
216
+ xs[i] = p.x;
217
+ ys[i] = p.y;
218
+ const r = layout.radii.get(n.id);
219
+ rs[i] =
220
+ r !== undefined && Number.isFinite(r) && r > 0
221
+ ? r
222
+ : nodeRadius(Math.max(n.degree.before, n.degree.after));
223
+ }
224
+
225
+ // ── Hunk tour order (respecting `requires`), then play order ──
226
+ const diffHunkIndex = new Map<string, number>();
227
+ for (let k = 0; k < diff.hunks.length; k++) diffHunkIndex.set((diff.hunks[k] as Hunk).id, k);
228
+ const requiresOf = (h: Hunk): number[] => {
229
+ const out: number[] = [];
230
+ for (const id of h.requires ?? []) {
231
+ const k = diffHunkIndex.get(id);
232
+ if (k !== undefined) out.push(k);
233
+ }
234
+ return out;
235
+ };
236
+ const tourItems: TourHunk[] = diff.hunks.map((h) => {
237
+ let x = 0;
238
+ let y = 0;
239
+ const a = nodeIndex.get(h.anchor);
240
+ if (a !== undefined) {
241
+ x = xs[a] as number;
242
+ y = ys[a] as number;
243
+ } else {
244
+ let count = 0;
245
+ for (const id of [...h.nodes, ...h.context]) {
246
+ const i = nodeIndex.get(id);
247
+ if (i === undefined) continue;
248
+ x += xs[i] as number;
249
+ y += ys[i] as number;
250
+ count++;
251
+ }
252
+ if (count > 0) {
253
+ x /= count;
254
+ y /= count;
255
+ }
256
+ }
257
+ return {
258
+ phase: h.phase,
259
+ size: h.nodes.length + h.edges.length,
260
+ order: h.order,
261
+ x,
262
+ y,
263
+ requires: requiresOf(h),
264
+ };
265
+ });
266
+ let tour = tourOrder(tourItems);
267
+ if (timelineMode(tour.length, chapterThreshold) === 'chapters') {
268
+ // A hunk that requires one of a later chapter plays in that chapter: reorder so the scene's
269
+ // hunk order is the play order (still requirement-respecting).
270
+ const pos = new Int32Array(tour.length);
271
+ for (let k = 0; k < tour.length; k++) pos[tour[k] as number] = k;
272
+ const plan = chapterPlan(
273
+ tour.map((h) => ({
274
+ phase: (tourItems[h] as TourHunk).phase,
275
+ requires: (tourItems[h] as TourHunk).requires.map((r) => pos[r] as number),
276
+ })),
277
+ );
278
+ tour = tour
279
+ .map((h, k) => ({ h, k, c: plan[k] as number }))
280
+ .sort((a, b) => a.c - b.c || a.k - b.k)
281
+ .map((x) => x.h);
282
+ }
283
+ const hunkIndex = new Map<string, number>();
284
+ for (let k = 0; k < tour.length; k++)
285
+ hunkIndex.set((diff.hunks[tour[k] as number] as Hunk).id, k);
286
+ const hunkOf = (id: string | undefined): number =>
287
+ id === undefined ? -1 : (hunkIndex.get(id) ?? -1);
288
+
289
+ // ── Scene nodes ──
290
+ const importance = importances(unionNodes);
291
+ const nodes: SceneNode[] = unionNodes.map((n, i) => {
292
+ const role = nodeRole(n);
293
+ const labelBefore = nodeLabelBefore(n);
294
+ const badge = nodeBadge(n, role);
295
+ let flow: number | undefined;
296
+ if (role === 'merge-source') {
297
+ const target = facetTarget(n, 'mergedInto');
298
+ flow = target === undefined ? undefined : nodeIndex.get(target);
299
+ } else if (role === 'split-target') {
300
+ const source = facetTarget(n, 'splitFrom');
301
+ flow = source === undefined ? undefined : nodeIndex.get(source);
302
+ } else if (role === 'deprecated') {
303
+ for (const id of n.after?.replacedBy ?? []) {
304
+ flow = byAfter.get(id) ?? nodeIndex.get(id);
305
+ if (flow !== undefined) break;
306
+ }
307
+ }
308
+ if (flow === i) flow = undefined;
309
+ const family = nodeFamily(n, role);
310
+ const node: SceneNode = {
311
+ index: i,
312
+ id: n.id,
313
+ x: xs[i] as number,
314
+ y: ys[i] as number,
315
+ r: rs[i] as number,
316
+ status: n.status,
317
+ role,
318
+ family,
319
+ familyBefore: nodeFamilyBefore(n, role, family),
320
+ label: n.label,
321
+ ...(labelBefore !== undefined && labelBefore !== n.label ? { labelBefore } : {}),
322
+ ...(badge !== undefined ? { badge } : {}),
323
+ ...(flow !== undefined ? { flow } : {}),
324
+ hunk: hunkOf(n.hunk),
325
+ presentBefore: n.before !== undefined || n.beforeId !== undefined,
326
+ presentAfter: n.after !== undefined || n.afterId !== undefined,
327
+ implicit: (n.after ?? n.before)?.implicit ?? false,
328
+ degree: Math.max(n.degree.before, n.degree.after),
329
+ hidden: layout.hidden.get(n.id) ?? 0,
330
+ importance: importance[i] as number,
331
+ };
332
+ return node;
333
+ });
334
+
335
+ // ── Scene edges ──
336
+ const curv = edgeCurvatures(ends);
337
+ const displayLabels = edgeDisplayLabels(diff);
338
+ const edges: SceneEdge[] = unionEdges.map((e, j) => {
339
+ const role = edgeRole(e);
340
+ const end = ends[j] as { s: number; t: number; sb: number; tb: number };
341
+ const { label, labelBefore } = displayLabels(e);
342
+ return {
343
+ index: j,
344
+ id: e.id,
345
+ s: end.s,
346
+ t: end.t,
347
+ sb: end.sb,
348
+ tb: end.tb,
349
+ status: e.status,
350
+ role,
351
+ family: edgeFamily(e, role),
352
+ label,
353
+ ...(labelBefore !== undefined && labelBefore !== label ? { labelBefore } : {}),
354
+ curvature: curv.primary[j] as number,
355
+ hunk: hunkOf(e.hunk),
356
+ presentBefore: e.before !== undefined || e.beforeKey !== undefined,
357
+ presentAfter: e.after !== undefined || e.afterKey !== undefined,
358
+ hierarchical: e.hierarchical,
359
+ };
360
+ });
361
+
362
+ // ── Bounds ──
363
+ const all = new BoundsBuilder();
364
+ for (const n of nodes) addNodeBox(all, n.x, n.y, n.r, n.label, n.labelBefore);
365
+ const bounds = all.rect();
366
+ const center = rectCenter(bounds);
367
+
368
+ // ── Hunks ──
369
+ const indicesOf = (ids: readonly UnionId[], index: ReadonlyMap<UnionId, number>): number[] => {
370
+ const out: number[] = [];
371
+ for (const id of ids) {
372
+ const i = index.get(id);
373
+ if (i !== undefined) out.push(i);
374
+ }
375
+ return out;
376
+ };
377
+ const edgeIndex = new Map<UnionId, number>();
378
+ for (const e of edges) edgeIndex.set(e.id, e.index);
379
+ const hunks: SceneHunk[] = tour.map((h, k) => {
380
+ const hunk = diff.hunks[h] as Hunk;
381
+ const hn = indicesOf(hunk.nodes, nodeIndex);
382
+ const hc = indicesOf(hunk.context, nodeIndex);
383
+ const b = new BoundsBuilder();
384
+ for (const i of [...hn, ...hc]) {
385
+ const n = nodes[i] as SceneNode;
386
+ addNodeBox(b, n.x, n.y, n.r, n.label, n.labelBefore);
387
+ }
388
+ const text = formatHunk(hunk, diff, locale);
389
+ return {
390
+ index: k,
391
+ id: hunk.id,
392
+ kind: hunk.kind,
393
+ phase: hunk.phase,
394
+ family: sceneHunkFamily(hunk, byId),
395
+ title: text.title,
396
+ subtitle: text.subtitle,
397
+ anchor: nodeIndex.get(hunk.anchor) ?? -1,
398
+ nodes: hn,
399
+ edges: indicesOf(hunk.edges, edgeIndex),
400
+ context: hc,
401
+ bounds: b.rect({ x: center.x, y: center.y, width: 0, height: 0 }),
402
+ };
403
+ });
404
+
405
+ // ── Suggestions ──
406
+ const suggestions: SceneSuggestion[] = [];
407
+ for (const s of diff.suggestions) {
408
+ const before = byBefore.get(s.before);
409
+ const after = byAfter.get(s.after);
410
+ if (before !== undefined && after !== undefined && before !== after) {
411
+ suggestions.push({ before, after, score: s.score });
412
+ }
413
+ }
414
+
415
+ const playPos = new Int32Array(tour.length);
416
+ for (let k = 0; k < tour.length; k++) playPos[tour[k] as number] = k;
417
+ const timeline = buildTimeline(
418
+ hunks.map((h, k) => {
419
+ const item = tourItems[tour[k] as number] as TourHunk;
420
+ return {
421
+ kind: h.kind,
422
+ phase: h.phase,
423
+ size: item.size,
424
+ bounds: h.bounds,
425
+ requires: item.requires.map((r) => playPos[r] as number),
426
+ };
427
+ }),
428
+ pace,
429
+ chapterThreshold,
430
+ );
431
+
432
+ const hunkIdx = new Map<string, number>();
433
+ for (const h of hunks) hunkIdx.set(h.id, h.index);
434
+
435
+ return {
436
+ nodes,
437
+ edges,
438
+ hunks,
439
+ suggestions,
440
+ bounds,
441
+ stats: diff.stats,
442
+ timeline,
443
+ theme,
444
+ locale,
445
+ meta: { before: diff.before.meta, after: diff.after.meta },
446
+ nodeIndex,
447
+ edgeIndex,
448
+ hunkIndex: hunkIdx,
449
+ };
450
+ }