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.
 * @copyright Synaptory Fractal Traveler, 2025-2026
 * @license MIT
 */

import {normalizeRotation, updateURLParams} from '../global/utils.js';
import {getCurrentPaletteId, isJuliaMode, resetAppState, updateInfo} from './ui.js';
import {CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE, FRACTAL_TYPE} from "../global/constants";
import {clampPanDelta} from "./mouseEventHandlers";

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

/** Long press zoom configuration */
const LONG_PRESS_THRESHOLD = 400; // ms before zoom starts
const LONG_PRESS_ZOOM_IN_FACTOR = 0.965; // zoom multiplier per frame (smaller = faster zoom in)

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;

// Cached rect (avoid layout thrash / inconsistencies during drag)
let dragRectLeft = 0;
let dragRectTop = 0;
let hasDragRect = false;

// Pinch state variables
let isPinching = false;
let pinchStartDistance = null;
let pinchStartAngle = null;
let lastPinchCenterX = null;
let lastPinchCenterY = null;

// Long press zoom state
let longPressTimeout = null;
let longPressZoomActive = false;
let longPressZoomRAF = null;
let longPressAnchorX = 0;
let longPressAnchorY = 0;

/**
 * Mark orbit dirty if the current renderer supports perturbation caching.
 * IMPORTANT: must NOT trigger orbit rebuild immediately (renderers should defer).
 */
function markOrbitDirtySafe() {
    if (fractalApp && typeof fractalApp.markOrbitDirty === "function") {
        fractalApp.markOrbitDirty();
    } else if (fractalApp) {
        // Fallback: do nothing; non-perturbation renderers won't need it.
    }
}

/**
 * Initialization and registering of the event handlers.
 * @param {FractalRenderer} app
 */
export function initTouchHandlers(app) {
    if (!app || !app.canvas) {
        console.warn(`%c initTouchHandlers: %c App or canvas not provided.`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);
        return;
    }

    fractalApp = app;
    canvas = app.canvas;
    canvas.addEventListener('contextmenu', (e) => e.preventDefault());

    registerTouchEventHandlers();
}

/** Registers touch handlers. */
export function registerTouchEventHandlers() {
    if (!canvas) {
        console.warn(`%c registerTouchEventHandlers: %c Canvas not initialized. Call initTouchHandlers first.`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);
        return;
    }

    if (touchHandlersRegistered) {
        console.warn(`%c registerTouchEventHandlers: %c Event handlers already registered!`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);
        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`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);
}

/** Unregisters touch handlers. */
export function unregisterTouchEventHandlers() {
    if (!canvas || !touchHandlersRegistered) {
        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`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);
}

// region > EVENT HANDLERS ---------------------------------------------------------------------------------------------

/**
 * Start the continuous long press zoom-in loop for touch.
 * Zooms in toward the anchor point while allowing panning.
 */
function startLongPressZoomIn() {
    if (longPressZoomActive) return;
    longPressZoomActive = true;

    function zoomLoop() {
        if (!longPressZoomActive || !fractalApp) return;

        const targetZoom = fractalApp.zoom * LONG_PRESS_ZOOM_IN_FACTOR;

        if (targetZoom > fractalApp.MAX_ZOOM) {
            // Use anchor-preserving zoom
            fractalApp.setZoomKeepingAnchor(targetZoom, longPressAnchorX, longPressAnchorY);
            markOrbitDirtySafe();
            fractalApp.draw();
            updateInfo(true);

            longPressZoomRAF = requestAnimationFrame(zoomLoop);
        } else {
            // Reached max zoom, stop
            stopLongPressZoomIn();
        }
    }

    longPressZoomRAF = requestAnimationFrame(zoomLoop);
    fractalApp.noteInteraction(160);
}

/**
 * Stop the long press zoom-in loop for touch.
 */
