Source: Camera.js

import { Transform } from './Transform.js'
import { BoundingBox } from './BoundingBox.js'
import { addSignals } from './Signals.js'

/**
 * Defines a rectangular viewing region inside a canvas area.
 * @typedef {Object} Viewport
 * @property {number} x - X-coordinate of the lower-left corner
 * @property {number} y - Y-coordinate of the lower-left corner
 * @property {number} dx - Width of the viewport
 * @property {number} dy - Height of the viewport
 * @property {number} w - Total canvas width
 * @property {number} h - Total canvas height
 */

/**
 * Camera class that manages viewport parameters and camera transformations.
 * Acts as a container for parameters needed to define the viewport and camera position,
 * supporting smooth animations between positions using source and target transforms.
 * 
 * The camera maintains two Transform objects:
 * - source: represents current position
 * - target: represents destination position
 * 
 * Animation between positions is handled automatically by the OpenLIME system
 * unless manually interrupted by user input.
 */
class Camera {
	/**
	 * Creates a new Camera instance.
	 * @param {Object} [options] - Configuration options
	 * @param {boolean} [options.bounded=true] - Whether to limit camera translation to scene boundaries
	 * @param {number} [options.maxFixedZoom=2] - Maximum allowed pixel size
	 * @param {number} [options.minScreenFraction=1] - Minimum portion of screen to show when zoomed in
	 * @param {Transform} [options.target] - Initial target transform
	 * @fires Camera#update
	 */
	constructor(options) {
		Object.assign(this, {
			viewport: null,
			bounded: true,
			minScreenFraction: 1,
			maxFixedZoom: 2,
			maxZoom: 2,
			minZoom: 1,
			boundingBox: new BoundingBox,
		});
		Object.assign(this, options);
		this.target = new Transform(this.target);
		this.source = this.target.copy();
		this.easing = 'linear';
	}

	/**
	 * Creates a deep copy of the camera instance.
	 * @returns {Camera} A new Camera instance with copied properties
	 */
	copy() {
		let camera = new Camera();
		Object.assign(camera, this);
		return camera;
	}

	/**
	* Updates the viewport while maintaining the camera position as close as possible to the previous one.
	* @param {Viewport} view - The new viewport in CSS coordinates
	*/
	setViewport(view) {
		if (this.viewport) {
			let rz = Math.sqrt((view.w / this.viewport.w) * (view.h / this.viewport.h));
			this.viewport = view;
			const { x, y, z, a } = this.target;
			this.setPosition(0, x, y, z * rz, a);
		} else {
			this.viewport = view;
		}
	}

	/**
	 * Returns the current viewport in device coordinates (accounting for device pixel ratio).
	 * @returns {Viewport} The current viewport scaled for device pixels
	 */
	glViewport() {
		let d = window.devicePixelRatio;
		let viewport = {};
		for (let i in this.viewport)
			viewport[i] = this.viewport[i] * d;
		return viewport;
	}

	/**
	 * Converts canvas coordinates to scene coordinates using the specified transform.
	 * @param {number} x - X coordinate relative to canvas
	 * @param {number} y - Y coordinate relative to canvas
	 * @param {Transform} transform - Transform to use for conversion
	 * @returns {{x: number, y: number}} Coordinates in scene space relative to viewport center
	 */
	mapToScene(x, y, transform) {
		//compute coords relative to the center of the viewport.
		x -= this.viewport.w / 2;
		y -= this.viewport.h / 2;
		x -= transform.x;
		y -= transform.y;
		x /= transform.z;
		y /= transform.z;
		let r = Transform.rotate(x, y, -transform.a);
		return { x: r.x, y: r.y };
	}

