@kgdiff/viewer 0.4.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.
Files changed (257) hide show
  1. package/README.md +43 -16
  2. package/custom-elements.json +2347 -0
  3. package/dist/bind.d.ts +41 -0
  4. package/dist/bind.d.ts.map +1 -0
  5. package/dist/bind.js +47 -0
  6. package/dist/bind.js.map +1 -0
  7. package/dist/context.d.ts +42 -0
  8. package/dist/context.d.ts.map +1 -0
  9. package/dist/context.js +87 -0
  10. package/dist/context.js.map +1 -0
  11. package/dist/controller.d.ts +26 -237
  12. package/dist/controller.d.ts.map +1 -1
  13. package/dist/controller.js +33 -1484
  14. package/dist/controller.js.map +1 -1
  15. package/dist/dom.d.ts +10 -0
  16. package/dist/dom.d.ts.map +1 -1
  17. package/dist/dom.js +29 -0
  18. package/dist/dom.js.map +1 -1
  19. package/dist/element.d.ts +51 -42
  20. package/dist/element.d.ts.map +1 -1
  21. package/dist/element.js +381 -602
  22. package/dist/element.js.map +1 -1
  23. package/dist/elements/actions.d.ts +27 -0
  24. package/dist/elements/actions.d.ts.map +1 -0
  25. package/dist/elements/actions.js +81 -0
  26. package/dist/elements/actions.js.map +1 -0
  27. package/dist/elements/bound.d.ts +63 -0
  28. package/dist/elements/bound.d.ts.map +1 -0
  29. package/dist/elements/bound.js +213 -0
  30. package/dist/elements/bound.js.map +1 -0
  31. package/dist/elements/change-detail.d.ts +25 -0
  32. package/dist/elements/change-detail.d.ts.map +1 -0
  33. package/dist/elements/change-detail.js +19 -0
  34. package/dist/elements/change-detail.js.map +1 -0
  35. package/dist/elements/change-list.d.ts +27 -0
  36. package/dist/elements/change-list.d.ts.map +1 -0
  37. package/dist/elements/change-list.js +29 -0
  38. package/dist/elements/change-list.js.map +1 -0
  39. package/dist/elements/filters.d.ts +25 -0
  40. package/dist/elements/filters.d.ts.map +1 -0
  41. package/dist/elements/filters.js +19 -0
  42. package/dist/elements/filters.js.map +1 -0
  43. package/dist/elements/legend.d.ts +40 -0
  44. package/dist/elements/legend.d.ts.map +1 -0
  45. package/dist/elements/legend.js +87 -0
  46. package/dist/elements/legend.js.map +1 -0
  47. package/dist/elements/minimap.d.ts +38 -0
  48. package/dist/elements/minimap.d.ts.map +1 -0
  49. package/dist/elements/minimap.js +59 -0
  50. package/dist/elements/minimap.js.map +1 -0
  51. package/dist/elements/root.d.ts +40 -0
  52. package/dist/elements/root.d.ts.map +1 -0
  53. package/dist/elements/root.js +67 -0
  54. package/dist/elements/root.js.map +1 -0
  55. package/dist/elements/search.d.ts +29 -0
  56. package/dist/elements/search.d.ts.map +1 -0
  57. package/dist/elements/search.js +23 -0
  58. package/dist/elements/search.js.map +1 -0
  59. package/dist/elements/stage-context.d.ts +31 -0
  60. package/dist/elements/stage-context.d.ts.map +1 -0
  61. package/dist/elements/stage-context.js +4 -0
  62. package/dist/elements/stage-context.js.map +1 -0
  63. package/dist/elements/stage.d.ts +65 -0
  64. package/dist/elements/stage.d.ts.map +1 -0
  65. package/dist/elements/stage.js +259 -0
  66. package/dist/elements/stage.js.map +1 -0
  67. package/dist/elements/title.d.ts +25 -0
  68. package/dist/elements/title.d.ts.map +1 -0
  69. package/dist/elements/title.js +19 -0
  70. package/dist/elements/title.js.map +1 -0
  71. package/dist/elements/transport.d.ts +29 -0
  72. package/dist/elements/transport.d.ts.map +1 -0
  73. package/dist/elements/transport.js +24 -0
  74. package/dist/elements/transport.js.map +1 -0
  75. package/dist/elements/view-bound.d.ts +39 -0
  76. package/dist/elements/view-bound.d.ts.map +1 -0
  77. package/dist/elements/view-bound.js +88 -0
  78. package/dist/elements/view-bound.js.map +1 -0
  79. package/dist/elements/view-dock.d.ts +28 -0
  80. package/dist/elements/view-dock.d.ts.map +1 -0
  81. package/dist/elements/view-dock.js +28 -0
  82. package/dist/elements/view-dock.js.map +1 -0
  83. package/dist/elements/zoom.d.ts +28 -0
  84. package/dist/elements/zoom.d.ts.map +1 -0
  85. package/dist/elements/zoom.js +22 -0
  86. package/dist/elements/zoom.js.map +1 -0
  87. package/dist/i18n.d.ts +10 -0
  88. package/dist/i18n.d.ts.map +1 -1
  89. package/dist/i18n.js +20 -0
  90. package/dist/i18n.js.map +1 -1
  91. package/dist/icons.d.ts +1 -0
  92. package/dist/icons.d.ts.map +1 -1
  93. package/dist/icons.js +1 -0
  94. package/dist/icons.js.map +1 -1
  95. package/dist/index.d.ts +331 -36
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +13 -0
  98. package/dist/index.js.map +1 -1
  99. package/dist/keyboard.d.ts +39 -2
  100. package/dist/keyboard.d.ts.map +1 -1
  101. package/dist/keyboard.js +145 -62
  102. package/dist/keyboard.js.map +1 -1
  103. package/dist/layout-store.d.ts +26 -0
  104. package/dist/layout-store.d.ts.map +1 -0
  105. package/dist/layout-store.js +46 -0
  106. package/dist/layout-store.js.map +1 -0
  107. package/dist/panel-levels.d.ts +18 -0
  108. package/dist/panel-levels.d.ts.map +1 -0
  109. package/dist/panel-levels.js +41 -0
  110. package/dist/panel-levels.js.map +1 -0
  111. package/dist/panels.d.ts +28 -0
  112. package/dist/panels.d.ts.map +1 -0
  113. package/dist/panels.js +73 -0
  114. package/dist/panels.js.map +1 -0
  115. package/dist/pipeline.d.ts.map +1 -1
  116. package/dist/pipeline.js +3 -2
  117. package/dist/pipeline.js.map +1 -1
  118. package/dist/prefs.d.ts +5 -2
  119. package/dist/prefs.d.ts.map +1 -1
  120. package/dist/prefs.js +12 -3
  121. package/dist/prefs.js.map +1 -1
  122. package/dist/react.d.ts +460 -0
  123. package/dist/react.d.ts.map +1 -0
  124. package/dist/react.js +2 -0
  125. package/dist/react.js.map +1 -0
  126. package/dist/root-element.d.ts +122 -0
  127. package/dist/root-element.d.ts.map +1 -0
  128. package/dist/root-element.js +541 -0
  129. package/dist/root-element.js.map +1 -0
  130. package/dist/root.d.ts +54 -0
  131. package/dist/root.d.ts.map +1 -0
  132. package/dist/root.js +288 -0
  133. package/dist/root.js.map +1 -0
  134. package/dist/schedule.d.ts +22 -0
  135. package/dist/schedule.d.ts.map +1 -0
  136. package/dist/schedule.js +41 -0
  137. package/dist/schedule.js.map +1 -0
  138. package/dist/session.d.ts +269 -0
  139. package/dist/session.d.ts.map +1 -0
  140. package/dist/session.js +1237 -0
  141. package/dist/session.js.map +1 -0
  142. package/dist/standalone/kgdiff-viewer-compute.js +1 -1
  143. package/dist/standalone/kgdiff-viewer.js +4 -4
  144. package/dist/styles.d.ts +17 -6
  145. package/dist/styles.d.ts.map +1 -1
  146. package/dist/styles.js +367 -258
  147. package/dist/styles.js.map +1 -1
  148. package/dist/theme.d.ts +32 -0
  149. package/dist/theme.d.ts.map +1 -0
  150. package/dist/theme.js +152 -0
  151. package/dist/theme.js.map +1 -0
  152. package/dist/topics.d.ts +8 -0
  153. package/dist/topics.d.ts.map +1 -0
  154. package/dist/topics.js +18 -0
  155. package/dist/topics.js.map +1 -0
  156. package/dist/ui/change-list.d.ts +18 -0
  157. package/dist/ui/change-list.d.ts.map +1 -0
  158. package/dist/ui/change-list.js +331 -0
  159. package/dist/ui/change-list.js.map +1 -0
  160. package/dist/ui/context.d.ts +34 -7
  161. package/dist/ui/context.d.ts.map +1 -1
  162. package/dist/ui/context.js.map +1 -1
  163. package/dist/ui/filters.d.ts +9 -0
  164. package/dist/ui/filters.d.ts.map +1 -0
  165. package/dist/ui/filters.js +68 -0
  166. package/dist/ui/filters.js.map +1 -0
  167. package/dist/ui/header.d.ts +41 -3
  168. package/dist/ui/header.d.ts.map +1 -1
  169. package/dist/ui/header.js +101 -36
  170. package/dist/ui/header.js.map +1 -1
  171. package/dist/ui/legend.d.ts +15 -0
  172. package/dist/ui/legend.d.ts.map +1 -0
  173. package/dist/ui/legend.js +68 -0
  174. package/dist/ui/legend.js.map +1 -0
  175. package/dist/ui/popovers.d.ts +3 -1
  176. package/dist/ui/popovers.d.ts.map +1 -1
  177. package/dist/ui/popovers.js +62 -23
  178. package/dist/ui/popovers.js.map +1 -1
  179. package/dist/ui/search.d.ts +13 -0
  180. package/dist/ui/search.d.ts.map +1 -0
  181. package/dist/ui/search.js +192 -0
  182. package/dist/ui/search.js.map +1 -0
  183. package/dist/ui/sidebar.d.ts +32 -17
  184. package/dist/ui/sidebar.d.ts.map +1 -1
  185. package/dist/ui/sidebar.js +327 -519
  186. package/dist/ui/sidebar.js.map +1 -1
  187. package/dist/ui/stage.d.ts +56 -1
  188. package/dist/ui/stage.d.ts.map +1 -1
  189. package/dist/ui/stage.js +244 -256
  190. package/dist/ui/stage.js.map +1 -1
  191. package/dist/ui/swatch.js +1 -1
  192. package/dist/ui/swatch.js.map +1 -1
  193. package/dist/ui/transport.d.ts +12 -0
  194. package/dist/ui/transport.d.ts.map +1 -0
  195. package/dist/ui/transport.js +198 -0
  196. package/dist/ui/transport.js.map +1 -0
  197. package/dist/ui/zoom.d.ts +15 -0
  198. package/dist/ui/zoom.d.ts.map +1 -0
  199. package/dist/ui/zoom.js +30 -0
  200. package/dist/ui/zoom.js.map +1 -0
  201. package/dist/view.d.ts +94 -0
  202. package/dist/view.d.ts.map +1 -0
  203. package/dist/view.js +441 -0
  204. package/dist/view.js.map +1 -0
  205. package/package.json +95 -12
  206. package/src/bind.ts +80 -0
  207. package/src/context.ts +106 -0
  208. package/src/controller.ts +52 -1630
  209. package/src/dom.ts +30 -0
  210. package/src/element.ts +386 -619
  211. package/src/elements/actions.ts +94 -0
  212. package/src/elements/bound.ts +247 -0
  213. package/src/elements/change-detail.ts +34 -0
  214. package/src/elements/change-list.ts +51 -0
  215. package/src/elements/filters.ts +34 -0
  216. package/src/elements/legend.ts +103 -0
  217. package/src/elements/minimap.ts +75 -0
  218. package/src/elements/root.ts +86 -0
  219. package/src/elements/search.ts +38 -0
  220. package/src/elements/stage-context.ts +34 -0
  221. package/src/elements/stage.ts +285 -0
  222. package/src/elements/title.ts +34 -0
  223. package/src/elements/transport.ts +39 -0
  224. package/src/elements/view-bound.ts +118 -0
  225. package/src/elements/view-dock.ts +43 -0
  226. package/src/elements/zoom.ts +38 -0
  227. package/src/i18n.ts +20 -0
  228. package/src/icons.ts +1 -0
  229. package/src/index.ts +350 -35
  230. package/src/keyboard.ts +188 -61
  231. package/src/layout-store.ts +71 -0
  232. package/src/panel-levels.ts +53 -0
  233. package/src/panels.ts +81 -0
  234. package/src/pipeline.ts +4 -1
  235. package/src/prefs.ts +12 -4
  236. package/src/react.ts +459 -0
  237. package/src/root-element.ts +624 -0
  238. package/src/root.ts +335 -0
  239. package/src/schedule.ts +43 -0
  240. package/src/session.ts +1375 -0
  241. package/src/styles.ts +396 -263
  242. package/src/theme.ts +170 -0
  243. package/src/topics.ts +37 -0
  244. package/src/ui/change-list.ts +405 -0
  245. package/src/ui/context.ts +36 -7
  246. package/src/ui/filters.ts +79 -0
  247. package/src/ui/header.ts +129 -58
  248. package/src/ui/legend.ts +112 -0
  249. package/src/ui/popovers.ts +68 -25
  250. package/src/ui/search.ts +225 -0
  251. package/src/ui/sidebar.ts +362 -639
  252. package/src/ui/stage.ts +267 -331
  253. package/src/ui/swatch.ts +1 -1
  254. package/src/ui/transport.ts +240 -0
  255. package/src/ui/zoom.ts +45 -0
  256. package/src/view.ts +516 -0
  257. package/vscode.html-custom-data.json +726 -0
