Source: ui/touchEventHandlers.js

/**
 * @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 -----------------------------------------------------------------------------------------------------------