@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,642 @@
1
+ import {
2
+ computed,
3
+ defineComponent,
4
+ onBeforeUnmount,
5
+ onMounted,
6
+ ref,
7
+ shallowRef,
8
+ watch,
9
+ type PropType,
10
+ } from "vue";
11
+
12
+ import "./HkNodeCanvas.scss";
13
+
14
+ /** A camera: zoom factor plus the translation of the content origin. */
15
+ export interface NodeCanvasCamera {
16
+ /** Zoom factor (1 = 1:1). */
17
+ k: number;
18
+ /** Translation of the content origin, in screen pixels. */
19
+ x: number;
20
+ /** Translation of the content origin, in screen pixels. */
21
+ y: number;
22
+ }
23
+
24
+ /** A rectangle in content (world) coordinates. */
25
+ export interface NodeCanvasBounds {
26
+ x: number;
27
+ y: number;
28
+ width: number;
29
+ height: number;
30
+ }
31
+
32
+ /** What the `overlay` and `minimap` slots receive — the live camera and the
33
+ * viewport it is drawing into, so a host can place DOM or minimap boxes
34
+ * without reaching into the component. */
35
+ export interface NodeCanvasSlotProps {
36
+ camera: NodeCanvasCamera;
37
+ viewport: { width: number; height: number };
38
+ contentBounds: NodeCanvasBounds | null;
39
+ }
40
+
41
+ export type MinimapPlacement = "bottom-right" | "bottom-left" | "top-right" | "top-left";
42
+
43
+ /** The defaults the chat views already ship (they were constants in the
44
+ * scada camera module; a base view has to publish them instead). */
45
+ export const NODE_CANVAS_DEFAULTS = {
46
+ zoomFactor: 1.05,
47
+ minZoom: 0.2,
48
+ maxZoom: 4,
49
+ /** Zoom snaps to this grid so a wheel tick lands on a reproducible step. */
50
+ zoomGridStep: 0.05,
51
+ fitPadding: 40,
52
+ fitCap: 1.25,
53
+ } as const;
54
+
55
+ const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
56
+
57
+ /** Movement (px) a pointer must travel before it stops being a tap. */
58
+ const PAN_SLOP = 6;
59
+
60
+ /** How long a request made of a controlled host stays in flight before the host
61
+ * is presumed not to have applied it. Long enough for an async write-back (a
62
+ * store behind an RPC queue answers late), short enough that a request the
63
+ * host dropped cannot become the camera the next gesture composes from — or
64
+ * leave `fit()` refusing for ever. */
65
+ const REQUEST_GRACE_MS = 250;
66
+
67
+ /** How many unanswered requests are remembered. A drag asks once per pointer
68
+ * move, so an in-order echo can lag by a second's worth and still be named. */
69
+ const MAX_PENDING_REQUESTS = 64;
70
+
71
+ /** A camera asked of a controlled host that it has not answered yet. */
72
+ interface PendingRequest {
73
+ camera: NodeCanvasCamera;
74
+ /** A `fit()` request: absolute and one-off, unlike a gesture's relative move. */
75
+ framing: boolean;
76
+ /** When it was written, so a request nobody answered can expire. */
77
+ at: number;
78
+ }
79
+
80
+ /**
81
+ * HkNodeCanvas — the base view for a pannable, zoomable node surface.
82
+ *
83
+ * The industrial topology, the media pipeline and the topic map are all the
84
+ * same shape: a camera over a piece of content, drawn as SVG, as DOM over an
85
+ * SVG edge layer, or as DOM over a canvas. What they share is NOT how they
86
+ * draw — it is the camera: how a wheel tick becomes a zoom step on a fixed
87
+ * grid, how a drag pans, how "fit" frames the content, and how a screen point
88
+ * maps back into content coordinates. That is what this component owns, and
89
+ * it owns it once instead of three times.
90
+ *
91
+ * What it does NOT own: edges, nodes, routing, layout. The host draws those
92
+ * through the default slot, which lives inside the transformed layer, so the
93
+ * host only has to think in content coordinates.
94
+ *
95
+ * Two behaviours are deliberate:
96
+ *
97
+ * - **the camera is ready on the first frame** when `fitOnLoad` is on and
98
+ * the content bounds are known: the initial transform is computed before
99
+ * the first paint instead of starting at 1:1 and snapping a frame later,
100
+ * which is what makes a large graph flash at the wrong scale;
101
+ * - **the minimap is mounted by default** (bottom-right, the corner the
102
+ * chat views use) and takes its boxes from the host through the
103
+ * `minimap` slot — the component owns where the map sits, the host owns
104
+ * what it shows.
105
+ */
106
+ export default defineComponent({
107
+ name: "HkNodeCanvas",
108
+ props: {
109
+ /** Controlled camera (`v-model:camera`). Without it the component keeps
110
+ * its own, seeded by `fitOnLoad`. */
111
+ camera: { type: Object as PropType<NodeCanvasCamera | undefined>, default: undefined },
112
+ /** Content rectangle, in world units, used for fit and clamping. */
113
+ contentBounds: { type: Object as PropType<NodeCanvasBounds | null>, default: null },
114
+ /** Smallest zoom the camera will settle on. */
115
+ minZoom: { type: Number, default: NODE_CANVAS_DEFAULTS.minZoom },
116
+ /** Largest zoom the camera will settle on. */
117
+ maxZoom: { type: Number, default: NODE_CANVAS_DEFAULTS.maxZoom },
118
+ /** One wheel/trackpad step multiplies zoom by this factor. */
119
+ zoomFactor: { type: Number, default: NODE_CANVAS_DEFAULTS.zoomFactor },
120
+ /** Zoom is snapped to this grid (0 disables snapping). */
121
+ zoomGridStep: { type: Number, default: NODE_CANVAS_DEFAULTS.zoomGridStep },
122
+ /** Padding (px) left around the content when fitting. */
123
+ fitPadding: { type: Number, default: NODE_CANVAS_DEFAULTS.fitPadding },
124
+ /** Largest zoom "fit" will choose, so a tiny graph is not blown up. */
125
+ fitCap: { type: Number, default: NODE_CANVAS_DEFAULTS.fitCap },
126
+ /** Frame the content immediately instead of starting at 1:1.
127
+ *
128
+ * Only the component's OWN camera is framed: a host that passes `camera`
129
+ * owns the view and calls `fit()` when it wants one. Automatic framing
130
+ * also stops for good once the camera has been moved by hand — a gesture,
131
+ * or an imperative `zoomBy`/`panBy`/`setCamera` — so a late `contentBounds`
132
+ * cannot throw the user's view away; `fit()` stays available to re-frame
133
+ * on demand. */
134
+ fitOnLoad: { type: Boolean, default: true },
135
+ /** Also frame a host-supplied `camera`, which is otherwise left alone: the
136
+ * host owns that camera and calls `fit()` when it wants one. Opt in when
137
+ * the host wants the component to seed the first frame anyway — framing
138
+ * still stops as soon as the camera is moved by hand. */
139
+ fitControlled: { type: Boolean, default: false },
140
+ /** Whether dragging pans the camera. */
141
+ pannable: { type: Boolean, default: true },
142
+ /** Whether the wheel zooms. */
143
+ zoomable: { type: Boolean, default: true },
144
+ /** Mount the minimap in the corner. */
145
+ minimap: { type: Boolean, default: true },
146
+ /** Which corner the minimap takes. */
147
+ minimapPlacement: {
148
+ type: String as PropType<MinimapPlacement>,
149
+ default: "bottom-right",
150
+ },
151
+ ariaLabel: { type: String, default: undefined },
152
+ },
153
+ emits: ["update:camera"],
154
+ setup(props, { slots, emit, expose }) {
155
+ const rootEl = shallowRef<HTMLElement | null>(null);
156
+ const viewport = ref({ width: 0, height: 0 });
157
+ const inner = ref<NodeCanvasCamera>({ k: 1, x: 0, y: 0 });
158
+ /** Panning state: the last pointer position while a drag is live. */
159
+ let panning: {
160
+ pointerId: number;
161
+ x: number;
162
+ y: number;
163
+ startX: number;
164
+ startY: number;
165
+ captured: boolean;
166
+ } | null = null;
167
+ /** Set by setCamera (gestures and imperative calls) — never by `fit`. */
168
+ let movedByHand = false;
169
+
170
+ const camera = computed<NodeCanvasCamera>(() => props.camera ?? inner.value);
171
+
172
+ /** Snap a zoom factor onto the grid, then into range. `quantizeScaleStep`
173
+ * in the scada module does the same thing; the base view carries the
174
+ * rule rather than the consumer. */
175
+ function normalizeZoom(k: number): number {
176
+ const step = props.zoomGridStep;
177
+ const snapped = step > 0 ? Math.round(k / step) * step : k;
178
+ return clamp(Number(snapped.toFixed(6)), props.minZoom, props.maxZoom);
179
+ }
180
+
181
+ /** Whether two cameras describe the same view. */
182
+ function sameCamera(a: NodeCanvasCamera, b: NodeCanvasCamera) {
183
+ return a.k === b.k && a.x === b.x && a.y === b.y;
184
+ }
185
+
186
+ /** The requests made of a controlled host that it has not answered yet,
187
+ * newest first. A drag asks once per pointer move, and a store behind a
188
+ * queue echoes them in order: only by naming the request a write belongs
189
+ * to can a host that is still catching up be told from one that answered
190
+ * the newest ask. */
191
+ let pending: PendingRequest[] = [];
192
+ /** Set once the host answers a request — it applies what it is asked to. */
193
+ let hostApplied = false;
194
+ /** The newest camera asked of the host. Repeating it is pointless while the
195
+ * host has not moved anywhere, and required as soon as it has. */
196
+ let lastRequested: NodeCanvasCamera | null = null;
197
+
198
+ /** Write a camera through to the host (controlled) or the local state.
199
+ * `framing` marks a `fit()` request: it is absolute and one-off, so it is
200
+ * never part of the gesture chain a drag composes from. */
201
+ function writeCamera(normalized: NodeCanvasCamera, framing = false) {
202
+ if (props.camera) {
203
+ lastRequested = normalized;
204
+ pending.unshift({ camera: normalized, framing, at: Date.now() });
205
+ if (pending.length > MAX_PENDING_REQUESTS) pending.length = MAX_PENDING_REQUESTS;
206
+ emit("update:camera", normalized);
207
+ } else {
208
+ inner.value = normalized;
209
+ }
210
+ }
211
+
212
+ function setCamera(next: NodeCanvasCamera) {
213
+ const k = normalizeZoom(next.k);
214
+ // A host camera with a zero or non-finite scale turns every coordinate
215
+ // derived from it into Infinity, and the browser drops the transform —
216
+ // the surface freezes with no way back. Refuse to propagate it.
217
+ if (!Number.isFinite(k) || k <= 0) return;
218
+ if (!Number.isFinite(next.x) || !Number.isFinite(next.y)) return;
219
+ // Any non-automatic camera move is the user's: a late `contentBounds`
220
+ // must not throw that work away. It also means the host is somewhere
221
+ // else, so a later `fit()` may ask for the same camera again.
222
+ movedByHand = true;
223
+ lastRequested = null;
224
+ writeCamera({ k, x: next.x, y: next.y });
225
+ }
226
+
227
+ /** The camera that frames `contentBounds` inside the viewport. */
228
+ function computeFit(): NodeCanvasCamera {
229
+ const bounds = props.contentBounds;
230
+ const { width, height } = viewport.value;
231
+ if (
232
+ !bounds ||
233
+ !(bounds.width > 0) ||
234
+ !(bounds.height > 0) ||
235
+ !Number.isFinite(bounds.x) ||
236
+ !Number.isFinite(bounds.y) ||
237
+ !Number.isFinite(bounds.width) ||
238
+ !Number.isFinite(bounds.height) ||
239
+ !(width > 0) ||
240
+ !(height > 0)
241
+ ) {
242
+ return camera.value;
243
+ }
244
+ const pad = props.fitPadding;
245
+ // Snap FIRST: the translation below has to be derived from the k that is
246
+ // actually rendered, or the content lands off-centre and a tall graph can
247
+ // run past the viewport edge.
248
+ const step = props.zoomGridStep;
249
+ const raw = clamp(
250
+ Math.min((width - pad * 2) / bounds.width, (height - pad * 2) / bounds.height),
251
+ props.minZoom,
252
+ Math.min(props.maxZoom, props.fitCap),
253
+ );
254
+ // Snap DOWN: rounding fit to the nearest grid step can land above the
255
+ // padding budget and squeeze (or push) the content past the viewport.
256
+ // Flooring can only lower k, so the upper bound after it is maxZoom:
257
+ // minZoom must still win over a fitCap configured below it.
258
+ const floorValue = step > 0 ? Math.floor(raw / step + 1e-9) * step : raw;
259
+ // A graph too large for one grid step floors to 0. Raising it to a whole
260
+ // step would over-zoom (up to 10x past the budget, and past `fitCap`);
261
+ // the un-quantised ideal is already inside [minZoom, min(fitCap, maxZoom)],
262
+ // so that is what frames it.
263
+ const floored = floorValue <= 0 ? raw : floorValue;
264
+ // The multiplication leaves binary dust (6 * 0.05 is 0.30000000000000004),
265
+ // and a camera whose k differs from what `normalizeZoom` would return for
266
+ // the same factor makes the first gesture after a fit jump a hair. Round
267
+ // it exactly like `normalizeZoom` does.
268
+ const k = clamp(Number(floored.toFixed(6)), props.minZoom, props.maxZoom);
269
+ // A host can configure a range that cannot describe a fit (minZoom 0, a
270
+ // padding budget wider than the viewport); framing with a zero or
271
+ // non-finite scale would put NaN into every coordinate derived from it.
272
+ if (!Number.isFinite(k) || k <= 0) return camera.value;
273
+ const x = (width - bounds.width * k) / 2 - bounds.x * k;
274
+ const y = (height - bounds.height * k) / 2 - bounds.y * k;
275
+ // A finite but enormous origin (1e308) overflows the translation into
276
+ // Infinity: the browser then drops the whole transform.
277
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return camera.value;
278
+ return { k, x, y };
279
+ }
280
+
281
+ /** Frame the content. Automatic while the camera is untouched, and exposed
282
+ * for the host to re-frame whenever it likes. */
283
+ function fit(): boolean {
284
+ const next = computeFit();
285
+ // Degenerate bounds return the live camera unchanged, and an unchanged
286
+ // camera must not be written back: a host that spells `contentBounds` as
287
+ // a fresh object literal re-renders on every emit, so comparing by
288
+ // identity would loop — fit emits, the host writes the value back, the
289
+ // object identity changes, the watcher fits again. Compare by value.
290
+ if (sameCamera(next, camera.value)) return false;
291
+ // A host may transform what it stores (rounding, clamping, a 10 Hz
292
+ // mirror), so its value never equals what we asked for and a host that
293
+ // frames on every update would ask again for ever. One request stands —
294
+ // but only while the host is still where it was when we asked: a host
295
+ // that moved the camera itself (a restored viewport, a minimap jump) is
296
+ // asking us to frame, and must get an answer.
297
+ //
298
+ // `false` therefore means "nothing was written": the camera is already the
299
+ // fitted one, the same request is still standing, or the host has not had
300
+ // the grace to answer it. What it must not mean is "for ever": a host that
301
+ // dropped the write would never be frameable again and could not be told
302
+ // apart from one with nothing to change, so an unanswered request expires
303
+ // and a later fit() asks again. A host that frames on every update cannot
304
+ // loop that way — it answers, and an answered request stands.
305
+ if (lastRequested && sameCamera(next, lastRequested)) {
306
+ if (!pending.length || Date.now() - pending[0].at <= REQUEST_GRACE_MS) return false;
307
+ }
308
+ writeCamera(next, true); // already normalised by computeFit
309
+ return true;
310
+ }
311
+
312
+ /** Automatic framing: the component's own camera only, and only until the
313
+ * host or the user has taken the view over. */
314
+ function autoFit() {
315
+ if (!props.fitOnLoad || movedByHand) return;
316
+ if (props.camera && !props.fitControlled) return;
317
+ fit();
318
+ }
319
+
320
+ /** The camera a gesture builds on. A controlled host may write back late — a
321
+ * throttled mirror, a store behind an RPC queue — so composing from its
322
+ * echo would make the surface trail the pointer, and an in-order queue
323
+ * makes it step backwards. The newest request is the truth until the host
324
+ * answers it. */
325
+ function gestureBase(): NodeCanvasCamera {
326
+ if (props.camera) {
327
+ const base = requestBase();
328
+ if (base) return base;
329
+ }
330
+ return camera.value;
331
+ }
332
+
333
+ /** The newest request, while it is still the camera the surface is being
334
+ * built on. A gesture is relative, so its request stays the base until the
335
+ * host answers it. A framing request is absolute and one-off: it is the
336
+ * base only while it is plausibly still in flight, and only for a host that
337
+ * has shown it applies what it is asked to — one that dropped it never had
338
+ * that camera, and a drag composed from it teleports the surface. */
339
+ function requestBase(): NodeCanvasCamera | null {
340
+ const newest = pending[0];
341
+ if (!newest) return null;
342
+ if (!newest.framing) return newest.camera;
343
+ if (!hostApplied) return null;
344
+ return Date.now() - newest.at <= REQUEST_GRACE_MS ? newest.camera : null;
345
+ }
346
+
347
+ function zoomAt(nextK: number, at: { x: number; y: number }) {
348
+ const current = gestureBase();
349
+ const k = normalizeZoom(nextK);
350
+ if (!Number.isFinite(k) || k <= 0 || k === current.k) return;
351
+ // Keep the world point under `at` fixed: the content moves opposite to
352
+ // the growth of the scale.
353
+ const scale = k / current.k;
354
+ setCamera({
355
+ k,
356
+ x: at.x - (at.x - current.x) * scale,
357
+ y: at.y - (at.y - current.y) * scale,
358
+ });
359
+ }
360
+
361
+ function zoomBy(direction: 1 | -1, at?: { x: number; y: number }) {
362
+ const point = at ?? { x: viewport.value.width / 2, y: viewport.value.height / 2 };
363
+ // Same base as the translation applied below, or a step computed from one
364
+ // camera lands on another.
365
+ const current = gestureBase().k;
366
+ const scaled = current * (direction > 0 ? props.zoomFactor : 1 / props.zoomFactor);
367
+ // Below k = 0.5 a 1.05 step is smaller than half a grid step, so snapping
368
+ // would round it straight back and the wheel would do nothing at all.
369
+ const step = props.zoomGridStep;
370
+ const next =
371
+ step > 0 && Math.abs(scaled - current) < step ? current + direction * step : scaled;
372
+ zoomAt(next, point);
373
+ }
374
+
375
+ function panBy(dx: number, dy: number) {
376
+ const current = gestureBase();
377
+ setCamera({ k: current.k, x: current.x + dx, y: current.y + dy });
378
+ }
379
+
380
+ /** Content coordinates of a point given in viewport pixels. */
381
+ function screenToWorld(point: { x: number; y: number }) {
382
+ const { k, x, y } = camera.value;
383
+ return { x: (point.x - x) / k, y: (point.y - y) / k };
384
+ }
385
+
386
+ /** Viewport pixels of a point given in content coordinates. */
387
+ function worldToScreen(point: { x: number; y: number }) {
388
+ const { k, x, y } = camera.value;
389
+ return { x: point.x * k + x, y: point.y * k + y };
390
+ }
391
+
392
+ function onWheel(event: WheelEvent) {
393
+ if (!props.zoomable) return;
394
+ event.preventDefault();
395
+ const rect = rootEl.value?.getBoundingClientRect();
396
+ const at = rect
397
+ ? { x: event.clientX - rect.left, y: event.clientY - rect.top }
398
+ : undefined;
399
+ // A wheel event's deltaY is device-dependent; the sign is what matters,
400
+ // and the grid keeps the result reproducible.
401
+ zoomBy(event.deltaY < 0 ? 1 : -1, at);
402
+ }
403
+
404
+ function onPointerDown(event: PointerEvent) {
405
+ if (!props.pannable || event.button !== 0) return;
406
+ // A host that drags its own nodes suppresses the pan by preventing the
407
+ // default on its card's pointerdown: the root listens in the bubble
408
+ // phase, so it sees the flag. Without an opt-out the root's capture
409
+ // would retarget the gesture and cut the host's drag short.
410
+ if (event.defaultPrevented) return;
411
+ // The overlay and minimap slots carry the host's chrome — inspectors,
412
+ // rails, sliders. A press there is the chrome's, not the canvas's. A host
413
+ // that draws its own interactive nodes in the default slot marks them
414
+ // with `data-hk-canvas-no-pan` (or claims the gesture with
415
+ // `preventDefault`, above).
416
+ const target = event.target as Element | null;
417
+ if (target?.closest?.("[data-hk-canvas-no-pan]")) return;
418
+ // Chrome is not a pan surface, unless the host marks a subtree inside it
419
+ // as one (`data-hk-canvas-pan`), which is how a HUD wrapper that covers
420
+ // the whole canvas stays pannable.
421
+ if (
422
+ !target?.closest?.("[data-hk-canvas-pan]") &&
423
+ target?.closest?.(".hk-node-canvas-overlay, .hk-node-canvas-minimap")
424
+ ) {
425
+ return;
426
+ }
427
+ panning = {
428
+ pointerId: event.pointerId,
429
+ x: event.clientX,
430
+ y: event.clientY,
431
+ startX: event.clientX,
432
+ startY: event.clientY,
433
+ captured: false,
434
+ };
435
+ // A release outside this element never reaches it, and the gesture is
436
+ // only captured once it passes the slop: listen for the release on the
437
+ // window so a pan cannot outlive the button.
438
+ window.addEventListener("pointerup", endPan, true);
439
+ window.addEventListener("pointercancel", endPan, true);
440
+ // A release swallowed by a focus change never arrives as an event.
441
+ window.addEventListener("blur", loseFocus, true);
442
+ }
443
+
444
+ function onPointerMove(event: PointerEvent) {
445
+ if (!panning || event.pointerId !== panning.pointerId) return;
446
+ // The gesture is only captured once it passes the slop, so a release that
447
+ // happens outside the canvas never reaches this element. Without this the
448
+ // pan would stay armed and the next button-less hover would move the
449
+ // camera on its own.
450
+ if (event.buttons === 0 && !panning.captured) {
451
+ endPan(event);
452
+ return;
453
+ }
454
+ if (!panning.captured) {
455
+ // Capturing at pointerdown retargets the gesture's click to the capture
456
+ // element and kills clicks on node cards (shittim-chest #818).
457
+ if (Math.hypot(event.clientX - panning.startX, event.clientY - panning.startY) < PAN_SLOP) {
458
+ return;
459
+ }
460
+ rootEl.value?.setPointerCapture?.(event.pointerId);
461
+ panning = { ...panning, captured: true };
462
+ }
463
+ const dx = event.clientX - panning.x;
464
+ const dy = event.clientY - panning.y;
465
+ panning = { ...panning, x: event.clientX, y: event.clientY };
466
+ panBy(dx, dy);
467
+ }
468
+
469
+ /** Stop watching for a release (the gesture is over either way). */
470
+ function stopWindowWatch() {
471
+ window.removeEventListener("pointerup", endPan, true);
472
+ window.removeEventListener("pointercancel", endPan, true);
473
+ window.removeEventListener("blur", loseFocus, true);
474
+ }
475
+
476
+ function endPan(event: PointerEvent) {
477
+ if (!panning || event.pointerId !== panning.pointerId) return;
478
+ const pointerId = panning.pointerId;
479
+ panning = null;
480
+ stopWindowWatch();
481
+ rootEl.value?.releasePointerCapture?.(pointerId);
482
+ }
483
+
484
+ /** A blur carries no pointer id: whatever was being dragged is over. */
485
+ function loseFocus(event: FocusEvent) {
486
+ // `blur` reaches this capture listener for ELEMENT blurs too (pressing the
487
+ // canvas clears focus), and ending the gesture there would kill the first
488
+ // pan after any click. Only the window's own blur ends it.
489
+ if (event.target && event.target !== window) return;
490
+ if (!panning) return;
491
+ const pointerId = panning.pointerId;
492
+ panning = null;
493
+ stopWindowWatch();
494
+ rootEl.value?.releasePointerCapture?.(pointerId);
495
+ }
496
+
497
+ let ro: ResizeObserver | null = null;
498
+
499
+ function measureViewport() {
500
+ const el = rootEl.value;
501
+ if (!el) return;
502
+ const rect = el.getBoundingClientRect();
503
+ const next = { width: Math.round(rect.width), height: Math.round(rect.height) };
504
+ if (next.width === viewport.value.width && next.height === viewport.value.height) return;
505
+ viewport.value = next;
506
+ }
507
+
508
+ onMounted(() => {
509
+ // Measure BEFORE the first paint the watcher can react to: with
510
+ // `fitOnLoad` this is what makes the first painted frame already be the
511
+ // fitted one instead of 1:1 and then a snap.
512
+ measureViewport();
513
+ if (props.contentBounds) autoFit();
514
+ if (typeof ResizeObserver !== "undefined" && rootEl.value) {
515
+ ro = new ResizeObserver(() => {
516
+ const wasUnmeasurable = viewport.value.width <= 0 || viewport.value.height <= 0;
517
+ measureViewport();
518
+ // Mounted inside a hidden tab there was nothing to fit into; frame it
519
+ // now that it has a viewport (unless the host or the user drives it).
520
+ if (wasUnmeasurable && props.contentBounds) autoFit();
521
+ });
522
+ ro.observe(rootEl.value);
523
+ }
524
+ });
525
+
526
+ // Late-arriving bounds (the usual case: the graph loads after mount) are
527
+ // framed as soon as they exist, still before the host has drawn at the
528
+ // wrong scale.
529
+ // Keyed on the VALUES: a host that mutates its bounds object in place (or
530
+ // reuses one instance) still gets framed.
531
+ // Handing control back (the host drops the `camera` prop) keeps the view
532
+ // the host was showing instead of jumping to the internal seed.
533
+ watch(
534
+ () => props.camera,
535
+ (next, previous) => {
536
+ if (!next) {
537
+ if (previous) inner.value = previous;
538
+ // The requests we made of it are moot.
539
+ pending = [];
540
+ lastRequested = null;
541
+ return;
542
+ }
543
+ // Which request is this the answer to? A store behind a queue echoes
544
+ // them in order, so a write that reproduces an OLDER request is the host
545
+ // catching up — the newest request has not been seen yet, and the
546
+ // gesture under the pointer must keep composing from it. Drop what the
547
+ // host has consumed and leave the newer requests standing.
548
+ const index = pending.findIndex((request) => sameCamera(request.camera, next));
549
+ if (index > 0) {
550
+ pending = pending.slice(0, index);
551
+ hostApplied = true;
552
+ return;
553
+ }
554
+ // The host answered the newest request — it may have transformed it, so
555
+ // this camera is where the surface is now — or it moved on its own, and
556
+ // then a later `fit()` may ask for the same camera again.
557
+ if (pending.length) {
558
+ pending = [];
559
+ hostApplied = true;
560
+ return;
561
+ }
562
+ lastRequested = null;
563
+ },
564
+ );
565
+
566
+ watch(
567
+ () => {
568
+ const bounds = props.contentBounds;
569
+ return bounds ? `${bounds.x}|${bounds.y}|${bounds.width}|${bounds.height}` : null;
570
+ },
571
+ (key) => {
572
+ if (key) autoFit();
573
+ },
574
+ );
575
+
576
+ onBeforeUnmount(() => {
577
+ ro?.disconnect();
578
+ ro = null;
579
+ panning = null;
580
+ window.removeEventListener("pointerup", endPan, true);
581
+ window.removeEventListener("pointercancel", endPan, true);
582
+ window.removeEventListener("blur", loseFocus, true);
583
+ });
584
+
585
+ expose({
586
+ camera,
587
+ viewport,
588
+ fit,
589
+ zoomBy,
590
+ panBy,
591
+ screenToWorld,
592
+ worldToScreen,
593
+ setCamera,
594
+ });
595
+
596
+ const slotProps = computed<NodeCanvasSlotProps>(() => ({
597
+ camera: camera.value,
598
+ viewport: viewport.value,
599
+ contentBounds: props.contentBounds,
600
+ }));
601
+
602
+ return () => {
603
+ const { k, x, y } = camera.value;
604
+ return (
605
+ <div
606
+ ref={rootEl}
607
+ class="hk-node-canvas"
608
+ data-pannable={props.pannable ? "" : undefined}
609
+ aria-label={props.ariaLabel}
610
+ style={{
611
+ // Only a surface that consumes the gesture may take touch scrolling
612
+ // away from the page.
613
+ touchAction: props.pannable || props.zoomable ? "none" : undefined,
614
+ }}
615
+ onWheel={onWheel}
616
+ onPointerdown={onPointerDown}
617
+ onPointermove={onPointerMove}
618
+ onPointerup={endPan}
619
+ onPointercancel={endPan}
620
+ >
621
+ {/* The transformed layer: the host draws in CONTENT coordinates and
622
+ this transform puts it on screen. */}
623
+ <div
624
+ class="hk-node-canvas-layer"
625
+ style={{ transform: `translate(${x}px, ${y}px) scale(${k})` }}
626
+ >
627
+ {slots.default?.(slotProps.value)}
628
+ </div>
629
+ <div class="hk-node-canvas-overlay">{slots.overlay?.(slotProps.value)}</div>
630
+ {props.minimap && (
631
+ <div
632
+ class="hk-node-canvas-minimap"
633
+ data-placement={props.minimapPlacement}
634
+ >
635
+ {slots.minimap?.(slotProps.value)}
636
+ </div>
637
+ )}
638
+ </div>
639
+ );
640
+ };
641
+ },
642
+ });
package/src/index.ts CHANGED
@@ -102,6 +102,13 @@ export type {
102
102
  KanbanHeaderSlotProps,
103
103
  KanbanMove,
104
104
  } from "./components/HkKanban";
105
+ export { default as HNodeCanvas, NODE_CANVAS_DEFAULTS } from "./components/HkNodeCanvas";
106
+ export type {
107
+ NodeCanvasCamera,
108
+ NodeCanvasBounds,
109
+ NodeCanvasSlotProps,
110
+ MinimapPlacement,
111
+ } from "./components/HkNodeCanvas";
105
112
  export type {
106
113
  WaterfallBucket,
107
114
  WaterfallCardSlotProps,