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