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 }