@kgdiff/renderer 0.0.0-stage → 0.1.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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -2
  3. package/dist/atlas.d.ts +55 -0
  4. package/dist/atlas.d.ts.map +1 -0
  5. package/dist/atlas.js +132 -0
  6. package/dist/atlas.js.map +1 -0
  7. package/dist/camera.d.ts +116 -0
  8. package/dist/camera.d.ts.map +1 -0
  9. package/dist/camera.js +368 -0
  10. package/dist/camera.js.map +1 -0
  11. package/dist/color.d.ts +12 -0
  12. package/dist/color.d.ts.map +1 -0
  13. package/dist/color.js +51 -0
  14. package/dist/color.js.map +1 -0
  15. package/dist/ctx.d.ts +57 -0
  16. package/dist/ctx.d.ts.map +1 -0
  17. package/dist/ctx.js +28 -0
  18. package/dist/ctx.js.map +1 -0
  19. package/dist/draw/background.d.ts +33 -0
  20. package/dist/draw/background.d.ts.map +1 -0
  21. package/dist/draw/background.js +105 -0
  22. package/dist/draw/background.js.map +1 -0
  23. package/dist/draw/badges.d.ts +26 -0
  24. package/dist/draw/badges.d.ts.map +1 -0
  25. package/dist/draw/badges.js +82 -0
  26. package/dist/draw/badges.js.map +1 -0
  27. package/dist/draw/collapsed.d.ts +15 -0
  28. package/dist/draw/collapsed.d.ts.map +1 -0
  29. package/dist/draw/collapsed.js +67 -0
  30. package/dist/draw/collapsed.js.map +1 -0
  31. package/dist/draw/edges.d.ts +14 -0
  32. package/dist/draw/edges.d.ts.map +1 -0
  33. package/dist/draw/edges.js +115 -0
  34. package/dist/draw/edges.js.map +1 -0
  35. package/dist/draw/glyphs.d.ts +31 -0
  36. package/dist/draw/glyphs.d.ts.map +1 -0
  37. package/dist/draw/glyphs.js +114 -0
  38. package/dist/draw/glyphs.js.map +1 -0
  39. package/dist/draw/halos.d.ts +19 -0
  40. package/dist/draw/halos.d.ts.map +1 -0
  41. package/dist/draw/halos.js +91 -0
  42. package/dist/draw/halos.js.map +1 -0
  43. package/dist/draw/labels.d.ts +55 -0
  44. package/dist/draw/labels.d.ts.map +1 -0
  45. package/dist/draw/labels.js +300 -0
  46. package/dist/draw/labels.js.map +1 -0
  47. package/dist/draw/link.d.ts +25 -0
  48. package/dist/draw/link.d.ts.map +1 -0
  49. package/dist/draw/link.js +122 -0
  50. package/dist/draw/link.js.map +1 -0
  51. package/dist/draw/nodes.d.ts +13 -0
  52. package/dist/draw/nodes.d.ts.map +1 -0
  53. package/dist/draw/nodes.js +165 -0
  54. package/dist/draw/nodes.js.map +1 -0
  55. package/dist/draw/shapes.d.ts +7 -0
  56. package/dist/draw/shapes.d.ts.map +1 -0
  57. package/dist/draw/shapes.js +26 -0
  58. package/dist/draw/shapes.js.map +1 -0
  59. package/dist/draw/view.d.ts +36 -0
  60. package/dist/draw/view.d.ts.map +1 -0
  61. package/dist/draw/view.js +16 -0
  62. package/dist/draw/view.js.map +1 -0
  63. package/dist/frame-cache.d.ts +103 -0
  64. package/dist/frame-cache.d.ts.map +1 -0
  65. package/dist/frame-cache.js +349 -0
  66. package/dist/frame-cache.js.map +1 -0
  67. package/dist/index.d.ts +162 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +19 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/input.d.ts +81 -0
  72. package/dist/input.d.ts.map +1 -0
  73. package/dist/input.js +329 -0
  74. package/dist/input.js.map +1 -0
  75. package/dist/labels.d.ts +103 -0
  76. package/dist/labels.d.ts.map +1 -0
  77. package/dist/labels.js +203 -0
  78. package/dist/labels.js.map +1 -0
  79. package/dist/math.d.ts +33 -0
  80. package/dist/math.d.ts.map +1 -0
  81. package/dist/math.js +138 -0
  82. package/dist/math.js.map +1 -0
  83. package/dist/minimap.d.ts +56 -0
  84. package/dist/minimap.d.ts.map +1 -0
  85. package/dist/minimap.js +501 -0
  86. package/dist/minimap.js.map +1 -0
  87. package/dist/paint-stats.d.ts +17 -0
  88. package/dist/paint-stats.d.ts.map +1 -0
  89. package/dist/paint-stats.js +23 -0
  90. package/dist/paint-stats.js.map +1 -0
  91. package/dist/paint.d.ts +64 -0
  92. package/dist/paint.d.ts.map +1 -0
  93. package/dist/paint.js +197 -0
  94. package/dist/paint.js.map +1 -0
  95. package/dist/pick.d.ts +26 -0
  96. package/dist/pick.d.ts.map +1 -0
  97. package/dist/pick.js +125 -0
  98. package/dist/pick.js.map +1 -0
  99. package/dist/renderer.d.ts +28 -0
  100. package/dist/renderer.d.ts.map +1 -0
  101. package/dist/renderer.js +470 -0
  102. package/dist/renderer.js.map +1 -0
  103. package/dist/smooth-zoom.d.ts +21 -0
  104. package/dist/smooth-zoom.d.ts.map +1 -0
  105. package/dist/smooth-zoom.js +52 -0
  106. package/dist/smooth-zoom.js.map +1 -0
  107. package/dist/spatial.d.ts +26 -0
  108. package/dist/spatial.d.ts.map +1 -0
  109. package/dist/spatial.js +103 -0
  110. package/dist/spatial.js.map +1 -0
  111. package/dist/text.d.ts +36 -0
  112. package/dist/text.d.ts.map +1 -0
  113. package/dist/text.js +123 -0
  114. package/dist/text.js.map +1 -0
  115. package/package.json +47 -3
  116. package/src/atlas.ts +171 -0
  117. package/src/camera.ts +482 -0
  118. package/src/color.ts +58 -0
  119. package/src/ctx.ts +102 -0
  120. package/src/draw/background.ts +139 -0
  121. package/src/draw/badges.ts +93 -0
  122. package/src/draw/collapsed.ts +86 -0
  123. package/src/draw/edges.ts +135 -0
  124. package/src/draw/glyphs.ts +168 -0
  125. package/src/draw/halos.ts +101 -0
  126. package/src/draw/labels.ts +399 -0
  127. package/src/draw/link.ts +157 -0
  128. package/src/draw/nodes.ts +184 -0
  129. package/src/draw/shapes.ts +36 -0
  130. package/src/draw/view.ts +37 -0
  131. package/src/frame-cache.ts +382 -0
  132. package/src/index.ts +161 -0
  133. package/src/input.ts +370 -0
  134. package/src/labels.ts +269 -0
  135. package/src/math.ts +185 -0
  136. package/src/minimap.ts +545 -0
  137. package/src/paint-stats.ts +38 -0
  138. package/src/paint.ts +235 -0
  139. package/src/pick.ts +173 -0
  140. package/src/renderer.ts +539 -0
  141. package/src/smooth-zoom.ts +65 -0
  142. package/src/spatial.ts +94 -0
  143. package/src/text.ts +130 -0
