@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.
- package/dist/aizawa.html +27 -0
- package/dist/clifford.html +25 -0
- package/dist/cmb.html +24 -0
- package/dist/dadras.html +26 -0
- package/dist/dejong.html +25 -0
- package/dist/gcanvas.es.js +5130 -372
- package/dist/gcanvas.es.min.js +1 -1
- package/dist/gcanvas.umd.js +1 -1
- package/dist/gcanvas.umd.min.js +1 -1
- package/dist/halvorsen.html +27 -0
- package/dist/index.html +96 -48
- package/dist/js/aizawa.js +425 -0
- package/dist/js/bezier.js +5 -5
- package/dist/js/clifford.js +236 -0
- package/dist/js/cmb.js +594 -0
- package/dist/js/dadras.js +405 -0
- package/dist/js/dejong.js +257 -0
- package/dist/js/halvorsen.js +405 -0
- package/dist/js/isometric.js +34 -46
- package/dist/js/lorenz.js +425 -0
- package/dist/js/painter.js +8 -8
- package/dist/js/rossler.js +480 -0
- package/dist/js/schrodinger.js +314 -18
- package/dist/js/thomas.js +394 -0
- package/dist/lorenz.html +27 -0
- package/dist/rossler.html +27 -0
- package/dist/scene-interactivity-test.html +220 -0
- package/dist/thomas.html +27 -0
- package/package.json +1 -1
- package/readme.md +30 -22
- package/src/game/objects/go.js +7 -0
- package/src/game/objects/index.js +2 -0
- package/src/game/objects/isometric-scene.js +53 -3
- package/src/game/objects/layoutscene.js +57 -0
- package/src/game/objects/mask.js +241 -0
- package/src/game/objects/scene.js +19 -0
- package/src/game/objects/wrapper.js +14 -2
- package/src/game/pipeline.js +17 -0
- package/src/game/ui/button.js +101 -16
- package/src/game/ui/theme.js +0 -6
- package/src/game/ui/togglebutton.js +25 -14
- package/src/game/ui/tooltip.js +12 -4
- package/src/index.js +3 -0
- package/src/io/gesture.js +409 -0
- package/src/io/index.js +4 -1
- package/src/io/keys.js +9 -1
- package/src/io/screen.js +476 -0
- package/src/math/attractors.js +664 -0
- package/src/math/heat.js +106 -0
- package/src/math/index.js +1 -0
- package/src/mixins/draggable.js +15 -19
- package/src/painter/painter.shapes.js +11 -5
- package/src/particle/particle-system.js +165 -1
- package/src/physics/index.js +26 -0
- package/src/physics/physics-updaters.js +333 -0
- package/src/physics/physics.js +375 -0
- package/src/shapes/image.js +5 -5
- package/src/shapes/index.js +2 -0
- package/src/shapes/parallelogram.js +147 -0
- package/src/shapes/righttriangle.js +115 -0
- package/src/shapes/svg.js +281 -100
- package/src/shapes/text.js +22 -6
- package/src/shapes/transformable.js +5 -0
- package/src/sound/effects.js +807 -0
- package/src/sound/index.js +13 -0
- package/src/webgl/index.js +7 -0
- package/src/webgl/shaders/clifford-point-shaders.js +131 -0
- package/src/webgl/shaders/dejong-point-shaders.js +131 -0
- package/src/webgl/shaders/point-sprite-shaders.js +152 -0
- package/src/webgl/webgl-clifford-renderer.js +477 -0
- package/src/webgl/webgl-dejong-renderer.js +472 -0
- package/src/webgl/webgl-line-renderer.js +391 -0
- package/src/webgl/webgl-particle-renderer.js +410 -0
- package/types/index.d.ts +30 -2
- package/types/io.d.ts +217 -0
- package/types/physics.d.ts +299 -0
- package/types/shapes.d.ts +8 -0
- package/types/webgl.d.ts +188 -109
package/src/game/ui/button.js
CHANGED
|
@@ -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
|
-
|
|
114
|
-
this.
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
+
// Text should START at top edge + padding
|
|
242
|
+
this.label.y = -halfHeight + this.padding + textHalfHeight;
|
|
228
243
|
break;
|
|
229
244
|
case "bottom":
|
|
230
|
-
|
|
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
|
-
|
|
261
|
-
this.
|
|
262
|
-
this.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
|
package/src/game/ui/theme.js
CHANGED
|
@@ -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
|
-
//
|
|
72
|
-
this.bg.
|
|
73
|
-
this.bg.
|
|
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.
|
|
78
|
-
this.bg.
|
|
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
|
-
*
|
|
85
|
-
*
|
|
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
|
|
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.
|
|
95
|
-
this.bg.
|
|
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
|
}
|
package/src/game/ui/tooltip.js
CHANGED
|
@@ -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
|
-
|
|
241
|
-
|
|
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]
|
|
245
|
-
|
|
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
|
@@ -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
|
+
}
|