function stopLongPressZoomIn() {
    if (longPressTimeout) {
        clearTimeout(longPressTimeout);
        longPressTimeout = null;
    }
    if (longPressZoomRAF) {
        cancelAnimationFrame(longPressZoomRAF);
        longPressZoomRAF = null;
    }
    if (longPressZoomActive) {
        longPressZoomActive = false;
        resetAppState();
    }
}

function handleTouchStart(event) {
    if (!fractalApp) return;

    if (event.touches.length === 1) {
        event.preventDefault();

        isTouchDragging = false;
        isPinching = false;

        pinchStartDistance = null;
        pinchStartAngle = null;

        const touch = event.touches[0];
        touchDownX = touch.clientX;
        touchDownY = touch.clientY;
        lastTouchX = touch.clientX;
        lastTouchY = touch.clientY;

        // Cache rect origin for stable relative coordinates during the drag.
        const rect = canvas.getBoundingClientRect();
        dragRectLeft = rect.left;
        dragRectTop = rect.top;
        hasDragRect = true;

        // Set up long press zoom anchor (relative to canvas)
        longPressAnchorX = touch.clientX - rect.left;
        longPressAnchorY = touch.clientY - rect.top;

        // Start long press timer
        if (longPressTimeout) clearTimeout(longPressTimeout);
        longPressTimeout = setTimeout(() => {
            // Only start zoom if we haven't started dragging or pinching
            if (!isTouchDragging && !isPinching) {
                startLongPressZoomIn();
            }
        }, LONG_PRESS_THRESHOLD);

        return;
    }

    if (event.touches.length === 2) {
        event.preventDefault();

        // Stop any long press zoom when switching to pinch
        stopLongPressZoomIn();

        // Two-finger gesture: pinch zoom + rotation + pan
        isPinching = true;
        isTouchDragging = false;

        // Release drag cache (rect on-demand for pinch)
        hasDragRect = false;

        const touch0 = event.touches[0];
        const touch1 = event.touches[1];

        pinchStartDistance = Math.hypot(
            touch0.clientX - touch1.clientX,
            touch0.clientY - touch1.clientY
        );

        pinchStartAngle = Math.atan2(
            touch1.clientY - touch0.clientY,
            touch1.clientX - touch0.clientX
        );

        // Initialize midpoint for panning
        const rect = canvas.getBoundingClientRect();
        const centerClientX = (touch0.clientX + touch1.clientX) / 2;
        const centerClientY = (touch0.clientY + touch1.clientY) / 2;
        lastPinchCenterX = centerClientX - rect.left;
        lastPinchCenterY = centerClientY - rect.top;

        // Stop any pending single-tap click when pinch starts
        if (touchClickTimeout) {
            clearTimeout(touchClickTimeout);
            touchClickTimeout = null;
        }

        fractalApp.noteInteraction(160);
    }
}

