@kgdiff/viewer 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -38,9 +38,50 @@ ESM only. Runs in evergreen browsers.
38
38
  `@kgdiff/viewer/standalone/compute` add-on brings the diff engine and force layout for `load()`.
39
39
 
40
40
  Attributes: `mode` (`overlay` | `swipe` | `replay` | `changes`), `theme`, `palette`, `locale`,
41
- `sidebar`, `sync-hash`, `global-shortcuts`. Methods: `load`, `selectHunk`, `next`, `prev`, `play`, `pause`,
42
- `fit`, `exportDelta`, `reviewState`, `setReviewed`. Events: `kgdiff-ready`, `kgdiff-select`,
43
- `kgdiff-mode`, `kgdiff-review`, `kgdiff-error`.
41
+ `sidebar`, `sync-hash`, `global-shortcuts`, `header`, `chrome`, `legend`, `loop`, `loop-delay`,
42
+ `pause-on-hover`. Methods: `load`, `selectHunk`, `next`, `prev`, `play`, `pause`, `fit`,
43
+ `exportDelta`, `reviewState`, `setReviewed`. Events: `kgdiff-ready`, `kgdiff-select`,
44
+ `kgdiff-mode`, `kgdiff-review`, `kgdiff-error`, `kgdiff-replay-end`.
45
+
46
+ ## Embed a small looping replay
47
+
48
+ To show a graph's changes as a small strip on another page, leave the review UI out and let the
49
+ replay loop:
50
+
51
+ ```html
52
+ <kgdiff-viewer mode="replay" chrome="none" loop style="height: 320px"></kgdiff-viewer>
53
+ ```
54
+
55
+ | Attribute (property) | Values | Default |
56
+ | --------------------------------- | ------------------------------ | ------------------------------------------------ |
57
+ | `chrome` (`chrome`) | `full`, `none` | `full` |
58
+ | `legend` (`legend`) | `auto`, `full`, `mini`, `none` | `auto`: `full`, or `mini` with `chrome="none"` |
59
+ | `loop` (`loop`) | boolean | off |
60
+ | `loop-delay` (`loopDelay`) | ms between the end and restart | `1200` |
61
+ | `pause-on-hover` (`pauseOnHover`) | `false` to keep playing | on: a looping replay pauses under a mouse or pen |
62
+
63
+ - `chrome="none"` shows the graph alone: no header (view switch, theme, full screen, help,
64
+ sidebar button), change list, step caption, playback bar, finished card, zoom buttons, minimap
65
+ or legend card. The hover tooltip stays, and the legend becomes one line with the families this
66
+ diff has, then unchanged. Clicks and keyboard shortcuts do nothing. The minimum height drops
67
+ from 420px to 120px. The view stays fitted: when the canvas is resized (unless the user
68
+ panned or zoomed since) and at the start of every loop. Labels are never cut at the edge: one
69
+ pushed from its spot by a neighbour moves inside the canvas or is left out (the tooltip
70
+ still names it).
71
+ - `loop` plays the replay by itself (in `replay` mode) and starts it again `loop-delay` ms after
72
+ it ends. `pause()` stops it until `play()`; a new diff starts it again.
73
+ - `kgdiff-replay-end` fires each time playback reaches the end, with `detail.loop` telling
74
+ whether it will start again. Set `loop = false` or call `pause()` in the listener to stop
75
+ there.
76
+ - `fit({ padding })` fits the graph with its labels into view, keeping `padding` px around them
77
+ (default 24, kept for the later automatic fits); `fit({ animate: false })` jumps.
78
+
79
+ ```js
80
+ let rounds = 0;
81
+ viewer.addEventListener('kgdiff-replay-end', (event) => {
82
+ if (event.detail.loop && ++rounds === 3) viewer.loop = false; // three rounds, then stop
83
+ });
84
+ ```
44
85
 
45
86
  ## Learn more
46
87
 
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import type { DiffResult, DiffStats, Locale, Rect, UnionId } from '@kgdiff/core';
14
14
  import type { LayoutResult } from '@kgdiff/layout';
15
- import type { Camera } from '@kgdiff/renderer';
15
+ import { type Camera } from '@kgdiff/renderer';
16
16
  import { type ChangeFamily, type FocusState, type PaletteName, type Scene, type Theme, type ThemeName, type VisualResolver } from '@kgdiff/scene';
