@kgdiff/viewer 0.3.0 → 0.5.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 (120) hide show
  1. package/README.md +9 -8
  2. package/dist/context.d.ts +42 -0
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/context.js +87 -0
  5. package/dist/context.js.map +1 -0
  6. package/dist/controller.d.ts +30 -173
  7. package/dist/controller.d.ts.map +1 -1
  8. package/dist/controller.js +94 -1099
  9. package/dist/controller.js.map +1 -1
  10. package/dist/element.d.ts +12 -6
  11. package/dist/element.d.ts.map +1 -1
  12. package/dist/element.js +105 -236
  13. package/dist/element.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 +24 -10
  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/pipeline.d.ts.map +1 -1
  30. package/dist/pipeline.js +3 -2
  31. package/dist/pipeline.js.map +1 -1
  32. package/dist/prefs.d.ts +7 -1
  33. package/dist/prefs.d.ts.map +1 -1
  34. package/dist/prefs.js +15 -3
  35. package/dist/prefs.js.map +1 -1
  36. package/dist/root.d.ts +54 -0
  37. package/dist/root.d.ts.map +1 -0
  38. package/dist/root.js +265 -0
  39. package/dist/root.js.map +1 -0
  40. package/dist/schedule.d.ts +22 -0
  41. package/dist/schedule.d.ts.map +1 -0
  42. package/dist/schedule.js +41 -0
  43. package/dist/schedule.js.map +1 -0
  44. package/dist/session.d.ts +253 -0
  45. package/dist/session.d.ts.map +1 -0
  46. package/dist/session.js +1239 -0
  47. package/dist/session.js.map +1 -0
  48. package/dist/standalone/kgdiff-viewer-compute.js +1 -1
  49. package/dist/standalone/kgdiff-viewer.js +4 -4
  50. package/dist/styles.d.ts +13 -6
  51. package/dist/styles.d.ts.map +1 -1
  52. package/dist/styles.js +270 -295
  53. package/dist/styles.js.map +1 -1
  54. package/dist/theme.d.ts +25 -0
  55. package/dist/theme.d.ts.map +1 -0
  56. package/dist/theme.js +149 -0
  57. package/dist/theme.js.map +1 -0
  58. package/dist/ui/change-list.d.ts +18 -0
  59. package/dist/ui/change-list.d.ts.map +1 -0
  60. package/dist/ui/change-list.js +331 -0
  61. package/dist/ui/change-list.js.map +1 -0
  62. package/dist/ui/context.d.ts +21 -5
  63. package/dist/ui/context.d.ts.map +1 -1
  64. package/dist/ui/context.js.map +1 -1
  65. package/dist/ui/dock.d.ts +17 -0
  66. package/dist/ui/dock.d.ts.map +1 -0
  67. package/dist/ui/dock.js +151 -0
  68. package/dist/ui/dock.js.map +1 -0
  69. package/dist/ui/filters.d.ts +9 -0
  70. package/dist/ui/filters.d.ts.map +1 -0
  71. package/dist/ui/filters.js +68 -0
  72. package/dist/ui/filters.js.map +1 -0
  73. package/dist/ui/header.d.ts +7 -2
  74. package/dist/ui/header.d.ts.map +1 -1
  75. package/dist/ui/header.js +26 -135
  76. package/dist/ui/header.js.map +1 -1
  77. package/dist/ui/popovers.d.ts.map +1 -1
  78. package/dist/ui/popovers.js +3 -2
  79. package/dist/ui/popovers.js.map +1 -1
  80. package/dist/ui/search.d.ts +13 -0
  81. package/dist/ui/search.d.ts.map +1 -0
  82. package/dist/ui/search.js +191 -0
  83. package/dist/ui/search.js.map +1 -0
  84. package/dist/ui/sidebar.d.ts +8 -16
  85. package/dist/ui/sidebar.d.ts.map +1 -1
  86. package/dist/ui/sidebar.js +15 -538
  87. package/dist/ui/sidebar.js.map +1 -1
  88. package/dist/ui/stage.d.ts +4 -0
  89. package/dist/ui/stage.d.ts.map +1 -1
  90. package/dist/ui/stage.js +42 -95
  91. package/dist/ui/stage.js.map +1 -1
  92. package/dist/ui/swatch.d.ts +7 -0
  93. package/dist/ui/swatch.d.ts.map +1 -0
  94. package/dist/ui/swatch.js +26 -0
  95. package/dist/ui/swatch.js.map +1 -0
  96. package/package.json +6 -6
  97. package/src/context.ts +106 -0
  98. package/src/controller.ts +120 -1196
  99. package/src/element.ts +114 -254
  100. package/src/hash-sync.ts +20 -6
  101. package/src/i18n.ts +15 -14
  102. package/src/index.ts +24 -10
  103. package/src/keyboard.ts +12 -6
  104. package/src/pipeline.ts +4 -1
  105. package/src/prefs.ts +19 -4
  106. package/src/root.ts +308 -0
  107. package/src/schedule.ts +43 -0
  108. package/src/session.ts +1386 -0
  109. package/src/styles.ts +277 -299
  110. package/src/theme.ts +167 -0
  111. package/src/ui/change-list.ts +401 -0
  112. package/src/ui/context.ts +23 -5
  113. package/src/ui/dock.ts +198 -0
  114. package/src/ui/filters.ts +79 -0
  115. package/src/ui/header.ts +40 -170
  116. package/src/ui/popovers.ts +3 -2
  117. package/src/ui/search.ts +220 -0
  118. package/src/ui/sidebar.ts +28 -667
  119. package/src/ui/stage.ts +50 -115
  120. package/src/ui/swatch.ts +31 -0
@@ -1,30 +1,20 @@
1
- import { KgdiffError } from '@kgdiff/core';
2
1
  import { labelFootprint, } from '@kgdiff/renderer';
3
- import { buildScene, computeFocus, createViewState, createVisualResolver, frameBounds, getTheme, } from '@kgdiff/scene';
4
- import { computeNow, computeStatus, loadCompute } from './compute-loader.js';
2
+ import { createViewState, frameBounds, } from '@kgdiff/scene';
5
3
  import { chromeInsets, FRAME_PADDING, freeView, NO_INSETS, zoomFit, } from './framing.js';
6
- import { translate } from './i18n.js';
7
- import { buildList, EMPTY_LIST, stepHunk } from './list-model.js';
8
- import { readPayload, runLoad } from './pipeline.js';
9
- import { toLocale, toMode, toPalette, toTheme } from './prefs.js';
10
4
  import { renderBackend } from './renderer-port.js';
11
- import { ReplayClock } from './replay.js';
12
- import { ReviewStore, reviewKey } from './review-store.js';
13
- import { SearchIndex } from './search.js';
5
+ import { DiffSession, padRect, } from './session.js';
14
6
  /** Camera follow is suppressed this long after the user moved the camera (ms). */
15
7
  export const USER_CAMERA_GRACE = 2000;
16
8
  /** The camera follows when less than this share of the target bounds is in view. */
17
9
  export const FOLLOW_COVERAGE = 0.7;
18
- /** Spotlight emphasis restarts here when switching directly between two hunks. */
19
- const SWITCH_EMPHASIS = 0.35;
20
10
  /** Flights to a change never zoom in further, so its neighbourhood stays readable. */
