@kgdiff/viewer 0.2.0 → 0.4.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 (78) hide show
  1. package/README.md +48 -6
  2. package/dist/controller.d.ts +63 -14
  3. package/dist/controller.d.ts.map +1 -1
  4. package/dist/controller.js +308 -54
  5. package/dist/controller.js.map +1 -1
  6. package/dist/element.d.ts +17 -4
  7. package/dist/element.d.ts.map +1 -1
  8. package/dist/element.js +199 -26
  9. package/dist/element.js.map +1 -1
  10. package/dist/framing.d.ts +20 -1
  11. package/dist/framing.d.ts.map +1 -1
  12. package/dist/framing.js +49 -0
  13. package/dist/framing.js.map +1 -1
  14. package/dist/hash-sync.d.ts +4 -2
  15. package/dist/hash-sync.d.ts.map +1 -1
  16. package/dist/hash-sync.js +21 -5
  17. package/dist/hash-sync.js.map +1 -1
  18. package/dist/i18n.d.ts +7 -7
  19. package/dist/i18n.d.ts.map +1 -1
  20. package/dist/i18n.js +14 -14
  21. package/dist/i18n.js.map +1 -1
  22. package/dist/index.d.ts +66 -6
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/keyboard.d.ts +6 -4
  26. package/dist/keyboard.d.ts.map +1 -1
  27. package/dist/keyboard.js +6 -5
  28. package/dist/keyboard.js.map +1 -1
  29. package/dist/prefs.d.ts +13 -1
  30. package/dist/prefs.d.ts.map +1 -1
  31. package/dist/prefs.js +30 -3
  32. package/dist/prefs.js.map +1 -1
  33. package/dist/standalone/kgdiff-viewer.js +4 -4
  34. package/dist/styles.d.ts.map +1 -1
  35. package/dist/styles.js +84 -61
  36. package/dist/styles.js.map +1 -1
  37. package/dist/ui/context.d.ts +2 -0
  38. package/dist/ui/context.d.ts.map +1 -1
  39. package/dist/ui/context.js.map +1 -1
  40. package/dist/ui/dock.d.ts +17 -0
  41. package/dist/ui/dock.d.ts.map +1 -0
  42. package/dist/ui/dock.js +151 -0
  43. package/dist/ui/dock.js.map +1 -0
  44. package/dist/ui/header.d.ts +7 -2
  45. package/dist/ui/header.d.ts.map +1 -1
  46. package/dist/ui/header.js +24 -133
  47. package/dist/ui/header.js.map +1 -1
  48. package/dist/ui/popovers.d.ts.map +1 -1
  49. package/dist/ui/popovers.js +3 -2
  50. package/dist/ui/popovers.js.map +1 -1
  51. package/dist/ui/sidebar.d.ts.map +1 -1
  52. package/dist/ui/sidebar.js +2 -1
  53. package/dist/ui/sidebar.js.map +1 -1
  54. package/dist/ui/stage.d.ts +8 -0
  55. package/dist/ui/stage.d.ts.map +1 -1
  56. package/dist/ui/stage.js +66 -93
  57. package/dist/ui/stage.js.map +1 -1
  58. package/dist/ui/swatch.d.ts +7 -0
  59. package/dist/ui/swatch.d.ts.map +1 -0
  60. package/dist/ui/swatch.js +26 -0
  61. package/dist/ui/swatch.js.map +1 -0
  62. package/package.json +6 -6
  63. package/src/controller.ts +332 -56
  64. package/src/element.ts +220 -29
  65. package/src/framing.ts +65 -1
  66. package/src/hash-sync.ts +20 -6
  67. package/src/i18n.ts +15 -14
  68. package/src/index.ts +65 -7
  69. package/src/keyboard.ts +12 -6
  70. package/src/prefs.ts +45 -5
  71. package/src/styles.ts +84 -61
  72. package/src/ui/context.ts +2 -0
  73. package/src/ui/dock.ts +198 -0
  74. package/src/ui/header.ts +38 -168
  75. package/src/ui/popovers.ts +3 -2
  76. package/src/ui/sidebar.ts +2 -1
  77. package/src/ui/stage.ts +88 -112
  78. package/src/ui/swatch.ts +31 -0
package/src/controller.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  * Viewer state and orchestration, independent of the UI:
3
3
  *
4
4
  * - load pipeline with progress steps (sources or a precomputed report);
