@kgdiff/renderer 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -2
  3. package/dist/atlas.d.ts +55 -0
  4. package/dist/atlas.d.ts.map +1 -0
  5. package/dist/atlas.js +132 -0
  6. package/dist/atlas.js.map +1 -0
  7. package/dist/camera.d.ts +116 -0
  8. package/dist/camera.d.ts.map +1 -0
  9. package/dist/camera.js +368 -0
  10. package/dist/camera.js.map +1 -0
  11. package/dist/color.d.ts +12 -0
  12. package/dist/color.d.ts.map +1 -0
  13. package/dist/color.js +51 -0
  14. package/dist/color.js.map +1 -0
  15. package/dist/ctx.d.ts +57 -0
  16. package/dist/ctx.d.ts.map +1 -0
  17. package/dist/ctx.js +28 -0
  18. package/dist/ctx.js.map +1 -0
  19. package/dist/draw/background.d.ts +33 -0
  20. package/dist/draw/background.d.ts.map +1 -0
  21. package/dist/draw/background.js +105 -0
  22. package/dist/draw/background.js.map +1 -0
  23. package/dist/draw/badges.d.ts +26 -0
  24. package/dist/draw/badges.d.ts.map +1 -0
  25. package/dist/draw/badges.js +82 -0
  26. package/dist/draw/badges.js.map +1 -0
  27. package/dist/draw/collapsed.d.ts +15 -0
  28. package/dist/draw/collapsed.d.ts.map +1 -0
  29. package/dist/draw/collapsed.js +67 -0
  30. package/dist/draw/collapsed.js.map +1 -0
  31. package/dist/draw/edges.d.ts +14 -0
  32. package/dist/draw/edges.d.ts.map +1 -0
  33. package/dist/draw/edges.js +115 -0
  34. package/dist/draw/edges.js.map +1 -0
  35. package/dist/draw/glyphs.d.ts +31 -0
  36. package/dist/draw/glyphs.d.ts.map +1 -0
  37. package/dist/draw/glyphs.js +114 -0
  38. package/dist/draw/glyphs.js.map +1 -0
  39. package/dist/draw/halos.d.ts +19 -0
  40. package/dist/draw/halos.d.ts.map +1 -0
  41. package/dist/draw/halos.js +91 -0
  42. package/dist/draw/halos.js.map +1 -0
  43. package/dist/draw/labels.d.ts +55 -0
  44. package/dist/draw/labels.d.ts.map +1 -0
  45. package/dist/draw/labels.js +300 -0
  46. package/dist/draw/labels.js.map +1 -0
  47. package/dist/draw/link.d.ts +25 -0
  48. package/dist/draw/link.d.ts.map +1 -0
  49. package/dist/draw/link.js +122 -0
  50. package/dist/draw/link.js.map +1 -0
  51. package/dist/draw/nodes.d.ts +13 -0
  52. package/dist/draw/nodes.d.ts.map +1 -0
  53. package/dist/draw/nodes.js +165 -0
  54. package/dist/draw/nodes.js.map +1 -0
  55. package/dist/draw/shapes.d.ts +7 -0
  56. package/dist/draw/shapes.d.ts.map +1 -0
  57. package/dist/draw/shapes.js +26 -0
  58. package/dist/draw/shapes.js.map +1 -0
  59. package/dist/draw/view.d.ts +36 -0
  60. package/dist/draw/view.d.ts.map +1 -0
  61. package/dist/draw/view.js +16 -0
  62. package/dist/draw/view.js.map +1 -0
  63. package/dist/frame-cache.d.ts +103 -0
  64. package/dist/frame-cache.d.ts.map +1 -0
  65. package/dist/frame-cache.js +349 -0
  66. package/dist/frame-cache.js.map +1 -0
  67. package/dist/index.d.ts +162 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +19 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/input.d.ts +81 -0
  72. package/dist/input.d.ts.map +1 -0
  73. package/dist/input.js +329 -0
  74. package/dist/input.js.map +1 -0
  75. package/dist/labels.d.ts +103 -0
  76. package/dist/labels.d.ts.map +1 -0
  77. package/dist/labels.js +203 -0
  78. package/dist/labels.js.map +1 -0
  79. package/dist/math.d.ts +33 -0
  80. package/dist/math.d.ts.map +1 -0
  81. package/dist/math.js +138 -0
  82. package/dist/math.js.map +1 -0
  83. package/dist/minimap.d.ts +56 -0
  84. package/dist/minimap.d.ts.map +1 -0
  85. package/dist/minimap.js +501 -0
  86. package/dist/minimap.js.map +1 -0
  87. package/dist/paint-stats.d.ts +17 -0
  88. package/dist/paint-stats.d.ts.map +1 -0
  89. package/dist/paint-stats.js +23 -0
  90. package/dist/paint-stats.js.map +1 -0
  91. package/dist/paint.d.ts +64 -0
  92. package/dist/paint.d.ts.map +1 -0
  93. package/dist/paint.js +197 -0
  94. package/dist/paint.js.map +1 -0
  95. package/dist/pick.d.ts +26 -0
  96. package/dist/pick.d.ts.map +1 -0
  97. package/dist/pick.js +125 -0
  98. package/dist/pick.js.map +1 -0
  99. package/dist/renderer.d.ts +28 -0
  100. package/dist/renderer.d.ts.map +1 -0
  101. package/dist/renderer.js +470 -0
  102. package/dist/renderer.js.map +1 -0
  103. package/dist/smooth-zoom.d.ts +21 -0
  104. package/dist/smooth-zoom.d.ts.map +1 -0
  105. package/dist/smooth-zoom.js +52 -0
  106. package/dist/smooth-zoom.js.map +1 -0
  107. package/dist/spatial.d.ts +26 -0
  108. package/dist/spatial.d.ts.map +1 -0
  109. package/dist/spatial.js +103 -0
  110. package/dist/spatial.js.map +1 -0
  111. package/dist/text.d.ts +36 -0
  112. package/dist/text.d.ts.map +1 -0
  113. package/dist/text.js +123 -0
  114. package/dist/text.js.map +1 -0
  115. package/package.json +47 -3
  116. package/src/atlas.ts +171 -0
  117. package/src/camera.ts +482 -0
  118. package/src/color.ts +58 -0
  119. package/src/ctx.ts +102 -0
  120. package/src/draw/background.ts +139 -0
  121. package/src/draw/badges.ts +93 -0
  122. package/src/draw/collapsed.ts +86 -0
  123. package/src/draw/edges.ts +135 -0
  124. package/src/draw/glyphs.ts +168 -0
  125. package/src/draw/halos.ts +101 -0
  126. package/src/draw/labels.ts +399 -0
  127. package/src/draw/link.ts +157 -0
  128. package/src/draw/nodes.ts +184 -0
  129. package/src/draw/shapes.ts +36 -0
  130. package/src/draw/view.ts +37 -0
  131. package/src/frame-cache.ts +382 -0
  132. package/src/index.ts +161 -0
  133. package/src/input.ts +370 -0
  134. package/src/labels.ts +269 -0
  135. package/src/math.ts +185 -0
  136. package/src/minimap.ts +545 -0
  137. package/src/paint-stats.ts +38 -0
  138. package/src/paint.ts +235 -0
  139. package/src/pick.ts +173 -0
  140. package/src/renderer.ts +539 -0
  141. package/src/smooth-zoom.ts +65 -0
  142. package/src/spatial.ts +94 -0
  143. package/src/text.ts +130 -0
