@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.
- package/README.md +48 -6
- package/dist/controller.d.ts +63 -14
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +308 -54
- package/dist/controller.js.map +1 -1
- package/dist/element.d.ts +17 -4
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +199 -26
- package/dist/element.js.map +1 -1
- package/dist/framing.d.ts +20 -1
- package/dist/framing.d.ts.map +1 -1
- package/dist/framing.js +49 -0
- package/dist/framing.js.map +1 -1
- package/dist/hash-sync.d.ts +4 -2
- package/dist/hash-sync.d.ts.map +1 -1
- package/dist/hash-sync.js +21 -5
- package/dist/hash-sync.js.map +1 -1
- package/dist/i18n.d.ts +7 -7
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +14 -14
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +66 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/keyboard.d.ts +6 -4
- package/dist/keyboard.d.ts.map +1 -1
- package/dist/keyboard.js +6 -5
- package/dist/keyboard.js.map +1 -1
- package/dist/prefs.d.ts +13 -1
- package/dist/prefs.d.ts.map +1 -1
- package/dist/prefs.js +30 -3
- package/dist/prefs.js.map +1 -1
- package/dist/standalone/kgdiff-viewer.js +4 -4
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +84 -61
- package/dist/styles.js.map +1 -1
- package/dist/ui/context.d.ts +2 -0
- package/dist/ui/context.d.ts.map +1 -1
- package/dist/ui/context.js.map +1 -1
- package/dist/ui/dock.d.ts +17 -0
- package/dist/ui/dock.d.ts.map +1 -0
- package/dist/ui/dock.js +151 -0
- package/dist/ui/dock.js.map +1 -0
- package/dist/ui/header.d.ts +7 -2
- package/dist/ui/header.d.ts.map +1 -1
- package/dist/ui/header.js +24 -133
- package/dist/ui/header.js.map +1 -1
- package/dist/ui/popovers.d.ts.map +1 -1
- package/dist/ui/popovers.js +3 -2
- package/dist/ui/popovers.js.map +1 -1
- package/dist/ui/sidebar.d.ts.map +1 -1
- package/dist/ui/sidebar.js +2 -1
- package/dist/ui/sidebar.js.map +1 -1
- package/dist/ui/stage.d.ts +8 -0
- package/dist/ui/stage.d.ts.map +1 -1
- package/dist/ui/stage.js +66 -93
- package/dist/ui/stage.js.map +1 -1
- package/dist/ui/swatch.d.ts +7 -0
- package/dist/ui/swatch.d.ts.map +1 -0
- package/dist/ui/swatch.js +26 -0
- package/dist/ui/swatch.js.map +1 -0
- package/package.json +6 -6
- package/src/controller.ts +332 -56
- package/src/element.ts +220 -29
- package/src/framing.ts +65 -1
- package/src/hash-sync.ts +20 -6
- package/src/i18n.ts +15 -14
- package/src/index.ts +65 -7
- package/src/keyboard.ts +12 -6
- package/src/prefs.ts +45 -5
- package/src/styles.ts +84 -61
- package/src/ui/context.ts +2 -0
- package/src/ui/dock.ts +198 -0
- package/src/ui/header.ts +38 -168
- package/src/ui/popovers.ts +3 -2
- package/src/ui/sidebar.ts +2 -1
- package/src/ui/stage.ts +88 -112
- package/src/ui/swatch.ts +31 -0
package/dist/controller.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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, {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
446
|
+
if (!fit || !camera)
|
|
391
447
|
return;
|
|
392
|
-
this.#
|
|
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.#
|
|
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
|
-
// ───
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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);
|