17
17
  import { type ChromeLayout } from './framing.js';
18
18
  import type { LoadOptions, ReportPayload, ReviewState, ThemePreference, ViewerMode } from './index.js';
@@ -30,6 +30,13 @@ export interface ControllerHost {
30
30
  review(hunk: string, reviewed: boolean, progress: ReviewState): void;
31
31
  error(error: Error): void;
32
32
  announce(text: string): void;
33
+ /** Playback reached the end of the replay; `loop`: it starts again after the loop delay. */
34
+ replayEnd(loop: boolean): void;
35
+ }
36
+ export interface FitOptions {
37
+ readonly animate?: boolean;
38
+ /** Room around the content, px (default `FRAME_PADDING`); kept for later automatic fits. */
39
+ readonly padding?: number;
33
40
  }
34
41
  export interface ControllerDeps {
35
42
  readonly now: () => number;
@@ -84,6 +91,13 @@ export declare class ViewerController {
84
91
  clock: ReplayClock | null;
85
92
  split: number;
86
93
  emphasis: number;
94
+ /**
95
+ * Keep the view fitted (embeds without controls): fit again when the canvas is resized unless
96
+ * the user moved the camera since, and at the start of every loop.
97
+ */
98
+ autoFit: boolean;
99
+ /** Canvas clicks select (and double clicks fly); off for embeds without controls. */
100
+ selectOnClick: boolean;
87
101
  constructor(host: ControllerHost, deps: ControllerDeps);
88
102
  subscribe(listener: (topics: ReadonlySet<Topic>) => void): () => void;
89
103
  /** Deliver pending notifications now (also used by tests). */
@@ -108,7 +122,15 @@ export declare class ViewerController {
108
122
  * Every fit and flight frames its target in the free part of the canvas (`framing.ts`).
109
123
  */
110
124
  setChrome(provider: (() => ChromeLayout) | null): void;
111
- /** The canvas was laid out or resized: run the initial fit a new diff is waiting for. */
125
+ /**
126
+ * Keep labels whole inside the canvas (embeds without camera controls): a label pushed from
127
+ * its spot by a neighbour moves inside or is left out, never cut at the edge.
128
+ */
129
+ setLabelsInside(inside: boolean): void;
130
+ /**
131
+ * The canvas was laid out or resized: run the initial fit a new diff is waiting for, or (with
132
+ * `autoFit`) fit again unless the user moved the camera since the last fit.
133
+ */
112
134
  layoutChanged(): void;
113
135
  /** The user panned / zoomed: suspend replay camera follow for `USER_CAMERA_GRACE`. */
114
136
  noteUserCamera(): void;
@@ -143,12 +165,10 @@ export declare class ViewerController {
143
165
  flyToNode(i: number): void;
144
166
  flyToSelection(): void;
145
167
  /**
146
- * Fit everything visible (the focus in changes-only mode) into the free part of the canvas.
147
- * Before the canvas is laid out it falls back to the renderer's own fit.
168
+ * Fit everything visible (the focus in changes-only mode), labels included, into the free part
169
+ * of the canvas. Before the canvas is laid out it falls back to the renderer's own fit.
148
170
  */
149
- fit(options?: {
150
- readonly animate?: boolean;
151
- }): Promise<void>;
171
+ fit(options?: FitOptions): Promise<void>;
152
172
  zoom(direction: 1 | -1): void;
153
173
  /** Clear hover state (pointer left the canvas). */
154
174
  clearHover(): void;
@@ -178,8 +198,21 @@ export declare class ViewerController {
178
198
  /** Bring a union node into the scene and the focus (search results), then select it. */
179
199
  revealNode(id: UnionId): Promise<void>;
180
200
  play(): void;
201
+ /** Pause; a looping replay stays paused (until `play`, a new diff or loop turned on again). */
181
202
  pause(): void;
182
203
  togglePlay(): void;
204
+ /**
205
+ * Play the replay over and over: `delay` ms after it ends it starts again; null plays it
206
+ * once. Turning the loop on starts a replay that is shown but not playing.
207
+ */
208
+ setLoop(delay: number | null): void;
209
+ get loopDelay(): number | null;
210
+ /**
211
+ * Hold playback while the pointer rests on the graph (`pause-on-hover`): a playing replay
212
+ * pauses and continues on release; a pending loop restart waits for the release, then the
213
+ * full loop delay.
214
+ */
215
+ hold(held: boolean): void;
183
216
  seek(t: number): void;
184
217
  stepReplay(direction: 1 | -1): void;
185
218
  restart(): void;
@@ -1 +1 @@
1
- {"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../src/controller.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../src/controller.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,8 @@
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';
@@ -72,6 +73,14 @@ export class ViewerController {
72
73
  clock = null;
73
74
  split = 0.5;
74
75
  emphasis = 1;
76
+ /**
77
+ * Keep the view fitted (embeds without controls): fit again when the canvas is resized unless
78
+ * the user moved the camera since, and at the start of every loop.
79
+ */
80
+ autoFit = false;
81
+ /** Canvas clicks select (and double clicks fly); off for embeds without controls. */
82
+ selectOnClick = true;
83
+ #labelsInside = false;
75
84
  #review = null;
76
85
  #searchIndex = null;
77
86
  #renderer = null;
@@ -94,6 +103,16 @@ export class ViewerController {
94
103
  #savedCamera = null;
95
104
  /** A new diff is installed but has not been fitted to a laid-out canvas yet. */
96
105
  #needsFit = false;
106
+ /** The user panned or zoomed since the last fit. */
107
+ #userMoved = false;
108
+ #fitPadding = FRAME_PADDING;
109
+ /** A finished replay starts again this long (ms) after its end; null: it plays once. */
110
+ #loopDelay = null;
111
+ /** When a finished looping replay starts again (`deps.now()` time), else NaN. */
112
+ #restartAt = Number.NaN;
113
+ /** Playback is held (pointer over the graph); `#resume`: it was playing and continues after. */
114
+ #held = false;
115
+ #resume = false;
97
116
  #listeners = new Set();
98
117
  #pendingTopics = new Set();
99
118
  #flushQueued = false;
@@ -206,6 +225,8 @@ export class ViewerController {
206
225
  fail(error) {
207
226
  this.#loadToken++;
208
227
  this.clock?.pause();
228
+ this.#restartAt = Number.NaN;
229
+ this.#resume = false;
209
230
  this.status = 'error';
210
231
  this.error = error;
211
232
  this.step = null;
@@ -275,6 +296,8 @@ export class ViewerController {
275
296
  this.#pendingHunk = null;
276
297
  if (pending !== null)
277
298
  this.selectHunkById(pending.id, { fly: pending.fly });
299
+ else
300
+ this.#autoplay();
278
301
  }
279
302
  /** (Re)build scene, resolver, list, clock and focus; selection is carried over by id. */
280
303
  #rebuildScene() {
@@ -328,7 +351,10 @@ export class ViewerController {
328
351
  if (this.#renderer)
329
352
  return;
330
353
  const backend = renderBackend();
331
- const renderer = backend.createRenderer(stage, { theme: this.theme });
354
+ const renderer = backend.createRenderer(stage, {
355
+ theme: this.theme,
356
+ labelsInside: this.#labelsInside,
357
+ });
332
358
  this.#renderer = renderer;
333
359
  // Re-attached (element moved): keep the view; this also stops the renderer's auto-fit.
334
360
  if (this.#savedCamera)
@@ -371,10 +397,26 @@ export class ViewerController {
371
397
  setChrome(provider) {
372
398
  this.#chrome = provider;
373
399
  }
374
- /** The canvas was laid out or resized: run the initial fit a new diff is waiting for. */
400
+ /**
401
+ * Keep labels whole inside the canvas (embeds without camera controls): a label pushed from
402
+ * its spot by a neighbour moves inside or is left out, never cut at the edge.
403
+ */
404
+ setLabelsInside(inside) {
405
+ this.#labelsInside = inside;
406
+ this.#renderer?.setLabelsInside(inside);
407
+ }
408
+ /**
409
+ * The canvas was laid out or resized: run the initial fit a new diff is waiting for, or (with
410
+ * `autoFit`) fit again unless the user moved the camera since the last fit.
411
+ */
375
412
  layoutChanged() {
376
- if (!this.#needsFit || this.status !== 'ready')
413
+ if (this.status !== 'ready')
414
+ return;
415
+ if (!this.#needsFit) {
416
+ if (this.autoFit && !this.#userMoved)
417
+ void this.fit({ animate: false });
377
418
  return;
419
+ }
378
420
  // A selection made before the first layout (deep link, `kgdiff-ready` handler) wins.
379
421
  const target = this.#selectionBounds();
380
422
  if (target) {
@@ -384,17 +426,16 @@ export class ViewerController {
384
426
  this.#flyTo(target, { animate: false });
385
427
  return;
386
428
  }
387
- const bounds = this.#contentBounds();
388
- const padding = bounds ? this.#frameInsets(bounds, FIT_MAX_ZOOM) : null;
429
+ const fit = this.#fitTarget();
389
430
  const camera = this.#renderer?.camera;
390
- if (!bounds || !padding || !camera)
431
+ if (!fit || !camera)
391
432
  return;
392
- this.#needsFit = false;
393
- void camera.fit(bounds, { animate: false, padding, maxZoom: FIT_MAX_ZOOM });
433
+ void this.#fitTo(camera, fit, { animate: false });
394
434
  }
395
435
  /** The user panned / zoomed: suspend replay camera follow for `USER_CAMERA_GRACE`. */
396
436
  noteUserCamera() {
397
437
  this.#lastUserCamera = this.#deps.now();
438
+ this.#userMoved = true;
398
439
  }
399
440
  // ─── Frame loop ───────────────────────────────────────────────────────────
400
441
  /** Redraw on the next animation frame. */
@@ -423,7 +464,13 @@ export class ViewerController {
423
464
  if (clock.playing)
424
465
  animating = true;
425
466
  else if (clock.finished)
426
- this.#host.announce(translate(this.locale, 'announce.finished'));
467
+ this.#ended();
468
+ }
469
+ if (!Number.isNaN(this.#restartAt) && !this.#held) {
470
+ if (now >= this.#restartAt)
471
+ this.#loopAgain();
472
+ else
473
+ animating = true;
427
474
  }
428
475
  if (this.#emphasisAnim) {
429
476
  const a = this.#emphasisAnim;
@@ -529,8 +576,11 @@ export class ViewerController {
529
576
  if (mode === null || mode === this.mode)
530
577
  return;
531
578
  this.mode = mode;
532
- if (mode !== 'replay')
579
+ if (mode !== 'replay') {
533
580
  this.clock?.pause();
581
+ this.#restartAt = Number.NaN;
582
+ this.#resume = false;
583
+ }
534
584
  this.#followed = '';
535
585
  this.#updateFocus();
536
586
  this.#minimapDirty = true;
@@ -540,6 +590,8 @@ export class ViewerController {
540
590
  this.#host.mode(mode);
541
591
  this.#host.announce(translate(this.locale, 'announce.mode', { mode: translate(this.locale, `mode.${mode}`) }));
542
592
  }
593
+ if (mode === 'replay')
594
+ this.#autoplay();
543
595
  }
544
596
  // ─── Selection ────────────────────────────────────────────────────────────
545
597
  selectHunkById(id, options = {}) {
@@ -581,6 +633,8 @@ export class ViewerController {
581
633
  if (this.mode === 'replay' && this.clock) {
582
634
  const end = this.clock.windowEnd(h);
583
635
  this.clock.pause();
636
+ this.#restartAt = Number.NaN;
637
+ this.#resume = false;
584
638
  if (end !== null)
585
639
  this.clock.seek(end);
586
640
  this.#followed = '';
@@ -795,47 +849,61 @@ export class ViewerController {
795
849
  this.#flyTo(b);
796
850
  }
797
851
  /**
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.
852
+ * Fit everything visible (the focus in changes-only mode), labels included, into the free part
853
+ * of the canvas. Before the canvas is laid out it falls back to the renderer's own fit.
800
854
  */
801
855
  fit(options = {}) {
802
856
  const camera = this.#renderer?.camera;
803
857
  if (!camera)
804
858
  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);
859
+ const { padding, ...transition } = options;
860
+ if (padding !== undefined) {
861
+ this.#fitPadding = Number.isFinite(padding) && padding >= 0 ? padding : FRAME_PADDING;
862
+ }
863
+ const fit = this.#fitTarget();
864
+ if (!fit)
865
+ return camera.fit(undefined, transition);
866
+ return this.#fitTo(camera, fit, transition);
867
+ }
868
+ #fitTo(camera, fit, transition) {
809
869
  this.#needsFit = false;
810
- return camera.fit(bounds, { ...options, padding, maxZoom: FIT_MAX_ZOOM });
870
+ this.#userMoved = false;
871
+ return camera.fit(fit.bounds, { ...transition, padding: fit.insets, maxZoom: FIT_MAX_ZOOM });
811
872
  }
812
- /** Bounds of what is drawn: the scene, or the visible part of the changes-only focus. */
813
- #contentBounds() {
814
- const scene = this.scene;
815
- if (!scene)
873
+ /**
874
+ * Bounds and insets that show everything visible (the changes-only focus) with every label
875
+ * in its spot below its node, clear of the chrome, or null before layout. Measured in the
876
+ * union layer, which holds every state a replay or swipe passes through. Insets depend on
877
+ * what is framed, which depends on the zoom they allow: they are settled once more.
878
+ */
879
+ #fitTarget() {
880
+ const layout = this.#chrome?.();
881
+ const resolver = this.resolver;
882
+ if (!layout || !resolver || !(layout.width > 0) || !(layout.height > 0))
816
883
  return null;
817
- 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);
884
+ const frame = resolver.resolve(createViewState({
885
+ layer: 'union',
886
+ focus: this.focus,
887
+ families: this.families,
888
+ reducedMotion: this.reducedMotion,
889
+ }));
890
+ const footprint = labelFootprint(frame, this.theme);
891
+ const extent = (k) => footprint.at(k);
892
+ const discs = extent(Number.POSITIVE_INFINITY);
893
+ if (!discs)
894
+ return null;
895
+ const { width, height } = layout;
896
+ const padding = this.#fitPadding;
897
+ let insets = chromeInsets(discs, layout, padding, FIT_MAX_ZOOM);
898
+ let fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM);
899
+ if (!fit)
900
+ return null;
901
+ const settled = chromeInsets(fit.content, layout, padding, FIT_MAX_ZOOM);
902
+ if (!sameInsets(settled, insets)) {
903
+ insets = settled;
904
+ fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM) ?? fit;
905
+ }
906
+ return { bounds: fit.bounds, insets };
839
907
  }
840
908
  zoom(direction) {
841
909
  this.noteUserCamera();
@@ -878,6 +946,8 @@ export class ViewerController {
878
946
  this.#onHover(null);
879
947
  }
880
948
  #onCanvasClick(pick) {
949
+ if (!this.selectOnClick)
950
+ return;
881
951
  if (!pick) {
882
952
  this.clearSelection();
883
953
  return;
@@ -902,7 +972,7 @@ export class ViewerController {
902
972
  }
903
973
  }
904
974
  #onCanvasDblClick(pick) {
905
- if (pick?.type !== 'node')
975
+ if (!this.selectOnClick || pick?.type !== 'node')
906
976
  return;
907
977
  const n = this.scene?.nodes[pick.index];
908
978
  if (!n)
@@ -1101,13 +1171,18 @@ export class ViewerController {
1101
1171
  return;
1102
1172
  if (this.mode !== 'replay')
1103
1173
  this.setMode('replay');
1174
+ this.#restartAt = Number.NaN;
1175
+ this.#resume = false;
1104
1176
  clock.play();
1105
1177
  this.#followed = '';
1106
1178
  this.#lastTick = 0;
1107
1179
  this.#notify('replay');
1108
1180
  this.invalidate();
1109
1181
  }
1182
+ /** Pause; a looping replay stays paused (until `play`, a new diff or loop turned on again). */
1110
1183
  pause() {
1184
+ this.#restartAt = Number.NaN;
1185
+ this.#resume = false;
1111
1186
  if (!this.clock?.playing)
1112
1187
  return;
1113
1188
  this.clock.pause();
@@ -1120,10 +1195,79 @@ export class ViewerController {
1120
1195
  else
1121
1196
  this.play();
1122
1197
  }
1198
+ /**
1199
+ * Play the replay over and over: `delay` ms after it ends it starts again; null plays it
1200
+ * once. Turning the loop on starts a replay that is shown but not playing.
1201
+ */
1202
+ setLoop(delay) {
1203
+ const was = this.#loopDelay;
1204
+ this.#loopDelay = delay === null ? null : Number.isFinite(delay) ? Math.max(0, delay) : 0;
1205
+ if (this.#loopDelay === null)
1206
+ this.#restartAt = Number.NaN;
1207
+ else if (was === null)
1208
+ this.#autoplay();
1209
+ }
1210
+ get loopDelay() {
1211
+ return this.#loopDelay;
1212
+ }
1213
+ /**
1214
+ * Hold playback while the pointer rests on the graph (`pause-on-hover`): a playing replay
1215
+ * pauses and continues on release; a pending loop restart waits for the release, then the
1216
+ * full loop delay.
1217
+ */
1218
+ hold(held) {
1219
+ if (held === this.#held)
1220
+ return;
1221
+ this.#held = held;
1222
+ const clock = this.clock;
1223
+ if (held) {
1224
+ if (!clock?.playing)
1225
+ return;
1226
+ clock.pause();
1227
+ this.#resume = true;
1228
+ this.#notify('replay');
1229
+ this.invalidate();
1230
+ return;
1231
+ }
1232
+ if (!Number.isNaN(this.#restartAt) && this.#loopDelay !== null) {
1233
+ this.#restartAt = this.#deps.now() + this.#loopDelay;
1234
+ this.#schedule();
1235
+ }
1236
+ if (this.#resume)
1237
+ this.play();
1238
+ }
1239
+ /** Looping and showing a replay that is not playing: start it (or after the hold). */
1240
+ #autoplay() {
1241
+ if (this.#loopDelay === null || this.status !== 'ready' || this.mode !== 'replay')
1242
+ return;
1243
+ if (!this.clock || this.clock.playing || !Number.isNaN(this.#restartAt))
1244
+ return;
1245
+ if (this.#held)
1246
+ this.#resume = true;
1247
+ else
1248
+ this.play();
1249
+ }
1250
+ /** Playback reached the end: announce it, tell the host and arm the loop restart. */
1251
+ #ended() {
1252
+ this.#host.announce(translate(this.locale, 'announce.finished'));
1253
+ const loop = this.#loopDelay !== null;
1254
+ // Armed first, so a `kgdiff-replay-end` listener can still cancel it (pause, loop off).
1255
+ if (loop)
1256
+ this.#restartAt = this.#deps.now() + (this.#loopDelay ?? 0);
1257
+ this.#host.replayEnd(loop);
1258
+ }
1259
+ #loopAgain() {
1260
+ this.#restartAt = Number.NaN;
1261
+ if (this.autoFit)
1262
+ void this.fit();
1263
+ this.clock?.seek(0);
1264
+ this.play();
1265
+ }
1123
1266
  seek(t) {
1124
1267
  const clock = this.clock;
1125
1268
  if (!clock)
1126
1269
  return;
1270
+ this.#restartAt = Number.NaN;
1127
1271
  clock.seek(t);
1128
1272
  this.#notify('replay');
1129
1273
  this.#follow();
@@ -1134,6 +1278,8 @@ export class ViewerController {
1134
1278
  if (!clock)
1135
1279
  return;
1136
1280
  clock.step(direction);
1281
+ this.#restartAt = Number.NaN;
1282
+ this.#resume = false;
1137
1283
  this.#followed = '';
1138
1284
  // Follow (and announce) the hunk that just completed: stops sit at window ends.
1139
1285
  const w = clock.timeline.windows[clock.position() - 1];
@@ -1245,6 +1391,9 @@ export class ViewerController {
1245
1391
  function isString(x) {
1246
1392
  return x !== undefined;
1247
1393
  }
1394
+ function sameInsets(a, b) {
1395
+ return a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left;
1396
+ }
1248
1397
  /** Pad a small rect so a node is shown with its neighbourhood. */
1249
1398
  function padRect(r, min = 200, ratio = 1.4) {
1250
1399
  const w = Math.max(min, r.width * ratio);