	/**
	 * Converts scene coordinates to canvas coordinates using the specified transform.
	 * @param {number} x - X coordinate in scene space
	 * @param {number} y - Y coordinate in scene space
	 * @param {Transform} transform - Transform to use for conversion
	 * @returns {{x: number, y: number}} Coordinates in canvas space
	 */
	sceneToCanvas(x, y, transform) {
		let r = Transform.rotate(x, y, transform.a);
		x = r.x * transform.z + transform.x - this.viewport.x + this.viewport.w / 2;
		y = r.y * transform.z - transform.y + this.viewport.y + this.viewport.h / 2;
		return { x: x, y: y };
	}

	/**
	 * Sets the camera target parameters for a new position.
	 * @param {number} dt - Animation duration in milliseconds
	 * @param {number} x - X component of translation
	 * @param {number} y - Y component of translation
	 * @param {number} z - Zoom factor
	 * @param {number} a - Rotation angle in degrees
	 * @param {string} [easing] - Easing function name for animation
	 * @fires Camera#update
	 */
	setPosition(dt, x, y, z, a, easing) {
		/**
		* The event is fired when the camera target is changed.
		* @event Camera#update
		*/

		// Discard events due to cursor outside window
		//if (Math.abs(x) > 64000 || Math.abs(y) > 64000) return;
		this.easing = easing || this.easing;

		if (this.bounded) {
			const sw = this.viewport.dx;
			const sh = this.viewport.dy;

			//
			let xform = new Transform({ x: x, y: y, z: z, a: a, t: 0 });
			let tbox = xform.transformBox(this.boundingBox);
			const bw = tbox.width();
			const bh = tbox.height();

			// Screen space offset between image boundary and screen boundary
			// Do not let transform offet go beyond this limit.
			// if (scaled-image-size < screen) it remains fully contained
			// else the scaled-image boundary closest to the screen cannot enter the screen.
			const dx = Math.abs(bw - sw) / 2;// + this.boundingBox.center().x- tbox.center().x;
			x = Math.min(Math.max(-dx, x), dx);

			const dy = Math.abs(bh - sh) / 2;// + this.boundingBox.center().y - tbox.center().y;
			y = Math.min(Math.max(-dy, y), dy);
		}

		let now = performance.now();
		this.source = this.getCurrentTransform(now);
		//the angle needs to be interpolated in the shortest direction.
		//target it is kept between 0 and +360, source is kept relative.
		a = Transform.normalizeAngle(a);
		this.source.a = Transform.normalizeAngle(this.source.a);
		if (a - this.source.a > 180) this.source.a += 360;
		if (this.source.a - a > 180) this.source.a -= 360;
		Object.assign(this.target, { x: x, y: y, z: z, a: a, t: now + dt });
		console.assert(!isNaN(this.target.x));
		this.emit('update');
	}

	/**
	 * Pans the camera by a specified amount in canvas coordinates.
	 * @param {number} dt - Animation duration in milliseconds
	 * @param {number} dx - Horizontal displacement
	 * @param {number} dy - Vertical displacement
	 */
	pan(dt, dx, dy) {
		let now = performance.now();
		let m = this.getCurrentTransform(now);
		m.x += dx;
		m.y += dy;
		this.setPosition(dt, m.x, m.y, m.z, m.a);
	}

	/**
	 * Zooms the camera to a specific point in canvas coordinates.
	 * @param {number} dt - Animation duration in milliseconds
	 * @param {number} z - Target zoom level
	 * @param {number} [x=0] - X coordinate to zoom towards
	 * @param {number} [y=0] - Y coordinate to zoom towards
	 */
	zoom(dt, z, x, y) {
		if (!x) x = 0;
		if (!y) y = 0;

		let now = performance.now();
		let m = this.getCurrentTransform(now);

		if (this.bounded) {
			z = Math.min(Math.max(z, this.minZoom), this.maxZoom);
		}

		//x, an y should be the center of the zoom.
		m.x += (m.x + x) * (m.z - z) / m.z;
		m.y += (m.y + y) * (m.z - z) / m.z;

		this.setPosition(dt, m.x, m.y, z, m.a);
	}

