Source: ControllerPanZoom.js

import { Controller } from './Controller.js'
import { CoordinateSystem } from './CoordinateSystem.js';
import { addSignals } from './Signals.js'


/**
 * ControllerPanZoom handles pan, zoom, and interaction events in a canvas element to manipulate camera parameters.
 * It supports multiple interaction methods including:
 * - Mouse drag for panning
 * - Mouse wheel for zooming
 * - Touch gestures (pinch to zoom)
 * - Double tap to zoom
 * 
 * The controller maintains state for ongoing pan and zoom operations and can be configured
 * to use different coordinate systems (HTML or GL) for calculations.
 * 
 * @extends Controller
 * @fires ControllerPanZoom#nowheel - Emitted when a wheel event is received but ctrl key is required and not pressed
 */
class ControllerPanZoom extends Controller {
	/**
	 * Creates a new ControllerPanZoom instance.
	 * @param {Camera} camera - The camera object to control
	 * @param {Object} [options] - Configuration options
	 * @param {number} [options.zoomAmount=1.2] - The zoom multiplier for wheel/double-tap events
	 * @param {boolean} [options.controlZoom=false] - If true, requires Ctrl key to be pressed for zoom operations
	 * @param {boolean} [options.useGLcoords=false] - If true, uses WebGL coordinate system instead of HTML
	 * @param {number} [options.panDelay] - Delay for pan animations
	 * @param {number} [options.zoomDelay] - Delay for zoom animations
	 */
	constructor(camera, options) {
		super(options);

		this.camera = camera;
		this.zoomAmount = 1.2;          //for wheel or double tap event
		this.controlZoom = false;       //require control+wheel to zoom

		this.panning = false;           //true if in the middle of a pan
		this.initialTransform = null;
		this.startMouse = null;

		this.zooming = false;           //true if in the middle of a pinch
		this.initialDistance = 0.0;
		this.useGLcoords = false;

		if (options)
			Object.assign(this, options);
	}

	/**
	 * Handles the start of a pan operation
	 * @private
	 * @param {PointerEvent} e - The pointer event that initiated the pan
	 */
	panStart(e) {
		if (!this.active || this.panning || !this.activeModifiers.includes(this.modifierState(e)))
			return;
		this.panning = true;

		this.startMouse = CoordinateSystem.fromCanvasHtmlToViewport({ x: e.offsetX, y: e.offsetY }, this.camera, this.useGLcoords);

		let now = performance.now();
		this.initialTransform = this.camera.getCurrentTransform(now);
		this.camera.target = this.initialTransform.copy(); //stop animation.
		e.preventDefault();
	}

	/**
	 * Updates camera position during a pan operation
	 * @private
	 * @param {PointerEvent} e - The pointer event with new coordinates
	 */
	panMove(e) {
		if (!this.panning)
			return;

		let m = this.initialTransform;
		const p = CoordinateSystem.fromCanvasHtmlToViewport({ x: e.offsetX, y: e.offsetY }, this.camera, this.useGLcoords);
		let dx = (p.x - this.startMouse.x);
		let dy = (p.y - this.startMouse.y);

		this.camera.setPosition(this.panDelay, m.x + dx, m.y + dy, m.z, m.a);
	}

	/**
	 * Ends the current pan operation
	 * @private
	 * @param {PointerEvent} e - The pointer event that ended the pan
	 */
	panEnd(e) {
		this.panning = false;
	}

	/**
	 * Calculates the Euclidean distance between two points
	 * @private
	 * @param {Object} e1 - First point with x, y coordinates
	 * @param {Object} e2 - Second point with x, y coordinates
	 * @returns {number} The distance between the points
	 */
	distance(e1, e2) {
		return Math.sqrt(Math.pow(e1.x - e2.x, 2) + Math.pow(e1.y - e2.y, 2));
	}

	/**
	 * Initializes a pinch zoom operation
	 * @private
	 * @param {TouchEvent} e1 - First touch point
	 * @param {TouchEvent} e2 - Second touch point
	 */
	pinchStart(e1, e2) {
		this.zooming = true;
		this.initialDistance = Math.max(30, this.distance(e1, e2));
		e1.preventDefault();
		//e2.preventDefault(); //TODO this is optional?
	}

	/**
	 * Updates zoom level during a pinch operation
	 * @private
	 * @param {TouchEvent} e1 - First touch point
	 * @param {TouchEvent} e2 - Second touch point
	 */
	pinchMove(e1, e2) {
		if (!this.zooming)
			return;
		let rect1 = e1.target.getBoundingClientRect();
		let offsetX1 = e1.clientX - rect1.left;
		let offsetY1 = e1.clientY - rect1.top;
		let rect2 = e2.target.getBoundingClientRect();
		let offsetX2 = e2.clientX - rect2.left;
		let offsetY2 = e2.clientY - rect2.top;
		const scale = this.distance(e1, e2);
		// FIXME CHECK ON TOUCH SCREEN
		//const pos = this.camera.mapToScene((offsetX1 + offsetX2)/2, (offsetY1 + offsetY2)/2, this.camera.getCurrentTransform(performance.now()));
		const pos = CoordinateSystem.fromCanvasHtmlToScene({ x: (offsetX1 + offsetX2) / 2, y: (offsetY1 + offsetY2) / 2 }, this.camera, this.useGLcoords);

		const dz = scale / this.initialDistance;
		this.camera.deltaZoom(this.zoomDelay, dz, pos.x, pos.y);
		this.initialDistance = scale;
		e1.preventDefault();
	}

	/**
	 * Ends the current pinch zoom operation
	 * @private
	 * @param {TouchEvent} e - The touch event that ended the pinch
	 * @param {number} x - The x coordinate of the pinch center
	 * @param {number} y - The y coordinate of the pinch center
	 * @param {number} scale - The final scale factor
	 */
	pinchEnd(e, x, y, scale) {
		this.zooming = false;
		e.preventDefault();
	}

	/**
	 * Handles mouse wheel events for zooming
	 * @private
	 * @param {WheelEvent} e - The wheel event
	 * @fires ControllerPanZoom#nowheel
	 */
	mouseWheel(e) {
		if (this.controlZoom && !e.ctrlKey) {
			this.emit('nowheel');
			return;
		}
		let delta = -e.deltaY / 53;
		//const pos = this.camera.mapToScene(e.offsetX, e.offsetY, this.camera.getCurrentTransform(performance.now()));
		const pos = CoordinateSystem.fromCanvasHtmlToScene({ x: e.offsetX, y: e.offsetY }, this.camera, this.useGLcoords);
		const dz = Math.pow(this.zoomAmount, delta);
		this.camera.deltaZoom(this.zoomDelay, dz, pos.x, pos.y);
		e.preventDefault();
	}

	/**
	 * Handles double tap events for zooming
	 * @private
	 * @param {PointerEvent} e - The pointer event representing the double tap
	 */
	fingerDoubleTap(e) { }
	fingerDoubleTap(e) {
		if (!this.active || !this.activeModifiers.includes(this.modifierState(e)))
			return;
		//const pos = this.camera.mapToScene(e.offsetX, e.offsetY, this.camera.getCurrentTransform(performance.now()));
		const pos = CoordinateSystem.fromCanvasHtmlToScene({ x: e.offsetX, y: e.offsetY }, this.camera, this.useGLcoords);

		const dz = this.zoomAmount;
		this.camera.deltaZoom(this.zoomDelay, dz, pos.x, pos.y);
	}

}
addSignals(ControllerPanZoom, 'nowheel');

export { ControllerPanZoom }