21
11
  export const FLY_MAX_ZOOM = 1.8;
22
12
  /** Fitting everything never zooms in further (the renderer's default). */
23
13
  export const FIT_MAX_ZOOM = 2.5;
24
14
  /** Minimap marks are re-rasterized at most this often while replaying (ms). */
25
15
  const MINIMAP_INTERVAL = 200;
26
- class Superseded extends Error {
27
- }
16
+ /** Everything counts as in view (the minimap hides) from this share of the content bounds on. */
17
+ export const OVERVIEW_COVERAGE = 0.98;
28
18
  /** Share of `bounds` inside `view`, in [0, 1]. */
29
19
  export function coverage(bounds, view) {
30
20
  const w = Math.min(bounds.x + bounds.width, view.x + view.width) - Math.max(bounds.x, view.x);
@@ -39,40 +29,7 @@ export function coverage(bounds, view) {
39
29
  }
40
30
  return w > 0 && h > 0 ? (w * h) / area : 0;
41
31
  }
42
- const ease = (u) => 1 - (1 - u) ** 3;
43
- export class ViewerController {
44
- status = 'idle';
45
- /** Steps of the current load, in order; `step` is the running one. */
46
- steps = [];
47
- step = null;
48
- error = null;
49
- data = null;
50
- layout = null;
51
- scene = null;
52
- resolver = null;
53
- list = EMPTY_LIST;
54
- mode = 'overlay';
55
- themePreference = 'auto';
56
- palette = 'default';
57
- locale = 'en';
58
- systemDark = true;
59
- reducedMotion = false;
60
- /** Preferences set explicitly by the embedder (a report's `view` does not override them). */
61
- explicit = new Set();
62
- selectedHunk = -1;
63
- selectedNode = -1;
64
- /** An edge emphasized from the detail panel (drawn as hovered). */
65
- pinnedEdge = -1;
66
- hoverNode = -1;
67
- hoverEdge = -1;
68
- hoverGroup = -1;
69
- families = new Set();
70
- hops = 1;
71
- expanded = new Set();
72
- focus = null;
73
- clock = null;
74
- split = 0.5;
75
- emphasis = 1;
32
+ export class ViewerController extends DiffSession {
76
33
  /**
77
34
  * Keep the view fitted (embeds without controls): fit again when the canvas is resized unless
78
35
  * the user moved the camera since, and at the start of every loop.
@@ -80,24 +37,17 @@ export class ViewerController {
80
37
  autoFit = false;
81
38
  /** Canvas clicks select (and double clicks fly); off for embeds without controls. */
82
39
  selectOnClick = true;
40
+ /** The whole content is in view (the minimap has nothing to add). */
41
+ overview = true;
83
42
  #labelsInside = false;
84
- #review = null;
85
- #searchIndex = null;
86
43
  #renderer = null;
87
44
  #minimap = null;
88
45
  #unbind = [];
89
46
  #frames = { main: null, before: null, after: null };
90
- #fadingHunk = -1;
91
- #emphasisAnim = null;
92
- #rafId = 0;
93
- #lastTick = 0;
94
- #frameDirty = false;
95
47
  #minimapDirty = true;
96
48
  #lastMinimap = Number.NEGATIVE_INFINITY;
97
49
  #lastUserCamera = Number.NEGATIVE_INFINITY;
98
- #followed = '';
99
- #loadToken = 0;
100
- #pendingHunk = null;
50
+ #boundsCache = null;
101
51
  #chrome = null;
102
52
  /** Camera kept across detach / attach (element moved in the DOM). */
103
53
  #savedCamera = null;
@@ -106,245 +56,11 @@ export class ViewerController {
106
56
  /** The user panned or zoomed since the last fit. */
107
57
  #userMoved = false;
108
58
  #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;
116
- #listeners = new Set();
117
- #pendingTopics = new Set();
118
- #flushQueued = false;
119
- #destroyed = false;
120
- #host;
121
- #deps;
59
+ #now;
122
60
  constructor(host, deps) {
123
- this.#host = host;
124
- this.#deps = deps;
125
- }
126
- // ─── Subscriptions ────────────────────────────────────────────────────────
127
- subscribe(listener) {
128
- this.#listeners.add(listener);
129
- return () => this.#listeners.delete(listener);
130
- }
131
- #notify(...topics) {
132
- for (const t of topics)
133
- this.#pendingTopics.add(t);
134
- if (this.#flushQueued)
135
- return;
136
- this.#flushQueued = true;
137
- queueMicrotask(() => this.flush());
138
- }
139
- /** Deliver pending notifications now (also used by tests). */
140
- flush() {
141
- this.#flushQueued = false;
142
- if (this.#pendingTopics.size === 0)
143
- return;
144
- const topics = this.#pendingTopics;
145
- this.#pendingTopics = new Set();
146
- for (const fn of this.#listeners)
147
- fn(topics);
148
- }
149
- // ─── Derived state ────────────────────────────────────────────────────────
150
- get themeName() {
151
- if (this.themePreference === 'auto')
152
- return this.systemDark ? 'dark' : 'light';
153
- return this.themePreference;
154
- }
155
- get theme() {
156
- try {
157
- return getTheme(this.themeName, this.palette);
158
- }
159
- catch {
160
- return getTheme('dark', 'default');
161
- }
162
- }
163
- get diff() {
164
- return this.data?.diff ?? null;
165
- }
166
- get hunkCount() {
167
- return this.scene?.hunks.length ?? 0;
168
- }
169
- isReviewed(hunkId) {
170
- return this.#review?.has(hunkId) ?? false;
171
- }
172
- reviewState() {
173
- return this.#review?.state() ?? { reviewed: [], total: 0 };
174
- }
175
- search(query, limit) {
176
- const diff = this.diff;
177
- if (!diff)
178
- return [];
179
- this.#searchIndex ??= new SearchIndex(diff);
180
- return this.#searchIndex.search(query, limit);
181
- }
182
- // ─── Loading ──────────────────────────────────────────────────────────────
183
- async load(options) {
184
- const text = (s) => typeof s === 'object' && s !== null && typeof s.text === 'string';
185
- const parse = text(options?.before) || text(options?.after);
186
- await this.#run(parse ? ['parse', 'match', 'layout', 'scene'] : ['match', 'layout', 'scene'], (onStep) => runLoad(options, onStep));
187
- }
188
- async loadPayload(payload) {
189
- await this.#run(['match', 'layout', 'scene'], (onStep) => readPayload(payload, onStep));
190
- }
191
- async #run(planned, work) {
192
- const token = ++this.#loadToken;
193
- this.pause();
194
- this.status = 'loading';
195
- this.error = null;
196
- this.steps = planned;
197
- this.step = null;
198
- this.#notify('status');
199
- const onStep = async (step) => {
200
- if (token !== this.#loadToken || this.#destroyed)
201
- throw new Superseded();
202
- this.step = step;
203
- this.#notify('status');
204
- this.flush();
205
- await this.#deps.yieldToPaint();
206
- if (token !== this.#loadToken || this.#destroyed)
207
- throw new Superseded();
208
- };
209
- try {
210
- const loaded = await work(onStep);
211
- await onStep('scene');
212
- this.#install(loaded);
213
- }
214
- catch (error) {
215
- if (error instanceof Superseded) {
216
- throw new DOMException('Superseded by a newer load.', 'AbortError');
217
- }
218
- const err = error instanceof Error ? error : new KgdiffError('invalid-input', String(error));
219
- if (token === this.#loadToken)
220
- this.fail(err);
221
- throw err;
222
- }
223
- }
224
- /** Show an error state (bad payload, failed load). */
225
- fail(error) {
226
- this.#loadToken++;
227
- this.clock?.pause();
228
- this.#restartAt = Number.NaN;
229
- this.#resume = false;
230
- this.status = 'error';
231
- this.error = error;
232
- this.step = null;
233
- // Nothing stale stays on screen next to the error.
234
- this.data = null;
235
- this.layout = null;
236
- this.scene = null;
237
- this.resolver = null;
238
- this.list = EMPTY_LIST;
239
- this.clock = null;
240
- this.focus = null;
241
- this.#review = null;
242
- this.selectedHunk = this.selectedNode = this.pinnedEdge = -1;
243
- this.#notify('status', 'data', 'selection', 'filters', 'review', 'replay', 'focus');
244
- this.#host.error(error);
245
- }
246
- #install(loaded) {
247
- const { diff, layout, view } = loaded;
248
- this.data = loaded;
249
- this.layout = layout;
250
- this.#searchIndex = null;
251
- this.families.clear();
252
- this.expanded.clear();
253
- this.hops = Math.max(0, Math.min(2, loaded.layoutOptions?.contextHops ?? 1));
254
- this.selectedHunk = -1;
255
- this.selectedNode = -1;
256
- this.pinnedEdge = -1;
257
- this.#fadingHunk = -1;
258
- this.emphasis = 1;
259
- this.#emphasisAnim = null;
260
- this.hoverNode = this.hoverEdge = this.hoverGroup = -1;
261
- this.split = 0.5;
262
- this.clock = null;
263
- this.#followed = '';
264
- if (view) {
265
- if (view.theme && !this.explicit.has('theme'))
266
- this.themePreference = view.theme;
267
- if (view.palette && !this.explicit.has('palette'))
268
- this.palette = view.palette;
269
- if (view.locale && !this.explicit.has('locale'))
270
- this.locale = view.locale;
271
- }
272
- const previousMode = this.mode;
273
- if (!this.explicit.has('mode')) {
274
- const threshold = loaded.layoutOptions?.largeGraphThreshold ?? 3000;
275
- const large = layout.scope === 'changes' || diff.nodes.length > threshold;
276
- this.mode = view?.mode ?? (large ? 'changes' : 'overlay');
277
- }
278
- this.#review = new ReviewStore(this.#deps.storage, reviewKey(diff.delta.base.hash, diff.delta.target.hash), diff.hunks.map((h) => h.id));
279
- this.#rebuildScene();
280
- this.status = 'ready';
281
- this.step = null;
282
- if (layout.scope === 'changes' && computeStatus() === 'unknown') {
283
- // Learn early whether out-of-layout groups can be expanded (standalone reports).
284
- loadCompute().then(() => this.#notify('focus'), () => this.#notify('focus'));
285
- }
286
- this.#notify('status', 'data', 'mode', 'selection', 'filters', 'review', 'replay', 'theme', 'locale', 'focus', 'swipe');
287
- // A new diff starts fitted, after the UI has shown its chrome (the notification above is
288
- // delivered first) and once the canvas has a size (see `layoutChanged`).
289
- this.#needsFit = true;
290
- this.#savedCamera = null;
291
- queueMicrotask(() => this.layoutChanged());
292
- this.#host.ready(diff.stats);
293
- if (this.mode !== previousMode)
294
- this.#host.mode(this.mode);
295
- const pending = this.#pendingHunk;
296
- this.#pendingHunk = null;
297
- if (pending !== null)
298
- this.selectHunkById(pending.id, { fly: pending.fly });
299
- else
300
- this.#autoplay();
301
- }
302
- /** (Re)build scene, resolver, list, clock and focus; selection is carried over by id. */
303
- #rebuildScene() {
304
- const data = this.data;
305
- const layout = this.layout;
306
- if (!data || !layout)
307
- return;
308
- const old = this.scene;
309
- const keepHunk = old && this.selectedHunk >= 0 ? old.hunks[this.selectedHunk]?.id : undefined;
310
- const keepNode = old && this.selectedNode >= 0 ? old.nodes[this.selectedNode]?.id : undefined;
311
- const keepEdge = old && this.pinnedEdge >= 0 ? old.edges[this.pinnedEdge]?.id : undefined;
312
- const scene = buildScene(data.diff, layout, { theme: this.theme, locale: this.locale });
313
- this.scene = scene;
314
- this.resolver = createVisualResolver(scene);
315
- this.#frames.main = this.#frames.before = this.#frames.after = null;
316
- const prevClock = this.clock;
317
- this.clock = new ReplayClock(scene.timeline);
318
- if (prevClock) {
319
- this.clock.setSpeed(prevClock.speed);
320
- this.clock.seek(prevClock.t);
321
- if (prevClock.playing)
322
- this.clock.play();
323
- }
324
- this.selectedHunk = keepHunk !== undefined ? (scene.hunkIndex.get(keepHunk) ?? -1) : -1;
325
- this.selectedNode = keepNode !== undefined ? (scene.nodeIndex.get(keepNode) ?? -1) : -1;
326
- this.pinnedEdge = keepEdge !== undefined ? (scene.edgeIndex.get(keepEdge) ?? -1) : -1;
327
- this.#fadingHunk = -1;
328
- this.hoverNode = this.hoverEdge = this.hoverGroup = -1;
329
- this.list = buildList(scene, this.families);
330
- this.#updateFocus();
331
- this.#renderer?.setTheme(scene.theme);
332
- this.#minimapDirty = true;
333
- this.invalidate();
334
- }
335
- #updateFocus() {
336
- const scene = this.scene;
337
- if (!scene || this.mode !== 'changes') {
338
- this.focus = null;
339
- return;
340
- }
341
- const expanded = new Set();
342
- for (const id of this.expanded) {
343
- const i = scene.nodeIndex.get(id);
344
- if (i !== undefined)
345
- expanded.add(i);
346
- }
347
- this.focus = computeFocus(scene, { hops: this.hops, expanded });
61
+ super(host, deps);
62
+ this.#now = deps.now;
63
+ this.addView(this);
348
64
  }
349
65
  // ─── Renderer ─────────────────────────────────────────────────────────────
350
66
  attach(stage, minimapHost) {
@@ -364,6 +80,7 @@ export class ViewerController {
364
80
  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 }) => {
365
81
  if (source === 'user')
366
82
  this.noteUserCamera();
83
+ this.#updateOverview();
367
84
  }));
368
85
  this.#minimapDirty = true;
369
86
  this.invalidate();
@@ -372,9 +89,7 @@ export class ViewerController {
372
89
  // A pending initial fit still runs after re-attach (`layoutChanged`), overriding this.
373
90
  if (this.#renderer && this.status === 'ready')
374
91
  this.#savedCamera = this.#renderer.camera.get();
375
- if (this.#rafId)
376
- this.#deps.cancelFrame(this.#rafId);
377
- this.#rafId = 0;
92
+ this.sleep();
378
93
  for (const fn of this.#unbind.splice(0))
379
94
  fn();
380
95
  this.#minimap?.destroy();
@@ -383,9 +98,8 @@ export class ViewerController {
383
98
  this.#renderer = null;
384
99
  }
385
100
  destroy() {
386
- this.#destroyed = true;
101
+ super.destroy();
387
102
  this.detach();
388
- this.#listeners.clear();
389
103
  }
390
104
  get camera() {
391
105
  return this.#renderer?.camera ?? null;
@@ -410,6 +124,7 @@ export class ViewerController {
410
124
  * `autoFit`) fit again unless the user moved the camera since the last fit.
411
125
  */
412
126
  layoutChanged() {
127
+ this.#updateOverview();
413
128
  if (this.status !== 'ready')
414
129
  return;
415
130
  if (!this.#needsFit) {
@@ -418,7 +133,7 @@ export class ViewerController {
418
133
  return;
419
134
  }
420
135
  // A selection made before the first layout (deep link, `kgdiff-ready` handler) wins.
421
- const target = this.#selectionBounds();
136
+ const target = this.selectionBounds();
422
137
  if (target) {
423
138
  if (!this.#frameInsets(target, FLY_MAX_ZOOM))
424
139
  return;
@@ -434,86 +149,12 @@ export class ViewerController {
434
149
  }
435
150
  /** The user panned / zoomed: suspend replay camera follow for `USER_CAMERA_GRACE`. */
436
151
  noteUserCamera() {
437
- this.#lastUserCamera = this.#deps.now();
152
+ this.#lastUserCamera = this.#now();
438
153
  this.#userMoved = true;
439
154
  }
440
- // ─── Frame loop ───────────────────────────────────────────────────────────
441
- /** Redraw on the next animation frame. */
442
- invalidate() {
443
- this.#frameDirty = true;
444
- this.#schedule();
445
- }
446
- #schedule() {
447
- if (this.#rafId !== 0 || this.#destroyed)
448
- return;
449
- this.#rafId = this.#deps.requestFrame(this.#tick) || 0;
450
- }
451
- #tick = () => {
452
- this.#rafId = 0;
453
- if (this.#destroyed)
454
- return;
455
- const now = this.#deps.now();
456
- const dt = this.#lastTick > 0 ? now - this.#lastTick : 0;
457
- let animating = false;
458
- const clock = this.clock;
459
- if (clock?.playing && this.mode === 'replay') {
460
- clock.advance(dt);
461
- this.#frameDirty = true;
462
- this.#notify('replay');
463
- this.#follow();
464
- if (clock.playing)
465
- animating = true;
466
- else if (clock.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;
474
- }
475
- if (this.#emphasisAnim) {
476
- const a = this.#emphasisAnim;
477
- const u = a.duration > 0 ? Math.min(1, (now - a.start) / a.duration) : 1;
478
- this.emphasis = a.from + (a.to - a.from) * ease(u);
479
- this.#frameDirty = true;
480
- if (u >= 1) {
481
- this.#emphasisAnim = null;
482
- if (this.#fadingHunk >= 0) {
483
- this.#fadingHunk = -1;
484
- this.emphasis = 1;
485
- }
486
- }
487
- else
488
- animating = true;
489
- }
490
- if (this.#frameDirty)
491
- this.draw(now);
492
- if (animating) {
493
- this.#lastTick = now;
494
- this.#schedule();
495
- }
496
- else
497
- this.#lastTick = 0;
498
- };
499
- #view(layer) {
500
- const selected = this.selectedHunk >= 0 ? this.selectedHunk : this.#fadingHunk;
501
- return createViewState({
502
- layer,
503
- t: this.clock?.t ?? 0,
504
- selectedHunk: selected,
505
- hoveredNode: this.hoverNode,
506
- hoveredEdge: this.hoverEdge >= 0 ? this.hoverEdge : this.pinnedEdge,
507
- selectedNode: this.selectedNode,
508
- focus: this.focus,
509
- families: this.families,
510
- reducedMotion: this.reducedMotion,
511
- emphasis: this.emphasis,
512
- });
513
- }
155
+ // ─── SessionView ──────────────────────────────────────────────────────────
514
156
  /** Resolve the current view and hand it to the renderer (and the minimap). */
515
- draw(now = this.#deps.now()) {
516
- this.#frameDirty = false;
157
+ draw(now = this.#now()) {
517
158
  const resolver = this.resolver;
518
159
  const renderer = this.#renderer;
519
160
  if (!resolver || !renderer || this.status !== 'ready')
@@ -521,14 +162,14 @@ export class ViewerController {
521
162
  const f = this.#frames;
522
163
  let mapFrame;
523
164
  if (this.mode === 'swipe') {
524
- f.before = resolver.resolve(this.#view('before'), f.before ?? undefined);
525
- f.after = resolver.resolve(this.#view('after'), f.after ?? undefined);
165
+ f.before = resolver.resolve(this.viewState('before'), f.before ?? undefined);
166
+ f.after = resolver.resolve(this.viewState('after'), f.after ?? undefined);
526
167
  renderer.setFrame({ split: this.split, before: f.before, after: f.after });
527
168
  mapFrame = f.after;
528
169
  }
529
170
  else {
530
171
  const layer = this.mode === 'replay' ? 'replay' : 'union';
531
- f.main = resolver.resolve(this.#view(layer), f.main ?? undefined);
172
+ f.main = resolver.resolve(this.viewState(layer), f.main ?? undefined);
532
173
  renderer.setFrame(f.main);
533
174
  mapFrame = f.main;
534
175
  }
@@ -540,216 +181,41 @@ export class ViewerController {
540
181
  this.#lastMinimap = now;
541
182
  }
542
183
  }
543
- #startEmphasis(from) {
544
- if (this.reducedMotion) {
545
- this.emphasis = 1;
546
- this.#emphasisAnim = null;
547
- this.#fadingHunk = -1;
548
- return;
549
- }
550
- this.emphasis = from;
551
- this.#emphasisAnim = {
552
- from,
553
- to: 1,
554
- start: this.#deps.now(),
555
- duration: this.theme.motion.base,
556
- };
184
+ sceneChanged() {
185
+ const f = this.#frames;
186
+ f.main = f.before = f.after = null;
187
+ this.#renderer?.setTheme(this.scene?.theme ?? this.theme);
188
+ this.#minimapDirty = true;
557
189
  }
558
- #fadeOutEmphasis(hunk) {
559
- if (this.reducedMotion || hunk < 0) {
560
- this.#fadingHunk = -1;
561
- this.#emphasisAnim = null;
562
- this.emphasis = 1;
563
- return;
564
- }
565
- this.#fadingHunk = hunk;
566
- this.#emphasisAnim = {
567
- from: this.emphasis,
568
- to: 0,
569
- start: this.#deps.now(),
570
- duration: this.theme.motion.base,
571
- };
190
+ installed() {
191
+ // Fitted after the UI has shown its chrome (the session's notification is delivered
192
+ // first) and once the canvas has a size (see `layoutChanged`).
193
+ this.#needsFit = true;
194
+ this.#savedCamera = null;
195
+ queueMicrotask(() => this.layoutChanged());
572
196
  }
573
- // ─── Mode ─────────────────────────────────────────────────────────────────
574
- setMode(value) {
575
- const mode = toMode(value);
576
- if (mode === null || mode === this.mode)
577
- return;
578
- this.mode = mode;
579
- if (mode !== 'replay') {
580
- this.clock?.pause();
581
- this.#restartAt = Number.NaN;
582
- this.#resume = false;
583
- }
584
- this.#followed = '';
585
- this.#updateFocus();
197
+ drawnChanged() {
198
+ this.#updateOverview();
586
199
  this.#minimapDirty = true;
587
- this.#notify('mode', 'focus', 'replay');
588
- this.invalidate();
589
- if (this.status === 'ready') {
590
- this.#host.mode(mode);
591
- this.#host.announce(translate(this.locale, 'announce.mode', { mode: translate(this.locale, `mode.${mode}`) }));
592
- }
593
- if (mode === 'replay')
594
- this.#autoplay();
595
200
  }
596
- // ─── Selection ────────────────────────────────────────────────────────────
597
- selectHunkById(id, options = {}) {
598
- if (id === null) {
599
- this.#pendingHunk = null;
600
- this.clearSelection();
601
- return;
602
- }
603
- const scene = this.scene;
604
- if (!scene || this.status !== 'ready') {
605
- this.#pendingHunk = { id, fly: options.fly ?? true };
606
- return;
607
- }
608
- const h = scene.hunkIndex.get(id);
609
- if (h !== undefined)
610
- this.selectHunk(h, options);
611
- }
612
- /**
613
- * Select hunk `h` (scene index; -1 clears). `node` focuses one of its nodes in the detail
614
- * panel. `fly` (default true) brings it into view. In replay, seeks to the end of its window.
615
- */
616
- selectHunk(h, options = {}) {
617
- const scene = this.scene;
618
- if (!scene)
619
- return;
620
- if (h < 0 || h >= scene.hunks.length) {
621
- this.clearSelection();
622
- return;
623
- }
624
- const node = options.node ?? -1;
625
- const prev = this.selectedHunk;
626
- const changed = prev !== h || this.selectedNode !== node;
627
- this.selectedHunk = h;
628
- this.selectedNode = node;
629
- this.pinnedEdge = -1;
630
- if (prev !== h) {
631
- this.#fadingHunk = -1;
632
- this.#startEmphasis(prev >= 0 ? SWITCH_EMPHASIS : 0);
633
- if (this.mode === 'replay' && this.clock) {
634
- const end = this.clock.windowEnd(h);
635
- this.clock.pause();
636
- this.#restartAt = Number.NaN;
637
- this.#resume = false;
638
- if (end !== null)
639
- this.clock.seek(end);
640
- this.#followed = '';
641
- this.#notify('replay');
642
- }
643
- }
644
- if (options.fly ?? true)
645
- this.flyToHunk(h);
646
- this.invalidate();
647
- if (!changed)
648
- return;
649
- this.#notify('selection');
650
- const hunk = scene.hunks[h];
651
- if (!hunk)
652
- return;
653
- const nodeId = node >= 0 ? (scene.nodes[node]?.id ?? null) : null;
654
- this.#host.select(hunk.id, nodeId);
655
- if (prev !== h) {
656
- const rank = this.list.rank.get(h);
657
- this.#host.announce(translate(this.locale, 'announce.change', {
658
- i: (rank ?? h) + 1,
659
- n: rank !== undefined ? this.list.order.length : scene.hunks.length,
660
- title: hunk.title,
661
- }));
662
- }
201
+ show(bounds) {
202
+ this.#flyTo(bounds);
663
203
  }
664
- /** Select a node: its hunk when it has one, else the node itself. */
665
- selectNode(i, options = {}) {
666
- const scene = this.scene;
667
- const n = scene?.nodes[i];
668
- if (!scene || !n)
669
- return;
670
- if (n.hunk >= 0) {
671
- this.selectHunk(n.hunk, { ...options, node: i });
672
- return;
673
- }
674
- const prev = this.selectedHunk;
675
- if (prev < 0 && this.selectedNode === i) {
676
- if (options.fly)
677
- this.flyToNode(i);
204
+ /** Follow `bounds` (replay): fly unless the user steered recently or it is mostly visible. */
205
+ follow(bounds) {
206
+ if (this.#now() - this.#lastUserCamera < USER_CAMERA_GRACE)
678
207
  return;
679
- }
680
- this.selectedHunk = -1;
681
- this.selectedNode = i;
682
- this.pinnedEdge = -1;
683
- if (prev >= 0)
684
- this.#fadeOutEmphasis(prev);
685
- if (options.fly)
686
- this.flyToNode(i);
687
- this.invalidate();
688
- this.#notify('selection');
689
- this.#host.select(null, n.id);
690
- this.#host.announce(translate(this.locale, 'announce.node', {
691
- label: n.label,
692
- status: translate(this.locale, `status.${n.status}`),
693
- }));
694
- }
695
- clearSelection() {
696
- if (this.selectedHunk < 0 && this.selectedNode < 0 && this.pinnedEdge < 0)
208
+ const free = this.#freeWorld(bounds);
209
+ if (!free || coverage(bounds, free) >= FOLLOW_COVERAGE)
697
210
  return;
698
- const prev = this.selectedHunk;
699
- this.selectedHunk = -1;
700
- this.selectedNode = -1;
701
- this.pinnedEdge = -1;
702
- this.#fadeOutEmphasis(prev);
703
- this.invalidate();
704
- this.#notify('selection');
705
- this.#host.select(null, null);
706
- this.#host.announce(translate(this.locale, 'announce.cleared'));
707
- }
708
- next() {
709
- this.#stepSelection(1);
710
- }
711
- prev() {
712
- this.#stepSelection(-1);
713
- }
714
- #stepSelection(direction) {
715
- const h = stepHunk(this.list, this.selectedHunk, direction);
716
- if (h >= 0)
717
- this.selectHunk(h);
718
- }
719
- first() {
720
- const h = this.list.order[0];
721
- if (h !== undefined)
722
- this.selectHunk(h);
211
+ this.#flyTo(bounds);
723
212
  }
724
- last() {
725
- const h = this.list.order[this.list.order.length - 1];
726
- if (h !== undefined)
727
- this.selectHunk(h);
213
+ looped() {
214
+ if (this.autoFit)
215
+ void this.fit();
728
216
  }
729
- /** Emphasize an edge (from the detail panel) and fly to it. */
730
- pinEdge(j) {
731
- const scene = this.scene;
732
- const e = scene?.edges[j];
733
- if (!scene || !e)
734
- return;
735
- this.pinnedEdge = j;
736
- const ids = [e.s, e.t, e.sb, e.tb];
737
- let x0 = Number.POSITIVE_INFINITY;
738
- let y0 = Number.POSITIVE_INFINITY;
739
- let x1 = Number.NEGATIVE_INFINITY;
740
- let y1 = Number.NEGATIVE_INFINITY;
741
- for (const i of ids) {
742
- const n = scene.nodes[i];
743
- if (!n)
744
- continue;
745
- x0 = Math.min(x0, n.x - n.r);
746
- y0 = Math.min(y0, n.y - n.r);
747
- x1 = Math.max(x1, n.x + n.r);
748
- y1 = Math.max(y1, n.y + n.r);
749
- }
750
- if (x0 <= x1)
751
- this.#flyTo(padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }));
752
- this.invalidate();
217
+ viewport() {
218
+ return this.#renderer?.camera.viewport() ?? null;
753
219
  }
754
220
  // ─── Camera ───────────────────────────────────────────────────────────────
755
221
  /** Insets keeping `bounds` clear of the chrome over the canvas, or null before layout. */
@@ -778,76 +244,6 @@ export class ViewerController {
778
244
  const insets = this.#frameInsets(bounds, FLY_MAX_ZOOM) ?? NO_INSETS;
779
245
  return freeView(camera.viewport(), camera.get().k, insets);
780
246
  }
781
- /** “Change 3 of 23: …” for a replayed hunk, “Restructured: 722 changes” for a chapter. */
782
- #announceTarget(kind, index) {
783
- const scene = this.scene;
784
- const clock = this.clock;
785
- if (!scene || !clock)
786
- return;
787
- if (kind === 'chapter') {
788
- const ch = scene.timeline.chapters[index];
789
- if (!ch)
790
- return;
791
- this.#host.announce(translate(this.locale, 'announce.chapter', {
792
- phase: translate(this.locale, `phase.${ch.phase}`),
793
- n: ch.hunks.length,
794
- }));
795
- return;
796
- }
797
- const hunk = scene.hunks[index];
798
- const order = clock.timeline.windows.findIndex((w) => w.hunk === index);
799
- if (!hunk || order < 0)
800
- return;
801
- this.#host.announce(translate(this.locale, 'announce.change', {
802
- i: order + 1,
803
- n: clock.total,
804
- title: hunk.title,
805
- }));
806
- }
807
- /** Follow `hunks` (replay): fly unless the user steered recently or they are mostly visible. */
808
- #followHunks(hunks) {
809
- const scene = this.scene;
810
- if (!scene || hunks.length === 0)
811
- return;
812
- if (this.#deps.now() - this.#lastUserCamera < USER_CAMERA_GRACE)
813
- return;
814
- const bounds = frameBounds(scene, hunks);
815
- const free = this.#freeWorld(bounds);
816
- if (!free || coverage(bounds, free) >= FOLLOW_COVERAGE)
817
- return;
818
- this.#flyTo(bounds);
819
- }
820
- #hunkBounds(h) {
821
- const scene = this.scene;
822
- return scene?.hunks[h] ? frameBounds(scene, [h]) : null;
823
- }
824
- #nodeBounds(i) {
825
- const n = this.scene?.nodes[i];
826
- return n ? padRect({ x: n.x - n.r, y: n.y - n.r, width: 2 * n.r, height: 2 * n.r }) : null;
827
- }
828
- /** Camera target of the current selection, or null. */
829
- #selectionBounds() {
830
- if (this.selectedHunk >= 0)
831
- return this.#hunkBounds(this.selectedHunk);
832
- if (this.selectedNode >= 0)
833
- return this.#nodeBounds(this.selectedNode);
834
- return null;
835
- }
836
- flyToHunk(h) {
837
- const b = this.#hunkBounds(h);
838
- if (b)
839
- this.#flyTo(b);
840
- }
841
- flyToNode(i) {
842
- const b = this.#nodeBounds(i);
843
- if (b)
844
- this.#flyTo(b);
845
- }
846
- flyToSelection() {
847
- const b = this.#selectionBounds();
848
- if (b)
849
- this.#flyTo(b);
850
- }
851
247
  /**
852
248
  * Fit everything visible (the focus in changes-only mode), labels included, into the free part
853
249
  * of the canvas. Before the canvas is laid out it falls back to the renderer's own fit.
@@ -905,45 +301,36 @@ export class ViewerController {
905
301
  }
906
302
  return { bounds: fit.bounds, insets };
907
303
  }
304
+ /** Bounds of what is drawn: the scene, or the visible part of the `changes` scope's focus. */
305
+ #contentBounds() {
306
+ const scene = this.scene;
307
+ if (!scene)
308
+ return null;
309
+ const focus = this.focus;
310
+ const cached = this.#boundsCache;
311
+ if (cached && cached.scene === scene && cached.focus === focus)
312
+ return cached.rect;
313
+ const rect = focus ? focusBounds(scene, focus) : frameBounds(scene);
314
+ this.#boundsCache = { scene, focus, rect };
315
+ return rect;
316
+ }
317
+ /** Whether everything drawn is in view; the minimap hides while it is. */
318
+ #updateOverview() {
319
+ const bounds = this.#contentBounds();
320
+ const view = this.#renderer?.camera.viewport();
321
+ const next = !bounds || !view || coverage(bounds, view) >= OVERVIEW_COVERAGE;
322
+ if (next === this.overview)
323
+ return;
324
+ this.overview = next;
325
+ this.notify('overview');
326
+ }
908
327
  zoom(direction) {
909
328
  this.noteUserCamera();
910
329
  this.#renderer?.camera.zoomBy(direction > 0 ? 1.4 : 1 / 1.4);
911
330
  }
912
- /** Replay: fly to the playing hunk / chapter when it changes and is mostly out of view. */
913
- #follow() {
914
- const scene = this.scene;
915
- const clock = this.clock;
916
- const camera = this.#renderer?.camera;
917
- if (!scene || !clock || !camera)
918
- return;
919
- const target = clock.activeTarget();
920
- if (target.index < 0)
921
- return;
922
- const key = `${target.kind}:${target.index}`;
923
- if (key === this.#followed)
924
- return;
925
- this.#followed = key;
926
- this.#announceTarget(target.kind, target.index);
927
- this.#followHunks(target.kind === 'chapter'
928
- ? (scene.timeline.chapters[target.index]?.hunks ?? [])
929
- : [target.index]);
930
- }
931
331
  // ─── Canvas events ────────────────────────────────────────────────────────
932
332
  #onHover(pick) {
933
- const node = pick?.type === 'node' ? pick.index : -1;
934
- const edge = pick?.type === 'edge' ? pick.index : -1;
935
- const group = pick?.type === 'collapsed' ? pick.index : -1;
936
- if (node === this.hoverNode && edge === this.hoverEdge && group === this.hoverGroup)
937
- return;
938
- this.hoverNode = node;
939
- this.hoverEdge = edge;
940
- this.hoverGroup = group;
941
- this.#notify('hover');
942
- this.invalidate();
943
- }
944
- /** Clear hover state (pointer left the canvas). */
945
- clearHover() {
946
- this.#onHover(null);
333
+ this.setHover(pick?.type === 'node' ? pick.index : -1, pick?.type === 'edge' ? pick.index : -1, pick?.type === 'collapsed' ? pick.index : -1);
947
334
  }
948
335
  #onCanvasClick(pick) {
949
336
  if (!this.selectOnClick)
@@ -982,422 +369,30 @@ export class ViewerController {
982
369
  else
983
370
  this.flyToNode(pick.index);
984
371
  }
985
- // ─── Filters, focus ───────────────────────────────────────────────────────
986
- toggleFamily(family) {
987
- if (this.families.has(family))
988
- this.families.delete(family);
989
- else
990
- this.families.add(family);
991
- this.#filtersChanged();
992
- }
993
- /** Replace the family filter (empty = all). Setting the current set again clears it. */
994
- setFamilies(families) {
995
- const same = families.length === this.families.size && families.every((f) => this.families.has(f));
996
- this.families.clear();
997
- if (!same)
998
- for (const f of families)
999
- this.families.add(f);
1000
- this.#filtersChanged();
1001
- }
1002
- #filtersChanged() {
1003
- if (this.scene)
1004
- this.list = buildList(this.scene, this.families);
1005
- this.#minimapDirty = true;
1006
- this.#notify('filters');
1007
- this.invalidate();
1008
- }
1009
- setHops(hops) {
1010
- const h = Math.max(0, Math.min(2, Math.round(hops)));
1011
- if (h === this.hops)
1012
- return;
1013
- this.hops = h;
1014
- this.#updateFocus();
1015
- this.#minimapDirty = true;
1016
- this.#notify('focus');
1017
- this.invalidate();
1018
- }
1019
- /**
1020
- * Expand collapsed group `index` of the current focus: its in-scene members become visible
1021
- * and, for a `changes`-scoped layout, the neighbours left out of the layout are placed with
1022
- * `extendLayout` (nothing already placed moves) and the scene is rebuilt.
1023
- */
1024
- expandGroup(index) {
1025
- const group = this.focus?.collapsed[index];
1026
- const scene = this.scene;
1027
- if (!group || !scene)
1028
- return;
1029
- const ids = group.nodes.map((i) => scene.nodes[i]?.id).filter(isString);
1030
- if (!this.canExpand(index)) {
1031
- // Without the layout engine only the members already laid out can be shown.
1032
- if (ids.length > 0)
1033
- void this.#expandIds(ids, []);
1034
- return;
1035
- }
1036
- const owner = group.id.startsWith('near:')
1037
- ? group.id.slice(5)
1038
- : group.id.startsWith('component:')
1039
- ? group.id.slice(10)
1040
- : null;
1041
- void this.#expandIds(ids, owner !== null ? [owner, ...ids] : ids);
1042
- }
1043
- /**
1044
- * The collapsed group nearest the selection (else the view center), or -1. Groups that can
1045
- * be expanded at least partly come first.
1046
- */
1047
- nearestGroup() {
1048
- const groups = this.focus?.collapsed ?? [];
1049
- if (groups.length === 0)
1050
- return -1;
1051
- const sel = this.#selectionBounds();
1052
- const view = this.#renderer?.camera.viewport();
1053
- const at = sel ?? view ?? this.scene?.bounds;
1054
- const cx = at ? at.x + at.width / 2 : 0;
1055
- const cy = at ? at.y + at.height / 2 : 0;
1056
- let best = -1;
1057
- let bestScore = Number.POSITIVE_INFINITY;
1058
- groups.forEach((g, i) => {
1059
- const usable = this.canExpand(i) || g.nodes.length > 0;
1060
- const score = Math.hypot(g.x - cx, g.y - cy) + (usable ? 0 : 1e12);
1061
- if (score < bestScore) {
1062
- bestScore = score;
1063
- best = i;
1064
- }
1065
- });
1066
- return best;
1067
- }
1068
- /** Expand the group nearest the selection (keyboard `e`); announces what happened. */
1069
- expandNearest() {
1070
- const i = this.nearestGroup();
1071
- const group = i >= 0 ? this.focus?.collapsed[i] : undefined;
1072
- if (!group || (!this.canExpand(i) && group.nodes.length === 0)) {
1073
- this.#host.announce(translate(this.locale, group ? 'focus.expandUnavailable' : 'announce.nothingHidden'));
1074
- return;
1075
- }
1076
- this.expandGroup(i);
1077
- this.#host.announce(translate(this.locale, 'announce.expanded', { label: group.label }));
1078
- }
1079
- /**
1080
- * Whether collapsed group `index` can be expanded completely: always, unless some members lie
1081
- * outside a `changes`-scoped layout and the layout engine is known to be unavailable
1082
- * (standalone report without the compute add-on).
1083
- */
1084
- canExpand(index) {
1085
- const group = this.focus?.collapsed[index];
1086
- if (!group)
1087
- return false;
1088
- const outside = this.layout?.scope === 'changes' && group.count > group.nodes.length;
1089
- return !outside || computeStatus() !== 'unavailable';
1090
- }
1091
- /**
1092
- * Make `ids` visible, placing those outside the layout, plus the out-of-layout neighbours of
1093
- * `neighboursOf`. Resolves once done (the layout engine may load lazily first).
1094
- */
1095
- #expandIds(ids, neighboursOf) {
1096
- const data = this.data;
1097
- const layout = this.layout;
1098
- if (!data || !layout)
1099
- return Promise.resolve();
1100
- for (const id of ids)
1101
- this.expanded.add(id);
1102
- const missing = new Set();
1103
- if (layout.scope === 'changes') {
1104
- for (const id of ids)
1105
- if (!layout.positions.has(id))
1106
- missing.add(id);
1107
- for (const id of neighboursOf) {
1108
- for (const e of data.diff.index.edgesOf(id)) {
1109
- for (const end of [e.source, e.target, e.sourceBefore, e.targetBefore]) {
1110
- if (end !== undefined && !layout.positions.has(end))
1111
- missing.add(end);
1112
- }
1113
- }
1114
- }
1115
- }
1116
- if (missing.size === 0) {
1117
- this.#focusChanged();
1118
- return Promise.resolve();
1119
- }
1120
- const place = () => {
1121
- const compute = computeNow();
1122
- const current = this.layout;
1123
- if (!compute || !current || this.data !== data)
1124
- return;
1125
- const todo = [...missing].filter((id) => !current.positions.has(id));
1126
- if (todo.length > 0) {
1127
- this.layout = compute.extendLayout(data.diff, current, todo, data.layoutOptions);
1128
- for (const id of todo)
1129
- this.expanded.add(id);
1130
- this.#rebuildScene();
1131
- this.#notify('data', 'selection', 'replay');
1132
- }
1133
- this.#focusChanged();
1134
- };
1135
- if (computeNow()) {
1136
- place();
1137
- return Promise.resolve();
1138
- }
1139
- // Standalone reports load the layout engine on demand.
1140
- return loadCompute().then(place, () => this.#focusChanged());
1141
- }
1142
- #focusChanged() {
1143
- this.#updateFocus();
1144
- this.#minimapDirty = true;
1145
- this.#notify('focus');
1146
- this.invalidate();
1147
- }
1148
- /** Bring a union node into the scene and the focus (search results), then select it. */
1149
- revealNode(id) {
1150
- if (!this.data || !this.scene)
1151
- return Promise.resolve();
1152
- const select = () => {
1153
- const i = this.scene?.nodeIndex.get(id);
1154
- if (i !== undefined)
1155
- this.selectNode(i, { fly: true });
1156
- };
1157
- if (this.scene.nodeIndex.has(id) && this.#isVisible(id)) {
1158
- select();
1159
- return Promise.resolve();
1160
- }
1161
- return this.#expandIds([id], []).then(select);
1162
- }
1163
- #isVisible(id) {
1164
- const i = this.scene?.nodeIndex.get(id);
1165
- return i !== undefined && (this.focus === null || this.focus.nodes[i] === 1);
1166
- }
1167
- // ─── Replay ───────────────────────────────────────────────────────────────
1168
- play() {
1169
- const clock = this.clock;
1170
- if (!clock || this.status !== 'ready')
1171
- return;
1172
- if (this.mode !== 'replay')
1173
- this.setMode('replay');
1174
- this.#restartAt = Number.NaN;
1175
- this.#resume = false;
1176
- clock.play();
1177
- this.#followed = '';
1178
- this.#lastTick = 0;
1179
- this.#notify('replay');
1180
- this.invalidate();
1181
- }
1182
- /** Pause; a looping replay stays paused (until `play`, a new diff or loop turned on again). */
1183
- pause() {
1184
- this.#restartAt = Number.NaN;
1185
- this.#resume = false;
1186
- if (!this.clock?.playing)
1187
- return;
1188
- this.clock.pause();
1189
- this.#notify('replay');
1190
- this.invalidate();
1191
- }
1192
- togglePlay() {
1193
- if (this.clock?.playing)
1194
- this.pause();
1195
- else
1196
- this.play();
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
- }
1266
- seek(t) {
1267
- const clock = this.clock;
1268
- if (!clock)
1269
- return;
1270
- this.#restartAt = Number.NaN;
1271
- clock.seek(t);
1272
- this.#notify('replay');
1273
- this.#follow();
1274
- this.invalidate();
1275
- }
1276
- stepReplay(direction) {
1277
- const clock = this.clock;
1278
- if (!clock)
1279
- return;
1280
- clock.step(direction);
1281
- this.#restartAt = Number.NaN;
1282
- this.#resume = false;
1283
- this.#followed = '';
1284
- // Follow (and announce) the hunk that just completed: stops sit at window ends.
1285
- const w = clock.timeline.windows[clock.position() - 1];
1286
- if (w) {
1287
- if (clock.timeline.mode === 'chapters')
1288
- this.#announceTarget('chapter', w.chapter);
1289
- else
1290
- this.#announceTarget('hunk', w.hunk);
1291
- this.#followHunks(clock.timeline.mode === 'chapters'
1292
- ? (clock.timeline.chapters[w.chapter]?.hunks ?? [w.hunk])
1293
- : [w.hunk]);
1294
- }
1295
- this.#notify('replay');
1296
- this.invalidate();
1297
- }
1298
- restart() {
1299
- this.clock?.seek(0);
1300
- this.play();
1301
- }
1302
- cycleSpeed() {
1303
- this.clock?.cycleSpeed();
1304
- this.#notify('replay');
1305
- }
1306
- setSpeed(speed) {
1307
- this.clock?.setSpeed(speed);
1308
- this.#notify('replay');
1309
- }
1310
- // ─── Swipe ────────────────────────────────────────────────────────────────
1311
- setSplit(split) {
1312
- const s = Number.isFinite(split) ? Math.min(1, Math.max(0, split)) : 0.5;
1313
- if (s === this.split)
1314
- return;
1315
- this.split = s;
1316
- this.#notify('swipe');
1317
- if (this.mode === 'swipe')
1318
- this.invalidate();
1319
- }
1320
- // ─── Review ───────────────────────────────────────────────────────────────
1321
- setReviewed(hunkId, reviewed) {
1322
- const review = this.#review;
1323
- if (!review?.setReviewed(hunkId, reviewed))
1324
- return;
1325
- const progress = review.state();
1326
- this.#notify('review');
1327
- this.#host.review(hunkId, reviewed, progress);
1328
- const text = translate(this.locale, 'review.progress', {
1329
- done: progress.reviewed.length,
1330
- total: progress.total,
1331
- });
1332
- this.#host.announce(translate(this.locale, reviewed ? 'announce.reviewed' : 'announce.unreviewed', {
1333
- progress: text,
1334
- }));
1335
- }
1336
- /** Another tab changed review progress (`storage` event): re-read it. */
1337
- syncReview(key) {
1338
- if (this.#review?.reload(key))
1339
- this.#notify('review');
1340
- }
1341
- toggleReviewed(hunkId) {
1342
- const id = hunkId ?? (this.selectedHunk >= 0 ? this.scene?.hunks[this.selectedHunk]?.id : undefined);
1343
- if (id !== undefined)
1344
- this.setReviewed(id, !this.isReviewed(id));
1345
- }
1346
- // ─── Appearance ───────────────────────────────────────────────────────────
1347
- #appearance(apply) {
1348
- const name = this.themeName;
1349
- const palette = this.palette;
1350
- const locale = this.locale;
1351
- apply();
1352
- const themeChanged = name !== this.themeName || palette !== this.palette;
1353
- const localeChanged = locale !== this.locale;
1354
- if (themeChanged || localeChanged) {
1355
- if (this.data)
1356
- this.#rebuildScene();
1357
- else
1358
- this.#renderer?.setTheme(this.theme);
1359
- }
1360
- if (themeChanged)
1361
- this.#notify('theme');
1362
- if (localeChanged)
1363
- this.#notify('locale', 'data');
1364
- }
1365
- setThemePreference(pref) {
1366
- this.#appearance(() => {
1367
- this.themePreference = toTheme(pref) ?? 'auto';
1368
- });
1369
- this.#notify('theme');
1370
- }
1371
- setPalette(palette) {
1372
- this.#appearance(() => {
1373
- this.palette = toPalette(palette) ?? 'default';
1374
- });
1375
- }
1376
- setLocale(locale) {
1377
- this.#appearance(() => {
1378
- this.locale = toLocale(locale) ?? 'en';
1379
- });
1380
- }
1381
- setSystemDark(dark) {
1382
- this.#appearance(() => {
1383
- this.systemDark = dark;
1384
- });
1385
- }
1386
- setReducedMotion(reduced) {
1387
- this.reducedMotion = reduced;
1388
- this.invalidate();
1389
- }
1390
- }
1391
- function isString(x) {
1392
- return x !== undefined;
1393
372
  }
