Source: ControllerLens.js

import { Controller } from './Controller.js'
import { CoordinateSystem } from './CoordinateSystem.js'
import { FocusContext } from './FocusContext.js';

/**
 * Controller for handling lens-based interactions.
 * Manages user interactions with a lens overlay including panning, zooming,
 * and lens radius adjustments through mouse/touch events.
 * @extends Controller
 */
class ControllerLens extends Controller {
    /**
     * Creates a new ControllerLens instance.
     * @param {Object} options - Configuration options
     * @param {Object} options.lensLayer - Layer used for lens visualization
     * @param {Camera} options.camera - Camera instance to control
     * @param {boolean} [options.useGL=false] - Whether to use WebGL coordinates
     * @param {boolean} [options.active=true] - Whether the controller is initially active
     * @throws {Error} If required options (lensLayer, camera) are missing
     */
    constructor(options) {

        super(options);

        if (!options.lensLayer) {
            console.log("ControllerLens lensLayer option required");
            throw "ControllerLens lensLayer option required";
        }

        if (!options.camera) {
            console.log("ControllerLens camera option required");
            throw "ControllerLens camera option required";
        }

        this.panning = false;
        this.zooming = false;
        this.initialDistance = 0;
        this.startPos = { x: 0, y: 0 };
        this.oldCursorPos = { x: 0, y: 0 };
        this.useGL = false;
    }

    /**
     * Handles start of pan operation.
     * @param {PointerEvent} e - Pan start event
     * @override
     */
    panStart(e) {
        if (!this.active)
            return;

        const p = this.getScenePosition(e);
        this.panning = false;

        const hit = this.isInsideLens(p);
        if (this.lensLayer.visible && hit.inside) {
            // if (hit.border) {
            //     this.zooming = true;
            //     const p = this.getPixelPosition(e);
            //     this.zoomStart(p);
            // } else {
            //     this.panning = true;
            // }
            this.panning = true;
            this.startPos = p;

            e.preventDefault();
        }
    }

    /**
     * Handles pan movement.
     * @param {PointerEvent} e - Pan move event
     * @override
     */
    panMove(e) {
        // Discard events due to cursor outside window
        const p = this.getPixelPosition(e);
        if (Math.abs(e.offsetX) > 64000 || Math.abs(e.offsetY) > 64000) return;
        if (this.panning) {
            const p = this.getScenePosition(e);
            const dx = p.x - this.startPos.x;
            const dy = p.y - this.startPos.y;
            const c = this.lensLayer.getTargetCenter();

            this.lensLayer.setCenter(c.x + dx, c.y + dy);
            this.startPos = p;
            e.preventDefault();
        }
    }

    /**
     * Handles end of pan operation.
     * @param {PointerEvent} e - Pan end event
     * @override
     */
    panEnd(e) {
        this.panning = false;
        this.zooming = false;
    }

    /**
     * Handles start of pinch operation.
     * @param {PointerEvent} e1 - First finger event
     * @param {PointerEvent} e2 - Second finger event
     * @override
     */
    pinchStart(e1, e2) {
        if (!this.active)
            return;

        const p0 = this.getScenePosition(e1);
        const p1 = this.getScenePosition(e2);
        const pc = { x: (p0.x + p1.x) * 0.5, y: (p0.y + p1.y) * 0.5 };

        if (this.lensLayer.visible && this.isInsideLens(pc).inside) {
            this.zooming = true;
            this.initialDistance = this.distance(e1, e2);
            this.initialRadius = this.lensLayer.getRadius();
            this.startPos = pc;

            e1.preventDefault();
        }
    }

    /**
     * Handles pinch movement.
     * @param {PointerEvent} e1 - First finger event
     * @param {PointerEvent} e2 - Second finger event
     * @override
     */
    pinchMove(e1, e2) {
        if (!this.zooming)
            return;
        const d = this.distance(e1, e2);
        const scale = d / (this.initialDistance + 0.00001);
        const newRadius = scale * this.initialRadius;
        this.lensLayer.setRadius(newRadius);
    }

    /**
     * Handles end of pinch operation.
     * @param {PointerEvent} e - End event
     * @param {number} x - X coordinate
     * @param {number} y - Y coordinate
     * @param {number} scale - Final scale value
     * @override
     */
    pinchEnd(e, x, y, scale) {
        this.zooming = false;
    }