function handleTouchMove(event) {
    if (!fractalApp) return;

    // Single touch drag (pan)
    if (event.touches.length === 1 && !isPinching) {
        event.preventDefault();

        const touch = event.touches[0];

        // If long press zoom is active, update anchor for panning while zooming
        if (longPressZoomActive) {
            const rect = canvas.getBoundingClientRect();
            longPressAnchorX = touch.clientX - rect.left;
            longPressAnchorY = touch.clientY - rect.top;
            return; // Don't process as regular drag
        }

        const dx = touch.clientX - touchDownX;
        const dy = touch.clientY - touchDownY;

        if (!isTouchDragging && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) {
            isTouchDragging = true;
            // Cancel long press timer when drag starts
            if (longPressTimeout) {
                clearTimeout(longPressTimeout);
                longPressTimeout = null;
            }
            if (touchClickTimeout) {
                clearTimeout(touchClickTimeout);
                touchClickTimeout = null;
            }
        }

        if (isTouchDragging) {
            // Use cached rect origin (fallback if not available).
            let left = dragRectLeft;
            let top = dragRectTop;
            if (!hasDragRect) {
                const rect = canvas.getBoundingClientRect();
                left = rect.left;
                top = rect.top;
            }

            // Stable deep-zoom pan delta:
            // pan += zoom * (vLast - vNow)
            const lastRelX = lastTouchX - left;
            const lastRelY = lastTouchY - top;
            const nowRelX = touch.clientX - left;
            const nowRelY = touch.clientY - top;

            const [vLastX, vLastY] = fractalApp.screenToViewVector(lastRelX, lastRelY);
            const [vNowX,  vNowY ] = fractalApp.screenToViewVector(nowRelX, nowRelY);

            if (Number.isFinite(fractalApp.zoom)) {
                const deltaX = (vLastX - vNowX) * fractalApp.zoom;
                const deltaY = (vLastY - vNowY) * fractalApp.zoom;

                fractalApp.addPan(deltaX, deltaY);
                markOrbitDirtySafe();
                fractalApp.noteInteraction(160);
            }

            lastTouchX = touch.clientX;
            lastTouchY = touch.clientY;

            fractalApp.draw();
            updateInfo(true);
        }

        return;
    }

    // Two-finger pinch: pan + zoom + rotation around midpoint
    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
        );

        // Midpoint in screen space
        const centerClientX = (touch0.clientX + touch1.clientX) / 2;
        const centerClientY = (touch0.clientY + touch1.clientY) / 2;

        const rect = canvas.getBoundingClientRect();
        const centerX = centerClientX - rect.left; // CSS px relative to canvas
        const centerY = centerClientY - rect.top;

        // Initialize baseline if missing (or if gesture restarted)
        if (!pinchStartDistance || !pinchStartAngle || lastPinchCenterX === null || lastPinchCenterY === null) {
            pinchStartDistance = currentDistance;
            pinchStartAngle = currentAngle;
            lastPinchCenterX = centerX;
            lastPinchCenterY = centerY;
            return;
        }

        // Pan: detect midpoint movement and apply pan delta
        const centerDeltaX = centerX - lastPinchCenterX;
        const centerDeltaY = centerY - lastPinchCenterY;

        if (Math.abs(centerDeltaX) > 0.1 || Math.abs(centerDeltaY) > 0.1) {
            // Convert screen delta to view delta and apply pan
            const [vLastX, vLastY] = fractalApp.screenToViewVector(lastPinchCenterX, lastPinchCenterY);
            const [vNowX, vNowY] = fractalApp.screenToViewVector(centerX, centerY);

            if (Number.isFinite(fractalApp.zoom)) {
                const deltaX = (vLastX - vNowX) * fractalApp.zoom;
                const deltaY = (vLastY - vNowY) * fractalApp.zoom;
                fractalApp.addPan(deltaX, deltaY);
            }
        }

        // Zoom:
        // If distance increases -> zoom in (smaller zoom value).
        // So scale zoom proportionally: zoom *= (startDistance / currentDistance)
        let targetZoom = fractalApp.zoom;
        if (currentDistance > 0) {
            const zoomFactor = pinchStartDistance / currentDistance;
            targetZoom = fractalApp.zoom * zoomFactor;
        }

        // Clamp (same semantics as your mouse/wheel handlers)
        if (targetZoom < fractalApp.MAX_ZOOM) targetZoom = fractalApp.MAX_ZOOM;
        if (targetZoom > fractalApp.MIN_ZOOM) targetZoom = fractalApp.MIN_ZOOM;

        // Apply zoom (without anchor adjustment since we handled pan separately)
        fractalApp.zoom = targetZoom;

        // Rotation (incremental, like mouse right-drag)
        const angleDifference = currentAngle - pinchStartAngle;
        if (Math.abs(angleDifference) > ROTATION_THRESHOLD) {
            fractalApp.rotation = normalizeRotation(fractalApp.rotation + angleDifference * ROTATION_SENSITIVITY);
        }

        // Update baselines for next frame
        pinchStartDistance = currentDistance;
        pinchStartAngle = currentAngle;
        lastPinchCenterX = centerX;
        lastPinchCenterY = centerY;

        markOrbitDirtySafe();
        fractalApp.noteInteraction(160);

        fractalApp.draw();
        updateInfo();
    }
}

