@celestia-island/hikari 0.55.22 → 0.55.24

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.22",
3
+ "version": "0.55.24",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -3,7 +3,7 @@ import { Comment, createApp, createVNode, h, nextTick, ref, type Slot } from "vu
3
3
 
4
4
  import HkInput from "./HkInput";
5
5
  import HkPasswordSurface from "./HkPasswordSurface";
6
- import { NOISE_TILE_H, NOISE_TILE_W } from "./revealKinematogram";
6
+ import { FILTER_PEDESTAL_L, NOISE_TILE_H, NOISE_TILE_W } from "./revealKinematogram";
7
7
  import { passwordLevel } from "../utils/password";
8
8
  import { setReducedMotion } from "../runtime/animationBus";
9
9
 
@@ -1339,6 +1339,8 @@ function stubRecordingContexts(opts: { linearGradients?: boolean } = {}) {
1339
1339
  gradientStops: string[];
1340
1340
  /** Font string assignments — pins the BOLD glyph aperture. */
1341
1341
  fonts: string[];
1342
+ /** arc() radii, in call order — pins the spatter dot SIZE band. */
1343
+ arcRadii: number[];
1342
1344
  fillStyles: string[];
1343
1345
  clips: Array<{ x: number; y: number; w: number; h: number }>;
1344
1346
  translates: number[];
@@ -1361,6 +1363,7 @@ function stubRecordingContexts(opts: { linearGradients?: boolean } = {}) {
1361
1363
  compositeOps: [],
1362
1364
  gradientStops: [],
1363
1365
  fonts: [],
1366
+ arcRadii: [],
1364
1367
  fillStyles: [],
1365
1368
  clips: [],
1366
1369
  translates: [],
@@ -1380,7 +1383,7 @@ function stubRecordingContexts(opts: { linearGradients?: boolean } = {}) {
1380
1383
  translate: (x: number) => r.translates.push(x),
1381
1384
  rotate: () => {},
1382
1385
  beginPath: () => {},
1383
- arc: () => {},
1386
+ arc: (_x: number, _y: number, radius: number) => r.arcRadii.push(radius),
1384
1387
  fill: () => {},
1385
1388
  fillRect: () => {
1386
1389
  if (gradientArmed) {
@@ -1582,10 +1585,10 @@ describe("HkInput password reveal strategies", () => {
1582
1585
 
1583
1586
  it("filter (default) keeps glyphs off the visible canvas: counter-drifting spatter, pedestal, halo", async () => {
1584
1587
  // The default reveal: STATIC BOLD glyph apertures filled with one
1585
- // GRAYSCALE spatter texture, over a statistically matched spatter
1586
- // field streaming the opposite way on the uniform near-black
1587
- // ground; the glyphs lifted by a small brightening pedestal with a
1588
- // white halo band around the row. Glyph
1588
+ // BRIGHT GRAYSCALE spatter texture, over a statistically matched
1589
+ // DIM spatter field drifting the opposite way on the uniform
1590
+ // near-black ground; the glyphs lifted by a LARGE brightening
1591
+ // pedestal with a white halo band around the row. Glyph
1589
1592
  // geometry must NEVER reach the visible canvas (mask → source-in
1590
1593
  // stamp only) — that is the screenshot contract. Math.random is
1591
1594
  // pinned at 0.5 so the dot lightness equals the exact layer base
@@ -1624,7 +1627,7 @@ describe("HkInput password reveal strategies", () => {
1624
1627
  // The two spatter tiles: bg first, ink second (deterministic
1625
1628
  // draw order), each with hundreds of solid-color dot fills. The
1626
1629
  // anchors are UNIFORM (white-on-black reading in every theme):
1627
- // near-black ground, light-gray speckle, and the pedestal lifts
1630
+ // near-black ground, dim-gray speckle, and the pedestal lifts
1628
1631
  // the glyph layer BRIGHTER. happy-dom resolves no computed
1629
1632
  // color, so the ink stays at the pre-sync fallback (a DARK ink)
1630
1633
  // — irrelevant now, the anchors follow nothing.
@@ -1661,6 +1664,13 @@ describe("HkInput password reveal strategies", () => {
1661
1664
  inkMean - bgMean,
1662
1665
  `glyph pedestal lifts BRIGHTER (bg ${bgMean.toFixed(1)} vs ink ${inkMean.toFixed(1)})`,
1663
1666
  ).toBeGreaterThan(8);
1667
+ // Magnitude pin (R1 F4): the pedestal is LARGE by design (26 L ≈
1668
+ // 66 rgb here) — a revert to the old small 10 L pedestal (~26
1669
+ // rgb) must go red, not just stay above the sign threshold.
1670
+ expect(
1671
+ inkMean - bgMean,
1672
+ "the bright-text pedestal is LARGE, not merely positive",
1673
+ ).toBeGreaterThan(36);
1664
1674
  // Strict grayscale: no theme hue may survive into the spatter —
1665
1675
  // every solid fill is a NEUTRAL gray (r === g === b).
1666
1676
  for (const tile of tiles) {
@@ -1689,6 +1699,21 @@ describe("HkInput password reveal strategies", () => {
1689
1699
  mask!.fonts.some((f) => f.startsWith("bold ")),
1690
1700
  "glyph apertures rasterize bold",
1691
1701
  ).toBe(true);
1702
+ // Dot size band (R1 F6): ~0.6–1.4× the stroke width — dpr is 1
1703
+ // in this environment (guarded below), so every spatter radius
1704
+ // sits in the CSS band [1.2, 2.8]. NOTE: with random pinned at
1705
+ // 0.5 every radius is the band MIDPOINT — the min-side teeth
1706
+ // live in the light-ink test's unmocked band check (R2 F1).
1707
+ expect(window.devicePixelRatio || 1).toBe(1);
1708
+ for (const tile of tiles) {
1709
+ expect(tile.arcRadii.length, "spatter dots drawn as arcs").toBeGreaterThan(1000);
1710
+ for (const rad of tile.arcRadii) {
1711
+ expect(
1712
+ rad >= 1.2 && rad <= 2.8,
1713
+ `dot radius inside the stroke band, got ${rad}`,
1714
+ ).toBe(true);
1715
+ }
1716
+ }
1692
1717
  // The DOM input still never flips.
1693
1718
  expect(input.type).toBe("password");
1694
1719
  // Bus frames advance BOTH layer drifts (fresh pattern phases on
@@ -1750,7 +1775,7 @@ describe("HkInput password reveal strategies", () => {
1750
1775
  it("filter anchors white-on-black regardless of the field ink (light ink)", async () => {
1751
1776
  // UNIFORMITY pin: the anchors follow NOTHING — feed a LIGHT field
1752
1777
  // ink through getComputedStyle and the reveal must still paint the
1753
- // same near-black ground, light-gray speckle, brighter pedestal
1778
+ // same near-black ground, dim-gray speckle, brighter pedestal
1754
1779
  // and white halo as the default (dark-ink) mount.
1755
1780
  const restoreInk = forceInkColor("rgb(148, 233, 211)");
1756
1781
  const rec = stubRecordingContexts();
@@ -1790,6 +1815,33 @@ describe("HkInput password reveal strategies", () => {
1790
1815
  expect(r8 === g8 && g8 === b8, `dark spatter must be grayscale, got ${fill}`).toBe(true);
1791
1816
  }
1792
1817
  }
1818
+ // Dot size band with REAL randomness (R2 F1): the default test's
1819
+ // pinned 0.5 random freezes every radius at the band MIDPOINT,
1820
+ // which is blind on the min side — here thousands of uniform
1821
+ // samples must ALL sit in [1.2, 2.8] AND the empirical extremes
1822
+ // must touch both edges, so a shrunken rMin (sub-pixel speckle)
1823
+ // or a blown rMax both go red. dpr is 1 in this environment —
1824
+ // the guard makes that assumption explicit (R2 F2).
1825
+ expect(window.devicePixelRatio || 1).toBe(1);
1826
+ {
1827
+ let min = Infinity;
1828
+ let max = -Infinity;
1829
+ let count = 0;
1830
+ for (const tile of tiles) {
1831
+ for (const rad of tile.arcRadii) {
1832
+ if (rad < min) min = rad;
1833
+ if (rad > max) max = rad;
1834
+ count++;
1835
+ expect(
1836
+ rad >= 1.2 && rad <= 2.8,
1837
+ `dot radius inside the stroke band, got ${rad}`,
1838
+ ).toBe(true);
1839
+ }
1840
+ }
1841
+ expect(count, "thousands of spatter dots sampled").toBeGreaterThan(3000);
1842
+ expect(min, "the small edge of the band is actually used").toBeLessThan(1.5);
1843
+ expect(max, "the large edge of the band is actually used").toBeGreaterThan(2.5);
1844
+ }
1793
1845
  // Matched texture statistics (R2 F2): the two layers must carry
1794
1846
  // the SAME dot count and (pedestal aside) the same lightness
1795
1847
  // RANGE — a diverging spread/density is the single-frame
@@ -1812,14 +1864,16 @@ describe("HkInput password reveal strategies", () => {
1812
1864
  `matched lightness spread (bg ${bgStats.range} vs ink ${inkStats.range})`,
1813
1865
  ).toBeLessThan(6);
1814
1866
  // The pedestal lifts ground AND dots together: the two grounds
1815
- // sit exactly one pedestal apart (~10 L ≈ 25 rgb), so an ink
1867
+ // sit exactly one pedestal apart (26 L ≈ 66 rgb at the current
1868
+ // tuning; the threshold derives from the constant), so an ink
1816
1869
  // ground that loses or doubles its shift goes red (R2 C1b).
1817
1870
  const groundOf = (r: (typeof tiles)[number]) =>
1818
1871
  Number(r.fillStyles[0]!.slice(4, -1).split(",")[0]);
1819
1872
  const groundDelta = Math.abs(groundOf(tiles[1]!) - groundOf(tiles[0]!));
1873
+ const pedRgb = FILTER_PEDESTAL_L * 2.55;
1820
1874
  expect(
1821
- groundDelta >= 20 && groundDelta <= 30,
1822
- `grounds one pedestal apart (delta ${groundDelta})`,
1875
+ groundDelta >= pedRgb - 6 && groundDelta <= pedRgb + 6,
1876
+ `grounds one pedestal apart (delta ${groundDelta}, want ~${pedRgb.toFixed(0)})`,
1823
1877
  ).toBe(true);
1824
1878
  // Near-black ground regardless of the ink: L 10 → rgb ≈ 26.
1825
1879
  const [gr, gg, gb] = tiles[0]!.fillStyles[0]!.slice(4, -1).split(",").map(Number);
@@ -1931,7 +1985,9 @@ describe("HkInput password reveal strategies", () => {
1931
1985
  );
1932
1986
  expect(tiles.length).toBe(2);
1933
1987
  const counts1 = tiles.map((r) => r.fillStyles.length);
1934
- expect(counts1[0]!, "ground + dots on hold 1").toBeGreaterThan(1000);
1988
+ // Dense field (R1 F5): ~2341 dots per tile at 28 px²/dot — the
1989
+ // old sparse 45 px²/dot (1457 fills) must fail this floor.
1990
+ expect(counts1[0]!, "dense spatter field on hold 1").toBeGreaterThan(2000);
1935
1991
  await hold();
1936
1992
  const counts2 = tiles.map((r) => r.fillStyles.length);
1937
1993
  expect(counts2[0]).toBe(counts1[0]! * 2);
@@ -77,14 +77,13 @@ export default defineComponent({
77
77
  /**
78
78
  * Reveal content for the password eye (`variant="password"`,
79
79
  * `passwordTrailing="eye"`):
80
- * - "filter" (default): dual FAST counter-drifting spatter layers
81
- * in strict black/white/gray — static BOLD glyph apertures
82
- * filled with one gray spatter texture over a statistically
83
- * matched field streaming the opposite way on a UNIFORM
84
- * near-black ground (white-on-black in every theme), plus a
85
- * small brightening pedestal and a white halo; the speed lets
86
- * persistence of vision average the glyphs readable. A
87
- * single-frame screenshot carries no glyph structure.
80
+ * - "filter" (default): dual counter-drifting spatter layers in
81
+ * strict black/white/gray — static BOLD glyph apertures filled
82
+ * with a BRIGHT dense gray spatter over a statistically matched
83
+ * DIM field drifting the opposite way on a UNIFORM near-black
84
+ * ground (white-on-black in every theme), plus a large
85
+ * brightening pedestal and a white halo. A single-frame
86
+ * screenshot carries no glyph structure.
88
87
  * - "sweep": a readable window — ordinary high-contrast text inside
89
88
  * a narrow band sweeping across the row over the boiling-noise
90
89
  * field. Easy to read; a single screenshot leaks the characters
@@ -0,0 +1,158 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkNodeCanvas from "./HkNodeCanvas";
5
+
6
+ /** Mount a component into a detached div and return the root element. */
7
+ function mountToDom(component: ReturnType<typeof defineComponent>): HTMLElement {
8
+ const el = document.createElement("div");
9
+ document.body.appendChild(el);
10
+ const app = createApp(component);
11
+ app.mount(el);
12
+ return el;
13
+ }
14
+
15
+ function pointerEvent(type: string, opts: Record<string, number | boolean> = {}): PointerEvent {
16
+ return new PointerEvent(type, {
17
+ pointerId: (opts.pointerId as number) ?? 1,
18
+ clientX: (opts.clientX as number) ?? 0,
19
+ clientY: (opts.clientY as number) ?? 0,
20
+ button: (opts.button as number) ?? 0,
21
+ bubbles: true,
22
+ cancelable: true,
23
+ ...opts,
24
+ });
25
+ }
26
+
27
+ describe("HkNodeCanvas gesture & animation upgrades", () => {
28
+ describe("tweenCamera", () => {
29
+ it("exposes tweenCamera as a function", () => {
30
+ const exposed: Record<string, unknown> = {};
31
+ const Host = defineComponent({
32
+ setup() {
33
+ const canvasRef = ref<unknown>(null);
34
+ const check = () => {
35
+ const vm = canvasRef.value as Record<string, unknown> | null;
36
+ if (vm) Object.assign(exposed, { tweenCamera: vm.tweenCamera });
37
+ };
38
+ return () =>
39
+ h(HkNodeCanvas, { ref: canvasRef, minimap: false, onVnodeMounted: check }, { default: () => h("div") });
40
+ },
41
+ });
42
+ const el = mountToDom(Host);
43
+ expect(typeof exposed.tweenCamera).toBe("function");
44
+ el.remove();
45
+ });
46
+
47
+ it("tweenMs=0 (default) does not start an animation frame", () => {
48
+ let tweenFn: ((...a: unknown[]) => void) | null = null;
49
+ const Host = defineComponent({
50
+ setup() {
51
+ const canvasRef = ref<unknown>(null);
52
+ const check = () => {
53
+ const vm = canvasRef.value as Record<string, unknown> | null;
54
+ if (vm) tweenFn = vm.tweenCamera as (...a: unknown[]) => void;
55
+ };
56
+ return () =>
57
+ h(HkNodeCanvas, {
58
+ ref: canvasRef,
59
+ minimap: false,
60
+ fitOnLoad: false,
61
+ onVnodeMounted: check,
62
+ }, { default: () => h("div") });
63
+ },
64
+ });
65
+ const el = mountToDom(Host);
66
+ expect(tweenFn).toBeTruthy();
67
+ // Calling with default tweenMs=0 should snap (no rAF started).
68
+ tweenFn!({ k: 2, x: 100, y: 50 });
69
+ el.remove();
70
+ });
71
+ });
72
+
73
+ describe("pinchable prop", () => {
74
+ it("defaults to true", () => {
75
+ const el = mountToDom(defineComponent({
76
+ setup() {
77
+ return () => h(HkNodeCanvas, { minimap: false }, { default: () => h("div") });
78
+ },
79
+ }));
80
+ expect(el.querySelector(".hk-node-canvas")).toBeTruthy();
81
+ el.remove();
82
+ });
83
+
84
+ it("can be disabled", () => {
85
+ const el = mountToDom(defineComponent({
86
+ setup() {
87
+ return () => h(HkNodeCanvas, { minimap: false, pinchable: false }, { default: () => h("div") });
88
+ },
89
+ }));
90
+ expect(el.querySelector(".hk-node-canvas")).toBeTruthy();
91
+ el.remove();
92
+ });
93
+ });
94
+
95
+ describe("reduced motion", () => {
96
+ it("respectReducedMotion defaults to true", () => {
97
+ const el = mountToDom(defineComponent({
98
+ setup() {
99
+ return () => h(HkNodeCanvas, { minimap: false }, { default: () => h("div") });
100
+ },
101
+ }));
102
+ expect(el.querySelector(".hk-node-canvas")).toBeTruthy();
103
+ el.remove();
104
+ });
105
+ });
106
+
107
+ describe("pointerScale prop", () => {
108
+ it("accepts a pointerScale function", () => {
109
+ const el = mountToDom(defineComponent({
110
+ setup() {
111
+ return () =>
112
+ h(HkNodeCanvas, {
113
+ minimap: false,
114
+ pointerScale: () => ({ x: 2, y: 2 }),
115
+ }, { default: () => h("div") });
116
+ },
117
+ }));
118
+ expect(el.querySelector(".hk-node-canvas")).toBeTruthy();
119
+ el.remove();
120
+ });
121
+ });
122
+
123
+ describe("pinch gesture (integration)", () => {
124
+ it("two simultaneous pointers do not crash", async () => {
125
+ const Host = defineComponent({
126
+ setup() {
127
+ return () =>
128
+ h(HkNodeCanvas, {
129
+ minimap: false,
130
+ pannable: true,
131
+ pinchable: true,
132
+ }, { default: () => h("div") });
133
+ },
134
+ });
135
+ const el = mountToDom(Host);
136
+ const root = el.querySelector(".hk-node-canvas")!;
137
+ expect(root).toBeTruthy();
138
+
139
+ // Simulate two fingers down.
140
+ root.dispatchEvent(pointerEvent("pointerdown", { pointerId: 10, clientX: 100, clientY: 100 }));
141
+ root.dispatchEvent(pointerEvent("pointerdown", { pointerId: 20, clientX: 200, clientY: 100 }));
142
+ await nextTick();
143
+
144
+ // Simulate one finger moving (pinch in progress).
145
+ root.dispatchEvent(pointerEvent("pointermove", { pointerId: 20, clientX: 150, clientY: 100 }));
146
+ await nextTick();
147
+
148
+ // Release both fingers.
149
+ root.dispatchEvent(pointerEvent("pointerup", { pointerId: 10 }));
150
+ root.dispatchEvent(pointerEvent("pointerup", { pointerId: 20 }));
151
+ await nextTick();
152
+
153
+ // No crash — the component is still mounted.
154
+ expect(el.querySelector(".hk-node-canvas")).toBeTruthy();
155
+ el.remove();
156
+ });
157
+ });
158
+ });
@@ -219,6 +219,26 @@ export default defineComponent({
219
219
  type: String as PropType<PrintPaper>,
220
220
  default: "auto" as PrintPaper,
221
221
  },
222
+
223
+ // ── Gesture & animation upgrades ─────────────────────────────
224
+
225
+ /** Camera tween duration in ms (0 disables tweening). Only
226
+ * `tweenCamera()` animates over this duration with ease-out cubic.
227
+ * Snaps instantly when prefers-reduced-motion is active and
228
+ * `respectReducedMotion` is on (default). */
229
+ tweenMs: { type: Number, default: 0 },
230
+ /** Two-finger pinch-to-zoom on touch devices. */
231
+ pinchable: { type: Boolean, default: true },
232
+ /** Snap tweens when prefers-reduced-motion is active (default). */
233
+ respectReducedMotion: { type: Boolean, default: true },
234
+ /** DPI scale provider: maps pointer coordinates to drawn coordinates.
235
+ * Needed when the canvas is inside a CSS `zoom` or `transform: scale()`
236
+ * container — pointer events report unscaled page coordinates but the
237
+ * camera measures the canvas's own pixels. */
238
+ pointerScale: {
239
+ type: Function as PropType<(() => { x: number; y: number }) | undefined>,
240
+ default: undefined,
241
+ },
222
242
  },
223
243
  emits: ["update:camera"],
224
244
  setup(props, { slots, emit, expose }) {
@@ -237,6 +257,27 @@ export default defineComponent({
237
257
  /** Set by setCamera (gestures and imperative calls) — never by `fit`. */
238
258
  let movedByHand = false;
239
259
 
260
+ // ── Pinch-to-zoom state ──────────────────────────────────────
261
+ /** Active pointers for pinch tracking (pointerId → screen position). */
262
+ const activePointers = new Map<number, { x: number; y: number }>();
263
+ /** Pinch gesture snapshot: start distance/zoom/midpoint for the ratio. */
264
+ let pinchSnapshot: {
265
+ startDist: number;
266
+ startZoom: number;
267
+ midX: number;
268
+ midY: number;
269
+ } | null = null;
270
+
271
+ // ── Camera tween state ───────────────────────────────────────
272
+ let tweenRaf: number | null = null;
273
+
274
+ // ── Motion constraints ───────────────────────────────────────
275
+ const reducedMotion = computed(() => {
276
+ if (!props.respectReducedMotion) return false;
277
+ return typeof window !== "undefined"
278
+ && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
279
+ });
280
+
240
281
  // ── Canvas2D painter layer ───────────────────────────────────
241
282
  const canvasEl = shallowRef<HTMLCanvasElement | null>(null);
242
283
  /** Registered painters, sorted by z on each frame. */
@@ -495,6 +536,11 @@ export default defineComponent({
495
536
  }
496
537
 
497
538
  function onPointerDown(event: PointerEvent) {
539
+ // When a pinch is live, cancel any single-pointer pan.
540
+ if (pinchSnapshot !== null) {
541
+ panning = null;
542
+ return;
543
+ }
498
544
  if (!props.pannable || event.button !== 0) return;
499
545
  // A host that drags its own nodes suppresses the pan by preventing the
500
546
  // default on its card's pointerdown: the root listens in the bubble
@@ -517,6 +563,7 @@ export default defineComponent({
517
563
  ) {
518
564
  return;
519
565
  }
566
+ trackPointer(event);
520
567
  panning = {
521
568
  pointerId: event.pointerId,
522
569
  x: event.clientX,
@@ -535,6 +582,14 @@ export default defineComponent({
535
582
  }
536
583
 
537
584
  function onPointerMove(event: PointerEvent) {
585
+ // Update pinch tracking when a tracked pointer moves.
586
+ if (activePointers.has(event.pointerId)) {
587
+ activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
588
+ if (pinchSnapshot !== null) {
589
+ updatePinch();
590
+ return;
591
+ }
592
+ }
538
593
  if (!panning || event.pointerId !== panning.pointerId) return;
539
594
  // The gesture is only captured once it passes the slop, so a release that
540
595
  // happens outside the canvas never reaches this element. Without this the
@@ -567,6 +622,8 @@ export default defineComponent({
567
622
  }
568
623
 
569
624
  function endPan(event: PointerEvent) {
625
+ endPointerTracking(event);
626
+ if (pinchSnapshot !== null) return;
570
627
  if (!panning || event.pointerId !== panning.pointerId) return;
571
628
  const pointerId = panning.pointerId;
572
629
  panning = null;
@@ -580,6 +637,11 @@ export default defineComponent({
580
637
  // canvas clears focus), and ending the gesture there would kill the first
581
638
  // pan after any click. Only the window's own blur ends it.
582
639
  if (event.target && event.target !== window) return;
640
+ // Clear pinch state too: a blur during a two-finger gesture would
641
+ // otherwise leave a stale snapshot that permanently suppresses pan
642
+ // (every pointerdown would hit the pinch-active early return).
643
+ activePointers.clear();
644
+ pinchSnapshot = null;
583
645
  if (!panning) return;
584
646
  const pointerId = panning.pointerId;
585
647
  panning = null;
@@ -668,6 +730,7 @@ export default defineComponent({
668
730
  );
669
731
 
670
732
  onBeforeUnmount(() => {
733
+ cancelTween();
671
734
  stopFrameLoop();
672
735
  ro?.disconnect();
673
736
  ro = null;
@@ -820,6 +883,97 @@ export default defineComponent({
820
883
  return copy;
821
884
  }
822
885
 
886
+ // ── Pinch-to-zoom ────────────────────────────────────────────
887
+
888
+ /** Track active pointers for pinch detection. */
889
+ function trackPointer(event: PointerEvent): void {
890
+ activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
891
+ if (
892
+ props.pinchable
893
+ && !props.printMode
894
+ && activePointers.size === 2
895
+ && pinchSnapshot === null
896
+ ) {
897
+ const [a, b] = [...activePointers.values()];
898
+ const dx = b.x - a.x;
899
+ const dy = b.y - a.y;
900
+ pinchSnapshot = {
901
+ startDist: Math.hypot(dx, dy),
902
+ startZoom: camera.value.k,
903
+ midX: (a.x + b.x) / 2,
904
+ midY: (a.y + b.y) / 2,
905
+ };
906
+ }
907
+ }
908
+
909
+ /** Update pinch zoom from current pointer positions. */
910
+ function updatePinch(): void {
911
+ if (!pinchSnapshot || activePointers.size < 2) return;
912
+ const [a, b] = [...activePointers.values()];
913
+ const dist = Math.hypot(b.x - a.x, b.y - a.y);
914
+ if (pinchSnapshot.startDist < 1) return;
915
+ const ratio = dist / pinchSnapshot.startDist;
916
+ const targetK = normalizeZoom(pinchSnapshot.startZoom * ratio);
917
+ // Zoom anchored at the pinch midpoint.
918
+ const rect = rootEl.value?.getBoundingClientRect();
919
+ if (!rect) return;
920
+ const scale = props.pointerScale?.() ?? { x: 1, y: 1 };
921
+ const midX = ((pinchSnapshot.midX - rect.left) / scale.x);
922
+ const midY = ((pinchSnapshot.midY - rect.top) / scale.y);
923
+ const cam = camera.value;
924
+ const k = targetK;
925
+ const x = midX - ((midX - cam.x) * k) / cam.k;
926
+ const y = midY - ((midY - cam.y) * k) / cam.k;
927
+ setCamera({ k, x, y });
928
+ }
929
+
930
+ /** End pinch tracking when a pointer lifts. */
931
+ function endPointerTracking(event: PointerEvent): void {
932
+ activePointers.delete(event.pointerId);
933
+ if (activePointers.size < 2) {
934
+ pinchSnapshot = null;
935
+ }
936
+ }
937
+
938
+ // ── Camera tween ─────────────────────────────────────────────
939
+
940
+ /** Cancel any running tween. */
941
+ function cancelTween(): void {
942
+ if (tweenRaf !== null) {
943
+ cancelAnimationFrame(tweenRaf);
944
+ tweenRaf = null;
945
+ }
946
+ }
947
+
948
+ /** Animate the camera to a target over `tweenMs`, or snap if
949
+ * reduced-motion is active or tweenMs is 0. */
950
+ function tweenCamera(target: NodeCanvasCamera, durationMs?: number): void {
951
+ cancelTween();
952
+ const ms = durationMs ?? props.tweenMs;
953
+ if (ms <= 0 || reducedMotion.value) {
954
+ setCamera(target);
955
+ return;
956
+ }
957
+ const start = { ...camera.value };
958
+ const startTime = performance.now();
959
+
960
+ const step = (now: number) => {
961
+ const t = Math.min((now - startTime) / ms, 1);
962
+ const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
963
+ setCamera({
964
+ k: start.k + (target.k - start.k) * eased,
965
+ x: start.x + (target.x - start.x) * eased,
966
+ y: start.y + (target.y - start.y) * eased,
967
+ });
968
+ if (t < 1) {
969
+ tweenRaf = requestAnimationFrame(step);
970
+ } else {
971
+ tweenRaf = null;
972
+ }
973
+ };
974
+ tweenRaf = requestAnimationFrame(step);
975
+ }
976
+
823
977
  expose({
824
978
  camera,
825
979
  viewport,
@@ -829,6 +983,7 @@ export default defineComponent({
829
983
  screenToWorld,
830
984
  worldToScreen,
831
985
  setCamera,
986
+ tweenCamera,
832
987
  // ── New: rendering base capabilities ──
833
988
  levelOfDetail,
834
989
  registerPainter,