@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
@@ -1,11 +1,12 @@
1
1
  import { KgdiffError } from '@kgdiff/core';
2
+ import { labelFootprint, } from '@kgdiff/renderer';
2
3
  import { buildScene, computeFocus, createViewState, createVisualResolver, frameBounds, getTheme, } from '@kgdiff/scene';
3
4
  import { computeNow, computeStatus, loadCompute } from './compute-loader.js';
4
- import { chromeInsets, FRAME_PADDING, freeView, NO_INSETS, } from './framing.js';
5
+ import { chromeInsets, FRAME_PADDING, freeView, NO_INSETS, zoomFit, } from './framing.js';
5
6
  import { translate } from './i18n.js';
6
7
  import { buildList, EMPTY_LIST, stepHunk } from './list-model.js';
7
8
  import { readPayload, runLoad } from './pipeline.js';
8
- import { toLocale, toMode, toPalette, toTheme } from './prefs.js';
9
+ import { LEGACY_CHANGES_MODE, toLocale, toMode, toPalette, toScope, toTheme } from './prefs.js';
9
10
  import { renderBackend } from './renderer-port.js';
10
11
  import { ReplayClock } from './replay.js';
11
12
  import { ReviewStore, reviewKey } from './review-store.js';
@@ -22,6 +23,8 @@ export const FLY_MAX_ZOOM = 1.8;
22
23
  export const FIT_MAX_ZOOM = 2.5;
23
24
  /** Minimap marks are re-rasterized at most this often while replaying (ms). */
24
25
  const MINIMAP_INTERVAL = 200;
26
+ /** Everything counts as in view (the minimap hides) from this share of the content bounds on. */
27
+ export const OVERVIEW_COVERAGE = 0.98;
25
28
  class Superseded extends Error {
26
29
  }
27
30
  /** Share of `bounds` inside `view`, in [0, 1]. */
