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) {
if(!this.active) return;
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 }