@guinetik/gcanvas 1.0.5 → 2.0.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 (78) hide show
  1. package/dist/aizawa.html +27 -0
  2. package/dist/clifford.html +25 -0
  3. package/dist/cmb.html +24 -0
  4. package/dist/dadras.html +26 -0
  5. package/dist/dejong.html +25 -0
  6. package/dist/gcanvas.es.js +5130 -372
  7. package/dist/gcanvas.es.min.js +1 -1
  8. package/dist/gcanvas.umd.js +1 -1
  9. package/dist/gcanvas.umd.min.js +1 -1
  10. package/dist/halvorsen.html +27 -0
  11. package/dist/index.html +96 -48
  12. package/dist/js/aizawa.js +425 -0
  13. package/dist/js/bezier.js +5 -5
  14. package/dist/js/clifford.js +236 -0
  15. package/dist/js/cmb.js +594 -0
  16. package/dist/js/dadras.js +405 -0
  17. package/dist/js/dejong.js +257 -0
  18. package/dist/js/halvorsen.js +405 -0
  19. package/dist/js/isometric.js +34 -46
  20. package/dist/js/lorenz.js +425 -0
  21. package/dist/js/painter.js +8 -8
  22. package/dist/js/rossler.js +480 -0
  23. package/dist/js/schrodinger.js +314 -18
  24. package/dist/js/thomas.js +394 -0
  25. package/dist/lorenz.html +27 -0
  26. package/dist/rossler.html +27 -0
  27. package/dist/scene-interactivity-test.html +220 -0
  28. package/dist/thomas.html +27 -0
  29. package/package.json +1 -1
  30. package/readme.md +30 -22
  31. package/src/game/objects/go.js +7 -0
  32. package/src/game/objects/index.js +2 -0
  33. package/src/game/objects/isometric-scene.js +53 -3
  34. package/src/game/objects/layoutscene.js +57 -0
  35. package/src/game/objects/mask.js +241 -0
  36. package/src/game/objects/scene.js +19 -0
  37. package/src/game/objects/wrapper.js +14 -2
  38. package/src/game/pipeline.js +17 -0
  39. package/src/game/ui/button.js +101 -16
  40. package/src/game/ui/theme.js +0 -6
  41. package/src/game/ui/togglebutton.js +25 -14
  42. package/src/game/ui/tooltip.js +12 -4
  43. package/src/index.js +3 -0
  44. package/src/io/gesture.js +409 -0
  45. package/src/io/index.js +4 -1
  46. package/src/io/keys.js +9 -1
  47. package/src/io/screen.js +476 -0
  48. package/src/math/attractors.js +664 -0
  49. package/src/math/heat.js +106 -0
  50. package/src/math/index.js +1 -0
  51. package/src/mixins/draggable.js +15 -19
  52. package/src/painter/painter.shapes.js +11 -5
  53. package/src/particle/particle-system.js +165 -1
  54. package/src/physics/index.js +26 -0
  55. package/src/physics/physics-updaters.js +333 -0
  56. package/src/physics/physics.js +375 -0
  57. package/src/shapes/image.js +5 -5
  58. package/src/shapes/index.js +2 -0
  59. package/src/shapes/parallelogram.js +147 -0
  60. package/src/shapes/righttriangle.js +115 -0
  61. package/src/shapes/svg.js +281 -100
  62. package/src/shapes/text.js +22 -6
  63. package/src/shapes/transformable.js +5 -0
  64. package/src/sound/effects.js +807 -0
  65. package/src/sound/index.js +13 -0
  66. package/src/webgl/index.js +7 -0
  67. package/src/webgl/shaders/clifford-point-shaders.js +131 -0
  68. package/src/webgl/shaders/dejong-point-shaders.js +131 -0
  69. package/src/webgl/shaders/point-sprite-shaders.js +152 -0
  70. package/src/webgl/webgl-clifford-renderer.js +477 -0
  71. package/src/webgl/webgl-dejong-renderer.js +472 -0
  72. package/src/webgl/webgl-line-renderer.js +391 -0
  73. package/src/webgl/webgl-particle-renderer.js +410 -0
  74. package/types/index.d.ts +30 -2
  75. package/types/io.d.ts +217 -0
  76. package/types/physics.d.ts +299 -0
  77. package/types/shapes.d.ts +8 -0
  78. package/types/webgl.d.ts +188 -109