package/src/controller.ts CHANGED
@@ -1,1669 +1,91 @@
1
1
  /**
2
- * Viewer state and orchestration, independent of the UI:
3
- *
4
- * - load pipeline with progress steps (sources or a precomputed report);
5
- * - view (mode × scope), selection (with an animated spotlight `emphasis`), family filters,
6
- * review progress;
7
- * - the `changes` scope's focus with expandable collapsed groups (`extendLayout` + scene rebuild);
8
- * - replay clock driven by one requestAnimationFrame loop that sleeps when idle, with a camera
9
- * that follows the playing hunk / chapter unless the user moved it in the last 2 s;
10
- * - swipe position, hover, theme / palette / locale (rebuilding the scene when they change).
11
- *
12
- * The UI subscribes to topic notifications (batched per microtask) and reads public fields.
2
+ * The session of `<kgdiff-viewer>`: a `DiffSession` (state, commands, events; `session.ts`) with
3
+ * the one view of the default layout's canvas (`StageView`, `view.ts`: renderer, camera,
4
+ * framing, minimap) built in, so the layout and its tests address both as one object. A
5
+ * `<kgdiff-stage>` bound to the viewer adds a view of its own; `fit()` and `zoom()` reach all.
13
6
  */
14
- import type { DiffResult, DiffStats, Locale, Rect, UnionId } from '@kgdiff/core';
15
- import { KgdiffError } from '@kgdiff/core';
16
- import type { LayoutResult } from '@kgdiff/layout';
17
- import {
18
- type Camera,
19
- type CameraState,
20
- labelFootprint,
21
- type Minimap,
22
- type PickResult,
23
- type Renderer,
24
- } from '@kgdiff/renderer';
25
- import {
26
- buildScene,
27
- type ChangeFamily,
28
- computeFocus,
29
- createViewState,
30
- createVisualResolver,
31
- type FocusState,
32
- type FrameVisuals,
33
- frameBounds,
34
- getTheme,
35
- type Layer,
36
- type PaletteName,
37
- type Scene,
38
- type Theme,
39
- type ThemeName,
40
- type ViewState,
41
- type VisualResolver,
42
- } from '@kgdiff/scene';
43
- import { computeNow, computeStatus, loadCompute } from './compute-loader.js';
44
- import {
45
- type ChromeLayout,
46
- chromeInsets,
47
- FRAME_PADDING,
48
- freeView,
49
- type Insets,
50
- NO_INSETS,
51
- zoomFit,
52
- } from './framing.js';
53
- import { translate } from './i18n.js';
54
- import type {
55
- LoadOptions,
56
- ReportPayload,
57
- ReviewState,
58
- ThemePreference,
59
- ViewerMode,
60
- ViewerScope,
61
- } from './index.js';
62
- import { buildList, EMPTY_LIST, type ListModel, stepHunk } from './list-model.js';
63
- import { type LoadedDiff, type LoadStep, readPayload, runLoad } from './pipeline.js';
64
- import { LEGACY_CHANGES_MODE, toLocale, toMode, toPalette, toScope, toTheme } from './prefs.js';
65
- import { renderBackend } from './renderer-port.js';
66
- import { ReplayClock } from './replay.js';
67
- import { ReviewStore, reviewKey, type StorageLike } from './review-store.js';
68
- import { type SearchHit, SearchIndex } from './search.js';
7
+ import type { Camera } from '@kgdiff/renderer';
8
+ import type { ChromeLayout } from './framing.js';
9
+ import { DiffSession, type SessionDeps, type SessionHost } from './session.js';
10
+ import { StageView } from './view.js';
69
11
 
70
- export type Status = 'idle' | 'loading' | 'ready' | 'error';
12
+ export type { Preference, Status, Topic } from './session.js';
13
+ export type { FitOptions } from './view.js';
14
+ export {
15
+ coverage,
16
+ FIT_MAX_ZOOM,
17
+ FLY_MAX_ZOOM,
18
+ FOLLOW_COVERAGE,
19
+ OVERVIEW_COVERAGE,
20
+ USER_CAMERA_GRACE,
21
+ } from './view.js';
22
+ export type ControllerHost = SessionHost;
23
+ export type ControllerDeps = SessionDeps;
71
24
 