    /**
     * Handles mouse wheel events.
     * @param {WheelEvent} e - Wheel event
     * @returns {boolean} True if event was handled
     * @override
     */
    mouseWheel(e) {
        const p = this.getScenePosition(e);
        let result = false;
        if (this.lensLayer.visible && this.isInsideLens(p).inside) {
            const delta = e.deltaY > 0 ? 1 : -1;
            const factor = delta > 0 ? 1.2 : 1 / 1.2;
            const r = this.lensLayer.getRadius();
            this.lensLayer.setRadius(r * factor);
            this.startPos = p;

            result = true;
            e.preventDefault();
        }

        return result;
    }

    /**
     * Initiates zoom operation when clicking on lens border.
     * @param {Object} pe - Pixel position in canvas coordinates
     * @param {number} pe.offsetX - X offset from canvas left
     * @param {number} pe.offsetY - Y offset from canvas top
     */
    zoomStart(pe) {
        if (!this.lensLayer.visible) return;

        this.zooming = true;
        this.oldCursorPos = pe; // Used by derived class
        const p = this.getScenePosition(pe);
        const lens = this.getFocus();
        const r = lens.radius;
        const c = lens.position;
        let v = { x: p.x - c.x, y: p.y - c.y };
        let d = Math.sqrt(v.x * v.x + v.y * v.y);

        // Difference between radius and |Click-LensCenter| will be used by zoomMove
        this.deltaR = d - r;
    }

    /**
     * Updates zoom when dragging lens border.
     * @param {Object} pe - Pixel position in canvas coordinates
     * @param {number} pe.offsetX - X offset from canvas left
     * @param {number} pe.offsetY - Y offset from canvas top
     */
    zoomMove(pe) {
        if (this.zooming) {
            const p = this.getScenePosition(pe);

            const lens = this.getFocus();
            const c = lens.position;
            let v = { x: p.x - c.x, y: p.y - c.y };
            let d = Math.sqrt(v.x * v.x + v.y * v.y);

            //  Set as new radius |Click-LensCenter|(now) - |Click-LensCenter|(start)
            const scale = this.camera.getCurrentTransform(performance.now()).z;
            const radiusRange = FocusContext.getRadiusRangeCanvas(this.camera.viewport);
            const newRadius = Math.max(radiusRange.min / scale, d - this.deltaR);

            this.lensLayer.setRadius(newRadius, this.zoomDelay);
        }
    }

    /**
     * Ends zoom operation.
     */
    zoomEnd() {
        this.zooming = false;
    }

    /**
     * Gets current focus state.
     * @returns {{position: {x: number, y: number}, radius: number}} Focus state object
     */
    getFocus() {
        const p = this.lensLayer.getCurrentCenter();
        const r = this.lensLayer.getRadius();
        return { position: p, radius: r }
    }

    /**
     * Checks if a point is inside the lens.
     * @param {Object} p - Point to check in scene coordinates
     * @param {number} p.x - X coordinate
     * @param {number} p.y - Y coordinate
     * @returns {{inside: boolean, border: boolean}} Whether point is inside lens and/or on border
     */
    isInsideLens(p) {
        const c = this.lensLayer.getCurrentCenter();
        const dx = p.x - c.x;
        const dy = p.y - c.y;
        const d = Math.sqrt(dx * dx + dy * dy);
        const r = this.lensLayer.getRadius();
        const inside = d < r;

        const t = this.camera.getCurrentTransform(performance.now());
        const b = this.lensLayer.getBorderWidth() / t.z;
        const border = inside && d > r - b;
        //console.log("IsInside " + d.toFixed(0) + " r " + r.toFixed(0) + ", b " + b.toFixed(0) + " IN " + inside + " B " + border);
        return { inside: inside, border: border };
    }

    /**
     * Converts position from canvas HTML coordinates to viewport coordinates.
     * @param {PointerEvent} e - event
     * @returns {{x: number, y: number}} Position in viewport coordinates (origin at bottom-left, y up)
     */
    getPixelPosition(e) {
        const p = { x: e.offsetX, y: e.offsetY };
        return CoordinateSystem.fromCanvasHtmlToViewport(p, this.camera, this.useGL);
    }

    /**
     * Converts position from canvas HTML coordinates to scene coordinates.
     * @param {PointerEvent} e - event
     * @returns {{x: number, y: number}} Position in scene coordinates (origin at center, y up)
     */
    getScenePosition(e) {
        const p = { x: e.offsetX, y: e.offsetY };
        return CoordinateSystem.fromCanvasHtmlToScene(p, this.camera, this.useGL);
    }

    /**
     * Calculates distance between two points.
     * @param {PointerEvent} e1 - event
     * @param {PointerEvent} e2 - event
     * @returns {number} Distance between points
     * @private
     */
    distance(e1, e2) {
        return Math.sqrt(Math.pow(e1.x - e2.x, 2) + Math.pow(e1.y - e2.y, 2));
    }
}

export { ControllerLens }