@kgdiff/viewer 0.4.0 → 0.5.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 (84) hide show
  1. package/dist/context.d.ts +42 -0
  2. package/dist/context.d.ts.map +1 -0
  3. package/dist/context.js +87 -0
  4. package/dist/context.js.map +1 -0
  5. package/dist/controller.d.ts +26 -185
  6. package/dist/controller.d.ts.map +1 -1
  7. package/dist/controller.js +45 -1155
  8. package/dist/controller.js.map +1 -1
  9. package/dist/element.d.ts +6 -3
  10. package/dist/element.d.ts.map +1 -1
  11. package/dist/element.js +49 -229
  12. package/dist/element.js.map +1 -1
  13. package/dist/index.d.ts +3 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/pipeline.d.ts.map +1 -1
  17. package/dist/pipeline.js +3 -2
  18. package/dist/pipeline.js.map +1 -1
  19. package/dist/root.d.ts +54 -0
  20. package/dist/root.d.ts.map +1 -0
  21. package/dist/root.js +265 -0
  22. package/dist/root.js.map +1 -0
  23. package/dist/schedule.d.ts +22 -0
  24. package/dist/schedule.d.ts.map +1 -0
  25. package/dist/schedule.js +41 -0
  26. package/dist/schedule.js.map +1 -0
  27. package/dist/session.d.ts +253 -0
  28. package/dist/session.d.ts.map +1 -0
  29. package/dist/session.js +1239 -0
  30. package/dist/session.js.map +1 -0
  31. package/dist/standalone/kgdiff-viewer-compute.js +1 -1
  32. package/dist/standalone/kgdiff-viewer.js +4 -4
  33. package/dist/styles.d.ts +13 -6
  34. package/dist/styles.d.ts.map +1 -1
  35. package/dist/styles.js +217 -248
  36. package/dist/styles.js.map +1 -1
  37. package/dist/theme.d.ts +25 -0
  38. package/dist/theme.d.ts.map +1 -0
  39. package/dist/theme.js +149 -0
  40. package/dist/theme.js.map +1 -0
  41. package/dist/ui/change-list.d.ts +18 -0
  42. package/dist/ui/change-list.d.ts.map +1 -0
  43. package/dist/ui/change-list.js +331 -0
  44. package/dist/ui/change-list.js.map +1 -0
  45. package/dist/ui/context.d.ts +19 -5
  46. package/dist/ui/context.d.ts.map +1 -1
  47. package/dist/ui/context.js.map +1 -1
  48. package/dist/ui/filters.d.ts +9 -0
  49. package/dist/ui/filters.d.ts.map +1 -0
  50. package/dist/ui/filters.js +68 -0
  51. package/dist/ui/filters.js.map +1 -0
  52. package/dist/ui/header.js +2 -2
  53. package/dist/ui/header.js.map +1 -1
  54. package/dist/ui/search.d.ts +13 -0
  55. package/dist/ui/search.d.ts.map +1 -0
  56. package/dist/ui/search.js +191 -0
  57. package/dist/ui/search.js.map +1 -0
  58. package/dist/ui/sidebar.d.ts +8 -16
  59. package/dist/ui/sidebar.d.ts.map +1 -1
  60. package/dist/ui/sidebar.js +15 -539
  61. package/dist/ui/sidebar.js.map +1 -1
  62. package/dist/ui/stage.js +1 -1
  63. package/dist/ui/stage.js.map +1 -1
  64. package/dist/ui/swatch.js +1 -1
  65. package/dist/ui/swatch.js.map +1 -1
  66. package/package.json +6 -6
  67. package/src/context.ts +106 -0
  68. package/src/controller.ts +70 -1257
  69. package/src/element.ts +52 -243
  70. package/src/index.ts +3 -1
  71. package/src/pipeline.ts +4 -1
  72. package/src/root.ts +308 -0
  73. package/src/schedule.ts +43 -0
  74. package/src/session.ts +1386 -0
  75. package/src/styles.ts +225 -253
  76. package/src/theme.ts +167 -0
  77. package/src/ui/change-list.ts +401 -0
  78. package/src/ui/context.ts +21 -5
  79. package/src/ui/filters.ts +79 -0
  80. package/src/ui/header.ts +2 -2
  81. package/src/ui/search.ts +220 -0
  82. package/src/ui/sidebar.ts +28 -668
  83. package/src/ui/stage.ts +1 -1
  84. package/src/ui/swatch.ts +1 -1
package/src/controller.ts CHANGED
@@ -1,19 +1,18 @@
1
1
  /**
2
- * Viewer state and orchestration, independent of the UI:
2
+ * The canvas side of the viewer: a `DiffSession` (state, commands, events; `session.ts`) plus
3
+ * the one view that draws it (RFC 0001 §1: renderer, camera, framing, minimap), behind the
4
+ * `SessionView` boundary. Phase 3 of the RFC moves this view out into a `StageView` of its own,
5
+ * so one session can drive several canvases.
3
6
  *
4
- * - load pipeline with progress steps (sources or a precomputed report);
5
- * - view (mode × scope), selection (with an animated spotlight `emphasis`), family filters,
6
- * review progress;
7
- * - the `changes` scope's focus with expandable collapsed groups (`extendLayout` + scene rebuild);
8
- * - replay clock driven by one requestAnimationFrame loop that sleeps when idle, with a camera
9
- * that follows the playing hunk / chapter unless the user moved it in the last 2 s;
10
- * - swipe position, hover, theme / palette / locale (rebuilding the scene when they change).
11
- *
12
- * The UI subscribes to topic notifications (batched per microtask) and reads public fields.
7
+ * - draws the session's view state into the renderer (and the minimap, throttled while
8
+ * replaying);
9
+ * - frames what the session asks for in the free part of the canvas, clear of the chrome over
10
+ * it (`framing.ts`); the replay camera follows the playing hunk / chapter unless the user
11
+ * moved it in the last 2 s;
12
+ * - fits a new diff once the canvas is laid out, and again on resize with `autoFit`;
13
+ * - turns canvas hovers and clicks into session commands.
13
14
  */