package/src/index.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @kgdiff/renderer: high-DPI Canvas 2D renderer for kgdiff scenes.
3
+ *
4
+ * The renderer draws `FrameVisuals` (from @kgdiff/scene) and owns everything
5
+ * pixel-related: camera, culling, level of detail, label placement, hit
6
+ * testing, swipe clipping and the minimap. It knows nothing about diffs.
7
+ *
8
+ * Quality bars: docs/visual-language.md §Rendering and docs/architecture.md §Budgets.
9
+ *
10
+ * Primitive semantics follow @kgdiff/scene's types: nested alphas (label, ring, halo, badge)
11
+ * multiply with their owner's; the curve convention is shared by edges and glyphs; arrowheads
12
+ * follow `EdgeVisual.arrowMinZoom`; glyph kinds this version does not know draw as plain lines.
13
+ */
14
+ import type { Point, Rect } from '@kgdiff/core';
15
+ import type { CollapsedGroup, FrameVisuals, Theme } from '@kgdiff/scene';
16
+ import { createMinimapImpl } from './minimap.js';
17
+ import { createRendererImpl } from './renderer.js';
18
+
19
+ /** World center + zoom. screen = (world − (x, y)) · k + viewportCenter. */
20
+ export interface CameraState {
21
+ readonly x: number;
22
+ readonly y: number;
23
+ readonly k: number;
24
+ }
25
+
26
+ export interface CameraTransitionOptions {
27
+ /** Extra space around the bounds, px. Default 48. */
28
+ readonly padding?: number | { top: number; right: number; bottom: number; left: number };
29
+ /** Default true (false when reduced motion). */
30
+ readonly animate?: boolean;
31
+ /** Override the computed duration (ms). */
32
+ readonly duration?: number;
33
+ /** Never zoom in beyond this. Default 2.5. */
34
+ readonly maxZoom?: number;
35
+ }
36
+
37
+ export interface Camera {
38
+ get(): CameraState;
39
+ /** Jump (cancels any running transition). */
40
+ set(state: CameraState): void;
41
+ /** Smooth zoom-and-pan (van Wijk–Nuij) so `bounds` fills the viewport. Resolves when done or cancelled. */
42
+ flyTo(bounds: Rect, options?: CameraTransitionOptions): Promise<void>;
43
+ /** Fit the given bounds (or the frame's content bounds). */
44
+ fit(bounds?: Rect, options?: CameraTransitionOptions): Promise<void>;
45
+ /**
46
+ * Multiply the zoom by `factor`, keeping the world point under `around` fixed (screen px,
47
+ * canvas-relative; default the viewport centre). Animated unless `animate: false` or
48
+ * reduced motion; repeated calls compound the target.
49
+ */
50
+ zoomBy(factor: number, around?: Point, options?: { readonly animate?: boolean }): void;
51
+ /** Move the content by (dx, dy) screen px (positive dx moves it right). Cancels motions. */
52
+ panBy(dx: number, dy: number): void;
53
+ worldToScreen(p: Point): Point;
54
+ screenToWorld(p: Point): Point;
55
+ /** The part of the world currently visible. */
56
+ viewport(): Rect;
57
+ }
58
+
59
+ export interface PickResult {
60
+ readonly type: 'node' | 'edge' | 'collapsed';
61
+ /** Node / edge index into the scene, or collapsed-group index. */
62
+ readonly index: number;
63
+ }
64
+
65
+ /** Swipe comparison: `before` drawn left of `split`, `after` right of it. */
66
+ export interface SplitFrame {
67
+ /** Divider position as a fraction of the viewport width, [0, 1]. */
68
+ readonly split: number;
69
+ readonly before: FrameVisuals;
70
+ readonly after: FrameVisuals;
71
+ }
72
+
73
+ export interface RendererEvents {
74
+ hover: PickResult | null;
75
+ click: {
76
+ readonly pick: PickResult | null;
77
+ readonly world: Point;
78
+ readonly event: PointerEvent | MouseEvent;
79
+ };
80
+ dblclick: { readonly pick: PickResult | null; readonly world: Point; readonly event: MouseEvent };
81
+ /**
82
+ * The camera moved (at most once per frame, and when the viewport is resized). `source` is
83
+ * 'user' when any change in that frame came from input (drag, wheel, pinch, inertia, minimap)
84
+ * and 'program' for API calls (`set`, `flyTo`, `fit`, `zoomBy`, `panBy`) and resizes.
85
+ */
86
+ camera: CameraState & { readonly source: 'user' | 'program' };
87
+ /** Fired after each drawn frame with its draw time. */
88
+ frame: { readonly ms: number };
89
+ }
90
+
91
+ export interface RendererOptions {
92
+ readonly theme: Theme;
93
+ /** Default `window.devicePixelRatio`, capped at 3. */
94
+ readonly pixelRatio?: number;
95
+ /** Default 0.05. */
96
+ readonly minZoom?: number;
97
+ /** Default 6. */
98
+ readonly maxZoom?: number;
99
+ /** Pan / zoom / pick with pointer, wheel and touch. Default true. */
100
+ readonly interactive?: boolean;
101
+ /** Draw the dot grid. Default true. */
102
+ readonly grid?: boolean;
103
+ /** Insets (px) the camera keeps clear when fitting, e.g. for floating UI. */
104
+ readonly safeArea?: { top: number; right: number; bottom: number; left: number };
105
+ }
106
+
107
+ export interface Renderer {
108
+ readonly canvas: HTMLCanvasElement;
109
+ readonly camera: Camera;
110
+ /** What to draw. Cheap: only marks the frame dirty; drawing happens on the next animation frame. */
111
+ setFrame(frame: FrameVisuals | SplitFrame): void;
112
+ setTheme(theme: Theme): void;
113
+ setSafeArea(area: { top: number; right: number; bottom: number; left: number }): void;
114
+ /** Topmost element under a screen point (px, relative to the canvas). */
115
+ pick(x: number, y: number): PickResult | null;
116
+ on<K extends keyof RendererEvents>(
117
+ type: K,
118
+ listener: (event: RendererEvents[K]) => void,
119
+ ): () => void;
120
+ /** Re-measure the container (called automatically through ResizeObserver). */
121
+ resize(): void;
122
+ requestRender(): void;
123
+ /** PNG snapshot of the current view at the given scale (default pixelRatio). */
124
+ toDataURL(scale?: number): string;
125
+ destroy(): void;
126
+ }
127
+
128
+ export interface MinimapOptions {
129
+ /** px, default 184 × 128. */
130
+ readonly width?: number;
131
+ readonly height?: number;
132
+ }
133
+
134
+ export interface Minimap {
135
+ readonly canvas: HTMLCanvasElement;
136
+ /** Redraw marks (call when the frame or theme changes; viewport tracking is automatic). */
137
+ update(frame: FrameVisuals, collapsed?: readonly CollapsedGroup[]): void;
138
+ destroy(): void;
139
+ }
140
+
141
+ /**
142
+ * Create a renderer that fills `container` (which should be position: relative).
143
+ *
144
+ * The first frame with content is fitted automatically unless the camera was moved before.
145
+ * Throws `KgdiffError('invalid-input')` for an invalid container or zoom / pixel-ratio options.
146
+ */
147
+ export function createRenderer(container: HTMLElement, options: RendererOptions): Renderer {
148
+ return createRendererImpl(container, options);
149
+ }
150
+
151
+ /**
152
+ * A minimap bound to `renderer`: change marks, viewport rectangle, click / drag to navigate.
153
+ * Call `update` when the frame changes; viewport and theme changes are tracked automatically.
154
+ */
155
+ export function createMinimap(
156
+ container: HTMLElement,
157
+ renderer: Renderer,
158
+ options?: MinimapOptions,
159
+ ): Minimap {
160
+ return createMinimapImpl(container, renderer, options);
161
+ }
package/src/input.ts ADDED
@@ -0,0 +1,370 @@
1
+ /**
2
+ * Pointer, wheel, touch and Safari gesture handling.
3
+ *
4
+ * - Drag (mouse / pen / one finger) pans with inertia; a press that moves less than
5
+ * `DRAG_THRESHOLD` px is a click.
6
+ * - Two pointers pinch-zoom around their centroid and pan with it.
7
+ * - Wheel: ctrl/meta + wheel or trackpad pinch → zoom at the cursor; two-finger trackpad
8
+ * scroll → pan; mouse wheel notches → smooth zoom at the cursor.
9
+ */
10
+ import type { CameraController } from './camera.js';
11
+ import { clamp } from './math.js';
12
+
13
+ /** A press that moves less than this (px) is a click, not a drag. */
14
+ export const DRAG_THRESHOLD = 4;
15
+ /** Wheel events closer than this (ms) belong to one gesture and keep its classification. */
16
+ export const WHEEL_GESTURE_GAP = 160;
17
+ /** Minimum release speed (px/ms) that starts an inertial glide. */
18
+ export const GLIDE_START_SPEED = 0.1;
19
+ /** Release speeds are capped (px/ms) so a jittery flick cannot fling the graph away. */
20
+ export const GLIDE_MAX_SPEED = 6;
21
+ /** macOS mouse wheels report multiples of this delta (with acceleration). */
22
+ const MAC_WHEEL_STEP = 4.000244140625;
23
+
24
+ export type WheelIntent = 'pinch' | 'notch' | 'pan';
25
+
26
+ export interface WheelLike {
27
+ readonly deltaX: number;
28
+ readonly deltaY: number;
29
+ readonly deltaMode: number;
30
+ readonly ctrlKey: boolean;
31
+ readonly metaKey: boolean;
32
+ /** Non-standard (Blink / WebKit); trackpads report exactly −3 × deltaY. */
33
+ readonly wheelDeltaY?: number | undefined;
34
+ }
35
+
36
+ export interface WheelMemory {
37
+ last: number;
38
+ intent: WheelIntent | null;
39
+ }
40
+
41
+ export function createWheelMemory(): WheelMemory {
42
+ return { last: Number.NEGATIVE_INFINITY, intent: null };
43
+ }
44
+
45
+ function isMacWheelStep(a: number): boolean {
46
+ const n = a / MAC_WHEEL_STEP;
47
+ return n >= 1 && Math.abs(n - Math.round(n)) < 1e-6;
48
+ }
49
+
50
+ /** Whether a vertical pixel delta looks like a discrete mouse-wheel notch. */
51
+ export function isNotchDelta(deltaY: number): boolean {
52
+ const a = Math.abs(deltaY);
53
+ if (a === 0) return false;
54
+ return isMacWheelStep(a) || (a >= 50 && Number.isInteger(a));
55
+ }
56
+
57
+ /**
58
+ * Classify a wheel event. Events of one gesture keep the first event's intent, because a
59
+ * fast trackpad flick can produce large integer deltas that look like a mouse notch.
60
+ */
61
+ export function classifyWheel(e: WheelLike, memory: WheelMemory, now: number): WheelIntent {
62
+ let intent: WheelIntent;
63
+ if (e.ctrlKey || e.metaKey) {
64
+ intent = e.deltaMode !== 0 || isNotchDelta(e.deltaY) ? 'notch' : 'pinch';
65
+ } else if (
66
+ memory.intent !== null &&
67
+ memory.intent !== 'pinch' &&
68
+ now - memory.last < WHEEL_GESTURE_GAP
69
+ ) {
70
+ intent = memory.intent;
71
+ } else if (e.deltaMode !== 0) {
72
+ intent = 'notch';
73
+ } else if (e.deltaX !== 0) {
74
+ intent = 'pan';
75
+ } else if (e.wheelDeltaY !== undefined && e.wheelDeltaY !== 0) {
76
+ intent = e.wheelDeltaY === -3 * e.deltaY ? 'pan' : 'notch';
77
+ } else {
78
+ intent = isNotchDelta(e.deltaY) ? 'notch' : 'pan';
79
+ }
80
+ memory.last = now;
81
+ memory.intent = intent;
82
+ return intent;
83
+ }
84
+
85
+ /** Wheel deltas in css px (line and page modes converted). */
86
+ export function wheelPixels(e: WheelLike, pageHeight: number): { x: number; y: number } {
87
+ const m = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? Math.max(pageHeight, 1) : 1;
88
+ return { x: e.deltaX * m, y: e.deltaY * m };
89
+ }
90
+
91
+ /**
92
+ * Zoom factor for a vertical wheel delta (px). Pinch deltas follow the native pinch scale
93
+ * (`exp(−dy / 100)`); notches zoom ≈ 1.33× per 100 px notch, macOS steps ≈ 1.1× each.
94
+ */
95
+ export function wheelZoomFactor(intent: 'pinch' | 'notch', deltaY: number): number {
96
+ if (intent === 'pinch') return Math.exp(-clamp(deltaY, -50, 50) / 100);
97
+ const a = Math.abs(deltaY);
98
+ const dy = isMacWheelStep(a) ? Math.sign(deltaY) * Math.round(a / MAC_WHEEL_STEP) * 33 : deltaY;
99
+ return 2 ** (-clamp(dy, -240, 240) / 240);
100
+ }
101
+
102
+ /** Release velocity from the last ~100 ms of pointer samples (ring buffer, allocation-free). */
103
+ export class VelocityTracker {
104
+ private readonly t = new Float64Array(8);
105
+ private readonly x = new Float64Array(8);
106
+ private readonly y = new Float64Array(8);
107
+ private n = 0;
108
+ private head = 0;
109
+
110
+ reset(): void {
111
+ this.n = 0;
112
+ this.head = 0;
113
+ }
114
+
115
+ add(t: number, x: number, y: number): void {
116
+ this.t[this.head] = t;
117
+ this.x[this.head] = x;
118
+ this.y[this.head] = y;
119
+ this.head = (this.head + 1) % 8;
120
+ if (this.n < 8) this.n++;
121
+ }
122
+
123
+ /** px/ms; zero when the pointer rested for more than 50 ms before `now`. */
124
+ velocity(now: number, window = 100): { x: number; y: number } {
125
+ if (this.n < 2) return { x: 0, y: 0 };
126
+ const newest = (this.head + 7) % 8;
127
+ const tn = this.t[newest] ?? 0;
128
+ if (now - tn > 50) return { x: 0, y: 0 };
129
+ let oldest = newest;
130
+ for (let i = 1; i < this.n; i++) {
131
+ const j = (newest - i + 8) % 8;
132
+ if (tn - (this.t[j] ?? 0) > window) break;
133
+ oldest = j;
134
+ }
135
+ const dt = tn - (this.t[oldest] ?? 0);
136
+ if (dt <= 0) return { x: 0, y: 0 };
137
+ let vx = ((this.x[newest] ?? 0) - (this.x[oldest] ?? 0)) / dt;
138
+ let vy = ((this.y[newest] ?? 0) - (this.y[oldest] ?? 0)) / dt;
139
+ const speed = Math.hypot(vx, vy);
140
+ if (speed > GLIDE_MAX_SPEED) {
141
+ vx *= GLIDE_MAX_SPEED / speed;
142
+ vy *= GLIDE_MAX_SPEED / speed;
143
+ }
144
+ return { x: vx, y: vy };
145
+ }
146
+ }
147
+
148
+ export interface InputHost {
149
+ readonly camera: CameraController;
150
+ /** Pointer moved over the canvas with no button pressed (css px, canvas-relative). */
151
+ hover(x: number, y: number): void;
152
+ /** Pointer left the canvas. */
153
+ leave(): void;
154
+ click(x: number, y: number, event: PointerEvent): void;
155
+ dblclick(x: number, y: number, event: MouseEvent): void;
156
+ /** A drag started / ended (cursor feedback). */
157
+ dragging(active: boolean): void;
158
+ /** Monotonic clock (ms). */
159
+ now(): number;
160
+ }
161
+
162
+ interface GestureEventLike extends Event {
163
+ readonly scale: number;
164
+ readonly clientX: number;
165
+ readonly clientY: number;
166
+ }
167
+
168
+ /** Wire input listeners on `el`; returns a function that removes all of them. */
169
+ export function attachInput(el: HTMLElement, host: InputHost): () => void {
170
+ const pointers = new Map<number, { x: number; y: number }>();
171
+ const tracker = new VelocityTracker();
172
+ const wheelMemory = createWheelMemory();
173
+ let downX = 0;
174
+ let downY = 0;
175
+ let lastX = 0;
176
+ let lastY = 0;
177
+ let dragging = false;
178
+ /** The gesture involved two pointers: never a click, no inertia. */
179
+ let multi = false;
180
+ let pinchX = 0;
181
+ let pinchY = 0;
182
+ let pinchD = 0;
183
+ let gesture = false;
184
+ let gestureScale = 1;
185
+
186
+ const local = (e: { clientX: number; clientY: number }): [number, number] => {
187
+ const r = el.getBoundingClientRect();
188
+ return [e.clientX - r.left, e.clientY - r.top];
189
+ };
190
+
191
+ const pinchState = (): [number, number, number] => {
192
+ const it = pointers.values();
193
+ const a = it.next().value;
194
+ const b = it.next().value;
195
+ if (!a || !b) return [0, 0, 0];
196
+ return [(a.x + b.x) / 2, (a.y + b.y) / 2, Math.hypot(a.x - b.x, a.y - b.y)];
197
+ };
198
+
199
+ const beginPinch = (): void => {
200
+ [pinchX, pinchY, pinchD] = pinchState();
201
+ };
202
+
203
+ const onDown = (e: PointerEvent): void => {
204
+ if (e.pointerType === 'mouse' && e.button !== 0 && e.button !== 1) return;
205
+ if (e.button === 1) e.preventDefault();
206
+ const [x, y] = local(e);
207
+ try {
208
+ el.setPointerCapture(e.pointerId);
209
+ } catch {
210
+ // Capture is best effort (synthetic events, detached elements).
211
+ }
212
+ pointers.set(e.pointerId, { x, y });
213
+ host.camera.interrupt();
214
+ if (pointers.size === 1) {
215
+ downX = lastX = x;
216
+ downY = lastY = y;
217
+ dragging = false;
218
+ multi = false;
219
+ tracker.reset();
220
+ tracker.add(host.now(), x, y);
221
+ } else {
222
+ multi = true;
223
+ beginPinch();
224
+ }
225
+ };
226
+
227
+ const onMove = (e: PointerEvent): void => {
228
+ const [x, y] = local(e);
229
+ const p = pointers.get(e.pointerId);
230
+ if (!p) {
231
+ if (pointers.size === 0) host.hover(x, y);
232
+ return;
233
+ }
234
+ p.x = x;
235
+ p.y = y;
236
+ if (pointers.size >= 2) {
237
+ const [cx, cy, d] = pinchState();
238
+ if (pinchD > 0 && d > 0) host.camera.zoomNow(d / pinchD, cx, cy);
239
+ host.camera.panNow(cx - pinchX, cy - pinchY);
240
+ pinchX = cx;
241
+ pinchY = cy;
242
+ pinchD = d;
243
+ return;
244
+ }
245
+ if (!dragging) {
246
+ if (Math.hypot(x - downX, y - downY) < DRAG_THRESHOLD) return;
247
+ dragging = true;
248
+ host.dragging(true);
249
+ }
250
+ host.camera.panNow(x - lastX, y - lastY);
251
+ lastX = x;
252
+ lastY = y;
253
+ tracker.add(host.now(), x, y);
254
+ };
255
+
256
+ const onUp = (e: PointerEvent): void => {
257
+ if (!pointers.delete(e.pointerId)) return;
258
+ const [x, y] = local(e);
259
+ if (pointers.size === 1) {
260
+ // Pinch → one finger left: keep panning with it from where it is.
261
+ const rest = pointers.values().next().value;
262
+ if (rest) {
263
+ lastX = rest.x;
264
+ lastY = rest.y;
265
+ }
266
+ tracker.reset();
267
+ if (!dragging) {
268
+ dragging = true;
269
+ host.dragging(true);
270
+ }
271
+ return;
272
+ }
273
+ if (pointers.size > 1) {
274
+ beginPinch();
275
+ return;
276
+ }
277
+ const cancelled = e.type === 'pointercancel';
278
+ if (dragging) {
279
+ host.dragging(false);
280
+ if (!cancelled && !multi) {
281
+ const v = tracker.velocity(host.now());
282
+ if (Math.hypot(v.x, v.y) > GLIDE_START_SPEED) host.camera.glide(v.x, v.y);
283
+ }
284
+ } else if (!multi && !cancelled) {
285
+ host.click(x, y, e);
286
+ }
287
+ dragging = false;
288
+ multi = false;
289
+ };
290
+
291
+ const onLeave = (): void => {
292
+ if (pointers.size === 0) host.leave();
293
+ };
294
+
295
+ const onWheel = (e: WheelEvent): void => {
296
+ e.preventDefault();
297
+ if (gesture && e.ctrlKey) return; // Safari also reports the pinch as gesture events.
298
+ let [x, y] = local(e);
299
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
300
+ x = el.clientWidth / 2;
301
+ y = el.clientHeight / 2;
302
+ }
303
+ const sample: WheelLike = {
304
+ deltaX: e.deltaX,
305
+ deltaY: e.deltaY,
306
+ deltaMode: e.deltaMode,
307
+ ctrlKey: e.ctrlKey,
308
+ metaKey: e.metaKey,
309
+ wheelDeltaY: 'wheelDeltaY' in e ? Number(e.wheelDeltaY) : undefined,
310
+ };
311
+ const intent = classifyWheel(sample, wheelMemory, host.now());
312
+ const d = wheelPixels(sample, el.clientHeight);
313
+ if (intent === 'pan') {
314
+ host.camera.interrupt();
315
+ host.camera.panNow(-d.x, -d.y);
316
+ } else if (intent === 'pinch') {
317
+ host.camera.interrupt();
318
+ host.camera.zoomNow(wheelZoomFactor('pinch', d.y), x, y);
319
+ } else {
320
+ host.camera.touched = true;
321
+ host.camera.zoomSmooth(wheelZoomFactor('notch', d.y), x, y);
322
+ }
323
+ };
324
+
325
+ const onDblClick = (e: MouseEvent): void => {
326
+ const [x, y] = local(e);
327
+ host.dblclick(x, y, e);
328
+ };
329
+
330
+ const onGestureStart = (e: Event): void => {
331
+ e.preventDefault();
332
+ if (pointers.size >= 2) return; // Touch pinch is handled through pointer events.
333
+ gesture = true;
334
+ gestureScale = 1;
335
+ host.camera.interrupt();
336
+ };
337
+
338
+ const onGestureChange = (e: Event): void => {
339
+ e.preventDefault();
340
+ if (!gesture || pointers.size >= 2) return;
341
+ const g = e as GestureEventLike;
342
+ if (!(g.scale > 0)) return;
343
+ const [x, y] = local(g);
344
+ host.camera.zoomNow(g.scale / gestureScale, x, y);
345
+ gestureScale = g.scale;
346
+ };
347
+
348
+ const onGestureEnd = (e: Event): void => {
349
+ e.preventDefault();
350
+ gesture = false;
351
+ };
352
+
353
+ const listeners: [string, EventListener, AddEventListenerOptions?][] = [
354
+ ['pointerdown', onDown as EventListener],
355
+ ['pointermove', onMove as EventListener],
356
+ ['pointerup', onUp as EventListener],
357
+ ['pointercancel', onUp as EventListener],
358
+ ['pointerleave', onLeave],
359
+ ['wheel', onWheel as EventListener, { passive: false }],
360
+ ['dblclick', onDblClick as EventListener],
361
+ ['gesturestart', onGestureStart, { passive: false }],
362
+ ['gesturechange', onGestureChange, { passive: false }],
363
+ ['gestureend', onGestureEnd, { passive: false }],
364
+ ];
365
+ for (const [type, fn, opts] of listeners) el.addEventListener(type, fn, opts);
366
+ return () => {
367
+ for (const [type, fn, opts] of listeners) el.removeEventListener(type, fn, opts);
368
+ pointers.clear();
369
+ };
370
+ }