@kgdiff/renderer 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
package/src/minimap.ts ADDED
@@ -0,0 +1,545 @@
1
+ /**
2
+ * Minimap (docs/visual-language.md §Minimap): a 184 × 128 floating card with the graph's edges
3
+ * as faint hairlines (below 3k edges), neutral nodes as small dots, changed nodes as outlined
4
+ * dots in their family color, and the viewport as an accent rectangle. Click or drag to navigate.
5
+ *
6
+ * The world window is centred on the content and grows just enough to contain the viewport, so
7
+ * the rectangle is always fully visible while the content stays centred and as large as possible.
8
+ * The window shrinks at once when the viewport needs room and grows back eased, with hysteresis,
9
+ * so panning never makes it jitter. Marks are rasterized into an offscreen layer whenever the
10
+ * window or the frame changes; camera moves otherwise only blit that layer and the rectangle.
11
+ */
12
+ import type { Rect } from '@kgdiff/core';
13
+ import { KgdiffError } from '@kgdiff/core';
14
+ import type { CollapsedGroup, FrameVisuals, NodeVisual, Theme } from '@kgdiff/scene';
15
+ import { type Ctx2D, domSurfaceFactory, type Surface, type SurfaceFactory } from './ctx.js';
16
+ import { roundRectPath } from './draw/shapes.js';
17
+ import type { Minimap, MinimapOptions, Renderer } from './index.js';
18
+ import { clamp, TAU } from './math.js';
19
+ import { internals } from './renderer.js';
20
+
21
+ const PAD = 8;
22
+ const RADIUS = 12;
23
+ /** Neutral dot size (css px) and alpha. */
24
+ const NEUTRAL_DOT = 1.5;
25
+ const NEUTRAL_ALPHA = 0.45;
26
+ /** Changed dot diameter and background outline (css px). */
27
+ const CHANGE_DOT = 2.5;
28
+ const CHANGE_OUTLINE = 1;
29
+ /** Edges are drawn below this count, as hairlines. */
30
+ const EDGE_LIMIT = 3000;
31
+ const EDGE_WIDTH = 0.5;
32
+ const EDGE_ALPHA = 0.12;
33
+ /** Window growth time constant (ms): settles in ~150 ms. */
34
+ const GROW_TAU = 50;
35
+ /** The window grows back only once it is this much smaller than needed (hysteresis). */
36
+ const GROW_SLACK = 0.8;
37
+
38
+ export interface MinimapDeps {
39
+ readonly context?: Ctx2D | null;
40
+ readonly surfaceFactory?: SurfaceFactory;
41
+ }
42
+
43
+ /** The family color of a change mark, or null for neutral context (by `NodeVisual.family`). */
44
+ export function markColor(n: NodeVisual, theme: Theme): string | null {
45
+ const f = n.family;
46
+ if (f === 'neutral') return null;
47
+ return theme.family[f]?.base ?? null;
48
+ }
49
+
50
+ /**
51
+ * The minimap's world window: centred on the content, covering the content and the viewport.
52
+ * Extending symmetrically keeps the content centred; the viewport always fits inside.
53
+ */
54
+ export function minimapWindow(content: Rect, viewport: Rect | null): Rect {
55
+ const cx = content.x + content.width / 2;
56
+ const cy = content.y + content.height / 2;
57
+ let hw = Math.max(content.width / 2, 1);
58
+ let hh = Math.max(content.height / 2, 1);
59
+ if (
60
+ viewport &&
61
+ Number.isFinite(viewport.x + viewport.y + viewport.width + viewport.height) &&
62
+ viewport.width > 0 &&
63
+ viewport.height > 0
64
+ ) {
65
+ hw = Math.max(hw, cx - viewport.x, viewport.x + viewport.width - cx);
66
+ hh = Math.max(hh, cy - viewport.y, viewport.y + viewport.height - cy);
67
+ }
68
+ return { x: cx - hw, y: cy - hh, width: 2 * hw, height: 2 * hh };
69
+ }
70
+
71
+ /**
72
+ * Minimap zoom (px per world unit) with asymmetric smoothing: shrinks at once (the viewport
73
+ * must stay visible), grows back eased and only once it has `GROW_SLACK` room (no jitter).
74
+ */
75
+ export class WindowScale {
76
+ value = 0;
77
+ target = 0;
78
+ growing = false;
79
+
80
+ /** Set the scale the window needs now; returns whether `value` changed. */
81
+ retarget(target: number): boolean {
82
+ if (!(target > 0) || !Number.isFinite(target)) return false;
83
+ this.target = target;
84
+ if (!(this.value > 0) || target <= this.value) {
85
+ const changed = target !== this.value;
86
+ this.value = target;
87
+ this.growing = false;
88
+ return changed;
89
+ }
90
+ if (!this.growing && this.value < target * GROW_SLACK) this.growing = true;
91
+ return false;
92
+ }
93
+
94
+ /** Advance the eased growth by `dt` ms; returns whether it is still growing. */
95
+ step(dt: number): boolean {
96
+ if (!this.growing) return false;
97
+ this.value += (this.target - this.value) * (1 - Math.exp(-Math.max(0, dt) / GROW_TAU));
98
+ if (Math.abs(this.target - this.value) <= this.target * 0.002) {
99
+ this.value = this.target;
100
+ this.growing = false;
101
+ }
102
+ return this.growing;
103
+ }
104
+ }
105
+
106
+ /** Uniform scale + offset that fits `bounds` into a w × h box with padding, centred. */
107
+ export function fitTransform(
108
+ bounds: Rect,
109
+ w: number,
110
+ h: number,
111
+ pad: number,
112
+ ): { s: number; ox: number; oy: number } {
113
+ const aw = Math.max(1, w - 2 * pad);
114
+ const ah = Math.max(1, h - 2 * pad);
115
+ const bw = Math.max(bounds.width, 1e-9);
116
+ const bh = Math.max(bounds.height, 1e-9);
117
+ const s = Math.min(aw / bw, ah / bh);
118
+ return { s, ox: (w - bw * s) / 2 - bounds.x * s, oy: (h - bh * s) / 2 - bounds.y * s };
119
+ }
120
+
121
+ /** The minimap plus internals tests and the dev harness inspect. */
122
+ export interface MinimapImpl extends Minimap {
123
+ /** Current world → minimap transform (css px), or null without content. */
124
+ transform(): { readonly s: number; readonly ox: number; readonly oy: number } | null;
125
+ }
126
+
127
+ class CanvasMinimap implements MinimapImpl {
128
+ readonly canvas: HTMLCanvasElement;
129
+ private readonly ctx: Ctx2D | null;
130
+ private readonly factory: SurfaceFactory;
131
+ private readonly width: number;
132
+ private readonly height: number;
133
+ private theme: Theme;
134
+ private dpr = 1;
135
+ private layer: Surface | null = null;
136
+ private frame: FrameVisuals | null = null;
137
+ private collapsed: readonly CollapsedGroup[] = [];
138
+ private fit: { s: number; ox: number; oy: number } | null = null;
139
+ private content: Rect | null = null;
140
+ private readonly scale = new WindowScale();
141
+ private rafId = 0;
142
+ private lastTick = -1;
143
+ private readonly cleanup: (() => void)[] = [];
144
+ private drag: { id: number; dx: number; dy: number } | null = null;
145
+ private destroyed = false;
146
+
147
+ constructor(
148
+ container: HTMLElement,
149
+ private readonly renderer: Renderer,
150
+ options: MinimapOptions,
151
+ deps: MinimapDeps,
152
+ ) {
153
+ const host = internals.get(renderer);
154
+ if (!host) {
155
+ throw new KgdiffError(
156
+ 'invalid-input',
157
+ 'createMinimap: renderer is destroyed or was not created by createRenderer',
158
+ );
159
+ }
160
+ this.width = Math.max(16, Math.round(options.width ?? 184));
161
+ this.height = Math.max(16, Math.round(options.height ?? 128));
162
+ this.theme = host.theme();
163
+ this.factory = deps.surfaceFactory ?? domSurfaceFactory;
164
+ const canvas = document.createElement('canvas');
165
+ canvas.setAttribute('aria-hidden', 'true');
166
+ const st = canvas.style;
167
+ st.display = 'block';
168
+ st.width = `${this.width}px`;
169
+ st.height = `${this.height}px`;
170
+ st.borderRadius = `${RADIUS}px`;
171
+ st.touchAction = 'none';
172
+ st.cursor = 'pointer';
173
+ st.setProperty('-webkit-tap-highlight-color', 'transparent');
174
+ container.appendChild(canvas);
175
+ this.canvas = canvas;
176
+ this.ctx = deps.context !== undefined ? deps.context : canvas.getContext('2d');
177
+ this.style();
178
+ this.resizeBuffer(host.pixelRatio());
179
+
180
+ this.cleanup.push(
181
+ renderer.on('camera', () => {
182
+ this.refit(false);
183
+ this.redraw();
184
+ }),
185
+ );
186
+ this.cleanup.push(
187
+ host.onTheme((theme) => {
188
+ this.theme = theme;
189
+ this.style();
190
+ this.rasterize();
191
+ this.redraw();
192
+ }),
193
+ );
194
+ const listen = <K extends keyof HTMLElementEventMap>(
195
+ type: K,
196
+ fn: (e: HTMLElementEventMap[K]) => void,
197
+ ): void => {
198
+ canvas.addEventListener(type, fn);
199
+ this.cleanup.push(() => canvas.removeEventListener(type, fn));
200
+ };
201
+ listen('pointerdown', (e) => this.onDown(e));
202
+ listen('pointermove', (e) => this.onMove(e));
203
+ listen('pointerup', (e) => this.onUp(e));
204
+ listen('pointercancel', (e) => this.onUp(e));
205
+ this.redraw();
206
+ }
207
+
208
+ update(frame: FrameVisuals, collapsed?: readonly CollapsedGroup[]): void {
209
+ if (this.destroyed) return;
210
+ this.frame = frame;
211
+ this.collapsed = collapsed ?? frame.collapsed;
212
+ const host = internals.get(this.renderer);
213
+ if (host && host.pixelRatio() !== this.dpr) this.resizeBuffer(host.pixelRatio());
214
+ const first = this.content === null;
215
+ this.content = this.bounds();
216
+ this.refit(first);
217
+ this.rasterize();
218
+ this.redraw();
219
+ }
220
+
221
+ transform(): { readonly s: number; readonly ox: number; readonly oy: number } | null {
222
+ return this.fit;
223
+ }
224
+
225
+ destroy(): void {
226
+ if (this.destroyed) return;
227
+ this.destroyed = true;
228
+ if (this.rafId) internals.get(this.renderer)?.cancelFrame(this.rafId);
229
+ this.rafId = 0;
230
+ for (const fn of this.cleanup.splice(0)) fn();
231
+ this.canvas.remove();
232
+ this.layer = null;
233
+ this.frame = null;
234
+ }
235
+
236
+ private style(): void {
237
+ const st = this.canvas.style;
238
+ st.background = this.theme.surface.overlay;
239
+ st.boxShadow = this.theme.surface.shadow;
240
+ st.setProperty('backdrop-filter', 'blur(16px)');
241
+ st.setProperty('-webkit-backdrop-filter', 'blur(16px)');
242
+ }
243
+
244
+ /**
245
+ * Recompute the window for the current content and viewport. Frozen while dragging on the
246
+ * minimap, so the world point under the pointer does not move. `reset` jumps to the target.
247
+ */
248
+ private refit(reset: boolean): void {
249
+ const content = this.content;
250
+ if (!content) {
251
+ this.fit = null;
252
+ return;
253
+ }
254
+ if (this.drag && !reset) return;
255
+ const viewport = this.renderer.camera.viewport();
256
+ const target = fitTransform(minimapWindow(content, viewport), this.width, this.height, PAD).s;
257
+ if (reset) this.scale.value = 0;
258
+ const jumped = this.scale.retarget(target);
259
+ this.applyScale();
260
+ if (jumped && !reset) this.rasterize();
261
+ if (this.scale.growing) this.schedule();
262
+ }
263
+
264
+ private applyScale(): void {
265
+ const content = this.content;
266
+ const s = this.scale.value;
267
+ if (!content || !(s > 0)) {
268
+ this.fit = null;
269
+ return;
270
+ }
271
+ const cx = content.x + content.width / 2;
272
+ const cy = content.y + content.height / 2;
273
+ this.fit = { s, ox: this.width / 2 - cx * s, oy: this.height / 2 - cy * s };
274
+ }
275
+
276
+ private schedule(): void {
277
+ if (this.rafId || this.destroyed) return;
278
+ const host = internals.get(this.renderer);
279
+ if (!host) return;
280
+ this.rafId = host.requestFrame(this.tick) || 0;
281
+ }
282
+
283
+ private readonly tick = (time: number): void => {
284
+ this.rafId = 0;
285
+ if (this.destroyed) return;
286
+ const dt = this.lastTick < 0 ? 16 : Math.min(64, time - this.lastTick);
287
+ const growing = this.scale.step(dt);
288
+ this.lastTick = growing ? time : -1;
289
+ this.applyScale();
290
+ this.rasterize();
291
+ this.redraw();
292
+ if (growing) this.schedule();
293
+ };
294
+
295
+ private resizeBuffer(dpr: number): void {
296
+ this.dpr = dpr;
297
+ this.canvas.width = Math.round(this.width * dpr);
298
+ this.canvas.height = Math.round(this.height * dpr);
299
+ this.layer = this.factory(this.canvas.width, this.canvas.height);
300
+ this.rasterize();
301
+ }
302
+
303
+ private bounds(): Rect | null {
304
+ const frame = this.frame;
305
+ if (!frame) return null;
306
+ let x0 = Number.POSITIVE_INFINITY;
307
+ let y0 = Number.POSITIVE_INFINITY;
308
+ let x1 = Number.NEGATIVE_INFINITY;
309
+ let y1 = Number.NEGATIVE_INFINITY;
310
+ for (const n of frame.nodes) {
311
+ if (!n.visible || !Number.isFinite(n.x + n.y)) continue;
312
+ if (n.x < x0) x0 = n.x;
313
+ if (n.y < y0) y0 = n.y;
314
+ if (n.x > x1) x1 = n.x;
315
+ if (n.y > y1) y1 = n.y;
316
+ }
317
+ for (const g of this.collapsed) {
318
+ if (!Number.isFinite(g.x + g.y)) continue;
319
+ x0 = Math.min(x0, g.x);
320
+ y0 = Math.min(y0, g.y);
321
+ x1 = Math.max(x1, g.x);
322
+ y1 = Math.max(y1, g.y);
323
+ }
324
+ return x0 <= x1 ? { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } : null;
325
+ }
326
+
327
+ /** Rasterize the marks into the offscreen layer (device px, snapped squares). */
328
+ private rasterize(): void {
329
+ const target = this.layer?.ctx ?? null;
330
+ if (!target) return;
331
+ target.setTransform(1, 0, 0, 1, 0, 0);
332
+ target.clearRect(0, 0, this.canvas.width, this.canvas.height);
333
+ this.drawMarks(target);
334
+ }
335
+
336
+ private drawMarks(ctx: Ctx2D): void {
337
+ const frame = this.frame;
338
+ const fit = this.fit;
339
+ if (!frame || !fit) return;
340
+ const theme = this.theme;
341
+ const dpr = this.dpr;
342
+ const sx = (wx: number): number => (wx * fit.s + fit.ox) * dpr;
343
+ const sy = (wy: number): number => (wy * fit.s + fit.oy) * dpr;
344
+ const neutral = theme.name === 'light' ? theme.neutral.nodeStroke : theme.neutral.node;
345
+
346
+ // Structure: edges as device-pixel hairlines (cheap below a few thousand).
347
+ if (frame.edges.length > 0 && frame.edges.length < EDGE_LIMIT) {
348
+ ctx.globalAlpha = EDGE_ALPHA;
349
+ ctx.strokeStyle = neutral;
350
+ ctx.lineWidth = Math.max(1, EDGE_WIDTH * dpr);
351
+ ctx.setLineDash([]);
352
+ ctx.beginPath();
353
+ for (const e of frame.edges) {
354
+ if (!e.visible || !Number.isFinite(e.x1 + e.y1 + e.x2 + e.y2)) continue;
355
+ ctx.moveTo(sx(e.x1), sy(e.y1));
356
+ ctx.lineTo(sx(e.x2), sy(e.y2));
357
+ }
358
+ ctx.stroke();
359
+ }
360
+
361
+ // Neutral context: small squares on the device-pixel grid.
362
+ const small = Math.max(1, Math.round(NEUTRAL_DOT * dpr));
363
+ const changed = new Map<string, number[]>();
364
+ ctx.globalAlpha = NEUTRAL_ALPHA;
365
+ ctx.fillStyle = neutral;
366
+ ctx.beginPath();
367
+ for (let i = 0; i < frame.nodes.length; i++) {
368
+ const n = frame.nodes[i]!;
369
+ if (!n.visible || !Number.isFinite(n.x + n.y)) continue;
370
+ const color = markColor(n, theme);
371
+ if (color !== null) {
372
+ let list = changed.get(color);
373
+ if (!list) {
374
+ list = [];
375
+ changed.set(color, list);
376
+ }
377
+ list.push(i);
378
+ continue;
379
+ }
380
+ ctx.rect(Math.round(sx(n.x) - small / 2), Math.round(sy(n.y) - small / 2), small, small);
381
+ }
382
+ ctx.fill();
383
+
384
+ const r = (CHANGE_DOT / 2) * dpr;
385
+ const outline = r + CHANGE_OUTLINE * dpr;
386
+ ctx.globalAlpha = 0.6;
387
+ ctx.fillStyle = theme.text.muted;
388
+ ctx.beginPath();
389
+ for (const g of this.collapsed) {
390
+ if (!Number.isFinite(g.x + g.y)) continue;
391
+ ctx.rect(
392
+ Math.round(sx(g.x) - r),
393
+ Math.round(sy(g.y) - r),
394
+ Math.round(2 * r),
395
+ Math.round(2 * r),
396
+ );
397
+ }
398
+ ctx.fill();
399
+
400
+ // Changes: dots in family color over a background-colored outline, so they pop.
401
+ if (changed.size === 0) return;
402
+ ctx.globalAlpha = 1;
403
+ ctx.fillStyle = theme.canvas.background;
404
+ ctx.beginPath();
405
+ for (const list of changed.values()) {
406
+ for (const i of list) {
407
+ const n = frame.nodes[i]!;
408
+ ctx.moveTo(sx(n.x) + outline, sy(n.y));
409
+ ctx.arc(sx(n.x), sy(n.y), outline, 0, TAU);
410
+ }
411
+ }
412
+ ctx.fill();
413
+ for (const [color, list] of changed) {
414
+ ctx.fillStyle = color;
415
+ ctx.beginPath();
416
+ for (const i of list) {
417
+ const n = frame.nodes[i]!;
418
+ ctx.moveTo(sx(n.x) + r, sy(n.y));
419
+ ctx.arc(sx(n.x), sy(n.y), r, 0, TAU);
420
+ }
421
+ ctx.fill();
422
+ }
423
+ }
424
+
425
+ /** Blit the marks, then draw the viewport rectangle and the card border. */
426
+ private redraw(): void {
427
+ const ctx = this.ctx;
428
+ if (!ctx || this.destroyed) return;
429
+ const ratio = internals.get(this.renderer)?.pixelRatio();
430
+ if (ratio !== undefined && ratio !== this.dpr) this.resizeBuffer(ratio);
431
+ const dpr = this.dpr;
432
+ const w = this.width;
433
+ const h = this.height;
434
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
435
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
436
+ ctx.save();
437
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
438
+ ctx.beginPath();
439
+ roundRectPath(ctx, 0, 0, w, h, RADIUS);
440
+ ctx.clip();
441
+ if (this.layer) {
442
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
443
+ ctx.drawImage(this.layer.canvas, 0, 0, this.canvas.width, this.canvas.height);
444
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
445
+ } else {
446
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
447
+ this.drawMarks(ctx);
448
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
449
+ }
450
+ const fit = this.fit;
451
+ if (fit) {
452
+ const vp = this.renderer.camera.viewport();
453
+ const x0 = clamp(vp.x * fit.s + fit.ox, -2, w + 2);
454
+ const y0 = clamp(vp.y * fit.s + fit.oy, -2, h + 2);
455
+ const x1 = clamp((vp.x + vp.width) * fit.s + fit.ox, -2, w + 2);
456
+ const y1 = clamp((vp.y + vp.height) * fit.s + fit.oy, -2, h + 2);
457
+ if (x1 - x0 > 0.5 && y1 - y0 > 0.5) {
458
+ const snapPx = (v: number): number => Math.round(v * dpr) / dpr;
459
+ const rx = snapPx(x0);
460
+ const ry = snapPx(y0);
461
+ const rw = Math.max(2, snapPx(x1) - rx);
462
+ const rh = Math.max(2, snapPx(y1) - ry);
463
+ ctx.globalAlpha = 1;
464
+ ctx.fillStyle = this.theme.accent.soft;
465
+ ctx.fillRect(rx, ry, rw, rh);
466
+ ctx.strokeStyle = this.theme.accent.base;
467
+ ctx.lineWidth = 1.25;
468
+ ctx.setLineDash([]);
469
+ ctx.beginPath();
470
+ ctx.rect(rx + 0.625, ry + 0.625, rw - 1.25, rh - 1.25);
471
+ ctx.stroke();
472
+ }
473
+ }
474
+ ctx.restore();
475
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
476
+ ctx.globalAlpha = 1;
477
+ ctx.strokeStyle = this.theme.surface.border;
478
+ ctx.lineWidth = 1;
479
+ ctx.beginPath();
480
+ roundRectPath(ctx, 0.5, 0.5, w - 1, h - 1, RADIUS - 0.5);
481
+ ctx.stroke();
482
+ }
483
+
484
+ private toWorld(e: PointerEvent): { x: number; y: number } | null {
485
+ const fit = this.fit;
486
+ if (!fit) return null;
487
+ const r = this.canvas.getBoundingClientRect();
488
+ const mx = e.clientX - r.left;
489
+ const my = e.clientY - r.top;
490
+ return { x: (mx - fit.ox) / fit.s, y: (my - fit.oy) / fit.s };
491
+ }
492
+
493
+ private onDown(e: PointerEvent): void {
494
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
495
+ const p = this.toWorld(e);
496
+ if (!p) return;
497
+ e.preventDefault();
498
+ try {
499
+ this.canvas.setPointerCapture(e.pointerId);
500
+ } catch {
501
+ // Best effort.
502
+ }
503
+ const cam = this.renderer.camera;
504
+ const s = cam.get();
505
+ const vp = cam.viewport();
506
+ const inside = p.x >= vp.x && p.x <= vp.x + vp.width && p.y >= vp.y && p.y <= vp.y + vp.height;
507
+ // Grabbing the rectangle keeps the grab offset; clicking elsewhere centres the view there.
508
+ this.drag = inside
509
+ ? { id: e.pointerId, dx: s.x - p.x, dy: s.y - p.y }
510
+ : { id: e.pointerId, dx: 0, dy: 0 };
511
+ if (!inside) this.move({ x: p.x, y: p.y, k: s.k });
512
+ }
513
+
514
+ /** Navigate on the user's behalf (the `camera` event reports source 'user'). */
515
+ private move(state: { x: number; y: number; k: number }): void {
516
+ const host = internals.get(this.renderer);
517
+ if (host) host.userSet(state);
518
+ else this.renderer.camera.set(state);
519
+ }
520
+
521
+ private onMove(e: PointerEvent): void {
522
+ const drag = this.drag;
523
+ if (!drag || drag.id !== e.pointerId) return;
524
+ const p = this.toWorld(e);
525
+ if (!p) return;
526
+ this.move({ x: p.x + drag.dx, y: p.y + drag.dy, k: this.renderer.camera.get().k });
527
+ }
528
+
529
+ private onUp(e: PointerEvent): void {
530
+ if (this.drag?.id !== e.pointerId) return;
531
+ this.drag = null;
532
+ // The window was frozen during the drag: catch up with the viewport now.
533
+ this.refit(false);
534
+ this.redraw();
535
+ }
536
+ }
537
+
538
+ export function createMinimapImpl(
539
+ container: HTMLElement,
540
+ renderer: Renderer,
541
+ options: MinimapOptions = {},
542
+ deps: MinimapDeps = {},
543
+ ): MinimapImpl {
544
+ return new CanvasMinimap(container, renderer, options, deps);
545
+ }
@@ -0,0 +1,38 @@
1
+ /** Counters filled by each paint (tests, the dev harness readout). */
2
+ export interface PaintStats {
3
+ nodes: number;
4
+ edges: number;
5
+ culledNodes: number;
6
+ culledEdges: number;
7
+ labels: number;
8
+ /** `stroke()` calls issued for edges (one per non-empty style bucket). */
9
+ edgeStrokes: number;
10
+ /** Labels placed but not yet rasterized (budget); the renderer schedules another frame. */
11
+ deferredLabels: number;
12
+ /** Passes in draw order, per layer. */
13
+ readonly phases: string[];
14
+ }
15
+
16
+ export function createStats(): PaintStats {
17
+ return {
18
+ nodes: 0,
19
+ edges: 0,
20
+ culledNodes: 0,
21
+ culledEdges: 0,
22
+ labels: 0,
23
+ edgeStrokes: 0,
24
+ deferredLabels: 0,
25
+ phases: [],
26
+ };
27
+ }
28
+
29
+ export function resetStats(s: PaintStats): void {
30
+ s.nodes = 0;
31
+ s.edges = 0;
32
+ s.culledNodes = 0;
33
+ s.culledEdges = 0;
34
+ s.labels = 0;
35
+ s.edgeStrokes = 0;
36
+ s.deferredLabels = 0;
37
+ s.phases.length = 0;
38
+ }