72
- export type Topic =
73
- | 'status'
74
- | 'data'
75
- | 'mode'
76
- | 'selection'
77
- | 'filters'
78
- | 'review'
79
- | 'replay'
80
- | 'hover'
81
- | 'theme'
82
- | 'locale'
83
- | 'focus'
84
- | 'swipe'
85
- | 'overview';
25
+ export class ViewerController extends DiffSession {
26
+ /** The default layout's canvas. */
27
+ readonly view: StageView;
86
28
 
87
- export interface ControllerHost {
88
- ready(stats: DiffStats): void;
89
- select(hunk: string | null, node: string | null): void;
90
- mode(mode: ViewerMode, scope: ViewerScope): void;
91
- review(hunk: string, reviewed: boolean, progress: ReviewState): void;
92
- error(error: Error): void;
93
- announce(text: string): void;
94
- /** Playback reached the end of the replay; `loop`: it starts again after the loop delay. */
95
- replayEnd(loop: boolean): void;
96
- }
97
-
98
- export interface FitOptions {
99
- readonly animate?: boolean;
100
- /** Room around the content, px (default `FRAME_PADDING`); kept for later automatic fits. */
101
- readonly padding?: number;
102
- }
103
-
104
- export interface ControllerDeps {
105
- readonly now: () => number;
106
- readonly requestFrame: (cb: (time: number) => void) => number;
107
- readonly cancelFrame: (id: number) => void;
108
- /** Resolves once the browser had a chance to paint (between loading steps). */
109
- readonly yieldToPaint: () => Promise<void>;
110
- readonly storage: StorageLike | null;
111
- }
112
-
113
- export type Preference = 'mode' | 'scope' | 'theme' | 'palette' | 'locale';
114
-
115
- /** Camera follow is suppressed this long after the user moved the camera (ms). */
116
- export const USER_CAMERA_GRACE = 2000;
117
- /** The camera follows when less than this share of the target bounds is in view. */
118
- export const FOLLOW_COVERAGE = 0.7;
119
- /** Spotlight emphasis restarts here when switching directly between two hunks. */
120
- const SWITCH_EMPHASIS = 0.35;
121
- /** Flights to a change never zoom in further, so its neighbourhood stays readable. */
122
- export const FLY_MAX_ZOOM = 1.8;
123
- /** Fitting everything never zooms in further (the renderer's default). */
124
- export const FIT_MAX_ZOOM = 2.5;
125
- /** Minimap marks are re-rasterized at most this often while replaying (ms). */
126
- const MINIMAP_INTERVAL = 200;
127
- /** Everything counts as in view (the minimap hides) from this share of the content bounds on. */
128
- export const OVERVIEW_COVERAGE = 0.98;
129
-
130
- class Superseded extends Error {}
131
-
132
- interface Frames {
133
- main: FrameVisuals | null;
134
- before: FrameVisuals | null;
135
- after: FrameVisuals | null;
136
- }
137
-
138
- interface EmphasisAnimation {
139
- readonly from: number;
140
- readonly to: number;
141
- readonly start: number;
142
- readonly duration: number;
143
- }
144
-
145
- /** Share of `bounds` inside `view`, in [0, 1]. */
146
- export function coverage(bounds: Rect, view: Rect): number {
147
- const w = Math.min(bounds.x + bounds.width, view.x + view.width) - Math.max(bounds.x, view.x);
148
- const h = Math.min(bounds.y + bounds.height, view.y + view.height) - Math.max(bounds.y, view.y);
149
- const area = bounds.width * bounds.height;
150
- if (!(area > 0)) {
151
- const inside =
152
- bounds.x >= view.x &&
153
- bounds.x <= view.x + view.width &&
154
- bounds.y >= view.y &&
155
- bounds.y <= view.y + view.height;
156
- return inside ? 1 : 0;
157
- }
158
- return w > 0 && h > 0 ? (w * h) / area : 0;
159
- }
160
-
161
- const ease = (u: number): number => 1 - (1 - u) ** 3;
162
-
163
- export class ViewerController {
164
- status: Status = 'idle';
165
- /** Steps of the current load, in order; `step` is the running one. */
166
- steps: LoadStep[] = [];
167
- step: LoadStep | null = null;
168
- error: Error | null = null;
169
-
170
- data: LoadedDiff | null = null;
171
- layout: LayoutResult | null = null;
172
- scene: Scene | null = null;
173
- resolver: VisualResolver | null = null;
174
- list: ListModel = EMPTY_LIST;
175
-
176
- mode: ViewerMode = 'overlay';
177
- scope: ViewerScope = 'all';
178
- themePreference: ThemePreference = 'auto';
179
- palette: PaletteName = 'default';
180
- locale: Locale = 'en';
181
- systemDark = true;
182
- reducedMotion = false;
183
- /** Preferences set explicitly by the embedder (a report's `view` does not override them). */
184
- readonly explicit = new Set<Preference>();
185
-
186
- selectedHunk = -1;
187
- selectedNode = -1;
188
- /** An edge emphasized from the detail panel (drawn as hovered). */
189
- pinnedEdge = -1;
190
- hoverNode = -1;
191
- hoverEdge = -1;
192
- hoverGroup = -1;
193
- readonly families = new Set<ChangeFamily>();
194
- hops = 1;
195
- readonly expanded = new Set<UnionId>();
196
- focus: FocusState | null = null;
197
- clock: ReplayClock | null = null;
198
- split = 0.5;
199
- emphasis = 1;
200
- /**
201
- * Keep the view fitted (embeds without controls): fit again when the canvas is resized unless
202
- * the user moved the camera since, and at the start of every loop.
203
- */
204
- autoFit = false;
205
- /** Canvas clicks select (and double clicks fly); off for embeds without controls. */
206
- selectOnClick = true;
207
- #labelsInside = false;
208
- /** The whole content is in view (the minimap has nothing to add). */
209
- overview = true;
210
-
211
- #review: ReviewStore | null = null;
212
- #searchIndex: SearchIndex | null = null;
213
- #renderer: Renderer | null = null;
214
- #minimap: Minimap | null = null;
215
- readonly #unbind: (() => void)[] = [];
216
- readonly #frames: Frames = { main: null, before: null, after: null };
217
- #fadingHunk = -1;
218
- #emphasisAnim: EmphasisAnimation | null = null;
219
- #rafId = 0;
220
- #lastTick = 0;
221
- #frameDirty = false;
222
- #minimapDirty = true;
223
- #lastMinimap = Number.NEGATIVE_INFINITY;
224
- #lastUserCamera = Number.NEGATIVE_INFINITY;
225
- #followed = '';
226
- #loadToken = 0;
227
- #pendingHunk: { readonly id: string; readonly fly: boolean } | null = null;
228
- #boundsCache: {
229
- readonly scene: Scene;
230
- readonly focus: FocusState | null;
231
- readonly rect: Rect | null;
232
- } | null = null;
233
- #chrome: (() => ChromeLayout) | null = null;
234
- /** Camera kept across detach / attach (element moved in the DOM). */
235
- #savedCamera: CameraState | null = null;
236
- /** A new diff is installed but has not been fitted to a laid-out canvas yet. */
237
- #needsFit = false;
238
- /** The user panned or zoomed since the last fit. */
239
- #userMoved = false;
240
- #fitPadding = FRAME_PADDING;
241
- /** A finished replay starts again this long (ms) after its end; null: it plays once. */
242
- #loopDelay: number | null = null;
243
- /** When a finished looping replay starts again (`deps.now()` time), else NaN. */
244
- #restartAt = Number.NaN;
245
- /** Playback is held (pointer over the graph); `#resume`: it was playing and continues after. */
246
- #held = false;
247
- #resume = false;
248
- readonly #listeners = new Set<(topics: ReadonlySet<Topic>) => void>();
249
- #pendingTopics = new Set<Topic>();
250
- #flushQueued = false;
251
- #destroyed = false;
252
-
253
- readonly #host: ControllerHost;
254
- readonly #deps: ControllerDeps;
255
-
256
- constructor(host: ControllerHost, deps: ControllerDeps) {
257
- this.#host = host;
258
- this.#deps = deps;
259
- }
260
-
261
- // ─── Subscriptions ────────────────────────────────────────────────────────
262
-
263
- subscribe(listener: (topics: ReadonlySet<Topic>) => void): () => void {
264
- this.#listeners.add(listener);
265
- return () => this.#listeners.delete(listener);
266
- }
267
-
268
- #notify(...topics: Topic[]): void {
269
- for (const t of topics) this.#pendingTopics.add(t);
270
- if (this.#flushQueued) return;
271
- this.#flushQueued = true;
272
- queueMicrotask(() => this.flush());
273
- }
274
-
275
- /** Deliver pending notifications now (also used by tests). */
276
- flush(): void {
277
- this.#flushQueued = false;
278
- if (this.#pendingTopics.size === 0) return;
279
- const topics = this.#pendingTopics;
280
- this.#pendingTopics = new Set();
281
- for (const fn of this.#listeners) fn(topics);
282
- }
283
-
284
- // ─── Derived state ────────────────────────────────────────────────────────
285
-
286
- get themeName(): ThemeName {
287
- if (this.themePreference === 'auto') return this.systemDark ? 'dark' : 'light';
288
- return this.themePreference;
289
- }
290
-
291
- get theme(): Theme {
292
- try {
293
- return getTheme(this.themeName, this.palette);
294
- } catch {
295
- return getTheme('dark', 'default');
296
- }
297
- }
298
-
299
- get diff(): DiffResult | null {
300
- return this.data?.diff ?? null;
29
+ constructor(host: SessionHost, deps: SessionDeps) {
30
+ super(host, deps);
31
+ this.view = new StageView(this);
32
+ this.addView(this.view);
301
33
  }
302
34
 
303
- get hunkCount(): number {
304
- return this.scene?.hunks.length ?? 0;
35
+ /** Keep the canvas fitted (`StageView.autoFit`). */
36
+ get autoFit(): boolean {
37
+ return this.view.autoFit;
305
38
  }
306
39
 
307
- isReviewed(hunkId: string): boolean {
308
- return this.#review?.has(hunkId) ?? false;
309
- }
310
-
311
- reviewState(): ReviewState {
312
- return this.#review?.state() ?? { reviewed: [], total: 0 };
313
- }
314
-
315
- search(query: string, limit?: number): SearchHit[] {
316
- const diff = this.diff;
317
- if (!diff) return [];
318
- this.#searchIndex ??= new SearchIndex(diff);
319
- return this.#searchIndex.search(query, limit);
320
- }
321
-
322
- // ─── Loading ──────────────────────────────────────────────────────────────
323
-
324
- async load(options: LoadOptions): Promise<void> {
325
- const text = (s: unknown): boolean =>
326
- typeof s === 'object' && s !== null && typeof (s as { text?: unknown }).text === 'string';
327
- const parse = text(options?.before) || text(options?.after);
328
- await this.#run(
329
- parse ? ['parse', 'match', 'layout', 'scene'] : ['match', 'layout', 'scene'],
330
- (onStep) => runLoad(options, onStep),
331
- );
40
+ set autoFit(value: boolean) {
41
+ this.view.autoFit = value;
332
42
  }
333
43
 
334
- async loadPayload(payload: ReportPayload): Promise<void> {
335
- await this.#run(['match', 'layout', 'scene'], (onStep) => readPayload(payload, onStep));
44
+ /** Canvas clicks select (`StageView.selectOnClick`). */
45
+ get selectOnClick(): boolean {
46
+ return this.view.selectOnClick;
336
47
  }
337
48
 
338
- async #run(
339
- planned: LoadStep[],
340
- work: (onStep: (step: LoadStep) => Promise<void>) => Promise<LoadedDiff>,
341
- ): Promise<void> {
342
- const token = ++this.#loadToken;
343
- this.pause();
344
- this.status = 'loading';
345
- this.error = null;
346
- this.steps = planned;
347
- this.step = null;
348
- this.#notify('status');
349
- const onStep = async (step: LoadStep): Promise<void> => {
350
- if (token !== this.#loadToken || this.#destroyed) throw new Superseded();
351
- this.step = step;
352
- this.#notify('status');
353
- this.flush();
354
- await this.#deps.yieldToPaint();
355
- if (token !== this.#loadToken || this.#destroyed) throw new Superseded();
356
- };
357
- try {
358
- const loaded = await work(onStep);
359
- await onStep('scene');
360
- this.#install(loaded);
361
- } catch (error) {
362
- if (error instanceof Superseded) {
363
- throw new DOMException('Superseded by a newer load.', 'AbortError');
364
- }
365
- const err = error instanceof Error ? error : new KgdiffError('invalid-input', String(error));
366
- if (token === this.#loadToken) this.fail(err);
367
- throw err;
368
- }
49
+ set selectOnClick(value: boolean) {
50
+ this.view.selectOnClick = value;
369
51
  }
370
52
 
371
- /** Show an error state (bad payload, failed load). */
372
- fail(error: Error): void {
373
- this.#loadToken++;
374
- this.clock?.pause();
375
- this.#restartAt = Number.NaN;
376
- this.#resume = false;
377
- this.status = 'error';
378
- this.error = error;
379
- this.step = null;
380
- // Nothing stale stays on screen next to the error.
381
- this.data = null;
382
- this.layout = null;
383
- this.scene = null;
384
- this.resolver = null;
385
- this.list = EMPTY_LIST;
386
- this.clock = null;
387
- this.focus = null;
388
- this.#review = null;
389
- this.selectedHunk = this.selectedNode = this.pinnedEdge = -1;
390
- this.#notify('status', 'data', 'selection', 'filters', 'review', 'replay', 'focus');
391
- this.#host.error(error);
53
+ /** The whole content is in the canvas's view (the minimap has nothing to add). */
54
+ get overview(): boolean {
55
+ return this.view.overview;
392
56
  }
393
57
 
394
- #install(loaded: LoadedDiff): void {
395
- const { diff, layout, view } = loaded;
396
- this.data = loaded;
397
- this.layout = layout;
398
- this.#searchIndex = null;
399
- this.families.clear();
400
- this.expanded.clear();
401
- this.hops = Math.max(0, Math.min(2, loaded.layoutOptions?.contextHops ?? 1));
402
- this.selectedHunk = -1;
403
- this.selectedNode = -1;
404
- this.pinnedEdge = -1;
405
- this.#fadingHunk = -1;
406
- this.emphasis = 1;
407
- this.#emphasisAnim = null;
408
- this.hoverNode = this.hoverEdge = this.hoverGroup = -1;
409
- this.split = 0.5;
410
- this.clock = null;
411
- this.#followed = '';
412
- if (view) {
413
- if (view.theme && !this.explicit.has('theme')) this.themePreference = view.theme;
414
- if (view.palette && !this.explicit.has('palette')) this.palette = view.palette;
415
- if (view.locale && !this.explicit.has('locale')) this.locale = view.locale;
416
- }
417
- const previousMode = this.mode;
418
- const previousScope = this.scope;
419
- if (!this.explicit.has('mode')) this.mode = view?.mode ?? 'overlay';
420
- if (layout.scope === 'changes') {
421
- // Only the changes are laid out: there is no whole graph to show.
422
- this.scope = 'changes';
423
- } else if (!this.explicit.has('scope')) {
424
- const threshold = loaded.layoutOptions?.largeGraphThreshold ?? 3000;
425
- this.scope = view?.scope ?? (diff.nodes.length > threshold ? 'changes' : 'all');
426
- }
427
- this.#review = new ReviewStore(
428
- this.#deps.storage,
429
- reviewKey(diff.delta.base.hash, diff.delta.target.hash),
430
- diff.hunks.map((h) => h.id),
431
- );
432
- this.#rebuildScene();
433
- this.status = 'ready';
434
- this.step = null;
435
- if (layout.scope === 'changes' && computeStatus() === 'unknown') {
436
- // Learn early whether out-of-layout groups can be expanded (standalone reports).
437
- loadCompute().then(
438
- () => this.#notify('focus'),
439
- () => this.#notify('focus'),
440
- );
441
- }
442
- this.#notify(
443
- 'status',
444
- 'data',
445
- 'mode',
446
- 'selection',
447
- 'filters',
448
- 'review',
449
- 'replay',
450
- 'theme',
451
- 'locale',
452
- 'focus',
453
- 'swipe',
454
- 'overview',
455
- );
456
- // A new diff starts fitted, after the UI has shown its chrome (the notification above is
457
- // delivered first) and once the canvas has a size (see `layoutChanged`).
458
- this.#needsFit = true;
459
- this.#savedCamera = null;
460
- queueMicrotask(() => this.layoutChanged());
461
- this.#host.ready(diff.stats);
462
- if (this.mode !== previousMode || this.scope !== previousScope) {
463
- this.#host.mode(this.mode, this.scope);
464
- }
465
- const pending = this.#pendingHunk;
466
- this.#pendingHunk = null;
467
- if (pending !== null) this.selectHunkById(pending.id, { fly: pending.fly });
468
- else this.#autoplay();
469
- }
470
-
471
- /** (Re)build scene, resolver, list, clock and focus; selection is carried over by id. */
472
- #rebuildScene(): void {
473
- const data = this.data;
474
- const layout = this.layout;
475
- if (!data || !layout) return;
476
- const old = this.scene;
477
- const keepHunk = old && this.selectedHunk >= 0 ? old.hunks[this.selectedHunk]?.id : undefined;
478
- const keepNode = old && this.selectedNode >= 0 ? old.nodes[this.selectedNode]?.id : undefined;
479
- const keepEdge = old && this.pinnedEdge >= 0 ? old.edges[this.pinnedEdge]?.id : undefined;
480
- const scene = buildScene(data.diff, layout, { theme: this.theme, locale: this.locale });
481
- this.scene = scene;
482
- this.resolver = createVisualResolver(scene);
483
- this.#frames.main = this.#frames.before = this.#frames.after = null;
484
- const prevClock = this.clock;
485
- this.clock = new ReplayClock(scene.timeline);
486
- if (prevClock) {
487
- this.clock.setSpeed(prevClock.speed);
488
- this.clock.seek(prevClock.t);
489
- if (prevClock.playing) this.clock.play();
490
- }
491
- this.selectedHunk = keepHunk !== undefined ? (scene.hunkIndex.get(keepHunk) ?? -1) : -1;
492
- this.selectedNode = keepNode !== undefined ? (scene.nodeIndex.get(keepNode) ?? -1) : -1;
493
- this.pinnedEdge = keepEdge !== undefined ? (scene.edgeIndex.get(keepEdge) ?? -1) : -1;
494
- this.#fadingHunk = -1;
495
- this.hoverNode = this.hoverEdge = this.hoverGroup = -1;
496
- this.list = buildList(scene, this.families);
497
- this.#updateFocus();
498
- this.#renderer?.setTheme(scene.theme);
499
- this.#minimapDirty = true;
500
- this.invalidate();
501
- }
502
-
503
- #updateFocus(): void {
504
- const scene = this.scene;
505
- if (!scene || this.scope !== 'changes') {
506
- this.focus = null;
507
- return;
508
- }
509
- const expanded = new Set<number>();
510
- for (const id of this.expanded) {
511
- const i = scene.nodeIndex.get(id);
512
- if (i !== undefined) expanded.add(i);
513
- }
514
- this.focus = computeFocus(scene, { hops: this.hops, expanded });
58
+ get camera(): Camera | null {
59
+ return this.view.camera;
515
60
  }
516
61
 
517
- // ─── Renderer ─────────────────────────────────────────────────────────────
518
-
519
62
  attach(stage: HTMLElement, minimapHost: HTMLElement | null): void {
520
- if (this.#renderer) return;
521
- const backend = renderBackend();
522
- const renderer = backend.createRenderer(stage, {
523
- theme: this.theme,
524
- labelsInside: this.#labelsInside,
525
- });
526
- this.#renderer = renderer;
527
- // Re-attached (element moved): keep the view; this also stops the renderer's auto-fit.
528
- if (this.#savedCamera) renderer.camera.set(this.#savedCamera);
529
- if (minimapHost) this.#minimap = backend.createMinimap(minimapHost, renderer);
530
- this.#unbind.push(
531
- renderer.on('hover', (pick) => this.#onHover(pick)),
532
- renderer.on('click', ({ pick }) => this.#onCanvasClick(pick)),
533
- renderer.on('dblclick', ({ pick }) => this.#onCanvasDblClick(pick)),
534
- renderer.on('camera', ({ source }) => {
535
- if (source === 'user') this.noteUserCamera();
536
- this.#updateOverview();
537
- }),
538
- );
539
- this.#minimapDirty = true;
540
- this.invalidate();
63
+ this.view.attach(stage, minimapHost);
541
64
  }
542
65
 
543
66
  detach(): void {
544
- // A pending initial fit still runs after re-attach (`layoutChanged`), overriding this.
545
- if (this.#renderer && this.status === 'ready') this.#savedCamera = this.#renderer.camera.get();
546
- if (this.#rafId) this.#deps.cancelFrame(this.#rafId);
547
- this.#rafId = 0;
548
- for (const fn of this.#unbind.splice(0)) fn();
549
- this.#minimap?.destroy();
550
- this.#minimap = null;
551
- this.#renderer?.destroy();
552
- this.#renderer = null;
553
- }
554
-
555
- destroy(): void {
556
- this.#destroyed = true;
557
- this.detach();
558
- this.#listeners.clear();
67
+ this.view.detach();
68
+ this.sleep();
559
69
  }
560
70
 
561
- get camera(): Camera | null {
562
- return this.#renderer?.camera ?? null;
71
+ override destroy(): void {
72
+ super.destroy();
73
+ this.view.detach();
563
74
  }
564
75
 
565
- /**
566
- * Where floating chrome covers the canvas (measured by the element at fit / flight time).
567
- * Every fit and flight frames its target in the free part of the canvas (`framing.ts`).
568
- */
569
76
  setChrome(provider: (() => ChromeLayout) | null): void {
570
- this.#chrome = provider;
77
+ this.view.setChrome(provider);
571
78
  }
572
79
 
573
- /**
574
- * Keep labels whole inside the canvas (embeds without camera controls): a label pushed from
575
- * its spot by a neighbour moves inside or is left out, never cut at the edge.
576
- */
577
80
  setLabelsInside(inside: boolean): void {
578
- this.#labelsInside = inside;
579
- this.#renderer?.setLabelsInside(inside);
81
+ this.view.setLabelsInside(inside);
580
82
  }
581
83
 
582
- /**
583
- * The canvas was laid out or resized: run the initial fit a new diff is waiting for, or (with
584
- * `autoFit`) fit again unless the user moved the camera since the last fit.
585
- */
586
84
  layoutChanged(): void {
587
- this.#updateOverview();
588
- if (this.status !== 'ready') return;
589
- if (!this.#needsFit) {
590
- if (this.autoFit && !this.#userMoved) void this.fit({ animate: false });
591
- return;
592
- }
593
- // A selection made before the first layout (deep link, `kgdiff-ready` handler) wins.
594
- const target = this.#selectionBounds();
595
- if (target) {
596
- if (!this.#frameInsets(target, FLY_MAX_ZOOM)) return;
597
- this.#needsFit = false;
598
- this.#flyTo(target, { animate: false });
599
- return;
600
- }
601
- const fit = this.#fitTarget();
602
- const camera = this.#renderer?.camera;
603
- if (!fit || !camera) return;
604
- void this.#fitTo(camera, fit, { animate: false });
85
+ this.view.layoutChanged();
605
86
  }
606
87
 
607
- /** The user panned / zoomed: suspend replay camera follow for `USER_CAMERA_GRACE`. */
608
88
  noteUserCamera(): void {
609
- this.#lastUserCamera = this.#deps.now();
610
- this.#userMoved = true;
611
- }
612
-
613
- // ─── Frame loop ───────────────────────────────────────────────────────────
614
-
615
- /** Redraw on the next animation frame. */
616
- invalidate(): void {
617
- this.#frameDirty = true;
618
- this.#schedule();
619
- }
620
-
621
- #schedule(): void {
622
- if (this.#rafId !== 0 || this.#destroyed) return;
623
- this.#rafId = this.#deps.requestFrame(this.#tick) || 0;
624
- }
625
-
626
- readonly #tick = (): void => {
627
- this.#rafId = 0;
628
- if (this.#destroyed) return;
629
- const now = this.#deps.now();
630
- const dt = this.#lastTick > 0 ? now - this.#lastTick : 0;
631
- let animating = false;
632
- const clock = this.clock;
633
- if (clock?.playing && this.mode === 'replay') {
634
- clock.advance(dt);
635
- this.#frameDirty = true;
636
- this.#notify('replay');
637
- this.#follow();
638
- if (clock.playing) animating = true;
639
- else if (clock.finished) this.#ended();
640
- }
641
- if (!Number.isNaN(this.#restartAt) && !this.#held) {
642
- if (now >= this.#restartAt) this.#loopAgain();
643
- else animating = true;
644
- }
645
- if (this.#emphasisAnim) {
646
- const a = this.#emphasisAnim;
647
- const u = a.duration > 0 ? Math.min(1, (now - a.start) / a.duration) : 1;
648
- this.emphasis = a.from + (a.to - a.from) * ease(u);
649
- this.#frameDirty = true;
650
- if (u >= 1) {
651
- this.#emphasisAnim = null;
652
- if (this.#fadingHunk >= 0) {
653
- this.#fadingHunk = -1;
654
- this.emphasis = 1;
655
- }
656
- } else animating = true;
657
- }
658
- if (this.#frameDirty) this.draw(now);
659
- if (animating) {
660
- this.#lastTick = now;
661
- this.#schedule();
662
- } else this.#lastTick = 0;
663
- };
664
-
665
- #view(layer: Layer): ViewState {
666
- const selected = this.selectedHunk >= 0 ? this.selectedHunk : this.#fadingHunk;
667
- return createViewState({
668
- layer,
669
- t: this.clock?.t ?? 0,
670
- selectedHunk: selected,
671
- hoveredNode: this.hoverNode,
672
- hoveredEdge: this.hoverEdge >= 0 ? this.hoverEdge : this.pinnedEdge,
673
- selectedNode: this.selectedNode,
674
- focus: this.focus,
675
- families: this.families,
676
- reducedMotion: this.reducedMotion,
677
- emphasis: this.emphasis,
678
- });
679
- }
680
-
681
- /** Resolve the current view and hand it to the renderer (and the minimap). */
682
- draw(now = this.#deps.now()): void {
683
- this.#frameDirty = false;
684
- const resolver = this.resolver;
685
- const renderer = this.#renderer;
686
- if (!resolver || !renderer || this.status !== 'ready') return;
687
- const f = this.#frames;
688
- let mapFrame: FrameVisuals;
689
- if (this.mode === 'swipe') {
690
- f.before = resolver.resolve(this.#view('before'), f.before ?? undefined);
691
- f.after = resolver.resolve(this.#view('after'), f.after ?? undefined);
692
- renderer.setFrame({ split: this.split, before: f.before, after: f.after });
693
- mapFrame = f.after;
694
- } else {
695
- const layer: Layer = this.mode === 'replay' ? 'replay' : 'union';
696
- f.main = resolver.resolve(this.#view(layer), f.main ?? undefined);
697
- renderer.setFrame(f.main);
698
- mapFrame = f.main;
699
- }
700
- const replaying = this.mode === 'replay' && (this.clock?.playing ?? false);
701
- if (
702
- this.#minimap &&
703
- (this.#minimapDirty || (replaying && now - this.#lastMinimap >= MINIMAP_INTERVAL))
704
- ) {
705
- this.#minimap.update(mapFrame);
706
- this.#minimapDirty = false;
707
- this.#lastMinimap = now;
708
- }
709
- }
710
-
711
- #startEmphasis(from: number): void {
712
- if (this.reducedMotion) {
713
- this.emphasis = 1;
714
- this.#emphasisAnim = null;
715
- this.#fadingHunk = -1;
716
- return;
717
- }
718
- this.emphasis = from;
719
- this.#emphasisAnim = {
720
- from,
721
- to: 1,
722
- start: this.#deps.now(),
723
- duration: this.theme.motion.base,
724
- };
725
- }
726
-
727
- #fadeOutEmphasis(hunk: number): void {
728
- if (this.reducedMotion || hunk < 0) {
729
- this.#fadingHunk = -1;
730
- this.#emphasisAnim = null;
731
- this.emphasis = 1;
732
- return;
733
- }
734
- this.#fadingHunk = hunk;
735
- this.#emphasisAnim = {
736
- from: this.emphasis,
737
- to: 0,
738
- start: this.#deps.now(),
739
- duration: this.theme.motion.base,
740
- };
741
- }
742
-
743
- // ─── View: mode × scope ───────────────────────────────────────────────────
744
-
745
- /** Whether the scope is fixed to `changes` (the layout covers only the changes). */
746
- get scopeLocked(): boolean {
747
- return this.layout?.scope === 'changes';
748
- }
749
-
750
- /** Switch how the versions are compared. The legacy `changes` means overlay + `changes` scope. */
751
- setMode(value: ViewerMode | typeof LEGACY_CHANGES_MODE): void {
752
- if (value === LEGACY_CHANGES_MODE) {
753
- this.setView('overlay', 'changes');
754
- return;
755
- }
756
- const mode = toMode(value);
757
- if (mode !== null) this.setView(mode, this.scope);
758
- }
759
-
760
- /** Show the whole graph or only the changes, in any mode. */
761
- setScope(value: ViewerScope): void {
762
- const scope = toScope(value);
763
- if (scope !== null) this.setView(this.mode, scope);
764
- }
765
-
766
- /** Toggle the `changes` scope (key `4`); says why when the scope is locked. */
767
- toggleScope(): void {
768
- if (this.scopeLocked) {
769
- this.#host.announce(translate(this.locale, 'scope.locked'));
770
- return;
771
- }
772
- this.setScope(this.scope === 'changes' ? 'all' : 'changes');
773
- }
774
-
775
- /** Set mode and scope together (one notification, one `kgdiff-mode` event). */
776
- setView(mode: ViewerMode, scope: ViewerScope): void {
777
- const nextScope: ViewerScope = this.scopeLocked ? 'changes' : scope;
778
- const modeChanged = mode !== this.mode;
779
- const scopeChanged = nextScope !== this.scope;
780
- if (!modeChanged && !scopeChanged) return;
781
- this.mode = mode;
782
- this.scope = nextScope;
783
- if (mode !== 'replay') {
784
- this.clock?.pause();
785
- this.#restartAt = Number.NaN;
786
- this.#resume = false;
787
- }
788
- this.#followed = '';
789
- if (scopeChanged) {
790
- this.#updateFocus();
791
- this.#updateOverview();
792
- }
793
- this.#minimapDirty = true;
794
- this.#notify('mode', 'focus', 'replay');
795
- this.invalidate();
796
- if (this.status === 'ready') {
797
- this.#host.mode(mode, nextScope);
798
- const parts: string[] = [];
799
- if (modeChanged) {
800
- parts.push(
801
- translate(this.locale, 'announce.mode', { mode: translate(this.locale, `mode.${mode}`) }),
802
- );
803
- }
804
- if (scopeChanged) {
805
- parts.push(translate(this.locale, nextScope === 'changes' ? 'scope.on' : 'scope.off'));
806
- }
807
- this.#host.announce(parts.join(' · '));
808
- }
809
- // Switching to replay starts a looping replay; toggling the scope leaves playback alone.
810
- if (modeChanged && mode === 'replay') this.#autoplay();
811
- }
812
-
813
- // ─── Selection ────────────────────────────────────────────────────────────
814
-
815
- selectHunkById(id: string | null, options: { fly?: boolean } = {}): void {
816
- if (id === null) {
817
- this.#pendingHunk = null;
818
- this.clearSelection();
819
- return;
820
- }
821
- const scene = this.scene;
822
- if (!scene || this.status !== 'ready') {
823
- this.#pendingHunk = { id, fly: options.fly ?? true };
824
- return;
825
- }
826
- const h = scene.hunkIndex.get(id);
827
- if (h !== undefined) this.selectHunk(h, options);
828
- }
829
-
830
- /**
831
- * Select hunk `h` (scene index; -1 clears). `node` focuses one of its nodes in the detail
832
- * panel. `fly` (default true) brings it into view. In replay, seeks to the end of its window.
833
- */
834
- selectHunk(h: number, options: { fly?: boolean; node?: number } = {}): void {
835
- const scene = this.scene;
836
- if (!scene) return;
837
- if (h < 0 || h >= scene.hunks.length) {
838
- this.clearSelection();
839
- return;
840
- }
841
- const node = options.node ?? -1;
842
- const prev = this.selectedHunk;
843
- const changed = prev !== h || this.selectedNode !== node;
844
- this.selectedHunk = h;
845
- this.selectedNode = node;
846
- this.pinnedEdge = -1;
847
- if (prev !== h) {
848
- this.#fadingHunk = -1;
849
- this.#startEmphasis(prev >= 0 ? SWITCH_EMPHASIS : 0);
850
- if (this.mode === 'replay' && this.clock) {
851
- const end = this.clock.windowEnd(h);
852
- this.clock.pause();
853
- this.#restartAt = Number.NaN;
854
- this.#resume = false;
855
- if (end !== null) this.clock.seek(end);
856
- this.#followed = '';
857
- this.#notify('replay');
858
- }
859
- }
860
- if (options.fly ?? true) this.flyToHunk(h);
861
- this.invalidate();
862
- if (!changed) return;
863
- this.#notify('selection');
864
- const hunk = scene.hunks[h];
865
- if (!hunk) return;
866
- const nodeId = node >= 0 ? (scene.nodes[node]?.id ?? null) : null;
867
- this.#host.select(hunk.id, nodeId);
868
- if (prev !== h) {
869
- const rank = this.list.rank.get(h);
870
- this.#host.announce(
871
- translate(this.locale, 'announce.change', {
872
- i: (rank ?? h) + 1,
873
- n: rank !== undefined ? this.list.order.length : scene.hunks.length,
874
- title: hunk.title,
875
- }),
876
- );
877
- }
878
- }
879
-
880
- /** Select a node: its hunk when it has one, else the node itself. */
881
- selectNode(i: number, options: { fly?: boolean } = {}): void {
882
- const scene = this.scene;
883
- const n = scene?.nodes[i];
884
- if (!scene || !n) return;
885
- if (n.hunk >= 0) {
886
- this.selectHunk(n.hunk, { ...options, node: i });
887
- return;
888
- }
889
- const prev = this.selectedHunk;
890
- if (prev < 0 && this.selectedNode === i) {
891
- if (options.fly) this.flyToNode(i);
892
- return;
893
- }
894
- this.selectedHunk = -1;
895
- this.selectedNode = i;
896
- this.pinnedEdge = -1;
897
- if (prev >= 0) this.#fadeOutEmphasis(prev);
898
- if (options.fly) this.flyToNode(i);
899
- this.invalidate();
900
- this.#notify('selection');
901
- this.#host.select(null, n.id);
902
- this.#host.announce(
903
- translate(this.locale, 'announce.node', {
904
- label: n.label,
905
- status: translate(this.locale, `status.${n.status}`),
906
- }),
907
- );
908
- }
909
-
910
- clearSelection(): void {
911
- if (this.selectedHunk < 0 && this.selectedNode < 0 && this.pinnedEdge < 0) return;
912
- const prev = this.selectedHunk;
913
- this.selectedHunk = -1;
914
- this.selectedNode = -1;
915
- this.pinnedEdge = -1;
916
- this.#fadeOutEmphasis(prev);
917
- this.invalidate();
918
- this.#notify('selection');
919
- this.#host.select(null, null);
920
- this.#host.announce(translate(this.locale, 'announce.cleared'));
921
- }
922
-
923
- next(): void {
924
- this.#stepSelection(1);
925
- }
926
-
927
- prev(): void {
928
- this.#stepSelection(-1);
929
- }
930
-
931
- #stepSelection(direction: 1 | -1): void {
932
- const h = stepHunk(this.list, this.selectedHunk, direction);
933
- if (h >= 0) this.selectHunk(h);
934
- }
935
-
936
- first(): void {
937
- const h = this.list.order[0];
938
- if (h !== undefined) this.selectHunk(h);
939
- }
940
-
941
- last(): void {
942
- const h = this.list.order[this.list.order.length - 1];
943
- if (h !== undefined) this.selectHunk(h);
944
- }
945
-
946
- /** Emphasize an edge (from the detail panel) and fly to it. */
947
- pinEdge(j: number): void {
948
- const scene = this.scene;
949
- const e = scene?.edges[j];
950
- if (!scene || !e) return;
951
- this.pinnedEdge = j;
952
- const ids = [e.s, e.t, e.sb, e.tb];
953
- let x0 = Number.POSITIVE_INFINITY;
954
- let y0 = Number.POSITIVE_INFINITY;
955
- let x1 = Number.NEGATIVE_INFINITY;
956
- let y1 = Number.NEGATIVE_INFINITY;
957
- for (const i of ids) {
958
- const n = scene.nodes[i];
959
- if (!n) continue;
960
- x0 = Math.min(x0, n.x - n.r);
961
- y0 = Math.min(y0, n.y - n.r);
962
- x1 = Math.max(x1, n.x + n.r);
963
- y1 = Math.max(y1, n.y + n.r);
964
- }
965
- if (x0 <= x1) this.#flyTo(padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }));
966
- this.invalidate();
967
- }
968
-
969
- // ─── Camera ───────────────────────────────────────────────────────────────
970
-
971
- /** Insets keeping `bounds` clear of the chrome over the canvas, or null before layout. */
972
- #frameInsets(bounds: Rect, maxZoom: number): Insets | null {
973
- const layout = this.#chrome?.();
974
- if (!layout || !(layout.width > 0) || !(layout.height > 0)) return null;
975
- return chromeInsets(bounds, layout, FRAME_PADDING, maxZoom);
976
- }
977
-
978
- #flyTo(bounds: Rect, options: { readonly animate?: boolean } = {}): void {
979
- const camera = this.#renderer?.camera;
980
- if (!camera) return;
981
- const padding = this.#frameInsets(bounds, FLY_MAX_ZOOM);
982
- void camera.flyTo(bounds, {
983
- ...options,
984
- maxZoom: FLY_MAX_ZOOM,
985
- ...(padding ? { padding } : {}),
986
- });
987
- }
988
-
989
- /** The world region visible outside the chrome, as framed for `bounds`. */
990
- #freeWorld(bounds: Rect): Rect | null {
991
- const camera = this.#renderer?.camera;
992
- if (!camera) return null;
993
- const insets = this.#frameInsets(bounds, FLY_MAX_ZOOM) ?? NO_INSETS;
994
- return freeView(camera.viewport(), camera.get().k, insets);
995
- }
996
-
997
- /** “Change 3 of 23: …” for a replayed hunk, “Restructured: 722 changes” for a chapter. */
998
- #announceTarget(kind: 'hunk' | 'chapter', index: number): void {
999
- const scene = this.scene;
1000
- const clock = this.clock;
1001
- if (!scene || !clock) return;
1002
- if (kind === 'chapter') {
1003
- const ch = scene.timeline.chapters[index];
1004
- if (!ch) return;
1005
- this.#host.announce(
1006
- translate(this.locale, 'announce.chapter', {
1007
- phase: translate(this.locale, `phase.${ch.phase}`),
1008
- n: ch.hunks.length,
1009
- }),
1010
- );
1011
- return;
1012
- }
1013
- const hunk = scene.hunks[index];
1014
- const order = clock.timeline.windows.findIndex((w) => w.hunk === index);
1015
- if (!hunk || order < 0) return;
1016
- this.#host.announce(
1017
- translate(this.locale, 'announce.change', {
1018
- i: order + 1,
1019
- n: clock.total,
1020
- title: hunk.title,
1021
- }),
1022
- );
1023
- }
1024
-
1025
- /** Follow `hunks` (replay): fly unless the user steered recently or they are mostly visible. */
1026
- #followHunks(hunks: readonly number[]): void {
1027
- const scene = this.scene;
1028
- if (!scene || hunks.length === 0) return;
1029
- if (this.#deps.now() - this.#lastUserCamera < USER_CAMERA_GRACE) return;
1030
- const bounds = frameBounds(scene, hunks);
1031
- const free = this.#freeWorld(bounds);
1032
- if (!free || coverage(bounds, free) >= FOLLOW_COVERAGE) return;
1033
- this.#flyTo(bounds);
1034
- }
1035
-
1036
- #hunkBounds(h: number): Rect | null {
1037
- const scene = this.scene;
1038
- return scene?.hunks[h] ? frameBounds(scene, [h]) : null;
1039
- }
1040
-
1041
- #nodeBounds(i: number): Rect | null {
1042
- const n = this.scene?.nodes[i];
1043
- return n ? padRect({ x: n.x - n.r, y: n.y - n.r, width: 2 * n.r, height: 2 * n.r }) : null;
1044
- }
1045
-
1046
- /** Camera target of the current selection, or null. */
1047
- #selectionBounds(): Rect | null {
1048
- if (this.selectedHunk >= 0) return this.#hunkBounds(this.selectedHunk);
1049
- if (this.selectedNode >= 0) return this.#nodeBounds(this.selectedNode);
1050
- return null;
1051
- }
1052
-
1053
- flyToHunk(h: number): void {
1054
- const b = this.#hunkBounds(h);
1055
- if (b) this.#flyTo(b);
1056
- }
1057
-
1058
- flyToNode(i: number): void {
1059
- const b = this.#nodeBounds(i);
1060
- if (b) this.#flyTo(b);
1061
- }
1062
-
1063
- flyToSelection(): void {
1064
- const b = this.#selectionBounds();
1065
- if (b) this.#flyTo(b);
1066
- }
1067
-
1068
- /**
1069
- * Fit everything visible (the focus in changes-only mode), labels included, into the free part
1070
- * of the canvas. Before the canvas is laid out it falls back to the renderer's own fit.
1071
- */
1072
- fit(options: FitOptions = {}): Promise<void> {
1073
- const camera = this.#renderer?.camera;
1074
- if (!camera) return Promise.resolve();
1075
- const { padding, ...transition } = options;
1076
- if (padding !== undefined) {
1077
- this.#fitPadding = Number.isFinite(padding) && padding >= 0 ? padding : FRAME_PADDING;
1078
- }
1079
- const fit = this.#fitTarget();
1080
- if (!fit) return camera.fit(undefined, transition);
1081
- return this.#fitTo(camera, fit, transition);
1082
- }
1083
-
1084
- #fitTo(
1085
- camera: Camera,
1086
- fit: { readonly bounds: Rect; readonly insets: Insets },
1087
- transition: { readonly animate?: boolean },
1088
- ): Promise<void> {
1089
- this.#needsFit = false;
1090
- this.#userMoved = false;
1091
- return camera.fit(fit.bounds, { ...transition, padding: fit.insets, maxZoom: FIT_MAX_ZOOM });
1092
- }
1093
-
1094
- /**
1095
- * Bounds and insets that show everything visible (the changes-only focus) with every label
1096
- * in its spot below its node, clear of the chrome, or null before layout. Measured in the
1097
- * union layer, which holds every state a replay or swipe passes through. Insets depend on
1098
- * what is framed, which depends on the zoom they allow: they are settled once more.
1099
- */
1100
- #fitTarget(): { bounds: Rect; insets: Insets } | null {
1101
- const layout = this.#chrome?.();
1102
- const resolver = this.resolver;
1103
- if (!layout || !resolver || !(layout.width > 0) || !(layout.height > 0)) return null;
1104
- const frame = resolver.resolve(
1105
- createViewState({
1106
- layer: 'union',
1107
- focus: this.focus,
1108
- families: this.families,
1109
- reducedMotion: this.reducedMotion,
1110
- }),
1111
- );
1112
- const footprint = labelFootprint(frame, this.theme);
1113
- const extent = (k: number): Rect | null => footprint.at(k);
1114
- const discs = extent(Number.POSITIVE_INFINITY);
1115
- if (!discs) return null;
1116
- const { width, height } = layout;
1117
- const padding = this.#fitPadding;
1118
- let insets = chromeInsets(discs, layout, padding, FIT_MAX_ZOOM);
1119
- let fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM);
1120
- if (!fit) return null;
1121
- const settled = chromeInsets(fit.content, layout, padding, FIT_MAX_ZOOM);
1122
- if (!sameInsets(settled, insets)) {
1123
- insets = settled;
1124
- fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM) ?? fit;
1125
- }
1126
- return { bounds: fit.bounds, insets };
1127
- }
1128
-
1129
- /** Bounds of what is drawn: the scene, or the visible part of the `changes` scope's focus. */
1130
- #contentBounds(): Rect | null {
1131
- const scene = this.scene;
1132
- if (!scene) return null;
1133
- const focus = this.focus;
1134
- const cached = this.#boundsCache;
1135
- if (cached && cached.scene === scene && cached.focus === focus) return cached.rect;
1136
- const rect = focus ? focusBounds(scene, focus) : frameBounds(scene);
1137
- this.#boundsCache = { scene, focus, rect };
1138
- return rect;
1139
- }
1140
-
1141
- /** Whether everything drawn is in view; the minimap hides while it is. */
1142
- #updateOverview(): void {
1143
- const bounds = this.#contentBounds();
1144
- const view = this.#renderer?.camera.viewport();
1145
- const next = !bounds || !view || coverage(bounds, view) >= OVERVIEW_COVERAGE;
1146
- if (next === this.overview) return;
1147
- this.overview = next;
1148
- this.#notify('overview');
1149
- }
1150
-
1151
- zoom(direction: 1 | -1): void {
1152
- this.noteUserCamera();
1153
- this.#renderer?.camera.zoomBy(direction > 0 ? 1.4 : 1 / 1.4);
1154
- }
1155
-
1156
- /** Replay: fly to the playing hunk / chapter when it changes and is mostly out of view. */
1157
- #follow(): void {
1158
- const scene = this.scene;
1159
- const clock = this.clock;
1160
- const camera = this.#renderer?.camera;
1161
- if (!scene || !clock || !camera) return;
1162
- const target = clock.activeTarget();
1163
- if (target.index < 0) return;
1164
- const key = `${target.kind}:${target.index}`;
1165
- if (key === this.#followed) return;
1166
- this.#followed = key;
1167
- this.#announceTarget(target.kind, target.index);
1168
- this.#followHunks(
1169
- target.kind === 'chapter'
1170
- ? (scene.timeline.chapters[target.index]?.hunks ?? [])
1171
- : [target.index],
1172
- );
1173
- }
1174
-
1175
- // ─── Canvas events ────────────────────────────────────────────────────────
1176
-
1177
- #onHover(pick: PickResult | null): void {
1178
- const node = pick?.type === 'node' ? pick.index : -1;
1179
- const edge = pick?.type === 'edge' ? pick.index : -1;
1180
- const group = pick?.type === 'collapsed' ? pick.index : -1;
1181
- if (node === this.hoverNode && edge === this.hoverEdge && group === this.hoverGroup) return;
1182
- this.hoverNode = node;
1183
- this.hoverEdge = edge;
1184
- this.hoverGroup = group;
1185
- this.#notify('hover');
1186
- this.invalidate();
1187
- }
1188
-
1189
- /** Clear hover state (pointer left the canvas). */
1190
- clearHover(): void {
1191
- this.#onHover(null);
1192
- }
1193
-
1194
- #onCanvasClick(pick: PickResult | null): void {
1195
- if (!this.selectOnClick) return;
1196
- if (!pick) {
1197
- this.clearSelection();
1198
- return;
1199
- }
1200
- switch (pick.type) {
1201
- case 'node':
1202
- this.selectNode(pick.index, { fly: false });
1203
- break;
1204
- case 'edge': {
1205
- const e = this.scene?.edges[pick.index];
1206
- if (e && e.hunk >= 0) this.selectHunk(e.hunk, { fly: false });
1207
- this.pinnedEdge = pick.index;
1208
- this.invalidate();
1209
- break;
1210
- }
1211
- case 'collapsed':
1212
- this.expandGroup(pick.index);
1213
- break;
1214
- default:
1215
- break;
1216
- }
1217
- }
1218
-
1219
- #onCanvasDblClick(pick: PickResult | null): void {
1220
- if (!this.selectOnClick || pick?.type !== 'node') return;
1221
- const n = this.scene?.nodes[pick.index];
1222
- if (!n) return;
1223
- if (n.hunk >= 0) this.flyToHunk(n.hunk);
1224
- else this.flyToNode(pick.index);
1225
- }
1226
-
1227
- // ─── Filters, focus ───────────────────────────────────────────────────────
1228
-
1229
- toggleFamily(family: ChangeFamily): void {
1230
- if (this.families.has(family)) this.families.delete(family);
1231
- else this.families.add(family);
1232
- this.#filtersChanged();
1233
- }
1234
-
1235
- /** Replace the family filter (empty = all). Setting the current set again clears it. */
1236
- setFamilies(families: readonly ChangeFamily[]): void {
1237
- const same =
1238
- families.length === this.families.size && families.every((f) => this.families.has(f));
1239
- this.families.clear();
1240
- if (!same) for (const f of families) this.families.add(f);
1241
- this.#filtersChanged();
1242
- }
1243
-
1244
- #filtersChanged(): void {
1245
- if (this.scene) this.list = buildList(this.scene, this.families);
1246
- this.#minimapDirty = true;
1247
- this.#notify('filters');
1248
- this.invalidate();
1249
- }
1250
-
1251
- setHops(hops: number): void {
1252
- const h = Math.max(0, Math.min(2, Math.round(hops)));
1253
- if (h === this.hops) return;
1254
- this.hops = h;
1255
- this.#updateFocus();
1256
- this.#updateOverview();
1257
- this.#minimapDirty = true;
1258
- this.#notify('focus');
1259
- this.invalidate();
1260
- }
1261
-
1262
- /**
1263
- * Expand collapsed group `index` of the current focus: its in-scene members become visible
1264
- * and, for a `changes`-scoped layout, the neighbours left out of the layout are placed with
1265
- * `extendLayout` (nothing already placed moves) and the scene is rebuilt.
1266
- */
1267
- expandGroup(index: number): void {
1268
- const group = this.focus?.collapsed[index];
1269
- const scene = this.scene;
1270
- if (!group || !scene) return;
1271
- const ids: UnionId[] = group.nodes.map((i) => scene.nodes[i]?.id).filter(isString);
1272
- if (!this.canExpand(index)) {
1273
- // Without the layout engine only the members already laid out can be shown.
1274
- if (ids.length > 0) void this.#expandIds(ids, []);
1275
- return;
1276
- }
1277
- const owner = group.id.startsWith('near:')
1278
- ? group.id.slice(5)
1279
- : group.id.startsWith('component:')
1280
- ? group.id.slice(10)
1281
- : null;
1282
- void this.#expandIds(ids, owner !== null ? [owner, ...ids] : ids);
1283
- }
1284
-
1285
- /**
1286
- * The collapsed group nearest the selection (else the view center), or -1. Groups that can
1287
- * be expanded at least partly come first.
1288
- */
1289
- nearestGroup(): number {
1290
- const groups = this.focus?.collapsed ?? [];
1291
- if (groups.length === 0) return -1;
1292
- const sel = this.#selectionBounds();
1293
- const view = this.#renderer?.camera.viewport();
1294
- const at = sel ?? view ?? this.scene?.bounds;
1295
- const cx = at ? at.x + at.width / 2 : 0;
1296
- const cy = at ? at.y + at.height / 2 : 0;
1297
- let best = -1;
1298
- let bestScore = Number.POSITIVE_INFINITY;
1299
- groups.forEach((g, i) => {
1300
- const usable = this.canExpand(i) || g.nodes.length > 0;
1301
- const score = Math.hypot(g.x - cx, g.y - cy) + (usable ? 0 : 1e12);
1302
- if (score < bestScore) {
1303
- bestScore = score;
1304
- best = i;
1305
- }
1306
- });
1307
- return best;
1308
- }
1309
-
1310
- /** Expand the group nearest the selection (keyboard `e`); announces what happened. */
1311
- expandNearest(): void {
1312
- const i = this.nearestGroup();
1313
- const group = i >= 0 ? this.focus?.collapsed[i] : undefined;
1314
- if (!group || (!this.canExpand(i) && group.nodes.length === 0)) {
1315
- this.#host.announce(
1316
- translate(this.locale, group ? 'focus.expandUnavailable' : 'announce.nothingHidden'),
1317
- );
1318
- return;
1319
- }
1320
- this.expandGroup(i);
1321
- this.#host.announce(translate(this.locale, 'announce.expanded', { label: group.label }));
1322
- }
1323
-
1324
- /**
1325
- * Whether collapsed group `index` can be expanded completely: always, unless some members lie
1326
- * outside a `changes`-scoped layout and the layout engine is known to be unavailable
1327
- * (standalone report without the compute add-on).
1328
- */
1329
- canExpand(index: number): boolean {
1330
- const group = this.focus?.collapsed[index];
1331
- if (!group) return false;
1332
- const outside = this.layout?.scope === 'changes' && group.count > group.nodes.length;
1333
- return !outside || computeStatus() !== 'unavailable';
1334
- }
1335
-
1336
- /**
1337
- * Make `ids` visible, placing those outside the layout, plus the out-of-layout neighbours of
1338
- * `neighboursOf`. Resolves once done (the layout engine may load lazily first).
1339
- */
1340
- #expandIds(ids: readonly UnionId[], neighboursOf: readonly UnionId[]): Promise<void> {
1341
- const data = this.data;
1342
- const layout = this.layout;
1343
- if (!data || !layout) return Promise.resolve();
1344
- for (const id of ids) this.expanded.add(id);
1345
- const missing = new Set<UnionId>();
1346
- if (layout.scope === 'changes') {
1347
- for (const id of ids) if (!layout.positions.has(id)) missing.add(id);
1348
- for (const id of neighboursOf) {
1349
- for (const e of data.diff.index.edgesOf(id)) {
1350
- for (const end of [e.source, e.target, e.sourceBefore, e.targetBefore]) {
1351
- if (end !== undefined && !layout.positions.has(end)) missing.add(end);
1352
- }
1353
- }
1354
- }
1355
- }
1356
- if (missing.size === 0) {
1357
- this.#focusChanged();
1358
- return Promise.resolve();
1359
- }
1360
- const place = (): void => {
1361
- const compute = computeNow();
1362
- const current = this.layout;
1363
- if (!compute || !current || this.data !== data) return;
1364
- const todo = [...missing].filter((id) => !current.positions.has(id));
1365
- if (todo.length > 0) {
1366
- this.layout = compute.extendLayout(data.diff, current, todo, data.layoutOptions);
1367
- for (const id of todo) this.expanded.add(id);
1368
- this.#rebuildScene();
1369
- this.#notify('data', 'selection', 'replay');
1370
- }
1371
- this.#focusChanged();
1372
- };
1373
- if (computeNow()) {
1374
- place();
1375
- return Promise.resolve();
1376
- }
1377
- // Standalone reports load the layout engine on demand.
1378
- return loadCompute().then(place, () => this.#focusChanged());
1379
- }
1380
-
1381
- #focusChanged(): void {
1382
- this.#updateFocus();
1383
- this.#updateOverview();
1384
- this.#minimapDirty = true;
1385
- this.#notify('focus');
1386
- this.invalidate();
1387
- }
1388
-
1389
- /** Bring a union node into the scene and the focus (search results), then select it. */
1390
- revealNode(id: UnionId): Promise<void> {
1391
- if (!this.data || !this.scene) return Promise.resolve();
1392
- const select = (): void => {
1393
- const i = this.scene?.nodeIndex.get(id);
1394
- if (i !== undefined) this.selectNode(i, { fly: true });
1395
- };
1396
- if (this.scene.nodeIndex.has(id) && this.#isVisible(id)) {
1397
- select();
1398
- return Promise.resolve();
1399
- }
1400
- return this.#expandIds([id], []).then(select);
1401
- }
1402
-
1403
- #isVisible(id: UnionId): boolean {
1404
- const i = this.scene?.nodeIndex.get(id);
1405
- return i !== undefined && (this.focus === null || this.focus.nodes[i] === 1);
1406
- }
1407
-
1408
- // ─── Replay ───────────────────────────────────────────────────────────────
1409
-
1410
- play(): void {
1411
- const clock = this.clock;
1412
- if (!clock || this.status !== 'ready') return;
1413
- if (this.mode !== 'replay') this.setMode('replay');
1414
- this.#restartAt = Number.NaN;
1415
- this.#resume = false;
1416
- clock.play();
1417
- this.#followed = '';
1418
- this.#lastTick = 0;
1419
- this.#notify('replay');
1420
- this.invalidate();
89
+ this.view.noteUserCamera();
1421
90
  }