@@ -110,8 +110,9 @@ export class Button extends GameObject {
110
110
  // Basic position and sizing
111
111
  this.x = x;
112
112
  this.y = y;
113
- this.width = width;
114
- this.height = height;
113
+ // Ensure minimum touch target size (44x44px recommended for mobile)
114
+ this.width = Math.max(width, 44);
115
+ this.height = Math.max(height, 44);
115
116
  this.padding = padding;
116
117
  this.textAlign = textAlign;
117
118
  this.textBaseline = textBaseline;
@@ -200,6 +201,10 @@ export class Button extends GameObject {
200
201
  /**
201
202
  * Update label position based on alignment and baseline settings
202
203
  * @private
204
+ *
205
+ * Note: TextShape now centers its bounding box at (x, y) regardless of textAlign.
206
+ * For non-center alignments, we adjust the position by half the text dimensions
207
+ * so that left-aligned text STARTS at the left edge, not centers there.
203
208
  */
204
209
  alignText() {
205
210
  if (!this.label) return;
@@ -207,13 +212,22 @@ export class Button extends GameObject {
207
212
  const halfWidth = this.width / 2;
208
213
  const halfHeight = this.height / 2;
209
214
 
210
- // Horizontal alignment
215
+ // Get text dimensions (available after TextShape._calculateBounds)
216
+ const textHalfWidth = (this.label._width || 0) / 2;
217
+ const textHalfHeight = (this.label._height || 0) / 2;
218
+
219
+ // Horizontal alignment - position where text CENTER should be
220
+ // TextShape centers its bounding box at (x, y), so we adjust accordingly
211
221
  switch (this.textAlign) {
212
222
  case "left":
213
- this.label.x = -halfWidth + this.padding;
223
+ // Text should START at left edge + padding
224
+ // So text CENTER should be at left edge + padding + half text width
225
+ this.label.x = -halfWidth + this.padding + textHalfWidth;
214
226
  break;
215
227
  case "right":
216
- this.label.x = halfWidth - this.padding;
228
+ // Text should END at right edge - padding
229
+ // So text CENTER should be at right edge - padding - half text width
230
+ this.label.x = halfWidth - this.padding - textHalfWidth;
217
231
  break;
218
232
  case "center":
219
233
  default:
@@ -221,13 +235,15 @@ export class Button extends GameObject {
221
235
  break;
222
236
  }
223
237
 
224
- // Vertical alignment
238
+ // Vertical alignment - position where text CENTER should be
225
239
  switch (this.textBaseline) {
226
240
  case "top":
227
- this.label.y = -halfHeight + this.padding;
241
+ // Text should START at top edge + padding
242
+ this.label.y = -halfHeight + this.padding + textHalfHeight;
228
243
  break;
229
244
  case "bottom":
230
- this.label.y = halfHeight - this.padding;
245
+ // Text should END at bottom edge - padding
246
+ this.label.y = halfHeight - this.padding - textHalfHeight;
231
247
  break;
232
248
  case "middle":
233
249
  default:
@@ -257,16 +273,85 @@ export class Button extends GameObject {
257
273
  this.onPressed = onPressed;
258
274
  this.onRelease = onRelease;
259
275
 
260
- this.on("mouseover", this.setState.bind(this, "hover"));
261
- this.on("mouseout", this.setState.bind(this, "default"));
262
- this.on("inputdown", this.setState.bind(this, "pressed"));
263
- this.on("inputup", () => {
264
- // Fire onClick if user was in "pressed" state
265
- if (this.state === "pressed" && typeof onClick === "function") {
276
+ // Track pointer state for proper hover handling
277
+ this._pointerOver = false;
278
+ this._isTouch = false;
279
+
280
+ // Mouse hover events (desktop only)
281
+ this.on("mouseover", (e) => {
282
+ this._pointerOver = true;
283
+ if (!this._isTouch) {
284
+ this.setState("hover");
285
+ }
286
+ });
287
+
288
+ this.on("mouseout", (e) => {
289
+ this._pointerOver = false;
290
+ if (!this._isTouch) {
291
+ this.setState("default");
292
+ }
293
+ });
294
+
295
+ // Touch/pointer down - prevent default for mobile-friendly behavior
296
+ this.on("inputdown", (e) => {
297
+ // Detect touch input
298
+ if (e.touches || (e.nativeEvent && e.nativeEvent.type === 'touchstart')) {
299
+ this._isTouch = true;
300
+ // Prevent default touch behaviors (scrolling, zooming)
301
+ if (e.nativeEvent) {
302
+ e.nativeEvent.preventDefault();
303
+ }
304
+ }
305
+ this._pointerOver = true;
306
+ this.setState("pressed");
307
+ });
308
+
309
+ // Touch/pointer up
310
+ this.on("inputup", (e) => {
311
+ const wasPressed = this.state === "pressed";
312
+
313
+ // Prevent default touch behaviors
314
+ if (e.touches || (e.nativeEvent && e.nativeEvent.type === 'touchend')) {
315
+ if (e.nativeEvent) {
316
+ e.nativeEvent.preventDefault();
317
+ }
318
+ this._isTouch = true;
319
+ }
320
+
321
+ // Verify pointer is still over button using hit test
322
+ const stillOver = this._hitTest && this._hitTest(e.x, e.y);
323
+
324
+ // Fire onClick if user was in "pressed" state and pointer is still over
325
+ if (wasPressed && stillOver && typeof onClick === "function") {
266
326
  onClick();
267
327
  }
268
- // Return to hover state if the pointer is still over the button
269
- this.setState("hover");
328
+
329
+ // Check if pointer is still over the button
330
+ // On touch devices, don't set hover state (no hover on touch)
331
+ if (stillOver && !this._isTouch) {
332
+ this.setState("hover");
333
+ } else {
334
+ this.setState("default");
335
+ // Reset touch flag after a delay to allow mouse hover to work
336
+ if (this._isTouch) {
337
+ setTimeout(() => {
338
+ this._isTouch = false;
339
+ }, 300);
340
+ }
341
+ }
342
+ });
343
+
344
+ // Touch move - update pointer position
345
+ this.on("inputmove", (e) => {
346
+ // Check if pointer is still over button
347
+ if (this._hitTest && this._hitTest(e.x, e.y)) {
348
+ this._pointerOver = true;
349
+ } else {
350
+ this._pointerOver = false;
351
+ if (this.state === "hover" && !this._isTouch) {
352
+ this.setState("default");
353
+ }
354
+ }
270
355
  });
271
356
  }
272
357
 
@@ -121,9 +121,3 @@ export default UI_THEME;
121
121
 
122
122
 
123
123
 
124
-
125
-
126
-
127
-
128
-
129
-
@@ -43,7 +43,13 @@ export class ToggleButton extends Button {
43
43
  }
44
44
 
45
45
  // Update our visual style for toggled vs. not
46
+ // Store current state before refresh
47
+ const currentState = this.state;
46
48
  this.refreshToggleVisual();
49
+ // Re-apply current state after refresh to ensure proper colors
50
+ if (currentState) {
51
+ this.setState(currentState);
52
+ }
47
53
  },
48
54
  });
49
55
  // Terminal × Vercel theme for toggled state
@@ -59,8 +65,13 @@ export class ToggleButton extends Button {
59
65
 
60
66
  toggle(v) {
61
67
  // Toggle the button state and refresh visuals
68
+ const currentState = this.state;
62
69
  this.toggled = v;
63
70
  this.refreshToggleVisual();
71
+ // Re-apply current state after refresh to ensure proper colors
72
+ if (currentState) {
73
+ this.setState(currentState);
74
+ }
64
75
  }
65
76
 
66
77
  /**
@@ -68,32 +79,32 @@ export class ToggleButton extends Button {
68
79
  */
69
80
  refreshToggleVisual() {
70
81
  if (this.toggled) {
71
- // E.g. "active" styling
72
- this.bg.fillColor = this.colorActiveBg;
73
- this.bg.strokeColor = this.colorActiveStroke;
82
+ // Active/toggled styling - use correct property names
83
+ this.bg.color = this.colorActiveBg;
84
+ this.bg.stroke = this.colorActiveStroke;
74
85
  this.label.color = this.colorActiveText;
75
86
  } else {
76
- // Revert to normal styling
77
- this.bg.fillColor = this.colors.default.bg;
78
- this.bg.strokeColor = this.colors.default.stroke;
87
+ // Revert to normal styling - use correct property names
88
+ this.bg.color = this.colors.default.bg;
89
+ this.bg.stroke = this.colors.default.stroke;
79
90
  this.label.color = this.colors.default.text;
80
91
  }
81
92
  }
82
93
 
83
94
  /**
84
- * If we want ephemeral states (hover, pressed) to remain visible briefly,
85
- * we can let parent setState run, then immediately re-apply toggled style
86
- * so we don't lose the "toggled" color. This is optional.
95
+ * Override setState to properly handle toggled state.
96
+ * When toggled, always use active colors. When not toggled, use normal button behavior.
87
97
  */
88
98
  setState(state) {
99
+ // Always call parent first to handle cursor and callbacks
89
100
  super.setState(state);
90
-
91
- // If we're toggled on, ensure it stays in the toggled visuals
92
- // after the parent sets hover/pressed colors.
101
+
102
+ // If toggled, override colors with active colors (ignore hover/pressed colors)
93
103
  if (this.toggled) {
94
- this.bg.fillColor = this.colorActiveBg;
95
- this.bg.strokeColor = this.colorActiveStroke;
104
+ this.bg.color = this.colorActiveBg;
105
+ this.bg.stroke = this.colorActiveStroke;
96
106
  this.label.color = this.colorActiveText;
97
107
  }
108
+ // If not toggled, parent's setState already set the correct colors
98
109
  }
99
110
  }
@@ -237,12 +237,20 @@ export class Tooltip extends GameObject {
237
237
  this.bg.height = textHeight + this.padding * 2;
238
238
 
239
239
  // Position each line inside bg
240
- const startX = -this.bg.width / 2 + this.padding;
241
- const startY = -this.bg.height / 2 + this.padding;
240
+ // TextShape now centers its bounding box at (x, y), so for left/top aligned text
241
+ // we need to position the CENTER where we want it, not the top-left corner.
242
+ // Text should START at the left edge + padding, so CENTER is at left edge + padding + textWidth/2
243
+ const bgLeft = -this.bg.width / 2;
242
244
 
243
245
  for (let i = 0; i < this.lineShapes.length; i++) {
244
- this.lineShapes[i].x = startX;
245
- this.lineShapes[i].y = startY + i * lineHeight;
246
+ const shape = this.lineShapes[i];
247
+ const textHalfWidth = (shape._width || 0) / 2;
248
+ const textHalfHeight = (shape._height || lineHeight) / 2;
249
+
250
+ // Text starts at left edge + padding, so center is at left edge + padding + halfWidth
251
+ shape.x = bgLeft + this.padding + textHalfWidth;
252
+ // Text starts at top edge + padding + i*lineHeight, so center is offset by halfHeight
253
+ shape.y = -this.bg.height / 2 + this.padding + i * lineHeight + textHalfHeight;
246
254
  }
247
255
  }
248
256
 
package/src/index.js CHANGED
@@ -21,5 +21,8 @@ export * from "./state";
21
21
  // Particle system
22
22
  export * from "./particle";
23
23
 
24
+ // Physics (for particle simulations)
25
+ export * from "./physics";
26
+
24
27
  // WebGL (optional, for shader effects)
25
28
  export * from "./webgl";
@@ -0,0 +1,409 @@
1
+ /**
2
+ * @module io/gesture
3
+ * @description High-level gesture recognition for zoom, pan, and tap across mouse and touch.
4
+ *
5
+ * Provides unified gesture handling that works seamlessly on both desktop and mobile:
6
+ * - Mouse wheel → zoom
7
+ * - Pinch (two fingers) → zoom
8
+ * - Mouse drag → pan
9
+ * - Single finger drag → pan
10
+ * - Quick tap/click → tap event
11
+ *
12
+ * @example
13
+ * // Basic usage
14
+ * import { Gesture } from '@guinetik/gcanvas';
15
+ *
16
+ * const gesture = new Gesture(canvas, {
17
+ * onZoom: (delta, center) => {
18
+ * this.zoom *= delta > 0 ? 1.1 : 0.9;
19
+ * },
20
+ * onPan: (dx, dy) => {
21
+ * this.offsetX += dx;
22
+ * this.offsetY += dy;
23
+ * },
24
+ * onTap: (x, y) => {
25
+ * this.handleClick(x, y);
26
+ * }
27
+ * });
28
+ *
29
+ * // Cleanup when done
30
+ * gesture.destroy();
31
+ */
32
+
33
+ /**
34
+ * Gesture class for handling zoom, pan, and tap gestures
35
+ */
36
+ export class Gesture {
37
+ /**
38
+ * Create a new Gesture handler
39
+ * @param {HTMLCanvasElement} canvas - The canvas element to attach gestures to
40
+ * @param {Object} options - Configuration options
41
+ * @param {Function} [options.onZoom] - Callback for zoom: (delta, centerX, centerY) => void
42
+ * delta > 0 = zoom in, delta < 0 = zoom out
43
+ * @param {Function} [options.onPan] - Callback for pan: (dx, dy) => void
44
+ * @param {Function} [options.onTap] - Callback for tap/click: (x, y) => void
45
+ * @param {Function} [options.onDragStart] - Callback when drag starts: (x, y) => void
46
+ * @param {Function} [options.onDragEnd] - Callback when drag ends: () => void
47
+ * @param {number} [options.wheelZoomFactor=0.1] - Zoom sensitivity for mouse wheel
48
+ * @param {number} [options.pinchZoomFactor=1] - Zoom sensitivity for pinch
49
+ * @param {number} [options.panScale=1] - Scale factor for pan deltas
50
+ * @param {number} [options.tapThreshold=10] - Max movement (px) to still count as tap
51
+ * @param {number} [options.tapTimeout=300] - Max duration (ms) for tap
52
+ * @param {boolean} [options.preventDefault=true] - Prevent default browser behavior
53
+ */
54
+ constructor(canvas, options = {}) {
55
+ this.canvas = canvas;
56
+
57
+ // Callbacks
58
+ this.onZoom = options.onZoom || null;
59
+ this.onPan = options.onPan || null;
60
+ this.onTap = options.onTap || null;
61
+ this.onDragStart = options.onDragStart || null;
62
+ this.onDragEnd = options.onDragEnd || null;
63
+
64
+ // Config
65
+ this.wheelZoomFactor = options.wheelZoomFactor ?? 0.1;
66
+ this.pinchZoomFactor = options.pinchZoomFactor ?? 1;
67
+ this.panScale = options.panScale ?? 1;
68
+ this.tapThreshold = options.tapThreshold ?? 10;
69
+ this.tapTimeout = options.tapTimeout ?? 300;
70
+ this.preventDefault = options.preventDefault ?? true;
71
+
72
+ // State
73
+ this._isDragging = false;
74
+ this._hasMoved = false;
75
+ this._startTime = 0;
76
+ this._startX = 0;
77
+ this._startY = 0;
78
+ this._lastX = 0;
79
+ this._lastY = 0;
80
+
81
+ // Touch state
82
+ this._touches = new Map();
83
+ this._lastPinchDist = 0;
84
+ this._lastPinchCenterX = 0;
85
+ this._lastPinchCenterY = 0;
86
+
87
+ // Bind handlers
88
+ this._onMouseDown = this._onMouseDown.bind(this);
89
+ this._onMouseMove = this._onMouseMove.bind(this);
90
+ this._onMouseUp = this._onMouseUp.bind(this);
91
+ this._onMouseLeave = this._onMouseLeave.bind(this);
92
+ this._onWheel = this._onWheel.bind(this);
93
+ this._onTouchStart = this._onTouchStart.bind(this);
94
+ this._onTouchMove = this._onTouchMove.bind(this);
95
+ this._onTouchEnd = this._onTouchEnd.bind(this);
96
+ this._onTouchCancel = this._onTouchCancel.bind(this);
97
+
98
+ // Attach listeners
99
+ this._attachListeners();
100
+ }
101
+
102
+ /**
103
+ * Check if currently dragging
104
+ * @returns {boolean}
105
+ */
106
+ get isDragging() {
107
+ return this._isDragging;
108
+ }
109
+
110
+ /**
111
+ * Attach all event listeners
112
+ * @private
113
+ */
114
+ _attachListeners() {
115
+ const canvas = this.canvas;
116
+ const passive = !this.preventDefault;
117
+
118
+ // Mouse
119
+ canvas.addEventListener('mousedown', this._onMouseDown);
120
+ canvas.addEventListener('mousemove', this._onMouseMove);
121
+ canvas.addEventListener('mouseup', this._onMouseUp);
122
+ canvas.addEventListener('mouseleave', this._onMouseLeave);
123
+ canvas.addEventListener('wheel', this._onWheel, { passive });
124
+
125
+ // Touch
126
+ canvas.addEventListener('touchstart', this._onTouchStart, { passive });
127
+ canvas.addEventListener('touchmove', this._onTouchMove, { passive });
128
+ canvas.addEventListener('touchend', this._onTouchEnd, { passive });
129
+ canvas.addEventListener('touchcancel', this._onTouchCancel);
130
+ }
131
+
132
+ /**
133
+ * Remove all event listeners
134
+ */
135
+ destroy() {
136
+ const canvas = this.canvas;
137
+
138
+ canvas.removeEventListener('mousedown', this._onMouseDown);
139
+ canvas.removeEventListener('mousemove', this._onMouseMove);
140
+ canvas.removeEventListener('mouseup', this._onMouseUp);
141
+ canvas.removeEventListener('mouseleave', this._onMouseLeave);
142
+ canvas.removeEventListener('wheel', this._onWheel);
143
+
144
+ canvas.removeEventListener('touchstart', this._onTouchStart);
145
+ canvas.removeEventListener('touchmove', this._onTouchMove);
146
+ canvas.removeEventListener('touchend', this._onTouchEnd);
147
+ canvas.removeEventListener('touchcancel', this._onTouchCancel);
148
+
149
+ this._touches.clear();
150
+ }
151
+
152
+ /**
153
+ * Get canvas-relative coordinates from a mouse event
154
+ * @private
155
+ */
156
+ _getMousePos(e) {
157
+ const rect = this.canvas.getBoundingClientRect();
158
+ return {
159
+ x: e.clientX - rect.left,
160
+ y: e.clientY - rect.top
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Get canvas-relative coordinates from a touch
166
+ * @private
167
+ */
168
+ _getTouchPos(touch) {
169
+ const rect = this.canvas.getBoundingClientRect();
170
+ return {
171
+ x: touch.clientX - rect.left,
172
+ y: touch.clientY - rect.top
173
+ };
174
+ }
175
+
176
+ // ─────────────────────────────────────────────────────────────────────────
177
+ // MOUSE HANDLERS
178
+ // ─────────────────────────────────────────────────────────────────────────
179
+
180
+ _onMouseDown(e) {
181
+ const pos = this._getMousePos(e);
182
+ this._isDragging = true;
183
+ this._hasMoved = false;
184
+ this._startTime = Date.now();
185
+ this._startX = pos.x;
186
+ this._startY = pos.y;
187
+ this._lastX = pos.x;
188
+ this._lastY = pos.y;
189
+
190
+ if (this.onDragStart) {
191
+ this.onDragStart(pos.x, pos.y);
192
+ }
193
+ }
194
+
195
+ _onMouseMove(e) {
196
+ if (!this._isDragging) return;
197
+
198
+ const pos = this._getMousePos(e);
199
+ const dx = pos.x - this._lastX;
200
+ const dy = pos.y - this._lastY;
201
+
202
+ // Check if moved enough to count as drag
203
+ const totalDx = Math.abs(pos.x - this._startX);
204
+ const totalDy = Math.abs(pos.y - this._startY);
205
+ if (totalDx > this.tapThreshold || totalDy > this.tapThreshold) {
206
+ this._hasMoved = true;
207
+ }
208
+
209
+ if (this.onPan && this._hasMoved) {
210
+ this.onPan(dx * this.panScale, dy * this.panScale);
211
+ }
212
+
213
+ this._lastX = pos.x;
214
+ this._lastY = pos.y;
215
+ }
216
+
217
+ _onMouseUp(e) {
218
+ if (!this._isDragging) return;
219
+
220
+ const now = Date.now();
221
+ const duration = now - this._startTime;
222
+
223
+ // Check for tap (quick, no movement)
224
+ if (!this._hasMoved && duration < this.tapTimeout && this.onTap) {
225
+ const pos = this._getMousePos(e);
226
+ this.onTap(pos.x, pos.y);
227
+ }
228
+
229
+ this._isDragging = false;
230
+
231
+ if (this.onDragEnd) {
232
+ this.onDragEnd();
233
+ }
234
+ }
235
+
236
+ _onMouseLeave() {
237
+ if (this._isDragging) {
238
+ this._isDragging = false;
239
+ if (this.onDragEnd) {
240
+ this.onDragEnd();
241
+ }
242
+ }
243
+ }
244
+
245
+ _onWheel(e) {
246
+ if (this.preventDefault) {
247
+ e.preventDefault();
248
+ }
249
+
250
+ if (this.onZoom) {
251
+ const pos = this._getMousePos(e);
252
+ const delta = e.deltaY > 0 ? -this.wheelZoomFactor : this.wheelZoomFactor;
253
+ this.onZoom(delta, pos.x, pos.y);
254
+ }
255
+ }
256
+
257
+ // ─────────────────────────────────────────────────────────────────────────
258
+ // TOUCH HANDLERS
259
+ // ─────────────────────────────────────────────────────────────────────────
260
+
261
+ _onTouchStart(e) {
262
+ if (this.preventDefault) {
263
+ e.preventDefault();
264
+ }
265
+
266
+ this._startTime = Date.now();
267
+ this._hasMoved = false;
268
+
269
+ // Track all touches
270
+ for (const touch of e.changedTouches) {
271
+ const pos = this._getTouchPos(touch);
272
+ this._touches.set(touch.identifier, { x: pos.x, y: pos.y });
273
+ }
274
+
275
+ // Two fingers - initialize pinch
276
+ if (this._touches.size === 2) {
277
+ const [t1, t2] = Array.from(this._touches.values());
278
+ this._lastPinchDist = Math.hypot(t2.x - t1.x, t2.y - t1.y);
279
+ this._lastPinchCenterX = (t1.x + t2.x) / 2;
280
+ this._lastPinchCenterY = (t1.y + t2.y) / 2;
281
+ // Stop single-finger drag when pinching
282
+ this._isDragging = false;
283
+ }
284
+
285
+ // Single finger - start drag
286
+ if (this._touches.size === 1) {
287
+ const touch = e.touches[0];
288
+ const pos = this._getTouchPos(touch);
289
+ this._isDragging = true;
290
+ this._startX = pos.x;
291
+ this._startY = pos.y;
292
+ this._lastX = pos.x;
293
+ this._lastY = pos.y;
294
+
295
+ if (this.onDragStart) {
296
+ this.onDragStart(pos.x, pos.y);
297
+ }
298
+ }
299
+ }
300
+
301
+ _onTouchMove(e) {
302
+ if (this.preventDefault) {
303
+ e.preventDefault();
304
+ }
305
+
306
+ // Update touch positions
307
+ for (const touch of e.changedTouches) {
308
+ if (this._touches.has(touch.identifier)) {
309
+ const pos = this._getTouchPos(touch);
310
+ this._touches.set(touch.identifier, { x: pos.x, y: pos.y });
311
+ }
312
+ }
313
+
314
+ // Two-finger pinch zoom + pan
315
+ if (this._touches.size === 2) {
316
+ const [t1, t2] = Array.from(this._touches.values());
317
+ const pinchDist = Math.hypot(t2.x - t1.x, t2.y - t1.y);
318
+ const pinchCenterX = (t1.x + t2.x) / 2;
319
+ const pinchCenterY = (t1.y + t2.y) / 2;
320
+
321
+ if (this._lastPinchDist > 0) {
322
+ // Zoom based on pinch distance change
323
+ if (this.onZoom) {
324
+ const pinchRatio = pinchDist / this._lastPinchDist;
325
+ const delta = (pinchRatio - 1) * this.pinchZoomFactor;
326
+ this.onZoom(delta, pinchCenterX, pinchCenterY);
327
+ }
328
+
329
+ // Pan based on pinch center movement
330
+ if (this.onPan) {
331
+ const dx = pinchCenterX - this._lastPinchCenterX;
332
+ const dy = pinchCenterY - this._lastPinchCenterY;
333
+ this.onPan(dx * this.panScale, dy * this.panScale);
334
+ }
335
+
336
+ this._hasMoved = true;
337
+ }
338
+
339
+ this._lastPinchDist = pinchDist;
340
+ this._lastPinchCenterX = pinchCenterX;
341
+ this._lastPinchCenterY = pinchCenterY;
342
+ }
343
+ // Single finger drag (pan)
344
+ else if (this._touches.size === 1 && this._isDragging) {
345
+ const touch = e.touches[0];
346
+ const pos = this._getTouchPos(touch);
347
+
348
+ const dx = pos.x - this._lastX;
349
+ const dy = pos.y - this._lastY;
350
+
351
+ // Check if moved enough
352
+ const totalDx = Math.abs(pos.x - this._startX);
353
+ const totalDy = Math.abs(pos.y - this._startY);
354
+ if (totalDx > this.tapThreshold || totalDy > this.tapThreshold) {
355
+ this._hasMoved = true;
356
+ }
357
+
358
+ if (this.onPan && this._hasMoved) {
359
+ this.onPan(dx * this.panScale, dy * this.panScale);
360
+ }
361
+
362
+ this._lastX = pos.x;
363
+ this._lastY = pos.y;
364
+ }
365
+ }
366
+
367
+ _onTouchEnd(e) {
368
+ if (this.preventDefault) {
369
+ e.preventDefault();
370
+ }
371
+
372
+ // Remove ended touches
373
+ for (const touch of e.changedTouches) {
374
+ this._touches.delete(touch.identifier);
375
+ }
376
+
377
+ // Reset pinch if no longer two fingers
378
+ if (this._touches.size < 2) {
379
+ this._lastPinchDist = 0;
380
+ }
381
+
382
+ // All touches released
383
+ if (this._touches.size === 0) {
384
+ // Check for tap
385
+ const now = Date.now();
386
+ const duration = now - this._startTime;
387
+
388
+ if (!this._hasMoved && duration < this.tapTimeout && this.onTap) {
389
+ this.onTap(this._startX, this._startY);
390
+ }
391
+
392
+ this._isDragging = false;
393
+
394
+ if (this.onDragEnd) {
395
+ this.onDragEnd();
396
+ }
397
+ }
398
+ }
399
+
400
+ _onTouchCancel() {
401
+ this._touches.clear();
402
+ this._lastPinchDist = 0;
403
+ this._isDragging = false;
404
+
405
+ if (this.onDragEnd) {
406
+ this.onDragEnd();
407
+ }
408
+ }
409
+ }