@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/session.ts ADDED
@@ -0,0 +1,1386 @@
1
+ /**
2
+ * The diff session (RFC 0001 §1): everything about one loaded diff that does not depend on how
3
+ * it is drawn.
4
+ *
5
+ * - load pipeline with progress steps (sources or a precomputed report);
6
+ * - view (mode × scope), selection (with an animated spotlight `emphasis`), hover, family
7
+ * filters, the node search query and its hits, review progress;
8
+ * - the `changes` scope's focus with expandable collapsed groups (`extendLayout` + scene rebuild);
9
+ * - replay clock and loop, swipe position, theme / palette / locale (rebuilding the scene when
10
+ * they change), and the events reported to the host.
11
+ *
12
+ * No DOM, no renderer: the views that draw the session (`SessionView`: the canvas with its
13
+ * camera, framing and minimap) are told what to draw and what to bring into view. One frame loop
14
+ * advances the replay clock and the emphasis and lets the views draw; it sleeps when idle.
15
+ * Listeners subscribe to topic notifications (batched per microtask) and read public fields.
16
+ */
17
+ import type { DiffResult, DiffStats, Locale, Rect, UnionId } from '@kgdiff/core';
18
+ import { KgdiffError } from '@kgdiff/core';
19
+ import type { LayoutResult } from '@kgdiff/layout';
20
+ import {
21
+ buildScene,
22
+ type ChangeFamily,
23
+ computeFocus,
24
+ createViewState,
25
+ createVisualResolver,
26
+ type FocusState,
27
+ frameBounds,
28
+ getTheme,
29
+ type Layer,
30
+ type PaletteName,
31
+ type Scene,
32
+ type Theme,
33
+ type ThemeName,
34
+ type ViewState,
35
+ type VisualResolver,
36
+ } from '@kgdiff/scene';
37
+ import { computeNow, computeStatus, loadCompute } from './compute-loader.js';
38
+ import { translate } from './i18n.js';
39
+ import type {
40
+ LoadOptions,
41
+ ReportPayload,
42
+ ReviewState,
43
+ ThemePreference,
44
+ ViewerMode,
45
+ ViewerScope,
46
+ } from './index.js';
47
+ import { buildList, EMPTY_LIST, type ListModel, stepHunk } from './list-model.js';
48
+ import { type LoadedDiff, type LoadStep, readPayload, runLoad } from './pipeline.js';
49
+ import { LEGACY_CHANGES_MODE, toLocale, toMode, toPalette, toScope, toTheme } from './prefs.js';
50
+ import { ReplayClock } from './replay.js';
51
+ import { ReviewStore, reviewKey, type StorageLike } from './review-store.js';
52
+ import { type SearchHit, SearchIndex } from './search.js';
53
+
54
+ export type Status = 'idle' | 'loading' | 'ready' | 'error';
55
+
56
+ export type Topic =
57
+ | 'status'
58
+ | 'data'
59
+ | 'mode'
60
+ | 'selection'
61
+ | 'filters'
62
+ | 'search'
63
+ | 'review'
64
+ | 'replay'
65
+ | 'hover'
66
+ | 'theme'
67
+ | 'locale'
68
+ | 'focus'
69
+ | 'swipe'
70
+ | 'overview';
71
+
72
+ /** Every topic: a full render. */
73
+ export const ALL_TOPICS: ReadonlySet<Topic> = new Set<Topic>([
74
+ 'status',
75
+ 'data',
76
+ 'mode',
77
+ 'selection',
78
+ 'filters',
79
+ 'search',
80
+ 'review',
81
+ 'replay',
82
+ 'hover',
83
+ 'theme',
84
+ 'locale',
85
+ 'focus',
86
+ 'swipe',
87
+ 'overview',
88
+ ]);
89
+
90
+ /** What the session reports (`<kgdiff-viewer>` turns these into events and announcements). */
91
+ export interface SessionHost {
92
+ ready(stats: DiffStats): void;
93
+ select(hunk: string | null, node: string | null): void;
94
+ mode(mode: ViewerMode, scope: ViewerScope): void;
95
+ review(hunk: string, reviewed: boolean, progress: ReviewState): void;
96
+ error(error: Error): void;
97
+ announce(text: string): void;
98
+ /** Playback reached the end of the replay; `loop`: it starts again after the loop delay. */
99
+ replayEnd(loop: boolean): void;
100
+ }
101
+
102
+ export interface SessionDeps {
103
+ readonly now: () => number;
104
+ readonly requestFrame: (cb: (time: number) => void) => number;
105
+ readonly cancelFrame: (id: number) => void;
106
+ /** Resolves once the browser had a chance to paint (between loading steps). */
107
+ readonly yieldToPaint: () => Promise<void>;
108
+ readonly storage: StorageLike | null;
109
+ /**
110
+ * The theme to draw with, given the built-in one for the current theme and palette. A root
111
+ * builds it from its computed CSS tokens (the theme's source of truth); without it the
112
+ * built-in theme is used as it is.
113
+ */
114
+ readonly theme?: (base: Theme) => Theme;
115
+ }
116
+
117
+ /**
118
+ * A view of the session: something that draws it (the canvas, with its camera, framing and
119
+ * minimap). The session tells its views what changed and what to bring into view; how they
120
+ * draw and frame it is theirs.
121
+ */
122
+ export interface SessionView {
123
+ /** Draw the session's current state (from the frame loop, when something changed). */
124
+ draw(now: number): void;
125
+ /** The scene was rebuilt, or the theme changed without a scene. */
126
+ sceneChanged(): void;
127
+ /** A new diff was installed: frame it once laid out. */
128
+ installed(): void;
129
+ /** What is drawn changed (mode, scope, focus, filters). */
130
+ drawnChanged(): void;
131
+ /** Bring `bounds` (world coordinates) into view. */
132
+ show(bounds: Rect): void;
133
+ /** Replay follows `bounds`: bring it into view unless it mostly is, or the user steered. */
134
+ follow(bounds: Rect): void;
135
+ /** A looping replay started again. */
136
+ looped(): void;
137
+ /** The world region in view, or null before layout. */
138
+ viewport(): Rect | null;
139
+ }
140
+
141
+ export type Preference = 'mode' | 'scope' | 'theme' | 'palette' | 'locale';
142
+
143
+ /** Spotlight emphasis restarts here when switching directly between two hunks. */
144
+ const SWITCH_EMPHASIS = 0.35;
145
+ /** Search hits kept for a query. */
146
+ export const SEARCH_LIMIT = 50;
147
+
148
+ class Superseded extends Error {}
149
+
150
+ interface EmphasisAnimation {
151
+ readonly from: number;
152
+ readonly to: number;
153
+ readonly start: number;
154
+ readonly duration: number;
155
+ }
156
+
157
+ const ease = (u: number): number => 1 - (1 - u) ** 3;
158
+
159
+ export class DiffSession {
160
+ status: Status = 'idle';
161
+ /** Steps of the current load, in order; `step` is the running one. */
162
+ steps: LoadStep[] = [];
163
+ step: LoadStep | null = null;
164
+ error: Error | null = null;
165
+
166
+ data: LoadedDiff | null = null;
167
+ layout: LayoutResult | null = null;
168
+ scene: Scene | null = null;
169
+ resolver: VisualResolver | null = null;
170
+ list: ListModel = EMPTY_LIST;
171
+
172
+ mode: ViewerMode = 'overlay';
173
+ scope: ViewerScope = 'all';
174
+ themePreference: ThemePreference = 'auto';
175
+ palette: PaletteName = 'default';
176
+ locale: Locale = 'en';
177
+ systemDark = true;
178
+ reducedMotion = false;
179
+ /** Preferences set explicitly by the embedder (a report's `view` does not override them). */
180
+ readonly explicit = new Set<Preference>();
181
+
182
+ selectedHunk = -1;
183
+ selectedNode = -1;
184
+ /** An edge emphasized from the detail panel (drawn as hovered). */
185
+ pinnedEdge = -1;
186
+ hoverNode = -1;
187
+ hoverEdge = -1;
188
+ hoverGroup = -1;
189
+ readonly families = new Set<ChangeFamily>();
190
+ /** The node search as typed; while it is not blank, `hits` replace the change list. */
191
+ query = '';
192
+ hits: readonly SearchHit[] = [];
193
+ hops = 1;
194
+ readonly expanded = new Set<UnionId>();
195
+ focus: FocusState | null = null;
196
+ clock: ReplayClock | null = null;
197
+ split = 0.5;
198
+ emphasis = 1;
199
+
200
+ #theme: Theme | null = null;
201
+ #themeKey = '';
202
+ #review: ReviewStore | null = null;
203
+ #searchIndex: SearchIndex | null = null;
204
+ readonly #views = new Set<SessionView>();
205
+ #fadingHunk = -1;
206
+ #emphasisAnim: EmphasisAnimation | null = null;
207
+ #rafId = 0;
208
+ #lastTick = 0;
209
+ #frameDirty = false;
210
+ #followed = '';
211
+ #loadToken = 0;
212
+ #pendingHunk: { readonly id: string; readonly fly: boolean } | null = null;
213
+ /** A finished replay starts again this long (ms) after its end; null: it plays once. */
214
+ #loopDelay: number | null = null;
215
+ /** When a finished looping replay starts again (`deps.now()` time), else NaN. */
216
+ #restartAt = Number.NaN;
217
+ /** Playback is held (pointer over the graph); `#resume`: it was playing and continues after. */
218
+ #held = false;
219
+ #resume = false;
220
+ readonly #listeners = new Set<(topics: ReadonlySet<Topic>) => void>();
221
+ #pendingTopics = new Set<Topic>();
222
+ #flushQueued = false;
223
+ #destroyed = false;
224
+
225
+ readonly #host: SessionHost;
226
+ readonly #deps: SessionDeps;
227
+
228
+ constructor(host: SessionHost, deps: SessionDeps) {
229
+ this.#host = host;
230
+ this.#deps = deps;
231
+ }
232
+
233
+ // ─── Subscriptions ────────────────────────────────────────────────────────
234
+
235
+ subscribe(listener: (topics: ReadonlySet<Topic>) => void): () => void {
236
+ this.#listeners.add(listener);
237
+ return () => this.#listeners.delete(listener);
238
+ }
239
+
240
+ /** @internal Queue a notification (views report what they derive, e.g. `overview`). */
241
+ notify(...topics: Topic[]): void {
242
+ for (const t of topics) this.#pendingTopics.add(t);
243
+ if (this.#flushQueued) return;
244
+ this.#flushQueued = true;
245
+ queueMicrotask(() => this.flush());
246
+ }
247
+
248
+ /** Deliver pending notifications now (also used by tests). */
249
+ flush(): void {
250
+ this.#flushQueued = false;
251
+ if (this.#pendingTopics.size === 0) return;
252
+ const topics = this.#pendingTopics;
253
+ this.#pendingTopics = new Set();
254
+ for (const fn of this.#listeners) fn(topics);
255
+ }
256
+
257
+ /** Draw on `view` from now on; returns the function that stops it. */
258
+ addView(view: SessionView): () => void {
259
+ this.#views.add(view);
260
+ return () => this.#views.delete(view);
261
+ }
262
+
263
+ // ─── Derived state ────────────────────────────────────────────────────────
264
+
265
+ get themeName(): ThemeName {
266
+ if (this.themePreference === 'auto') return this.systemDark ? 'dark' : 'light';
267
+ return this.themePreference;
268
+ }
269
+
270
+ /** The theme to draw with: the built-in one for theme and palette, as the root's CSS says. */
271
+ get theme(): Theme {
272
+ const key = `${this.themeName}/${this.palette}`;
273
+ if (this.#theme === null || key !== this.#themeKey) {
274
+ this.#themeKey = key;
275
+ this.#theme = this.#resolveTheme();
276
+ }
277
+ return this.#theme;
278
+ }
279
+
280
+ #resolveTheme(): Theme {
281
+ let base: Theme;
282
+ try {
283
+ base = getTheme(this.themeName, this.palette);
284
+ } catch {
285
+ base = getTheme('dark', 'default');
286
+ }
287
+ return this.#deps.theme?.(base) ?? base;
288
+ }
289
+
290
+ get diff(): DiffResult | null {
291
+ return this.data?.diff ?? null;
292
+ }
293
+
294
+ get hunkCount(): number {
295
+ return this.scene?.hunks.length ?? 0;
296
+ }
297
+
298
+ isReviewed(hunkId: string): boolean {
299
+ return this.#review?.has(hunkId) ?? false;
300
+ }
301
+
302
+ reviewState(): ReviewState {
303
+ return this.#review?.state() ?? { reviewed: [], total: 0 };
304
+ }
305
+
306
+ search(query: string, limit?: number): SearchHit[] {
307
+ const diff = this.diff;
308
+ if (!diff) return [];
309
+ this.#searchIndex ??= new SearchIndex(diff);
310
+ return this.#searchIndex.search(query, limit);
311
+ }
312
+
313
+ /** Search the nodes for `query`; delivered at once, so the results follow every keystroke. */
314
+ setQuery(query: string): void {
315
+ this.query = query;
316
+ this.#runSearch();
317
+ this.notify('search');
318
+ this.flush();
319
+ }
320
+
321
+ #runSearch(): void {
322
+ const q = this.query.trim();
323
+ this.hits = q ? this.search(q, SEARCH_LIMIT) : [];
324
+ }
325
+
326
+ // ─── Loading ──────────────────────────────────────────────────────────────
327
+
328
+ async load(options: LoadOptions): Promise<void> {
329
+ const text = (s: unknown): boolean =>
330
+ typeof s === 'object' && s !== null && typeof (s as { text?: unknown }).text === 'string';
331
+ const parse = text(options?.before) || text(options?.after);
332
+ await this.#run(
333
+ parse ? ['parse', 'match', 'layout', 'scene'] : ['match', 'layout', 'scene'],
334
+ (onStep) => runLoad(options, onStep),
335
+ );
336
+ }
337
+
338
+ async loadPayload(payload: ReportPayload): Promise<void> {
339
+ await this.#run(['match', 'layout', 'scene'], (onStep) => readPayload(payload, onStep));
340
+ }
341
+
342
+ async #run(
343
+ planned: LoadStep[],
344
+ work: (onStep: (step: LoadStep) => Promise<void>) => Promise<LoadedDiff>,
345
+ ): Promise<void> {
346
+ const token = ++this.#loadToken;
347
+ this.pause();
348
+ this.status = 'loading';
349
+ this.error = null;
350
+ this.steps = planned;
351
+ this.step = null;
352
+ this.notify('status');
353
+ const onStep = async (step: LoadStep): Promise<void> => {
354
+ if (token !== this.#loadToken || this.#destroyed) throw new Superseded();
355
+ this.step = step;
356
+ this.notify('status');
357
+ this.flush();
358
+ await this.#deps.yieldToPaint();
359
+ if (token !== this.#loadToken || this.#destroyed) throw new Superseded();
360
+ };
361
+ try {
362
+ const loaded = await work(onStep);
363
+ await onStep('scene');
364
+ this.#install(loaded);
365
+ } catch (error) {
366
+ if (error instanceof Superseded) {
367
+ throw new DOMException('Superseded by a newer load.', 'AbortError');
368
+ }
369
+ const err = error instanceof Error ? error : new KgdiffError('invalid-input', String(error));
370
+ if (token === this.#loadToken) this.fail(err);
371
+ throw err;
372
+ }
373
+ }
374
+
375
+ /** Show an error state (bad payload, failed load). */
376
+ fail(error: Error): void {
377
+ this.#loadToken++;
378
+ this.clock?.pause();
379
+ this.#restartAt = Number.NaN;
380
+ this.#resume = false;
381
+ this.status = 'error';
382
+ this.error = error;
383
+ this.step = null;
384
+ // Nothing stale stays on screen next to the error.
385
+ this.data = null;
386
+ this.layout = null;
387
+ this.scene = null;
388
+ this.resolver = null;
389
+ this.list = EMPTY_LIST;
390
+ this.clock = null;
391
+ this.focus = null;
392
+ this.#review = null;
393
+ this.#searchIndex = null;
394
+ this.#runSearch();
395
+ this.selectedHunk = this.selectedNode = this.pinnedEdge = -1;
396
+ this.notify('status', 'data', 'selection', 'filters', 'search', 'review', 'replay', 'focus');
397
+ this.#host.error(error);
398
+ }
399
+
400
+ #install(loaded: LoadedDiff): void {
401
+ const { diff, layout, view } = loaded;
402
+ this.data = loaded;
403
+ this.layout = layout;
404
+ this.#searchIndex = null;
405
+ this.#runSearch();
406
+ this.families.clear();
407
+ this.expanded.clear();
408
+ this.hops = Math.max(0, Math.min(2, loaded.layoutOptions?.contextHops ?? 1));
409
+ this.selectedHunk = -1;
410
+ this.selectedNode = -1;
411
+ this.pinnedEdge = -1;
412
+ this.#fadingHunk = -1;
413
+ this.emphasis = 1;
414
+ this.#emphasisAnim = null;
415
+ this.hoverNode = this.hoverEdge = this.hoverGroup = -1;
416
+ this.split = 0.5;
417
+ this.clock = null;
418
+ this.#followed = '';
419
+ if (view) {
420
+ if (view.theme && !this.explicit.has('theme')) this.themePreference = view.theme;
421
+ if (view.palette && !this.explicit.has('palette')) this.palette = view.palette;
422
+ if (view.locale && !this.explicit.has('locale')) this.locale = view.locale;
423
+ }
424
+ const previousMode = this.mode;
425
+ const previousScope = this.scope;
426
+ if (!this.explicit.has('mode')) this.mode = view?.mode ?? 'overlay';
427
+ if (layout.scope === 'changes') {
428
+ // Only the changes are laid out: there is no whole graph to show.
429
+ this.scope = 'changes';
430
+ } else if (!this.explicit.has('scope')) {
431
+ const threshold = loaded.layoutOptions?.largeGraphThreshold ?? 3000;
432
+ this.scope = view?.scope ?? (diff.nodes.length > threshold ? 'changes' : 'all');
433
+ }
434
+ this.#review = new ReviewStore(
435
+ this.#deps.storage,
436
+ reviewKey(diff.delta.base.hash, diff.delta.target.hash),
437
+ diff.hunks.map((h) => h.id),
438
+ );
439
+ this.#rebuildScene();
440
+ this.status = 'ready';
441
+ this.step = null;
442
+ if (layout.scope === 'changes' && computeStatus() === 'unknown') {
443
+ // Learn early whether out-of-layout groups can be expanded (standalone reports).
444
+ loadCompute().then(
445
+ () => this.notify('focus'),
446
+ () => this.notify('focus'),
447
+ );
448
+ }
449
+ this.notify(...ALL_TOPICS);
450
+ // A new diff starts fitted, after the UI has shown its chrome (the notification above is
451
+ // delivered first) and once the canvas has a size.
452
+ for (const v of this.#views) v.installed();
453
+ this.#host.ready(diff.stats);
454
+ if (this.mode !== previousMode || this.scope !== previousScope) {
455
+ this.#host.mode(this.mode, this.scope);
456
+ }
457
+ const pending = this.#pendingHunk;
458
+ this.#pendingHunk = null;
459
+ if (pending !== null) this.selectHunkById(pending.id, { fly: pending.fly });
460
+ else this.#autoplay();
461
+ }
462
+
463
+ /** (Re)build scene, resolver, list, clock and focus; selection is carried over by id. */
464
+ #rebuildScene(): void {
465
+ const data = this.data;
466
+ const layout = this.layout;
467
+ if (!data || !layout) return;
468
+ const old = this.scene;
469
+ const keepHunk = old && this.selectedHunk >= 0 ? old.hunks[this.selectedHunk]?.id : undefined;
470
+ const keepNode = old && this.selectedNode >= 0 ? old.nodes[this.selectedNode]?.id : undefined;
471
+ const keepEdge = old && this.pinnedEdge >= 0 ? old.edges[this.pinnedEdge]?.id : undefined;
472
+ const scene = buildScene(data.diff, layout, { theme: this.theme, locale: this.locale });
473
+ this.scene = scene;
474
+ this.resolver = createVisualResolver(scene);
475
+ const prevClock = this.clock;
476
+ this.clock = new ReplayClock(scene.timeline);
477
+ if (prevClock) {
478
+ this.clock.setSpeed(prevClock.speed);
479
+ this.clock.seek(prevClock.t);
480
+ if (prevClock.playing) this.clock.play();
481
+ }
482
+ this.selectedHunk = keepHunk !== undefined ? (scene.hunkIndex.get(keepHunk) ?? -1) : -1;
483
+ this.selectedNode = keepNode !== undefined ? (scene.nodeIndex.get(keepNode) ?? -1) : -1;
484
+ this.pinnedEdge = keepEdge !== undefined ? (scene.edgeIndex.get(keepEdge) ?? -1) : -1;
485
+ this.#fadingHunk = -1;
486
+ this.hoverNode = this.hoverEdge = this.hoverGroup = -1;
487
+ this.list = buildList(scene, this.families);
488
+ this.#updateFocus();
489
+ for (const v of this.#views) v.sceneChanged();
490
+ this.invalidate();
491
+ }
492
+
493
+ #updateFocus(): void {
494
+ const scene = this.scene;
495
+ if (!scene || this.scope !== 'changes') {
496
+ this.focus = null;
497
+ return;
498
+ }
499
+ const expanded = new Set<number>();
500
+ for (const id of this.expanded) {
501
+ const i = scene.nodeIndex.get(id);
502
+ if (i !== undefined) expanded.add(i);
503
+ }
504
+ this.focus = computeFocus(scene, { hops: this.hops, expanded });
505
+ }
506
+
507
+ destroy(): void {
508
+ this.#destroyed = true;
509
+ this.sleep();
510
+ this.#listeners.clear();
511
+ }
512
+
513
+ // ─── Frame loop ───────────────────────────────────────────────────────────
514
+
515
+ /** Redraw on the next frame. */
516
+ invalidate(): void {
517
+ this.#frameDirty = true;
518
+ this.#schedule();
519
+ }
520
+
521
+ /** @internal Drop the pending frame (the views are gone); `invalidate` wakes the loop. */
522
+ sleep(): void {
523
+ if (this.#rafId) this.#deps.cancelFrame(this.#rafId);
524
+ this.#rafId = 0;
525
+ }
526
+
527
+ #schedule(): void {
528
+ if (this.#rafId !== 0 || this.#destroyed) return;
529
+ this.#rafId = this.#deps.requestFrame(this.#tick) || 0;
530
+ }
531
+
532
+ readonly #tick = (): void => {
533
+ this.#rafId = 0;
534
+ if (this.#destroyed) return;
535
+ const now = this.#deps.now();
536
+ const dt = this.#lastTick > 0 ? now - this.#lastTick : 0;
537
+ let animating = false;
538
+ const clock = this.clock;
539
+ if (clock?.playing && this.mode === 'replay') {
540
+ clock.advance(dt);
541
+ this.#frameDirty = true;
542
+ this.notify('replay');
543
+ this.#follow();
544
+ if (clock.playing) animating = true;
545
+ else if (clock.finished) this.#ended();
546
+ }
547
+ if (!Number.isNaN(this.#restartAt) && !this.#held) {
548
+ if (now >= this.#restartAt) this.#loopAgain();
549
+ else animating = true;
550
+ }
551
+ if (this.#emphasisAnim) {
552
+ const a = this.#emphasisAnim;
553
+ const u = a.duration > 0 ? Math.min(1, (now - a.start) / a.duration) : 1;
554
+ this.emphasis = a.from + (a.to - a.from) * ease(u);
555
+ this.#frameDirty = true;
556
+ if (u >= 1) {
557
+ this.#emphasisAnim = null;
558
+ if (this.#fadingHunk >= 0) {
559
+ this.#fadingHunk = -1;
560
+ this.emphasis = 1;
561
+ }
562
+ } else animating = true;
563
+ }
564
+ if (this.#frameDirty) {
565
+ this.#frameDirty = false;
566
+ for (const v of this.#views) v.draw(now);
567
+ }
568
+ if (animating) {
569
+ this.#lastTick = now;
570
+ this.#schedule();
571
+ } else this.#lastTick = 0;
572
+ };
573
+
574
+ /** What a view draws for `layer` now. */
575
+ viewState(layer: Layer): ViewState {
576
+ const selected = this.selectedHunk >= 0 ? this.selectedHunk : this.#fadingHunk;
577
+ return createViewState({
578
+ layer,
579
+ t: this.clock?.t ?? 0,
580
+ selectedHunk: selected,
581
+ hoveredNode: this.hoverNode,
582
+ hoveredEdge: this.hoverEdge >= 0 ? this.hoverEdge : this.pinnedEdge,
583
+ selectedNode: this.selectedNode,
584
+ focus: this.focus,
585
+ families: this.families,
586
+ reducedMotion: this.reducedMotion,
587
+ emphasis: this.emphasis,
588
+ });
589
+ }
590
+
591
+ #startEmphasis(from: number): void {
592
+ if (this.reducedMotion) {
593
+ this.emphasis = 1;
594
+ this.#emphasisAnim = null;
595
+ this.#fadingHunk = -1;
596
+ return;
597
+ }
598
+ this.emphasis = from;
599
+ this.#emphasisAnim = {
600
+ from,
601
+ to: 1,
602
+ start: this.#deps.now(),
603
+ duration: this.theme.motion.base,
604
+ };
605
+ }
606
+
607
+ #fadeOutEmphasis(hunk: number): void {
608
+ if (this.reducedMotion || hunk < 0) {
609
+ this.#fadingHunk = -1;
610
+ this.#emphasisAnim = null;
611
+ this.emphasis = 1;
612
+ return;
613
+ }
614
+ this.#fadingHunk = hunk;
615
+ this.#emphasisAnim = {
616
+ from: this.emphasis,
617
+ to: 0,
618
+ start: this.#deps.now(),
619
+ duration: this.theme.motion.base,
620
+ };
621
+ }
622
+
623
+ #drawnChanged(): void {
624
+ for (const v of this.#views) v.drawnChanged();
625
+ }
626
+
627
+ // ─── View: mode × scope ───────────────────────────────────────────────────
628
+
629
+ /** Whether the scope is fixed to `changes` (the layout covers only the changes). */
630
+ get scopeLocked(): boolean {
631
+ return this.layout?.scope === 'changes';
632
+ }
633
+
634
+ /** Switch how the versions are compared. The legacy `changes` means overlay + `changes` scope. */
635
+ setMode(value: ViewerMode | typeof LEGACY_CHANGES_MODE): void {
636
+ if (value === LEGACY_CHANGES_MODE) {
637
+ this.setView('overlay', 'changes');
638
+ return;
639
+ }
640
+ const mode = toMode(value);
641
+ if (mode !== null) this.setView(mode, this.scope);
642
+ }
643
+
644
+ /** Show the whole graph or only the changes, in any mode. */
645
+ setScope(value: ViewerScope): void {
646
+ const scope = toScope(value);
647
+ if (scope !== null) this.setView(this.mode, scope);
648
+ }
649
+
650
+ /** Toggle the `changes` scope (key `4`); says why when the scope is locked. */
651
+ toggleScope(): void {
652
+ if (this.scopeLocked) {
653
+ this.#host.announce(translate(this.locale, 'scope.locked'));
654
+ return;
655
+ }
656
+ this.setScope(this.scope === 'changes' ? 'all' : 'changes');
657
+ }
658
+
659
+ /** Set mode and scope together (one notification, one `kgdiff-mode` event). */
660
+ setView(mode: ViewerMode, scope: ViewerScope): void {
661
+ const nextScope: ViewerScope = this.scopeLocked ? 'changes' : scope;
662
+ const modeChanged = mode !== this.mode;
663
+ const scopeChanged = nextScope !== this.scope;
664
+ if (!modeChanged && !scopeChanged) return;
665
+ this.mode = mode;
666
+ this.scope = nextScope;
667
+ if (mode !== 'replay') {
668
+ this.clock?.pause();
669
+ this.#restartAt = Number.NaN;
670
+ this.#resume = false;
671
+ }
672
+ this.#followed = '';
673
+ if (scopeChanged) this.#updateFocus();
674
+ this.#drawnChanged();
675
+ this.notify('mode', 'focus', 'replay');
676
+ this.invalidate();
677
+ if (this.status === 'ready') {
678
+ this.#host.mode(mode, nextScope);
679
+ const parts: string[] = [];
680
+ if (modeChanged) {
681
+ parts.push(
682
+ translate(this.locale, 'announce.mode', { mode: translate(this.locale, `mode.${mode}`) }),
683
+ );
684
+ }
685
+ if (scopeChanged) {
686
+ parts.push(translate(this.locale, nextScope === 'changes' ? 'scope.on' : 'scope.off'));
687
+ }
688
+ this.#host.announce(parts.join(' · '));
689
+ }
690
+ // Switching to replay starts a looping replay; toggling the scope leaves playback alone.
691
+ if (modeChanged && mode === 'replay') this.#autoplay();
692
+ }
693
+
694
+ // ─── Selection ────────────────────────────────────────────────────────────
695
+
696
+ selectHunkById(id: string | null, options: { fly?: boolean } = {}): void {
697
+ if (id === null) {
698
+ this.#pendingHunk = null;
699
+ this.clearSelection();
700
+ return;
701
+ }
702
+ const scene = this.scene;
703
+ if (!scene || this.status !== 'ready') {
704
+ this.#pendingHunk = { id, fly: options.fly ?? true };
705
+ return;
706
+ }
707
+ const h = scene.hunkIndex.get(id);
708
+ if (h !== undefined) this.selectHunk(h, options);
709
+ }
710
+
711
+ /**
712
+ * Select hunk `h` (scene index; -1 clears). `node` focuses one of its nodes in the detail
713
+ * panel. `fly` (default true) brings it into view. In replay, seeks to the end of its window.
714
+ */
715
+ selectHunk(h: number, options: { fly?: boolean; node?: number } = {}): void {
716
+ const scene = this.scene;
717
+ if (!scene) return;
718
+ if (h < 0 || h >= scene.hunks.length) {
719
+ this.clearSelection();
720
+ return;
721
+ }
722
+ const node = options.node ?? -1;
723
+ const prev = this.selectedHunk;
724
+ const changed = prev !== h || this.selectedNode !== node;
725
+ this.selectedHunk = h;
726
+ this.selectedNode = node;
727
+ this.pinnedEdge = -1;
728
+ if (prev !== h) {
729
+ this.#fadingHunk = -1;
730
+ this.#startEmphasis(prev >= 0 ? SWITCH_EMPHASIS : 0);
731
+ if (this.mode === 'replay' && this.clock) {
732
+ const end = this.clock.windowEnd(h);
733
+ this.clock.pause();
734
+ this.#restartAt = Number.NaN;
735
+ this.#resume = false;
736
+ if (end !== null) this.clock.seek(end);
737
+ this.#followed = '';
738
+ this.notify('replay');
739
+ }
740
+ }
741
+ if (options.fly ?? true) this.flyToHunk(h);
742
+ this.invalidate();
743
+ if (!changed) return;
744
+ this.notify('selection');
745
+ const hunk = scene.hunks[h];
746
+ if (!hunk) return;
747
+ const nodeId = node >= 0 ? (scene.nodes[node]?.id ?? null) : null;
748
+ this.#host.select(hunk.id, nodeId);
749
+ if (prev !== h) {
750
+ const rank = this.list.rank.get(h);
751
+ this.#host.announce(
752
+ translate(this.locale, 'announce.change', {
753
+ i: (rank ?? h) + 1,
754
+ n: rank !== undefined ? this.list.order.length : scene.hunks.length,
755
+ title: hunk.title,
756
+ }),
757
+ );
758
+ }
759
+ }
760
+
761
+ /** Select a node: its hunk when it has one, else the node itself. */
762
+ selectNode(i: number, options: { fly?: boolean } = {}): void {
763
+ const scene = this.scene;
764
+ const n = scene?.nodes[i];
765
+ if (!scene || !n) return;
766
+ if (n.hunk >= 0) {
767
+ this.selectHunk(n.hunk, { ...options, node: i });
768
+ return;
769
+ }
770
+ const prev = this.selectedHunk;
771
+ if (prev < 0 && this.selectedNode === i) {
772
+ if (options.fly) this.flyToNode(i);
773
+ return;
774
+ }
775
+ this.selectedHunk = -1;
776
+ this.selectedNode = i;
777
+ this.pinnedEdge = -1;
778
+ if (prev >= 0) this.#fadeOutEmphasis(prev);
779
+ if (options.fly) this.flyToNode(i);
780
+ this.invalidate();
781
+ this.notify('selection');
782
+ this.#host.select(null, n.id);
783
+ this.#host.announce(
784
+ translate(this.locale, 'announce.node', {
785
+ label: n.label,
786
+ status: translate(this.locale, `status.${n.status}`),
787
+ }),
788
+ );
789
+ }
790
+
791
+ clearSelection(): void {
792
+ if (this.selectedHunk < 0 && this.selectedNode < 0 && this.pinnedEdge < 0) return;
793
+ const prev = this.selectedHunk;
794
+ this.selectedHunk = -1;
795
+ this.selectedNode = -1;
796
+ this.pinnedEdge = -1;
797
+ this.#fadeOutEmphasis(prev);
798
+ this.invalidate();
799
+ this.notify('selection');
800
+ this.#host.select(null, null);
801
+ this.#host.announce(translate(this.locale, 'announce.cleared'));
802
+ }
803
+
804
+ next(): void {
805
+ this.#stepSelection(1);
806
+ }
807
+
808
+ prev(): void {
809
+ this.#stepSelection(-1);
810
+ }
811
+
812
+ #stepSelection(direction: 1 | -1): void {
813
+ const h = stepHunk(this.list, this.selectedHunk, direction);
814
+ if (h >= 0) this.selectHunk(h);
815
+ }
816
+
817
+ first(): void {
818
+ const h = this.list.order[0];
819
+ if (h !== undefined) this.selectHunk(h);
820
+ }
821
+
822
+ last(): void {
823
+ const h = this.list.order[this.list.order.length - 1];
824
+ if (h !== undefined) this.selectHunk(h);
825
+ }
826
+
827
+ /** Emphasize an edge (from the detail panel) and fly to it. */
828
+ pinEdge(j: number): void {
829
+ const scene = this.scene;
830
+ const e = scene?.edges[j];
831
+ if (!scene || !e) return;
832
+ this.pinnedEdge = j;
833
+ const ids = [e.s, e.t, e.sb, e.tb];
834
+ let x0 = Number.POSITIVE_INFINITY;
835
+ let y0 = Number.POSITIVE_INFINITY;
836
+ let x1 = Number.NEGATIVE_INFINITY;
837
+ let y1 = Number.NEGATIVE_INFINITY;
838
+ for (const i of ids) {
839
+ const n = scene.nodes[i];
840
+ if (!n) continue;
841
+ x0 = Math.min(x0, n.x - n.r);
842
+ y0 = Math.min(y0, n.y - n.r);
843
+ x1 = Math.max(x1, n.x + n.r);
844
+ y1 = Math.max(y1, n.y + n.r);
845
+ }
846
+ if (x0 <= x1) this.#show(padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }));
847
+ this.invalidate();
848
+ }
849
+
850
+ setHover(node: number, edge: number, group: number): void {
851
+ if (node === this.hoverNode && edge === this.hoverEdge && group === this.hoverGroup) return;
852
+ this.hoverNode = node;
853
+ this.hoverEdge = edge;
854
+ this.hoverGroup = group;
855
+ this.notify('hover');
856
+ this.invalidate();
857
+ }
858
+
859
+ /** Clear hover state (pointer left the canvas). */
860
+ clearHover(): void {
861
+ this.setHover(-1, -1, -1);
862
+ }
863
+
864
+ // ─── Bringing things into view ────────────────────────────────────────────
865
+
866
+ #show(bounds: Rect): void {
867
+ for (const v of this.#views) v.show(bounds);
868
+ }
869
+
870
+ /** “Change 3 of 23: …” for a replayed hunk, “Restructured: 722 changes” for a chapter. */
871
+ #announceTarget(kind: 'hunk' | 'chapter', index: number): void {
872
+ const scene = this.scene;
873
+ const clock = this.clock;
874
+ if (!scene || !clock) return;
875
+ if (kind === 'chapter') {
876
+ const ch = scene.timeline.chapters[index];
877
+ if (!ch) return;
878
+ this.#host.announce(
879
+ translate(this.locale, 'announce.chapter', {
880
+ phase: translate(this.locale, `phase.${ch.phase}`),
881
+ n: ch.hunks.length,
882
+ }),
883
+ );
884
+ return;
885
+ }
886
+ const hunk = scene.hunks[index];
887
+ const order = clock.timeline.windows.findIndex((w) => w.hunk === index);
888
+ if (!hunk || order < 0) return;
889
+ this.#host.announce(
890
+ translate(this.locale, 'announce.change', {
891
+ i: order + 1,
892
+ n: clock.total,
893
+ title: hunk.title,
894
+ }),
895
+ );
896
+ }
897
+
898
+ /** Replay follows `hunks`: the views bring them into view unless they mostly are. */
899
+ #followHunks(hunks: readonly number[]): void {
900
+ const scene = this.scene;
901
+ if (!scene || hunks.length === 0) return;
902
+ const bounds = frameBounds(scene, hunks);
903
+ for (const v of this.#views) v.follow(bounds);
904
+ }
905
+
906
+ /** Replay: follow (and announce) the playing hunk / chapter when it changes. */
907
+ #follow(): void {
908
+ const scene = this.scene;
909
+ const clock = this.clock;
910
+ if (!scene || !clock) return;
911
+ const target = clock.activeTarget();
912
+ if (target.index < 0) return;
913
+ const key = `${target.kind}:${target.index}`;
914
+ if (key === this.#followed) return;
915
+ this.#followed = key;
916
+ this.#announceTarget(target.kind, target.index);
917
+ this.#followHunks(
918
+ target.kind === 'chapter'
919
+ ? (scene.timeline.chapters[target.index]?.hunks ?? [])
920
+ : [target.index],
921
+ );
922
+ }
923
+
924
+ #hunkBounds(h: number): Rect | null {
925
+ const scene = this.scene;
926
+ return scene?.hunks[h] ? frameBounds(scene, [h]) : null;
927
+ }
928
+
929
+ #nodeBounds(i: number): Rect | null {
930
+ const n = this.scene?.nodes[i];
931
+ return n ? padRect({ x: n.x - n.r, y: n.y - n.r, width: 2 * n.r, height: 2 * n.r }) : null;
932
+ }
933
+
934
+ /** Where the current selection is (world coordinates), or null. */
935
+ selectionBounds(): Rect | null {
936
+ if (this.selectedHunk >= 0) return this.#hunkBounds(this.selectedHunk);
937
+ if (this.selectedNode >= 0) return this.#nodeBounds(this.selectedNode);
938
+ return null;
939
+ }
940
+
941
+ flyToHunk(h: number): void {
942
+ const b = this.#hunkBounds(h);
943
+ if (b) this.#show(b);
944
+ }
945
+
946
+ flyToNode(i: number): void {
947
+ const b = this.#nodeBounds(i);
948
+ if (b) this.#show(b);
949
+ }
950
+
951
+ flyToSelection(): void {
952
+ const b = this.selectionBounds();
953
+ if (b) this.#show(b);
954
+ }
955
+
956
+ // ─── Filters, focus ───────────────────────────────────────────────────────
957
+
958
+ toggleFamily(family: ChangeFamily): void {
959
+ if (this.families.has(family)) this.families.delete(family);
960
+ else this.families.add(family);
961
+ this.#filtersChanged();
962
+ }
963
+
964
+ /** Replace the family filter (empty = all). Setting the current set again clears it. */
965
+ setFamilies(families: readonly ChangeFamily[]): void {
966
+ const same =
967
+ families.length === this.families.size && families.every((f) => this.families.has(f));
968
+ this.families.clear();
969
+ if (!same) for (const f of families) this.families.add(f);
970
+ this.#filtersChanged();
971
+ }
972
+
973
+ #filtersChanged(): void {
974
+ if (this.scene) this.list = buildList(this.scene, this.families);
975
+ this.#drawnChanged();
976
+ this.notify('filters');
977
+ this.invalidate();
978
+ }
979
+
980
+ setHops(hops: number): void {
981
+ const h = Math.max(0, Math.min(2, Math.round(hops)));
982
+ if (h === this.hops) return;
983
+ this.hops = h;
984
+ this.#focusChanged();
985
+ }
986
+
987
+ /**
988
+ * Expand collapsed group `index` of the current focus: its in-scene members become visible
989
+ * and, for a `changes`-scoped layout, the neighbours left out of the layout are placed with
990
+ * `extendLayout` (nothing already placed moves) and the scene is rebuilt.
991
+ */
992
+ expandGroup(index: number): void {
993
+ const group = this.focus?.collapsed[index];
994
+ const scene = this.scene;
995
+ if (!group || !scene) return;
996
+ const ids: UnionId[] = group.nodes.map((i) => scene.nodes[i]?.id).filter(isString);
997
+ if (!this.canExpand(index)) {
998
+ // Without the layout engine only the members already laid out can be shown.
999
+ if (ids.length > 0) void this.#expandIds(ids, []);
1000
+ return;
1001
+ }
1002
+ const owner = group.id.startsWith('near:')
1003
+ ? group.id.slice(5)
1004
+ : group.id.startsWith('component:')
1005
+ ? group.id.slice(10)
1006
+ : null;
1007
+ void this.#expandIds(ids, owner !== null ? [owner, ...ids] : ids);
1008
+ }
1009
+
1010
+ /**
1011
+ * The collapsed group nearest the selection (else the view center), or -1. Groups that can
1012
+ * be expanded at least partly come first.
1013
+ */
1014
+ nearestGroup(): number {
1015
+ const groups = this.focus?.collapsed ?? [];
1016
+ if (groups.length === 0) return -1;
1017
+ const sel = this.selectionBounds();
1018
+ let view: Rect | null = null;
1019
+ for (const v of this.#views) view ??= v.viewport();
1020
+ const at = sel ?? view ?? this.scene?.bounds;
1021
+ const cx = at ? at.x + at.width / 2 : 0;
1022
+ const cy = at ? at.y + at.height / 2 : 0;
1023
+ let best = -1;
1024
+ let bestScore = Number.POSITIVE_INFINITY;
1025
+ groups.forEach((g, i) => {
1026
+ const usable = this.canExpand(i) || g.nodes.length > 0;
1027
+ const score = Math.hypot(g.x - cx, g.y - cy) + (usable ? 0 : 1e12);
1028
+ if (score < bestScore) {
1029
+ bestScore = score;
1030
+ best = i;
1031
+ }
1032
+ });
1033
+ return best;
1034
+ }
1035
+
1036
+ /** Expand the group nearest the selection (keyboard `e`); announces what happened. */
1037
+ expandNearest(): void {
1038
+ const i = this.nearestGroup();
1039
+ const group = i >= 0 ? this.focus?.collapsed[i] : undefined;
1040
+ if (!group || (!this.canExpand(i) && group.nodes.length === 0)) {
1041
+ this.#host.announce(
1042
+ translate(this.locale, group ? 'focus.expandUnavailable' : 'announce.nothingHidden'),
1043
+ );
1044
+ return;
1045
+ }
1046
+ this.expandGroup(i);
1047
+ this.#host.announce(translate(this.locale, 'announce.expanded', { label: group.label }));
1048
+ }
1049
+
1050
+ /**
1051
+ * Whether collapsed group `index` can be expanded completely: always, unless some members lie
1052
+ * outside a `changes`-scoped layout and the layout engine is known to be unavailable
1053
+ * (standalone report without the compute add-on).
1054
+ */
1055
+ canExpand(index: number): boolean {
1056
+ const group = this.focus?.collapsed[index];
1057
+ if (!group) return false;
1058
+ const outside = this.layout?.scope === 'changes' && group.count > group.nodes.length;
1059
+ return !outside || computeStatus() !== 'unavailable';
1060
+ }
1061
+
1062
+ /**
1063
+ * Make `ids` visible, placing those outside the layout, plus the out-of-layout neighbours of
1064
+ * `neighboursOf`. Resolves once done (the layout engine may load lazily first).
1065
+ */
1066
+ #expandIds(ids: readonly UnionId[], neighboursOf: readonly UnionId[]): Promise<void> {
1067
+ const data = this.data;
1068
+ const layout = this.layout;
1069
+ if (!data || !layout) return Promise.resolve();
1070
+ for (const id of ids) this.expanded.add(id);
1071
+ const missing = new Set<UnionId>();
1072
+ if (layout.scope === 'changes') {
1073
+ for (const id of ids) if (!layout.positions.has(id)) missing.add(id);
1074
+ for (const id of neighboursOf) {
1075
+ for (const e of data.diff.index.edgesOf(id)) {
1076
+ for (const end of [e.source, e.target, e.sourceBefore, e.targetBefore]) {
1077
+ if (end !== undefined && !layout.positions.has(end)) missing.add(end);
1078
+ }
1079
+ }
1080
+ }
1081
+ }
1082
+ if (missing.size === 0) {
1083
+ this.#focusChanged();
1084
+ return Promise.resolve();
1085
+ }
1086
+ const place = (): void => {
1087
+ const compute = computeNow();
1088
+ const current = this.layout;
1089
+ if (!compute || !current || this.data !== data) return;
1090
+ const todo = [...missing].filter((id) => !current.positions.has(id));
1091
+ if (todo.length > 0) {
1092
+ this.layout = compute.extendLayout(data.diff, current, todo, data.layoutOptions);
1093
+ for (const id of todo) this.expanded.add(id);
1094
+ this.#rebuildScene();
1095
+ this.notify('data', 'selection', 'replay');
1096
+ }
1097
+ this.#focusChanged();
1098
+ };
1099
+ if (computeNow()) {
1100
+ place();
1101
+ return Promise.resolve();
1102
+ }
1103
+ // Standalone reports load the layout engine on demand.
1104
+ return loadCompute().then(place, () => this.#focusChanged());
1105
+ }
1106
+
1107
+ #focusChanged(): void {
1108
+ this.#updateFocus();
1109
+ this.#drawnChanged();
1110
+ this.notify('focus');
1111
+ this.invalidate();
1112
+ }
1113
+
1114
+ /** Bring a union node into the scene and the focus (search results), then select it. */
1115
+ revealNode(id: UnionId): Promise<void> {
1116
+ if (!this.data || !this.scene) return Promise.resolve();
1117
+ const select = (): void => {
1118
+ const i = this.scene?.nodeIndex.get(id);
1119
+ if (i !== undefined) this.selectNode(i, { fly: true });
1120
+ };
1121
+ if (this.scene.nodeIndex.has(id) && this.#isVisible(id)) {
1122
+ select();
1123
+ return Promise.resolve();
1124
+ }
1125
+ return this.#expandIds([id], []).then(select);
1126
+ }
1127
+
1128
+ #isVisible(id: UnionId): boolean {
1129
+ const i = this.scene?.nodeIndex.get(id);
1130
+ return i !== undefined && (this.focus === null || this.focus.nodes[i] === 1);
1131
+ }
1132
+
1133
+ // ─── Replay ───────────────────────────────────────────────────────────────
1134
+
1135
+ play(): void {
1136
+ const clock = this.clock;
1137
+ if (!clock || this.status !== 'ready') return;
1138
+ if (this.mode !== 'replay') this.setMode('replay');
1139
+ this.#restartAt = Number.NaN;
1140
+ this.#resume = false;
1141
+ clock.play();
1142
+ this.#followed = '';
1143
+ this.#lastTick = 0;
1144
+ this.notify('replay');
1145
+ this.invalidate();
1146
+ }
1147
+
1148
+ /** Pause; a looping replay stays paused (until `play`, a new diff or loop turned on again). */
1149
+ pause(): void {
1150
+ this.#restartAt = Number.NaN;
1151
+ this.#resume = false;
1152
+ if (!this.clock?.playing) return;
1153
+ this.clock.pause();
1154
+ this.notify('replay');
1155
+ this.invalidate();
1156
+ }
1157
+
1158
+ togglePlay(): void {
1159
+ if (this.clock?.playing) this.pause();
1160
+ else this.play();
1161
+ }
1162
+
1163
+ /**
1164
+ * Play the replay over and over: `delay` ms after it ends it starts again; null plays it
1165
+ * once. Turning the loop on starts a replay that is shown but not playing.
1166
+ */
1167
+ setLoop(delay: number | null): void {
1168
+ const was = this.#loopDelay;
1169
+ this.#loopDelay = delay === null ? null : Number.isFinite(delay) ? Math.max(0, delay) : 0;
1170
+ if (this.#loopDelay === null) this.#restartAt = Number.NaN;
1171
+ else if (was === null) this.#autoplay();
1172
+ }
1173
+
1174
+ get loopDelay(): number | null {
1175
+ return this.#loopDelay;
1176
+ }
1177
+
1178
+ /**
1179
+ * Hold playback while the pointer rests on the graph (`pause-on-hover`): a playing replay
1180
+ * pauses and continues on release; a pending loop restart waits for the release, then the
1181
+ * full loop delay.
1182
+ */
1183
+ hold(held: boolean): void {
1184
+ if (held === this.#held) return;
1185
+ this.#held = held;
1186
+ const clock = this.clock;
1187
+ if (held) {
1188
+ if (!clock?.playing) return;
1189
+ clock.pause();
1190
+ this.#resume = true;
1191
+ this.notify('replay');
1192
+ this.invalidate();
1193
+ return;
1194
+ }
1195
+ if (!Number.isNaN(this.#restartAt) && this.#loopDelay !== null) {
1196
+ this.#restartAt = this.#deps.now() + this.#loopDelay;
1197
+ this.#schedule();
1198
+ }
1199
+ if (this.#resume) this.play();
1200
+ }
1201
+
1202
+ /** Looping and showing a replay that is not playing: start it (or after the hold). */
1203
+ #autoplay(): void {
1204
+ if (this.#loopDelay === null || this.status !== 'ready' || this.mode !== 'replay') return;
1205
+ if (!this.clock || this.clock.playing || !Number.isNaN(this.#restartAt)) return;
1206
+ if (this.#held) this.#resume = true;
1207
+ else this.play();
1208
+ }
1209
+
1210
+ /** Playback reached the end: announce it, tell the host and arm the loop restart. */
1211
+ #ended(): void {
1212
+ this.#host.announce(translate(this.locale, 'announce.finished'));
1213
+ const loop = this.#loopDelay !== null;
1214
+ // Armed first, so a `kgdiff-replay-end` listener can still cancel it (pause, loop off).
1215
+ if (loop) this.#restartAt = this.#deps.now() + (this.#loopDelay ?? 0);
1216
+ this.#host.replayEnd(loop);
1217
+ }
1218
+
1219
+ #loopAgain(): void {
1220
+ this.#restartAt = Number.NaN;
1221
+ for (const v of this.#views) v.looped();
1222
+ this.clock?.seek(0);
1223
+ this.play();
1224
+ }
1225
+
1226
+ seek(t: number): void {
1227
+ const clock = this.clock;
1228
+ if (!clock) return;
1229
+ this.#restartAt = Number.NaN;
1230
+ clock.seek(t);
1231
+ this.notify('replay');
1232
+ this.#follow();
1233
+ this.invalidate();
1234
+ }
1235
+
1236
+ stepReplay(direction: 1 | -1): void {
1237
+ const clock = this.clock;
1238
+ if (!clock) return;
1239
+ clock.step(direction);
1240
+ this.#restartAt = Number.NaN;
1241
+ this.#resume = false;
1242
+ this.#followed = '';
1243
+ // Follow (and announce) the hunk that just completed: stops sit at window ends.
1244
+ const w = clock.timeline.windows[clock.position() - 1];
1245
+ if (w) {
1246
+ if (clock.timeline.mode === 'chapters') this.#announceTarget('chapter', w.chapter);
1247
+ else this.#announceTarget('hunk', w.hunk);
1248
+ this.#followHunks(
1249
+ clock.timeline.mode === 'chapters'
1250
+ ? (clock.timeline.chapters[w.chapter]?.hunks ?? [w.hunk])
1251
+ : [w.hunk],
1252
+ );
1253
+ }
1254
+ this.notify('replay');
1255
+ this.invalidate();
1256
+ }
1257
+
1258
+ restart(): void {
1259
+ this.clock?.seek(0);
1260
+ this.play();
1261
+ }
1262
+
1263
+ cycleSpeed(): void {
1264
+ this.clock?.cycleSpeed();
1265
+ this.notify('replay');
1266
+ }
1267
+
1268
+ setSpeed(speed: number): void {
1269
+ this.clock?.setSpeed(speed);
1270
+ this.notify('replay');
1271
+ }
1272
+
1273
+ // ─── Swipe ────────────────────────────────────────────────────────────────
1274
+
1275
+ setSplit(split: number): void {
1276
+ const s = Number.isFinite(split) ? Math.min(1, Math.max(0, split)) : 0.5;
1277
+ if (s === this.split) return;
1278
+ this.split = s;
1279
+ this.notify('swipe');
1280
+ if (this.mode === 'swipe') this.invalidate();
1281
+ }
1282
+
1283
+ // ─── Review ───────────────────────────────────────────────────────────────
1284
+
1285
+ setReviewed(hunkId: string, reviewed: boolean): void {
1286
+ const review = this.#review;
1287
+ if (!review?.setReviewed(hunkId, reviewed)) return;
1288
+ const progress = review.state();
1289
+ this.notify('review');
1290
+ this.#host.review(hunkId, reviewed, progress);
1291
+ const text = translate(this.locale, 'review.progress', {
1292
+ done: progress.reviewed.length,
1293
+ total: progress.total,
1294
+ });
1295
+ this.#host.announce(
1296
+ translate(this.locale, reviewed ? 'announce.reviewed' : 'announce.unreviewed', {
1297
+ progress: text,
1298
+ }),
1299
+ );
1300
+ }
1301
+
1302
+ /** Another tab changed review progress (`storage` event): re-read it. */
1303
+ syncReview(key: string | null): void {
1304
+ if (this.#review?.reload(key)) this.notify('review');
1305
+ }
1306
+
1307
+ toggleReviewed(hunkId?: string): void {
1308
+ const id =
1309
+ hunkId ?? (this.selectedHunk >= 0 ? this.scene?.hunks[this.selectedHunk]?.id : undefined);
1310
+ if (id !== undefined) this.setReviewed(id, !this.isReviewed(id));
1311
+ }
1312
+
1313
+ // ─── Appearance ───────────────────────────────────────────────────────────
1314
+
1315
+ #appearance(apply: () => void): void {
1316
+ const name = this.themeName;
1317
+ const palette = this.palette;
1318
+ const locale = this.locale;
1319
+ apply();
1320
+ const themeChanged = name !== this.themeName || palette !== this.palette;
1321
+ const localeChanged = locale !== this.locale;
1322
+ if (themeChanged || localeChanged) this.#rethemed();
1323
+ if (themeChanged) this.notify('theme');
1324
+ if (localeChanged) this.notify('locale', 'data');
1325
+ }
1326
+
1327
+ /** Draw with the current theme and locale: rebuild the scene, or re-theme the views. */
1328
+ #rethemed(): void {
1329
+ if (this.data) this.#rebuildScene();
1330
+ else for (const v of this.#views) v.sceneChanged();
1331
+ }
1332
+
1333
+ /**
1334
+ * @internal Read the theme again (`SessionDeps.theme`: the root's CSS tokens changed, e.g.
1335
+ * the host overrode one); redraws only when the result changed.
1336
+ */
1337
+ refreshTheme(): void {
1338
+ const next = this.#resolveTheme();
1339
+ if (next === this.#theme) return;
1340
+ this.#theme = next;
1341
+ this.#themeKey = `${this.themeName}/${this.palette}`;
1342
+ this.#rethemed();
1343
+ this.notify('theme');
1344
+ }
1345
+
1346
+ setThemePreference(pref: ThemePreference): void {
1347
+ this.#appearance(() => {
1348
+ this.themePreference = toTheme(pref) ?? 'auto';
1349
+ });
1350
+ this.notify('theme');
1351
+ }
1352
+
1353
+ setPalette(palette: PaletteName): void {
1354
+ this.#appearance(() => {
1355
+ this.palette = toPalette(palette) ?? 'default';
1356
+ });
1357
+ }
1358
+
1359
+ setLocale(locale: Locale): void {
1360
+ this.#appearance(() => {
1361
+ this.locale = toLocale(locale) ?? 'en';
1362
+ });
1363
+ }
1364
+
1365
+ setSystemDark(dark: boolean): void {
1366
+ this.#appearance(() => {
1367
+ this.systemDark = dark;
1368
+ });
1369
+ }
1370
+
1371
+ setReducedMotion(reduced: boolean): void {
1372
+ this.reducedMotion = reduced;
1373
+ this.invalidate();
1374
+ }
1375
+ }
1376
+
1377
+ function isString(x: string | undefined): x is string {
1378
+ return x !== undefined;
1379
+ }
1380
+
1381
+ /** Pad a small rect so a node is shown with its neighbourhood. */
1382
+ export function padRect(r: Rect, min = 200, ratio = 1.4): Rect {
1383
+ const w = Math.max(min, r.width * ratio);
1384
+ const h = Math.max(min, r.height * ratio);
1385
+ return { x: r.x + r.width / 2 - w / 2, y: r.y + r.height / 2 - h / 2, width: w, height: h };
1386
+ }