function handleTouchEnd(event) {
    if (!fractalApp) return;

    // Reset pinch baseline when leaving 2-finger gesture.
    if (event.touches.length < 2) {
        pinchStartDistance = null;
        pinchStartAngle = null;
        lastPinchCenterX = null;
        lastPinchCenterY = null;
    }

    // When all touches end, decide if it was tap/double-tap or drag/pinch end.
    if (event.touches.length === 0) {
        // Stop long press zoom on touch end
        if (longPressZoomActive) {
            stopLongPressZoomIn();
            return; // Don't process as tap
        }
        // Cancel pending long press timer
        if (longPressTimeout) {
            clearTimeout(longPressTimeout);
            longPressTimeout = null;
        }

        // End of a pinch gesture: just settle.
        if (isPinching) {
            isPinching = false;
            resetAppState();

            // Final settle rebuild request (renderer decides when to rebuild)
            markOrbitDirtySafe();
            fractalApp.draw();

            return;
        }

        // Release drag cache
        hasDragRect = false;

        if (!isTouchDragging) {
            const touch = event.changedTouches[0];

            const rect = canvas.getBoundingClientRect();
            const touchX = touch.clientX - rect.left;
            const touchY = touch.clientY - rect.top;

            if (touchClickTimeout !== null) {
                clearTimeout(touchClickTimeout);
                touchClickTimeout = null;

                // Use delta-based pan to preserve DD precision at deep zoom
                let deltaPan = fractalApp.screenToPanDelta(touchX, touchY);

                // Clamp to prevent panning out of view
                deltaPan = clampPanDelta(fractalApp.pan, deltaPan);

                console.log(`%c handleTouchEnd: %c Double Tap: Centering on ${touchX}x${touchY} -> delta [${deltaPan[0]}, ${deltaPan[1]}]`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);

                const targetZoom = fractalApp.zoom * ZOOM_STEP;
                if (targetZoom > fractalApp.MAX_ZOOM) {
                    fractalApp.animatePanByAndZoomTo(deltaPan, targetZoom).then(resetAppState);
                }
            } else {
                touchClickTimeout = setTimeout(() => {
                    // Use delta-based pan to preserve DD precision at deep zoom
                    let deltaPan = fractalApp.screenToPanDelta(touchX, touchY);

                    // Clamp to prevent panning out of view
                    deltaPan = clampPanDelta(fractalApp.pan, deltaPan);

                    console.log(`%c handleTouchEnd: %c Single Tap Click: Centering on ${touchX}x${touchY} -> delta [${deltaPan[0]}, ${deltaPan[1]}]`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);

                    // Centering action using delta-based pan:
                    fractalApp.animatePanBy(deltaPan, 400).then(() => {
                        resetAppState();
                        // Get the updated fractal coordinates after pan
                        const [newFx, newFy] = fractalApp.screenToFractal(touchX, touchY);
                        if (isJuliaMode()) {
                            updateURLParams(FRACTAL_TYPE.JULIA, newFx, newFy, fractalApp.zoom, fractalApp.rotation, fractalApp.c[0], fractalApp.c[1], getCurrentPaletteId());
                        } else {
                            updateURLParams(FRACTAL_TYPE.MANDELBROT, newFx, newFy, fractalApp.zoom, fractalApp.rotation, null, null, getCurrentPaletteId());
                        }
                    });

                    touchClickTimeout = null;
                }, DOUBLE_TAP_THRESHOLD);
            }
        } else {
            resetAppState();
            isTouchDragging = false;

            // Final settle rebuild request (renderer decides when to rebuild)
            markOrbitDirtySafe();
            fractalApp.draw();
        }

        isTouchDragging = false;
    }
}

// endregion -----------------------------------------------------------------------------------------------------------