1394
373
  function sameInsets(a, b) {
1395
374
  return a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left;
1396
375
  }
1397
- /** Pad a small rect so a node is shown with its neighbourhood. */
1398
- function padRect(r, min = 200, ratio = 1.4) {
1399
- const w = Math.max(min, r.width * ratio);
1400
- const h = Math.max(min, r.height * ratio);
1401
- return { x: r.x + r.width / 2 - w / 2, y: r.y + r.height / 2 - h / 2, width: w, height: h };
376
+ /** Bounds of the visible part of `focus`, with room for labels and the collapsed pills. */
377
+ function focusBounds(scene, focus) {
378
+ let x0 = Number.POSITIVE_INFINITY;
379
+ let y0 = Number.POSITIVE_INFINITY;
380
+ let x1 = Number.NEGATIVE_INFINITY;
381
+ let y1 = Number.NEGATIVE_INFINITY;
382
+ const add = (x, y, rx, top, bottom) => {
383
+ x0 = Math.min(x0, x - rx);
384
+ x1 = Math.max(x1, x + rx);
385
+ y0 = Math.min(y0, y - top);
386
+ y1 = Math.max(y1, y + bottom);
387
+ };
388
+ // Labels sit centered below their node: leave room for a typical one.
389
+ for (const n of scene.nodes)
390
+ if (focus.nodes[n.index] === 1)
391
+ add(n.x, n.y, n.r + 48, n.r, n.r + 24);
392
+ for (const g of focus.collapsed)
393
+ add(g.x, g.y, 48, 12, 12);
394
+ if (!(x0 <= x1))
395
+ return frameBounds(scene);
396
+ return padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }, 160, 1.12);
1402
397
  }
1403
398
  //# sourceMappingURL=controller.js.map