@@ -51,6 +54,7 @@ export class ViewerController {
51
54
  resolver = null;
52
55
  list = EMPTY_LIST;
53
56
  mode = 'overlay';
57
+ scope = 'all';
54
58
  themePreference = 'auto';
55
59
  palette = 'default';
56
60
  locale = 'en';
@@ -72,6 +76,16 @@ export class ViewerController {
72
76
  clock = null;
73
77
  split = 0.5;
74
78
  emphasis = 1;
79
+ /**
80
+ * Keep the view fitted (embeds without controls): fit again when the canvas is resized unless
81
+ * the user moved the camera since, and at the start of every loop.
82
+ */
83
+ autoFit = false;
84
+ /** Canvas clicks select (and double clicks fly); off for embeds without controls. */
85
+ selectOnClick = true;
86
+ #labelsInside = false;
87
+ /** The whole content is in view (the minimap has nothing to add). */
88
+ overview = true;
75
89
  #review = null;
76
90
  #searchIndex = null;
77
91
  #renderer = null;
@@ -89,11 +103,22 @@ export class ViewerController {
89
103
  #followed = '';
90
104
  #loadToken = 0;
91
105
  #pendingHunk = null;
106
+ #boundsCache = null;
92
107
  #chrome = null;
93
108
  /** Camera kept across detach / attach (element moved in the DOM). */
94
109
  #savedCamera = null;
95
110
  /** A new diff is installed but has not been fitted to a laid-out canvas yet. */
96
111
  #needsFit = false;
112
+ /** The user panned or zoomed since the last fit. */
113
+ #userMoved = false;
114
+ #fitPadding = FRAME_PADDING;
115
+ /** A finished replay starts again this long (ms) after its end; null: it plays once. */
116
+ #loopDelay = null;
117
+ /** When a finished looping replay starts again (`deps.now()` time), else NaN. */
118
+ #restartAt = Number.NaN;
119
+ /** Playback is held (pointer over the graph); `#resume`: it was playing and continues after. */
120
+ #held = false;
121
+ #resume = false;
97
122
  #listeners = new Set();
98
123
  #pendingTopics = new Set();
99
124
  #flushQueued = false;
@@ -206,6 +231,8 @@ export class ViewerController {
206
231
  fail(error) {
207
232
  this.#loadToken++;
208
233
  this.clock?.pause();
234
+ this.#restartAt = Number.NaN;
235
+ this.#resume = false;
209
236
  this.status = 'error';
210
237
  this.error = error;
211
238
  this.step = null;
@@ -249,10 +276,16 @@ export class ViewerController {
249
276
  this.locale = view.locale;
250
277
  }
251
278
  const previousMode = this.mode;
252
- if (!this.explicit.has('mode')) {
279
+ const previousScope = this.scope;
280
+ if (!this.explicit.has('mode'))
281
+ this.mode = view?.mode ?? 'overlay';
282
+ if (layout.scope === 'changes') {
283
+ // Only the changes are laid out: there is no whole graph to show.
284
+ this.scope = 'changes';
285
+ }
286
+ else if (!this.explicit.has('scope')) {
253
287
  const threshold = loaded.layoutOptions?.largeGraphThreshold ?? 3000;
254
- const large = layout.scope === 'changes' || diff.nodes.length > threshold;
255
- this.mode = view?.mode ?? (large ? 'changes' : 'overlay');
288
+ this.scope = view?.scope ?? (diff.nodes.length > threshold ? 'changes' : 'all');
256
289
  }
257
290
  this.#review = new ReviewStore(this.#deps.storage, reviewKey(diff.delta.base.hash, diff.delta.target.hash), diff.hunks.map((h) => h.id));
258
291
  this.#rebuildScene();
@@ -262,19 +295,22 @@ export class ViewerController {
262
295
  // Learn early whether out-of-layout groups can be expanded (standalone reports).
263
296
  loadCompute().then(() => this.#notify('focus'), () => this.#notify('focus'));
264
297
  }
265
- this.#notify('status', 'data', 'mode', 'selection', 'filters', 'review', 'replay', 'theme', 'locale', 'focus', 'swipe');
298
+ this.#notify('status', 'data', 'mode', 'selection', 'filters', 'review', 'replay', 'theme', 'locale', 'focus', 'swipe', 'overview');
266
299
  // A new diff starts fitted, after the UI has shown its chrome (the notification above is
267
300
  // delivered first) and once the canvas has a size (see `layoutChanged`).
268
301
  this.#needsFit = true;
269
302
  this.#savedCamera = null;
270
303
  queueMicrotask(() => this.layoutChanged());
271
304
  this.#host.ready(diff.stats);
272
- if (this.mode !== previousMode)
273
- this.#host.mode(this.mode);
305
+ if (this.mode !== previousMode || this.scope !== previousScope) {
306
+ this.#host.mode(this.mode, this.scope);
307
+ }
274
308
  const pending = this.#pendingHunk;
275
309
  this.#pendingHunk = null;
276
310
  if (pending !== null)
277
311
  this.selectHunkById(pending.id, { fly: pending.fly });
312
+ else
313
+ this.#autoplay();
278
314
  }
279
315
  /** (Re)build scene, resolver, list, clock and focus; selection is carried over by id. */
280
316
  #rebuildScene() {
@@ -311,7 +347,7 @@ export class ViewerController {
311
347
  }
312
348
  #updateFocus() {
313
349
  const scene = this.scene;
314
- if (!scene || this.mode !== 'changes') {
350
+ if (!scene || this.scope !== 'changes') {
315
351
  this.focus = null;
316
352
  return;
317
353
  }
@@ -328,7 +364,10 @@ export class ViewerController {
328
364
  if (this.#renderer)
329
365
  return;
330
366
  const backend = renderBackend();
331
- const renderer = backend.createRenderer(stage, { theme: this.theme });
367
+ const renderer = backend.createRenderer(stage, {
368
+ theme: this.theme,
369
+ labelsInside: this.#labelsInside,
370
+ });
332
371
  this.#renderer = renderer;
333
372
  // Re-attached (element moved): keep the view; this also stops the renderer's auto-fit.
334
373
  if (this.#savedCamera)
@@ -338,6 +377,7 @@ export class ViewerController {
338
377
  this.#unbind.push(renderer.on('hover', (pick) => this.#onHover(pick)), renderer.on('click', ({ pick }) => this.#onCanvasClick(pick)), renderer.on('dblclick', ({ pick }) => this.#onCanvasDblClick(pick)), renderer.on('camera', ({ source }) => {
339
378
  if (source === 'user')
340
379
  this.noteUserCamera();
380
+ this.#updateOverview();
341
381
  }));
342
382
  this.#minimapDirty = true;
343
383
  this.invalidate();
@@ -371,10 +411,27 @@ export class ViewerController {
371
411
  setChrome(provider) {
372
412
  this.#chrome = provider;
373
413
  }
374
- /** The canvas was laid out or resized: run the initial fit a new diff is waiting for. */
414
+ /**
415
+ * Keep labels whole inside the canvas (embeds without camera controls): a label pushed from
416
+ * its spot by a neighbour moves inside or is left out, never cut at the edge.
417
+ */
418
+ setLabelsInside(inside) {
419
+ this.#labelsInside = inside;
420
+ this.#renderer?.setLabelsInside(inside);
421
+ }
422
+ /**
423
+ * The canvas was laid out or resized: run the initial fit a new diff is waiting for, or (with
424
+ * `autoFit`) fit again unless the user moved the camera since the last fit.
425
+ */
375
426
  layoutChanged() {
376
- if (!this.#needsFit || this.status !== 'ready')
427
+ this.#updateOverview();
428
+ if (this.status !== 'ready')
429
+ return;
430
+ if (!this.#needsFit) {
431
+ if (this.autoFit && !this.#userMoved)
432
+ void this.fit({ animate: false });
377
433
  return;
434
+ }
378
435
  // A selection made before the first layout (deep link, `kgdiff-ready` handler) wins.
379
436
  const target = this.#selectionBounds();
380
437
  if (target) {
@@ -384,17 +441,16 @@ export class ViewerController {
384
441
  this.#flyTo(target, { animate: false });
385
442
  return;
386
443
  }
387
- const bounds = this.#contentBounds();
388
- const padding = bounds ? this.#frameInsets(bounds, FIT_MAX_ZOOM) : null;
444
+ const fit = this.#fitTarget();
389
445
  const camera = this.#renderer?.camera;
390
- if (!bounds || !padding || !camera)
446
+ if (!fit || !camera)
391
447
  return;
392
- this.#needsFit = false;
393
- void camera.fit(bounds, { animate: false, padding, maxZoom: FIT_MAX_ZOOM });
448
+ void this.#fitTo(camera, fit, { animate: false });
394
449
  }
395
450
  /** The user panned / zoomed: suspend replay camera follow for `USER_CAMERA_GRACE`. */
396
451
  noteUserCamera() {
397
452
  this.#lastUserCamera = this.#deps.now();
453
+ this.#userMoved = true;
398
454
  }
399
455
  // ─── Frame loop ───────────────────────────────────────────────────────────
400
456
  /** Redraw on the next animation frame. */
@@ -423,7 +479,13 @@ export class ViewerController {
423
479
  if (clock.playing)
424
480
  animating = true;
425
481
  else if (clock.finished)
426
- this.#host.announce(translate(this.locale, 'announce.finished'));
482
+ this.#ended();
483
+ }
484
+ if (!Number.isNaN(this.#restartAt) && !this.#held) {
485
+ if (now >= this.#restartAt)
486
+ this.#loopAgain();
487
+ else
488
+ animating = true;
427
489
  }
428
490
  if (this.#emphasisAnim) {
429
491
  const a = this.#emphasisAnim;
@@ -523,23 +585,71 @@ export class ViewerController {
523
585
  duration: this.theme.motion.base,
524
586
  };
525
587
  }
526
- // ─── Mode ─────────────────────────────────────────────────────────────────
588
+ // ─── View: mode × scope ───────────────────────────────────────────────────
589
+ /** Whether the scope is fixed to `changes` (the layout covers only the changes). */
590
+ get scopeLocked() {
591
+ return this.layout?.scope === 'changes';
592
+ }
593
+ /** Switch how the versions are compared. The legacy `changes` means overlay + `changes` scope. */
527
594
  setMode(value) {
595
+ if (value === LEGACY_CHANGES_MODE) {
596
+ this.setView('overlay', 'changes');
597
+ return;
598
+ }
528
599
  const mode = toMode(value);
529
- if (mode === null || mode === this.mode)
600
+ if (mode !== null)
601
+ this.setView(mode, this.scope);
602
+ }
603
+ /** Show the whole graph or only the changes, in any mode. */
604
+ setScope(value) {
605
+ const scope = toScope(value);
606
+ if (scope !== null)
607
+ this.setView(this.mode, scope);
608
+ }
609
+ /** Toggle the `changes` scope (key `4`); says why when the scope is locked. */
610
+ toggleScope() {
611
+ if (this.scopeLocked) {
612
+ this.#host.announce(translate(this.locale, 'scope.locked'));
613
+ return;
614
+ }
615
+ this.setScope(this.scope === 'changes' ? 'all' : 'changes');
616
+ }
617
+ /** Set mode and scope together (one notification, one `kgdiff-mode` event). */
618
+ setView(mode, scope) {
619
+ const nextScope = this.scopeLocked ? 'changes' : scope;
620
+ const modeChanged = mode !== this.mode;
621
+ const scopeChanged = nextScope !== this.scope;
622
+ if (!modeChanged && !scopeChanged)
530
623
  return;
531
624
  this.mode = mode;
532
- if (mode !== 'replay')
625
+ this.scope = nextScope;
626
+ if (mode !== 'replay') {
533
627
  this.clock?.pause();
628
+ this.#restartAt = Number.NaN;
629
+ this.#resume = false;
630
+ }
534
631
  this.#followed = '';
535
- this.#updateFocus();
632
+ if (scopeChanged) {
633
+ this.#updateFocus();
634
+ this.#updateOverview();
635
+ }
536
636
  this.#minimapDirty = true;
537
637
  this.#notify('mode', 'focus', 'replay');
538
638
  this.invalidate();
539
639
  if (this.status === 'ready') {
540
- this.#host.mode(mode);
541
- this.#host.announce(translate(this.locale, 'announce.mode', { mode: translate(this.locale, `mode.${mode}`) }));
640
+ this.#host.mode(mode, nextScope);
641
+ const parts = [];
642
+ if (modeChanged) {
643
+ parts.push(translate(this.locale, 'announce.mode', { mode: translate(this.locale, `mode.${mode}`) }));
644
+ }
645
+ if (scopeChanged) {
646
+ parts.push(translate(this.locale, nextScope === 'changes' ? 'scope.on' : 'scope.off'));
647
+ }
648
+ this.#host.announce(parts.join(' · '));
542
649
  }
650
+ // Switching to replay starts a looping replay; toggling the scope leaves playback alone.
651
+ if (modeChanged && mode === 'replay')
652
+ this.#autoplay();
543
653
  }
544
654
  // ─── Selection ────────────────────────────────────────────────────────────
545
655
  selectHunkById(id, options = {}) {
@@ -581,6 +691,8 @@ export class ViewerController {
581
691
  if (this.mode === 'replay' && this.clock) {
582
692
  const end = this.clock.windowEnd(h);
583
693
  this.clock.pause();
694
+ this.#restartAt = Number.NaN;
695
+ this.#resume = false;
584
696
  if (end !== null)
585
697
  this.clock.seek(end);
586
698
  this.#followed = '';
@@ -795,47 +907,84 @@ export class ViewerController {
795
907
  this.#flyTo(b);
796
908
  }
797
909
  /**
798
- * Fit everything visible (the focus in changes-only mode) into the free part of the canvas.
799
- * Before the canvas is laid out it falls back to the renderer's own fit.
910
+ * Fit everything visible (the focus in changes-only mode), labels included, into the free part
911
+ * of the canvas. Before the canvas is laid out it falls back to the renderer's own fit.
800
912
  */
801
913
  fit(options = {}) {
802
914
  const camera = this.#renderer?.camera;
803
915
  if (!camera)
804
916
  return Promise.resolve();
805
- const bounds = this.#contentBounds();
806
- const padding = bounds ? this.#frameInsets(bounds, FIT_MAX_ZOOM) : null;
807
- if (!bounds || !padding)
808
- return camera.fit(undefined, options);
917
+ const { padding, ...transition } = options;
918
+ if (padding !== undefined) {
919
+ this.#fitPadding = Number.isFinite(padding) && padding >= 0 ? padding : FRAME_PADDING;
920
+ }
921
+ const fit = this.#fitTarget();
922
+ if (!fit)
923
+ return camera.fit(undefined, transition);
924
+ return this.#fitTo(camera, fit, transition);
925
+ }
926
+ #fitTo(camera, fit, transition) {
809
927
  this.#needsFit = false;
810
- return camera.fit(bounds, { ...options, padding, maxZoom: FIT_MAX_ZOOM });
928
+ this.#userMoved = false;
929
+ return camera.fit(fit.bounds, { ...transition, padding: fit.insets, maxZoom: FIT_MAX_ZOOM });
930
+ }
931
+ /**
932
+ * Bounds and insets that show everything visible (the changes-only focus) with every label
933
+ * in its spot below its node, clear of the chrome, or null before layout. Measured in the
934
+ * union layer, which holds every state a replay or swipe passes through. Insets depend on
935
+ * what is framed, which depends on the zoom they allow: they are settled once more.
936
+ */
937
+ #fitTarget() {
938
+ const layout = this.#chrome?.();
939
+ const resolver = this.resolver;
940
+ if (!layout || !resolver || !(layout.width > 0) || !(layout.height > 0))
941
+ return null;
942
+ const frame = resolver.resolve(createViewState({
943
+ layer: 'union',
944
+ focus: this.focus,
945
+ families: this.families,
946
+ reducedMotion: this.reducedMotion,
947
+ }));
948
+ const footprint = labelFootprint(frame, this.theme);
949
+ const extent = (k) => footprint.at(k);
950
+ const discs = extent(Number.POSITIVE_INFINITY);
951
+ if (!discs)
952
+ return null;
953
+ const { width, height } = layout;
954
+ const padding = this.#fitPadding;
955
+ let insets = chromeInsets(discs, layout, padding, FIT_MAX_ZOOM);
956
+ let fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM);
957
+ if (!fit)
958
+ return null;
959
+ const settled = chromeInsets(fit.content, layout, padding, FIT_MAX_ZOOM);
960
+ if (!sameInsets(settled, insets)) {
961
+ insets = settled;
962
+ fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM) ?? fit;
963
+ }
964
+ return { bounds: fit.bounds, insets };
811
965
  }
812
- /** Bounds of what is drawn: the scene, or the visible part of the changes-only focus. */
966
+ /** Bounds of what is drawn: the scene, or the visible part of the `changes` scope's focus. */
813
967
  #contentBounds() {
814
968
  const scene = this.scene;
815
969
  if (!scene)
816
970
  return null;
817
971
  const focus = this.focus;
818
- if (!focus)
819
- return frameBounds(scene);
820
- let x0 = Number.POSITIVE_INFINITY;
821
- let y0 = Number.POSITIVE_INFINITY;
822
- let x1 = Number.NEGATIVE_INFINITY;
823
- let y1 = Number.NEGATIVE_INFINITY;
824
- const add = (x, y, rx, top, bottom) => {
825
- x0 = Math.min(x0, x - rx);
826
- x1 = Math.max(x1, x + rx);
827
- y0 = Math.min(y0, y - top);
828
- y1 = Math.max(y1, y + bottom);
829
- };
830
- // Labels sit centered below their node: leave room for a typical one.
831
- for (const n of scene.nodes)
832
- if (focus.nodes[n.index] === 1)
833
- add(n.x, n.y, n.r + 48, n.r, n.r + 24);
834
- for (const g of focus.collapsed)
835
- add(g.x, g.y, 48, 12, 12);
836
- if (!(x0 <= x1))
837
- return frameBounds(scene);
838
- return padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }, 160, 1.12);
972
+ const cached = this.#boundsCache;
973
+ if (cached && cached.scene === scene && cached.focus === focus)
974
+ return cached.rect;
975
+ const rect = focus ? focusBounds(scene, focus) : frameBounds(scene);
976
+ this.#boundsCache = { scene, focus, rect };
977
+ return rect;
978
+ }
979
+ /** Whether everything drawn is in view; the minimap hides while it is. */
980
+ #updateOverview() {
981
+ const bounds = this.#contentBounds();
982
+ const view = this.#renderer?.camera.viewport();
983
+ const next = !bounds || !view || coverage(bounds, view) >= OVERVIEW_COVERAGE;
984
+ if (next === this.overview)
985
+ return;
986
+ this.overview = next;
987
+ this.#notify('overview');
839
988
  }
840
989
  zoom(direction) {
841
990
  this.noteUserCamera();
@@ -878,6 +1027,8 @@ export class ViewerController {
878
1027
  this.#onHover(null);
879
1028
  }
880
1029
  #onCanvasClick(pick) {
1030
+ if (!this.selectOnClick)
1031
+ return;
881
1032
  if (!pick) {
882
1033
  this.clearSelection();
883
1034
  return;
@@ -902,7 +1053,7 @@ export class ViewerController {
902
1053
  }
903
1054
  }
904
1055
  #onCanvasDblClick(pick) {
905
- if (pick?.type !== 'node')
1056
+ if (!this.selectOnClick || pick?.type !== 'node')
906
1057
  return;
907
1058
  const n = this.scene?.nodes[pick.index];
908
1059
  if (!n)
@@ -942,6 +1093,7 @@ export class ViewerController {
942
1093
  return;
943
1094
  this.hops = h;
944
1095
  this.#updateFocus();
1096
+ this.#updateOverview();
945
1097
  this.#minimapDirty = true;
946
1098
  this.#notify('focus');
947
1099
  this.invalidate();
@@ -1071,6 +1223,7 @@ export class ViewerController {
1071
1223
  }
1072
1224
  #focusChanged() {
1073
1225
  this.#updateFocus();
1226
+ this.#updateOverview();
1074
1227
  this.#minimapDirty = true;
1075
1228
  this.#notify('focus');
1076
1229
  this.invalidate();
@@ -1101,13 +1254,18 @@ export class ViewerController {
1101
1254
  return;
1102
1255
  if (this.mode !== 'replay')
1103
1256
  this.setMode('replay');
1257
+ this.#restartAt = Number.NaN;
1258
+ this.#resume = false;
1104
1259
  clock.play();
1105
1260
  this.#followed = '';
1106
1261
  this.#lastTick = 0;
1107
1262
  this.#notify('replay');
1108
1263
  this.invalidate();
1109
1264
  }
1265
+ /** Pause; a looping replay stays paused (until `play`, a new diff or loop turned on again). */
1110
1266
  pause() {
1267
+ this.#restartAt = Number.NaN;
1268
+ this.#resume = false;
1111
1269
  if (!this.clock?.playing)
1112
1270
  return;
1113
1271
  this.clock.pause();
@@ -1120,10 +1278,79 @@ export class ViewerController {
1120
1278
  else
1121
1279
  this.play();
1122
1280
  }
1281
+ /**
1282
+ * Play the replay over and over: `delay` ms after it ends it starts again; null plays it
1283
+ * once. Turning the loop on starts a replay that is shown but not playing.
1284
+ */
1285
+ setLoop(delay) {
1286
+ const was = this.#loopDelay;
1287
+ this.#loopDelay = delay === null ? null : Number.isFinite(delay) ? Math.max(0, delay) : 0;
1288
+ if (this.#loopDelay === null)
1289
+ this.#restartAt = Number.NaN;
1290
+ else if (was === null)
1291
+ this.#autoplay();
1292
+ }
1293
+ get loopDelay() {
1294
+ return this.#loopDelay;
1295
+ }
1296
+ /**
1297
+ * Hold playback while the pointer rests on the graph (`pause-on-hover`): a playing replay
1298
+ * pauses and continues on release; a pending loop restart waits for the release, then the
1299
+ * full loop delay.
1300
+ */
1301
+ hold(held) {
1302
+ if (held === this.#held)
1303
+ return;
1304
+ this.#held = held;
1305
+ const clock = this.clock;
1306
+ if (held) {
1307
+ if (!clock?.playing)
1308
+ return;
1309
+ clock.pause();
1310
+ this.#resume = true;
1311
+ this.#notify('replay');
1312
+ this.invalidate();
1313
+ return;
1314
+ }
1315
+ if (!Number.isNaN(this.#restartAt) && this.#loopDelay !== null) {
1316
+ this.#restartAt = this.#deps.now() + this.#loopDelay;
1317
+ this.#schedule();
1318
+ }
1319
+ if (this.#resume)
1320
+ this.play();
1321
+ }
1322
+ /** Looping and showing a replay that is not playing: start it (or after the hold). */
1323
+ #autoplay() {
1324
+ if (this.#loopDelay === null || this.status !== 'ready' || this.mode !== 'replay')
1325
+ return;
1326
+ if (!this.clock || this.clock.playing || !Number.isNaN(this.#restartAt))
1327
+ return;
1328
+ if (this.#held)
1329
+ this.#resume = true;
1330
+ else
1331
+ this.play();
1332
+ }
1333
+ /** Playback reached the end: announce it, tell the host and arm the loop restart. */
1334
+ #ended() {
1335
+ this.#host.announce(translate(this.locale, 'announce.finished'));
1336
+ const loop = this.#loopDelay !== null;
1337
+ // Armed first, so a `kgdiff-replay-end` listener can still cancel it (pause, loop off).
1338
+ if (loop)
1339
+ this.#restartAt = this.#deps.now() + (this.#loopDelay ?? 0);
1340
+ this.#host.replayEnd(loop);
1341
+ }
1342
+ #loopAgain() {
1343
+ this.#restartAt = Number.NaN;
1344
+ if (this.autoFit)
1345
+ void this.fit();
1346
+ this.clock?.seek(0);
1347
+ this.play();
1348
+ }
1123
1349
  seek(t) {
1124
1350
  const clock = this.clock;
1125
1351
  if (!clock)
1126
1352
  return;
1353
+ this.#restartAt = Number.NaN;
1127
1354
  clock.seek(t);
1128
1355
  this.#notify('replay');
1129
1356
  this.#follow();
@@ -1134,6 +1361,8 @@ export class ViewerController {
1134
1361
  if (!clock)
1135
1362
  return;
1136
1363
  clock.step(direction);
1364
+ this.#restartAt = Number.NaN;
1365
+ this.#resume = false;
1137
1366
  this.#followed = '';
1138
1367
  // Follow (and announce) the hunk that just completed: stops sit at window ends.
1139
1368
  const w = clock.timeline.windows[clock.position() - 1];
@@ -1245,6 +1474,31 @@ export class ViewerController {
1245
1474
  function isString(x) {
1246
1475
  return x !== undefined;
1247
1476
  }
1477
+ function sameInsets(a, b) {
1478
+ return a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left;
1479
+ }
1480
+ /** Bounds of the visible part of `focus`, with room for labels and the collapsed pills. */
1481
+ function focusBounds(scene, focus) {
1482
+ let x0 = Number.POSITIVE_INFINITY;
1483
+ let y0 = Number.POSITIVE_INFINITY;
1484
+ let x1 = Number.NEGATIVE_INFINITY;
1485
+ let y1 = Number.NEGATIVE_INFINITY;
1486
+ const add = (x, y, rx, top, bottom) => {
1487
+ x0 = Math.min(x0, x - rx);
1488
+ x1 = Math.max(x1, x + rx);
1489
+ y0 = Math.min(y0, y - top);
1490
+ y1 = Math.max(y1, y + bottom);
1491
+ };
1492
+ // Labels sit centered below their node: leave room for a typical one.
1493
+ for (const n of scene.nodes)
1494
+ if (focus.nodes[n.index] === 1)
1495
+ add(n.x, n.y, n.r + 48, n.r, n.r + 24);
1496
+ for (const g of focus.collapsed)
1497
+ add(g.x, g.y, 48, 12, 12);
1498
+ if (!(x0 <= x1))
1499
+ return frameBounds(scene);
1500
+ return padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }, 160, 1.12);
1501
+ }
1248
1502
  /** Pad a small rect so a node is shown with its neighbourhood. */
1249
1503
  function padRect(r, min = 200, ratio = 1.4) {
1250
1504
  const w = Math.max(min, r.width * ratio);