/**
* @module TouchEventHandlers
* @author Radim Brnka
* @description This module exports a function registerTouchEventHandlers that sets up all touch events. It interacts directly with the fractalRenderer instance.
*/
import {calculatePanDelta, expandComplexToString, normalizeRotation, updateURLParams} from '../global/utils.js';
import {isJuliaMode, resetAppState, updateInfo} from './ui.js';
import {DEFAULT_CONSOLE_GROUP_COLOR, FRACTAL_TYPE} from "../global/constants";
/** How long should we wait before distinguish between double tap and two single taps. */
const DOUBLE_TAP_THRESHOLD = 300;
/** Tolerance of finger movements before drag starts with move gesture. */
const DRAG_THRESHOLD = 5;
const ZOOM_STEP = 0.05;
/** Tolerance of finger movements before rotation starts with pinch gesture. */
const ROTATION_THRESHOLD = 0.05;
const ROTATION_SENSITIVITY = 1;
let canvas;
let fractalApp;
/** Global variable to track registration */
let touchHandlersRegistered = false;
// Stored references to event handler functions
let handleTouchStartEvent;
let handleTouchMoveEvent;
let handleTouchEndEvent;
let touchDownX = 0, touchDownY = 0;
let lastTouchX = 0, lastTouchY = 0;
let touchClickTimeout = null;
let isTouchDragging = false;
// Pinch state variables
let isPinching = false;
let pinchStartDistance = null;
let pinchStartZoom = null;
let pinchStartPan = null;
let pinchStartCenterFractal = null;
let pinchStartAngle = null;
/**
* Initialization and registering of the event handlers.
* @param {FractalRenderer} app
*/
export function initTouchHandlers(app) {
fractalApp = app;
canvas = app.canvas;
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
registerTouchEventHandlers(app);
}
/** Registers touch handlers. */
export function registerTouchEventHandlers() {
if (touchHandlersRegistered) {
console.warn(`%c registerTouchEventHandlers: %c Event handlers already registered!`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');
return;
}
handleTouchStartEvent = (event) => handleTouchStart(event);
handleTouchMoveEvent = (event) => handleTouchMove(event);
handleTouchEndEvent = (event) => handleTouchEnd(event);
canvas.addEventListener('touchstart', handleTouchStartEvent, {passive: false});
canvas.addEventListener('touchmove', handleTouchMoveEvent, {passive: false});
canvas.addEventListener('touchend', handleTouchEndEvent, {passive: false});
touchHandlersRegistered = true;
console.log(`%c registerTouchEventHandlers: %c Touch event handlers registered`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');
}
/** Unregisters touch handlers. */
export function unregisterTouchEventHandlers() {
if (!touchHandlersRegistered) {
console.warn(`%c unregisterTouchEventHandlers: %c Event handlers are not registered so cannot be unregistered!`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');
return;
}
canvas.removeEventListener('touchstart', handleTouchStartEvent, {passive: false});
canvas.removeEventListener('touchmove', handleTouchMoveEvent, {passive: false});
canvas.removeEventListener('touchend', handleTouchEndEvent, {passive: false});
touchHandlersRegistered = false;
console.warn(`%c unregisterTouchEventHandlers: %c Event handlers unregistered`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');
}
// region > EVENT HANDLERS ---------------------------------------------------------------------------------------------
function handleTouchStart(event) {
if (event.touches.length === 1) {
event.preventDefault();
isTouchDragging = false;
isPinching = false;
pinchStartDistance = null;
const touch = event.touches[0];
touchDownX = touch.clientX;
touchDownY = touch.clientY;
lastTouchX = touch.clientX;
lastTouchY = touch.clientY;
} else if (event.touches.length === 2) {
event.preventDefault();
isPinching = true;
const touch0 = event.touches[0];
const touch1 = event.touches[1];
pinchStartDistance = Math.hypot(
touch0.clientX - touch1.clientX,
touch0.clientY - touch1.clientY
);
pinchStartZoom = fractalApp.zoom;
pinchStartPan = [...fractalApp.pan];
pinchStartAngle = Math.atan2(
touch1.clientY - touch0.clientY,
touch1.clientX - touch0.clientX
);
// Use the midpoint in screen coordinates
const centerScreenX = (touch0.clientX + touch1.clientX) / 2;
const centerScreenY = (touch0.clientY + touch1.clientY) / 2;
pinchStartCenterFractal = fractalApp.screenToFractal(centerScreenX, centerScreenY);
}
}
function handleTouchMove(event) {
if (event.touches.length === 1 && !isPinching) {
// Handle single touch drag (no changes here)
event.preventDefault();
const touch = event.touches[0];
const dx = touch.clientX - touchDownX;
const dy = touch.clientY - touchDownY;
if (!isTouchDragging && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) {
isTouchDragging = true;
if (touchClickTimeout) {
clearTimeout(touchClickTimeout);
touchClickTimeout = null;
}
}
if (isTouchDragging) {
const rect = canvas.getBoundingClientRect();
// Calculate pan delta from the current and last touch positions.
const [deltaX, deltaY] = calculatePanDelta(
touch.clientX, touch.clientY, lastTouchX, lastTouchY, rect,
fractalApp.rotation, fractalApp.zoom
);
fractalApp.pan[0] += deltaX;
fractalApp.pan[1] += deltaY;
// Update last touch coordinates.
lastTouchX = touch.clientX;
lastTouchY = touch.clientY;
fractalApp.draw();
updateInfo(true);
}
return;
}
if (event.touches.length === 2) {
event.preventDefault();
isPinching = true;
const touch0 = event.touches[0];
const touch1 = event.touches[1];
const currentDistance = Math.hypot(
touch0.clientX - touch1.clientX,
touch0.clientY - touch1.clientY
);
const currentAngle = Math.atan2(
touch1.clientY - touch0.clientY,
touch1.clientX - touch0.clientX
);
// Calculate the midpoint in screen space.
const centerScreenX = (touch0.clientX + touch1.clientX) / 2;
const centerScreenY = (touch0.clientY + touch1.clientY) / 2;
if (!pinchStartDistance || !pinchStartAngle || !pinchStartCenterFractal) {
pinchStartDistance = currentDistance;
pinchStartZoom = fractalApp.zoom;
pinchStartAngle = currentAngle;
pinchStartPan = [...fractalApp.pan];
pinchStartCenterFractal = fractalApp.screenToFractal(centerScreenX, centerScreenY);
return;
}
// Update zoom from pinch distance.
const targetZoom = pinchStartDistance / currentDistance * pinchStartZoom;
if (targetZoom > fractalApp.MAX_ZOOM && targetZoom < fractalApp.MIN_ZOOM) {
fractalApp.zoom = targetZoom;
}
// Update rotation.
const angleDifference = currentAngle - pinchStartAngle;
if (Math.abs(angleDifference) > ROTATION_THRESHOLD) {
fractalApp.rotation = normalizeRotation(fractalApp.rotation + angleDifference * ROTATION_SENSITIVITY);
}
// Recalculate the fractal center from the midpoint.
const newCenterFractal = fractalApp.screenToFractal(centerScreenX, centerScreenY);
// Adjust pan so that the fractal center remains the same.
fractalApp.pan[0] += pinchStartCenterFractal[0] - newCenterFractal[0];
fractalApp.pan[1] += pinchStartCenterFractal[1] - newCenterFractal[1];
// Update starting values for the next move.
pinchStartAngle = currentAngle;
pinchStartCenterFractal = fractalApp.screenToFractal(centerScreenX, centerScreenY);
fractalApp.draw();
updateInfo();
}
}
function handleTouchEnd(event) {
// Reset pinch state if fewer than two touches remain.
if (event.touches.length < 2) {
pinchStartDistance = null;
pinchStartZoom = null;
pinchStartAngle = null;
pinchStartPan = null;
}
if (event.touches.length === 0) {
if (isPinching) {
isPinching = false;
return;
}
if (!isTouchDragging) {
const touch = event.changedTouches[0];
// Use visual viewport or canvas bounding rect for touch position.
const rect = canvas.getBoundingClientRect();
const touchX = touch.clientX - rect.left;
const touchY = touch.clientY - rect.top;
const [fx, fy] = fractalApp.screenToFractal(touchX, touchY);
if (touchClickTimeout !== null) {
clearTimeout(touchClickTimeout);
touchClickTimeout = null;
console.log(`%c handleTouchEnd: %c Double Tap: Centering on ${touchX}x${touchY} which is fractal coords [${expandComplexToString([fx, fy])}]`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');
const targetZoom = fractalApp.zoom * ZOOM_STEP;
if (targetZoom > fractalApp.MAX_ZOOM) {
fractalApp.animatePanAndZoomTo([fx, fy], targetZoom).then(resetAppState);
}
} else {
touchClickTimeout = setTimeout(() => {
console.log(`%c handleTouchEnd: %c Single Tap Click: Centering on ${touchX}x${touchY} which is fractal coords ${expandComplexToString([fx, fy])}`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');
// Centering action:
fractalApp.animatePanTo([fx, fy], 400).then(() => {
resetAppState();
if (isJuliaMode()) {
updateURLParams(FRACTAL_TYPE.JULIA, fx, fy, fractalApp.zoom, fractalApp.rotation, fractalApp.c[0], fractalApp.c[1]);
} else {
updateURLParams(FRACTAL_TYPE.MANDELBROT, fx, fy, fractalApp.zoom, fractalApp.rotation);
}
});
touchClickTimeout = null;
}, DOUBLE_TAP_THRESHOLD);
}
}
resetAppState();
isTouchDragging = false;
}
}
// endregion -----------------------------------------------------------------------------------------------------------