@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.
- package/README.md +9 -8
- package/dist/context.d.ts +42 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +87 -0
- package/dist/context.js.map +1 -0
- package/dist/controller.d.ts +30 -173
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +94 -1099
- package/dist/controller.js.map +1 -1
- package/dist/element.d.ts +12 -6
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +105 -236
- package/dist/element.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 +24 -10
- 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/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +3 -2
- package/dist/pipeline.js.map +1 -1
- package/dist/prefs.d.ts +7 -1
- package/dist/prefs.d.ts.map +1 -1
- package/dist/prefs.js +15 -3
- package/dist/prefs.js.map +1 -1
- package/dist/root.d.ts +54 -0
- package/dist/root.d.ts.map +1 -0
- package/dist/root.js +265 -0
- package/dist/root.js.map +1 -0
- package/dist/schedule.d.ts +22 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +41 -0
- package/dist/schedule.js.map +1 -0
- package/dist/session.d.ts +253 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +1239 -0
- package/dist/session.js.map +1 -0
- package/dist/standalone/kgdiff-viewer-compute.js +1 -1
- package/dist/standalone/kgdiff-viewer.js +4 -4
- package/dist/styles.d.ts +13 -6
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +270 -295
- package/dist/styles.js.map +1 -1
- package/dist/theme.d.ts +25 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +149 -0
- package/dist/theme.js.map +1 -0
- package/dist/ui/change-list.d.ts +18 -0
- package/dist/ui/change-list.d.ts.map +1 -0
- package/dist/ui/change-list.js +331 -0
- package/dist/ui/change-list.js.map +1 -0
- package/dist/ui/context.d.ts +21 -5
- 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/filters.d.ts +9 -0
- package/dist/ui/filters.d.ts.map +1 -0
- package/dist/ui/filters.js +68 -0
- package/dist/ui/filters.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 +26 -135
- 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/search.d.ts +13 -0
- package/dist/ui/search.d.ts.map +1 -0
- package/dist/ui/search.js +191 -0
- package/dist/ui/search.js.map +1 -0
- package/dist/ui/sidebar.d.ts +8 -16
- package/dist/ui/sidebar.d.ts.map +1 -1
- package/dist/ui/sidebar.js +15 -538
- package/dist/ui/sidebar.js.map +1 -1
- package/dist/ui/stage.d.ts +4 -0
- package/dist/ui/stage.d.ts.map +1 -1
- package/dist/ui/stage.js +42 -95
- 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/context.ts +106 -0
- package/src/controller.ts +120 -1196
- package/src/element.ts +114 -254
- package/src/hash-sync.ts +20 -6
- package/src/i18n.ts +15 -14
- package/src/index.ts +24 -10
- package/src/keyboard.ts +12 -6
- package/src/pipeline.ts +4 -1
- package/src/prefs.ts +19 -4
- package/src/root.ts +308 -0
- package/src/schedule.ts +43 -0
- package/src/session.ts +1386 -0
- package/src/styles.ts +277 -299
- package/src/theme.ts +167 -0
- package/src/ui/change-list.ts +401 -0
- package/src/ui/context.ts +23 -5
- package/src/ui/dock.ts +198 -0
- package/src/ui/filters.ts +79 -0
- package/src/ui/header.ts +40 -170
- package/src/ui/popovers.ts +3 -2
- package/src/ui/search.ts +220 -0
- package/src/ui/sidebar.ts +28 -667
- package/src/ui/stage.ts +50 -115
- package/src/ui/swatch.ts +31 -0
package/src/controller.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The canvas side of the viewer: a `DiffSession` (state, commands, events; `session.ts`) plus
|
|
3
|
+
* the one view that draws it (RFC 0001 §1: renderer, camera, framing, minimap), behind the
|
|
4
|
+
* `SessionView` boundary. Phase 3 of the RFC moves this view out into a `StageView` of its own,
|
|
5
|
+
* so one session can drive several canvases.
|
|
3
6
|
*
|
|
4
|
-
* -
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
* The UI subscribes to topic notifications (batched per microtask) and reads public fields.
|
|
7
|
+
* - draws the session's view state into the renderer (and the minimap, throttled while
|
|
8
|
+
* replaying);
|
|
9
|
+
* - frames what the session asks for in the free part of the canvas, clear of the chrome over
|
|
10
|
+
* it (`framing.ts`); the replay camera follows the playing hunk / chapter unless the user
|
|
11
|
+
* moved it in the last 2 s;
|
|
12
|
+
* - fits a new diff once the canvas is laid out, and again on resize with `autoFit`;
|
|
13
|
+
* - turns canvas hovers and clicks into session commands.
|
|
12
14
|
*/
|
|
13
|
-
import type {
|
|
14
|
-
import { KgdiffError } from '@kgdiff/core';
|
|
15
|
-
import type { LayoutResult } from '@kgdiff/layout';
|
|
15
|
+
import type { Rect } from '@kgdiff/core';
|
|
16
16
|
import {
|
|
17
17
|
type Camera,
|
|
18
18
|
type CameraState,
|
|
@@ -22,24 +22,13 @@ import {
|
|
|
22
22
|
type Renderer,
|
|
23
23
|
} from '@kgdiff/renderer';
|
|
24
24
|
import {
|
|
25
|
-
buildScene,
|
|
26
|
-
type ChangeFamily,
|
|
27
|
-
computeFocus,
|
|
28
25
|
createViewState,
|
|
29
|
-
createVisualResolver,
|
|
30
26
|
type FocusState,
|
|
31
27
|
type FrameVisuals,
|
|
32
28
|
frameBounds,
|
|
33
|
-
getTheme,
|
|
34
29
|
type Layer,
|
|
35
|
-
type PaletteName,
|
|
36
30
|
type Scene,
|
|
37
|
-
type Theme,
|
|
38
|
-
type ThemeName,
|
|
39
|
-
type ViewState,
|
|
40
|
-
type VisualResolver,
|
|
41
31
|
} from '@kgdiff/scene';
|
|
42
|
-
import { computeNow, computeStatus, loadCompute } from './compute-loader.js';
|
|
43
32
|
import {
|
|
44
33
|
type ChromeLayout,
|
|
45
34
|
chromeInsets,
|
|
@@ -49,48 +38,18 @@ import {
|
|
|
49
38
|
NO_INSETS,
|
|
50
39
|
zoomFit,
|
|
51
40
|
} from './framing.js';
|
|
52
|
-
import { translate } from './i18n.js';
|
|
53
|
-
import type {
|
|
54
|
-
LoadOptions,
|
|
55
|
-
ReportPayload,
|
|
56
|
-
ReviewState,
|
|
57
|
-
ThemePreference,
|
|
58
|
-
ViewerMode,
|
|
59
|
-
} from './index.js';
|
|
60
|
-
import { buildList, EMPTY_LIST, type ListModel, stepHunk } from './list-model.js';
|
|
61
|
-
import { type LoadedDiff, type LoadStep, readPayload, runLoad } from './pipeline.js';
|
|
62
|
-
import { toLocale, toMode, toPalette, toTheme } from './prefs.js';
|
|
63
41
|
import { renderBackend } from './renderer-port.js';
|
|
64
|
-
import {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
| 'status'
|
|
72
|
-
| 'data'
|
|
73
|
-
| 'mode'
|
|
74
|
-
| 'selection'
|
|
75
|
-
| 'filters'
|
|
76
|
-
| 'review'
|
|
77
|
-
| 'replay'
|
|
78
|
-
| 'hover'
|
|
79
|
-
| 'theme'
|
|
80
|
-
| 'locale'
|
|
81
|
-
| 'focus'
|
|
82
|
-
| 'swipe';
|
|
42
|
+
import {
|
|
43
|
+
DiffSession,
|
|
44
|
+
padRect,
|
|
45
|
+
type SessionDeps,
|
|
46
|
+
type SessionHost,
|
|
47
|
+
type SessionView,
|
|
48
|
+
} from './session.js';
|
|
83
49
|
|
|
84
|
-
export
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
mode(mode: ViewerMode): void;
|
|
88
|
-
review(hunk: string, reviewed: boolean, progress: ReviewState): void;
|
|
89
|
-
error(error: Error): void;
|
|
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
|
-
}
|
|
50
|
+
export type { Preference, Status, Topic } from './session.js';
|
|
51
|
+
export type ControllerHost = SessionHost;
|
|
52
|
+
export type ControllerDeps = SessionDeps;
|
|
94
53
|
|
|
95
54
|
export interface FitOptions {
|
|
96
55
|
readonly animate?: boolean;
|
|
@@ -98,31 +57,18 @@ export interface FitOptions {
|
|
|
98
57
|
readonly padding?: number;
|
|
99
58
|
}
|
|
100
59
|
|
|
101
|
-
export interface ControllerDeps {
|
|
102
|
-
readonly now: () => number;
|
|
103
|
-
readonly requestFrame: (cb: (time: number) => void) => number;
|
|
104
|
-
readonly cancelFrame: (id: number) => void;
|
|
105
|
-
/** Resolves once the browser had a chance to paint (between loading steps). */
|
|
106
|
-
readonly yieldToPaint: () => Promise<void>;
|
|
107
|
-
readonly storage: StorageLike | null;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export type Preference = 'mode' | 'theme' | 'palette' | 'locale';
|
|
111
|
-
|
|
112
60
|
/** Camera follow is suppressed this long after the user moved the camera (ms). */
|
|
113
61
|
export const USER_CAMERA_GRACE = 2000;
|
|
114
62
|
/** The camera follows when less than this share of the target bounds is in view. */
|
|
115
63
|
export const FOLLOW_COVERAGE = 0.7;
|
|
116
|
-
/** Spotlight emphasis restarts here when switching directly between two hunks. */
|
|
117
|
-
const SWITCH_EMPHASIS = 0.35;
|
|
118
64
|
/** Flights to a change never zoom in further, so its neighbourhood stays readable. */
|
|
119
65
|
export const FLY_MAX_ZOOM = 1.8;
|
|
120
66
|
/** Fitting everything never zooms in further (the renderer's default). */
|
|
121
67
|
export const FIT_MAX_ZOOM = 2.5;
|
|
122
68
|
/** Minimap marks are re-rasterized at most this often while replaying (ms). */
|
|
123
69
|
const MINIMAP_INTERVAL = 200;
|
|
124
|
-
|
|
125
|
-
|
|
70
|
+
/** Everything counts as in view (the minimap hides) from this share of the content bounds on. */
|
|
71
|
+
export const OVERVIEW_COVERAGE = 0.98;
|
|
126
72
|
|
|
127
73
|
interface Frames {
|
|
128
74
|
main: FrameVisuals | null;
|
|
@@ -130,13 +76,6 @@ interface Frames {
|
|
|
130
76
|
after: FrameVisuals | null;
|
|
131
77
|
}
|
|
132
78
|
|
|
133
|
-
interface EmphasisAnimation {
|
|
134
|
-
readonly from: number;
|
|
135
|
-
readonly to: number;
|
|
136
|
-
readonly start: number;
|
|
137
|
-
readonly duration: number;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
79
|
/** Share of `bounds` inside `view`, in [0, 1]. */
|
|
141
80
|
export function coverage(bounds: Rect, view: Rect): number {
|
|
142
81
|
const w = Math.min(bounds.x + bounds.width, view.x + view.width) - Math.max(bounds.x, view.x);
|
|
@@ -153,44 +92,7 @@ export function coverage(bounds: Rect, view: Rect): number {
|
|
|
153
92
|
return w > 0 && h > 0 ? (w * h) / area : 0;
|
|
154
93
|
}
|
|
155
94
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
export class ViewerController {
|
|
159
|
-
status: Status = 'idle';
|
|
160
|
-
/** Steps of the current load, in order; `step` is the running one. */
|
|
161
|
-
steps: LoadStep[] = [];
|
|
162
|
-
step: LoadStep | null = null;
|
|
163
|
-
error: Error | null = null;
|
|
164
|
-
|
|
165
|
-
data: LoadedDiff | null = null;
|
|
166
|
-
layout: LayoutResult | null = null;
|
|
167
|
-
scene: Scene | null = null;
|
|
168
|
-
resolver: VisualResolver | null = null;
|
|
169
|
-
list: ListModel = EMPTY_LIST;
|
|
170
|
-
|
|
171
|
-
mode: ViewerMode = 'overlay';
|
|
172
|
-
themePreference: ThemePreference = 'auto';
|
|
173
|
-
palette: PaletteName = 'default';
|
|
174
|
-
locale: Locale = 'en';
|
|
175
|
-
systemDark = true;
|
|
176
|
-
reducedMotion = false;
|
|
177
|
-
/** Preferences set explicitly by the embedder (a report's `view` does not override them). */
|
|
178
|
-
readonly explicit = new Set<Preference>();
|
|
179
|
-
|
|
180
|
-
selectedHunk = -1;
|
|
181
|
-
selectedNode = -1;
|
|
182
|
-
/** An edge emphasized from the detail panel (drawn as hovered). */
|
|
183
|
-
pinnedEdge = -1;
|
|
184
|
-
hoverNode = -1;
|
|
185
|
-
hoverEdge = -1;
|
|
186
|
-
hoverGroup = -1;
|
|
187
|
-
readonly families = new Set<ChangeFamily>();
|
|
188
|
-
hops = 1;
|
|
189
|
-
readonly expanded = new Set<UnionId>();
|
|
190
|
-
focus: FocusState | null = null;
|
|
191
|
-
clock: ReplayClock | null = null;
|
|
192
|
-
split = 0.5;
|
|
193
|
-
emphasis = 1;
|
|
95
|
+
export class ViewerController extends DiffSession implements SessionView {
|
|
194
96
|
/**
|
|
195
97
|
* Keep the view fitted (embeds without controls): fit again when the canvas is resized unless
|
|
196
98
|
* the user moved the camera since, and at the start of every loop.
|
|
@@ -198,25 +100,22 @@ export class ViewerController {
|
|
|
198
100
|
autoFit = false;
|
|
199
101
|
/** Canvas clicks select (and double clicks fly); off for embeds without controls. */
|
|
200
102
|
selectOnClick = true;
|
|
201
|
-
|
|
103
|
+
/** The whole content is in view (the minimap has nothing to add). */
|
|
104
|
+
overview = true;
|
|
202
105
|
|
|
203
|
-
#
|
|
204
|
-
#searchIndex: SearchIndex | null = null;
|
|
106
|
+
#labelsInside = false;
|
|
205
107
|
#renderer: Renderer | null = null;
|
|
206
108
|
#minimap: Minimap | null = null;
|
|
207
109
|
readonly #unbind: (() => void)[] = [];
|
|
208
110
|
readonly #frames: Frames = { main: null, before: null, after: null };
|
|
209
|
-
#fadingHunk = -1;
|
|
210
|
-
#emphasisAnim: EmphasisAnimation | null = null;
|
|
211
|
-
#rafId = 0;
|
|
212
|
-
#lastTick = 0;
|
|
213
|
-
#frameDirty = false;
|
|
214
111
|
#minimapDirty = true;
|
|
215
112
|
#lastMinimap = Number.NEGATIVE_INFINITY;
|
|
216
113
|
#lastUserCamera = Number.NEGATIVE_INFINITY;
|
|
217
|
-
#
|
|
218
|
-
|
|
219
|
-
|
|
114
|
+
#boundsCache: {
|
|
115
|
+
readonly scene: Scene;
|
|
116
|
+
readonly focus: FocusState | null;
|
|
117
|
+
readonly rect: Rect | null;
|
|
118
|
+
} | null = null;
|
|
220
119
|
#chrome: (() => ChromeLayout) | null = null;
|
|
221
120
|
/** Camera kept across detach / attach (element moved in the DOM). */
|
|
222
121
|
#savedCamera: CameraState | null = null;
|
|
@@ -225,273 +124,12 @@ export class ViewerController {
|
|
|
225
124
|
/** The user panned or zoomed since the last fit. */
|
|
226
125
|
#userMoved = false;
|
|
227
126
|
#fitPadding = FRAME_PADDING;
|
|
228
|
-
|
|
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;
|
|
235
|
-
readonly #listeners = new Set<(topics: ReadonlySet<Topic>) => void>();
|
|
236
|
-
#pendingTopics = new Set<Topic>();
|
|
237
|
-
#flushQueued = false;
|
|
238
|
-
#destroyed = false;
|
|
239
|
-
|
|
240
|
-
readonly #host: ControllerHost;
|
|
241
|
-
readonly #deps: ControllerDeps;
|
|
242
|
-
|
|
243
|
-
constructor(host: ControllerHost, deps: ControllerDeps) {
|
|
244
|
-
this.#host = host;
|
|
245
|
-
this.#deps = deps;
|
|
246
|
-
}
|
|
127
|
+
readonly #now: () => number;
|
|
247
128
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
this
|
|
252
|
-
return () => this.#listeners.delete(listener);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
#notify(...topics: Topic[]): void {
|
|
256
|
-
for (const t of topics) this.#pendingTopics.add(t);
|
|
257
|
-
if (this.#flushQueued) return;
|
|
258
|
-
this.#flushQueued = true;
|
|
259
|
-
queueMicrotask(() => this.flush());
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/** Deliver pending notifications now (also used by tests). */
|
|
263
|
-
flush(): void {
|
|
264
|
-
this.#flushQueued = false;
|
|
265
|
-
if (this.#pendingTopics.size === 0) return;
|
|
266
|
-
const topics = this.#pendingTopics;
|
|
267
|
-
this.#pendingTopics = new Set();
|
|
268
|
-
for (const fn of this.#listeners) fn(topics);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// ─── Derived state ────────────────────────────────────────────────────────
|
|
272
|
-
|
|
273
|
-
get themeName(): ThemeName {
|
|
274
|
-
if (this.themePreference === 'auto') return this.systemDark ? 'dark' : 'light';
|
|
275
|
-
return this.themePreference;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
get theme(): Theme {
|
|
279
|
-
try {
|
|
280
|
-
return getTheme(this.themeName, this.palette);
|
|
281
|
-
} catch {
|
|
282
|
-
return getTheme('dark', 'default');
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
get diff(): DiffResult | null {
|
|
287
|
-
return this.data?.diff ?? null;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
get hunkCount(): number {
|
|
291
|
-
return this.scene?.hunks.length ?? 0;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
isReviewed(hunkId: string): boolean {
|
|
295
|
-
return this.#review?.has(hunkId) ?? false;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
reviewState(): ReviewState {
|
|
299
|
-
return this.#review?.state() ?? { reviewed: [], total: 0 };
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
search(query: string, limit?: number): SearchHit[] {
|
|
303
|
-
const diff = this.diff;
|
|
304
|
-
if (!diff) return [];
|
|
305
|
-
this.#searchIndex ??= new SearchIndex(diff);
|
|
306
|
-
return this.#searchIndex.search(query, limit);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// ─── Loading ──────────────────────────────────────────────────────────────
|
|
310
|
-
|
|
311
|
-
async load(options: LoadOptions): Promise<void> {
|
|
312
|
-
const text = (s: unknown): boolean =>
|
|
313
|
-
typeof s === 'object' && s !== null && typeof (s as { text?: unknown }).text === 'string';
|
|
314
|
-
const parse = text(options?.before) || text(options?.after);
|
|
315
|
-
await this.#run(
|
|
316
|
-
parse ? ['parse', 'match', 'layout', 'scene'] : ['match', 'layout', 'scene'],
|
|
317
|
-
(onStep) => runLoad(options, onStep),
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
async loadPayload(payload: ReportPayload): Promise<void> {
|
|
322
|
-
await this.#run(['match', 'layout', 'scene'], (onStep) => readPayload(payload, onStep));
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
async #run(
|
|
326
|
-
planned: LoadStep[],
|
|
327
|
-
work: (onStep: (step: LoadStep) => Promise<void>) => Promise<LoadedDiff>,
|
|
328
|
-
): Promise<void> {
|
|
329
|
-
const token = ++this.#loadToken;
|
|
330
|
-
this.pause();
|
|
331
|
-
this.status = 'loading';
|
|
332
|
-
this.error = null;
|
|
333
|
-
this.steps = planned;
|
|
334
|
-
this.step = null;
|
|
335
|
-
this.#notify('status');
|
|
336
|
-
const onStep = async (step: LoadStep): Promise<void> => {
|
|
337
|
-
if (token !== this.#loadToken || this.#destroyed) throw new Superseded();
|
|
338
|
-
this.step = step;
|
|
339
|
-
this.#notify('status');
|
|
340
|
-
this.flush();
|
|
341
|
-
await this.#deps.yieldToPaint();
|
|
342
|
-
if (token !== this.#loadToken || this.#destroyed) throw new Superseded();
|
|
343
|
-
};
|
|
344
|
-
try {
|
|
345
|
-
const loaded = await work(onStep);
|
|
346
|
-
await onStep('scene');
|
|
347
|
-
this.#install(loaded);
|
|
348
|
-
} catch (error) {
|
|
349
|
-
if (error instanceof Superseded) {
|
|
350
|
-
throw new DOMException('Superseded by a newer load.', 'AbortError');
|
|
351
|
-
}
|
|
352
|
-
const err = error instanceof Error ? error : new KgdiffError('invalid-input', String(error));
|
|
353
|
-
if (token === this.#loadToken) this.fail(err);
|
|
354
|
-
throw err;
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/** Show an error state (bad payload, failed load). */
|
|
359
|
-
fail(error: Error): void {
|
|
360
|
-
this.#loadToken++;
|
|
361
|
-
this.clock?.pause();
|
|
362
|
-
this.#restartAt = Number.NaN;
|
|
363
|
-
this.#resume = false;
|
|
364
|
-
this.status = 'error';
|
|
365
|
-
this.error = error;
|
|
366
|
-
this.step = null;
|
|
367
|
-
// Nothing stale stays on screen next to the error.
|
|
368
|
-
this.data = null;
|
|
369
|
-
this.layout = null;
|
|
370
|
-
this.scene = null;
|
|
371
|
-
this.resolver = null;
|
|
372
|
-
this.list = EMPTY_LIST;
|
|
373
|
-
this.clock = null;
|
|
374
|
-
this.focus = null;
|
|
375
|
-
this.#review = null;
|
|
376
|
-
this.selectedHunk = this.selectedNode = this.pinnedEdge = -1;
|
|
377
|
-
this.#notify('status', 'data', 'selection', 'filters', 'review', 'replay', 'focus');
|
|
378
|
-
this.#host.error(error);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
#install(loaded: LoadedDiff): void {
|
|
382
|
-
const { diff, layout, view } = loaded;
|
|
383
|
-
this.data = loaded;
|
|
384
|
-
this.layout = layout;
|
|
385
|
-
this.#searchIndex = null;
|
|
386
|
-
this.families.clear();
|
|
387
|
-
this.expanded.clear();
|
|
388
|
-
this.hops = Math.max(0, Math.min(2, loaded.layoutOptions?.contextHops ?? 1));
|
|
389
|
-
this.selectedHunk = -1;
|
|
390
|
-
this.selectedNode = -1;
|
|
391
|
-
this.pinnedEdge = -1;
|
|
392
|
-
this.#fadingHunk = -1;
|
|
393
|
-
this.emphasis = 1;
|
|
394
|
-
this.#emphasisAnim = null;
|
|
395
|
-
this.hoverNode = this.hoverEdge = this.hoverGroup = -1;
|
|
396
|
-
this.split = 0.5;
|
|
397
|
-
this.clock = null;
|
|
398
|
-
this.#followed = '';
|
|
399
|
-
if (view) {
|
|
400
|
-
if (view.theme && !this.explicit.has('theme')) this.themePreference = view.theme;
|
|
401
|
-
if (view.palette && !this.explicit.has('palette')) this.palette = view.palette;
|
|
402
|
-
if (view.locale && !this.explicit.has('locale')) this.locale = view.locale;
|
|
403
|
-
}
|
|
404
|
-
const previousMode = this.mode;
|
|
405
|
-
if (!this.explicit.has('mode')) {
|
|
406
|
-
const threshold = loaded.layoutOptions?.largeGraphThreshold ?? 3000;
|
|
407
|
-
const large = layout.scope === 'changes' || diff.nodes.length > threshold;
|
|
408
|
-
this.mode = view?.mode ?? (large ? 'changes' : 'overlay');
|
|
409
|
-
}
|
|
410
|
-
this.#review = new ReviewStore(
|
|
411
|
-
this.#deps.storage,
|
|
412
|
-
reviewKey(diff.delta.base.hash, diff.delta.target.hash),
|
|
413
|
-
diff.hunks.map((h) => h.id),
|
|
414
|
-
);
|
|
415
|
-
this.#rebuildScene();
|
|
416
|
-
this.status = 'ready';
|
|
417
|
-
this.step = null;
|
|
418
|
-
if (layout.scope === 'changes' && computeStatus() === 'unknown') {
|
|
419
|
-
// Learn early whether out-of-layout groups can be expanded (standalone reports).
|
|
420
|
-
loadCompute().then(
|
|
421
|
-
() => this.#notify('focus'),
|
|
422
|
-
() => this.#notify('focus'),
|
|
423
|
-
);
|
|
424
|
-
}
|
|
425
|
-
this.#notify(
|
|
426
|
-
'status',
|
|
427
|
-
'data',
|
|
428
|
-
'mode',
|
|
429
|
-
'selection',
|
|
430
|
-
'filters',
|
|
431
|
-
'review',
|
|
432
|
-
'replay',
|
|
433
|
-
'theme',
|
|
434
|
-
'locale',
|
|
435
|
-
'focus',
|
|
436
|
-
'swipe',
|
|
437
|
-
);
|
|
438
|
-
// A new diff starts fitted, after the UI has shown its chrome (the notification above is
|
|
439
|
-
// delivered first) and once the canvas has a size (see `layoutChanged`).
|
|
440
|
-
this.#needsFit = true;
|
|
441
|
-
this.#savedCamera = null;
|
|
442
|
-
queueMicrotask(() => this.layoutChanged());
|
|
443
|
-
this.#host.ready(diff.stats);
|
|
444
|
-
if (this.mode !== previousMode) this.#host.mode(this.mode);
|
|
445
|
-
const pending = this.#pendingHunk;
|
|
446
|
-
this.#pendingHunk = null;
|
|
447
|
-
if (pending !== null) this.selectHunkById(pending.id, { fly: pending.fly });
|
|
448
|
-
else this.#autoplay();
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/** (Re)build scene, resolver, list, clock and focus; selection is carried over by id. */
|
|
452
|
-
#rebuildScene(): void {
|
|
453
|
-
const data = this.data;
|
|
454
|
-
const layout = this.layout;
|
|
455
|
-
if (!data || !layout) return;
|
|
456
|
-
const old = this.scene;
|
|
457
|
-
const keepHunk = old && this.selectedHunk >= 0 ? old.hunks[this.selectedHunk]?.id : undefined;
|
|
458
|
-
const keepNode = old && this.selectedNode >= 0 ? old.nodes[this.selectedNode]?.id : undefined;
|
|
459
|
-
const keepEdge = old && this.pinnedEdge >= 0 ? old.edges[this.pinnedEdge]?.id : undefined;
|
|
460
|
-
const scene = buildScene(data.diff, layout, { theme: this.theme, locale: this.locale });
|
|
461
|
-
this.scene = scene;
|
|
462
|
-
this.resolver = createVisualResolver(scene);
|
|
463
|
-
this.#frames.main = this.#frames.before = this.#frames.after = null;
|
|
464
|
-
const prevClock = this.clock;
|
|
465
|
-
this.clock = new ReplayClock(scene.timeline);
|
|
466
|
-
if (prevClock) {
|
|
467
|
-
this.clock.setSpeed(prevClock.speed);
|
|
468
|
-
this.clock.seek(prevClock.t);
|
|
469
|
-
if (prevClock.playing) this.clock.play();
|
|
470
|
-
}
|
|
471
|
-
this.selectedHunk = keepHunk !== undefined ? (scene.hunkIndex.get(keepHunk) ?? -1) : -1;
|
|
472
|
-
this.selectedNode = keepNode !== undefined ? (scene.nodeIndex.get(keepNode) ?? -1) : -1;
|
|
473
|
-
this.pinnedEdge = keepEdge !== undefined ? (scene.edgeIndex.get(keepEdge) ?? -1) : -1;
|
|
474
|
-
this.#fadingHunk = -1;
|
|
475
|
-
this.hoverNode = this.hoverEdge = this.hoverGroup = -1;
|
|
476
|
-
this.list = buildList(scene, this.families);
|
|
477
|
-
this.#updateFocus();
|
|
478
|
-
this.#renderer?.setTheme(scene.theme);
|
|
479
|
-
this.#minimapDirty = true;
|
|
480
|
-
this.invalidate();
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
#updateFocus(): void {
|
|
484
|
-
const scene = this.scene;
|
|
485
|
-
if (!scene || this.mode !== 'changes') {
|
|
486
|
-
this.focus = null;
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
const expanded = new Set<number>();
|
|
490
|
-
for (const id of this.expanded) {
|
|
491
|
-
const i = scene.nodeIndex.get(id);
|
|
492
|
-
if (i !== undefined) expanded.add(i);
|
|
493
|
-
}
|
|
494
|
-
this.focus = computeFocus(scene, { hops: this.hops, expanded });
|
|
129
|
+
constructor(host: SessionHost, deps: SessionDeps) {
|
|
130
|
+
super(host, deps);
|
|
131
|
+
this.#now = deps.now;
|
|
132
|
+
this.addView(this);
|
|
495
133
|
}
|
|
496
134
|
|
|
497
135
|
// ─── Renderer ─────────────────────────────────────────────────────────────
|
|
@@ -513,6 +151,7 @@ export class ViewerController {
|
|
|
513
151
|
renderer.on('dblclick', ({ pick }) => this.#onCanvasDblClick(pick)),
|
|
514
152
|
renderer.on('camera', ({ source }) => {
|
|
515
153
|
if (source === 'user') this.noteUserCamera();
|
|
154
|
+
this.#updateOverview();
|
|
516
155
|
}),
|
|
517
156
|
);
|
|
518
157
|
this.#minimapDirty = true;
|
|
@@ -522,8 +161,7 @@ export class ViewerController {
|
|
|
522
161
|
detach(): void {
|
|
523
162
|
// A pending initial fit still runs after re-attach (`layoutChanged`), overriding this.
|
|
524
163
|
if (this.#renderer && this.status === 'ready') this.#savedCamera = this.#renderer.camera.get();
|
|
525
|
-
|
|
526
|
-
this.#rafId = 0;
|
|
164
|
+
this.sleep();
|
|
527
165
|
for (const fn of this.#unbind.splice(0)) fn();
|
|
528
166
|
this.#minimap?.destroy();
|
|
529
167
|
this.#minimap = null;
|
|
@@ -531,10 +169,9 @@ export class ViewerController {
|
|
|
531
169
|
this.#renderer = null;
|
|
532
170
|
}
|
|
533
171
|
|
|
534
|
-
destroy(): void {
|
|
535
|
-
|
|
172
|
+
override destroy(): void {
|
|
173
|
+
super.destroy();
|
|
536
174
|
this.detach();
|
|
537
|
-
this.#listeners.clear();
|
|
538
175
|
}
|
|
539
176
|
|
|
540
177
|
get camera(): Camera | null {
|
|
@@ -563,13 +200,14 @@ export class ViewerController {
|
|
|
563
200
|
* `autoFit`) fit again unless the user moved the camera since the last fit.
|
|
564
201
|
*/
|
|
565
202
|
layoutChanged(): void {
|
|
203
|
+
this.#updateOverview();
|
|
566
204
|
if (this.status !== 'ready') return;
|
|
567
205
|
if (!this.#needsFit) {
|
|
568
206
|
if (this.autoFit && !this.#userMoved) void this.fit({ animate: false });
|
|
569
207
|
return;
|
|
570
208
|
}
|
|
571
209
|
// A selection made before the first layout (deep link, `kgdiff-ready` handler) wins.
|
|
572
|
-
const target = this
|
|
210
|
+
const target = this.selectionBounds();
|
|
573
211
|
if (target) {
|
|
574
212
|
if (!this.#frameInsets(target, FLY_MAX_ZOOM)) return;
|
|
575
213
|
this.#needsFit = false;
|
|
@@ -584,94 +222,27 @@ export class ViewerController {
|
|
|
584
222
|
|
|
585
223
|
/** The user panned / zoomed: suspend replay camera follow for `USER_CAMERA_GRACE`. */
|
|
586
224
|
noteUserCamera(): void {
|
|
587
|
-
this.#lastUserCamera = this.#
|
|
225
|
+
this.#lastUserCamera = this.#now();
|
|
588
226
|
this.#userMoved = true;
|
|
589
227
|
}
|
|
590
228
|
|
|
591
|
-
// ───
|
|
592
|
-
|
|
593
|
-
/** Redraw on the next animation frame. */
|
|
594
|
-
invalidate(): void {
|
|
595
|
-
this.#frameDirty = true;
|
|
596
|
-
this.#schedule();
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
#schedule(): void {
|
|
600
|
-
if (this.#rafId !== 0 || this.#destroyed) return;
|
|
601
|
-
this.#rafId = this.#deps.requestFrame(this.#tick) || 0;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
readonly #tick = (): void => {
|
|
605
|
-
this.#rafId = 0;
|
|
606
|
-
if (this.#destroyed) return;
|
|
607
|
-
const now = this.#deps.now();
|
|
608
|
-
const dt = this.#lastTick > 0 ? now - this.#lastTick : 0;
|
|
609
|
-
let animating = false;
|
|
610
|
-
const clock = this.clock;
|
|
611
|
-
if (clock?.playing && this.mode === 'replay') {
|
|
612
|
-
clock.advance(dt);
|
|
613
|
-
this.#frameDirty = true;
|
|
614
|
-
this.#notify('replay');
|
|
615
|
-
this.#follow();
|
|
616
|
-
if (clock.playing) animating = true;
|
|
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;
|
|
622
|
-
}
|
|
623
|
-
if (this.#emphasisAnim) {
|
|
624
|
-
const a = this.#emphasisAnim;
|
|
625
|
-
const u = a.duration > 0 ? Math.min(1, (now - a.start) / a.duration) : 1;
|
|
626
|
-
this.emphasis = a.from + (a.to - a.from) * ease(u);
|
|
627
|
-
this.#frameDirty = true;
|
|
628
|
-
if (u >= 1) {
|
|
629
|
-
this.#emphasisAnim = null;
|
|
630
|
-
if (this.#fadingHunk >= 0) {
|
|
631
|
-
this.#fadingHunk = -1;
|
|
632
|
-
this.emphasis = 1;
|
|
633
|
-
}
|
|
634
|
-
} else animating = true;
|
|
635
|
-
}
|
|
636
|
-
if (this.#frameDirty) this.draw(now);
|
|
637
|
-
if (animating) {
|
|
638
|
-
this.#lastTick = now;
|
|
639
|
-
this.#schedule();
|
|
640
|
-
} else this.#lastTick = 0;
|
|
641
|
-
};
|
|
642
|
-
|
|
643
|
-
#view(layer: Layer): ViewState {
|
|
644
|
-
const selected = this.selectedHunk >= 0 ? this.selectedHunk : this.#fadingHunk;
|
|
645
|
-
return createViewState({
|
|
646
|
-
layer,
|
|
647
|
-
t: this.clock?.t ?? 0,
|
|
648
|
-
selectedHunk: selected,
|
|
649
|
-
hoveredNode: this.hoverNode,
|
|
650
|
-
hoveredEdge: this.hoverEdge >= 0 ? this.hoverEdge : this.pinnedEdge,
|
|
651
|
-
selectedNode: this.selectedNode,
|
|
652
|
-
focus: this.focus,
|
|
653
|
-
families: this.families,
|
|
654
|
-
reducedMotion: this.reducedMotion,
|
|
655
|
-
emphasis: this.emphasis,
|
|
656
|
-
});
|
|
657
|
-
}
|
|
229
|
+
// ─── SessionView ──────────────────────────────────────────────────────────
|
|
658
230
|
|
|
659
231
|
/** Resolve the current view and hand it to the renderer (and the minimap). */
|
|
660
|
-
draw(now = this.#
|
|
661
|
-
this.#frameDirty = false;
|
|
232
|
+
draw(now = this.#now()): void {
|
|
662
233
|
const resolver = this.resolver;
|
|
663
234
|
const renderer = this.#renderer;
|
|
664
235
|
if (!resolver || !renderer || this.status !== 'ready') return;
|
|
665
236
|
const f = this.#frames;
|
|
666
237
|
let mapFrame: FrameVisuals;
|
|
667
238
|
if (this.mode === 'swipe') {
|
|
668
|
-
f.before = resolver.resolve(this
|
|
669
|
-
f.after = resolver.resolve(this
|
|
239
|
+
f.before = resolver.resolve(this.viewState('before'), f.before ?? undefined);
|
|
240
|
+
f.after = resolver.resolve(this.viewState('after'), f.after ?? undefined);
|
|
670
241
|
renderer.setFrame({ split: this.split, before: f.before, after: f.after });
|
|
671
242
|
mapFrame = f.after;
|
|
672
243
|
} else {
|
|
673
244
|
const layer: Layer = this.mode === 'replay' ? 'replay' : 'union';
|
|
674
|
-
f.main = resolver.resolve(this
|
|
245
|
+
f.main = resolver.resolve(this.viewState(layer), f.main ?? undefined);
|
|
675
246
|
renderer.setFrame(f.main);
|
|
676
247
|
mapFrame = f.main;
|
|
677
248
|
}
|
|
@@ -686,217 +257,44 @@ export class ViewerController {
|
|
|
686
257
|
}
|
|
687
258
|
}
|
|
688
259
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
this.#fadingHunk = -1;
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
this.emphasis = from;
|
|
697
|
-
this.#emphasisAnim = {
|
|
698
|
-
from,
|
|
699
|
-
to: 1,
|
|
700
|
-
start: this.#deps.now(),
|
|
701
|
-
duration: this.theme.motion.base,
|
|
702
|
-
};
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
#fadeOutEmphasis(hunk: number): void {
|
|
706
|
-
if (this.reducedMotion || hunk < 0) {
|
|
707
|
-
this.#fadingHunk = -1;
|
|
708
|
-
this.#emphasisAnim = null;
|
|
709
|
-
this.emphasis = 1;
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
|
-
this.#fadingHunk = hunk;
|
|
713
|
-
this.#emphasisAnim = {
|
|
714
|
-
from: this.emphasis,
|
|
715
|
-
to: 0,
|
|
716
|
-
start: this.#deps.now(),
|
|
717
|
-
duration: this.theme.motion.base,
|
|
718
|
-
};
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
// ─── Mode ─────────────────────────────────────────────────────────────────
|
|
722
|
-
|
|
723
|
-
setMode(value: ViewerMode): void {
|
|
724
|
-
const mode = toMode(value);
|
|
725
|
-
if (mode === null || mode === this.mode) return;
|
|
726
|
-
this.mode = mode;
|
|
727
|
-
if (mode !== 'replay') {
|
|
728
|
-
this.clock?.pause();
|
|
729
|
-
this.#restartAt = Number.NaN;
|
|
730
|
-
this.#resume = false;
|
|
731
|
-
}
|
|
732
|
-
this.#followed = '';
|
|
733
|
-
this.#updateFocus();
|
|
260
|
+
sceneChanged(): void {
|
|
261
|
+
const f = this.#frames;
|
|
262
|
+
f.main = f.before = f.after = null;
|
|
263
|
+
this.#renderer?.setTheme(this.scene?.theme ?? this.theme);
|
|
734
264
|
this.#minimapDirty = true;
|
|
735
|
-
this.#notify('mode', 'focus', 'replay');
|
|
736
|
-
this.invalidate();
|
|
737
|
-
if (this.status === 'ready') {
|
|
738
|
-
this.#host.mode(mode);
|
|
739
|
-
this.#host.announce(
|
|
740
|
-
translate(this.locale, 'announce.mode', { mode: translate(this.locale, `mode.${mode}`) }),
|
|
741
|
-
);
|
|
742
|
-
}
|
|
743
|
-
if (mode === 'replay') this.#autoplay();
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
// ─── Selection ────────────────────────────────────────────────────────────
|
|
747
|
-
|
|
748
|
-
selectHunkById(id: string | null, options: { fly?: boolean } = {}): void {
|
|
749
|
-
if (id === null) {
|
|
750
|
-
this.#pendingHunk = null;
|
|
751
|
-
this.clearSelection();
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
const scene = this.scene;
|
|
755
|
-
if (!scene || this.status !== 'ready') {
|
|
756
|
-
this.#pendingHunk = { id, fly: options.fly ?? true };
|
|
757
|
-
return;
|
|
758
|
-
}
|
|
759
|
-
const h = scene.hunkIndex.get(id);
|
|
760
|
-
if (h !== undefined) this.selectHunk(h, options);
|
|
761
265
|
}
|
|
762
266
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
if (!scene) return;
|
|
770
|
-
if (h < 0 || h >= scene.hunks.length) {
|
|
771
|
-
this.clearSelection();
|
|
772
|
-
return;
|
|
773
|
-
}
|
|
774
|
-
const node = options.node ?? -1;
|
|
775
|
-
const prev = this.selectedHunk;
|
|
776
|
-
const changed = prev !== h || this.selectedNode !== node;
|
|
777
|
-
this.selectedHunk = h;
|
|
778
|
-
this.selectedNode = node;
|
|
779
|
-
this.pinnedEdge = -1;
|
|
780
|
-
if (prev !== h) {
|
|
781
|
-
this.#fadingHunk = -1;
|
|
782
|
-
this.#startEmphasis(prev >= 0 ? SWITCH_EMPHASIS : 0);
|
|
783
|
-
if (this.mode === 'replay' && this.clock) {
|
|
784
|
-
const end = this.clock.windowEnd(h);
|
|
785
|
-
this.clock.pause();
|
|
786
|
-
this.#restartAt = Number.NaN;
|
|
787
|
-
this.#resume = false;
|
|
788
|
-
if (end !== null) this.clock.seek(end);
|
|
789
|
-
this.#followed = '';
|
|
790
|
-
this.#notify('replay');
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
if (options.fly ?? true) this.flyToHunk(h);
|
|
794
|
-
this.invalidate();
|
|
795
|
-
if (!changed) return;
|
|
796
|
-
this.#notify('selection');
|
|
797
|
-
const hunk = scene.hunks[h];
|
|
798
|
-
if (!hunk) return;
|
|
799
|
-
const nodeId = node >= 0 ? (scene.nodes[node]?.id ?? null) : null;
|
|
800
|
-
this.#host.select(hunk.id, nodeId);
|
|
801
|
-
if (prev !== h) {
|
|
802
|
-
const rank = this.list.rank.get(h);
|
|
803
|
-
this.#host.announce(
|
|
804
|
-
translate(this.locale, 'announce.change', {
|
|
805
|
-
i: (rank ?? h) + 1,
|
|
806
|
-
n: rank !== undefined ? this.list.order.length : scene.hunks.length,
|
|
807
|
-
title: hunk.title,
|
|
808
|
-
}),
|
|
809
|
-
);
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
/** Select a node: its hunk when it has one, else the node itself. */
|
|
814
|
-
selectNode(i: number, options: { fly?: boolean } = {}): void {
|
|
815
|
-
const scene = this.scene;
|
|
816
|
-
const n = scene?.nodes[i];
|
|
817
|
-
if (!scene || !n) return;
|
|
818
|
-
if (n.hunk >= 0) {
|
|
819
|
-
this.selectHunk(n.hunk, { ...options, node: i });
|
|
820
|
-
return;
|
|
821
|
-
}
|
|
822
|
-
const prev = this.selectedHunk;
|
|
823
|
-
if (prev < 0 && this.selectedNode === i) {
|
|
824
|
-
if (options.fly) this.flyToNode(i);
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
827
|
-
this.selectedHunk = -1;
|
|
828
|
-
this.selectedNode = i;
|
|
829
|
-
this.pinnedEdge = -1;
|
|
830
|
-
if (prev >= 0) this.#fadeOutEmphasis(prev);
|
|
831
|
-
if (options.fly) this.flyToNode(i);
|
|
832
|
-
this.invalidate();
|
|
833
|
-
this.#notify('selection');
|
|
834
|
-
this.#host.select(null, n.id);
|
|
835
|
-
this.#host.announce(
|
|
836
|
-
translate(this.locale, 'announce.node', {
|
|
837
|
-
label: n.label,
|
|
838
|
-
status: translate(this.locale, `status.${n.status}`),
|
|
839
|
-
}),
|
|
840
|
-
);
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
clearSelection(): void {
|
|
844
|
-
if (this.selectedHunk < 0 && this.selectedNode < 0 && this.pinnedEdge < 0) return;
|
|
845
|
-
const prev = this.selectedHunk;
|
|
846
|
-
this.selectedHunk = -1;
|
|
847
|
-
this.selectedNode = -1;
|
|
848
|
-
this.pinnedEdge = -1;
|
|
849
|
-
this.#fadeOutEmphasis(prev);
|
|
850
|
-
this.invalidate();
|
|
851
|
-
this.#notify('selection');
|
|
852
|
-
this.#host.select(null, null);
|
|
853
|
-
this.#host.announce(translate(this.locale, 'announce.cleared'));
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
next(): void {
|
|
857
|
-
this.#stepSelection(1);
|
|
267
|
+
installed(): void {
|
|
268
|
+
// Fitted after the UI has shown its chrome (the session's notification is delivered
|
|
269
|
+
// first) and once the canvas has a size (see `layoutChanged`).
|
|
270
|
+
this.#needsFit = true;
|
|
271
|
+
this.#savedCamera = null;
|
|
272
|
+
queueMicrotask(() => this.layoutChanged());
|
|
858
273
|
}
|
|
859
274
|
|
|
860
|
-
|
|
861
|
-
this.#
|
|
275
|
+
drawnChanged(): void {
|
|
276
|
+
this.#updateOverview();
|
|
277
|
+
this.#minimapDirty = true;
|
|
862
278
|
}
|
|
863
279
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
if (h >= 0) this.selectHunk(h);
|
|
280
|
+
show(bounds: Rect): void {
|
|
281
|
+
this.#flyTo(bounds);
|
|
867
282
|
}
|
|
868
283
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
if (
|
|
284
|
+
/** Follow `bounds` (replay): fly unless the user steered recently or it is mostly visible. */
|
|
285
|
+
follow(bounds: Rect): void {
|
|
286
|
+
if (this.#now() - this.#lastUserCamera < USER_CAMERA_GRACE) return;
|
|
287
|
+
const free = this.#freeWorld(bounds);
|
|
288
|
+
if (!free || coverage(bounds, free) >= FOLLOW_COVERAGE) return;
|
|
289
|
+
this.#flyTo(bounds);
|
|
872
290
|
}
|
|
873
291
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
if (h !== undefined) this.selectHunk(h);
|
|
292
|
+
looped(): void {
|
|
293
|
+
if (this.autoFit) void this.fit();
|
|
877
294
|
}
|
|
878
295
|
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
const scene = this.scene;
|
|
882
|
-
const e = scene?.edges[j];
|
|
883
|
-
if (!scene || !e) return;
|
|
884
|
-
this.pinnedEdge = j;
|
|
885
|
-
const ids = [e.s, e.t, e.sb, e.tb];
|
|
886
|
-
let x0 = Number.POSITIVE_INFINITY;
|
|
887
|
-
let y0 = Number.POSITIVE_INFINITY;
|
|
888
|
-
let x1 = Number.NEGATIVE_INFINITY;
|
|
889
|
-
let y1 = Number.NEGATIVE_INFINITY;
|
|
890
|
-
for (const i of ids) {
|
|
891
|
-
const n = scene.nodes[i];
|
|
892
|
-
if (!n) continue;
|
|
893
|
-
x0 = Math.min(x0, n.x - n.r);
|
|
894
|
-
y0 = Math.min(y0, n.y - n.r);
|
|
895
|
-
x1 = Math.max(x1, n.x + n.r);
|
|
896
|
-
y1 = Math.max(y1, n.y + n.r);
|
|
897
|
-
}
|
|
898
|
-
if (x0 <= x1) this.#flyTo(padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }));
|
|
899
|
-
this.invalidate();
|
|
296
|
+
viewport(): Rect | null {
|
|
297
|
+
return this.#renderer?.camera.viewport() ?? null;
|
|
900
298
|
}
|
|
901
299
|
|
|
902
300
|
// ─── Camera ───────────────────────────────────────────────────────────────
|
|
@@ -927,77 +325,6 @@ export class ViewerController {
|
|
|
927
325
|
return freeView(camera.viewport(), camera.get().k, insets);
|
|
928
326
|
}
|
|
929
327
|
|
|
930
|
-
/** “Change 3 of 23: …” for a replayed hunk, “Restructured: 722 changes” for a chapter. */
|
|
931
|
-
#announceTarget(kind: 'hunk' | 'chapter', index: number): void {
|
|
932
|
-
const scene = this.scene;
|
|
933
|
-
const clock = this.clock;
|
|
934
|
-
if (!scene || !clock) return;
|
|
935
|
-
if (kind === 'chapter') {
|
|
936
|
-
const ch = scene.timeline.chapters[index];
|
|
937
|
-
if (!ch) return;
|
|
938
|
-
this.#host.announce(
|
|
939
|
-
translate(this.locale, 'announce.chapter', {
|
|
940
|
-
phase: translate(this.locale, `phase.${ch.phase}`),
|
|
941
|
-
n: ch.hunks.length,
|
|
942
|
-
}),
|
|
943
|
-
);
|
|
944
|
-
return;
|
|
945
|
-
}
|
|
946
|
-
const hunk = scene.hunks[index];
|
|
947
|
-
const order = clock.timeline.windows.findIndex((w) => w.hunk === index);
|
|
948
|
-
if (!hunk || order < 0) return;
|
|
949
|
-
this.#host.announce(
|
|
950
|
-
translate(this.locale, 'announce.change', {
|
|
951
|
-
i: order + 1,
|
|
952
|
-
n: clock.total,
|
|
953
|
-
title: hunk.title,
|
|
954
|
-
}),
|
|
955
|
-
);
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
/** Follow `hunks` (replay): fly unless the user steered recently or they are mostly visible. */
|
|
959
|
-
#followHunks(hunks: readonly number[]): void {
|
|
960
|
-
const scene = this.scene;
|
|
961
|
-
if (!scene || hunks.length === 0) return;
|
|
962
|
-
if (this.#deps.now() - this.#lastUserCamera < USER_CAMERA_GRACE) return;
|
|
963
|
-
const bounds = frameBounds(scene, hunks);
|
|
964
|
-
const free = this.#freeWorld(bounds);
|
|
965
|
-
if (!free || coverage(bounds, free) >= FOLLOW_COVERAGE) return;
|
|
966
|
-
this.#flyTo(bounds);
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
#hunkBounds(h: number): Rect | null {
|
|
970
|
-
const scene = this.scene;
|
|
971
|
-
return scene?.hunks[h] ? frameBounds(scene, [h]) : null;
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
#nodeBounds(i: number): Rect | null {
|
|
975
|
-
const n = this.scene?.nodes[i];
|
|
976
|
-
return n ? padRect({ x: n.x - n.r, y: n.y - n.r, width: 2 * n.r, height: 2 * n.r }) : null;
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
/** Camera target of the current selection, or null. */
|
|
980
|
-
#selectionBounds(): Rect | null {
|
|
981
|
-
if (this.selectedHunk >= 0) return this.#hunkBounds(this.selectedHunk);
|
|
982
|
-
if (this.selectedNode >= 0) return this.#nodeBounds(this.selectedNode);
|
|
983
|
-
return null;
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
flyToHunk(h: number): void {
|
|
987
|
-
const b = this.#hunkBounds(h);
|
|
988
|
-
if (b) this.#flyTo(b);
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
flyToNode(i: number): void {
|
|
992
|
-
const b = this.#nodeBounds(i);
|
|
993
|
-
if (b) this.#flyTo(b);
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
flyToSelection(): void {
|
|
997
|
-
const b = this.#selectionBounds();
|
|
998
|
-
if (b) this.#flyTo(b);
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
328
|
/**
|
|
1002
329
|
* Fit everything visible (the focus in changes-only mode), labels included, into the free part
|
|
1003
330
|
* of the canvas. Before the canvas is laid out it falls back to the renderer's own fit.
|
|
@@ -1059,47 +386,41 @@ export class ViewerController {
|
|
|
1059
386
|
return { bounds: fit.bounds, insets };
|
|
1060
387
|
}
|
|
1061
388
|
|
|
389
|
+
/** Bounds of what is drawn: the scene, or the visible part of the `changes` scope's focus. */
|
|
390
|
+
#contentBounds(): Rect | null {
|
|
391
|
+
const scene = this.scene;
|
|
392
|
+
if (!scene) return null;
|
|
393
|
+
const focus = this.focus;
|
|
394
|
+
const cached = this.#boundsCache;
|
|
395
|
+
if (cached && cached.scene === scene && cached.focus === focus) return cached.rect;
|
|
396
|
+
const rect = focus ? focusBounds(scene, focus) : frameBounds(scene);
|
|
397
|
+
this.#boundsCache = { scene, focus, rect };
|
|
398
|
+
return rect;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Whether everything drawn is in view; the minimap hides while it is. */
|
|
402
|
+
#updateOverview(): void {
|
|
403
|
+
const bounds = this.#contentBounds();
|
|
404
|
+
const view = this.#renderer?.camera.viewport();
|
|
405
|
+
const next = !bounds || !view || coverage(bounds, view) >= OVERVIEW_COVERAGE;
|
|
406
|
+
if (next === this.overview) return;
|
|
407
|
+
this.overview = next;
|
|
408
|
+
this.notify('overview');
|
|
409
|
+
}
|
|
410
|
+
|
|
1062
411
|
zoom(direction: 1 | -1): void {
|
|
1063
412
|
this.noteUserCamera();
|
|
1064
413
|
this.#renderer?.camera.zoomBy(direction > 0 ? 1.4 : 1 / 1.4);
|
|
1065
414
|
}
|
|
1066
415
|
|
|
1067
|
-
/** Replay: fly to the playing hunk / chapter when it changes and is mostly out of view. */
|
|
1068
|
-
#follow(): void {
|
|
1069
|
-
const scene = this.scene;
|
|
1070
|
-
const clock = this.clock;
|
|
1071
|
-
const camera = this.#renderer?.camera;
|
|
1072
|
-
if (!scene || !clock || !camera) return;
|
|
1073
|
-
const target = clock.activeTarget();
|
|
1074
|
-
if (target.index < 0) return;
|
|
1075
|
-
const key = `${target.kind}:${target.index}`;
|
|
1076
|
-
if (key === this.#followed) return;
|
|
1077
|
-
this.#followed = key;
|
|
1078
|
-
this.#announceTarget(target.kind, target.index);
|
|
1079
|
-
this.#followHunks(
|
|
1080
|
-
target.kind === 'chapter'
|
|
1081
|
-
? (scene.timeline.chapters[target.index]?.hunks ?? [])
|
|
1082
|
-
: [target.index],
|
|
1083
|
-
);
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
416
|
// ─── Canvas events ────────────────────────────────────────────────────────
|
|
1087
417
|
|
|
1088
418
|
#onHover(pick: PickResult | null): void {
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
this.hoverEdge = edge;
|
|
1095
|
-
this.hoverGroup = group;
|
|
1096
|
-
this.#notify('hover');
|
|
1097
|
-
this.invalidate();
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
/** Clear hover state (pointer left the canvas). */
|
|
1101
|
-
clearHover(): void {
|
|
1102
|
-
this.#onHover(null);
|
|
419
|
+
this.setHover(
|
|
420
|
+
pick?.type === 'node' ? pick.index : -1,
|
|
421
|
+
pick?.type === 'edge' ? pick.index : -1,
|
|
422
|
+
pick?.type === 'collapsed' ? pick.index : -1,
|
|
423
|
+
);
|
|
1103
424
|
}
|
|
1104
425
|
|
|
1105
426
|
#onCanvasClick(pick: PickResult | null): void {
|
|
@@ -1134,425 +455,28 @@ export class ViewerController {
|
|
|
1134
455
|
if (n.hunk >= 0) this.flyToHunk(n.hunk);
|
|
1135
456
|
else this.flyToNode(pick.index);
|
|
1136
457
|
}
|
|
1137
|
-
|
|
1138
|
-
// ─── Filters, focus ───────────────────────────────────────────────────────
|
|
1139
|
-
|
|
1140
|
-
toggleFamily(family: ChangeFamily): void {
|
|
1141
|
-
if (this.families.has(family)) this.families.delete(family);
|
|
1142
|
-
else this.families.add(family);
|
|
1143
|
-
this.#filtersChanged();
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
/** Replace the family filter (empty = all). Setting the current set again clears it. */
|
|
1147
|
-
setFamilies(families: readonly ChangeFamily[]): void {
|
|
1148
|
-
const same =
|
|
1149
|
-
families.length === this.families.size && families.every((f) => this.families.has(f));
|
|
1150
|
-
this.families.clear();
|
|
1151
|
-
if (!same) for (const f of families) this.families.add(f);
|
|
1152
|
-
this.#filtersChanged();
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
#filtersChanged(): void {
|
|
1156
|
-
if (this.scene) this.list = buildList(this.scene, this.families);
|
|
1157
|
-
this.#minimapDirty = true;
|
|
1158
|
-
this.#notify('filters');
|
|
1159
|
-
this.invalidate();
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
setHops(hops: number): void {
|
|
1163
|
-
const h = Math.max(0, Math.min(2, Math.round(hops)));
|
|
1164
|
-
if (h === this.hops) return;
|
|
1165
|
-
this.hops = h;
|
|
1166
|
-
this.#updateFocus();
|
|
1167
|
-
this.#minimapDirty = true;
|
|
1168
|
-
this.#notify('focus');
|
|
1169
|
-
this.invalidate();
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
/**
|
|
1173
|
-
* Expand collapsed group `index` of the current focus: its in-scene members become visible
|
|
1174
|
-
* and, for a `changes`-scoped layout, the neighbours left out of the layout are placed with
|
|
1175
|
-
* `extendLayout` (nothing already placed moves) and the scene is rebuilt.
|
|
1176
|
-
*/
|
|
1177
|
-
expandGroup(index: number): void {
|
|
1178
|
-
const group = this.focus?.collapsed[index];
|
|
1179
|
-
const scene = this.scene;
|
|
1180
|
-
if (!group || !scene) return;
|
|
1181
|
-
const ids: UnionId[] = group.nodes.map((i) => scene.nodes[i]?.id).filter(isString);
|
|
1182
|
-
if (!this.canExpand(index)) {
|
|
1183
|
-
// Without the layout engine only the members already laid out can be shown.
|
|
1184
|
-
if (ids.length > 0) void this.#expandIds(ids, []);
|
|
1185
|
-
return;
|
|
1186
|
-
}
|
|
1187
|
-
const owner = group.id.startsWith('near:')
|
|
1188
|
-
? group.id.slice(5)
|
|
1189
|
-
: group.id.startsWith('component:')
|
|
1190
|
-
? group.id.slice(10)
|
|
1191
|
-
: null;
|
|
1192
|
-
void this.#expandIds(ids, owner !== null ? [owner, ...ids] : ids);
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
/**
|
|
1196
|
-
* The collapsed group nearest the selection (else the view center), or -1. Groups that can
|
|
1197
|
-
* be expanded at least partly come first.
|
|
1198
|
-
*/
|
|
1199
|
-
nearestGroup(): number {
|
|
1200
|
-
const groups = this.focus?.collapsed ?? [];
|
|
1201
|
-
if (groups.length === 0) return -1;
|
|
1202
|
-
const sel = this.#selectionBounds();
|
|
1203
|
-
const view = this.#renderer?.camera.viewport();
|
|
1204
|
-
const at = sel ?? view ?? this.scene?.bounds;
|
|
1205
|
-
const cx = at ? at.x + at.width / 2 : 0;
|
|
1206
|
-
const cy = at ? at.y + at.height / 2 : 0;
|
|
1207
|
-
let best = -1;
|
|
1208
|
-
let bestScore = Number.POSITIVE_INFINITY;
|
|
1209
|
-
groups.forEach((g, i) => {
|
|
1210
|
-
const usable = this.canExpand(i) || g.nodes.length > 0;
|
|
1211
|
-
const score = Math.hypot(g.x - cx, g.y - cy) + (usable ? 0 : 1e12);
|
|
1212
|
-
if (score < bestScore) {
|
|
1213
|
-
bestScore = score;
|
|
1214
|
-
best = i;
|
|
1215
|
-
}
|
|
1216
|
-
});
|
|
1217
|
-
return best;
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
/** Expand the group nearest the selection (keyboard `e`); announces what happened. */
|
|
1221
|
-
expandNearest(): void {
|
|
1222
|
-
const i = this.nearestGroup();
|
|
1223
|
-
const group = i >= 0 ? this.focus?.collapsed[i] : undefined;
|
|
1224
|
-
if (!group || (!this.canExpand(i) && group.nodes.length === 0)) {
|
|
1225
|
-
this.#host.announce(
|
|
1226
|
-
translate(this.locale, group ? 'focus.expandUnavailable' : 'announce.nothingHidden'),
|
|
1227
|
-
);
|
|
1228
|
-
return;
|
|
1229
|
-
}
|
|
1230
|
-
this.expandGroup(i);
|
|
1231
|
-
this.#host.announce(translate(this.locale, 'announce.expanded', { label: group.label }));
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
/**
|
|
1235
|
-
* Whether collapsed group `index` can be expanded completely: always, unless some members lie
|
|
1236
|
-
* outside a `changes`-scoped layout and the layout engine is known to be unavailable
|
|
1237
|
-
* (standalone report without the compute add-on).
|
|
1238
|
-
*/
|
|
1239
|
-
canExpand(index: number): boolean {
|
|
1240
|
-
const group = this.focus?.collapsed[index];
|
|
1241
|
-
if (!group) return false;
|
|
1242
|
-
const outside = this.layout?.scope === 'changes' && group.count > group.nodes.length;
|
|
1243
|
-
return !outside || computeStatus() !== 'unavailable';
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
/**
|
|
1247
|
-
* Make `ids` visible, placing those outside the layout, plus the out-of-layout neighbours of
|
|
1248
|
-
* `neighboursOf`. Resolves once done (the layout engine may load lazily first).
|
|
1249
|
-
*/
|
|
1250
|
-
#expandIds(ids: readonly UnionId[], neighboursOf: readonly UnionId[]): Promise<void> {
|
|
1251
|
-
const data = this.data;
|
|
1252
|
-
const layout = this.layout;
|
|
1253
|
-
if (!data || !layout) return Promise.resolve();
|
|
1254
|
-
for (const id of ids) this.expanded.add(id);
|
|
1255
|
-
const missing = new Set<UnionId>();
|
|
1256
|
-
if (layout.scope === 'changes') {
|
|
1257
|
-
for (const id of ids) if (!layout.positions.has(id)) missing.add(id);
|
|
1258
|
-
for (const id of neighboursOf) {
|
|
1259
|
-
for (const e of data.diff.index.edgesOf(id)) {
|
|
1260
|
-
for (const end of [e.source, e.target, e.sourceBefore, e.targetBefore]) {
|
|
1261
|
-
if (end !== undefined && !layout.positions.has(end)) missing.add(end);
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
if (missing.size === 0) {
|
|
1267
|
-
this.#focusChanged();
|
|
1268
|
-
return Promise.resolve();
|
|
1269
|
-
}
|
|
1270
|
-
const place = (): void => {
|
|
1271
|
-
const compute = computeNow();
|
|
1272
|
-
const current = this.layout;
|
|
1273
|
-
if (!compute || !current || this.data !== data) return;
|
|
1274
|
-
const todo = [...missing].filter((id) => !current.positions.has(id));
|
|
1275
|
-
if (todo.length > 0) {
|
|
1276
|
-
this.layout = compute.extendLayout(data.diff, current, todo, data.layoutOptions);
|
|
1277
|
-
for (const id of todo) this.expanded.add(id);
|
|
1278
|
-
this.#rebuildScene();
|
|
1279
|
-
this.#notify('data', 'selection', 'replay');
|
|
1280
|
-
}
|
|
1281
|
-
this.#focusChanged();
|
|
1282
|
-
};
|
|
1283
|
-
if (computeNow()) {
|
|
1284
|
-
place();
|
|
1285
|
-
return Promise.resolve();
|
|
1286
|
-
}
|
|
1287
|
-
// Standalone reports load the layout engine on demand.
|
|
1288
|
-
return loadCompute().then(place, () => this.#focusChanged());
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
#focusChanged(): void {
|
|
1292
|
-
this.#updateFocus();
|
|
1293
|
-
this.#minimapDirty = true;
|
|
1294
|
-
this.#notify('focus');
|
|
1295
|
-
this.invalidate();
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
/** Bring a union node into the scene and the focus (search results), then select it. */
|
|
1299
|
-
revealNode(id: UnionId): Promise<void> {
|
|
1300
|
-
if (!this.data || !this.scene) return Promise.resolve();
|
|
1301
|
-
const select = (): void => {
|
|
1302
|
-
const i = this.scene?.nodeIndex.get(id);
|
|
1303
|
-
if (i !== undefined) this.selectNode(i, { fly: true });
|
|
1304
|
-
};
|
|
1305
|
-
if (this.scene.nodeIndex.has(id) && this.#isVisible(id)) {
|
|
1306
|
-
select();
|
|
1307
|
-
return Promise.resolve();
|
|
1308
|
-
}
|
|
1309
|
-
return this.#expandIds([id], []).then(select);
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
#isVisible(id: UnionId): boolean {
|
|
1313
|
-
const i = this.scene?.nodeIndex.get(id);
|
|
1314
|
-
return i !== undefined && (this.focus === null || this.focus.nodes[i] === 1);
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
// ─── Replay ───────────────────────────────────────────────────────────────
|
|
1318
|
-
|
|
1319
|
-
play(): void {
|
|
1320
|
-
const clock = this.clock;
|
|
1321
|
-
if (!clock || this.status !== 'ready') return;
|
|
1322
|
-
if (this.mode !== 'replay') this.setMode('replay');
|
|
1323
|
-
this.#restartAt = Number.NaN;
|
|
1324
|
-
this.#resume = false;
|
|
1325
|
-
clock.play();
|
|
1326
|
-
this.#followed = '';
|
|
1327
|
-
this.#lastTick = 0;
|
|
1328
|
-
this.#notify('replay');
|
|
1329
|
-
this.invalidate();
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
/** Pause; a looping replay stays paused (until `play`, a new diff or loop turned on again). */
|
|
1333
|
-
pause(): void {
|
|
1334
|
-
this.#restartAt = Number.NaN;
|
|
1335
|
-
this.#resume = false;
|
|
1336
|
-
if (!this.clock?.playing) return;
|
|
1337
|
-
this.clock.pause();
|
|
1338
|
-
this.#notify('replay');
|
|
1339
|
-
this.invalidate();
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
togglePlay(): void {
|
|
1343
|
-
if (this.clock?.playing) this.pause();
|
|
1344
|
-
else this.play();
|
|
1345
|
-
}
|
|
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
|
-
|
|
1410
|
-
seek(t: number): void {
|
|
1411
|
-
const clock = this.clock;
|
|
1412
|
-
if (!clock) return;
|
|
1413
|
-
this.#restartAt = Number.NaN;
|
|
1414
|
-
clock.seek(t);
|
|
1415
|
-
this.#notify('replay');
|
|
1416
|
-
this.#follow();
|
|
1417
|
-
this.invalidate();
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
stepReplay(direction: 1 | -1): void {
|
|
1421
|
-
const clock = this.clock;
|
|
1422
|
-
if (!clock) return;
|
|
1423
|
-
clock.step(direction);
|
|
1424
|
-
this.#restartAt = Number.NaN;
|
|
1425
|
-
this.#resume = false;
|
|
1426
|
-
this.#followed = '';
|
|
1427
|
-
// Follow (and announce) the hunk that just completed: stops sit at window ends.
|
|
1428
|
-
const w = clock.timeline.windows[clock.position() - 1];
|
|
1429
|
-
if (w) {
|
|
1430
|
-
if (clock.timeline.mode === 'chapters') this.#announceTarget('chapter', w.chapter);
|
|
1431
|
-
else this.#announceTarget('hunk', w.hunk);
|
|
1432
|
-
this.#followHunks(
|
|
1433
|
-
clock.timeline.mode === 'chapters'
|
|
1434
|
-
? (clock.timeline.chapters[w.chapter]?.hunks ?? [w.hunk])
|
|
1435
|
-
: [w.hunk],
|
|
1436
|
-
);
|
|
1437
|
-
}
|
|
1438
|
-
this.#notify('replay');
|
|
1439
|
-
this.invalidate();
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
restart(): void {
|
|
1443
|
-
this.clock?.seek(0);
|
|
1444
|
-
this.play();
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
cycleSpeed(): void {
|
|
1448
|
-
this.clock?.cycleSpeed();
|
|
1449
|
-
this.#notify('replay');
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
setSpeed(speed: number): void {
|
|
1453
|
-
this.clock?.setSpeed(speed);
|
|
1454
|
-
this.#notify('replay');
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
// ─── Swipe ────────────────────────────────────────────────────────────────
|
|
1458
|
-
|
|
1459
|
-
setSplit(split: number): void {
|
|
1460
|
-
const s = Number.isFinite(split) ? Math.min(1, Math.max(0, split)) : 0.5;
|
|
1461
|
-
if (s === this.split) return;
|
|
1462
|
-
this.split = s;
|
|
1463
|
-
this.#notify('swipe');
|
|
1464
|
-
if (this.mode === 'swipe') this.invalidate();
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
// ─── Review ───────────────────────────────────────────────────────────────
|
|
1468
|
-
|
|
1469
|
-
setReviewed(hunkId: string, reviewed: boolean): void {
|
|
1470
|
-
const review = this.#review;
|
|
1471
|
-
if (!review?.setReviewed(hunkId, reviewed)) return;
|
|
1472
|
-
const progress = review.state();
|
|
1473
|
-
this.#notify('review');
|
|
1474
|
-
this.#host.review(hunkId, reviewed, progress);
|
|
1475
|
-
const text = translate(this.locale, 'review.progress', {
|
|
1476
|
-
done: progress.reviewed.length,
|
|
1477
|
-
total: progress.total,
|
|
1478
|
-
});
|
|
1479
|
-
this.#host.announce(
|
|
1480
|
-
translate(this.locale, reviewed ? 'announce.reviewed' : 'announce.unreviewed', {
|
|
1481
|
-
progress: text,
|
|
1482
|
-
}),
|
|
1483
|
-
);
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
/** Another tab changed review progress (`storage` event): re-read it. */
|
|
1487
|
-
syncReview(key: string | null): void {
|
|
1488
|
-
if (this.#review?.reload(key)) this.#notify('review');
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
toggleReviewed(hunkId?: string): void {
|
|
1492
|
-
const id =
|
|
1493
|
-
hunkId ?? (this.selectedHunk >= 0 ? this.scene?.hunks[this.selectedHunk]?.id : undefined);
|
|
1494
|
-
if (id !== undefined) this.setReviewed(id, !this.isReviewed(id));
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
// ─── Appearance ───────────────────────────────────────────────────────────
|
|
1498
|
-
|
|
1499
|
-
#appearance(apply: () => void): void {
|
|
1500
|
-
const name = this.themeName;
|
|
1501
|
-
const palette = this.palette;
|
|
1502
|
-
const locale = this.locale;
|
|
1503
|
-
apply();
|
|
1504
|
-
const themeChanged = name !== this.themeName || palette !== this.palette;
|
|
1505
|
-
const localeChanged = locale !== this.locale;
|
|
1506
|
-
if (themeChanged || localeChanged) {
|
|
1507
|
-
if (this.data) this.#rebuildScene();
|
|
1508
|
-
else this.#renderer?.setTheme(this.theme);
|
|
1509
|
-
}
|
|
1510
|
-
if (themeChanged) this.#notify('theme');
|
|
1511
|
-
if (localeChanged) this.#notify('locale', 'data');
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
setThemePreference(pref: ThemePreference): void {
|
|
1515
|
-
this.#appearance(() => {
|
|
1516
|
-
this.themePreference = toTheme(pref) ?? 'auto';
|
|
1517
|
-
});
|
|
1518
|
-
this.#notify('theme');
|
|
1519
|
-
}
|
|
1520
|
-
|
|
1521
|
-
setPalette(palette: PaletteName): void {
|
|
1522
|
-
this.#appearance(() => {
|
|
1523
|
-
this.palette = toPalette(palette) ?? 'default';
|
|
1524
|
-
});
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
setLocale(locale: Locale): void {
|
|
1528
|
-
this.#appearance(() => {
|
|
1529
|
-
this.locale = toLocale(locale) ?? 'en';
|
|
1530
|
-
});
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
setSystemDark(dark: boolean): void {
|
|
1534
|
-
this.#appearance(() => {
|
|
1535
|
-
this.systemDark = dark;
|
|
1536
|
-
});
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
setReducedMotion(reduced: boolean): void {
|
|
1540
|
-
this.reducedMotion = reduced;
|
|
1541
|
-
this.invalidate();
|
|
1542
|
-
}
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
function isString(x: string | undefined): x is string {
|
|
1546
|
-
return x !== undefined;
|
|
1547
458
|
}
|
|
1548
459
|
|
|
1549
460
|
function sameInsets(a: Insets, b: Insets): boolean {
|
|
1550
461
|
return a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left;
|
|
1551
462
|
}
|
|
1552
463
|
|
|
1553
|
-
/**
|
|
1554
|
-
function
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
464
|
+
/** Bounds of the visible part of `focus`, with room for labels and the collapsed pills. */
|
|
465
|
+
function focusBounds(scene: Scene, focus: FocusState): Rect {
|
|
466
|
+
let x0 = Number.POSITIVE_INFINITY;
|
|
467
|
+
let y0 = Number.POSITIVE_INFINITY;
|
|
468
|
+
let x1 = Number.NEGATIVE_INFINITY;
|
|
469
|
+
let y1 = Number.NEGATIVE_INFINITY;
|
|
470
|
+
const add = (x: number, y: number, rx: number, top: number, bottom: number): void => {
|
|
471
|
+
x0 = Math.min(x0, x - rx);
|
|
472
|
+
x1 = Math.max(x1, x + rx);
|
|
473
|
+
y0 = Math.min(y0, y - top);
|
|
474
|
+
y1 = Math.max(y1, y + bottom);
|
|
475
|
+
};
|
|
476
|
+
// Labels sit centered below their node: leave room for a typical one.
|
|
477
|
+
for (const n of scene.nodes)
|
|
478
|
+
if (focus.nodes[n.index] === 1) add(n.x, n.y, n.r + 48, n.r, n.r + 24);
|
|
479
|
+
for (const g of focus.collapsed) add(g.x, g.y, 48, 12, 12);
|
|
480
|
+
if (!(x0 <= x1)) return frameBounds(scene);
|
|
481
|
+
return padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }, 160, 1.12);
|
|
1558
482
|
}
|