	/**
	 * Rotates the camera around its z-axis.
	 * @param {number} dt - Animation duration in milliseconds
	 * @param {number} a - Rotation angle in degrees
	 */
	rotate(dt, a) {

		let now = performance.now();
		let m = this.getCurrentTransform(now);

		this.setPosition(dt, m.x, m.y, m.z, this.target.a + a);
	}

	/**
	 * Applies a relative zoom change at a specific point.
	 * @param {number} dt - Animation duration in milliseconds
	 * @param {number} dz - Relative zoom change factor
	 * @param {number} [x=0] - X coordinate to zoom around
	 * @param {number} [y=0] - Y coordinate to zoom around
	 */
	deltaZoom(dt, dz, x = 0, y = 0) {

		let now = performance.now();
		let m = this.getCurrentTransform(now);

		//rapid firing wheel event need to compound.
		//but the x, y in input are relative to the current transform.
		dz *= this.target.z / m.z;

		if (this.bounded) {
			if (m.z * dz < this.minZoom) dz = this.minZoom / m.z;
			if (m.z * dz > this.maxZoom) dz = this.maxZoom / m.z;
		}

		//transform is x*z + dx = X , there x is positrion in scene, X on screen
		//we want x*z*dz + dx1 = X (stay put, we need to find dx1.
		let r = Transform.rotate(x, y, m.a);
		m.x += r.x * m.z * (1 - dz);
		m.y += r.y * m.z * (1 - dz);

		this.setPosition(dt, m.x, m.y, m.z * dz, m.a);
	}

	/**
	 * Gets the camera transform at a specific time.
	 * @param {number} time - Current time in milliseconds (from performance.now())
	 * @returns {Transform} The interpolated transform at the specified time
	 */
	getCurrentTransform(time) {
		if (time > this.target.t) this.easing = 'linear';
		return Transform.interpolate(this.source, this.target, time, this.easing);
	}

	/**
	 * Gets the camera transform at a specific time in device coordinates.
	 * @param {number} time - Current time in milliseconds (from performance.now())
	 * @returns {Transform} The interpolated transform scaled for device pixels
	 */
	getGlCurrentTransform(time) {
		const pos = this.getCurrentTransform(time);
		pos.x *= window.devicePixelRatio;
		pos.y *= window.devicePixelRatio;
		pos.z *= window.devicePixelRatio;
		return pos;
	}

	/**
	 * Adjusts the camera to frame a specified bounding box.
	 * @param {BoundingBox} box - The box to frame in canvas coordinates
	 * @param {number} [dt=0] - Animation duration in milliseconds
	 */
	fit(box, dt) {
		if (box.isEmpty()) return;
		if (!dt) dt = 0;

		//find if we align the topbottom borders or the leftright border.
		let w = this.viewport.dx;
		let h = this.viewport.dy;

		let bw = box.width();
		let bh = box.height();
		let c = box.center();
		let z = Math.min(w / bw, h / bh);

		this.setPosition(dt, -c.x * z, -c.y * z, z, 0);
	}

	/**
	 * Resets the camera to show the entire scene.
	 * @param {number} dt - Animation duration in milliseconds
	 */
	fitCameraBox(dt) {
		this.fit(this.boundingBox, dt);
	}

	/**
	 * Updates the camera's boundary constraints and zoom limits.
	 * @private
	 * @param {BoundingBox} box - New bounding box for constraints
	 * @param {number} minScale - Minimum scale factor
	 */
	updateBounds(box, minScale) {
		this.boundingBox = box;
		const w = this.viewport.dx;
		const h = this.viewport.dy;

		let bw = this.boundingBox.width();
		let bh = this.boundingBox.height();

		this.minZoom = Math.min(w / bw, h / bh) * this.minScreenFraction;
		this.maxZoom = minScale > 0 ? this.maxFixedZoom / minScale : this.maxFixedZoom;
		this.maxZoom = Math.max(this.minZoom, this.maxZoom);
	}
}

addSignals(Camera, 'update');

export { Camera }