Source: Controller2D.js

import { BoundingBox } from './BoundingBox.js';
import { Controller } from './Controller.js'

/**
 * Callback for position updates.
 * @callback updatePosition
 * @param {number} x - X coordinate in the range [-1, 1]
 * @param {number} y - Y coordinate in the range [-1, 1]
 */

/**
 * Clamps a value between a minimum and maximum.
 * @param {number} value - Value to clamp
 * @param {number} min - Minimum allowed value
 * @param {number} max - Maximum allowed value
 * @returns {number} Clamped value
 * @private
 */
function clamp(value, min, max) {
	return Math.max(min, Math.min(max, value));
}

/**
 * Controller for handling 2D position updates based on pan and tap events.
 * Extends the base Controller to track a 2D position (x, y) of the device pointer.
 * 
 * Supports two coordinate systems:
 * - Absolute: Coordinates mapped to [-1, 1] with origin at bottom-left of canvas
 * - Relative: Coordinates based on distance from initial pan position, scaled by speed
 * 
 * @extends Controller
 */
class Controller2D extends Controller {
	/**
	 * Creates a new Controller2D instance.
	 * @param {updatePosition} callback - Function called when position is updated
	 * @param {Object} [options] - Configuration options
	 * @param {boolean} [options.relative=false] - Whether to use relative coordinate system
	 * @param {number} [options.speed=2.0] - Scaling factor for relative coordinates
	 * @param {BoundingBox} [options.box] - Bounding box for coordinate constraints
	 * @param {updatePosition} [options.onPanStart] - Callback for pan start event
	 * @param {updatePosition} [options.onPanEnd] - Callback for pan end event
	 * @param {boolean} [options.active=true] - Whether the controller is active
	 * @param {number[]} [options.activeModifiers=[0]] - Array of active modifier states
	 */
	constructor(callback, options) {
		super(options);
		Object.assign(this, {
			relative: false,
			speed: 2.0,
			start_x: 0,
			start_y: 0,
			current_x: 0,
			current_y: 0,
			onPanStart: null,
			onPanEnd: null
		}, options);

		//By default the controller is active only with no modifiers.
		//you can select which subsets of the modifiers are active.
		this.callback = callback;

		if (!this.box) { //FIXME What is that? Is it used?
			this.box = new BoundingBox({ xLow: -0.99, yLow: -0.99, xHigh: 0.99, yHigh: 0.99 });
		}

		this.panning = false;
	}

	/**
	 * Updates the stored position for relative coordinate system.
	 * This is a convenience method typically used within callbacks.
	 * @param {number} x - New X coordinate in range [-1, 1]
	 * @param {number} y - New Y coordinate in range [-1, 1]
	 */
	setPosition(x, y) {
		this.current_x = x;
		this.current_y = y;
		this.callback(x, y);
	}

	/**
	 * Maps canvas pixel coordinates to normalized coordinates [-1, 1].
	 * @param {MouseEvent|TouchEvent} e - Mouse or touch event
	 * @returns {number[]} Array containing [x, y] in normalized coordinates
	 * @private
	 */
	project(e) {
		let rect = e.target.getBoundingClientRect();
		let x = 2 * e.offsetX / rect.width - 1;
		let y = 2 * (1 - e.offsetY / rect.height) - 1;
		return [x, y]
	}

	/**
	 * Converts event coordinates to the appropriate coordinate system (absolute or relative).
	 * @param {MouseEvent|TouchEvent} e - Mouse or touch event
	 * @returns {number[]} Array containing [x, y] in the chosen coordinate system
	 * @private
	 */
	rangeCoords(e) {
		let [x, y] = this.project(e);

		if (this.relative) {
			x = clamp(this.speed * (x - this.start_x) + this.current_x, -1, 1);
			y = clamp(this.speed * (y - this.start_y) + this.current_y, -1, 1);
		}
		return [x, y];
	}

	/**
	 * Handles start of pan gesture.
	 * @param {MouseEvent|TouchEvent} e - Pan start event
	 * @override
	 */
	panStart(e) {
		if (!this.active || !this.activeModifiers.includes(this.modifierState(e)))
			return;

		if (this.relative) {
			let [x, y] = this.project(e);
			this.start_x = x;
			this.start_y = y;
		}
		if (this.onPanStart)
			this.onPanStart(...this.rangeCoords(e));
		this.callback(...this.rangeCoords(e));
		this.panning = true;
		e.preventDefault();
	}

	/**
	 * Handles pan movement.
	 * @param {MouseEvent|TouchEvent} e - Pan move event
	 * @returns {boolean} False if not currently panning
	 * @override
	 */
	panMove(e) {
		if (!this.panning)
			return false;
		this.callback(...this.rangeCoords(e));
	}

	/**
	 * Handles end of pan gesture.
	 * @param {MouseEvent|TouchEvent} e - Pan end event
	 * @returns {boolean} False if not currently panning
	 * @override
	 */
	panEnd(e) {
		if (!this.panning)
			return false;
		this.panning = false;
		if (this.relative) {
			let [x, y] = this.project(e);
			this.current_x = clamp(this.speed * (x - this.start_x) + this.current_x, -1, 1);
			this.current_y = clamp(this.speed * (y - this.start_y) + this.current_y, -1, 1);
		}
		if (this.onPanEnd)
			this.onPanEnd(...this.rangeCoords(e));
	}

	/**
	 * Handles single tap/click events.
	 * Only processes events in absolute coordinate mode.
	 * @param {MouseEvent|TouchEvent} e - Tap event
	 * @override
	 */
	fingerSingleTap(e) {
		if (!this.active || !this.activeModifiers.includes(this.modifierState(e)))
			return;
		if (this.relative)
			return;

		this.callback(...this.rangeCoords(e));
		e.preventDefault();
	}

}

export { Controller2D }