@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/src/controller.ts CHANGED
@@ -13,7 +13,14 @@
13
13
  import type { DiffResult, DiffStats, Locale, Rect, UnionId } from '@kgdiff/core';
14
14
  import { KgdiffError } from '@kgdiff/core';
15
15
  import type { LayoutResult } from '@kgdiff/layout';
16
- import type { Camera, CameraState, Minimap, PickResult, Renderer } from '@kgdiff/renderer';
16
+ import {
17
+ type Camera,
18
+ type CameraState,
19
+ labelFootprint,
20
+ type Minimap,
21
+ type PickResult,
22
+ type Renderer,
23
+ } from '@kgdiff/renderer';
17
24
  import {
18
25
  buildScene,
19
26
  type ChangeFamily,
@@ -40,6 +47,7 @@ import {
40
47
  freeView,
41
48
  type Insets,
42
49
  NO_INSETS,
50
+ zoomFit,
43
51
  } from './framing.js';
44
52
  import { translate } from './i18n.js';
45
53
  import type {
@@ -80,6 +88,14 @@ export interface ControllerHost {
80
88
  review(hunk: string, reviewed: boolean, progress: ReviewState): void;
81
89
  error(error: Error): void;
82
90
  announce(text: string): void;
91
+ /** Playback reached the end of the replay; `loop`: it starts again after the loop delay. */
92
+ replayEnd(loop: boolean): void;
93
+ }
94
+
95
+ export interface FitOptions {
96
+ readonly animate?: boolean;
97
+ /** Room around the content, px (default `FRAME_PADDING`); kept for later automatic fits. */
98
+ readonly padding?: number;
83
99
  }
84
100
 
85
101
  export interface ControllerDeps {
@@ -175,6 +191,14 @@ export class ViewerController {
175
191
  clock: ReplayClock | null = null;
176
192
  split = 0.5;
177
193
  emphasis = 1;
194
+ /**
195
+ * Keep the view fitted (embeds without controls): fit again when the canvas is resized unless
196
+ * the user moved the camera since, and at the start of every loop.
197
+ */
198
+ autoFit = false;
199
+ /** Canvas clicks select (and double clicks fly); off for embeds without controls. */
200
+ selectOnClick = true;
201
+ #labelsInside = false;
178
202
 
179
203
  #review: ReviewStore | null = null;
180
204
  #searchIndex: SearchIndex | null = null;
@@ -198,6 +222,16 @@ export class ViewerController {
198
222
  #savedCamera: CameraState | null = null;
199
223
  /** A new diff is installed but has not been fitted to a laid-out canvas yet. */
200
224
  #needsFit = false;
225
+ /** The user panned or zoomed since the last fit. */
226
+ #userMoved = false;
227
+ #fitPadding = FRAME_PADDING;
228
+ /** A finished replay starts again this long (ms) after its end; null: it plays once. */
229
+ #loopDelay: number | null = null;
230
+ /** When a finished looping replay starts again (`deps.now()` time), else NaN. */
231
+ #restartAt = Number.NaN;
232
+ /** Playback is held (pointer over the graph); `#resume`: it was playing and continues after. */
233
+ #held = false;
234
+ #resume = false;
201
235
  readonly #listeners = new Set<(topics: ReadonlySet<Topic>) => void>();
202
236
  #pendingTopics = new Set<Topic>();
203
237
  #flushQueued = false;
@@ -325,6 +359,8 @@ export class ViewerController {
325
359
  fail(error: Error): void {
326
360
  this.#loadToken++;
327
361
  this.clock?.pause();
362
+ this.#restartAt = Number.NaN;
363
+ this.#resume = false;
328
364
  this.status = 'error';
329
365
  this.error = error;
330
366
  this.step = null;
@@ -409,6 +445,7 @@ export class ViewerController {
409
445
  const pending = this.#pendingHunk;
410
446
  this.#pendingHunk = null;
411
447
  if (pending !== null) this.selectHunkById(pending.id, { fly: pending.fly });
448
+ else this.#autoplay();
412
449
  }
413
450
 
414
451
  /** (Re)build scene, resolver, list, clock and focus; selection is carried over by id. */
@@ -462,7 +499,10 @@ export class ViewerController {
462
499
  attach(stage: HTMLElement, minimapHost: HTMLElement | null): void {
463
500
  if (this.#renderer) return;
464
501
  const backend = renderBackend();
465
- const renderer = backend.createRenderer(stage, { theme: this.theme });
502
+ const renderer = backend.createRenderer(stage, {
503
+ theme: this.theme,
504
+ labelsInside: this.#labelsInside,
505
+ });
466
506
  this.#renderer = renderer;
467
507
  // Re-attached (element moved): keep the view; this also stops the renderer's auto-fit.
468
508
  if (this.#savedCamera) renderer.camera.set(this.#savedCamera);
@@ -509,9 +549,25 @@ export class ViewerController {
509
549
  this.#chrome = provider;
510
550
  }
511
551
 
512
- /** The canvas was laid out or resized: run the initial fit a new diff is waiting for. */
552
+ /**
553
+ * Keep labels whole inside the canvas (embeds without camera controls): a label pushed from
554
+ * its spot by a neighbour moves inside or is left out, never cut at the edge.
555
+ */
556
+ setLabelsInside(inside: boolean): void {
557
+ this.#labelsInside = inside;
558
+ this.#renderer?.setLabelsInside(inside);
559
+ }
560
+
561
+ /**
562
+ * The canvas was laid out or resized: run the initial fit a new diff is waiting for, or (with
563
+ * `autoFit`) fit again unless the user moved the camera since the last fit.
564
+ */
513
565
  layoutChanged(): void {
514
- if (!this.#needsFit || this.status !== 'ready') return;
566
+ if (this.status !== 'ready') return;
567
+ if (!this.#needsFit) {
568
+ if (this.autoFit && !this.#userMoved) void this.fit({ animate: false });
569
+ return;
570
+ }
515
571
  // A selection made before the first layout (deep link, `kgdiff-ready` handler) wins.
516
572
  const target = this.#selectionBounds();
517
573
  if (target) {
@@ -520,17 +576,16 @@ export class ViewerController {
520
576
  this.#flyTo(target, { animate: false });
521
577
  return;
522
578
  }
523
- const bounds = this.#contentBounds();
524
- const padding = bounds ? this.#frameInsets(bounds, FIT_MAX_ZOOM) : null;
579
+ const fit = this.#fitTarget();
525
580
  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 });
581
+ if (!fit || !camera) return;
582
+ void this.#fitTo(camera, fit, { animate: false });
529
583
  }
530
584
 
531
585
  /** The user panned / zoomed: suspend replay camera follow for `USER_CAMERA_GRACE`. */
532
586
  noteUserCamera(): void {
533
587
  this.#lastUserCamera = this.#deps.now();
588
+ this.#userMoved = true;
534
589
  }
535
590
 
536
591
  // ─── Frame loop ───────────────────────────────────────────────────────────
@@ -559,7 +614,11 @@ export class ViewerController {
559
614
  this.#notify('replay');
560
615
  this.#follow();
561
616
  if (clock.playing) animating = true;
562
- else if (clock.finished) this.#host.announce(translate(this.locale, 'announce.finished'));
617
+ else if (clock.finished) this.#ended();
618
+ }
619
+ if (!Number.isNaN(this.#restartAt) && !this.#held) {
620
+ if (now >= this.#restartAt) this.#loopAgain();
621
+ else animating = true;
563
622
  }
564
623
  if (this.#emphasisAnim) {
565
624
  const a = this.#emphasisAnim;
@@ -665,7 +724,11 @@ export class ViewerController {
665
724
  const mode = toMode(value);
666
725
  if (mode === null || mode === this.mode) return;
667
726
  this.mode = mode;
668
- if (mode !== 'replay') this.clock?.pause();
727
+ if (mode !== 'replay') {
728
+ this.clock?.pause();
729
+ this.#restartAt = Number.NaN;
730
+ this.#resume = false;
731
+ }
669
732
  this.#followed = '';
670
733
  this.#updateFocus();
671
734
  this.#minimapDirty = true;
@@ -677,6 +740,7 @@ export class ViewerController {
677
740
  translate(this.locale, 'announce.mode', { mode: translate(this.locale, `mode.${mode}`) }),
678
741
  );
679
742
  }
743
+ if (mode === 'replay') this.#autoplay();
680
744
  }
681
745
 
682
746
  // ─── Selection ────────────────────────────────────────────────────────────
@@ -719,6 +783,8 @@ export class ViewerController {
719
783
  if (this.mode === 'replay' && this.clock) {
720
784
  const end = this.clock.windowEnd(h);
721
785
  this.clock.pause();
786
+ this.#restartAt = Number.NaN;
787
+ this.#resume = false;
722
788
  if (end !== null) this.clock.seek(end);
723
789
  this.#followed = '';
724
790
  this.#notify('replay');
@@ -933,41 +999,64 @@ export class ViewerController {
933
999
  }
934
1000
 
935
1001
  /**
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.
1002
+ * Fit everything visible (the focus in changes-only mode), labels included, into the free part
1003
+ * of the canvas. Before the canvas is laid out it falls back to the renderer's own fit.
938
1004
  */
939
- fit(options: { readonly animate?: boolean } = {}): Promise<void> {
1005
+ fit(options: FitOptions = {}): Promise<void> {
940
1006
  const camera = this.#renderer?.camera;
941
1007
  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);
1008
+ const { padding, ...transition } = options;
1009
+ if (padding !== undefined) {
1010
+ this.#fitPadding = Number.isFinite(padding) && padding >= 0 ? padding : FRAME_PADDING;
1011
+ }
1012
+ const fit = this.#fitTarget();
1013
+ if (!fit) return camera.fit(undefined, transition);
1014
+ return this.#fitTo(camera, fit, transition);
1015
+ }
1016
+
1017
+ #fitTo(
1018
+ camera: Camera,
1019
+ fit: { readonly bounds: Rect; readonly insets: Insets },
1020
+ transition: { readonly animate?: boolean },
1021
+ ): Promise<void> {
945
1022
  this.#needsFit = false;
946
- return camera.fit(bounds, { ...options, padding, maxZoom: FIT_MAX_ZOOM });
1023
+ this.#userMoved = false;
1024
+ return camera.fit(fit.bounds, { ...transition, padding: fit.insets, maxZoom: FIT_MAX_ZOOM });
947
1025
  }
948
1026
 
949
- /** Bounds of what is drawn: the scene, or the visible part of the changes-only focus. */
950
- #contentBounds(): Rect | null {
951
- const scene = this.scene;
952
- if (!scene) return null;
953
- 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);
1027
+ /**
1028
+ * Bounds and insets that show everything visible (the changes-only focus) with every label
1029
+ * in its spot below its node, clear of the chrome, or null before layout. Measured in the
1030
+ * union layer, which holds every state a replay or swipe passes through. Insets depend on
1031
+ * what is framed, which depends on the zoom they allow: they are settled once more.
1032
+ */
1033
+ #fitTarget(): { bounds: Rect; insets: Insets } | null {
1034
+ const layout = this.#chrome?.();
1035
+ const resolver = this.resolver;
1036
+ if (!layout || !resolver || !(layout.width > 0) || !(layout.height > 0)) return null;
1037
+ const frame = resolver.resolve(
1038
+ createViewState({
1039
+ layer: 'union',
1040
+ focus: this.focus,
1041
+ families: this.families,
1042
+ reducedMotion: this.reducedMotion,
1043
+ }),
1044
+ );
1045
+ const footprint = labelFootprint(frame, this.theme);
1046
+ const extent = (k: number): Rect | null => footprint.at(k);
1047
+ const discs = extent(Number.POSITIVE_INFINITY);
1048
+ if (!discs) return null;
1049
+ const { width, height } = layout;
1050
+ const padding = this.#fitPadding;
1051
+ let insets = chromeInsets(discs, layout, padding, FIT_MAX_ZOOM);
1052
+ let fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM);
1053
+ if (!fit) return null;
1054
+ const settled = chromeInsets(fit.content, layout, padding, FIT_MAX_ZOOM);
1055
+ if (!sameInsets(settled, insets)) {
1056
+ insets = settled;
1057
+ fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM) ?? fit;
1058
+ }
1059
+ return { bounds: fit.bounds, insets };
971
1060
  }
972
1061
 
973
1062
  zoom(direction: 1 | -1): void {
@@ -1014,6 +1103,7 @@ export class ViewerController {
1014
1103
  }
1015
1104
 
1016
1105
  #onCanvasClick(pick: PickResult | null): void {
1106
+ if (!this.selectOnClick) return;
1017
1107
  if (!pick) {
1018
1108
  this.clearSelection();
1019
1109
  return;
@@ -1038,7 +1128,7 @@ export class ViewerController {
1038
1128
  }
1039
1129
 
1040
1130
  #onCanvasDblClick(pick: PickResult | null): void {
1041
- if (pick?.type !== 'node') return;
1131
+ if (!this.selectOnClick || pick?.type !== 'node') return;
1042
1132
  const n = this.scene?.nodes[pick.index];
1043
1133
  if (!n) return;
1044
1134
  if (n.hunk >= 0) this.flyToHunk(n.hunk);
@@ -1230,6 +1320,8 @@ export class ViewerController {
1230
1320
  const clock = this.clock;
1231
1321
  if (!clock || this.status !== 'ready') return;
1232
1322
  if (this.mode !== 'replay') this.setMode('replay');
1323
+ this.#restartAt = Number.NaN;
1324
+ this.#resume = false;
1233
1325
  clock.play();
1234
1326
  this.#followed = '';
1235
1327
  this.#lastTick = 0;
@@ -1237,7 +1329,10 @@ export class ViewerController {
1237
1329
  this.invalidate();
1238
1330
  }
1239
1331
 
1332
+ /** Pause; a looping replay stays paused (until `play`, a new diff or loop turned on again). */
1240
1333
  pause(): void {
1334
+ this.#restartAt = Number.NaN;
1335
+ this.#resume = false;
1241
1336
  if (!this.clock?.playing) return;
1242
1337
  this.clock.pause();
1243
1338
  this.#notify('replay');
@@ -1249,9 +1344,73 @@ export class ViewerController {
1249
1344
  else this.play();
1250
1345
  }
1251
1346
 
1347
+ /**
1348
+ * Play the replay over and over: `delay` ms after it ends it starts again; null plays it
1349
+ * once. Turning the loop on starts a replay that is shown but not playing.
1350
+ */
1351
+ setLoop(delay: number | null): void {
1352
+ const was = this.#loopDelay;
1353
+ this.#loopDelay = delay === null ? null : Number.isFinite(delay) ? Math.max(0, delay) : 0;
1354
+ if (this.#loopDelay === null) this.#restartAt = Number.NaN;
1355
+ else if (was === null) this.#autoplay();
1356
+ }
1357
+
1358
+ get loopDelay(): number | null {
1359
+ return this.#loopDelay;
1360
+ }
1361
+
1362
+ /**
1363
+ * Hold playback while the pointer rests on the graph (`pause-on-hover`): a playing replay
1364
+ * pauses and continues on release; a pending loop restart waits for the release, then the
1365
+ * full loop delay.
1366
+ */
1367
+ hold(held: boolean): void {
1368
+ if (held === this.#held) return;
1369
+ this.#held = held;
1370
+ const clock = this.clock;
1371
+ if (held) {
1372
+ if (!clock?.playing) return;
1373
+ clock.pause();
1374
+ this.#resume = true;
1375
+ this.#notify('replay');
1376
+ this.invalidate();
1377
+ return;
1378
+ }
1379
+ if (!Number.isNaN(this.#restartAt) && this.#loopDelay !== null) {
1380
+ this.#restartAt = this.#deps.now() + this.#loopDelay;
1381
+ this.#schedule();
1382
+ }
1383
+ if (this.#resume) this.play();
1384
+ }
1385
+
1386
+ /** Looping and showing a replay that is not playing: start it (or after the hold). */
1387
+ #autoplay(): void {
1388
+ if (this.#loopDelay === null || this.status !== 'ready' || this.mode !== 'replay') return;
1389
+ if (!this.clock || this.clock.playing || !Number.isNaN(this.#restartAt)) return;
1390
+ if (this.#held) this.#resume = true;
1391
+ else this.play();
1392
+ }
1393
+
1394
+ /** Playback reached the end: announce it, tell the host and arm the loop restart. */
1395
+ #ended(): void {
1396
+ this.#host.announce(translate(this.locale, 'announce.finished'));
1397
+ const loop = this.#loopDelay !== null;
1398
+ // Armed first, so a `kgdiff-replay-end` listener can still cancel it (pause, loop off).
1399
+ if (loop) this.#restartAt = this.#deps.now() + (this.#loopDelay ?? 0);
1400
+ this.#host.replayEnd(loop);
1401
+ }
1402
+
1403
+ #loopAgain(): void {
1404
+ this.#restartAt = Number.NaN;
1405
+ if (this.autoFit) void this.fit();
1406
+ this.clock?.seek(0);
1407
+ this.play();
1408
+ }
1409
+
1252
1410
  seek(t: number): void {
1253
1411
  const clock = this.clock;
1254
1412
  if (!clock) return;
1413
+ this.#restartAt = Number.NaN;
1255
1414
  clock.seek(t);
1256
1415
  this.#notify('replay');
1257
1416
  this.#follow();
@@ -1262,6 +1421,8 @@ export class ViewerController {
1262
1421
  const clock = this.clock;
1263
1422
  if (!clock) return;
1264
1423
  clock.step(direction);
1424
+ this.#restartAt = Number.NaN;
1425
+ this.#resume = false;
1265
1426
  this.#followed = '';
1266
1427
  // Follow (and announce) the hunk that just completed: stops sit at window ends.
1267
1428
  const w = clock.timeline.windows[clock.position() - 1];
@@ -1385,6 +1546,10 @@ function isString(x: string | undefined): x is string {
1385
1546
  return x !== undefined;
1386
1547
  }
1387
1548
 
1549
+ function sameInsets(a: Insets, b: Insets): boolean {
1550
+ return a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left;
1551
+ }
1552
+
1388
1553
  /** Pad a small rect so a node is shown with its neighbourhood. */
1389
1554
  function padRect(r: Rect, min = 200, ratio = 1.4): Rect {
1390
1555
  const w = Math.max(min, r.width * ratio);
package/src/element.ts CHANGED
@@ -9,16 +9,29 @@ import { h } from './dom.js';
9
9
  import { formatHash, parseHash } from './hash-sync.js';
10
10
  import { detectLocale, translate, translator } from './i18n.js';
11
11
  import type {
12
+ FitOptions,
12
13
  KgdiffViewerElement,
13
14
  LoadOptions,
14
15
  ReportPayload,
15
16
  ReviewState,
16
17
  ThemePreference,
18
+ ViewerChrome,
19
+ ViewerLegend,
17
20
  ViewerMode,
18
21
  } from './index.js';
19
22
  import { isTypingTarget, keyAction } from './keyboard.js';
20
23
  import { assertPayload } from './payload.js';
21
- import { THEME_PREFERENCES, toLocale, toMode, toPalette, toTheme } from './prefs.js';
24
+ import {
25
+ DEFAULT_LOOP_DELAY,
26
+ THEME_PREFERENCES,
27
+ toChrome,
28
+ toDelay,
29
+ toLegend,
30
+ toLocale,
31
+ toMode,
32
+ toPalette,
33
+ toTheme,
34
+ } from './prefs.js';
22
35
  import { defaultStorage } from './review-store.js';
23
36
  import { STYLES, themeVariables } from './styles.js';
24
37
  import type { UiContext } from './ui/context.js';
@@ -32,8 +45,31 @@ export const COMPACT_WIDTH = 760;
32
45
  /** Below this width the header drops the diffstat pills and version tags. */
33
46
  const NARROW_WIDTH = 1100;
34
47
 
35
- const OBSERVED = ['mode', 'theme', 'palette', 'locale', 'sidebar', 'sync-hash'] as const;
36
- const UPGRADED = ['mode', 'theme', 'palette', 'locale', 'payload'] as const;
48
+ const OBSERVED = [
49
+ 'mode',
50
+ 'theme',
51
+ 'palette',
52
+ 'locale',
53
+ 'sidebar',
54
+ 'sync-hash',
55
+ 'chrome',
56
+ 'legend',
57
+ 'loop',
58
+ 'loop-delay',
59
+ 'pause-on-hover',
60
+ ] as const;
61
+ const UPGRADED = [
62
+ 'mode',
63
+ 'theme',
64
+ 'palette',
65
+ 'locale',
66
+ 'chrome',
67
+ 'legend',
68
+ 'loop',
69
+ 'loopDelay',
70
+ 'pauseOnHover',
71
+ 'payload',
72
+ ] as const;
37
73
 
38
74
  /** Keys the page itself scrolls with: never taken at document level. */
39
75
  const PAGE_KEYS: ReadonlySet<string> = new Set([
@@ -110,6 +146,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
110
146
  readonly #cleanup: (() => void)[] = [];
111
147
  #lastHash = '';
112
148
  #upgraded = false;
149
+ /** A mouse or pen rests on the graph (holds a looping replay with `pause-on-hover`). */
150
+ #pointerOver = false;
113
151
 
114
152
  constructor() {
115
153
  super();
@@ -128,6 +166,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
128
166
  this.#emit('kgdiff-review', { hunk, reviewed, progress }),
129
167
  error: (error) => this.#emit('kgdiff-error', { error }),
130
168
  announce: (text) => this.#announce(text),
169
+ replayEnd: (loop) => this.#emit('kgdiff-replay-end', { loop }),
131
170
  };
132
171
  this.controller = new ViewerController(host, {
133
172
  now: () => (typeof performance !== 'undefined' ? performance.now() : Date.now()),
@@ -165,8 +204,10 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
165
204
  // The graph takes keyboard focus (shortcuts apply where focus is), also on click.
166
205
  const canvas = ui.stage.canvasHost.querySelector('canvas');
167
206
  if (canvas) {
168
- canvas.tabIndex = 0;
169
- const focusCanvas = (): void => canvas.focus({ preventScroll: true });
207
+ canvas.tabIndex = this.chrome === 'none' ? -1 : 0;
208
+ const focusCanvas = (): void => {
209
+ if (this.chrome !== 'none') canvas.focus({ preventScroll: true });
210
+ };
170
211
  canvas.addEventListener('pointerdown', focusCanvas);
171
212
  this.#cleanup.push(() => canvas.removeEventListener('pointerdown', focusCanvas));
172
213
  }
@@ -206,6 +247,9 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
206
247
  if (i >= 0) connected.splice(i, 1);
207
248
  for (const fn of this.#cleanup.splice(0)) fn();
208
249
  this.controller.detach();
250
+ // Moved while hovered: no pointerleave follows.
251
+ this.#pointerOver = false;
252
+ this.#applyHold();
209
253
  }
210
254
 
211
255
  attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
@@ -223,6 +267,18 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
223
267
  case 'sync-hash':
224
268
  if (value !== null) this.#readHash();
225
269
  break;
270
+ case 'chrome':
271
+ case 'legend':
272
+ this.#applyChrome();
273
+ break;
274
+ case 'loop':
275
+ case 'loop-delay':
276
+ this.controller.setLoop(this.loop ? this.loopDelay : null);
277
+ this.#applyHold();
278
+ break;
279
+ case 'pause-on-hover':
280
+ this.#applyHold();
281
+ break;
226
282
  default:
227
283
  break;
228
284
  }
@@ -307,6 +363,52 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
307
363
  this.#applyPreference('locale', value, true);
308
364
  }
309
365
 
366
+ get chrome(): ViewerChrome {
367
+ return toChrome(this.getAttribute('chrome')) ?? 'full';
368
+ }
369
+
370
+ set chrome(value: ViewerChrome) {
371
+ this.#reflect('chrome', toChrome(value) === 'none' ? 'none' : null);
372
+ this.#applyChrome();
373
+ }
374
+
375
+ get legend(): ViewerLegend {
376
+ return toLegend(this.getAttribute('legend')) ?? 'auto';
377
+ }
378
+
379
+ set legend(value: ViewerLegend) {
380
+ const v = toLegend(value);
381
+ this.#reflect('legend', v === 'auto' ? null : v);
382
+ this.#applyChrome();
383
+ }
384
+
385
+ get loop(): boolean {
386
+ return this.hasAttribute('loop');
387
+ }
388
+
389
+ set loop(value: boolean) {
390
+ this.toggleAttribute('loop', Boolean(value));
391
+ }
392
+
393
+ get loopDelay(): number {
394
+ return toDelay(this.getAttribute('loop-delay')) ?? DEFAULT_LOOP_DELAY;
395
+ }
396
+
397
+ set loopDelay(value: number) {
398
+ const v = toDelay(value);
399
+ if (v === null) this.removeAttribute('loop-delay');
400
+ else this.setAttribute('loop-delay', String(v));
401
+ }
402
+
403
+ get pauseOnHover(): boolean {
404
+ return this.getAttribute('pause-on-hover') !== 'false';
405
+ }
406
+
407
+ set pauseOnHover(value: boolean) {
408
+ if (value) this.removeAttribute('pause-on-hover');
409
+ else this.setAttribute('pause-on-hover', 'false');
410
+ }
411
+
310
412
  get payload(): ReportPayload | null {
311
413
  return this.#currentPayload;
312
414
  }
@@ -353,8 +455,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
353
455
  this.controller.pause();
354
456
  }
355
457
 
356
- fit(): Promise<void> {
357
- return this.controller.fit();
458
+ fit(options?: FitOptions): Promise<void> {
459
+ return this.controller.fit(options);
358
460
  }
359
461
 
360
462
  exportDelta(): Delta {
@@ -415,6 +517,34 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
415
517
  ui.header.setSidebarState(this.#sidebarOpen);
416
518
  }
417
519
 
520
+ /**
521
+ * `chrome` and `legend`: what the stage shows, whether clicks select, whether the view stays
522
+ * fitted. The CSS hides the rest (`.root[data-chrome="none"]`).
523
+ */
524
+ #applyChrome(): void {
525
+ const bare = this.chrome === 'none';
526
+ const c = this.controller;
527
+ c.autoFit = bare;
528
+ c.selectOnClick = !bare;
529
+ c.setLabelsInside(bare);
530
+ const ui = this.#ui;
531
+ if (!ui) return;
532
+ ui.root.dataset.chrome = this.chrome;
533
+ const legend = this.legend;
534
+ ui.stage.setChrome(legend === 'auto' ? (bare ? 'mini' : 'full') : legend, !bare);
535
+ const canvas = ui.stage.canvasHost.querySelector('canvas');
536
+ if (canvas) canvas.tabIndex = bare ? -1 : 0;
537
+ if (bare) {
538
+ ui.help.hide();
539
+ this.#toggleMenu(false);
540
+ }
541
+ }
542
+
543
+ /** Hold a looping replay while the pointer is over the graph (`pause-on-hover`). */
544
+ #applyHold(): void {
545
+ this.controller.hold(this.#pointerOver && this.loop && this.pauseOnHover);
546
+ }
547
+
418
548
  #build(): void {
419
549
  const style = h('style', { text: STYLES });
420
550
  const vars = h('style');
@@ -463,7 +593,15 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
463
593
  const path = e.composedPath();
464
594
  if (!path.includes(menu.el) && !path.includes(header.menuButton)) this.#toggleMenu(false);
465
595
  });
596
+ const hover = (over: boolean) => (e: PointerEvent) => {
597
+ if (e.pointerType === 'touch') return;
598
+ this.#pointerOver = over;
599
+ this.#applyHold();
600
+ };
601
+ stage.el.addEventListener('pointerenter', hover(true));
602
+ stage.el.addEventListener('pointerleave', hover(false));
466
603
  this.#applySidebar();
604
+ this.#applyChrome();
467
605
  }
468
606
 
469
607
  /** Render, never throwing: a failure shows the error state with safe appearance defaults. */
@@ -563,13 +701,15 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
563
701
  this.controller.layoutChanged();
564
702
  });
565
703
  ro.observe(this);
704
+ // The canvas also changes size without the element (chrome shown or hidden).
705
+ if (this.#ui) ro.observe(this.#ui.stage.el);
566
706
  this.#cleanup.push(() => ro.disconnect());
567
707
  }
568
708
 
569
709
  #onKeyDown(e: KeyboardEvent): void {
570
710
  if (e.defaultPrevented || e.isComposing) return;
571
711
  const ui = this.#ui;
572
- if (!ui) return;
712
+ if (!ui || this.chrome === 'none') return;
573
713
  const path = e.composedPath();
574
714
  if (!path.includes(this)) {
575
715
  // Outside the component only with `global-shortcuts`, when nothing else has focus, for