14
- import type { DiffResult, DiffStats, Locale, Rect, UnionId } from '@kgdiff/core';
15
- import { KgdiffError } from '@kgdiff/core';
16
- import type { LayoutResult } from '@kgdiff/layout';
15
+ import type { Rect } from '@kgdiff/core';
17
16
  import {
18
17
  type Camera,
19
18
  type CameraState,
@@ -23,24 +22,13 @@ import {
23
22
  type Renderer,
24
23
  } from '@kgdiff/renderer';
25
24
  import {
26
- buildScene,
27
- type ChangeFamily,
28
- computeFocus,
29
25
  createViewState,
30
- createVisualResolver,
31
26
  type FocusState,
32
27
  type FrameVisuals,
33
28
  frameBounds,
34
- getTheme,
35
29
  type Layer,
36
- type PaletteName,
37
30
  type Scene,
38
- type Theme,
39
- type ThemeName,
40
- type ViewState,
41
- type VisualResolver,
42
31
  } from '@kgdiff/scene';
43
- import { computeNow, computeStatus, loadCompute } from './compute-loader.js';
44
32
  import {
45
33
  type ChromeLayout,
46
34
  chromeInsets,
@@ -50,50 +38,18 @@ import {
50
38
  NO_INSETS,
51
39
  zoomFit,
52
40
  } from './framing.js';
53
- import { translate } from './i18n.js';
54
- import type {
55
- LoadOptions,
56
- ReportPayload,
57
- ReviewState,
58
- ThemePreference,
59
- ViewerMode,
60
- ViewerScope,
61
- } from './index.js';
62
- import { buildList, EMPTY_LIST, type ListModel, stepHunk } from './list-model.js';
63
- import { type LoadedDiff, type LoadStep, readPayload, runLoad } from './pipeline.js';
64
- import { LEGACY_CHANGES_MODE, toLocale, toMode, toPalette, toScope, toTheme } from './prefs.js';
65
41
  import { renderBackend } from './renderer-port.js';
66
- import { ReplayClock } from './replay.js';
67
- import { ReviewStore, reviewKey, type StorageLike } from './review-store.js';
68
- import { type SearchHit, SearchIndex } from './search.js';
69
-
70
- export type Status = 'idle' | 'loading' | 'ready' | 'error';
71
-
72
- export type Topic =
73
- | 'status'
74
- | 'data'
75
- | 'mode'
76
- | 'selection'
77
- | 'filters'
78
- | 'review'
79
- | 'replay'
80
- | 'hover'
81
- | 'theme'
82
- | 'locale'
83
- | 'focus'
84
- | 'swipe'
85
- | 'overview';
42
+ import {
43
+ DiffSession,
44
+ padRect,
45
+ type SessionDeps,
46
+ type SessionHost,
47
+ type SessionView,
48
+ } from './session.js';
86
49
 
87
- export interface ControllerHost {
88
- ready(stats: DiffStats): void;
89
- select(hunk: string | null, node: string | null): void;
90
- mode(mode: ViewerMode, scope: ViewerScope): void;
91
- review(hunk: string, reviewed: boolean, progress: ReviewState): void;
92
- error(error: Error): void;
93
- announce(text: string): void;
94
- /** Playback reached the end of the replay; `loop`: it starts again after the loop delay. */
95
- replayEnd(loop: boolean): void;
96
- }
50
+ export type { Preference, Status, Topic } from './session.js';
51
+ export type ControllerHost = SessionHost;
52
+ export type ControllerDeps = SessionDeps;
97
53
 
98
54
  export interface FitOptions {
99
55
  readonly animate?: boolean;
@@ -101,23 +57,10 @@ export interface FitOptions {
101
57
  readonly padding?: number;
102
58
  }
103
59
 
104
- export interface ControllerDeps {
105
- readonly now: () => number;
106
- readonly requestFrame: (cb: (time: number) => void) => number;
107
- readonly cancelFrame: (id: number) => void;
108
- /** Resolves once the browser had a chance to paint (between loading steps). */
109
- readonly yieldToPaint: () => Promise<void>;
110
- readonly storage: StorageLike | null;
111
- }
112
-
113
- export type Preference = 'mode' | 'scope' | 'theme' | 'palette' | 'locale';
114
-
115
60
  /** Camera follow is suppressed this long after the user moved the camera (ms). */
116
61
  export const USER_CAMERA_GRACE = 2000;
117
62
  /** The camera follows when less than this share of the target bounds is in view. */
118
63
  export const FOLLOW_COVERAGE = 0.7;
119
- /** Spotlight emphasis restarts here when switching directly between two hunks. */
120
- const SWITCH_EMPHASIS = 0.35;
121
64
  /** Flights to a change never zoom in further, so its neighbourhood stays readable. */
122
65
  export const FLY_MAX_ZOOM = 1.8;
123
66
  /** Fitting everything never zooms in further (the renderer's default). */
@@ -127,21 +70,12 @@ const MINIMAP_INTERVAL = 200;
127
70
  /** Everything counts as in view (the minimap hides) from this share of the content bounds on. */
128
71
  export const OVERVIEW_COVERAGE = 0.98;
129
72
 
130
- class Superseded extends Error {}
131
-
132
73
  interface Frames {
133
74
  main: FrameVisuals | null;
134
75
  before: FrameVisuals | null;
135
76
  after: FrameVisuals | null;
136
77
  }
137
78
 
138
- interface EmphasisAnimation {
139
- readonly from: number;
140
- readonly to: number;
141
- readonly start: number;
142
- readonly duration: number;
143
- }
144
-
145
79
  /** Share of `bounds` inside `view`, in [0, 1]. */
146
80
  export function coverage(bounds: Rect, view: Rect): number {
147
81
  const w = Math.min(bounds.x + bounds.width, view.x + view.width) - Math.max(bounds.x, view.x);
@@ -158,45 +92,7 @@ export function coverage(bounds: Rect, view: Rect): number {
158
92
  return w > 0 && h > 0 ? (w * h) / area : 0;
159
93
  }
160
94
 
161
- const ease = (u: number): number => 1 - (1 - u) ** 3;
162
-
163
- export class ViewerController {
164
- status: Status = 'idle';
165
- /** Steps of the current load, in order; `step` is the running one. */
166
- steps: LoadStep[] = [];
167
- step: LoadStep | null = null;
168
- error: Error | null = null;
169
-
170
- data: LoadedDiff | null = null;
171
- layout: LayoutResult | null = null;
172
- scene: Scene | null = null;
173
- resolver: VisualResolver | null = null;
174
- list: ListModel = EMPTY_LIST;
175
-
176
- mode: ViewerMode = 'overlay';
177
- scope: ViewerScope = 'all';
178
- themePreference: ThemePreference = 'auto';
179
- palette: PaletteName = 'default';
180
- locale: Locale = 'en';
181
- systemDark = true;
182
- reducedMotion = false;
183
- /** Preferences set explicitly by the embedder (a report's `view` does not override them). */
184
- readonly explicit = new Set<Preference>();
185
-
186
- selectedHunk = -1;
187
- selectedNode = -1;
188
- /** An edge emphasized from the detail panel (drawn as hovered). */
189
- pinnedEdge = -1;
190
- hoverNode = -1;
191
- hoverEdge = -1;
192
- hoverGroup = -1;
193
- readonly families = new Set<ChangeFamily>();
194
- hops = 1;
195
- readonly expanded = new Set<UnionId>();
196
- focus: FocusState | null = null;
197
- clock: ReplayClock | null = null;
198
- split = 0.5;
199
- emphasis = 1;
95
+ export class ViewerController extends DiffSession implements SessionView {
200
96
  /**
201
97
  * Keep the view fitted (embeds without controls): fit again when the canvas is resized unless
202
98
  * the user moved the camera since, and at the start of every loop.
@@ -204,27 +100,17 @@ export class ViewerController {
204
100
  autoFit = false;
205
101
  /** Canvas clicks select (and double clicks fly); off for embeds without controls. */
206
102
  selectOnClick = true;
207
- #labelsInside = false;
208
103
  /** The whole content is in view (the minimap has nothing to add). */
209
104
  overview = true;
210
105
 
211
- #review: ReviewStore | null = null;
212
- #searchIndex: SearchIndex | null = null;
106
+ #labelsInside = false;
213
107
  #renderer: Renderer | null = null;
214
108
  #minimap: Minimap | null = null;
215
109
  readonly #unbind: (() => void)[] = [];
216
110
  readonly #frames: Frames = { main: null, before: null, after: null };
217
- #fadingHunk = -1;
218
- #emphasisAnim: EmphasisAnimation | null = null;
219
- #rafId = 0;
220
- #lastTick = 0;
221
- #frameDirty = false;
222
111
  #minimapDirty = true;
223
112
  #lastMinimap = Number.NEGATIVE_INFINITY;
224
113
  #lastUserCamera = Number.NEGATIVE_INFINITY;
225
- #followed = '';
226
- #loadToken = 0;
227
- #pendingHunk: { readonly id: string; readonly fly: boolean } | null = null;
228
114
  #boundsCache: {
229
115
  readonly scene: Scene;
230
116
  readonly focus: FocusState | null;
@@ -238,280 +124,12 @@ export class ViewerController {
238
124
  /** The user panned or zoomed since the last fit. */
239
125
  #userMoved = false;
240
126
  #fitPadding = FRAME_PADDING;
241
- /** A finished replay starts again this long (ms) after its end; null: it plays once. */
242
- #loopDelay: number | null = null;
243
- /** When a finished looping replay starts again (`deps.now()` time), else NaN. */
244
- #restartAt = Number.NaN;
245
- /** Playback is held (pointer over the graph); `#resume`: it was playing and continues after. */
246
- #held = false;
247
- #resume = false;
248
- readonly #listeners = new Set<(topics: ReadonlySet<Topic>) => void>();
249
- #pendingTopics = new Set<Topic>();
250
- #flushQueued = false;
251
- #destroyed = false;
252
-
253
- readonly #host: ControllerHost;
254
- readonly #deps: ControllerDeps;
255
-
256
- constructor(host: ControllerHost, deps: ControllerDeps) {
257
- this.#host = host;
258
- this.#deps = deps;
259
- }
260
-
261
- // ─── Subscriptions ────────────────────────────────────────────────────────
127
+ readonly #now: () => number;
262
128
 
263
- subscribe(listener: (topics: ReadonlySet<Topic>) => void): () => void {
264
- this.#listeners.add(listener);
265
- return () => this.#listeners.delete(listener);
266
- }
267
-
268
- #notify(...topics: Topic[]): void {
269
- for (const t of topics) this.#pendingTopics.add(t);
270
- if (this.#flushQueued) return;
271
- this.#flushQueued = true;
272
- queueMicrotask(() => this.flush());
273
- }
274
-
275
- /** Deliver pending notifications now (also used by tests). */
276
- flush(): void {
277
- this.#flushQueued = false;
278
- if (this.#pendingTopics.size === 0) return;
279
- const topics = this.#pendingTopics;
280
- this.#pendingTopics = new Set();
281
- for (const fn of this.#listeners) fn(topics);
282
- }
283
-
284
- // ─── Derived state ────────────────────────────────────────────────────────
285
-
286
- get themeName(): ThemeName {
287
- if (this.themePreference === 'auto') return this.systemDark ? 'dark' : 'light';
288
- return this.themePreference;
289
- }
290
-
291
- get theme(): Theme {
292
- try {
293
- return getTheme(this.themeName, this.palette);
294
- } catch {
295
- return getTheme('dark', 'default');
296
- }
297
- }
298
-
299
- get diff(): DiffResult | null {
300
- return this.data?.diff ?? null;
301
- }
302
-
303
- get hunkCount(): number {
304
- return this.scene?.hunks.length ?? 0;
305
- }
306
-
307
- isReviewed(hunkId: string): boolean {
308
- return this.#review?.has(hunkId) ?? false;
309
- }
310
-
311
- reviewState(): ReviewState {
312
- return this.#review?.state() ?? { reviewed: [], total: 0 };
313
- }
314
-
315
- search(query: string, limit?: number): SearchHit[] {
316
- const diff = this.diff;
317
- if (!diff) return [];
318
- this.#searchIndex ??= new SearchIndex(diff);
319
- return this.#searchIndex.search(query, limit);
320
- }
321
-
322
- // ─── Loading ──────────────────────────────────────────────────────────────
323
-
324
- async load(options: LoadOptions): Promise<void> {
325
- const text = (s: unknown): boolean =>
326
- typeof s === 'object' && s !== null && typeof (s as { text?: unknown }).text === 'string';
327
- const parse = text(options?.before) || text(options?.after);
328
- await this.#run(
329
- parse ? ['parse', 'match', 'layout', 'scene'] : ['match', 'layout', 'scene'],
330
- (onStep) => runLoad(options, onStep),
331
- );
332
- }
333
-
334
- async loadPayload(payload: ReportPayload): Promise<void> {
335
- await this.#run(['match', 'layout', 'scene'], (onStep) => readPayload(payload, onStep));
336
- }
337
-
338
- async #run(
339
- planned: LoadStep[],
340
- work: (onStep: (step: LoadStep) => Promise<void>) => Promise<LoadedDiff>,
341
- ): Promise<void> {
342
- const token = ++this.#loadToken;
343
- this.pause();
344
- this.status = 'loading';
345
- this.error = null;
346
- this.steps = planned;
347
- this.step = null;
348
- this.#notify('status');
349
- const onStep = async (step: LoadStep): Promise<void> => {
350
- if (token !== this.#loadToken || this.#destroyed) throw new Superseded();
351
- this.step = step;
352
- this.#notify('status');
353
- this.flush();
354
- await this.#deps.yieldToPaint();
355
- if (token !== this.#loadToken || this.#destroyed) throw new Superseded();
356
- };
357
- try {
358
- const loaded = await work(onStep);
359
- await onStep('scene');
360
- this.#install(loaded);
361
- } catch (error) {
362
- if (error instanceof Superseded) {
363
- throw new DOMException('Superseded by a newer load.', 'AbortError');
364
- }
365
- const err = error instanceof Error ? error : new KgdiffError('invalid-input', String(error));
366
- if (token === this.#loadToken) this.fail(err);
367
- throw err;
368
- }
369
- }
370
-
371
- /** Show an error state (bad payload, failed load). */
372
- fail(error: Error): void {
373
- this.#loadToken++;
374
- this.clock?.pause();
375
- this.#restartAt = Number.NaN;
376
- this.#resume = false;
377
- this.status = 'error';
378
- this.error = error;
379
- this.step = null;
380
- // Nothing stale stays on screen next to the error.
381
- this.data = null;
382
- this.layout = null;
383
- this.scene = null;
384
- this.resolver = null;
385
- this.list = EMPTY_LIST;
386
- this.clock = null;
387
- this.focus = null;
388
- this.#review = null;
389
- this.selectedHunk = this.selectedNode = this.pinnedEdge = -1;
390
- this.#notify('status', 'data', 'selection', 'filters', 'review', 'replay', 'focus');
391
- this.#host.error(error);
392
- }
393
-
394
- #install(loaded: LoadedDiff): void {
395
- const { diff, layout, view } = loaded;
396
- this.data = loaded;
397
- this.layout = layout;
398
- this.#searchIndex = null;
399
- this.families.clear();
400
- this.expanded.clear();
401
- this.hops = Math.max(0, Math.min(2, loaded.layoutOptions?.contextHops ?? 1));
402
- this.selectedHunk = -1;
403
- this.selectedNode = -1;
404
- this.pinnedEdge = -1;
405
- this.#fadingHunk = -1;
406
- this.emphasis = 1;
407
- this.#emphasisAnim = null;
408
- this.hoverNode = this.hoverEdge = this.hoverGroup = -1;
409
- this.split = 0.5;
410
- this.clock = null;
411
- this.#followed = '';
412
- if (view) {
413
- if (view.theme && !this.explicit.has('theme')) this.themePreference = view.theme;
414
- if (view.palette && !this.explicit.has('palette')) this.palette = view.palette;
415
- if (view.locale && !this.explicit.has('locale')) this.locale = view.locale;
416
- }
417
- const previousMode = this.mode;
418
- const previousScope = this.scope;
419
- if (!this.explicit.has('mode')) this.mode = view?.mode ?? 'overlay';
420
- if (layout.scope === 'changes') {
421
- // Only the changes are laid out: there is no whole graph to show.
422
- this.scope = 'changes';
423
- } else if (!this.explicit.has('scope')) {
424
- const threshold = loaded.layoutOptions?.largeGraphThreshold ?? 3000;
425
- this.scope = view?.scope ?? (diff.nodes.length > threshold ? 'changes' : 'all');
426
- }
427
- this.#review = new ReviewStore(
428
- this.#deps.storage,
429
- reviewKey(diff.delta.base.hash, diff.delta.target.hash),
430
- diff.hunks.map((h) => h.id),
431
- );
432
- this.#rebuildScene();
433
- this.status = 'ready';
434
- this.step = null;
435
- if (layout.scope === 'changes' && computeStatus() === 'unknown') {
436
- // Learn early whether out-of-layout groups can be expanded (standalone reports).
437
- loadCompute().then(
438
- () => this.#notify('focus'),
439
- () => this.#notify('focus'),
440
- );
441
- }
442
- this.#notify(
443
- 'status',
444
- 'data',
445
- 'mode',
446
- 'selection',
447
- 'filters',
448
- 'review',
449
- 'replay',
450
- 'theme',
451
- 'locale',
452
- 'focus',
453
- 'swipe',
454
- 'overview',
455
- );
456
- // A new diff starts fitted, after the UI has shown its chrome (the notification above is
457
- // delivered first) and once the canvas has a size (see `layoutChanged`).
458
- this.#needsFit = true;
459
- this.#savedCamera = null;
460
- queueMicrotask(() => this.layoutChanged());
461
- this.#host.ready(diff.stats);
462
- if (this.mode !== previousMode || this.scope !== previousScope) {
463
- this.#host.mode(this.mode, this.scope);
464
- }
465
- const pending = this.#pendingHunk;
466
- this.#pendingHunk = null;
467
- if (pending !== null) this.selectHunkById(pending.id, { fly: pending.fly });
468
- else this.#autoplay();
469
- }
470
-
471
- /** (Re)build scene, resolver, list, clock and focus; selection is carried over by id. */
472
- #rebuildScene(): void {
473
- const data = this.data;
474
- const layout = this.layout;
475
- if (!data || !layout) return;
476
- const old = this.scene;
477
- const keepHunk = old && this.selectedHunk >= 0 ? old.hunks[this.selectedHunk]?.id : undefined;
478
- const keepNode = old && this.selectedNode >= 0 ? old.nodes[this.selectedNode]?.id : undefined;
479
- const keepEdge = old && this.pinnedEdge >= 0 ? old.edges[this.pinnedEdge]?.id : undefined;
480
- const scene = buildScene(data.diff, layout, { theme: this.theme, locale: this.locale });
481
- this.scene = scene;
482
- this.resolver = createVisualResolver(scene);
483
- this.#frames.main = this.#frames.before = this.#frames.after = null;
484
- const prevClock = this.clock;
485
- this.clock = new ReplayClock(scene.timeline);
486
- if (prevClock) {
487
- this.clock.setSpeed(prevClock.speed);
488
- this.clock.seek(prevClock.t);
489
- if (prevClock.playing) this.clock.play();
490
- }
491
- this.selectedHunk = keepHunk !== undefined ? (scene.hunkIndex.get(keepHunk) ?? -1) : -1;
492
- this.selectedNode = keepNode !== undefined ? (scene.nodeIndex.get(keepNode) ?? -1) : -1;
493
- this.pinnedEdge = keepEdge !== undefined ? (scene.edgeIndex.get(keepEdge) ?? -1) : -1;
494
- this.#fadingHunk = -1;
495
- this.hoverNode = this.hoverEdge = this.hoverGroup = -1;
496
- this.list = buildList(scene, this.families);
497
- this.#updateFocus();
498
- this.#renderer?.setTheme(scene.theme);
499
- this.#minimapDirty = true;
500
- this.invalidate();
501
- }
502
-
503
- #updateFocus(): void {
504
- const scene = this.scene;
505
- if (!scene || this.scope !== 'changes') {
506
- this.focus = null;
507
- return;
508
- }
509
- const expanded = new Set<number>();
510
- for (const id of this.expanded) {
511
- const i = scene.nodeIndex.get(id);
512
- if (i !== undefined) expanded.add(i);
513
- }
514
- this.focus = computeFocus(scene, { hops: this.hops, expanded });
129
+ constructor(host: SessionHost, deps: SessionDeps) {
130
+ super(host, deps);
131
+ this.#now = deps.now;
132
+ this.addView(this);
515
133
  }
516
134
 
517
135
  // ─── Renderer ─────────────────────────────────────────────────────────────
@@ -543,8 +161,7 @@ export class ViewerController {
543
161
  detach(): void {
544
162
  // A pending initial fit still runs after re-attach (`layoutChanged`), overriding this.
545
163
  if (this.#renderer && this.status === 'ready') this.#savedCamera = this.#renderer.camera.get();
546
- if (this.#rafId) this.#deps.cancelFrame(this.#rafId);
547
- this.#rafId = 0;
164
+ this.sleep();
548
165
  for (const fn of this.#unbind.splice(0)) fn();
549
166
  this.#minimap?.destroy();
550
167
  this.#minimap = null;
@@ -552,10 +169,9 @@ export class ViewerController {
552
169
  this.#renderer = null;
553
170
  }
554
171
 
555
- destroy(): void {
556
- this.#destroyed = true;
172
+ override destroy(): void {
173
+ super.destroy();
557
174
  this.detach();
558
- this.#listeners.clear();
559
175
  }
560
176
 
561
177
  get camera(): Camera | null {
@@ -591,7 +207,7 @@ export class ViewerController {
591
207
  return;
592
208
  }
593
209
  // A selection made before the first layout (deep link, `kgdiff-ready` handler) wins.
594
- const target = this.#selectionBounds();
210
+ const target = this.selectionBounds();
595
211
  if (target) {
596
212
  if (!this.#frameInsets(target, FLY_MAX_ZOOM)) return;
597
213
  this.#needsFit = false;
@@ -606,94 +222,27 @@ export class ViewerController {
606
222
 
607
223
  /** The user panned / zoomed: suspend replay camera follow for `USER_CAMERA_GRACE`. */
608
224
  noteUserCamera(): void {
609
- this.#lastUserCamera = this.#deps.now();
225
+ this.#lastUserCamera = this.#now();
610
226
  this.#userMoved = true;
611
227
  }
612
228
 
613
- // ─── Frame loop ───────────────────────────────────────────────────────────
614
-
615
- /** Redraw on the next animation frame. */
616
- invalidate(): void {
617
- this.#frameDirty = true;
618
- this.#schedule();
619
- }
620
-
621
- #schedule(): void {
622
- if (this.#rafId !== 0 || this.#destroyed) return;
623
- this.#rafId = this.#deps.requestFrame(this.#tick) || 0;
624
- }
625
-
626
- readonly #tick = (): void => {
627
- this.#rafId = 0;
628
- if (this.#destroyed) return;
629
- const now = this.#deps.now();
630
- const dt = this.#lastTick > 0 ? now - this.#lastTick : 0;
631
- let animating = false;
632
- const clock = this.clock;
633
- if (clock?.playing && this.mode === 'replay') {
634
- clock.advance(dt);
635
- this.#frameDirty = true;
636
- this.#notify('replay');
637
- this.#follow();
638
- if (clock.playing) animating = true;
639
- else if (clock.finished) this.#ended();
640
- }
641
- if (!Number.isNaN(this.#restartAt) && !this.#held) {
642
- if (now >= this.#restartAt) this.#loopAgain();
643
- else animating = true;
644
- }
645
- if (this.#emphasisAnim) {
646
- const a = this.#emphasisAnim;
647
- const u = a.duration > 0 ? Math.min(1, (now - a.start) / a.duration) : 1;
648
- this.emphasis = a.from + (a.to - a.from) * ease(u);
649
- this.#frameDirty = true;
650
- if (u >= 1) {
651
- this.#emphasisAnim = null;
652
- if (this.#fadingHunk >= 0) {
653
- this.#fadingHunk = -1;
654
- this.emphasis = 1;
655
- }
656
- } else animating = true;
657
- }
658
- if (this.#frameDirty) this.draw(now);
659
- if (animating) {
660
- this.#lastTick = now;
661
- this.#schedule();
662
- } else this.#lastTick = 0;
663
- };
664
-
665
- #view(layer: Layer): ViewState {
666
- const selected = this.selectedHunk >= 0 ? this.selectedHunk : this.#fadingHunk;
667
- return createViewState({
668
- layer,
669
- t: this.clock?.t ?? 0,
670
- selectedHunk: selected,
671
- hoveredNode: this.hoverNode,
672
- hoveredEdge: this.hoverEdge >= 0 ? this.hoverEdge : this.pinnedEdge,
673
- selectedNode: this.selectedNode,
674
- focus: this.focus,
675
- families: this.families,
676
- reducedMotion: this.reducedMotion,
677
- emphasis: this.emphasis,
678
- });
679
- }
229
+ // ─── SessionView ──────────────────────────────────────────────────────────
680
230
 
681
231
  /** Resolve the current view and hand it to the renderer (and the minimap). */
682
- draw(now = this.#deps.now()): void {
683
- this.#frameDirty = false;
232
+ draw(now = this.#now()): void {
684
233
  const resolver = this.resolver;
685
234
  const renderer = this.#renderer;
686
235
  if (!resolver || !renderer || this.status !== 'ready') return;
687
236
  const f = this.#frames;
688
237
  let mapFrame: FrameVisuals;
689
238
  if (this.mode === 'swipe') {
690
- f.before = resolver.resolve(this.#view('before'), f.before ?? undefined);
691
- f.after = resolver.resolve(this.#view('after'), f.after ?? undefined);
239
+ f.before = resolver.resolve(this.viewState('before'), f.before ?? undefined);
240
+ f.after = resolver.resolve(this.viewState('after'), f.after ?? undefined);
692
241
  renderer.setFrame({ split: this.split, before: f.before, after: f.after });
693
242
  mapFrame = f.after;
694
243
  } else {
695
244
  const layer: Layer = this.mode === 'replay' ? 'replay' : 'union';
696
- f.main = resolver.resolve(this.#view(layer), f.main ?? undefined);
245
+ f.main = resolver.resolve(this.viewState(layer), f.main ?? undefined);
697
246
  renderer.setFrame(f.main);
698
247
  mapFrame = f.main;
699
248
  }
@@ -708,262 +257,44 @@ export class ViewerController {
708
257
  }
709
258
  }
710
259
 
711
- #startEmphasis(from: number): void {
712
- if (this.reducedMotion) {
713
- this.emphasis = 1;
714
- this.#emphasisAnim = null;
715
- this.#fadingHunk = -1;
716
- return;
717
- }
718
- this.emphasis = from;
719
- this.#emphasisAnim = {
720
- from,
721
- to: 1,
722
- start: this.#deps.now(),
723
- duration: this.theme.motion.base,
724
- };
725
- }
726
-
727
- #fadeOutEmphasis(hunk: number): void {
728
- if (this.reducedMotion || hunk < 0) {
729
- this.#fadingHunk = -1;
730
- this.#emphasisAnim = null;
731
- this.emphasis = 1;
732
- return;
733
- }
734
- this.#fadingHunk = hunk;
735
- this.#emphasisAnim = {
736
- from: this.emphasis,
737
- to: 0,
738
- start: this.#deps.now(),
739
- duration: this.theme.motion.base,
740
- };
741
- }
742
-
743
- // ─── View: mode × scope ───────────────────────────────────────────────────
744
-
745
- /** Whether the scope is fixed to `changes` (the layout covers only the changes). */
746
- get scopeLocked(): boolean {
747
- return this.layout?.scope === 'changes';
748
- }
749
-
750
- /** Switch how the versions are compared. The legacy `changes` means overlay + `changes` scope. */
751
- setMode(value: ViewerMode | typeof LEGACY_CHANGES_MODE): void {
752
- if (value === LEGACY_CHANGES_MODE) {
753
- this.setView('overlay', 'changes');
754
- return;
755
- }
756
- const mode = toMode(value);
757
- if (mode !== null) this.setView(mode, this.scope);
758
- }
759
-
760
- /** Show the whole graph or only the changes, in any mode. */
761
- setScope(value: ViewerScope): void {
762
- const scope = toScope(value);
763
- if (scope !== null) this.setView(this.mode, scope);
764
- }
765
-
766
- /** Toggle the `changes` scope (key `4`); says why when the scope is locked. */
767
- toggleScope(): void {
768
- if (this.scopeLocked) {
769
- this.#host.announce(translate(this.locale, 'scope.locked'));
770
- return;
771
- }
772
- this.setScope(this.scope === 'changes' ? 'all' : 'changes');
773
- }
774
-
775
- /** Set mode and scope together (one notification, one `kgdiff-mode` event). */
776
- setView(mode: ViewerMode, scope: ViewerScope): void {
777
- const nextScope: ViewerScope = this.scopeLocked ? 'changes' : scope;
778
- const modeChanged = mode !== this.mode;
779
- const scopeChanged = nextScope !== this.scope;
780
- if (!modeChanged && !scopeChanged) return;
781
- this.mode = mode;
782
- this.scope = nextScope;
783
- if (mode !== 'replay') {
784
- this.clock?.pause();
785
- this.#restartAt = Number.NaN;
786
- this.#resume = false;
787
- }
788
- this.#followed = '';
789
- if (scopeChanged) {
790
- this.#updateFocus();
791
- this.#updateOverview();
792
- }
260
+ sceneChanged(): void {
261
+ const f = this.#frames;
262
+ f.main = f.before = f.after = null;
263
+ this.#renderer?.setTheme(this.scene?.theme ?? this.theme);
793
264
  this.#minimapDirty = true;
794
- this.#notify('mode', 'focus', 'replay');
795
- this.invalidate();
796
- if (this.status === 'ready') {
797
- this.#host.mode(mode, nextScope);
798
- const parts: string[] = [];
799
- if (modeChanged) {
800
- parts.push(
801
- translate(this.locale, 'announce.mode', { mode: translate(this.locale, `mode.${mode}`) }),
802
- );
803
- }
804
- if (scopeChanged) {
805
- parts.push(translate(this.locale, nextScope === 'changes' ? 'scope.on' : 'scope.off'));
806
- }
807
- this.#host.announce(parts.join(' · '));
808
- }
809
- // Switching to replay starts a looping replay; toggling the scope leaves playback alone.
810
- if (modeChanged && mode === 'replay') this.#autoplay();
811
- }
812
-
813
- // ─── Selection ────────────────────────────────────────────────────────────
814
-
815
- selectHunkById(id: string | null, options: { fly?: boolean } = {}): void {
816
- if (id === null) {
817
- this.#pendingHunk = null;
818
- this.clearSelection();
819
- return;
820
- }
821
- const scene = this.scene;
822
- if (!scene || this.status !== 'ready') {
823
- this.#pendingHunk = { id, fly: options.fly ?? true };
824
- return;
825
- }
826
- const h = scene.hunkIndex.get(id);
827
- if (h !== undefined) this.selectHunk(h, options);
828
265
  }
829
266
 
830
- /**
831
- * Select hunk `h` (scene index; -1 clears). `node` focuses one of its nodes in the detail
832
- * panel. `fly` (default true) brings it into view. In replay, seeks to the end of its window.
833
- */
834
- selectHunk(h: number, options: { fly?: boolean; node?: number } = {}): void {
835
- const scene = this.scene;
836
- if (!scene) return;
837
- if (h < 0 || h >= scene.hunks.length) {
838
- this.clearSelection();
839
- return;
840
- }
841
- const node = options.node ?? -1;
842
- const prev = this.selectedHunk;
843
- const changed = prev !== h || this.selectedNode !== node;
844
- this.selectedHunk = h;
845
- this.selectedNode = node;
846
- this.pinnedEdge = -1;
847
- if (prev !== h) {
848
- this.#fadingHunk = -1;
849
- this.#startEmphasis(prev >= 0 ? SWITCH_EMPHASIS : 0);
850
- if (this.mode === 'replay' && this.clock) {
851
- const end = this.clock.windowEnd(h);
852
- this.clock.pause();
853
- this.#restartAt = Number.NaN;
854
- this.#resume = false;
855
- if (end !== null) this.clock.seek(end);
856
- this.#followed = '';
857
- this.#notify('replay');
858
- }
859
- }
860
- if (options.fly ?? true) this.flyToHunk(h);
861
- this.invalidate();
862
- if (!changed) return;
863
- this.#notify('selection');
864
- const hunk = scene.hunks[h];
865
- if (!hunk) return;
866
- const nodeId = node >= 0 ? (scene.nodes[node]?.id ?? null) : null;
867
- this.#host.select(hunk.id, nodeId);
868
- if (prev !== h) {
869
- const rank = this.list.rank.get(h);
870
- this.#host.announce(
871
- translate(this.locale, 'announce.change', {
872
- i: (rank ?? h) + 1,
873
- n: rank !== undefined ? this.list.order.length : scene.hunks.length,
874
- title: hunk.title,
875
- }),
876
- );
877
- }
878
- }
879
-
880
- /** Select a node: its hunk when it has one, else the node itself. */
881
- selectNode(i: number, options: { fly?: boolean } = {}): void {
882
- const scene = this.scene;
883
- const n = scene?.nodes[i];
884
- if (!scene || !n) return;
885
- if (n.hunk >= 0) {
886
- this.selectHunk(n.hunk, { ...options, node: i });
887
- return;
888
- }
889
- const prev = this.selectedHunk;
890
- if (prev < 0 && this.selectedNode === i) {
891
- if (options.fly) this.flyToNode(i);
892
- return;
893
- }
894
- this.selectedHunk = -1;
895
- this.selectedNode = i;
896
- this.pinnedEdge = -1;
897
- if (prev >= 0) this.#fadeOutEmphasis(prev);
898
- if (options.fly) this.flyToNode(i);
899
- this.invalidate();
900
- this.#notify('selection');
901
- this.#host.select(null, n.id);
902
- this.#host.announce(
903
- translate(this.locale, 'announce.node', {
904
- label: n.label,
905
- status: translate(this.locale, `status.${n.status}`),
906
- }),
907
- );
908
- }
909
-
910
- clearSelection(): void {
911
- if (this.selectedHunk < 0 && this.selectedNode < 0 && this.pinnedEdge < 0) return;
912
- const prev = this.selectedHunk;
913
- this.selectedHunk = -1;
914
- this.selectedNode = -1;
915
- this.pinnedEdge = -1;
916
- this.#fadeOutEmphasis(prev);
917
- this.invalidate();
918
- this.#notify('selection');
919
- this.#host.select(null, null);
920
- this.#host.announce(translate(this.locale, 'announce.cleared'));
921
- }
922
-
923
- next(): void {
924
- this.#stepSelection(1);
267
+ installed(): void {
268
+ // Fitted after the UI has shown its chrome (the session's notification is delivered
269
+ // first) and once the canvas has a size (see `layoutChanged`).
270
+ this.#needsFit = true;
271
+ this.#savedCamera = null;
272
+ queueMicrotask(() => this.layoutChanged());
925
273
  }
926
274
 
927
- prev(): void {
928
- this.#stepSelection(-1);
275
+ drawnChanged(): void {
276
+ this.#updateOverview();
277
+ this.#minimapDirty = true;
929
278
  }
930
279
 
931
- #stepSelection(direction: 1 | -1): void {
932
- const h = stepHunk(this.list, this.selectedHunk, direction);
933
- if (h >= 0) this.selectHunk(h);
280
+ show(bounds: Rect): void {
281
+ this.#flyTo(bounds);
934
282
  }
935
283
 
936
- first(): void {
937
- const h = this.list.order[0];
938
- if (h !== undefined) this.selectHunk(h);
284
+ /** Follow `bounds` (replay): fly unless the user steered recently or it is mostly visible. */
285
+ follow(bounds: Rect): void {
286
+ if (this.#now() - this.#lastUserCamera < USER_CAMERA_GRACE) return;
287
+ const free = this.#freeWorld(bounds);
288
+ if (!free || coverage(bounds, free) >= FOLLOW_COVERAGE) return;
289
+ this.#flyTo(bounds);
939
290
  }
940
291
 
941
- last(): void {
942
- const h = this.list.order[this.list.order.length - 1];
943
- if (h !== undefined) this.selectHunk(h);
292
+ looped(): void {
293
+ if (this.autoFit) void this.fit();
944
294
  }
945
295
 
946
- /** Emphasize an edge (from the detail panel) and fly to it. */
947
- pinEdge(j: number): void {
948
- const scene = this.scene;
949
- const e = scene?.edges[j];
950
- if (!scene || !e) return;
951
- this.pinnedEdge = j;
952
- const ids = [e.s, e.t, e.sb, e.tb];
953
- let x0 = Number.POSITIVE_INFINITY;
954
- let y0 = Number.POSITIVE_INFINITY;
955
- let x1 = Number.NEGATIVE_INFINITY;
956
- let y1 = Number.NEGATIVE_INFINITY;
957
- for (const i of ids) {
958
- const n = scene.nodes[i];
959
- if (!n) continue;
960
- x0 = Math.min(x0, n.x - n.r);
961
- y0 = Math.min(y0, n.y - n.r);
962
- x1 = Math.max(x1, n.x + n.r);
963
- y1 = Math.max(y1, n.y + n.r);
964
- }
965
- if (x0 <= x1) this.#flyTo(padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }));
966
- this.invalidate();
296
+ viewport(): Rect | null {
297
+ return this.#renderer?.camera.viewport() ?? null;
967
298
  }
968
299
 
969
300
  // ─── Camera ───────────────────────────────────────────────────────────────
@@ -994,77 +325,6 @@ export class ViewerController {
994
325
  return freeView(camera.viewport(), camera.get().k, insets);
995
326
  }
996
327
 
997
- /** “Change 3 of 23: …” for a replayed hunk, “Restructured: 722 changes” for a chapter. */
998
- #announceTarget(kind: 'hunk' | 'chapter', index: number): void {
999
- const scene = this.scene;
1000
- const clock = this.clock;
1001
- if (!scene || !clock) return;
1002
- if (kind === 'chapter') {
1003
- const ch = scene.timeline.chapters[index];
1004
- if (!ch) return;
1005
- this.#host.announce(
1006
- translate(this.locale, 'announce.chapter', {
1007
- phase: translate(this.locale, `phase.${ch.phase}`),
1008
- n: ch.hunks.length,
1009
- }),
1010
- );
1011
- return;
1012
- }
1013
- const hunk = scene.hunks[index];
1014
- const order = clock.timeline.windows.findIndex((w) => w.hunk === index);
1015
- if (!hunk || order < 0) return;
1016
- this.#host.announce(
1017
- translate(this.locale, 'announce.change', {
1018
- i: order + 1,
1019
- n: clock.total,
1020
- title: hunk.title,
1021
- }),
1022
- );
1023
- }
1024
-
1025
- /** Follow `hunks` (replay): fly unless the user steered recently or they are mostly visible. */
1026
- #followHunks(hunks: readonly number[]): void {
1027
- const scene = this.scene;
1028
- if (!scene || hunks.length === 0) return;
1029
- if (this.#deps.now() - this.#lastUserCamera < USER_CAMERA_GRACE) return;
1030
- const bounds = frameBounds(scene, hunks);
1031
- const free = this.#freeWorld(bounds);
1032
- if (!free || coverage(bounds, free) >= FOLLOW_COVERAGE) return;
1033
- this.#flyTo(bounds);
1034
- }
1035
-
1036
- #hunkBounds(h: number): Rect | null {
1037
- const scene = this.scene;
1038
- return scene?.hunks[h] ? frameBounds(scene, [h]) : null;
1039
- }
1040
-
1041
- #nodeBounds(i: number): Rect | null {
1042
- const n = this.scene?.nodes[i];
1043
- return n ? padRect({ x: n.x - n.r, y: n.y - n.r, width: 2 * n.r, height: 2 * n.r }) : null;
1044
- }
1045
-
1046
- /** Camera target of the current selection, or null. */
1047
- #selectionBounds(): Rect | null {
1048
- if (this.selectedHunk >= 0) return this.#hunkBounds(this.selectedHunk);
1049
- if (this.selectedNode >= 0) return this.#nodeBounds(this.selectedNode);
1050
- return null;
1051
- }
1052
-
1053
- flyToHunk(h: number): void {
1054
- const b = this.#hunkBounds(h);
1055
- if (b) this.#flyTo(b);
1056
- }
1057
-
1058
- flyToNode(i: number): void {
1059
- const b = this.#nodeBounds(i);
1060
- if (b) this.#flyTo(b);
1061
- }
1062
-
1063
- flyToSelection(): void {
1064
- const b = this.#selectionBounds();
1065
- if (b) this.#flyTo(b);
1066
- }
1067
-
1068
328
  /**
1069
329
  * Fit everything visible (the focus in changes-only mode), labels included, into the free part
1070
330
  * of the canvas. Before the canvas is laid out it falls back to the renderer's own fit.
@@ -1145,7 +405,7 @@ export class ViewerController {
1145
405
  const next = !bounds || !view || coverage(bounds, view) >= OVERVIEW_COVERAGE;
1146
406
  if (next === this.overview) return;
1147
407
  this.overview = next;
1148
- this.#notify('overview');
408
+ this.notify('overview');
1149
409
  }
1150
410
 
1151
411
  zoom(direction: 1 | -1): void {
@@ -1153,42 +413,14 @@ export class ViewerController {
1153
413
  this.#renderer?.camera.zoomBy(direction > 0 ? 1.4 : 1 / 1.4);
1154
414
  }
1155
415
 
1156
- /** Replay: fly to the playing hunk / chapter when it changes and is mostly out of view. */
1157
- #follow(): void {
1158
- const scene = this.scene;
1159
- const clock = this.clock;
1160
- const camera = this.#renderer?.camera;
1161
- if (!scene || !clock || !camera) return;
1162
- const target = clock.activeTarget();
1163
- if (target.index < 0) return;
1164
- const key = `${target.kind}:${target.index}`;
1165
- if (key === this.#followed) return;
1166
- this.#followed = key;
1167
- this.#announceTarget(target.kind, target.index);
1168
- this.#followHunks(
1169
- target.kind === 'chapter'
1170
- ? (scene.timeline.chapters[target.index]?.hunks ?? [])
1171
- : [target.index],
1172
- );
1173
- }
1174
-
1175
416
  // ─── Canvas events ────────────────────────────────────────────────────────
1176
417
 
1177
418
  #onHover(pick: PickResult | null): void {
1178
- const node = pick?.type === 'node' ? pick.index : -1;
1179
- const edge = pick?.type === 'edge' ? pick.index : -1;
1180
- const group = pick?.type === 'collapsed' ? pick.index : -1;
1181
- if (node === this.hoverNode && edge === this.hoverEdge && group === this.hoverGroup) return;
1182
- this.hoverNode = node;
1183
- this.hoverEdge = edge;
1184
- this.hoverGroup = group;
1185
- this.#notify('hover');
1186
- this.invalidate();
1187
- }
1188
-
1189
- /** Clear hover state (pointer left the canvas). */
1190
- clearHover(): void {
1191
- this.#onHover(null);
419
+ this.setHover(
420
+ pick?.type === 'node' ? pick.index : -1,
421
+ pick?.type === 'edge' ? pick.index : -1,
422
+ pick?.type === 'collapsed' ? pick.index : -1,
423
+ );
1192
424
  }
1193
425
 
1194
426
  #onCanvasClick(pick: PickResult | null): void {
@@ -1223,418 +455,6 @@ export class ViewerController {
1223
455
  if (n.hunk >= 0) this.flyToHunk(n.hunk);
1224
456
  else this.flyToNode(pick.index);
1225
457
  }
1226
-
1227
- // ─── Filters, focus ───────────────────────────────────────────────────────
1228
-
1229
- toggleFamily(family: ChangeFamily): void {
1230
- if (this.families.has(family)) this.families.delete(family);
1231
- else this.families.add(family);
1232
- this.#filtersChanged();
1233
- }
1234
-
1235
- /** Replace the family filter (empty = all). Setting the current set again clears it. */
1236
- setFamilies(families: readonly ChangeFamily[]): void {
1237
- const same =
1238
- families.length === this.families.size && families.every((f) => this.families.has(f));
1239
- this.families.clear();
1240
- if (!same) for (const f of families) this.families.add(f);
1241
- this.#filtersChanged();
1242
- }
1243
-
1244
- #filtersChanged(): void {
1245
- if (this.scene) this.list = buildList(this.scene, this.families);
1246
- this.#minimapDirty = true;
1247
- this.#notify('filters');
1248
- this.invalidate();
1249
- }
1250
-
1251
- setHops(hops: number): void {
1252
- const h = Math.max(0, Math.min(2, Math.round(hops)));
1253
- if (h === this.hops) return;
1254
- this.hops = h;
1255
- this.#updateFocus();
1256
- this.#updateOverview();
1257
- this.#minimapDirty = true;
1258
- this.#notify('focus');
1259
- this.invalidate();
1260
- }
1261
-
1262
- /**
1263
- * Expand collapsed group `index` of the current focus: its in-scene members become visible
1264
- * and, for a `changes`-scoped layout, the neighbours left out of the layout are placed with
1265
- * `extendLayout` (nothing already placed moves) and the scene is rebuilt.
1266
- */
1267
- expandGroup(index: number): void {
1268
- const group = this.focus?.collapsed[index];
1269
- const scene = this.scene;
1270
- if (!group || !scene) return;
1271
- const ids: UnionId[] = group.nodes.map((i) => scene.nodes[i]?.id).filter(isString);
1272
- if (!this.canExpand(index)) {
1273
- // Without the layout engine only the members already laid out can be shown.
1274
- if (ids.length > 0) void this.#expandIds(ids, []);
1275
- return;
1276
- }
1277
- const owner = group.id.startsWith('near:')
1278
- ? group.id.slice(5)
1279
- : group.id.startsWith('component:')
1280
- ? group.id.slice(10)
1281
- : null;
1282
- void this.#expandIds(ids, owner !== null ? [owner, ...ids] : ids);
1283
- }
1284
-
1285
- /**
1286
- * The collapsed group nearest the selection (else the view center), or -1. Groups that can
1287
- * be expanded at least partly come first.
1288
- */
1289
- nearestGroup(): number {
1290
- const groups = this.focus?.collapsed ?? [];
1291
- if (groups.length === 0) return -1;
1292
- const sel = this.#selectionBounds();
1293
- const view = this.#renderer?.camera.viewport();
1294
- const at = sel ?? view ?? this.scene?.bounds;
1295
- const cx = at ? at.x + at.width / 2 : 0;
1296
- const cy = at ? at.y + at.height / 2 : 0;
1297
- let best = -1;
1298
- let bestScore = Number.POSITIVE_INFINITY;
1299
- groups.forEach((g, i) => {
1300
- const usable = this.canExpand(i) || g.nodes.length > 0;
1301
- const score = Math.hypot(g.x - cx, g.y - cy) + (usable ? 0 : 1e12);
1302
- if (score < bestScore) {
1303
- bestScore = score;
1304
- best = i;
1305
- }
1306
- });
1307
- return best;
1308
- }
1309
-
1310
- /** Expand the group nearest the selection (keyboard `e`); announces what happened. */
1311
- expandNearest(): void {
1312
- const i = this.nearestGroup();
1313
- const group = i >= 0 ? this.focus?.collapsed[i] : undefined;
1314
- if (!group || (!this.canExpand(i) && group.nodes.length === 0)) {
1315
- this.#host.announce(
1316
- translate(this.locale, group ? 'focus.expandUnavailable' : 'announce.nothingHidden'),
1317
- );
1318
- return;
1319
- }
1320
- this.expandGroup(i);
1321
- this.#host.announce(translate(this.locale, 'announce.expanded', { label: group.label }));
1322
- }
1323
-
1324
- /**
1325
- * Whether collapsed group `index` can be expanded completely: always, unless some members lie
1326
- * outside a `changes`-scoped layout and the layout engine is known to be unavailable
1327
- * (standalone report without the compute add-on).
1328
- */
1329
- canExpand(index: number): boolean {
1330
- const group = this.focus?.collapsed[index];
1331
- if (!group) return false;
1332
- const outside = this.layout?.scope === 'changes' && group.count > group.nodes.length;
1333
- return !outside || computeStatus() !== 'unavailable';
1334
- }
1335
-
1336
- /**
1337
- * Make `ids` visible, placing those outside the layout, plus the out-of-layout neighbours of
1338
- * `neighboursOf`. Resolves once done (the layout engine may load lazily first).
1339
- */
1340
- #expandIds(ids: readonly UnionId[], neighboursOf: readonly UnionId[]): Promise<void> {
1341
- const data = this.data;
1342
- const layout = this.layout;
1343
- if (!data || !layout) return Promise.resolve();
1344
- for (const id of ids) this.expanded.add(id);
1345
- const missing = new Set<UnionId>();
1346
- if (layout.scope === 'changes') {
1347
- for (const id of ids) if (!layout.positions.has(id)) missing.add(id);
1348
- for (const id of neighboursOf) {
1349
- for (const e of data.diff.index.edgesOf(id)) {
1350
- for (const end of [e.source, e.target, e.sourceBefore, e.targetBefore]) {
1351
- if (end !== undefined && !layout.positions.has(end)) missing.add(end);
1352
- }
1353
- }
1354
- }
1355
- }
1356
- if (missing.size === 0) {
1357
- this.#focusChanged();
1358
- return Promise.resolve();
1359
- }
1360
- const place = (): void => {
1361
- const compute = computeNow();
1362
- const current = this.layout;
1363
- if (!compute || !current || this.data !== data) return;
1364
- const todo = [...missing].filter((id) => !current.positions.has(id));
1365
- if (todo.length > 0) {
1366
- this.layout = compute.extendLayout(data.diff, current, todo, data.layoutOptions);
1367
- for (const id of todo) this.expanded.add(id);
1368
- this.#rebuildScene();
1369
- this.#notify('data', 'selection', 'replay');
1370
- }
1371
- this.#focusChanged();
1372
- };
1373
- if (computeNow()) {
1374
- place();
1375
- return Promise.resolve();
1376
- }
1377
- // Standalone reports load the layout engine on demand.
1378
- return loadCompute().then(place, () => this.#focusChanged());
1379
- }
1380
-
1381
- #focusChanged(): void {
1382
- this.#updateFocus();
1383
- this.#updateOverview();
1384
- this.#minimapDirty = true;
1385
- this.#notify('focus');
1386
- this.invalidate();
1387
- }
1388
-
1389
- /** Bring a union node into the scene and the focus (search results), then select it. */
1390
- revealNode(id: UnionId): Promise<void> {
1391
- if (!this.data || !this.scene) return Promise.resolve();
1392
- const select = (): void => {
1393
- const i = this.scene?.nodeIndex.get(id);
1394
- if (i !== undefined) this.selectNode(i, { fly: true });
1395
- };
1396
- if (this.scene.nodeIndex.has(id) && this.#isVisible(id)) {
1397
- select();
1398
- return Promise.resolve();
1399
- }
1400
- return this.#expandIds([id], []).then(select);
1401
- }
1402
-
1403
- #isVisible(id: UnionId): boolean {
1404
- const i = this.scene?.nodeIndex.get(id);
1405
- return i !== undefined && (this.focus === null || this.focus.nodes[i] === 1);
1406
- }
1407
-
1408
- // ─── Replay ───────────────────────────────────────────────────────────────
1409
-
1410
- play(): void {
1411
- const clock = this.clock;
1412
- if (!clock || this.status !== 'ready') return;
1413
- if (this.mode !== 'replay') this.setMode('replay');
1414
- this.#restartAt = Number.NaN;
1415
- this.#resume = false;
1416
- clock.play();
1417
- this.#followed = '';
1418
- this.#lastTick = 0;
1419
- this.#notify('replay');
1420
- this.invalidate();
1421
- }
1422
-
1423
- /** Pause; a looping replay stays paused (until `play`, a new diff or loop turned on again). */
1424
- pause(): void {
1425
- this.#restartAt = Number.NaN;
1426
- this.#resume = false;
1427
- if (!this.clock?.playing) return;
1428
- this.clock.pause();
1429
- this.#notify('replay');
1430
- this.invalidate();
1431
- }
1432
-
1433
- togglePlay(): void {
1434
- if (this.clock?.playing) this.pause();
1435
- else this.play();
1436
- }
1437
-
1438
- /**
1439
- * Play the replay over and over: `delay` ms after it ends it starts again; null plays it
1440
- * once. Turning the loop on starts a replay that is shown but not playing.
1441
- */
1442
- setLoop(delay: number | null): void {
1443
- const was = this.#loopDelay;
1444
- this.#loopDelay = delay === null ? null : Number.isFinite(delay) ? Math.max(0, delay) : 0;
1445
- if (this.#loopDelay === null) this.#restartAt = Number.NaN;
1446
- else if (was === null) this.#autoplay();
1447
- }
1448
-
1449
- get loopDelay(): number | null {
1450
- return this.#loopDelay;
1451
- }
1452
-
1453
- /**
1454
- * Hold playback while the pointer rests on the graph (`pause-on-hover`): a playing replay
1455
- * pauses and continues on release; a pending loop restart waits for the release, then the
1456
- * full loop delay.
1457
- */
1458
- hold(held: boolean): void {
1459
- if (held === this.#held) return;
1460
- this.#held = held;
1461
- const clock = this.clock;
1462
- if (held) {
1463
- if (!clock?.playing) return;
1464
- clock.pause();
1465
- this.#resume = true;
1466
- this.#notify('replay');
1467
- this.invalidate();
1468
- return;
1469
- }
1470
- if (!Number.isNaN(this.#restartAt) && this.#loopDelay !== null) {
1471
- this.#restartAt = this.#deps.now() + this.#loopDelay;
1472
- this.#schedule();
1473
- }
1474
- if (this.#resume) this.play();
1475
- }
1476
-
1477
- /** Looping and showing a replay that is not playing: start it (or after the hold). */
1478
- #autoplay(): void {
1479
- if (this.#loopDelay === null || this.status !== 'ready' || this.mode !== 'replay') return;
1480
- if (!this.clock || this.clock.playing || !Number.isNaN(this.#restartAt)) return;
1481
- if (this.#held) this.#resume = true;
1482
- else this.play();
1483
- }
1484
-
1485
- /** Playback reached the end: announce it, tell the host and arm the loop restart. */
1486
- #ended(): void {
1487
- this.#host.announce(translate(this.locale, 'announce.finished'));
1488
- const loop = this.#loopDelay !== null;
1489
- // Armed first, so a `kgdiff-replay-end` listener can still cancel it (pause, loop off).
1490
- if (loop) this.#restartAt = this.#deps.now() + (this.#loopDelay ?? 0);
1491
- this.#host.replayEnd(loop);
1492
- }
1493
-
1494
- #loopAgain(): void {
1495
- this.#restartAt = Number.NaN;
1496
- if (this.autoFit) void this.fit();
1497
- this.clock?.seek(0);
1498
- this.play();
1499
- }
1500
-
1501
- seek(t: number): void {
1502
- const clock = this.clock;
1503
- if (!clock) return;
1504
- this.#restartAt = Number.NaN;
1505
- clock.seek(t);
1506
- this.#notify('replay');
1507
- this.#follow();
1508
- this.invalidate();
1509
- }
1510
-
1511
- stepReplay(direction: 1 | -1): void {
1512
- const clock = this.clock;
1513
- if (!clock) return;
1514
- clock.step(direction);
1515
- this.#restartAt = Number.NaN;
1516
- this.#resume = false;
1517
- this.#followed = '';
1518
- // Follow (and announce) the hunk that just completed: stops sit at window ends.
1519
- const w = clock.timeline.windows[clock.position() - 1];
1520
- if (w) {
1521
- if (clock.timeline.mode === 'chapters') this.#announceTarget('chapter', w.chapter);
1522
- else this.#announceTarget('hunk', w.hunk);
1523
- this.#followHunks(
1524
- clock.timeline.mode === 'chapters'
1525
- ? (clock.timeline.chapters[w.chapter]?.hunks ?? [w.hunk])
1526
- : [w.hunk],
1527
- );
1528
- }
1529
- this.#notify('replay');
1530
- this.invalidate();
1531
- }
1532
-
1533
- restart(): void {
1534
- this.clock?.seek(0);
1535
- this.play();
1536
- }
1537
-
1538
- cycleSpeed(): void {
1539
- this.clock?.cycleSpeed();
1540
- this.#notify('replay');
1541
- }
1542
-
1543
- setSpeed(speed: number): void {
1544
- this.clock?.setSpeed(speed);
1545
- this.#notify('replay');
1546
- }
1547
-
1548
- // ─── Swipe ────────────────────────────────────────────────────────────────
1549
-
1550
- setSplit(split: number): void {
1551
- const s = Number.isFinite(split) ? Math.min(1, Math.max(0, split)) : 0.5;
1552
- if (s === this.split) return;
1553
- this.split = s;
1554
- this.#notify('swipe');
1555
- if (this.mode === 'swipe') this.invalidate();
1556
- }
1557
-
1558
- // ─── Review ───────────────────────────────────────────────────────────────
1559
-
1560
- setReviewed(hunkId: string, reviewed: boolean): void {
1561
- const review = this.#review;
1562
- if (!review?.setReviewed(hunkId, reviewed)) return;
1563
- const progress = review.state();
1564
- this.#notify('review');
1565
- this.#host.review(hunkId, reviewed, progress);
1566
- const text = translate(this.locale, 'review.progress', {
1567
- done: progress.reviewed.length,
1568
- total: progress.total,
1569
- });
1570
- this.#host.announce(
1571
- translate(this.locale, reviewed ? 'announce.reviewed' : 'announce.unreviewed', {
1572
- progress: text,
1573
- }),
1574
- );
1575
- }
1576
-
1577
- /** Another tab changed review progress (`storage` event): re-read it. */
1578
- syncReview(key: string | null): void {
1579
- if (this.#review?.reload(key)) this.#notify('review');
1580
- }
1581
-
1582
- toggleReviewed(hunkId?: string): void {
1583
- const id =
1584
- hunkId ?? (this.selectedHunk >= 0 ? this.scene?.hunks[this.selectedHunk]?.id : undefined);
1585
- if (id !== undefined) this.setReviewed(id, !this.isReviewed(id));
1586
- }
1587
-
1588
- // ─── Appearance ───────────────────────────────────────────────────────────
1589
-
1590
- #appearance(apply: () => void): void {
1591
- const name = this.themeName;
1592
- const palette = this.palette;
1593
- const locale = this.locale;
1594
- apply();
1595
- const themeChanged = name !== this.themeName || palette !== this.palette;
1596
- const localeChanged = locale !== this.locale;
1597
- if (themeChanged || localeChanged) {
1598
- if (this.data) this.#rebuildScene();
1599
- else this.#renderer?.setTheme(this.theme);
1600
- }
1601
- if (themeChanged) this.#notify('theme');
1602
- if (localeChanged) this.#notify('locale', 'data');
1603
- }
1604
-
1605
- setThemePreference(pref: ThemePreference): void {
1606
- this.#appearance(() => {
1607
- this.themePreference = toTheme(pref) ?? 'auto';
1608
- });
1609
- this.#notify('theme');
1610
- }
1611
-
1612
- setPalette(palette: PaletteName): void {
1613
- this.#appearance(() => {
1614
- this.palette = toPalette(palette) ?? 'default';
1615
- });
1616
- }
1617
-
1618
- setLocale(locale: Locale): void {
1619
- this.#appearance(() => {
1620
- this.locale = toLocale(locale) ?? 'en';
1621
- });
1622
- }
1623
-
1624
- setSystemDark(dark: boolean): void {
1625
- this.#appearance(() => {
1626
- this.systemDark = dark;
1627
- });
1628
- }
1629
-
1630
- setReducedMotion(reduced: boolean): void {
1631
- this.reducedMotion = reduced;
1632
- this.invalidate();
1633
- }
1634
- }
1635
-
1636
- function isString(x: string | undefined): x is string {
1637
- return x !== undefined;
1638
458
  }
1639
459
 
1640
460
  function sameInsets(a: Insets, b: Insets): boolean {
@@ -1660,10 +480,3 @@ function focusBounds(scene: Scene, focus: FocusState): Rect {
1660
480
  if (!(x0 <= x1)) return frameBounds(scene);
1661
481
  return padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }, 160, 1.12);
1662
482
  }
1663
-
1664
- /** Pad a small rect so a node is shown with its neighbourhood. */
1665
- function padRect(r: Rect, min = 200, ratio = 1.4): Rect {
1666
- const w = Math.max(min, r.width * ratio);
1667
- const h = Math.max(min, r.height * ratio);
1668
- return { x: r.x + r.width / 2 - w / 2, y: r.y + r.height / 2 - h / 2, width: w, height: h };
1669
- }