@kgdiff/viewer 0.5.0 → 0.6.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 +43 -16
- package/custom-elements.json +2347 -0
- package/dist/bind.d.ts +41 -0
- package/dist/bind.d.ts.map +1 -0
- package/dist/bind.js +47 -0
- package/dist/bind.js.map +1 -0
- package/dist/controller.d.ts +22 -74
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +35 -376
- package/dist/controller.js.map +1 -1
- package/dist/dom.d.ts +10 -0
- package/dist/dom.d.ts.map +1 -1
- package/dist/dom.js +29 -0
- package/dist/dom.js.map +1 -1
- package/dist/element.d.ts +52 -46
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +362 -403
- package/dist/element.js.map +1 -1
- package/dist/elements/actions.d.ts +27 -0
- package/dist/elements/actions.d.ts.map +1 -0
- package/dist/elements/actions.js +81 -0
- package/dist/elements/actions.js.map +1 -0
- package/dist/elements/bound.d.ts +63 -0
- package/dist/elements/bound.d.ts.map +1 -0
- package/dist/elements/bound.js +213 -0
- package/dist/elements/bound.js.map +1 -0
- package/dist/elements/change-detail.d.ts +25 -0
- package/dist/elements/change-detail.d.ts.map +1 -0
- package/dist/elements/change-detail.js +19 -0
- package/dist/elements/change-detail.js.map +1 -0
- package/dist/elements/change-list.d.ts +27 -0
- package/dist/elements/change-list.d.ts.map +1 -0
- package/dist/elements/change-list.js +29 -0
- package/dist/elements/change-list.js.map +1 -0
- package/dist/elements/filters.d.ts +25 -0
- package/dist/elements/filters.d.ts.map +1 -0
- package/dist/elements/filters.js +19 -0
- package/dist/elements/filters.js.map +1 -0
- package/dist/elements/legend.d.ts +40 -0
- package/dist/elements/legend.d.ts.map +1 -0
- package/dist/elements/legend.js +87 -0
- package/dist/elements/legend.js.map +1 -0
- package/dist/elements/minimap.d.ts +38 -0
- package/dist/elements/minimap.d.ts.map +1 -0
- package/dist/elements/minimap.js +59 -0
- package/dist/elements/minimap.js.map +1 -0
- package/dist/elements/root.d.ts +40 -0
- package/dist/elements/root.d.ts.map +1 -0
- package/dist/elements/root.js +67 -0
- package/dist/elements/root.js.map +1 -0
- package/dist/elements/search.d.ts +29 -0
- package/dist/elements/search.d.ts.map +1 -0
- package/dist/elements/search.js +23 -0
- package/dist/elements/search.js.map +1 -0
- package/dist/elements/stage-context.d.ts +31 -0
- package/dist/elements/stage-context.d.ts.map +1 -0
- package/dist/elements/stage-context.js +4 -0
- package/dist/elements/stage-context.js.map +1 -0
- package/dist/elements/stage.d.ts +65 -0
- package/dist/elements/stage.d.ts.map +1 -0
- package/dist/elements/stage.js +259 -0
- package/dist/elements/stage.js.map +1 -0
- package/dist/elements/title.d.ts +25 -0
- package/dist/elements/title.d.ts.map +1 -0
- package/dist/elements/title.js +19 -0
- package/dist/elements/title.js.map +1 -0
- package/dist/elements/transport.d.ts +29 -0
- package/dist/elements/transport.d.ts.map +1 -0
- package/dist/elements/transport.js +24 -0
- package/dist/elements/transport.js.map +1 -0
- package/dist/elements/view-bound.d.ts +39 -0
- package/dist/elements/view-bound.d.ts.map +1 -0
- package/dist/elements/view-bound.js +88 -0
- package/dist/elements/view-bound.js.map +1 -0
- package/dist/elements/view-dock.d.ts +28 -0
- package/dist/elements/view-dock.d.ts.map +1 -0
- package/dist/elements/view-dock.js +28 -0
- package/dist/elements/view-dock.js.map +1 -0
- package/dist/elements/zoom.d.ts +28 -0
- package/dist/elements/zoom.d.ts.map +1 -0
- package/dist/elements/zoom.js +22 -0
- package/dist/elements/zoom.js.map +1 -0
- package/dist/i18n.d.ts +10 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +20 -0
- package/dist/i18n.js.map +1 -1
- package/dist/icons.d.ts +1 -0
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +1 -0
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +328 -34
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/keyboard.d.ts +39 -2
- package/dist/keyboard.d.ts.map +1 -1
- package/dist/keyboard.js +145 -62
- package/dist/keyboard.js.map +1 -1
- package/dist/layout-store.d.ts +26 -0
- package/dist/layout-store.d.ts.map +1 -0
- package/dist/layout-store.js +46 -0
- package/dist/layout-store.js.map +1 -0
- package/dist/panel-levels.d.ts +18 -0
- package/dist/panel-levels.d.ts.map +1 -0
- package/dist/panel-levels.js +41 -0
- package/dist/panel-levels.js.map +1 -0
- package/dist/panels.d.ts +28 -0
- package/dist/panels.d.ts.map +1 -0
- package/dist/panels.js +73 -0
- package/dist/panels.js.map +1 -0
- package/dist/prefs.d.ts +5 -2
- package/dist/prefs.d.ts.map +1 -1
- package/dist/prefs.js +12 -3
- package/dist/prefs.js.map +1 -1
- package/dist/react.d.ts +460 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +2 -0
- package/dist/react.js.map +1 -0
- package/dist/root-element.d.ts +122 -0
- package/dist/root-element.d.ts.map +1 -0
- package/dist/root-element.js +541 -0
- package/dist/root-element.js.map +1 -0
- package/dist/root.d.ts +30 -30
- package/dist/root.d.ts.map +1 -1
- package/dist/root.js +65 -42
- package/dist/root.js.map +1 -1
- package/dist/session.d.ts +19 -3
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +15 -17
- package/dist/session.js.map +1 -1
- package/dist/standalone/kgdiff-viewer.js +4 -4
- package/dist/styles.d.ts +4 -0
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +224 -84
- package/dist/styles.js.map +1 -1
- package/dist/theme.d.ts +7 -0
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +10 -7
- package/dist/theme.js.map +1 -1
- package/dist/topics.d.ts +8 -0
- package/dist/topics.d.ts.map +1 -0
- package/dist/topics.js +18 -0
- package/dist/topics.js.map +1 -0
- package/dist/ui/change-list.d.ts.map +1 -1
- package/dist/ui/change-list.js +2 -2
- package/dist/ui/change-list.js.map +1 -1
- package/dist/ui/context.d.ts +20 -7
- package/dist/ui/context.d.ts.map +1 -1
- package/dist/ui/context.js.map +1 -1
- package/dist/ui/header.d.ts +41 -3
- package/dist/ui/header.d.ts.map +1 -1
- package/dist/ui/header.js +99 -34
- package/dist/ui/header.js.map +1 -1
- package/dist/ui/legend.d.ts +15 -0
- package/dist/ui/legend.d.ts.map +1 -0
- package/dist/ui/legend.js +68 -0
- package/dist/ui/legend.js.map +1 -0
- package/dist/ui/popovers.d.ts +3 -1
- package/dist/ui/popovers.d.ts.map +1 -1
- package/dist/ui/popovers.js +62 -23
- package/dist/ui/popovers.js.map +1 -1
- package/dist/ui/search.d.ts.map +1 -1
- package/dist/ui/search.js +3 -2
- package/dist/ui/search.js.map +1 -1
- package/dist/ui/sidebar.d.ts +26 -3
- package/dist/ui/sidebar.d.ts.map +1 -1
- package/dist/ui/sidebar.js +344 -12
- package/dist/ui/sidebar.js.map +1 -1
- package/dist/ui/stage.d.ts +56 -1
- package/dist/ui/stage.d.ts.map +1 -1
- package/dist/ui/stage.js +244 -256
- package/dist/ui/stage.js.map +1 -1
- package/dist/ui/transport.d.ts +12 -0
- package/dist/ui/transport.d.ts.map +1 -0
- package/dist/ui/transport.js +198 -0
- package/dist/ui/transport.js.map +1 -0
- package/dist/ui/zoom.d.ts +15 -0
- package/dist/ui/zoom.d.ts.map +1 -0
- package/dist/ui/zoom.js +30 -0
- package/dist/ui/zoom.js.map +1 -0
- package/dist/view.d.ts +94 -0
- package/dist/view.d.ts.map +1 -0
- package/dist/view.js +441 -0
- package/dist/view.js.map +1 -0
- package/package.json +95 -12
- package/src/bind.ts +80 -0
- package/src/controller.ts +51 -442
- package/src/dom.ts +30 -0
- package/src/element.ts +364 -406
- package/src/elements/actions.ts +94 -0
- package/src/elements/bound.ts +247 -0
- package/src/elements/change-detail.ts +34 -0
- package/src/elements/change-list.ts +51 -0
- package/src/elements/filters.ts +34 -0
- package/src/elements/legend.ts +103 -0
- package/src/elements/minimap.ts +75 -0
- package/src/elements/root.ts +86 -0
- package/src/elements/search.ts +38 -0
- package/src/elements/stage-context.ts +34 -0
- package/src/elements/stage.ts +285 -0
- package/src/elements/title.ts +34 -0
- package/src/elements/transport.ts +39 -0
- package/src/elements/view-bound.ts +118 -0
- package/src/elements/view-dock.ts +43 -0
- package/src/elements/zoom.ts +38 -0
- package/src/i18n.ts +20 -0
- package/src/icons.ts +1 -0
- package/src/index.ts +347 -34
- package/src/keyboard.ts +188 -61
- package/src/layout-store.ts +71 -0
- package/src/panel-levels.ts +53 -0
- package/src/panels.ts +81 -0
- package/src/prefs.ts +12 -4
- package/src/react.ts +459 -0
- package/src/root-element.ts +624 -0
- package/src/root.ts +92 -65
- package/src/session.ts +22 -33
- package/src/styles.ts +245 -84
- package/src/theme.ts +10 -7
- package/src/topics.ts +37 -0
- package/src/ui/change-list.ts +6 -2
- package/src/ui/context.ts +20 -7
- package/src/ui/header.ts +127 -56
- package/src/ui/legend.ts +112 -0
- package/src/ui/popovers.ts +68 -25
- package/src/ui/search.ts +10 -5
- package/src/ui/sidebar.ts +386 -23
- package/src/ui/stage.ts +267 -331
- package/src/ui/transport.ts +240 -0
- package/src/ui/zoom.ts +45 -0
- package/src/view.ts +516 -0
- package/vscode.html-custom-data.json +726 -0
package/src/view.ts
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A view of a diff session (RFC 0001 §1): one canvas, with its renderer, camera, framing and
|
|
3
|
+
* minimaps, behind the session's `SessionView` boundary. A session can have several (each
|
|
4
|
+
* `<kgdiff-stage>` has one); they share everything the session holds (selection, hover, mode,
|
|
5
|
+
* scope, the replay clock, filters, search) and each frames it on its own canvas.
|
|
6
|
+
*
|
|
7
|
+
* - draws the session's view state into the renderer (and the minimaps, throttled while
|
|
8
|
+
* replaying); a view pinned to a `version` draws that version alone, whatever the mode;
|
|
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.
|
|
14
|
+
*/
|
|
15
|
+
import type { Rect } from '@kgdiff/core';
|
|
16
|
+
import {
|
|
17
|
+
type Camera,
|
|
18
|
+
type CameraState,
|
|
19
|
+
labelFootprint,
|
|
20
|
+
type Minimap,
|
|
21
|
+
type PickResult,
|
|
22
|
+
type Renderer,
|
|
23
|
+
} from '@kgdiff/renderer';
|
|
24
|
+
import {
|
|
25
|
+
createViewState,
|
|
26
|
+
type FocusState,
|
|
27
|
+
type FrameVisuals,
|
|
28
|
+
frameBounds,
|
|
29
|
+
type Layer,
|
|
30
|
+
type Scene,
|
|
31
|
+
} from '@kgdiff/scene';
|
|
32
|
+
import {
|
|
33
|
+
type ChromeLayout,
|
|
34
|
+
chromeInsets,
|
|
35
|
+
FRAME_PADDING,
|
|
36
|
+
freeView,
|
|
37
|
+
type Insets,
|
|
38
|
+
NO_INSETS,
|
|
39
|
+
zoomFit,
|
|
40
|
+
} from './framing.js';
|
|
41
|
+
import type { StageVersion } from './index.js';
|
|
42
|
+
import { renderBackend } from './renderer-port.js';
|
|
43
|
+
import { type DiffSession, padRect, type SessionView } from './session.js';
|
|
44
|
+
|
|
45
|
+
export interface FitOptions {
|
|
46
|
+
readonly animate?: boolean;
|
|
47
|
+
/** Room around the content, px (default `FRAME_PADDING`); kept for later automatic fits. */
|
|
48
|
+
readonly padding?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Camera follow is suppressed this long after the user moved the camera (ms). */
|
|
52
|
+
export const USER_CAMERA_GRACE = 2000;
|
|
53
|
+
/** The camera follows when less than this share of the target bounds is in view. */
|
|
54
|
+
export const FOLLOW_COVERAGE = 0.7;
|
|
55
|
+
/** Flights to a change never zoom in further, so its neighbourhood stays readable. */
|
|
56
|
+
export const FLY_MAX_ZOOM = 1.8;
|
|
57
|
+
/** Fitting everything never zooms in further (the renderer's default). */
|
|
58
|
+
export const FIT_MAX_ZOOM = 2.5;
|
|
59
|
+
/** Minimap marks are re-rasterized at most this often while replaying (ms). */
|
|
60
|
+
const MINIMAP_INTERVAL = 200;
|
|
61
|
+
/** Everything counts as in view (the minimap hides) from this share of the content bounds on. */
|
|
62
|
+
export const OVERVIEW_COVERAGE = 0.98;
|
|
63
|
+
|
|
64
|
+
interface Frames {
|
|
65
|
+
main: FrameVisuals | null;
|
|
66
|
+
before: FrameVisuals | null;
|
|
67
|
+
after: FrameVisuals | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Share of `bounds` inside `view`, in [0, 1]. */
|
|
71
|
+
export function coverage(bounds: Rect, view: Rect): number {
|
|
72
|
+
const w = Math.min(bounds.x + bounds.width, view.x + view.width) - Math.max(bounds.x, view.x);
|
|
73
|
+
const h = Math.min(bounds.y + bounds.height, view.y + view.height) - Math.max(bounds.y, view.y);
|
|
74
|
+
const area = bounds.width * bounds.height;
|
|
75
|
+
if (!(area > 0)) {
|
|
76
|
+
const inside =
|
|
77
|
+
bounds.x >= view.x &&
|
|
78
|
+
bounds.x <= view.x + view.width &&
|
|
79
|
+
bounds.y >= view.y &&
|
|
80
|
+
bounds.y <= view.y + view.height;
|
|
81
|
+
return inside ? 1 : 0;
|
|
82
|
+
}
|
|
83
|
+
return w > 0 && h > 0 ? (w * h) / area : 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class StageView implements SessionView {
|
|
87
|
+
/**
|
|
88
|
+
* Keep the view fitted (embeds without controls): fit again when the canvas is resized unless
|
|
89
|
+
* the user moved the camera since, and at the start of every loop.
|
|
90
|
+
*/
|
|
91
|
+
autoFit = false;
|
|
92
|
+
/** Canvas clicks select (and double clicks fly); off for embeds without controls. */
|
|
93
|
+
selectOnClick = true;
|
|
94
|
+
/** The whole content is in view (the minimaps have nothing to add). */
|
|
95
|
+
overview = true;
|
|
96
|
+
|
|
97
|
+
readonly #session: DiffSession;
|
|
98
|
+
#version: StageVersion | null = null;
|
|
99
|
+
#labelsInside = false;
|
|
100
|
+
#renderer: Renderer | null = null;
|
|
101
|
+
/** Minimap hosts, with their minimap while the renderer is attached. */
|
|
102
|
+
readonly #minimaps = new Map<HTMLElement, Minimap | null>();
|
|
103
|
+
readonly #unbind: (() => void)[] = [];
|
|
104
|
+
readonly #frames: Frames = { main: null, before: null, after: null };
|
|
105
|
+
#minimapDirty = true;
|
|
106
|
+
#lastMinimap = Number.NEGATIVE_INFINITY;
|
|
107
|
+
#lastUserCamera = Number.NEGATIVE_INFINITY;
|
|
108
|
+
#boundsCache: {
|
|
109
|
+
readonly scene: Scene;
|
|
110
|
+
readonly focus: FocusState | null;
|
|
111
|
+
readonly rect: Rect | null;
|
|
112
|
+
} | null = null;
|
|
113
|
+
#chrome: (() => ChromeLayout) | null = null;
|
|
114
|
+
/** Camera kept across detach / attach (element moved in the DOM). */
|
|
115
|
+
#savedCamera: CameraState | null = null;
|
|
116
|
+
/** A new diff is installed but has not been fitted to a laid-out canvas yet. */
|
|
117
|
+
#needsFit = false;
|
|
118
|
+
/** The user panned or zoomed since the last fit. */
|
|
119
|
+
#userMoved = false;
|
|
120
|
+
#fitPadding = FRAME_PADDING;
|
|
121
|
+
|
|
122
|
+
constructor(session: DiffSession) {
|
|
123
|
+
this.#session = session;
|
|
124
|
+
// A view joining a session that already shows a diff fits it once laid out.
|
|
125
|
+
this.#needsFit = session.status === 'ready';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Renderer ─────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
attach(stage: HTMLElement, minimapHost: HTMLElement | null): void {
|
|
131
|
+
if (this.#renderer) return;
|
|
132
|
+
const s = this.#session;
|
|
133
|
+
const backend = renderBackend();
|
|
134
|
+
const renderer = backend.createRenderer(stage, {
|
|
135
|
+
theme: s.theme,
|
|
136
|
+
labelsInside: this.#labelsInside,
|
|
137
|
+
});
|
|
138
|
+
this.#renderer = renderer;
|
|
139
|
+
// Re-attached (element moved): keep the view; this also stops the renderer's auto-fit.
|
|
140
|
+
if (this.#savedCamera) renderer.camera.set(this.#savedCamera);
|
|
141
|
+
if (minimapHost && !this.#minimaps.has(minimapHost)) this.#minimaps.set(minimapHost, null);
|
|
142
|
+
for (const host of this.#minimaps.keys()) {
|
|
143
|
+
this.#minimaps.set(host, backend.createMinimap(host, renderer));
|
|
144
|
+
}
|
|
145
|
+
this.#unbind.push(
|
|
146
|
+
renderer.on('hover', (pick) => this.#onHover(pick)),
|
|
147
|
+
renderer.on('click', ({ pick }) => this.#onCanvasClick(pick)),
|
|
148
|
+
renderer.on('dblclick', ({ pick }) => this.#onCanvasDblClick(pick)),
|
|
149
|
+
renderer.on('camera', ({ source }) => {
|
|
150
|
+
if (source === 'user') this.noteUserCamera();
|
|
151
|
+
this.#updateOverview();
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
this.#minimapDirty = true;
|
|
155
|
+
s.invalidate();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
detach(): void {
|
|
159
|
+
// A pending initial fit still runs after re-attach (`layoutChanged`), overriding this.
|
|
160
|
+
if (this.#renderer && this.#session.status === 'ready') {
|
|
161
|
+
this.#savedCamera = this.#renderer.camera.get();
|
|
162
|
+
}
|
|
163
|
+
for (const fn of this.#unbind.splice(0)) fn();
|
|
164
|
+
for (const [host, minimap] of this.#minimaps) {
|
|
165
|
+
minimap?.destroy();
|
|
166
|
+
this.#minimaps.set(host, null);
|
|
167
|
+
}
|
|
168
|
+
this.#renderer?.destroy();
|
|
169
|
+
this.#renderer = null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Draw a minimap of this view into `host` (in addition to the one `attach` was given); returns
|
|
174
|
+
* the function that removes it.
|
|
175
|
+
*/
|
|
176
|
+
addMinimap(host: HTMLElement): () => void {
|
|
177
|
+
if (!this.#minimaps.has(host)) {
|
|
178
|
+
const renderer = this.#renderer;
|
|
179
|
+
this.#minimaps.set(host, renderer ? renderBackend().createMinimap(host, renderer) : null);
|
|
180
|
+
this.#minimapDirty = true;
|
|
181
|
+
this.#session.invalidate();
|
|
182
|
+
}
|
|
183
|
+
return () => {
|
|
184
|
+
this.#minimaps.get(host)?.destroy();
|
|
185
|
+
this.#minimaps.delete(host);
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
get camera(): Camera | null {
|
|
190
|
+
return this.#renderer?.camera ?? null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The version this view draws whatever the mode (`<kgdiff-stage version>`), or null. */
|
|
194
|
+
get version(): StageVersion | null {
|
|
195
|
+
return this.#version;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
set version(version: StageVersion | null) {
|
|
199
|
+
if (version === this.#version) return;
|
|
200
|
+
this.#version = version;
|
|
201
|
+
this.#minimapDirty = true;
|
|
202
|
+
this.#session.invalidate();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Where floating chrome covers the canvas (measured by the element at fit / flight time).
|
|
207
|
+
* Every fit and flight frames its target in the free part of the canvas (`framing.ts`).
|
|
208
|
+
*/
|
|
209
|
+
setChrome(provider: (() => ChromeLayout) | null): void {
|
|
210
|
+
this.#chrome = provider;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Keep labels whole inside the canvas (embeds without camera controls): a label pushed from
|
|
215
|
+
* its spot by a neighbour moves inside or is left out, never cut at the edge.
|
|
216
|
+
*/
|
|
217
|
+
setLabelsInside(inside: boolean): void {
|
|
218
|
+
this.#labelsInside = inside;
|
|
219
|
+
this.#renderer?.setLabelsInside(inside);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The canvas was laid out or resized: run the initial fit a new diff is waiting for, or (with
|
|
224
|
+
* `autoFit`) fit again unless the user moved the camera since the last fit.
|
|
225
|
+
*/
|
|
226
|
+
layoutChanged(): void {
|
|
227
|
+
this.#updateOverview();
|
|
228
|
+
if (this.#session.status !== 'ready') return;
|
|
229
|
+
if (!this.#needsFit) {
|
|
230
|
+
if (this.autoFit && !this.#userMoved) void this.fit({ animate: false });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
// A selection made before the first layout (deep link, `kgdiff-ready` handler) wins.
|
|
234
|
+
const target = this.#session.selectionBounds();
|
|
235
|
+
if (target) {
|
|
236
|
+
if (!this.#frameInsets(target, FLY_MAX_ZOOM)) return;
|
|
237
|
+
this.#needsFit = false;
|
|
238
|
+
this.#flyTo(target, { animate: false });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const fit = this.#fitTarget();
|
|
242
|
+
const camera = this.#renderer?.camera;
|
|
243
|
+
if (!fit || !camera) return;
|
|
244
|
+
void this.#fitTo(camera, fit, { animate: false });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The user panned / zoomed: suspend replay camera follow for `USER_CAMERA_GRACE`. */
|
|
248
|
+
noteUserCamera(): void {
|
|
249
|
+
this.#lastUserCamera = this.#session.now();
|
|
250
|
+
this.#userMoved = true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── SessionView ──────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
/** Resolve the current view and hand it to the renderer (and the minimaps). */
|
|
256
|
+
draw(now = this.#session.now()): void {
|
|
257
|
+
const s = this.#session;
|
|
258
|
+
const resolver = s.resolver;
|
|
259
|
+
const renderer = this.#renderer;
|
|
260
|
+
if (!resolver || !renderer || s.status !== 'ready') return;
|
|
261
|
+
const f = this.#frames;
|
|
262
|
+
let mapFrame: FrameVisuals;
|
|
263
|
+
const version = this.#version;
|
|
264
|
+
if (version) {
|
|
265
|
+
f.main = resolver.resolve(s.viewState(version), f.main ?? undefined);
|
|
266
|
+
renderer.setFrame(f.main);
|
|
267
|
+
mapFrame = f.main;
|
|
268
|
+
} else if (s.mode === 'swipe') {
|
|
269
|
+
f.before = resolver.resolve(s.viewState('before'), f.before ?? undefined);
|
|
270
|
+
f.after = resolver.resolve(s.viewState('after'), f.after ?? undefined);
|
|
271
|
+
renderer.setFrame({ split: s.split, before: f.before, after: f.after });
|
|
272
|
+
mapFrame = f.after;
|
|
273
|
+
} else {
|
|
274
|
+
const layer: Layer = s.mode === 'replay' ? 'replay' : 'union';
|
|
275
|
+
f.main = resolver.resolve(s.viewState(layer), f.main ?? undefined);
|
|
276
|
+
renderer.setFrame(f.main);
|
|
277
|
+
mapFrame = f.main;
|
|
278
|
+
}
|
|
279
|
+
const replaying = !version && s.mode === 'replay' && (s.clock?.playing ?? false);
|
|
280
|
+
if (
|
|
281
|
+
this.#minimaps.size > 0 &&
|
|
282
|
+
(this.#minimapDirty || (replaying && now - this.#lastMinimap >= MINIMAP_INTERVAL))
|
|
283
|
+
) {
|
|
284
|
+
for (const minimap of this.#minimaps.values()) minimap?.update(mapFrame);
|
|
285
|
+
this.#minimapDirty = false;
|
|
286
|
+
this.#lastMinimap = now;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
sceneChanged(): void {
|
|
291
|
+
const f = this.#frames;
|
|
292
|
+
f.main = f.before = f.after = null;
|
|
293
|
+
this.#renderer?.setTheme(this.#session.scene?.theme ?? this.#session.theme);
|
|
294
|
+
this.#minimapDirty = true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
installed(): void {
|
|
298
|
+
// Fitted after the UI has shown its chrome (the session's notification is delivered
|
|
299
|
+
// first) and once the canvas has a size (see `layoutChanged`).
|
|
300
|
+
this.#needsFit = true;
|
|
301
|
+
this.#savedCamera = null;
|
|
302
|
+
queueMicrotask(() => this.layoutChanged());
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
drawnChanged(): void {
|
|
306
|
+
this.#updateOverview();
|
|
307
|
+
this.#minimapDirty = true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
show(bounds: Rect): void {
|
|
311
|
+
this.#flyTo(bounds);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Follow `bounds` (replay): fly unless the user steered recently or it is mostly visible. */
|
|
315
|
+
follow(bounds: Rect): void {
|
|
316
|
+
if (this.#session.now() - this.#lastUserCamera < USER_CAMERA_GRACE) return;
|
|
317
|
+
const free = this.#freeWorld(bounds);
|
|
318
|
+
if (!free || coverage(bounds, free) >= FOLLOW_COVERAGE) return;
|
|
319
|
+
this.#flyTo(bounds);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
looped(): void {
|
|
323
|
+
if (this.autoFit) void this.fit();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
viewport(): Rect | null {
|
|
327
|
+
return this.#renderer?.camera.viewport() ?? null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ─── Camera ───────────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
/** Insets keeping `bounds` clear of the chrome over the canvas, or null before layout. */
|
|
333
|
+
#frameInsets(bounds: Rect, maxZoom: number): Insets | null {
|
|
334
|
+
const layout = this.#chrome?.();
|
|
335
|
+
if (!layout || !(layout.width > 0) || !(layout.height > 0)) return null;
|
|
336
|
+
return chromeInsets(bounds, layout, FRAME_PADDING, maxZoom);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
#flyTo(bounds: Rect, options: { readonly animate?: boolean } = {}): void {
|
|
340
|
+
const camera = this.#renderer?.camera;
|
|
341
|
+
if (!camera) return;
|
|
342
|
+
const padding = this.#frameInsets(bounds, FLY_MAX_ZOOM);
|
|
343
|
+
void camera.flyTo(bounds, {
|
|
344
|
+
...options,
|
|
345
|
+
maxZoom: FLY_MAX_ZOOM,
|
|
346
|
+
...(padding ? { padding } : {}),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The world region visible outside the chrome, as framed for `bounds`. */
|
|
351
|
+
#freeWorld(bounds: Rect): Rect | null {
|
|
352
|
+
const camera = this.#renderer?.camera;
|
|
353
|
+
if (!camera) return null;
|
|
354
|
+
const insets = this.#frameInsets(bounds, FLY_MAX_ZOOM) ?? NO_INSETS;
|
|
355
|
+
return freeView(camera.viewport(), camera.get().k, insets);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Fit everything visible (the focus in changes-only mode), labels included, into the free part
|
|
360
|
+
* of the canvas. Before the canvas is laid out it falls back to the renderer's own fit.
|
|
361
|
+
*/
|
|
362
|
+
fit(options: FitOptions = {}): Promise<void> {
|
|
363
|
+
const camera = this.#renderer?.camera;
|
|
364
|
+
if (!camera) return Promise.resolve();
|
|
365
|
+
const { padding, ...transition } = options;
|
|
366
|
+
if (padding !== undefined) {
|
|
367
|
+
this.#fitPadding = Number.isFinite(padding) && padding >= 0 ? padding : FRAME_PADDING;
|
|
368
|
+
}
|
|
369
|
+
const fit = this.#fitTarget();
|
|
370
|
+
if (!fit) return camera.fit(undefined, transition);
|
|
371
|
+
return this.#fitTo(camera, fit, transition);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#fitTo(
|
|
375
|
+
camera: Camera,
|
|
376
|
+
fit: { readonly bounds: Rect; readonly insets: Insets },
|
|
377
|
+
transition: { readonly animate?: boolean },
|
|
378
|
+
): Promise<void> {
|
|
379
|
+
this.#needsFit = false;
|
|
380
|
+
this.#userMoved = false;
|
|
381
|
+
return camera.fit(fit.bounds, { ...transition, padding: fit.insets, maxZoom: FIT_MAX_ZOOM });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Bounds and insets that show everything visible (the changes-only focus) with every label
|
|
386
|
+
* in its spot below its node, clear of the chrome, or null before layout. Measured in the
|
|
387
|
+
* union layer, which holds every state a replay or swipe passes through (and both versions,
|
|
388
|
+
* so stages pinned to one version frame alike). Insets depend on what is framed, which
|
|
389
|
+
* depends on the zoom they allow: they are settled once more.
|
|
390
|
+
*/
|
|
391
|
+
#fitTarget(): { bounds: Rect; insets: Insets } | null {
|
|
392
|
+
const s = this.#session;
|
|
393
|
+
const layout = this.#chrome?.();
|
|
394
|
+
const resolver = s.resolver;
|
|
395
|
+
if (!layout || !resolver || !(layout.width > 0) || !(layout.height > 0)) return null;
|
|
396
|
+
const frame = resolver.resolve(
|
|
397
|
+
createViewState({
|
|
398
|
+
layer: 'union',
|
|
399
|
+
focus: s.focus,
|
|
400
|
+
families: s.families,
|
|
401
|
+
reducedMotion: s.reducedMotion,
|
|
402
|
+
}),
|
|
403
|
+
);
|
|
404
|
+
const footprint = labelFootprint(frame, s.theme);
|
|
405
|
+
const extent = (k: number): Rect | null => footprint.at(k);
|
|
406
|
+
const discs = extent(Number.POSITIVE_INFINITY);
|
|
407
|
+
if (!discs) return null;
|
|
408
|
+
const { width, height } = layout;
|
|
409
|
+
const padding = this.#fitPadding;
|
|
410
|
+
let insets = chromeInsets(discs, layout, padding, FIT_MAX_ZOOM);
|
|
411
|
+
let fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM);
|
|
412
|
+
if (!fit) return null;
|
|
413
|
+
const settled = chromeInsets(fit.content, layout, padding, FIT_MAX_ZOOM);
|
|
414
|
+
if (!sameInsets(settled, insets)) {
|
|
415
|
+
insets = settled;
|
|
416
|
+
fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM) ?? fit;
|
|
417
|
+
}
|
|
418
|
+
return { bounds: fit.bounds, insets };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Bounds of what is drawn: the scene, or the visible part of the `changes` scope's focus. */
|
|
422
|
+
#contentBounds(): Rect | null {
|
|
423
|
+
const scene = this.#session.scene;
|
|
424
|
+
if (!scene) return null;
|
|
425
|
+
const focus = this.#session.focus;
|
|
426
|
+
const cached = this.#boundsCache;
|
|
427
|
+
if (cached && cached.scene === scene && cached.focus === focus) return cached.rect;
|
|
428
|
+
const rect = focus ? focusBounds(scene, focus) : frameBounds(scene);
|
|
429
|
+
this.#boundsCache = { scene, focus, rect };
|
|
430
|
+
return rect;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Whether everything drawn is in view; the minimaps hide while it is. */
|
|
434
|
+
#updateOverview(): void {
|
|
435
|
+
const bounds = this.#contentBounds();
|
|
436
|
+
const view = this.#renderer?.camera.viewport();
|
|
437
|
+
const next = !bounds || !view || coverage(bounds, view) >= OVERVIEW_COVERAGE;
|
|
438
|
+
if (next === this.overview) return;
|
|
439
|
+
this.overview = next;
|
|
440
|
+
this.#session.notify('overview');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
zoom(direction: 1 | -1): void {
|
|
444
|
+
this.noteUserCamera();
|
|
445
|
+
this.#renderer?.camera.zoomBy(direction > 0 ? 1.4 : 1 / 1.4);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ─── Canvas events ────────────────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
#onHover(pick: PickResult | null): void {
|
|
451
|
+
this.#session.setHover(
|
|
452
|
+
pick?.type === 'node' ? pick.index : -1,
|
|
453
|
+
pick?.type === 'edge' ? pick.index : -1,
|
|
454
|
+
pick?.type === 'collapsed' ? pick.index : -1,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
#onCanvasClick(pick: PickResult | null): void {
|
|
459
|
+
if (!this.selectOnClick) return;
|
|
460
|
+
const s = this.#session;
|
|
461
|
+
if (!pick) {
|
|
462
|
+
s.clearSelection();
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
switch (pick.type) {
|
|
466
|
+
case 'node':
|
|
467
|
+
s.selectNode(pick.index, { fly: false });
|
|
468
|
+
break;
|
|
469
|
+
case 'edge': {
|
|
470
|
+
const e = s.scene?.edges[pick.index];
|
|
471
|
+
if (e && e.hunk >= 0) s.selectHunk(e.hunk, { fly: false });
|
|
472
|
+
s.pinnedEdge = pick.index;
|
|
473
|
+
s.invalidate();
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
case 'collapsed':
|
|
477
|
+
s.expandGroup(pick.index);
|
|
478
|
+
break;
|
|
479
|
+
default:
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
#onCanvasDblClick(pick: PickResult | null): void {
|
|
485
|
+
if (!this.selectOnClick || pick?.type !== 'node') return;
|
|
486
|
+
const s = this.#session;
|
|
487
|
+
const n = s.scene?.nodes[pick.index];
|
|
488
|
+
if (!n) return;
|
|
489
|
+
if (n.hunk >= 0) s.flyToHunk(n.hunk);
|
|
490
|
+
else s.flyToNode(pick.index);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function sameInsets(a: Insets, b: Insets): boolean {
|
|
495
|
+
return a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Bounds of the visible part of `focus`, with room for labels and the collapsed pills. */
|
|
499
|
+
function focusBounds(scene: Scene, focus: FocusState): Rect {
|
|
500
|
+
let x0 = Number.POSITIVE_INFINITY;
|
|
501
|
+
let y0 = Number.POSITIVE_INFINITY;
|
|
502
|
+
let x1 = Number.NEGATIVE_INFINITY;
|
|
503
|
+
let y1 = Number.NEGATIVE_INFINITY;
|
|
504
|
+
const add = (x: number, y: number, rx: number, top: number, bottom: number): void => {
|
|
505
|
+
x0 = Math.min(x0, x - rx);
|
|
506
|
+
x1 = Math.max(x1, x + rx);
|
|
507
|
+
y0 = Math.min(y0, y - top);
|
|
508
|
+
y1 = Math.max(y1, y + bottom);
|
|
509
|
+
};
|
|
510
|
+
// Labels sit centered below their node: leave room for a typical one.
|
|
511
|
+
for (const n of scene.nodes)
|
|
512
|
+
if (focus.nodes[n.index] === 1) add(n.x, n.y, n.r + 48, n.r, n.r + 24);
|
|
513
|
+
for (const g of focus.collapsed) add(g.x, g.y, 48, 12, 12);
|
|
514
|
+
if (!(x0 <= x1)) return frameBounds(scene);
|
|
515
|
+
return padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }, 160, 1.12);
|
|
516
|
+
}
|