@celestia-island/hikari 0.55.2 → 0.55.4

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.
@@ -0,0 +1,1053 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, isRef, nextTick, onUpdated, ref, type App } from "vue";
3
+
4
+ import HkNodeCanvas, { NODE_CANVAS_DEFAULTS } from "./HkNodeCanvas";
5
+
6
+ /**
7
+ * HkNodeCanvas tests — the camera protocol the three node surfaces share:
8
+ * fit-before-first-paint, grid-snapped zoom that keeps the point under the
9
+ * cursor fixed, panning, the coordinate round trip, and the minimap corner.
10
+ */
11
+
12
+ interface Instance {
13
+ camera: { k: number; x: number; y: number };
14
+ viewport: { width: number; height: number };
15
+ fit: () => boolean;
16
+ zoomBy: (direction: 1 | -1, at?: { x: number; y: number }) => void;
17
+ panBy: (dx: number, dy: number) => void;
18
+ screenToWorld: (p: { x: number; y: number }) => { x: number; y: number };
19
+ worldToScreen: (p: { x: number; y: number }) => { x: number; y: number };
20
+ }
21
+
22
+ interface Bounds {
23
+ x: number;
24
+ y: number;
25
+ width: number;
26
+ height: number;
27
+ }
28
+
29
+ /** A camera as a test's store sees it. */
30
+ interface Cam {
31
+ k: number;
32
+ x: number;
33
+ y: number;
34
+ }
35
+
36
+ const apps: App[] = [];
37
+ let originalRect: typeof HTMLElement.prototype.getBoundingClientRect;
38
+ const VIEWPORT = { width: 800, height: 600 };
39
+ const BOUNDS = { x: 0, y: 0, width: 400, height: 200 };
40
+
41
+ /** What the mocked `getBoundingClientRect` reports; 0×0 stands for "not laid out yet". */
42
+ let viewportRect = { ...VIEWPORT };
43
+ /** Where the canvas sits on the page, so a test can offset the root rect. */
44
+ let viewportOffset = { left: 0, top: 0 };
45
+ /** Live ResizeObserver callbacks, so a test can play the "tab became visible" beat. */
46
+ let resizeCallbacks: Array<() => void> = [];
47
+
48
+ function triggerResize() {
49
+ for (const callback of resizeCallbacks) callback();
50
+ }
51
+
52
+ beforeEach(() => {
53
+ viewportRect = { ...VIEWPORT };
54
+ viewportOffset = { left: 0, top: 0 };
55
+ resizeCallbacks = [];
56
+ originalRect = HTMLElement.prototype.getBoundingClientRect;
57
+ HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
58
+ if (this.classList?.contains("hk-node-canvas")) {
59
+ return {
60
+ width: viewportRect.width,
61
+ height: viewportRect.height,
62
+ top: viewportOffset.top,
63
+ left: viewportOffset.left,
64
+ right: viewportOffset.left + viewportRect.width,
65
+ bottom: viewportOffset.top + viewportRect.height,
66
+ x: viewportOffset.left,
67
+ y: viewportOffset.top,
68
+ toJSON: () => ({}),
69
+ } as DOMRect;
70
+ }
71
+ return originalRect.call(this);
72
+ };
73
+ (globalThis as { ResizeObserver?: unknown }).ResizeObserver = class {
74
+ constructor(callback: () => void) {
75
+ resizeCallbacks.push(callback);
76
+ }
77
+ observe(): void {}
78
+ disconnect(): void {}
79
+ unobserve(): void {}
80
+ };
81
+ });
82
+
83
+ afterEach(() => {
84
+ for (const app of apps.splice(0)) app.unmount();
85
+ HTMLElement.prototype.getBoundingClientRect = originalRect;
86
+ });
87
+
88
+ function mount(props: Record<string, unknown>, slots: Record<string, unknown> = {}) {
89
+ const container = document.createElement("div");
90
+ document.body.appendChild(container);
91
+ const instance = ref<Instance | null>(null);
92
+ const Root = defineComponent({
93
+ setup() {
94
+ return () => {
95
+ // Refs are read inside the render, so a test can change a prop (a graph
96
+ // arriving after mount) and watch the component react to it.
97
+ const resolved: Record<string, unknown> = {};
98
+ for (const [key, value] of Object.entries(props)) {
99
+ resolved[key] = isRef(value) ? value.value : value;
100
+ }
101
+ return h(HkNodeCanvas, { ref: instance as never, ...resolved }, slots);
102
+ };
103
+ },
104
+ });
105
+ const app = createApp(Root);
106
+ app.mount(container);
107
+ apps.push(app);
108
+ return {
109
+ container,
110
+ instance,
111
+ root: container.querySelector<HTMLElement>(".hk-node-canvas")!,
112
+ };
113
+ }
114
+
115
+ /** A pointer event, whatever the environment's constructor support is. */
116
+ function pointerEvent(
117
+ type: string,
118
+ init: { pointerId: number; clientX: number; clientY: number; buttons?: number },
119
+ ): Event {
120
+ const Ctor = (globalThis as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
121
+ // A real drag carries the held button on every move; a hover carries none.
122
+ const buttons = init.buttons ?? (type === "pointerup" ? 0 : 1);
123
+ const event = new Ctor(type, { bubbles: true, cancelable: true, button: 0, buttons });
124
+ for (const [key, value] of Object.entries({ ...init, buttons })) {
125
+ Object.defineProperty(event, key, { value, configurable: true });
126
+ }
127
+ return event;
128
+ }
129
+
130
+ /**
131
+ * happy-dom's WheelEvent drops clientX/clientY (its MouseEvent does not), so
132
+ * the cursor position has to be attached by hand for the cursor-anchored wheel
133
+ * path to be exercised at all.
134
+ */
135
+ function wheelEvent(init: { deltaY: number; clientX: number; clientY: number }): WheelEvent {
136
+ const event = new WheelEvent("wheel", { deltaY: init.deltaY, bubbles: true, cancelable: true });
137
+ Object.defineProperty(event, "clientX", { value: init.clientX, configurable: true });
138
+ Object.defineProperty(event, "clientY", { value: init.clientY, configurable: true });
139
+ return event;
140
+ }
141
+
142
+ describe("HkNodeCanvas", () => {
143
+ it("paints the FITTED camera on the very first frame", async () => {
144
+ // The bug this exists to prevent: a large graph starting at 1:1 and
145
+ // snapping into place a frame later. The bounds here are far wider than
146
+ // the viewport, so fitting must shrink — and it must have happened by the
147
+ // time the first render is inspected.
148
+ const wide = { x: 0, y: 0, width: 4000, height: 2000 };
149
+ const { container, instance } = mount({ contentBounds: wide, fitOnLoad: true });
150
+ await nextTick();
151
+ const cam = instance.value?.camera;
152
+ expect(cam).toBeTruthy();
153
+ expect(cam!.k).toBeLessThan(1);
154
+ expect(cam!.k).toBe(NODE_CANVAS_DEFAULTS.minZoom);
155
+
156
+ const layer = container.querySelector<HTMLElement>(".hk-node-canvas-layer");
157
+ expect(layer?.style.transform).toContain(`scale(${cam!.k})`);
158
+ });
159
+
160
+ it("uses the whole grid step when the ideal factor lands exactly on the grid", async () => {
161
+ // 720 / 1200 is exactly 0.6, twelve grid steps — but in binary it is
162
+ // 11.999999999999998 steps, so a bare floor throws a whole step away and
163
+ // frames the content 8% smaller than the padding budget allows.
164
+ const { instance } = mount({
165
+ contentBounds: { x: 0, y: 0, width: 1200, height: 800 },
166
+ fitOnLoad: true,
167
+ });
168
+ await nextTick();
169
+ expect(instance.value!.camera.k).toBe(0.6);
170
+ });
171
+
172
+ it("does not keep a pan armed after a release that happens off the canvas", async () => {
173
+ // The gesture is only captured once it passes the slop, so a press near the
174
+ // edge that drifts out and releases outside never sends this element a
175
+ // pointerup. A later hover must not move the camera on its own.
176
+ const { instance, root } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
177
+ await nextTick();
178
+ const capture = vi.fn();
179
+ root.setPointerCapture = capture;
180
+
181
+ root.dispatchEvent(pointerEvent("pointerdown", { pointerId: 11, clientX: 100, clientY: 100 }));
182
+ root.dispatchEvent(pointerEvent("pointermove", { pointerId: 11, clientX: 102, clientY: 101 }));
183
+ document.body.dispatchEvent(
184
+ pointerEvent("pointerup", { pointerId: 11, clientX: 102, clientY: 101, buttons: 0 }),
185
+ );
186
+
187
+ root.dispatchEvent(
188
+ pointerEvent("pointermove", { pointerId: 11, clientX: 300, clientY: 260, buttons: 0 }),
189
+ );
190
+ expect(capture).not.toHaveBeenCalled();
191
+ expect(instance.value!.camera).toEqual({ k: 1, x: 0, y: 0 });
192
+ });
193
+
194
+ it("leaves the pan to the host when the host claims the pointerdown", async () => {
195
+ // Node dragging is the core gesture of every consumer: a card that prevents
196
+ // the default on its own pointerdown keeps the pan — and the root capture
197
+ // that would cut its drag short — out of the way.
198
+ const { instance, root } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
199
+ await nextTick();
200
+ const card = document.createElement("div");
201
+ card.addEventListener("pointerdown", (event) => event.preventDefault());
202
+ root.appendChild(card);
203
+ const capture = vi.fn();
204
+ root.setPointerCapture = capture;
205
+
206
+ const press = pointerEvent("pointerdown", { pointerId: 12, clientX: 100, clientY: 100 });
207
+ card.dispatchEvent(press);
208
+ expect(press.defaultPrevented).toBe(true);
209
+ root.dispatchEvent(pointerEvent("pointermove", { pointerId: 12, clientX: 160, clientY: 100 }));
210
+ expect(capture).not.toHaveBeenCalled();
211
+ expect(instance.value!.camera).toEqual({ k: 1, x: 0, y: 0 });
212
+ });
213
+
214
+ it("does not loop when the host writes the emitted camera back", async () => {
215
+ // The natural host spelling: `v-model:camera` plus a bounds object literal,
216
+ // which is a fresh identity on every render. Framing has to settle instead
217
+ // of emitting, re-rendering and framing again for ever.
218
+ const camera = ref({ k: 1, x: 0, y: 0 });
219
+ let renders = 0;
220
+ let emits = 0;
221
+ const instance = ref<Instance | null>(null);
222
+ const container = document.createElement("div");
223
+ document.body.appendChild(container);
224
+ const Root = defineComponent({
225
+ setup() {
226
+ return () => {
227
+ renders++;
228
+ return h(HkNodeCanvas, {
229
+ ref: instance as never,
230
+ contentBounds: { x: 0, y: 0, width: BOUNDS.width, height: BOUNDS.height },
231
+ camera: camera.value,
232
+ "onUpdate:camera": (next: { k: number; x: number; y: number }) => {
233
+ emits++;
234
+ camera.value = next;
235
+ },
236
+ });
237
+ };
238
+ },
239
+ });
240
+ const app = createApp(Root);
241
+ app.mount(container);
242
+ apps.push(app);
243
+
244
+ for (let frame = 0; frame < 5; frame++) await nextTick();
245
+ expect(renders).toBeLessThan(10);
246
+ // The host owns a controlled camera: automatic framing never touches it.
247
+ expect(camera.value).toEqual({ k: 1, x: 0, y: 0 });
248
+
249
+ // An imperative fit frames it once; the second one has nothing to change,
250
+ // so it must stay silent instead of emitting the same camera again.
251
+ instance.value?.fit();
252
+ await nextTick();
253
+ const afterFirst = emits;
254
+ expect(afterFirst).toBeGreaterThan(0);
255
+ instance.value?.fit();
256
+ instance.value?.fit();
257
+ await nextTick();
258
+ expect(emits).toBe(afterFirst);
259
+ });
260
+
261
+ it("does not blow a small graph up past the fit cap", async () => {
262
+ // Zooming in is the user's call, not the initial frame's.
263
+ const { instance } = mount({ contentBounds: BOUNDS, fitOnLoad: true });
264
+ await nextTick();
265
+ expect(instance.value!.camera.k).toBe(NODE_CANVAS_DEFAULTS.fitCap);
266
+ });
267
+
268
+ it("snaps zoom to the grid and keeps it in range", async () => {
269
+ const { instance } = mount({ contentBounds: BOUNDS });
270
+ await nextTick();
271
+ instance.value?.fit();
272
+ const before = instance.value!.camera.k;
273
+ instance.value?.zoomBy(1);
274
+ const after = instance.value!.camera.k;
275
+ expect(after).toBeGreaterThan(before);
276
+ // A grid-snapped value is a whole number of steps.
277
+ expect(Math.abs(after / NODE_CANVAS_DEFAULTS.zoomGridStep - Math.round(after / NODE_CANVAS_DEFAULTS.zoomGridStep))).toBeLessThan(1e-6);
278
+ expect(after).toBeLessThanOrEqual(NODE_CANVAS_DEFAULTS.maxZoom);
279
+ });
280
+
281
+ it("keeps the point under the cursor fixed while zooming", async () => {
282
+ const { instance } = mount({ contentBounds: BOUNDS });
283
+ await nextTick();
284
+ instance.value?.fit();
285
+ const at = { x: 120, y: 90 };
286
+ const worldBefore = instance.value!.screenToWorld(at);
287
+ instance.value?.zoomBy(1, at);
288
+ const worldAfter = instance.value!.screenToWorld(at);
289
+ expect(worldAfter.x).toBeCloseTo(worldBefore.x, 6);
290
+ expect(worldAfter.y).toBeCloseTo(worldBefore.y, 6);
291
+ });
292
+
293
+ it("pans without changing the zoom", async () => {
294
+ const { instance } = mount({ contentBounds: BOUNDS });
295
+ await nextTick();
296
+ instance.value?.fit();
297
+ const before = { ...instance.value!.camera };
298
+ instance.value?.panBy(30, -20);
299
+ const after = instance.value!.camera;
300
+ expect(after.k).toBe(before.k);
301
+ expect(after.x).toBeCloseTo(before.x + 30, 6);
302
+ expect(after.y).toBeCloseTo(before.y - 20, 6);
303
+ });
304
+
305
+ it("round-trips a point through screen and world space", async () => {
306
+ const { instance } = mount({ contentBounds: BOUNDS });
307
+ await nextTick();
308
+ instance.value?.fit();
309
+ const screen = { x: 333, y: 222 };
310
+ const back = instance.value!.worldToScreen(instance.value!.screenToWorld(screen));
311
+ expect(back.x).toBeCloseTo(screen.x, 6);
312
+ expect(back.y).toBeCloseTo(screen.y, 6);
313
+ });
314
+
315
+ it("ignores the wheel when zooming is off and the drag when panning is off", async () => {
316
+ // zoomBy/panBy are the imperative API and stay available to the host; the
317
+ // flags gate the GESTURES, so this has to dispatch real events.
318
+ const { instance, root } = mount({
319
+ contentBounds: BOUNDS,
320
+ zoomable: false,
321
+ pannable: false,
322
+ fitOnLoad: false,
323
+ });
324
+ await nextTick();
325
+ const before = { ...instance.value!.camera };
326
+
327
+ const wheel = wheelEvent({ deltaY: -100, clientX: 400, clientY: 300 });
328
+ root.dispatchEvent(wheel);
329
+ // A surface that cannot zoom must not swallow the page scroll either.
330
+ expect(wheel.defaultPrevented).toBe(false);
331
+ expect(instance.value!.camera).toEqual(before);
332
+
333
+ const capture = vi.fn();
334
+ root.setPointerCapture = capture;
335
+ root.dispatchEvent(pointerEvent("pointerdown", { pointerId: 1, clientX: 100, clientY: 100 }));
336
+ root.dispatchEvent(pointerEvent("pointermove", { pointerId: 1, clientX: 160, clientY: 100 }));
337
+ root.dispatchEvent(pointerEvent("pointerup", { pointerId: 1, clientX: 160, clientY: 100 }));
338
+ expect(capture).not.toHaveBeenCalled();
339
+ expect(instance.value!.camera).toEqual(before);
340
+ expect(root.hasAttribute("data-pannable")).toBe(false);
341
+ // Nothing consumes the gesture, so the page keeps its touch scrolling.
342
+ expect(root.style.touchAction).toBe("");
343
+
344
+ const on = mount({ contentBounds: BOUNDS, fitOnLoad: false });
345
+ await nextTick();
346
+ expect(on.root.hasAttribute("data-pannable")).toBe(true);
347
+ expect(on.root.style.touchAction).toBe("none");
348
+ });
349
+
350
+ it("zooms towards the cursor on the wheel", async () => {
351
+ const { instance, root } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
352
+ await nextTick();
353
+ const at = { x: 600, y: 150 };
354
+ const worldBefore = instance.value!.screenToWorld(at);
355
+ const wheel = wheelEvent({ deltaY: -100, clientX: at.x, clientY: at.y });
356
+ root.dispatchEvent(wheel);
357
+ expect(wheel.defaultPrevented).toBe(true);
358
+ expect(instance.value!.camera.k).toBeGreaterThan(1);
359
+ const worldAfter = instance.value!.screenToWorld(at);
360
+ expect(worldAfter.x).toBeCloseTo(worldBefore.x, 6);
361
+ expect(worldAfter.y).toBeCloseTo(worldBefore.y, 6);
362
+ });
363
+
364
+ it("centres content whose bounds do not start at the origin", async () => {
365
+ // Every bounds fixture with x = y = 0 lets a fit that forgets the
366
+ // `- bounds.x * k` term pass unnoticed; this one does not.
367
+ const offset = { x: 1000, y: 500, width: 400, height: 200 };
368
+ const { instance } = mount({ contentBounds: offset, fitOnLoad: true });
369
+ await nextTick();
370
+ const cam = instance.value!.camera;
371
+ const topLeft = instance.value!.worldToScreen({ x: offset.x, y: offset.y });
372
+ const bottomRight = instance.value!.worldToScreen({
373
+ x: offset.x + offset.width,
374
+ y: offset.y + offset.height,
375
+ });
376
+ expect(topLeft.x).toBeCloseTo(VIEWPORT.width - bottomRight.x, 6);
377
+ expect(topLeft.y).toBeCloseTo(VIEWPORT.height - bottomRight.y, 6);
378
+ expect(topLeft.x).toBeCloseTo((VIEWPORT.width - offset.width * cam.k) / 2, 6);
379
+ expect(topLeft.y).toBeCloseTo((VIEWPORT.height - offset.height * cam.k) / 2, 6);
380
+ });
381
+
382
+ it("fits off-grid bounds inside the padding budget", async () => {
383
+ // 520 / 1571 is 0.331, six and a bit grid steps. Rounding that to seven
384
+ // renders at 0.35 and pushes the content past the edge, so the fit has to
385
+ // snap DOWN — and derive the translation from the scale it actually
386
+ // renders. The floor also leaves binary dust (6 * 0.05), which a camera
387
+ // shared with the host must not carry.
388
+ const tall = { x: 0, y: 0, width: 100, height: 1571 };
389
+ const { instance } = mount({ contentBounds: tall, fitOnLoad: true });
390
+ await nextTick();
391
+ expect(instance.value!.camera.k).toBe(0.3);
392
+
393
+ const topLeft = instance.value!.worldToScreen({ x: 0, y: 0 });
394
+ const bottomRight = instance.value!.worldToScreen({ x: tall.width, y: tall.height });
395
+ const pad = NODE_CANVAS_DEFAULTS.fitPadding - 1e-6;
396
+ expect(topLeft.x).toBeGreaterThanOrEqual(pad);
397
+ expect(topLeft.y).toBeGreaterThanOrEqual(pad);
398
+ expect(VIEWPORT.width - bottomRight.x).toBeGreaterThanOrEqual(pad);
399
+ expect(VIEWPORT.height - bottomRight.y).toBeGreaterThanOrEqual(pad);
400
+ });
401
+
402
+ it("moves the zoom on every wheel tick, including where a grid step is coarser than the factor", async () => {
403
+ // 0.25 * 1.05 = 0.2625 is less than half a grid step away from where it
404
+ // started, so multiplying and snapping rounds straight back and the wheel
405
+ // dies in the whole band below k = 0.5 — which is exactly where a large
406
+ // graph lands when it is fitted.
407
+ const large = { x: 0, y: 0, width: 2880, height: 2080 };
408
+ const { instance } = mount({ contentBounds: large, fitOnLoad: true });
409
+ await nextTick();
410
+ expect(instance.value!.camera.k).toBe(0.25);
411
+
412
+ let previous = instance.value!.camera.k;
413
+ for (let tick = 0; tick < 8; tick++) {
414
+ instance.value?.zoomBy(1);
415
+ const next = instance.value!.camera.k;
416
+ expect(next).toBeGreaterThan(previous);
417
+ previous = next;
418
+ }
419
+
420
+ instance.value?.fit();
421
+ let ticks = 0;
422
+ while (instance.value!.camera.k > NODE_CANVAS_DEFAULTS.minZoom) {
423
+ const before = instance.value!.camera.k;
424
+ instance.value?.zoomBy(-1);
425
+ const after = instance.value!.camera.k;
426
+ expect(after).toBeLessThan(before);
427
+ previous = after;
428
+ if (++ticks > 60) break;
429
+ }
430
+ expect(previous).toBe(NODE_CANVAS_DEFAULTS.minZoom);
431
+ });
432
+
433
+ it("captures the pointer only once a drag is real, so card clicks survive", async () => {
434
+ // Capturing on pointerdown retargets the gesture's click to the capturing
435
+ // element, and clicks on node cards stop reaching their handlers.
436
+ const { instance, root } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
437
+ await nextTick();
438
+ const capture = vi.fn();
439
+ root.setPointerCapture = capture;
440
+ root.releasePointerCapture = vi.fn();
441
+
442
+ root.dispatchEvent(pointerEvent("pointerdown", { pointerId: 7, clientX: 100, clientY: 100 }));
443
+ root.dispatchEvent(pointerEvent("pointermove", { pointerId: 7, clientX: 103, clientY: 102 }));
444
+ root.dispatchEvent(pointerEvent("pointerup", { pointerId: 7, clientX: 103, clientY: 102 }));
445
+ expect(capture).not.toHaveBeenCalled();
446
+ expect(instance.value!.camera).toEqual({ k: 1, x: 0, y: 0 });
447
+
448
+ root.dispatchEvent(pointerEvent("pointerdown", { pointerId: 8, clientX: 100, clientY: 100 }));
449
+ root.dispatchEvent(pointerEvent("pointermove", { pointerId: 8, clientX: 110, clientY: 100 }));
450
+ expect(capture).toHaveBeenCalledWith(8);
451
+ root.dispatchEvent(pointerEvent("pointerup", { pointerId: 8, clientX: 110, clientY: 100 }));
452
+ expect(instance.value!.camera.x).toBeCloseTo(10, 6);
453
+ });
454
+
455
+ it("does not throw away a camera the user already moved when the graph arrives late", async () => {
456
+ const bounds = ref<Bounds | undefined>(undefined);
457
+ const { instance } = mount({ contentBounds: bounds, fitOnLoad: true });
458
+ await nextTick();
459
+ instance.value?.panBy(30, 20);
460
+ const moved = { ...instance.value!.camera };
461
+ expect(moved.x).toBe(30);
462
+
463
+ bounds.value = BOUNDS;
464
+ await nextTick();
465
+ expect(instance.value!.camera).toEqual(moved);
466
+ });
467
+
468
+ it("still frames a late graph when nobody has touched the camera", async () => {
469
+ const bounds = ref<Bounds | undefined>(undefined);
470
+ const { instance } = mount({ contentBounds: bounds, fitOnLoad: true });
471
+ await nextTick();
472
+ expect(instance.value!.camera.k).toBe(1);
473
+
474
+ bounds.value = BOUNDS;
475
+ await nextTick();
476
+ expect(instance.value!.camera.k).toBe(NODE_CANVAS_DEFAULTS.fitCap);
477
+
478
+ // A graph that keeps growing is still followed: framing the content is not
479
+ // the user taking over, so it must not switch the automatic fit off.
480
+ bounds.value = { x: 0, y: 0, width: 2880, height: 2080 };
481
+ await nextTick();
482
+ expect(instance.value!.camera.k).toBe(0.25);
483
+ });
484
+
485
+ it("frames the graph once a canvas mounted inside a hidden tab becomes measurable", async () => {
486
+ viewportRect = { width: 0, height: 0 };
487
+ const { instance } = mount({ contentBounds: BOUNDS, fitOnLoad: true });
488
+ await nextTick();
489
+ expect(instance.value!.camera).toEqual({ k: 1, x: 0, y: 0 });
490
+
491
+ viewportRect = { ...VIEWPORT };
492
+ triggerResize();
493
+ await nextTick();
494
+ expect(instance.value!.camera.k).toBe(NODE_CANVAS_DEFAULTS.fitCap);
495
+ });
496
+
497
+ it("does not re-frame on reveal if the user moved the camera while it was hidden", async () => {
498
+ viewportRect = { width: 0, height: 0 };
499
+ const { instance } = mount({ contentBounds: BOUNDS, fitOnLoad: true });
500
+ await nextTick();
501
+ instance.value?.panBy(25, 5);
502
+
503
+ viewportRect = { ...VIEWPORT };
504
+ triggerResize();
505
+ await nextTick();
506
+ expect(instance.value!.camera).toEqual({ k: 1, x: 25, y: 5 });
507
+ });
508
+
509
+ it("leaves a controlled camera alone when the bounds cannot describe a fit", async () => {
510
+ const onUpdate = vi.fn();
511
+ const { instance } = mount({
512
+ contentBounds: { x: 0, y: 0, width: 0, height: 0 },
513
+ camera: { k: 0.123456, x: 5, y: 6 },
514
+ fitOnLoad: true,
515
+ "onUpdate:camera": onUpdate,
516
+ });
517
+ await nextTick();
518
+ instance.value?.fit();
519
+ expect(onUpdate).not.toHaveBeenCalled();
520
+ expect(instance.value!.camera).toEqual({ k: 0.123456, x: 5, y: 6 });
521
+ });
522
+
523
+ it("mounts the minimap by default and takes it away when asked", async () => {
524
+ const withMap = mount({ contentBounds: BOUNDS }, { minimap: () => h("div", { class: "map" }, "map") });
525
+ await nextTick();
526
+ const holder = withMap.container.querySelector(".hk-node-canvas-minimap");
527
+ expect(holder?.getAttribute("data-placement")).toBe("bottom-right");
528
+ expect(holder?.querySelector(".map")).toBeTruthy();
529
+
530
+ const without = mount({ contentBounds: BOUNDS, minimap: false });
531
+ await nextTick();
532
+ expect(without.container.querySelector(".hk-node-canvas-minimap")).toBeNull();
533
+ });
534
+
535
+ it("frames a graph too large for one grid step at the un-quantised ideal", async () => {
536
+ // A fit that floors to zero is not a scale anything can be drawn at, and
537
+ // refusing to frame would leave the graph at 1:1. Raising it to a whole
538
+ // step instead would over-zoom — by an order of magnitude here.
539
+ const huge = { x: 0, y: 0, width: 100000, height: 80000 };
540
+ const { instance } = mount({ contentBounds: huge, minZoom: 0, fitOnLoad: true });
541
+ await nextTick();
542
+ expect(instance.value!.camera.k).toBe(0.0065);
543
+
544
+ // ... and it stays inside the cap the host configured.
545
+ const capped = mount({
546
+ contentBounds: { x: 0, y: 0, width: 100000, height: 100000 },
547
+ minZoom: 0.01,
548
+ fitCap: 0.02,
549
+ fitOnLoad: true,
550
+ });
551
+ await nextTick();
552
+ expect(capped.instance.value!.camera.k).toBe(0.01);
553
+ });
554
+
555
+ it("answers an explicit fit after the host moved the camera itself", async () => {
556
+ // A restored viewport or a minimap jump moves the controlled camera with no
557
+ // gesture; a later fit() is the host asking to be framed and has to work.
558
+ const camera = ref({ k: 1, x: 0, y: 0 });
559
+ const { instance } = mount({
560
+ contentBounds: BOUNDS,
561
+ camera,
562
+ "onUpdate:camera": (next: { k: number; x: number; y: number }) => {
563
+ camera.value = next;
564
+ },
565
+ });
566
+ await nextTick();
567
+ expect(instance.value!.fit()).toBe(true);
568
+ await nextTick();
569
+ expect(camera.value.x).toBe(150);
570
+
571
+ camera.value = { k: 1.25, x: 250, y: 175 };
572
+ await nextTick();
573
+ expect(instance.value!.fit()).toBe(true);
574
+ await nextTick();
575
+ expect(camera.value.x).toBe(150);
576
+ });
577
+
578
+ it("re-frames when the host restores the camera it had before the last fit", async () => {
579
+ // A "reset view" writes exactly the camera that was live when the component
580
+ // asked, so "the host moved" cannot be decided by looking only at the value
581
+ // it moved back to.
582
+ const camera = ref({ k: 1, x: 0, y: 0 });
583
+ const { instance } = mount({
584
+ contentBounds: BOUNDS,
585
+ camera,
586
+ "onUpdate:camera": (next: { k: number; x: number; y: number }) => {
587
+ camera.value = next;
588
+ },
589
+ });
590
+ await nextTick();
591
+ instance.value?.fit();
592
+ await nextTick();
593
+ expect(camera.value.k).toBe(NODE_CANVAS_DEFAULTS.fitCap);
594
+
595
+ camera.value = { k: 1, x: 0, y: 0 };
596
+ await nextTick();
597
+ expect(instance.value!.fit()).toBe(true);
598
+ await nextTick();
599
+ expect(camera.value.k).toBe(NODE_CANVAS_DEFAULTS.fitCap);
600
+ });
601
+
602
+ it("keeps panning with the pointer when the host writes back late", async () => {
603
+ // A throttled mirror means the `camera` prop lags: composing the drag from
604
+ // it would make the surface trail the pointer by however much is in flight.
605
+ const onUpdate = vi.fn();
606
+ const { instance } = mount({
607
+ contentBounds: BOUNDS,
608
+ camera: { k: 1, x: 0, y: 0 },
609
+ fitOnLoad: false,
610
+ "onUpdate:camera": onUpdate,
611
+ });
612
+ await nextTick();
613
+ instance.value?.panBy(30, 0);
614
+ instance.value?.panBy(30, 0);
615
+ expect(onUpdate).toHaveBeenLastCalledWith({ k: 1, x: 60, y: 0 });
616
+ });
617
+
618
+ it("keeps the drag on the newest request when the store echoes older ones", async () => {
619
+ // A store behind an async transport (an RPC, a queue) echoes every write, in
620
+ // order, a beat later — so the camera it publishes mid-drag belongs to an
621
+ // OLDER request than the pointer has already made. Reading that echo as the
622
+ // answer steps the surface backwards and throws most of the drag away (a
623
+ // 300 px drag moved the view 100 px): the gesture has to stay on the newest
624
+ // request until the host answers *that* one.
625
+ const LAG = 6; // ~100 ms of a 16 ms-per-move drag
626
+ const asked: Cam[] = [];
627
+ const queue: Cam[] = [];
628
+ const camera = ref<Cam>({ k: 1, x: 0, y: 0 });
629
+ const { root } = mount({
630
+ contentBounds: BOUNDS,
631
+ camera,
632
+ fitOnLoad: false,
633
+ "onUpdate:camera": (next: Cam) => {
634
+ asked.push(next);
635
+ queue.push(next);
636
+ },
637
+ });
638
+ await nextTick();
639
+ root.setPointerCapture = vi.fn();
640
+
641
+ const echoed: number[] = [];
642
+ root.dispatchEvent(pointerEvent("pointerdown", { pointerId: 41, clientX: 100, clientY: 100 }));
643
+ for (let step = 1; step <= 24; step++) {
644
+ root.dispatchEvent(
645
+ pointerEvent("pointermove", { pointerId: 41, clientX: 100 + step * 12.5, clientY: 100 }),
646
+ );
647
+ // One echo per move, oldest first: the host walks its queue behind us.
648
+ if (queue.length > LAG) camera.value = queue.shift()!;
649
+ await nextTick();
650
+ echoed.push(camera.value.x);
651
+ }
652
+
653
+ // The component's own asks never step backwards...
654
+ expect(asked.map((c) => c.x)).toEqual([...asked.map((c) => c.x)].sort((a, b) => a - b));
655
+ // ...and the drag lands where the pointer went, not where the echo was.
656
+ expect(asked[asked.length - 1].x).toBeCloseTo(300, 6);
657
+ // The host's own value drains to the same place, still in order.
658
+ while (queue.length) camera.value = queue.shift()!;
659
+ await nextTick();
660
+ expect(camera.value.x).toBeCloseTo(300, 6);
661
+ expect(echoed).toEqual([...echoed].sort((a, b) => a - b));
662
+ });
663
+
664
+ it("re-anchors the gesture to the camera the host actually kept", async () => {
665
+ // A host with a pan limit (the content has to keep covering the viewport)
666
+ // clamps what it is given. Composing the way back from the request instead
667
+ // of from the clamp makes the pointer travel the whole overshoot again
668
+ // before anything moves: an answer to the NEWEST request is where the
669
+ // surface is, and the gesture has to build on it.
670
+ const camera = ref<Cam>({ k: 1, x: 0, y: 0 });
671
+ const { instance, root } = mount({
672
+ contentBounds: BOUNDS,
673
+ camera,
674
+ fitOnLoad: false,
675
+ "onUpdate:camera": (next: Cam) => {
676
+ camera.value = { ...next, x: Math.min(0, next.x) };
677
+ },
678
+ });
679
+ await nextTick();
680
+ root.setPointerCapture = vi.fn();
681
+
682
+ const drag = async (pointerId: number, fromX: number, toX: number) => {
683
+ root.dispatchEvent(pointerEvent("pointerdown", { pointerId, clientX: fromX, clientY: 300 }));
684
+ for (let step = 1; step <= 8; step++) {
685
+ root.dispatchEvent(
686
+ pointerEvent("pointermove", {
687
+ pointerId,
688
+ clientX: fromX + ((toX - fromX) * step) / 8,
689
+ clientY: 300,
690
+ }),
691
+ );
692
+ await nextTick();
693
+ }
694
+ root.dispatchEvent(pointerEvent("pointerup", { pointerId, clientX: toX, clientY: 300 }));
695
+ await nextTick();
696
+ };
697
+
698
+ await drag(42, 400, 600); // 200 px into the wall
699
+ expect(instance.value!.camera.x).toBe(0);
700
+ await drag(43, 400, 200); // and 200 px back out
701
+ expect(instance.value!.camera.x).toBeCloseTo(-200, 6);
702
+ });
703
+
704
+ it("re-frames after a rounding store restores the camera it had before the last fit", async () => {
705
+ // The echo rule has to be "the host answered, or it moved on its own", never
706
+ // "the host wrote back the camera we asked for": a store that rounds (or
707
+ // clamps, or snaps) never echoes exactly, and a "reset view" writes back
708
+ // exactly the camera that was live when the component asked.
709
+ const camera = ref<Cam>({ k: 1, x: 0, y: 0 });
710
+ const { instance } = mount({
711
+ contentBounds: { x: 0, y: 0, width: 100, height: 1571 },
712
+ camera,
713
+ "onUpdate:camera": (next: Cam) => {
714
+ camera.value = { k: next.k, x: Math.round(next.x), y: Math.round(next.y) };
715
+ },
716
+ });
717
+ await nextTick();
718
+ expect(instance.value!.fit()).toBe(true);
719
+ await nextTick();
720
+ expect(camera.value).toEqual({ k: 0.3, x: 385, y: 64 });
721
+
722
+ camera.value = { k: 1, x: 0, y: 0 };
723
+ await nextTick();
724
+ expect(instance.value!.fit()).toBe(true);
725
+ await nextTick();
726
+ expect(camera.value).toEqual({ k: 0.3, x: 385, y: 64 });
727
+ });
728
+
729
+ it("asks again when the host never applied the request", async () => {
730
+ // A host that drops the write can never be framed while one unanswered
731
+ // request stands for ever: `fit()` keeps answering `false` — the same answer
732
+ // as "there was nothing to change" — and the surface stays unframed with no
733
+ // way back. A request the host never answered expires, so a later explicit
734
+ // fit() asks again.
735
+ const onUpdate = vi.fn();
736
+ const { instance } = mount({
737
+ contentBounds: { x: 0, y: 0, width: 100, height: 1571 },
738
+ camera: { k: 1, x: 0, y: 0 },
739
+ fitControlled: true,
740
+ "onUpdate:camera": onUpdate,
741
+ });
742
+ await nextTick();
743
+ expect(onUpdate).toHaveBeenCalledTimes(1); // the mount fit
744
+
745
+ expect(instance.value!.fit()).toBe(false);
746
+ expect(onUpdate).toHaveBeenCalledTimes(1);
747
+
748
+ // Past the grace the host has had its chance: the request expires and the
749
+ // next fit() is allowed to ask again.
750
+ await new Promise((resolve) => setTimeout(resolve, 400));
751
+ expect(instance.value!.fit()).toBe(true);
752
+ expect(onUpdate).toHaveBeenCalledTimes(2);
753
+ const reAsked = onUpdate.mock.calls[1][0] as Cam;
754
+ expect(reAsked.k).toBe(0.3);
755
+ expect(reAsked.x).toBeCloseTo(385, 6);
756
+ expect(reAsked.y).toBeCloseTo(64.35, 6);
757
+ });
758
+
759
+ it("refuses a fit whose translation would overflow", async () => {
760
+ // 1.7e308 * 1.25 overflows: writing that transform makes the browser drop it
761
+ // and the surface freezes at whatever it was showing. The guard has to cover
762
+ // the translation the fit derives, not only the scale it picked.
763
+ const bounds = { x: -1.7e308, y: 0, width: 100, height: 100 };
764
+ const onUpdate = vi.fn();
765
+ const { instance } = mount({
766
+ contentBounds: bounds,
767
+ camera: { k: 1, x: 0, y: 0 },
768
+ fitOnLoad: false,
769
+ "onUpdate:camera": onUpdate,
770
+ });
771
+ await nextTick();
772
+ expect(instance.value!.fit()).toBe(false);
773
+ expect(onUpdate).not.toHaveBeenCalled();
774
+
775
+ const own = mount({ contentBounds: bounds, fitOnLoad: true });
776
+ await nextTick();
777
+ expect(Number.isFinite(own.instance.value!.camera.x)).toBe(true);
778
+ expect(own.instance.value!.camera).toEqual({ k: 1, x: 0, y: 0 });
779
+ const layer = own.container.querySelector<HTMLElement>(".hk-node-canvas-layer")!;
780
+ expect(layer.style.transform).not.toContain("Infinity");
781
+ });
782
+
783
+ it("refuses to propagate a camera it cannot compute", async () => {
784
+ const onUpdate = vi.fn();
785
+ const { instance } = mount({
786
+ contentBounds: BOUNDS,
787
+ camera: { k: 0, x: 0, y: 0 },
788
+ fitOnLoad: false,
789
+ "onUpdate:camera": onUpdate,
790
+ });
791
+ await nextTick();
792
+ instance.value?.zoomBy(1);
793
+ expect(onUpdate).not.toHaveBeenCalled();
794
+ });
795
+
796
+ it("leaves the camera alone for bounds it cannot frame", async () => {
797
+ const { instance } = mount({
798
+ contentBounds: { x: Number.NaN, y: 0, width: 400, height: 200 },
799
+ fitOnLoad: true,
800
+ });
801
+ await nextTick();
802
+ expect(instance.value!.camera).toEqual({ k: 1, x: 0, y: 0 });
803
+
804
+ const infinite = mount({
805
+ contentBounds: { x: 0, y: 0, width: 400, height: Number.POSITIVE_INFINITY },
806
+ fitOnLoad: true,
807
+ });
808
+ await nextTick();
809
+ expect(infinite.instance.value!.camera).toEqual({ k: 1, x: 0, y: 0 });
810
+ });
811
+
812
+ it("keeps the view when the host hands control back", async () => {
813
+ const camera = ref<{ k: number; x: number; y: number } | undefined>({ k: 1.5, x: 40, y: 25 });
814
+ const { instance } = mount({ contentBounds: BOUNDS, camera, fitOnLoad: false });
815
+ await nextTick();
816
+ expect(instance.value!.camera).toEqual({ k: 1.5, x: 40, y: 25 });
817
+
818
+ camera.value = undefined;
819
+ await nextTick();
820
+ expect(instance.value!.camera).toEqual({ k: 1.5, x: 40, y: 25 });
821
+ });
822
+
823
+ it("does not end the pan on an element blur", async () => {
824
+ // Pressing the canvas clears focus, and `blur` reaches a window capture
825
+ // listener for element blurs too: ending the gesture there would kill the
826
+ // first pan after any click. (A real window blur still ends it — that path
827
+ // is covered by the browser probe, not by this environment.)
828
+ const { instance, root } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
829
+ await nextTick();
830
+ const input = document.createElement("input");
831
+ root.appendChild(input);
832
+ root.setPointerCapture = vi.fn();
833
+ root.dispatchEvent(pointerEvent("pointerdown", { pointerId: 31, clientX: 100, clientY: 100 }));
834
+ root.dispatchEvent(pointerEvent("pointermove", { pointerId: 31, clientX: 160, clientY: 100 }));
835
+ expect(instance.value!.camera.x).toBeCloseTo(60, 6);
836
+
837
+ input.dispatchEvent(new Event("blur"));
838
+ root.dispatchEvent(pointerEvent("pointermove", { pointerId: 31, clientX: 220, clientY: 100 }));
839
+ expect(instance.value!.camera.x).toBeCloseTo(120, 6);
840
+ });
841
+
842
+ it("lets a host subtree inside its own chrome keep panning", async () => {
843
+ const { instance, root } = mount({ contentBounds: BOUNDS, fitOnLoad: false }, {
844
+ overlay: () => h("div", { class: "hud" }, [h("div", { class: "card", "data-hk-canvas-pan": "" }, "hud card")]),
845
+ });
846
+ await nextTick();
847
+ root.setPointerCapture = vi.fn();
848
+ const card = root.querySelector<HTMLElement>(".hud .card")!;
849
+ card.dispatchEvent(pointerEvent("pointerdown", { pointerId: 32, clientX: 100, clientY: 100 }));
850
+ root.dispatchEvent(pointerEvent("pointermove", { pointerId: 32, clientX: 160, clientY: 100 }));
851
+ expect(instance.value!.camera.x).toBeCloseTo(60, 6);
852
+ });
853
+
854
+ it("keeps a captured drag alive across a button-less move", async () => {
855
+ // Browsers can deliver a move whose `buttons` reads 0 in the middle of a
856
+ // gesture; once the gesture is captured it is real, so it must not be
857
+ // cancelled by one odd event.
858
+ const { instance, root } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
859
+ await nextTick();
860
+ root.setPointerCapture = vi.fn();
861
+ root.releasePointerCapture = vi.fn();
862
+ root.dispatchEvent(pointerEvent("pointerdown", { pointerId: 21, clientX: 100, clientY: 100 }));
863
+ root.dispatchEvent(pointerEvent("pointermove", { pointerId: 21, clientX: 120, clientY: 100 }));
864
+ root.dispatchEvent(
865
+ pointerEvent("pointermove", { pointerId: 21, clientX: 140, clientY: 100, buttons: 0 }),
866
+ );
867
+ root.dispatchEvent(pointerEvent("pointermove", { pointerId: 21, clientX: 160, clientY: 100 }));
868
+ expect(instance.value!.camera.x).toBeCloseTo(60, 6);
869
+ });
870
+
871
+ it("ends a pan when the button comes up anywhere on the page", async () => {
872
+ // The release may land on a neighbouring pane; without a window-level
873
+ // listener the next press-and-drag would resume the old gesture and jump.
874
+ const { instance, root } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
875
+ await nextTick();
876
+ root.setPointerCapture = vi.fn();
877
+ root.dispatchEvent(pointerEvent("pointerdown", { pointerId: 22, clientX: 100, clientY: 100 }));
878
+ root.dispatchEvent(pointerEvent("pointermove", { pointerId: 22, clientX: 130, clientY: 100 }));
879
+ const afterDrag = instance.value!.camera.x;
880
+ document.body.dispatchEvent(
881
+ pointerEvent("pointerup", { pointerId: 22, clientX: 130, clientY: 100, buttons: 0 }),
882
+ );
883
+ // A fresh, button-less drag from elsewhere must not move the camera.
884
+ root.dispatchEvent(pointerEvent("pointermove", { pointerId: 22, clientX: 300, clientY: 280 }));
885
+ expect(instance.value!.camera.x).toBe(afterDrag);
886
+ });
887
+
888
+ it("leaves the presses that belong to its own chrome alone", async () => {
889
+ // Inspectors, rails and sliders live in the overlay; a press there is not a
890
+ // pan. Hosts mark their own interactive nodes with the data attribute.
891
+ const overlay = document.createElement("div");
892
+ overlay.className = "hk-node-canvas-overlay";
893
+ const slider = document.createElement("input");
894
+ overlay.appendChild(slider);
895
+ const { instance, root } = mount({ contentBounds: BOUNDS, fitOnLoad: false }, {
896
+ overlay: () => h("div", { class: "chrome" }, [h("input", { class: "slider" })]),
897
+ });
898
+ await nextTick();
899
+ root.setPointerCapture = vi.fn();
900
+ const chrome = root.querySelector<HTMLElement>(".hk-node-canvas-overlay")!;
901
+ chrome.dispatchEvent(pointerEvent("pointerdown", { pointerId: 23, clientX: 100, clientY: 100 }));
902
+ root.dispatchEvent(pointerEvent("pointermove", { pointerId: 23, clientX: 200, clientY: 100 }));
903
+ expect(instance.value!.camera).toEqual({ k: 1, x: 0, y: 0 });
904
+
905
+ const marked = document.createElement("div");
906
+ marked.setAttribute("data-hk-canvas-no-pan", "");
907
+ root.appendChild(marked);
908
+ marked.dispatchEvent(pointerEvent("pointerdown", { pointerId: 24, clientX: 100, clientY: 100 }));
909
+ root.dispatchEvent(pointerEvent("pointermove", { pointerId: 24, clientX: 240, clientY: 100 }));
910
+ expect(instance.value!.camera).toEqual({ k: 1, x: 0, y: 0 });
911
+ });
912
+
913
+ it("frames a host-supplied camera only when the host opts in", async () => {
914
+ const camera = ref({ k: 1, x: 0, y: 0 });
915
+ const onUpdate = (next: { k: number; x: number; y: number }) => {
916
+ camera.value = next;
917
+ };
918
+ const optIn = mount(
919
+ { contentBounds: BOUNDS, camera, fitOnLoad: true, fitControlled: true, "onUpdate:camera": onUpdate },
920
+ );
921
+ await nextTick();
922
+ expect(camera.value.k).toBe(NODE_CANVAS_DEFAULTS.fitCap);
923
+ optIn.instance.value?.fit();
924
+ expect(camera.value.k).toBe(NODE_CANVAS_DEFAULTS.fitCap);
925
+ });
926
+
927
+ it("converges when the host frames on every render and rounds what it stores", async () => {
928
+ // The real store rounds and clamps what it is given, so its value never
929
+ // equals the request; a host that frames in its own update hook would then
930
+ // ask for ever. One request has to stand until the host moves.
931
+ const camera = ref({ k: 1, x: 0, y: 0 });
932
+ let renders = 0;
933
+ const instance = ref<Instance | null>(null);
934
+ const container = document.createElement("div");
935
+ document.body.appendChild(container);
936
+ const Root = defineComponent({
937
+ setup() {
938
+ onUpdated(() => {
939
+ instance.value?.fit();
940
+ });
941
+ return () => {
942
+ renders++;
943
+ return h(HkNodeCanvas, {
944
+ ref: instance as never,
945
+ contentBounds: { x: 0, y: 0, width: 100, height: 1571 },
946
+ camera: camera.value,
947
+ fitControlled: true,
948
+ "onUpdate:camera": (next: { k: number; x: number; y: number }) => {
949
+ camera.value = { k: next.k, x: Math.round(next.x), y: Math.round(next.y) };
950
+ },
951
+ });
952
+ };
953
+ },
954
+ });
955
+ const app = createApp(Root);
956
+ app.mount(container);
957
+ apps.push(app);
958
+
959
+ for (let frame = 0; frame < 6; frame++) await nextTick();
960
+ expect(renders).toBeLessThan(12);
961
+ expect(camera.value.k).toBe(0.3);
962
+ });
963
+
964
+ it("re-frames when the host mutates its bounds in place", async () => {
965
+ const bounds = ref<Bounds | null>({ x: 0, y: 0, width: 400, height: 200 });
966
+ const { instance } = mount({ contentBounds: bounds, fitOnLoad: true });
967
+ await nextTick();
968
+ expect(instance.value!.camera.k).toBe(NODE_CANVAS_DEFAULTS.fitCap);
969
+
970
+ bounds.value!.width = 2880;
971
+ bounds.value!.height = 2080;
972
+ await nextTick();
973
+ expect(instance.value!.camera.k).toBe(0.25);
974
+ });
975
+
976
+ it("reports whether fit actually changed anything", async () => {
977
+ const { instance } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
978
+ await nextTick();
979
+ expect(instance.value!.fit()).toBe(true);
980
+ expect(instance.value!.fit()).toBe(false);
981
+ });
982
+
983
+ it("measures the wheel's focal point against the canvas, not the page", async () => {
984
+ viewportOffset = { left: 120, top: 40 };
985
+ const { instance, root } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
986
+ await nextTick();
987
+ const local = { x: 600, y: 150 };
988
+ const worldBefore = instance.value!.screenToWorld(local);
989
+ const page = { x: local.x + viewportOffset.left, y: local.y + viewportOffset.top };
990
+ root.dispatchEvent(wheelEvent({ deltaY: -100, clientX: page.x, clientY: page.y }));
991
+ const worldAfter = instance.value!.screenToWorld(local);
992
+ expect(instance.value!.camera.k).toBeGreaterThan(1);
993
+ expect(worldAfter.x).toBeCloseTo(worldBefore.x, 6);
994
+ expect(worldAfter.y).toBeCloseTo(worldBefore.y, 6);
995
+ // A wheel read against the page point would settle somewhere else.
996
+ expect(worldAfter.x).not.toBeCloseTo(instance.value!.screenToWorld(page).x, 3);
997
+ });
998
+
999
+ it("stops zooming at the configured range", async () => {
1000
+ const { instance } = mount({
1001
+ contentBounds: BOUNDS,
1002
+ minZoom: 0.5,
1003
+ maxZoom: 1,
1004
+ fitOnLoad: false,
1005
+ });
1006
+ await nextTick();
1007
+ for (let tick = 0; tick < 40; tick++) instance.value?.zoomBy(1);
1008
+ expect(instance.value!.camera.k).toBe(1);
1009
+ for (let tick = 0; tick < 40; tick++) instance.value?.zoomBy(-1);
1010
+ expect(instance.value!.camera.k).toBe(0.5);
1011
+ });
1012
+
1013
+ it("derives the fit translation from the scale it renders, off the origin", async () => {
1014
+ const offset = { x: 300, y: 700, width: 100, height: 1571 };
1015
+ const { instance } = mount({ contentBounds: offset, fitOnLoad: true });
1016
+ await nextTick();
1017
+ const topLeft = instance.value!.worldToScreen({ x: offset.x, y: offset.y });
1018
+ expect(topLeft.x).toBeCloseTo((VIEWPORT.width - offset.width * instance.value!.camera.k) / 2, 6);
1019
+ expect(topLeft.y).toBeCloseTo((VIEWPORT.height - offset.height * instance.value!.camera.k) / 2, 6);
1020
+ });
1021
+
1022
+ it("does not re-frame a visible canvas just because it was resized", async () => {
1023
+ const { instance } = mount({ contentBounds: BOUNDS, fitOnLoad: true });
1024
+ await nextTick();
1025
+ expect(instance.value!.camera.k).toBe(NODE_CANVAS_DEFAULTS.fitCap);
1026
+ viewportRect = { width: 400, height: 300 };
1027
+ triggerResize();
1028
+ await nextTick();
1029
+ expect(instance.value!.camera.k).toBe(NODE_CANVAS_DEFAULTS.fitCap);
1030
+ });
1031
+
1032
+ it("fits the viewport it measured, not a truncated one", async () => {
1033
+ // 801.7 rounds to 802: with the rounded width a 2888-unit graph fits at
1034
+ // exactly 0.25, while a truncated 801 would land a step lower.
1035
+ viewportRect = { width: 801.7, height: 600 };
1036
+ const { instance } = mount({
1037
+ contentBounds: { x: 0, y: 0, width: 2888, height: 100 },
1038
+ fitOnLoad: true,
1039
+ });
1040
+ await nextTick();
1041
+ expect(instance.value!.camera.k).toBe(0.25);
1042
+ });
1043
+
1044
+ it("emits camera updates instead of keeping them when controlled", async () => {
1045
+ const onUpdate = vi.fn();
1046
+ const { instance } = mount({ contentBounds: BOUNDS, camera: { k: 1, x: 0, y: 0 }, "onUpdate:camera": onUpdate });
1047
+ await nextTick();
1048
+ instance.value?.fit();
1049
+ expect(onUpdate).toHaveBeenCalled();
1050
+ // Controlled: the internal camera did not move on its own.
1051
+ expect(instance.value!.camera).toEqual({ k: 1, x: 0, y: 0 });
1052
+ });
1053
+ });