1422
-
1423
- /** Pause; a looping replay stays paused (until `play`, a new diff or loop turned on again). */
1424
- pause(): void {
1425
- this.#restartAt = Number.NaN;
1426
- this.#resume = false;
1427
- if (!this.clock?.playing) return;
1428
- this.clock.pause();
1429
- this.#notify('replay');
1430
- this.invalidate();
1431
- }
1432
-
1433
- togglePlay(): void {
1434
- if (this.clock?.playing) this.pause();
1435
- else this.play();
1436
- }
1437
-
1438
- /**
1439
- * Play the replay over and over: `delay` ms after it ends it starts again; null plays it
1440
- * once. Turning the loop on starts a replay that is shown but not playing.
1441
- */
1442
- setLoop(delay: number | null): void {
1443
- const was = this.#loopDelay;
1444
- this.#loopDelay = delay === null ? null : Number.isFinite(delay) ? Math.max(0, delay) : 0;
1445
- if (this.#loopDelay === null) this.#restartAt = Number.NaN;
1446
- else if (was === null) this.#autoplay();
1447
- }
1448
-
1449
- get loopDelay(): number | null {
1450
- return this.#loopDelay;
1451
- }
1452
-
1453
- /**
1454
- * Hold playback while the pointer rests on the graph (`pause-on-hover`): a playing replay
1455
- * pauses and continues on release; a pending loop restart waits for the release, then the
1456
- * full loop delay.
1457
- */
1458
- hold(held: boolean): void {
1459
- if (held === this.#held) return;
1460
- this.#held = held;
1461
- const clock = this.clock;
1462
- if (held) {
1463
- if (!clock?.playing) return;
1464
- clock.pause();
1465
- this.#resume = true;
1466
- this.#notify('replay');
1467
- this.invalidate();
1468
- return;
1469
- }
1470
- if (!Number.isNaN(this.#restartAt) && this.#loopDelay !== null) {
1471
- this.#restartAt = this.#deps.now() + this.#loopDelay;
1472
- this.#schedule();
1473
- }
1474
- if (this.#resume) this.play();
1475
- }
1476
-
1477
- /** Looping and showing a replay that is not playing: start it (or after the hold). */
1478
- #autoplay(): void {
1479
- if (this.#loopDelay === null || this.status !== 'ready' || this.mode !== 'replay') return;
1480
- if (!this.clock || this.clock.playing || !Number.isNaN(this.#restartAt)) return;
1481
- if (this.#held) this.#resume = true;
1482
- else this.play();
1483
- }
1484
-
1485
- /** Playback reached the end: announce it, tell the host and arm the loop restart. */
1486
- #ended(): void {
1487
- this.#host.announce(translate(this.locale, 'announce.finished'));
1488
- const loop = this.#loopDelay !== null;
1489
- // Armed first, so a `kgdiff-replay-end` listener can still cancel it (pause, loop off).
1490
- if (loop) this.#restartAt = this.#deps.now() + (this.#loopDelay ?? 0);
1491
- this.#host.replayEnd(loop);
1492
- }
1493
-
1494
- #loopAgain(): void {
1495
- this.#restartAt = Number.NaN;
1496
- if (this.autoFit) void this.fit();
1497
- this.clock?.seek(0);
1498
- this.play();
1499
- }
1500
-
1501
- seek(t: number): void {
1502
- const clock = this.clock;
1503
- if (!clock) return;
1504
- this.#restartAt = Number.NaN;
1505
- clock.seek(t);
1506
- this.#notify('replay');
1507
- this.#follow();
1508
- this.invalidate();
1509
- }
1510
-
1511
- stepReplay(direction: 1 | -1): void {
1512
- const clock = this.clock;
1513
- if (!clock) return;
1514
- clock.step(direction);
1515
- this.#restartAt = Number.NaN;
1516
- this.#resume = false;
1517
- this.#followed = '';
1518
- // Follow (and announce) the hunk that just completed: stops sit at window ends.
1519
- const w = clock.timeline.windows[clock.position() - 1];
1520
- if (w) {
1521
- if (clock.timeline.mode === 'chapters') this.#announceTarget('chapter', w.chapter);
1522
- else this.#announceTarget('hunk', w.hunk);
1523
- this.#followHunks(
1524
- clock.timeline.mode === 'chapters'
1525
- ? (clock.timeline.chapters[w.chapter]?.hunks ?? [w.hunk])
1526
- : [w.hunk],
1527
- );
1528
- }
1529
- this.#notify('replay');
1530
- this.invalidate();
1531
- }
1532
-
1533
- restart(): void {
1534
- this.clock?.seek(0);
1535
- this.play();
1536
- }
1537
-
1538
- cycleSpeed(): void {
1539
- this.clock?.cycleSpeed();
1540
- this.#notify('replay');
1541
- }
1542
-
1543
- setSpeed(speed: number): void {
1544
- this.clock?.setSpeed(speed);
1545
- this.#notify('replay');
1546
- }
1547
-
1548
- // ─── Swipe ────────────────────────────────────────────────────────────────
1549
-
1550
- setSplit(split: number): void {
1551
- const s = Number.isFinite(split) ? Math.min(1, Math.max(0, split)) : 0.5;
1552
- if (s === this.split) return;
1553
- this.split = s;
1554
- this.#notify('swipe');
1555
- if (this.mode === 'swipe') this.invalidate();
1556
- }
1557
-
1558
- // ─── Review ───────────────────────────────────────────────────────────────
1559
-
1560
- setReviewed(hunkId: string, reviewed: boolean): void {
1561
- const review = this.#review;
1562
- if (!review?.setReviewed(hunkId, reviewed)) return;
1563
- const progress = review.state();
1564
- this.#notify('review');
1565
- this.#host.review(hunkId, reviewed, progress);
1566
- const text = translate(this.locale, 'review.progress', {
1567
- done: progress.reviewed.length,
1568
- total: progress.total,
1569
- });
1570
- this.#host.announce(
1571
- translate(this.locale, reviewed ? 'announce.reviewed' : 'announce.unreviewed', {
1572
- progress: text,
1573
- }),
1574
- );
1575
- }
1576
-
1577
- /** Another tab changed review progress (`storage` event): re-read it. */
1578
- syncReview(key: string | null): void {
1579
- if (this.#review?.reload(key)) this.#notify('review');
1580
- }
1581
-
1582
- toggleReviewed(hunkId?: string): void {
1583
- const id =
1584
- hunkId ?? (this.selectedHunk >= 0 ? this.scene?.hunks[this.selectedHunk]?.id : undefined);
1585
- if (id !== undefined) this.setReviewed(id, !this.isReviewed(id));
1586
- }
1587
-
1588
- // ─── Appearance ───────────────────────────────────────────────────────────
1589
-
1590
- #appearance(apply: () => void): void {
1591
- const name = this.themeName;
1592
- const palette = this.palette;
1593
- const locale = this.locale;
1594
- apply();
1595
- const themeChanged = name !== this.themeName || palette !== this.palette;
1596
- const localeChanged = locale !== this.locale;
1597
- if (themeChanged || localeChanged) {
1598
- if (this.data) this.#rebuildScene();
1599
- else this.#renderer?.setTheme(this.theme);
1600
- }
1601
- if (themeChanged) this.#notify('theme');
1602
- if (localeChanged) this.#notify('locale', 'data');
1603
- }
1604
-
1605
- setThemePreference(pref: ThemePreference): void {
1606
- this.#appearance(() => {
1607
- this.themePreference = toTheme(pref) ?? 'auto';
1608
- });
1609
- this.#notify('theme');
1610
- }
1611
-
1612
- setPalette(palette: PaletteName): void {
1613
- this.#appearance(() => {
1614
- this.palette = toPalette(palette) ?? 'default';
1615
- });
1616
- }
1617
-
1618
- setLocale(locale: Locale): void {
1619
- this.#appearance(() => {
1620
- this.locale = toLocale(locale) ?? 'en';
1621
- });
1622
- }
1623
-
1624
- setSystemDark(dark: boolean): void {
1625
- this.#appearance(() => {
1626
- this.systemDark = dark;
1627
- });
1628
- }
1629
-
1630
- setReducedMotion(reduced: boolean): void {
1631
- this.reducedMotion = reduced;
1632
- this.invalidate();
1633
- }
1634
- }
1635
-
1636
- function isString(x: string | undefined): x is string {
1637
- return x !== undefined;
1638
- }
1639
-
1640
- function sameInsets(a: Insets, b: Insets): boolean {
1641
- return a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left;
1642
- }
1643
-
1644
- /** Bounds of the visible part of `focus`, with room for labels and the collapsed pills. */
1645
- function focusBounds(scene: Scene, focus: FocusState): Rect {
1646
- let x0 = Number.POSITIVE_INFINITY;
1647
- let y0 = Number.POSITIVE_INFINITY;
1648
- let x1 = Number.NEGATIVE_INFINITY;
1649
- let y1 = Number.NEGATIVE_INFINITY;
1650
- const add = (x: number, y: number, rx: number, top: number, bottom: number): void => {
1651
- x0 = Math.min(x0, x - rx);
1652
- x1 = Math.max(x1, x + rx);
1653
- y0 = Math.min(y0, y - top);
1654
- y1 = Math.max(y1, y + bottom);
1655
- };
1656
- // Labels sit centered below their node: leave room for a typical one.
1657
- for (const n of scene.nodes)
1658
- if (focus.nodes[n.index] === 1) add(n.x, n.y, n.r + 48, n.r, n.r + 24);
1659
- for (const g of focus.collapsed) add(g.x, g.y, 48, 12, 12);
1660
- if (!(x0 <= x1)) return frameBounds(scene);
1661
- return padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }, 160, 1.12);
1662
- }
1663
-
1664
- /** Pad a small rect so a node is shown with its neighbourhood. */
1665
- function padRect(r: Rect, min = 200, ratio = 1.4): Rect {
1666
- const w = Math.max(min, r.width * ratio);
1667
- const h = Math.max(min, r.height * ratio);
1668
- return { x: r.x + r.width / 2 - w / 2, y: r.y + r.height / 2 - h / 2, width: w, height: h };
1669
91
  }