5
- * - mode, selection (with an animated spotlight `emphasis`), family filters, review progress;
6
- * - changes-only focus with expandable collapsed groups (`extendLayout` + scene rebuild);
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);
7
8
  * - replay clock driven by one requestAnimationFrame loop that sleeps when idle, with a camera
8
9
  * that follows the playing hunk / chapter unless the user moved it in the last 2 s;
9
10
  * - swipe position, hover, theme / palette / locale (rebuilding the scene when they change).
@@ -13,7 +14,14 @@
13
14
  import type { DiffResult, DiffStats, Locale, Rect, UnionId } from '@kgdiff/core';
14
15
  import { KgdiffError } from '@kgdiff/core';
15
16
  import type { LayoutResult } from '@kgdiff/layout';
16
- import type { Camera, CameraState, Minimap, PickResult, Renderer } from '@kgdiff/renderer';
17
+ import {
18
+ type Camera,
19
+ type CameraState,
20
+ labelFootprint,
21
+ type Minimap,
22
+ type PickResult,
23
+ type Renderer,
24
+ } from '@kgdiff/renderer';
17
25
  import {
18
26
  buildScene,
19
27
  type ChangeFamily,
@@ -40,6 +48,7 @@ import {
40
48
  freeView,
41
49
  type Insets,
42
50
  NO_INSETS,
51
+ zoomFit,
43
52
  } from './framing.js';
44
53
  import { translate } from './i18n.js';
45
54
  import type {
@@ -48,10 +57,11 @@ import type {
48
57
  ReviewState,
49
58
  ThemePreference,
50
59
  ViewerMode,
60
+ ViewerScope,
51
61
  } from './index.js';
52
62
  import { buildList, EMPTY_LIST, type ListModel, stepHunk } from './list-model.js';
53
63
  import { type LoadedDiff, type LoadStep, readPayload, runLoad } from './pipeline.js';
54
- import { toLocale, toMode, toPalette, toTheme } from './prefs.js';
64
+ import { LEGACY_CHANGES_MODE, toLocale, toMode, toPalette, toScope, toTheme } from './prefs.js';
55
65
  import { renderBackend } from './renderer-port.js';
56
66
  import { ReplayClock } from './replay.js';
57
67
  import { ReviewStore, reviewKey, type StorageLike } from './review-store.js';
@@ -71,15 +81,24 @@ export type Topic =
71
81
  | 'theme'
72
82
  | 'locale'
73
83
  | 'focus'
74
- | 'swipe';
84
+ | 'swipe'
85
+ | 'overview';
75
86
 
76
87
  export interface ControllerHost {
77
88
  ready(stats: DiffStats): void;
78
89
  select(hunk: string | null, node: string | null): void;
79
- mode(mode: ViewerMode): void;
90
+ mode(mode: ViewerMode, scope: ViewerScope): void;
80
91
  review(hunk: string, reviewed: boolean, progress: ReviewState): void;
81
92
  error(error: Error): void;
82
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
+ }
97
+
98
+ export interface FitOptions {
99
+ readonly animate?: boolean;
100
+ /** Room around the content, px (default `FRAME_PADDING`); kept for later automatic fits. */
101
+ readonly padding?: number;
83
102
  }
84
103
 
85
104
  export interface ControllerDeps {
@@ -91,7 +110,7 @@ export interface ControllerDeps {
91
110
  readonly storage: StorageLike | null;
92
111
  }
93
112
 
94
- export type Preference = 'mode' | 'theme' | 'palette' | 'locale';
113
+ export type Preference = 'mode' | 'scope' | 'theme' | 'palette' | 'locale';
95
114
 
96
115
  /** Camera follow is suppressed this long after the user moved the camera (ms). */
97
116
  export const USER_CAMERA_GRACE = 2000;
@@ -105,6 +124,8 @@ export const FLY_MAX_ZOOM = 1.8;
105
124
  export const FIT_MAX_ZOOM = 2.5;
106
125
  /** Minimap marks are re-rasterized at most this often while replaying (ms). */
107
126
  const MINIMAP_INTERVAL = 200;
127
+ /** Everything counts as in view (the minimap hides) from this share of the content bounds on. */
128
+ export const OVERVIEW_COVERAGE = 0.98;
108
129
 
109
130
  class Superseded extends Error {}
110
131
 
@@ -153,6 +174,7 @@ export class ViewerController {
153
174
  list: ListModel = EMPTY_LIST;
154
175
 
155
176
  mode: ViewerMode = 'overlay';
177
+ scope: ViewerScope = 'all';
156
178
  themePreference: ThemePreference = 'auto';
157
179
  palette: PaletteName = 'default';
158
180
  locale: Locale = 'en';
@@ -175,6 +197,16 @@ export class ViewerController {
175
197
  clock: ReplayClock | null = null;
176
198
  split = 0.5;
177
199
  emphasis = 1;
200
+ /**
201
+ * Keep the view fitted (embeds without controls): fit again when the canvas is resized unless
202
+ * the user moved the camera since, and at the start of every loop.
203
+ */
204
+ autoFit = false;
205
+ /** Canvas clicks select (and double clicks fly); off for embeds without controls. */
206
+ selectOnClick = true;
207
+ #labelsInside = false;
208
+ /** The whole content is in view (the minimap has nothing to add). */
209
+ overview = true;
178
210
 
179
211
  #review: ReviewStore | null = null;
180
212
  #searchIndex: SearchIndex | null = null;
@@ -193,11 +225,26 @@ export class ViewerController {
193
225
  #followed = '';
194
226
  #loadToken = 0;
195
227
  #pendingHunk: { readonly id: string; readonly fly: boolean } | null = null;
228
+ #boundsCache: {
229
+ readonly scene: Scene;
230
+ readonly focus: FocusState | null;
231
+ readonly rect: Rect | null;
232
+ } | null = null;
196
233
  #chrome: (() => ChromeLayout) | null = null;
197
234
  /** Camera kept across detach / attach (element moved in the DOM). */
198
235
  #savedCamera: CameraState | null = null;
199
236
  /** A new diff is installed but has not been fitted to a laid-out canvas yet. */
200
237
  #needsFit = false;
238
+ /** The user panned or zoomed since the last fit. */
239
+ #userMoved = false;
240
+ #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;
201
248
  readonly #listeners = new Set<(topics: ReadonlySet<Topic>) => void>();
202
249
  #pendingTopics = new Set<Topic>();
203
250
  #flushQueued = false;
@@ -325,6 +372,8 @@ export class ViewerController {
325
372
  fail(error: Error): void {
326
373
  this.#loadToken++;
327
374
  this.clock?.pause();
375
+ this.#restartAt = Number.NaN;
376
+ this.#resume = false;
328
377
  this.status = 'error';
329
378
  this.error = error;
330
379
  this.step = null;
@@ -366,10 +415,14 @@ export class ViewerController {
366
415
  if (view.locale && !this.explicit.has('locale')) this.locale = view.locale;
367
416
  }
368
417
  const previousMode = this.mode;
369
- if (!this.explicit.has('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')) {
370
424
  const threshold = loaded.layoutOptions?.largeGraphThreshold ?? 3000;
371
- const large = layout.scope === 'changes' || diff.nodes.length > threshold;
372
- this.mode = view?.mode ?? (large ? 'changes' : 'overlay');
425
+ this.scope = view?.scope ?? (diff.nodes.length > threshold ? 'changes' : 'all');
373
426
  }
374
427
  this.#review = new ReviewStore(
375
428
  this.#deps.storage,
@@ -398,6 +451,7 @@ export class ViewerController {
398
451
  'locale',
399
452
  'focus',
400
453
  'swipe',
454
+ 'overview',
401
455
  );
402
456
  // A new diff starts fitted, after the UI has shown its chrome (the notification above is
403
457
  // delivered first) and once the canvas has a size (see `layoutChanged`).
@@ -405,10 +459,13 @@ export class ViewerController {
405
459
  this.#savedCamera = null;
406
460
  queueMicrotask(() => this.layoutChanged());
407
461
  this.#host.ready(diff.stats);
408
- if (this.mode !== previousMode) this.#host.mode(this.mode);
462
+ if (this.mode !== previousMode || this.scope !== previousScope) {
463
+ this.#host.mode(this.mode, this.scope);
464
+ }
409
465
  const pending = this.#pendingHunk;
410
466
  this.#pendingHunk = null;
411
467
  if (pending !== null) this.selectHunkById(pending.id, { fly: pending.fly });
468
+ else this.#autoplay();
412
469
  }
413
470
 
414
471
  /** (Re)build scene, resolver, list, clock and focus; selection is carried over by id. */
@@ -445,7 +502,7 @@ export class ViewerController {
445
502
 
446
503
  #updateFocus(): void {
447
504
  const scene = this.scene;
448
- if (!scene || this.mode !== 'changes') {
505
+ if (!scene || this.scope !== 'changes') {
449
506
  this.focus = null;
450
507
  return;
451
508
  }
@@ -462,7 +519,10 @@ export class ViewerController {
462
519
  attach(stage: HTMLElement, minimapHost: HTMLElement | null): void {
463
520
  if (this.#renderer) return;
464
521
  const backend = renderBackend();
465
- const renderer = backend.createRenderer(stage, { theme: this.theme });
522
+ const renderer = backend.createRenderer(stage, {
523
+ theme: this.theme,
524
+ labelsInside: this.#labelsInside,
525
+ });
466
526
  this.#renderer = renderer;
467
527
  // Re-attached (element moved): keep the view; this also stops the renderer's auto-fit.
468
528
  if (this.#savedCamera) renderer.camera.set(this.#savedCamera);
@@ -473,6 +533,7 @@ export class ViewerController {
473
533
  renderer.on('dblclick', ({ pick }) => this.#onCanvasDblClick(pick)),
474
534
  renderer.on('camera', ({ source }) => {
475
535
  if (source === 'user') this.noteUserCamera();
536
+ this.#updateOverview();
476
537
  }),
477
538
  );
478
539
  this.#minimapDirty = true;
@@ -509,9 +570,26 @@ export class ViewerController {
509
570
  this.#chrome = provider;
510
571
  }
511
572
 
512
- /** The canvas was laid out or resized: run the initial fit a new diff is waiting for. */
573
+ /**
574
+ * Keep labels whole inside the canvas (embeds without camera controls): a label pushed from
575
+ * its spot by a neighbour moves inside or is left out, never cut at the edge.
576
+ */
577
+ setLabelsInside(inside: boolean): void {
578
+ this.#labelsInside = inside;
579
+ this.#renderer?.setLabelsInside(inside);
580
+ }
581
+
582
+ /**
583
+ * The canvas was laid out or resized: run the initial fit a new diff is waiting for, or (with
584
+ * `autoFit`) fit again unless the user moved the camera since the last fit.
585
+ */
513
586
  layoutChanged(): void {
514
- if (!this.#needsFit || this.status !== 'ready') return;
587
+ this.#updateOverview();
588
+ if (this.status !== 'ready') return;
589
+ if (!this.#needsFit) {
590
+ if (this.autoFit && !this.#userMoved) void this.fit({ animate: false });
591
+ return;
592
+ }
515
593
  // A selection made before the first layout (deep link, `kgdiff-ready` handler) wins.
516
594
  const target = this.#selectionBounds();
517
595
  if (target) {
@@ -520,17 +598,16 @@ export class ViewerController {
520
598
  this.#flyTo(target, { animate: false });
521
599
  return;
522
600
  }
523
- const bounds = this.#contentBounds();
524
- const padding = bounds ? this.#frameInsets(bounds, FIT_MAX_ZOOM) : null;
601
+ const fit = this.#fitTarget();
525
602
  const camera = this.#renderer?.camera;
526
- if (!bounds || !padding || !camera) return;
527
- this.#needsFit = false;
528
- void camera.fit(bounds, { animate: false, padding, maxZoom: FIT_MAX_ZOOM });
603
+ if (!fit || !camera) return;
604
+ void this.#fitTo(camera, fit, { animate: false });
529
605
  }
530
606
 
531
607
  /** The user panned / zoomed: suspend replay camera follow for `USER_CAMERA_GRACE`. */
532
608
  noteUserCamera(): void {
533
609
  this.#lastUserCamera = this.#deps.now();
610
+ this.#userMoved = true;
534
611
  }
535
612
 
536
613
  // ─── Frame loop ───────────────────────────────────────────────────────────
@@ -559,7 +636,11 @@ export class ViewerController {
559
636
  this.#notify('replay');
560
637
  this.#follow();
561
638
  if (clock.playing) animating = true;
562
- else if (clock.finished) this.#host.announce(translate(this.locale, 'announce.finished'));
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;
563
644
  }
564
645
  if (this.#emphasisAnim) {
565
646
  const a = this.#emphasisAnim;
@@ -659,24 +740,74 @@ export class ViewerController {
659
740
  };
660
741
  }
661
742
 
662
- // ─── Mode ─────────────────────────────────────────────────────────────────
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
+ }
663
749
 
664
- setMode(value: ViewerMode): void {
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
+ }
665
756
  const mode = toMode(value);
666
- if (mode === null || mode === this.mode) return;
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;
667
781
  this.mode = mode;
668
- if (mode !== 'replay') this.clock?.pause();
782
+ this.scope = nextScope;
783
+ if (mode !== 'replay') {
784
+ this.clock?.pause();
785
+ this.#restartAt = Number.NaN;
786
+ this.#resume = false;
787
+ }
669
788
  this.#followed = '';
670
- this.#updateFocus();
789
+ if (scopeChanged) {
790
+ this.#updateFocus();
791
+ this.#updateOverview();
792
+ }
671
793
  this.#minimapDirty = true;
672
794
  this.#notify('mode', 'focus', 'replay');
673
795
  this.invalidate();
674
796
  if (this.status === 'ready') {
675
- this.#host.mode(mode);
676
- this.#host.announce(
677
- translate(this.locale, 'announce.mode', { mode: translate(this.locale, `mode.${mode}`) }),
678
- );
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(' · '));
679
808
  }
809
+ // Switching to replay starts a looping replay; toggling the scope leaves playback alone.
810
+ if (modeChanged && mode === 'replay') this.#autoplay();
680
811
  }
681
812
 
682
813
  // ─── Selection ────────────────────────────────────────────────────────────
@@ -719,6 +850,8 @@ export class ViewerController {
719
850
  if (this.mode === 'replay' && this.clock) {
720
851
  const end = this.clock.windowEnd(h);
721
852
  this.clock.pause();
853
+ this.#restartAt = Number.NaN;
854
+ this.#resume = false;
722
855
  if (end !== null) this.clock.seek(end);
723
856
  this.#followed = '';
724
857
  this.#notify('replay');
@@ -933,41 +1066,86 @@ export class ViewerController {
933
1066
  }
934
1067
 
935
1068
  /**
936
- * Fit everything visible (the focus in changes-only mode) into the free part of the canvas.
937
- * Before the canvas is laid out it falls back to the renderer's own fit.
1069
+ * Fit everything visible (the focus in changes-only mode), labels included, into the free part
1070
+ * of the canvas. Before the canvas is laid out it falls back to the renderer's own fit.
938
1071
  */
939
- fit(options: { readonly animate?: boolean } = {}): Promise<void> {
1072
+ fit(options: FitOptions = {}): Promise<void> {
940
1073
  const camera = this.#renderer?.camera;
941
1074
  if (!camera) return Promise.resolve();
942
- const bounds = this.#contentBounds();
943
- const padding = bounds ? this.#frameInsets(bounds, FIT_MAX_ZOOM) : null;
944
- if (!bounds || !padding) return camera.fit(undefined, options);
1075
+ const { padding, ...transition } = options;
1076
+ if (padding !== undefined) {
1077
+ this.#fitPadding = Number.isFinite(padding) && padding >= 0 ? padding : FRAME_PADDING;
1078
+ }
1079
+ const fit = this.#fitTarget();
1080
+ if (!fit) return camera.fit(undefined, transition);
1081
+ return this.#fitTo(camera, fit, transition);
1082
+ }
1083
+
1084
+ #fitTo(
1085
+ camera: Camera,
1086
+ fit: { readonly bounds: Rect; readonly insets: Insets },
1087
+ transition: { readonly animate?: boolean },
1088
+ ): Promise<void> {
945
1089
  this.#needsFit = false;
946
- return camera.fit(bounds, { ...options, padding, maxZoom: FIT_MAX_ZOOM });
1090
+ this.#userMoved = false;
1091
+ return camera.fit(fit.bounds, { ...transition, padding: fit.insets, maxZoom: FIT_MAX_ZOOM });
947
1092
  }
948
1093
 
949
- /** Bounds of what is drawn: the scene, or the visible part of the changes-only focus. */
1094
+ /**
1095
+ * Bounds and insets that show everything visible (the changes-only focus) with every label
1096
+ * in its spot below its node, clear of the chrome, or null before layout. Measured in the
1097
+ * union layer, which holds every state a replay or swipe passes through. Insets depend on
1098
+ * what is framed, which depends on the zoom they allow: they are settled once more.
1099
+ */
1100
+ #fitTarget(): { bounds: Rect; insets: Insets } | null {
1101
+ const layout = this.#chrome?.();
1102
+ const resolver = this.resolver;
1103
+ if (!layout || !resolver || !(layout.width > 0) || !(layout.height > 0)) return null;
1104
+ const frame = resolver.resolve(
1105
+ createViewState({
1106
+ layer: 'union',
1107
+ focus: this.focus,
1108
+ families: this.families,
1109
+ reducedMotion: this.reducedMotion,
1110
+ }),
1111
+ );
1112
+ const footprint = labelFootprint(frame, this.theme);
1113
+ const extent = (k: number): Rect | null => footprint.at(k);
1114
+ const discs = extent(Number.POSITIVE_INFINITY);
1115
+ if (!discs) return null;
1116
+ const { width, height } = layout;
1117
+ const padding = this.#fitPadding;
1118
+ let insets = chromeInsets(discs, layout, padding, FIT_MAX_ZOOM);
1119
+ let fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM);
1120
+ if (!fit) return null;
1121
+ const settled = chromeInsets(fit.content, layout, padding, FIT_MAX_ZOOM);
1122
+ if (!sameInsets(settled, insets)) {
1123
+ insets = settled;
1124
+ fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM) ?? fit;
1125
+ }
1126
+ return { bounds: fit.bounds, insets };
1127
+ }
1128
+
1129
+ /** Bounds of what is drawn: the scene, or the visible part of the `changes` scope's focus. */
950
1130
  #contentBounds(): Rect | null {
951
1131
  const scene = this.scene;
952
1132
  if (!scene) return null;
953
1133
  const focus = this.focus;
954
- if (!focus) return frameBounds(scene);
955
- let x0 = Number.POSITIVE_INFINITY;
956
- let y0 = Number.POSITIVE_INFINITY;
957
- let x1 = Number.NEGATIVE_INFINITY;
958
- let y1 = Number.NEGATIVE_INFINITY;
959
- const add = (x: number, y: number, rx: number, top: number, bottom: number): void => {
960
- x0 = Math.min(x0, x - rx);
961
- x1 = Math.max(x1, x + rx);
962
- y0 = Math.min(y0, y - top);
963
- y1 = Math.max(y1, y + bottom);
964
- };
965
- // Labels sit centered below their node: leave room for a typical one.
966
- for (const n of scene.nodes)
967
- if (focus.nodes[n.index] === 1) add(n.x, n.y, n.r + 48, n.r, n.r + 24);
968
- for (const g of focus.collapsed) add(g.x, g.y, 48, 12, 12);
969
- if (!(x0 <= x1)) return frameBounds(scene);
970
- return padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }, 160, 1.12);
1134
+ const cached = this.#boundsCache;
1135
+ if (cached && cached.scene === scene && cached.focus === focus) return cached.rect;
1136
+ const rect = focus ? focusBounds(scene, focus) : frameBounds(scene);
1137
+ this.#boundsCache = { scene, focus, rect };
1138
+ return rect;
1139
+ }
1140
+
1141
+ /** Whether everything drawn is in view; the minimap hides while it is. */
1142
+ #updateOverview(): void {
1143
+ const bounds = this.#contentBounds();
1144
+ const view = this.#renderer?.camera.viewport();
1145
+ const next = !bounds || !view || coverage(bounds, view) >= OVERVIEW_COVERAGE;
1146
+ if (next === this.overview) return;
1147
+ this.overview = next;
1148
+ this.#notify('overview');
971
1149
  }
972
1150
 
973
1151
  zoom(direction: 1 | -1): void {
@@ -1014,6 +1192,7 @@ export class ViewerController {
1014
1192
  }
1015
1193
 
1016
1194
  #onCanvasClick(pick: PickResult | null): void {
1195
+ if (!this.selectOnClick) return;
1017
1196
  if (!pick) {
1018
1197
  this.clearSelection();
1019
1198
  return;
@@ -1038,7 +1217,7 @@ export class ViewerController {
1038
1217
  }
1039
1218
 
1040
1219
  #onCanvasDblClick(pick: PickResult | null): void {
1041
- if (pick?.type !== 'node') return;
1220
+ if (!this.selectOnClick || pick?.type !== 'node') return;
1042
1221
  const n = this.scene?.nodes[pick.index];
1043
1222
  if (!n) return;
1044
1223
  if (n.hunk >= 0) this.flyToHunk(n.hunk);
@@ -1074,6 +1253,7 @@ export class ViewerController {
1074
1253
  if (h === this.hops) return;
1075
1254
  this.hops = h;
1076
1255
  this.#updateFocus();
1256
+ this.#updateOverview();
1077
1257
  this.#minimapDirty = true;
1078
1258
  this.#notify('focus');
1079
1259
  this.invalidate();
@@ -1200,6 +1380,7 @@ export class ViewerController {
1200
1380
 
1201
1381
  #focusChanged(): void {
1202
1382
  this.#updateFocus();
1383
+ this.#updateOverview();
1203
1384
  this.#minimapDirty = true;
1204
1385
  this.#notify('focus');
1205
1386
  this.invalidate();
@@ -1230,6 +1411,8 @@ export class ViewerController {
1230
1411
  const clock = this.clock;
1231
1412
  if (!clock || this.status !== 'ready') return;
1232
1413
  if (this.mode !== 'replay') this.setMode('replay');
1414
+ this.#restartAt = Number.NaN;
1415
+ this.#resume = false;
1233
1416
  clock.play();
1234
1417
  this.#followed = '';
1235
1418
  this.#lastTick = 0;
@@ -1237,7 +1420,10 @@ export class ViewerController {
1237
1420
  this.invalidate();
1238
1421
  }
1239
1422
 
1423
+ /** Pause; a looping replay stays paused (until `play`, a new diff or loop turned on again). */
1240
1424
  pause(): void {
1425
+ this.#restartAt = Number.NaN;
1426
+ this.#resume = false;
1241
1427
  if (!this.clock?.playing) return;
1242
1428
  this.clock.pause();
1243
1429
  this.#notify('replay');
@@ -1249,9 +1435,73 @@ export class ViewerController {
1249
1435
  else this.play();
1250
1436
  }
1251
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
+
1252
1501
  seek(t: number): void {
1253
1502
  const clock = this.clock;
1254
1503
  if (!clock) return;
1504
+ this.#restartAt = Number.NaN;
1255
1505
  clock.seek(t);
1256
1506
  this.#notify('replay');
1257
1507
  this.#follow();
@@ -1262,6 +1512,8 @@ export class ViewerController {
1262
1512
  const clock = this.clock;
1263
1513
  if (!clock) return;
1264
1514
  clock.step(direction);
1515
+ this.#restartAt = Number.NaN;
1516
+ this.#resume = false;
1265
1517
  this.#followed = '';
1266
1518
  // Follow (and announce) the hunk that just completed: stops sit at window ends.
1267
1519
  const w = clock.timeline.windows[clock.position() - 1];
@@ -1385,6 +1637,30 @@ function isString(x: string | undefined): x is string {
1385
1637
  return x !== undefined;
1386
1638
  }
1387
1639
 
1640
+ function sameInsets(a: Insets, b: Insets): boolean {
1641
+ return a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left;
1642
+ }
1643
+
1644
+ /** Bounds of the visible part of `focus`, with room for labels and the collapsed pills. */
1645
+ function focusBounds(scene: Scene, focus: FocusState): Rect {
1646
+ let x0 = Number.POSITIVE_INFINITY;
1647
+ let y0 = Number.POSITIVE_INFINITY;
1648
+ let x1 = Number.NEGATIVE_INFINITY;
1649
+ let y1 = Number.NEGATIVE_INFINITY;
1650
+ const add = (x: number, y: number, rx: number, top: number, bottom: number): void => {
1651
+ x0 = Math.min(x0, x - rx);
1652
+ x1 = Math.max(x1, x + rx);
1653
+ y0 = Math.min(y0, y - top);
1654
+ y1 = Math.max(y1, y + bottom);
1655
+ };
1656
+ // Labels sit centered below their node: leave room for a typical one.
1657
+ for (const n of scene.nodes)
1658
+ if (focus.nodes[n.index] === 1) add(n.x, n.y, n.r + 48, n.r, n.r + 24);
1659
+ for (const g of focus.collapsed) add(g.x, g.y, 48, 12, 12);
1660
+ if (!(x0 <= x1)) return frameBounds(scene);
1661
+ return padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }, 160, 1.12);
1662
+ }
1663
+
1388
1664
  /** Pad a small rect so a node is shown with its neighbourhood. */
1389
1665
  function padRect(r: Rect, min = 200, ratio = 1.4): Rect {
1390
1666
  const w = Math.max(min, r.width * ratio);