@kgdiff/scene 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 (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/types.ts ADDED
@@ -0,0 +1,544 @@
1
+ /**
2
+ * @kgdiff/scene: the renderer-agnostic visual model of a graph diff.
3
+ *
4
+ * scene = diff semantics → visual primitives. Renderers (canvas, G6, …) never
5
+ * interpret diff semantics themselves: they draw the `FrameVisuals` produced by
6
+ * a `VisualResolver` for a given `ViewState`. This keeps every renderer
7
+ * pixel-consistent and puts the visual language in exactly one place
8
+ * (docs/visual-language.md).
9
+ *
10
+ * Coordinates are world units (y down). Sizes marked "px" are screen pixels and
11
+ * must NOT be scaled by the camera zoom.
12
+ *
13
+ * Curve convention (edges and glyphs): a line from P1 to P2 with curvature `c` is the quadratic
14
+ * Bézier whose control point is the chord midpoint + `c · (−dy, dx)` with `(dx, dy) = P2 − P1`
15
+ * (so the apex lies `c / 2 · |P2 − P1|` from the chord). `c = 0` is straight; the sign is
16
+ * relative to the line's own direction.
17
+ */
18
+ import type {
19
+ ChangeStatus,
20
+ DiffStats,
21
+ GraphMeta,
22
+ HunkKind,
23
+ Locale,
24
+ Rect,
25
+ ReplayPhase,
26
+ UnionId,
27
+ } from '@kgdiff/core';
28
+
29
+ // ─── Design tokens ───────────────────────────────────────────────────────────
30
+
31
+ export type ThemeName = 'light' | 'dark';
32
+ export type PaletteName = 'default' | 'colorblind';
33
+
34
+ /**
35
+ * Meaning families: the core of the visual language. Color always means one of these.
36
+ * - added: exists only in the after version
37
+ * - removed: exists only in the before version
38
+ * - content: same thing, different content (rename, attributes, types, aliases, deprecation)
39
+ * - structure: same thing, different place (hierarchy move, retarget, predicate change)
40
+ * - identity: the notion of "which thing" changed (merge, split, rekey, suggestions)
41
+ * - neutral: unchanged context
42
+ */
43
+ export type Family = 'added' | 'removed' | 'content' | 'structure' | 'identity' | 'neutral';
44
+ export type ChangeFamily = Exclude<Family, 'neutral'>;
45
+
46
+ export interface FamilyColors {
47
+ /** Marks: fills, strokes, rings. ≥ 3:1 contrast against the canvas background. */
48
+ readonly base: string;
49
+ /** Emphasis: selected / hovered marks. */
50
+ readonly strong: string;
51
+ /** Translucent tint: badge backgrounds, halos, row highlights. */
52
+ readonly soft: string;
53
+ /**
54
+ * Text on panels and canvas. ≥ 4.5:1 against `surface.panel`, `surface.raised` and
55
+ * `canvas.background`, and over its own `soft` tint composited on each of them (badges, chips).
56
+ */
57
+ readonly text: string;
58
+ }
59
+
60
+ export interface Theme {
61
+ readonly name: ThemeName;
62
+ readonly palette: PaletteName;
63
+ readonly canvas: {
64
+ readonly background: string;
65
+ /** Dot-grid color (already includes its alpha). */
66
+ readonly grid: string;
67
+ };
68
+ readonly surface: {
69
+ readonly panel: string;
70
+ readonly raised: string;
71
+ /** Translucent overlay (floating toolbars) — used with backdrop blur. */
72
+ readonly overlay: string;
73
+ /** Hairlines (dividers, card edges). Decorative: no contrast requirement. */
74
+ readonly border: string;
75
+ readonly borderStrong: string;
76
+ /**
77
+ * Boundaries of interactive controls (inputs, the unchecked "reviewed" circle, toggles):
78
+ * ≥ 3:1 against panel, raised and canvas (WCAG 1.4.11).
79
+ */
80
+ readonly controlBorder: string;
81
+ /** CSS box-shadow for floating surfaces. */
82
+ readonly shadow: string;
83
+ };
84
+ /** All ≥ 4.5:1 against `surface.panel`, `surface.raised` and `canvas.background`. */
85
+ readonly text: {
86
+ readonly primary: string;
87
+ readonly secondary: string;
88
+ /** Informative small text (old names, captions). */
89
+ readonly muted: string;
90
+ };
91
+ /**
92
+ * Selection, focus rings and primary UI affordances. Never used to encode change.
93
+ * `base` ≥ 3:1 against panel, raised and canvas (usable as a 2 px focus indicator).
94
+ */
95
+ readonly accent: FamilyColors;
96
+ readonly neutral: {
97
+ readonly node: string;
98
+ readonly nodeStroke: string;
99
+ readonly edge: string;
100
+ readonly label: string;
101
+ };
102
+ readonly family: Readonly<Record<ChangeFamily, FamilyColors>>;
103
+ /** Dark themes render halos as soft glows; light themes as crisp offset rings. */
104
+ readonly glow: boolean;
105
+ readonly font: {
106
+ /** CSS font-family stack including CJK fallbacks. */
107
+ readonly family: string;
108
+ readonly mono: string;
109
+ /** Base label size (px). */
110
+ readonly size: number;
111
+ readonly weightRegular: number;
112
+ readonly weightStrong: number;
113
+ };
114
+ readonly motion: {
115
+ /** ms */
116
+ readonly fast: number;
117
+ readonly base: number;
118
+ readonly slow: number;
119
+ /** cubic-bezier control points. */
120
+ readonly easing: readonly [number, number, number, number];
121
+ };
122
+ }
123
+
124
+ // ─── Scene ──────────────────────────────────────────────────────────────────
125
+
126
+ export type NodeRole =
127
+ | 'unchanged'
128
+ | 'added'
129
+ | 'removed'
130
+ | 'modified'
131
+ | 'deprecated'
132
+ | 'merge-source'
133
+ | 'merge-target'
134
+ | 'split-source'
135
+ | 'split-target';
136
+
137
+ export type EdgeRole =
138
+ | 'unchanged'
139
+ | 'added'
140
+ | 'removed'
141
+ | 'modified'
142
+ /** Endpoint changed by an edit or a hierarchy move. */
143
+ | 'retarget'
144
+ /** Endpoint changed because a merge / split carried it. */
145
+ | 'carried'
146
+ /** Removed because a merge made it a duplicate. */
147
+ | 'collapsed';
148
+
149
+ export interface SceneNode {
150
+ readonly index: number;
151
+ readonly id: UnionId;
152
+ readonly x: number;
153
+ readonly y: number;
154
+ /** World units. */
155
+ readonly r: number;
156
+ readonly status: ChangeStatus;
157
+ readonly role: NodeRole;
158
+ readonly family: Family;
159
+ /**
160
+ * Family the node is drawn in in the `before` layer: merge / split targets that existed before
161
+ * show their own facets there (`neutral` when they have none); otherwise equal to `family`.
162
+ */
163
+ readonly familyBefore: Family;
164
+ /** Current (after) display label, or the before label for removed nodes. */
165
+ readonly label: string;
166
+ /** Set only when renamed. */
167
+ readonly labelBefore?: string;
168
+ /** Compact change summary for badges, e.g. `+2 −1 ~1`. */
169
+ readonly badge?: string;
170
+ /**
171
+ * merge-source / split-target: index of the node it flows into / out of during replay;
172
+ * deprecated: index of its replacement (`replacedBy`), when in the scene.
173
+ */
174
+ readonly flow?: number;
175
+ /** Index into `scene.hunks`, or -1. */
176
+ readonly hunk: number;
177
+ readonly presentBefore: boolean;
178
+ readonly presentAfter: boolean;
179
+ readonly implicit: boolean;
180
+ readonly degree: number;
181
+ /** Neighbours left out of the layout (scope `changes`). */
182
+ readonly hidden: number;
183
+ /**
184
+ * Label priority in [0, 1] from the degree percentile: changed nodes 0.8–1, unchanged 0–0.75
185
+ * (so every changed node outranks every unchanged one).
186
+ */
187
+ readonly importance: number;
188
+ }
189
+
190
+ export interface SceneEdge {
191
+ readonly index: number;
192
+ readonly id: UnionId;
193
+ /** Node indices of the after-version endpoints (before-version for removed edges). */
194
+ readonly s: number;
195
+ readonly t: number;
196
+ /** Node indices of the before-version endpoints (equal to s / t unless retargeted / carried). */
197
+ readonly sb: number;
198
+ readonly tb: number;
199
+ readonly status: ChangeStatus;
200
+ readonly role: EdgeRole;
201
+ readonly family: Family;
202
+ readonly label: string;
203
+ /** Set only when the label or predicate changed. */
204
+ readonly labelBefore?: string;
205
+ /**
206
+ * Signed curvature for parallel edges between the same pair (0 = straight): ±0.18·k fanned
207
+ * around the pair; a lone edge is straight. See the curve convention above.
208
+ */
209
+ readonly curvature: number;
210
+ /** Index into `scene.hunks`, or -1. */
211
+ readonly hunk: number;
212
+ readonly presentBefore: boolean;
213
+ readonly presentAfter: boolean;
214
+ readonly hierarchical: boolean;
215
+ }
216
+
217
+ export interface SceneHunk {
218
+ readonly index: number;
219
+ readonly id: string;
220
+ readonly kind: HunkKind;
221
+ readonly phase: ReplayPhase;
222
+ readonly family: ChangeFamily;
223
+ /** Localized (core `formatHunk`). */
224
+ readonly title: string;
225
+ readonly subtitle: string;
226
+ /** Node index of the anchor, or -1 when it is outside the layout. */
227
+ readonly anchor: number;
228
+ readonly nodes: readonly number[];
229
+ readonly edges: readonly number[];
230
+ readonly context: readonly number[];
231
+ /** Bounds of nodes + context, padded for labels (camera target). */
232
+ readonly bounds: Rect;
233
+ }
234
+
235
+ /** A dotted "maybe the same?" link between an unmatched removed and added node. */
236
+ export interface SceneSuggestion {
237
+ readonly before: number;
238
+ readonly after: number;
239
+ readonly score: number;
240
+ }
241
+
242
+ export interface TimelineWindow {
243
+ readonly hunk: number;
244
+ /** ms at 1× speed. Windows of one chapter may overlap (chapter mode plays them together). */
245
+ readonly start: number;
246
+ readonly end: number;
247
+ readonly chapter: number;
248
+ }
249
+
250
+ export interface TimelineChapter {
251
+ readonly phase: ReplayPhase;
252
+ readonly start: number;
253
+ readonly end: number;
254
+ readonly hunks: readonly number[];
255
+ readonly bounds: Rect;
256
+ }
257
+
258
+ export interface Timeline {
259
+ /** ms at 1× speed. */
260
+ readonly duration: number;
261
+ /** One per hunk, in play order. */
262
+ readonly windows: readonly TimelineWindow[];
263
+ readonly chapters: readonly TimelineChapter[];
264
+ /** `hunks`: one hunk at a time. `chapters`: many small hunks play together per phase. */
265
+ readonly mode: 'hunks' | 'chapters';
266
+ }
267
+
268
+ export interface Scene {
269
+ readonly nodes: readonly SceneNode[];
270
+ readonly edges: readonly SceneEdge[];
271
+ /** In review / replay order (spatially refined). */
272
+ readonly hunks: readonly SceneHunk[];
273
+ readonly suggestions: readonly SceneSuggestion[];
274
+ /** Bounds of everything, padded for labels. */
275
+ readonly bounds: Rect;
276
+ readonly stats: DiffStats;
277
+ readonly timeline: Timeline;
278
+ readonly theme: Theme;
279
+ readonly locale: Locale;
280
+ readonly meta: { readonly before: GraphMeta; readonly after: GraphMeta };
281
+ /** Node index by union id. */
282
+ readonly nodeIndex: ReadonlyMap<UnionId, number>;
283
+ readonly edgeIndex: ReadonlyMap<UnionId, number>;
284
+ readonly hunkIndex: ReadonlyMap<string, number>;
285
+ }
286
+
287
+ export interface SceneOptions {
288
+ /** Default `dark`. */
289
+ readonly theme?: Theme | ThemeName;
290
+ /** Default `default`. Ignored when `theme` is a Theme object. */
291
+ readonly palette?: PaletteName;
292
+ /** Default `en`. */
293
+ readonly locale?: Locale;
294
+ readonly replay?: {
295
+ /** Average ms per hunk at 1×. Default 1100. */
296
+ readonly pace?: number;
297
+ /** Above this many hunks the timeline switches to chapter mode. Default 40. */
298
+ readonly chapterThreshold?: number;
299
+ };
300
+ }
301
+
302
+ // ─── View state ─────────────────────────────────────────────────────────────
303
+
304
+ /**
305
+ * Which version is shown:
306
+ * - `union`: overlay of both versions (default).
307
+ * - `before` / `after`: one version only, with changed elements still colored.
308
+ * - `replay`: the before version morphing into the after version, driven by `t`.
309
+ */
310
+ export type Layer = 'union' | 'before' | 'after' | 'replay';
311
+
312
+ export interface FocusState {
313
+ /** 1 = visible, by node index. */
314
+ readonly nodes: Uint8Array;
315
+ /** 1 = visible, by edge index. */
316
+ readonly edges: Uint8Array;
317
+ /** 1 = shown as context (rendered quieter), by node index. */
318
+ readonly context: Uint8Array;
319
+ readonly collapsed: readonly CollapsedGroup[];
320
+ }
321
+
322
+ /** Hidden unchanged nodes summarized as one pill: “+38 unchanged”. */
323
+ export interface CollapsedGroup {
324
+ /** Stable across rebuilds: `near:<union id>` (behind a visible node) or `component:<union id>`. */
325
+ readonly id: string;
326
+ /** World position of the pill: centroid of its in-scene members, else just outside its node. */
327
+ readonly x: number;
328
+ readonly y: number;
329
+ /** Hidden nodes, including neighbours left out of the layout (`SceneNode.hidden`). */
330
+ readonly count: number;
331
+ /** In-scene member node indices (may be empty when every member is outside the layout). */
332
+ readonly nodes: readonly number[];
333
+ /**
334
+ * Localized pill text in `scene.locale`, e.g. “+38 unchanged” / “+38 个未变节点”
335
+ * (“+3 hidden” / “+3 个隐藏节点” when a hunk-restricted focus also hides changed nodes).
336
+ */
337
+ readonly label: string;
338
+ }
339
+
340
+ export interface ViewState {
341
+ readonly layer: Layer;
342
+ /** Replay time in ms within [0, timeline.duration]. Used when layer is `replay`. */
343
+ readonly t: number;
344
+ /** Hunk index or -1. Emphasizes the hunk and dims the rest. */
345
+ readonly selectedHunk: number;
346
+ readonly hoveredNode: number;
347
+ readonly hoveredEdge: number;
348
+ readonly selectedNode: number;
349
+ /** Changes-only view; null shows everything. */
350
+ readonly focus: FocusState | null;
351
+ /** Hunk families to emphasize (filter chips). Empty = all. */
352
+ readonly families: ReadonlySet<ChangeFamily>;
353
+ readonly showSuggestions: boolean;
354
+ /** Respect prefers-reduced-motion (crossfades instead of motion). */
355
+ readonly reducedMotion: boolean;
356
+ /**
357
+ * Strength of the selection spotlight in [0, 1] (default 1). Hosts animate it 0 → 1 over
358
+ * `theme.motion.base` when `selectedHunk` changes; the resolver interpolates the dim levels
359
+ * (context 100% → 70%, rest 100% → 18%, rest labels fading out), the selection halos, the
360
+ * selected-edge width and `FrameVisuals.spotlight` by it. The replay spotlight is driven by `t`
361
+ * and ignores it.
362
+ */
363
+ readonly emphasis: number;
364
+ }
365
+
366
+ // ─── Visual primitives (what renderers draw) ────────────────────────────────
367
+
368
+ export interface LabelVisual {
369
+ text: string;
370
+ color: string;
371
+ /** Relative to the owning node / edge / glyph alpha (renderers multiply). */
372
+ alpha: number;
373
+ /** px */
374
+ size: number;
375
+ weight: number;
376
+ strike: boolean;
377
+ /** Higher wins label collisions. */
378
+ priority: number;
379
+ /** Hidden below this camera zoom (LOD). 0 = always. */
380
+ minZoom: number;
381
+ /**
382
+ * Node labels: px below the node's edge, i.e. the label's top sits at `r·k + offset` below the
383
+ * center (primary 5, the struck old name 19; animated during replay). Edge and glyph labels:
384
+ * px from the curve midpoint (0 = centered on it).
385
+ */
386
+ offset: number;
387
+ }
388
+
389
+ /** Drawn with the owning node's alpha. */
390
+ export interface BadgeVisual {
391
+ text: string;
392
+ color: string;
393
+ background: string;
394
+ /** Hidden below this camera zoom (LOD). 0 = always. */
395
+ minZoom: number;
396
+ }
397
+
398
+ export interface NodeVisual {
399
+ visible: boolean;
400
+ x: number;
401
+ y: number;
402
+ /** World units. */
403
+ r: number;
404
+ /** Alpha of the mark; nested ring / halo / label / badge alphas are relative to it. */
405
+ alpha: number;
406
+ /**
407
+ * The family the node is currently drawn in: its own, `neutral` when drawn as context (filtered
408
+ * out, unchanged), `content` for deprecated nodes. For minimaps and legends.
409
+ */
410
+ family: Family;
411
+ /** null = hollow ghost (background-colored fill so edges do not show through). */
412
+ fill: string | null;
413
+ stroke: string | null;
414
+ /** px */
415
+ strokeWidth: number;
416
+ dash: readonly number[] | null;
417
+ /** Concentric ring at `r·k + gap` px, `width` px. `alpha` relative to the node's. */
418
+ ring: { color: string; width: number; gap: number; alpha: number } | null;
419
+ /**
420
+ * Glow (dark themes, `theme.glow`): radial glow of `radius` world units (2.6·r).
421
+ * Offset ring (light themes): `radius` = r, drawn 5 px outside it, 2 px wide.
422
+ * `alpha` relative to the node's.
423
+ */
424
+ halo: { color: string; radius: number; alpha: number } | null;
425
+ label: LabelVisual | null;
426
+ /** Old name under the primary label (renames), drawn struck through. */
427
+ secondaryLabel: LabelVisual | null;
428
+ badge: BadgeVisual | null;
429
+ /**
430
+ * Replay emphasis pulse: 0 = none; otherwise the progress in (0, 1) of one expanding ring that
431
+ * fades out (draw it at growing radius with alpha `1 − pulse`).
432
+ */
433
+ pulse: number;
434
+ /** Draw order inside its pass; higher = later. */
435
+ z: number;
436
+ }
437
+
438
+ export interface EdgeVisual {
439
+ visible: boolean;
440
+ x1: number;
441
+ y1: number;
442
+ x2: number;
443
+ y2: number;
444
+ /** Radii of the endpoints so the line stops at the node boundary (world units). */
445
+ r1: number;
446
+ r2: number;
447
+ /** See the curve convention above. */
448
+ curvature: number;
449
+ color: string;
450
+ /** px */
451
+ width: number;
452
+ /** Alpha of the line; the label's alpha is relative to it. */
453
+ alpha: number;
454
+ dash: readonly number[] | null;
455
+ /** Dash offset for flow animation (px). */
456
+ dashOffset: number;
457
+ /** The edge has an arrowhead at its target (every edge is directed). */
458
+ arrow: boolean;
459
+ /**
460
+ * Arrowhead LOD: drawn from this camera zoom on. 0 for changed / selected / hovered edges
461
+ * (always), 1.2 for neutral context.
462
+ */
463
+ arrowMinZoom: number;
464
+ /** Fraction of the edge drawn from the source end, in [0, 1] (grow / retract animations). */
465
+ progress: number;
466
+ /** When true `progress` grows from the target end instead. */
467
+ fromTarget: boolean;
468
+ label: LabelVisual | null;
469
+ z: number;
470
+ }
471
+
472
+ export type GlyphKind =
473
+ | 'merge-arrow'
474
+ | 'split-arrow'
475
+ | 'replacement-arrow'
476
+ | 'edge-ghost'
477
+ | 'move-arrow'
478
+ | 'suggestion'
479
+ | 'pivot';
480
+
481
+ /**
482
+ * Annotations drawn above edges and below labels.
483
+ * - `merge-arrow`: arc with arrowhead from a merge source to the node that absorbs it.
484
+ * - `split-arrow`: arc with arrowhead from a split source to one of its targets.
485
+ * - `replacement-arrow`: arc with arrowhead from a deprecated node to its replacement.
486
+ * - `edge-ghost`: the before form of a retargeted / carried edge (where the edge was), drawn
487
+ * like an edge with an arrowhead: dashed ghost in overlays, solid while crossfading in replay.
488
+ * - `move-arrow`: reserved; not emitted by the scene.
489
+ * - `suggestion`: dotted link between a removed and an added node that may be the same, with a
490
+ * `?` label.
491
+ * - `pivot`: a dot of diameter `width` px at (x1, y1) — the endpoint a retargeted edge keeps.
492
+ */
493
+ export interface GlyphVisual {
494
+ kind: GlyphKind;
495
+ /** Endpoint centers (world units); for `pivot` both are the dot position. */
496
+ x1: number;
497
+ y1: number;
498
+ x2: number;
499
+ y2: number;
500
+ /**
501
+ * Radii (world units) at which the line starts / stops, like `EdgeVisual.r1` / `r2`: the node
502
+ * radius plus clearance for its ring. 0 when the endpoint is not a node (`pivot`).
503
+ */
504
+ r1: number;
505
+ r2: number;
506
+ color: string;
507
+ /** Absolute; the label's alpha is relative to it. */
508
+ alpha: number;
509
+ /** px */
510
+ width: number;
511
+ dash: readonly number[] | null;
512
+ /** Fraction drawn, [0, 1]. */
513
+ progress: number;
514
+ /** Bend of the arc (signed, 0 = straight); see the curve convention above. */
515
+ curvature: number;
516
+ label: LabelVisual | null;
517
+ }
518
+
519
+ export interface FrameVisuals {
520
+ /** Same length and order as `scene.nodes`. */
521
+ nodes: NodeVisual[];
522
+ /** Same length and order as `scene.edges`. */
523
+ edges: EdgeVisual[];
524
+ glyphs: GlyphVisual[];
525
+ collapsed: readonly CollapsedGroup[];
526
+ /**
527
+ * Spotlight strength in [0, 1], for renderer-wide effects (e.g. dimming the grid): 0 = no
528
+ * spotlight; 1 = non-emphasized elements fully dimmed (a selected hunk at `view.emphasis` 1,
529
+ * or a replay window between its spotlight and settle phases; both ramp in between).
530
+ */
531
+ spotlight: number;
532
+ }
533
+
534
+ export interface VisualResolver {
535
+ readonly scene: Scene;
536
+ /**
537
+ * Resolve every element for `view`. When `out` is given its arrays and element objects are
538
+ * reused: no object or array allocations in steady state (replay at 60 fps). Transient V8
539
+ * number boxing while replay animations run (a few KB per frame at most) is tolerated. Since
540
+ * `out` is mutated in place, hand it to the renderer again (`renderer.setFrame(frame)`) after
541
+ * each call; every call is a change.
542
+ */
543
+ resolve(view: ViewState, out?: FrameVisuals): FrameVisuals;
544
+ }
package/src/view.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * View state construction.
3
+ */
4
+ import type { ChangeFamily, ViewState } from './types.js';
5
+
6
+ const NO_FAMILIES: ReadonlySet<ChangeFamily> = new Set<ChangeFamily>();
7
+
8
+ /**
9
+ * Default view: union layer, t = 0, nothing selected or hovered, no focus, every family,
10
+ * suggestions shown, motion enabled, full spotlight emphasis. `partial` overrides fields; `undefined` values keep the
11
+ * default. The result is frozen.
12
+ */
13
+ export function createViewState(partial: Partial<ViewState> = {}): ViewState {
14
+ return Object.freeze({
15
+ layer: partial.layer ?? 'union',
16
+ t: partial.t ?? 0,
17
+ selectedHunk: partial.selectedHunk ?? -1,
18
+ hoveredNode: partial.hoveredNode ?? -1,
19
+ hoveredEdge: partial.hoveredEdge ?? -1,
20
+ selectedNode: partial.selectedNode ?? -1,
21
+ focus: partial.focus ?? null,
22
+ families: partial.families ?? NO_FAMILIES,
23
+ showSuggestions: partial.showSuggestions ?? true,
24
+ reducedMotion: partial.reducedMotion ?? false,
25
+ emphasis: partial.emphasis ?? 1,
26
+ });
27
+ }