@@ -0,0 +1,539 @@
1
+ /**
2
+ * `createRenderer`: DOM wiring around the painter.
3
+ *
4
+ * - One canvas sized in device pixels (ResizeObserver, `device-pixel-content-box` when
5
+ * supported), `pixelRatio ≤ 3`; resizes repaint synchronously, so there is never a blank frame.
6
+ * - One requestAnimationFrame loop that runs only while something is dirty or animating: the
7
+ * camera advances, `camera` fires (once per frame), hover is picked (once per frame), the
8
+ * frame is painted and `frame` fires. Idle = no callbacks at all.
9
+ */
10
+ import type { Rect } from '@kgdiff/core';
11
+ import { KgdiffError } from '@kgdiff/core';
12
+ import type { FrameVisuals, Theme } from '@kgdiff/scene';
13
+ import { CameraController, type Insets, NO_INSETS, type Size } from './camera.js';
14
+ import { type Ctx2D, domSurfaceFactory, type SurfaceFactory } from './ctx.js';
15
+ import { FrameCache, unionBounds } from './frame-cache.js';
16
+ import type {
17
+ CameraState,
18
+ PickResult,
19
+ Renderer,
20
+ RendererEvents,
21
+ RendererOptions,
22
+ SplitFrame,
23
+ } from './index.js';
24
+ import { attachInput } from './input.js';
25
+ import { clamp } from './math.js';
26
+ import { isSplit, LABEL_RASTER_BUDGET, Painter, Resources, splitX } from './paint.js';
27
+ import { pickAt } from './pick.js';
28
+
29
+ /** Test seams and platform overrides (not part of the public API). */
30
+ export interface RendererDeps {
31
+ /** Context to draw into instead of `canvas.getContext('2d')` (null = no drawing). */
32
+ readonly context?: Ctx2D | null;
33
+ readonly surfaceFactory?: SurfaceFactory;
34
+ readonly now?: () => number;
35
+ readonly requestFrame?: (cb: (time: number) => void) => number;
36
+ readonly cancelFrame?: (id: number) => void;
37
+ }
38
+
39
+ /** What the minimap needs from its renderer beyond the public API. */
40
+ export interface RendererInternals {
41
+ theme(): Theme;
42
+ pixelRatio(): number;
43
+ size(): Size;
44
+ onTheme(listener: (theme: Theme) => void): () => void;
45
+ /** Move the camera on behalf of the user (minimap drags): `camera` reports source 'user'. */
46
+ userSet(state: CameraState): void;
47
+ readonly requestFrame: (cb: (time: number) => void) => number;
48
+ readonly cancelFrame: (id: number) => void;
49
+ }
50
+
51
+ export const internals = new WeakMap<Renderer, RendererInternals>();
52
+
53
+ type Listeners = { [K in keyof RendererEvents]: Set<(event: RendererEvents[K]) => void> };
54
+
55
+ const MAX_PIXEL_RATIO = 3;
56
+
57
+ function samePick(a: PickResult | null, b: PickResult | null): boolean {
58
+ return a === b || (a !== null && b !== null && a.type === b.type && a.index === b.index);
59
+ }
60
+
61
+ function windowRatio(): number {
62
+ return typeof window !== 'undefined' && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1;
63
+ }
64
+
65
+ class CanvasRenderer implements Renderer {
66
+ readonly canvas: HTMLCanvasElement;
67
+ readonly camera: CameraController;
68
+ private readonly ctx: Ctx2D | null;
69
+ private theme: Theme;
70
+ private safe: Insets;
71
+ private readonly grid: boolean;
72
+ private readonly fixedRatio: number | undefined;
73
+ private dpr = 1;
74
+ private width = 0;
75
+ private height = 0;
76
+ private frame: FrameVisuals | SplitFrame | null = null;
77
+ private version = 0;
78
+ private readonly caches = {
79
+ main: new FrameCache(),
80
+ before: new FrameCache(),
81
+ after: new FrameCache(),
82
+ };
83
+ private readonly res: Resources;
84
+ private readonly painter: Painter;
85
+ private readonly listeners: Listeners = {
86
+ hover: new Set(),
87
+ click: new Set(),
88
+ dblclick: new Set(),
89
+ camera: new Set(),
90
+ frame: new Set(),
91
+ };
92
+ private readonly themeListeners = new Set<(theme: Theme) => void>();
93
+ private readonly cleanup: (() => void)[] = [];
94
+ private readonly now: () => number;
95
+ private readonly requestFrame: (cb: (time: number) => void) => number;
96
+ private readonly cancelFrame: (id: number) => void;
97
+ private readonly motionQuery: MediaQueryList | null;
98
+ private rafId = 0;
99
+ private dirty = true;
100
+ private busy = false;
101
+ private destroyed = false;
102
+ private cameraChanged = true;
103
+ /** The viewport changed size: `camera` fires even if the state did not change. */
104
+ private viewportChanged = true;
105
+ private emitted: CameraState | null = null;
106
+ private autoFitted = false;
107
+ private pointer: { x: number; y: number } | null = null;
108
+ private hoverPending = false;
109
+ private hovered: PickResult | null = null;
110
+ private dragging = false;
111
+
112
+ constructor(container: HTMLElement, options: RendererOptions, deps: RendererDeps) {
113
+ const minZoom = options.minZoom ?? 0.05;
114
+ const maxZoom = options.maxZoom ?? 6;
115
+ if (!(minZoom > 0) || !(maxZoom >= minZoom) || !Number.isFinite(maxZoom)) {
116
+ throw new KgdiffError(
117
+ 'invalid-input',
118
+ `createRenderer: expected 0 < minZoom ≤ maxZoom, got minZoom ${minZoom}, maxZoom ${maxZoom}`,
119
+ );
120
+ }
121
+ if (options.pixelRatio !== undefined && !(options.pixelRatio > 0)) {
122
+ throw new KgdiffError(
123
+ 'invalid-input',
124
+ `createRenderer: pixelRatio must be > 0, got ${options.pixelRatio}`,
125
+ );
126
+ }
127
+ this.theme = options.theme;
128
+ this.safe = options.safeArea ?? NO_INSETS;
129
+ this.grid = options.grid ?? true;
130
+ this.fixedRatio = options.pixelRatio;
131
+ this.now = deps.now ?? (() => performance.now());
132
+ this.requestFrame =
133
+ deps.requestFrame ??
134
+ ((cb) => (typeof requestAnimationFrame === 'function' ? requestAnimationFrame(cb) : 0));
135
+ this.cancelFrame =
136
+ deps.cancelFrame ??
137
+ ((id) => (typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame(id) : undefined));
138
+ this.motionQuery =
139
+ typeof window !== 'undefined' && typeof window.matchMedia === 'function'
140
+ ? window.matchMedia('(prefers-reduced-motion: reduce)')
141
+ : null;
142
+
143
+ const canvas = document.createElement('canvas');
144
+ canvas.setAttribute('role', 'img');
145
+ const st = canvas.style;
146
+ st.position = 'absolute';
147
+ st.left = '0';
148
+ st.top = '0';
149
+ st.width = '100%';
150
+ st.height = '100%';
151
+ st.display = 'block';
152
+ st.touchAction = 'none';
153
+ st.userSelect = 'none';
154
+ st.setProperty('-webkit-user-select', 'none');
155
+ st.setProperty('-webkit-tap-highlight-color', 'transparent');
156
+ st.outline = 'none';
157
+ container.appendChild(canvas);
158
+ this.canvas = canvas;
159
+ this.ctx =
160
+ deps.context !== undefined ? deps.context : canvas.getContext('2d', { alpha: false });
161
+
162
+ this.camera = new CameraController(
163
+ {
164
+ size: () => ({ width: this.width, height: this.height }),
165
+ safeArea: () => this.safe,
166
+ contentBounds: () => this.contentBounds(),
167
+ reducedMotion: () => this.motionQuery?.matches ?? false,
168
+ invalidate: () => {
169
+ this.cameraChanged = true;
170
+ if (this.pointer) this.hoverPending = true;
171
+ this.requestRender();
172
+ },
173
+ },
174
+ minZoom,
175
+ maxZoom,
176
+ );
177
+ this.res = new Resources(deps.surfaceFactory ?? domSurfaceFactory, this.theme, this.ctx);
178
+ this.painter = new Painter(this.res);
179
+
180
+ this.observeSize();
181
+ this.watchPixelRatio();
182
+ this.watchFonts();
183
+ if (options.interactive ?? true) {
184
+ this.cleanup.push(
185
+ attachInput(canvas, {
186
+ camera: this.camera,
187
+ hover: (x, y) => {
188
+ this.pointer = { x, y };
189
+ this.hoverPending = true;
190
+ this.schedule();
191
+ },
192
+ leave: () => {
193
+ this.pointer = null;
194
+ this.setHover(null);
195
+ },
196
+ click: (x, y, event) => {
197
+ this.emit('click', {
198
+ pick: this.pick(x, y),
199
+ world: this.camera.screenToWorld({ x, y }),
200
+ event,
201
+ });
202
+ },
203
+ dblclick: (x, y, event) => {
204
+ this.emit('dblclick', {
205
+ pick: this.pick(x, y),
206
+ world: this.camera.screenToWorld({ x, y }),
207
+ event,
208
+ });
209
+ },
210
+ dragging: (active) => {
211
+ this.dragging = active;
212
+ this.canvas.style.cursor = active ? 'grabbing' : '';
213
+ if (active) this.setHover(null);
214
+ else if (this.pointer) {
215
+ this.hoverPending = true;
216
+ this.schedule();
217
+ }
218
+ },
219
+ now: this.now,
220
+ }),
221
+ );
222
+ }
223
+ internals.set(this, {
224
+ theme: () => this.theme,
225
+ pixelRatio: () => this.dpr,
226
+ size: () => ({ width: this.width, height: this.height }),
227
+ userSet: (state) => this.camera.set(state, 'user'),
228
+ onTheme: (listener) => {
229
+ this.themeListeners.add(listener);
230
+ return () => this.themeListeners.delete(listener);
231
+ },
232
+ requestFrame: this.requestFrame,
233
+ cancelFrame: this.cancelFrame,
234
+ });
235
+ this.resize();
236
+ }
237
+
238
+ // ─── Public API ────────────────────────────────────────────────────────────
239
+
240
+ setFrame(frame: FrameVisuals | SplitFrame): void {
241
+ if (this.destroyed) return;
242
+ this.frame = frame;
243
+ this.version++;
244
+ if (this.pointer) this.hoverPending = true;
245
+ this.requestRender();
246
+ }
247
+
248
+ setTheme(theme: Theme): void {
249
+ if (this.destroyed || theme === this.theme) return;
250
+ this.theme = theme;
251
+ this.res.setTheme(theme);
252
+ for (const fn of this.themeListeners) fn(theme);
253
+ this.requestRender();
254
+ }
255
+
256
+ setSafeArea(area: { top: number; right: number; bottom: number; left: number }): void {
257
+ this.safe = { top: area.top, right: area.right, bottom: area.bottom, left: area.left };
258
+ }
259
+
260
+ pick(x: number, y: number): PickResult | null {
261
+ const f = this.frame;
262
+ if (!f || !Number.isFinite(x) || !Number.isFinite(y)) return null;
263
+ const k = this.camera.k;
264
+ const view = {
265
+ k,
266
+ ox: this.width / 2 - this.camera.x * k,
267
+ oy: this.height / 2 - this.camera.y * k,
268
+ dpr: this.dpr,
269
+ };
270
+ let frame: FrameVisuals;
271
+ let cache: FrameCache;
272
+ if (isSplit(f)) {
273
+ const left = x < splitX(f.split, this.width, this.dpr);
274
+ frame = left ? f.before : f.after;
275
+ cache = left ? this.caches.before : this.caches.after;
276
+ } else {
277
+ frame = f;
278
+ cache = this.caches.main;
279
+ }
280
+ cache.sync(frame, this.version, this.res.interner);
281
+ return pickAt(frame, cache, view, x, y, this.res.fonts, this.res.measurer);
282
+ }
283
+
284
+ on<K extends keyof RendererEvents>(
285
+ type: K,
286
+ listener: (event: RendererEvents[K]) => void,
287
+ ): () => void {
288
+ const set = this.listeners[type];
289
+ set.add(listener);
290
+ return () => {
291
+ set.delete(listener);
292
+ };
293
+ }
294
+
295
+ resize(): void {
296
+ if (this.destroyed) return;
297
+ this.applySize(this.canvas.clientWidth, this.canvas.clientHeight, null);
298
+ this.draw();
299
+ }
300
+
301
+ requestRender(): void {
302
+ if (this.destroyed) return;
303
+ this.dirty = true;
304
+ if (!this.busy) this.schedule();
305
+ }
306
+
307
+ toDataURL(scale?: number): string {
308
+ const s = scale !== undefined && scale > 0 ? Math.min(scale, 8) : this.dpr;
309
+ if (!this.ctx) return this.canvas.toDataURL('image/png');
310
+ if (s === this.dpr) {
311
+ // Exports contain every label: no per-frame raster budget.
312
+ this.painter.rasterBudget = Number.POSITIVE_INFINITY;
313
+ this.draw();
314
+ this.painter.rasterBudget = LABEL_RASTER_BUDGET;
315
+ return this.canvas.toDataURL('image/png');
316
+ }
317
+ const out = document.createElement('canvas');
318
+ out.width = Math.max(1, Math.round(this.width * s));
319
+ out.height = Math.max(1, Math.round(this.height * s));
320
+ const ctx = out.getContext('2d', { alpha: false });
321
+ if (!ctx) return this.canvas.toDataURL('image/png');
322
+ this.painter.rasterBudget = Number.POSITIVE_INFINITY;
323
+ this.painter.paint(
324
+ { ctx, width: this.width, height: this.height, dpr: s },
325
+ this.camera,
326
+ this.frame,
327
+ this.caches,
328
+ this.version,
329
+ this.theme,
330
+ this.grid,
331
+ );
332
+ this.painter.rasterBudget = LABEL_RASTER_BUDGET;
333
+ this.dirty = true; // pixel-ratio caches were re-keyed for the export
334
+ this.schedule();
335
+ return out.toDataURL('image/png');
336
+ }
337
+
338
+ destroy(): void {
339
+ if (this.destroyed) return;
340
+ this.destroyed = true;
341
+ if (this.rafId) this.cancelFrame(this.rafId);
342
+ this.rafId = 0;
343
+ this.camera.stop();
344
+ for (const fn of this.cleanup.splice(0)) fn();
345
+ for (const set of Object.values(this.listeners)) set.clear();
346
+ this.themeListeners.clear();
347
+ this.canvas.remove();
348
+ internals.delete(this);
349
+ }
350
+
351
+ // ─── Loop ─────────────────────────────────────────────────────────────────
352
+
353
+ private schedule(): void {
354
+ if (this.rafId === 0 && !this.destroyed) this.rafId = this.requestFrame(this.tick) || 0;
355
+ }
356
+
357
+ private readonly tick = (time: number): void => {
358
+ this.rafId = 0;
359
+ if (this.destroyed) return;
360
+ this.busy = true;
361
+ try {
362
+ this.camera.tick(time);
363
+ this.emitCamera();
364
+ if (this.hoverPending) this.updateHover();
365
+ } finally {
366
+ this.busy = false;
367
+ }
368
+ if (this.dirty) this.draw();
369
+ if (this.camera.animating) this.schedule();
370
+ };
371
+
372
+ private draw(): void {
373
+ if (this.destroyed) return;
374
+ const ctx = this.ctx;
375
+ if (!ctx || this.width <= 0 || this.height <= 0) {
376
+ this.dirty = false;
377
+ return;
378
+ }
379
+ this.busy = true;
380
+ try {
381
+ this.autoFit();
382
+ this.dirty = false;
383
+ const t0 = this.now();
384
+ this.painter.paint(
385
+ { ctx, width: this.width, height: this.height, dpr: this.dpr },
386
+ this.camera,
387
+ this.frame,
388
+ this.caches,
389
+ this.version,
390
+ this.theme,
391
+ this.grid,
392
+ );
393
+ const ms = this.now() - t0;
394
+ // Labels the raster budget left for the next frame.
395
+ if (this.painter.stats.deferredLabels > 0) this.dirty = true;
396
+ this.emitCamera();
397
+ this.emit('frame', { ms });
398
+ } finally {
399
+ this.busy = false;
400
+ }
401
+ if (this.dirty) this.schedule();
402
+ }
403
+
404
+ /** The first frame with content is fitted (as a jump) unless the camera was used already. */
405
+ private autoFit(): void {
406
+ if (this.autoFitted || this.camera.touched || !this.frame) return;
407
+ const bounds = this.contentBounds();
408
+ if (!bounds) return;
409
+ this.autoFitted = true;
410
+ this.camera.jump(this.camera.targetFor(bounds, {}, { width: this.width, height: this.height }));
411
+ }
412
+
413
+ private emitCamera(): void {
414
+ if (!this.cameraChanged) return;
415
+ this.cameraChanged = false;
416
+ const s = this.camera.get();
417
+ const e = this.emitted;
418
+ const resized = this.viewportChanged;
419
+ this.viewportChanged = false;
420
+ const source = this.camera.takeSource();
421
+ if (!resized && e && e.x === s.x && e.y === s.y && e.k === s.k) return;
422
+ this.emitted = s;
423
+ this.emit('camera', { x: s.x, y: s.y, k: s.k, source });
424
+ }
425
+
426
+ private updateHover(): void {
427
+ this.hoverPending = false;
428
+ if (!this.pointer || this.dragging) return;
429
+ this.setHover(this.pick(this.pointer.x, this.pointer.y));
430
+ }
431
+
432
+ private setHover(pick: PickResult | null): void {
433
+ if (samePick(pick, this.hovered)) return;
434
+ this.hovered = pick;
435
+ if (!this.dragging) this.canvas.style.cursor = pick ? 'pointer' : '';
436
+ this.emit('hover', pick);
437
+ }
438
+
439
+ private emit<K extends keyof RendererEvents>(type: K, event: RendererEvents[K]): void {
440
+ for (const fn of this.listeners[type]) fn(event);
441
+ }
442
+
443
+ private contentBounds(): Rect | null {
444
+ const f = this.frame;
445
+ if (!f) return null;
446
+ const { interner } = this.res;
447
+ if (isSplit(f)) {
448
+ this.caches.before.sync(f.before, this.version, interner);
449
+ this.caches.after.sync(f.after, this.version, interner);
450
+ return unionBounds(this.caches.before.contentBounds(), this.caches.after.contentBounds());
451
+ }
452
+ this.caches.main.sync(f, this.version, interner);
453
+ return this.caches.main.contentBounds();
454
+ }
455
+
456
+ // ─── Size, pixel ratio, fonts ─────────────────────────────────────────────
457
+
458
+ private pixelRatio(): number {
459
+ return clamp(this.fixedRatio ?? windowRatio(), 0.25, MAX_PIXEL_RATIO);
460
+ }
461
+
462
+ private applySize(width: number, height: number, device: readonly [number, number] | null): void {
463
+ const dpr = this.pixelRatio();
464
+ const exact =
465
+ device !== null && this.fixedRatio === undefined && windowRatio() <= MAX_PIXEL_RATIO;
466
+ const bw = Math.max(1, exact ? device[0] : Math.round(width * dpr));
467
+ const bh = Math.max(1, exact ? device[1] : Math.round(height * dpr));
468
+ if (this.canvas.width !== bw) this.canvas.width = bw;
469
+ if (this.canvas.height !== bh) this.canvas.height = bh;
470
+ const changed = width !== this.width || height !== this.height || dpr !== this.dpr;
471
+ this.width = Math.max(0, width);
472
+ this.height = Math.max(0, height);
473
+ this.dpr = dpr;
474
+ if (changed) {
475
+ this.cameraChanged = true;
476
+ this.viewportChanged = true;
477
+ this.camera.resized();
478
+ }
479
+ this.dirty = true;
480
+ }
481
+
482
+ private observeSize(): void {
483
+ if (typeof ResizeObserver !== 'function') return;
484
+ const ro = new ResizeObserver((entries) => {
485
+ if (this.destroyed) return;
486
+ const entry = entries[entries.length - 1];
487
+ if (!entry) return;
488
+ const box = entry.contentBoxSize?.[0];
489
+ const width = box ? box.inlineSize : entry.contentRect.width;
490
+ const height = box ? box.blockSize : entry.contentRect.height;
491
+ const dev = entry.devicePixelContentBoxSize?.[0];
492
+ this.applySize(width, height, dev ? [dev.inlineSize, dev.blockSize] : null);
493
+ this.draw(); // synchronously, before the browser paints the cleared canvas
494
+ });
495
+ try {
496
+ ro.observe(this.canvas, { box: 'device-pixel-content-box' });
497
+ } catch {
498
+ ro.observe(this.canvas);
499
+ }
500
+ this.cleanup.push(() => ro.disconnect());
501
+ }
502
+
503
+ private watchPixelRatio(): void {
504
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
505
+ let query: MediaQueryList | null = null;
506
+ const onChange = (): void => {
507
+ query?.removeEventListener('change', onChange);
508
+ query = window.matchMedia(`(resolution: ${windowRatio()}dppx)`);
509
+ query.addEventListener('change', onChange);
510
+ if (!this.destroyed && this.pixelRatio() !== this.dpr) this.resize();
511
+ };
512
+ query = window.matchMedia(`(resolution: ${windowRatio()}dppx)`);
513
+ query.addEventListener('change', onChange);
514
+ this.cleanup.push(() => query?.removeEventListener('change', onChange));
515
+ }
516
+
517
+ private watchFonts(): void {
518
+ const fonts = typeof document !== 'undefined' ? document.fonts : undefined;
519
+ if (!fonts || typeof fonts.addEventListener !== 'function') return;
520
+ const onLoaded = (): void => {
521
+ this.res.fontsChanged();
522
+ this.requestRender();
523
+ };
524
+ fonts.addEventListener('loadingdone', onLoaded);
525
+ this.cleanup.push(() => fonts.removeEventListener('loadingdone', onLoaded));
526
+ }
527
+ }
528
+
529
+ /** Implementation of `createRenderer` with optional test seams. */
530
+ export function createRendererImpl(
531
+ container: HTMLElement,
532
+ options: RendererOptions,
533
+ deps: RendererDeps = {},
534
+ ): Renderer {
535
+ if (!container || typeof container.appendChild !== 'function') {
536
+ throw new KgdiffError('invalid-input', 'createRenderer: container must be an HTMLElement');
537
+ }
538
+ return new CanvasRenderer(container, options, deps);
539
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Van Wijk & Nuij, "Smooth and efficient zooming and panning" (InfoVis 2003).
3
+ *
4
+ * Interpolates between two views (centre + visible width) along the path that is optimal
5
+ * for perceived motion: zoom out while travelling, zoom back in on arrival.
6
+ */
7
+
8
+ export interface ZoomView {
9
+ readonly x: number;
10
+ readonly y: number;
11
+ /** Visible extent in world units (inverse of zoom). */
12
+ readonly w: number;
13
+ }
14
+
15
+ export interface ZoomPath {
16
+ /** Path length `S` in the paper's units (≥ 0). Duration is derived from it. */
17
+ readonly S: number;
18
+ /** View at `t ∈ [0, 1]`; exact endpoints at 0 and 1. */
19
+ at(t: number): ZoomView;
20
+ }
21
+
22
+ const TINY = 1e-12;
23
+
24
+ /** Optimal zoom-and-pan path from `a` to `b`. `rho` trades zooming for panning (√2 by default). */
25
+ export function smoothZoom(a: ZoomView, b: ZoomView, rho: number = Math.SQRT2): ZoomPath {
26
+ const rho2 = rho * rho;
27
+ const rho4 = rho2 * rho2;
28
+ const w0 = Math.max(a.w, TINY);
29
+ const w1 = Math.max(b.w, TINY);
30
+ const dx = b.x - a.x;
31
+ const dy = b.y - a.y;
32
+ const d1 = Math.hypot(dx, dy);
33
+
34
+ if (d1 < 1e-9 * Math.max(w0, w1)) {
35
+ // Pure zoom (or no motion): exponential in the width keeps perceived speed constant.
36
+ const ratio = Math.log(w1 / w0);
37
+ return {
38
+ S: Math.abs(ratio) / rho,
39
+ at(t) {
40
+ if (t <= 0) return a;
41
+ if (t >= 1) return b;
42
+ return { x: a.x + t * dx, y: a.y + t * dy, w: w0 * Math.exp(ratio * t) };
43
+ },
44
+ };
45
+ }
46
+
47
+ const b0 = (w1 * w1 - w0 * w0 + rho4 * d1 * d1) / (2 * w0 * rho2 * d1);
48
+ const b1 = (w1 * w1 - w0 * w0 - rho4 * d1 * d1) / (2 * w1 * rho2 * d1);
49
+ // ln(√(b²+1) − b) = −asinh(b), numerically stable for large |b|.
50
+ const r0 = -Math.asinh(b0);
51
+ const r1 = -Math.asinh(b1);
52
+ const S = (r1 - r0) / rho;
53
+ const coshR0 = Math.cosh(r0);
54
+ const sinhR0 = Math.sinh(r0);
55
+ return {
56
+ S,
57
+ at(t) {
58
+ if (t <= 0) return a;
59
+ if (t >= 1) return b;
60
+ const s = t * S;
61
+ const u = (w0 / (rho2 * d1)) * (coshR0 * Math.tanh(rho * s + r0) - sinhR0);
62
+ return { x: a.x + u * dx, y: a.y + u * dy, w: (w0 * coshR0) / Math.cosh(rho * s + r0) };
63
+ },
64
+ };
65
+ }
package/src/spatial.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Uniform grid over points (node centres) in compressed-row form: O(n) build with two passes
3
+ * and no per-cell arrays, O(k) rectangle queries. Used for picking and glyph endpoint lookup.
4
+ */
5
+
6
+ const MAX_CELLS_PER_AXIS = 512;
7
+
8
+ export class PointGrid {
9
+ private x0 = 0;
10
+ private y0 = 0;
11
+ private cell = 1;
12
+ private cols = 0;
13
+ private rows = 0;
14
+ private start = new Int32Array(1);
15
+ private items = new Int32Array(0);
16
+ /** Largest radius among the indexed points (query rectangles grow by it). */
17
+ maxR = 0;
18
+ /** Number of indexed points. */
19
+ size = 0;
20
+
21
+ /**
22
+ * Index `n` points. `ids[i]` is reported by queries for the point at (xs[i], ys[i]).
23
+ * The cell size targets about one point per cell, bounded by the largest radius.
24
+ */
25
+ build(xs: Float64Array, ys: Float64Array, rs: Float64Array, ids: Int32Array, n: number): void {
26
+ this.size = n;
27
+ let minX = Number.POSITIVE_INFINITY;
28
+ let minY = Number.POSITIVE_INFINITY;
29
+ let maxX = Number.NEGATIVE_INFINITY;
30
+ let maxY = Number.NEGATIVE_INFINITY;
31
+ let maxR = 0;
32
+ for (let i = 0; i < n; i++) {
33
+ const x = xs[i]!;
34
+ const y = ys[i]!;
35
+ if (x < minX) minX = x;
36
+ if (x > maxX) maxX = x;
37
+ if (y < minY) minY = y;
38
+ if (y > maxY) maxY = y;
39
+ if (rs[i]! > maxR) maxR = rs[i]!;
40
+ }
41
+ this.maxR = maxR;
42
+ if (n === 0) {
43
+ this.cols = this.rows = 0;
44
+ return;
45
+ }
46
+ const w = Math.max(maxX - minX, 1e-9);
47
+ const h = Math.max(maxY - minY, 1e-9);
48
+ let cell = Math.max(Math.sqrt((w * h) / n), 2 * maxR, 1e-6);
49
+ cell = Math.max(cell, w / MAX_CELLS_PER_AXIS, h / MAX_CELLS_PER_AXIS);
50
+ this.cell = cell;
51
+ this.x0 = minX;
52
+ this.y0 = minY;
53
+ this.cols = Math.floor(w / cell) + 1;
54
+ this.rows = Math.floor(h / cell) + 1;
55
+ const cells = this.cols * this.rows;
56
+ if (this.start.length < cells + 1) this.start = new Int32Array(cells + 1);
57
+ else this.start.fill(0, 0, cells + 1);
58
+ if (this.items.length < n) this.items = new Int32Array(Math.max(n, this.items.length * 2));
59
+ const start = this.start;
60
+ for (let i = 0; i < n; i++) start[this.cellOf(xs[i]!, ys[i]!) + 1]!++;
61
+ for (let c = 0; c < cells; c++) start[c + 1]! += start[c]!;
62
+ // Fill using `start` as a cursor, then shift it back.
63
+ for (let i = 0; i < n; i++) {
64
+ const c = this.cellOf(xs[i]!, ys[i]!);
65
+ this.items[start[c]!++] = ids[i]!;
66
+ }
67
+ for (let c = cells; c > 0; c--) start[c] = start[c - 1]!;
68
+ start[0] = 0;
69
+ }
70
+
71
+ /** Ids of points whose centre may lie in [x0, x1] × [y0, y1]; returns how many were written. */
72
+ query(x0: number, y0: number, x1: number, y1: number, out: number[]): number {
73
+ out.length = 0;
74
+ if (this.cols === 0) return 0;
75
+ const c0 = Math.max(0, Math.floor((x0 - this.x0) / this.cell));
76
+ const c1 = Math.min(this.cols - 1, Math.floor((x1 - this.x0) / this.cell));
77
+ const r0 = Math.max(0, Math.floor((y0 - this.y0) / this.cell));
78
+ const r1 = Math.min(this.rows - 1, Math.floor((y1 - this.y0) / this.cell));
79
+ for (let r = r0; r <= r1; r++) {
80
+ for (let c = c0; c <= c1; c++) {
81
+ const cell = r * this.cols + c;
82
+ const end = this.start[cell + 1]!;
83
+ for (let j = this.start[cell]!; j < end; j++) out.push(this.items[j]!);
84
+ }
85
+ }
86
+ return out.length;
87
+ }
88
+
89
+ private cellOf(x: number, y: number): number {
90
+ const c = Math.min(this.cols - 1, Math.max(0, Math.floor((x - this.x0) / this.cell)));
91
+ const r = Math.min(this.rows - 1, Math.max(0, Math.floor((y - this.y0) / this.cell)));
92
+ return r * this.cols + c;
93
+ }
94
+ }