import { ControllerLens } from './ControllerLens.js'
import { CoordinateSystem } from './CoordinateSystem.js';
import { FocusContext } from './FocusContext.js';
import { addSignals } from './Signals.js'
/**
* Controller for handling Focus+Context visualization interactions.
* Manages lens-based focus region and context region interactions including
* panning, zooming, and lens radius adjustments.
* @fires ControllerFocusContext#panStart - Emitted when a pan operation begins, with timestamp
* @fires ControllerFocusContext#panEnd - Emitted when a pan operation ends, with timestamp
* @fires ControllerFocusContext#pinchStart - Emitted when a pinch operation begins, with timestamp
* @fires ControllerFocusContext#pinchEnd - Emitted when a pinch operation ends, with timestamp
* @extends ControllerLens
*/
class ControllerFocusContext extends ControllerLens {
/**
* Helper method to trigger updates.
* @param {Object} param - Object containing update method
* @private
*/
static callUpdate(param) {
param.update();
}
/**
* Creates a new ControllerFocusContext instance.
* @param {Object} options - Configuration options
* @param {number} [options.updateTimeInterval=50] - Time interval for position updates in ms
* @param {number} [options.updateDelay=100] - Delay for position updates in ms
* @param {number} [options.zoomDelay=150] - Delay for zoom animations in ms
* @param {number} [options.zoomAmount=1.5] - Scale factor for zoom operations
* @param {number} [options.priority=-100] - Controller priority
* @param {boolean} [options.enableDirectContextControl=true] - Enable direct manipulation of context region
* @param {Layer} options.lensLayer - Layer to use for lens visualization
* @param {Camera} options.camera - Camera instance to control
* @param {Canvas} options.canvas - Canvas instance to monitor
* @throws {Error} If required options (lensLayer, camera, canvas) are missing
*/
constructor(options) {
super(options);
Object.assign(this, {
updateTimeInterval: 50,
updateDelay: 100,
zoomDelay: 150,
zoomAmount: 1.5,
priority: -100,
enableDirectContextControl: true
}, options);
if (!options.lensLayer) {
console.log("ControllerFocusContext lensLayer option required");
throw "ControllerFocusContext lensLayer option required";
}
if (!options.camera) {
console.log("ControllerFocusContext camera option required");
throw "ControllerFocusContext camera option required";
}
if (!options.canvas) {
console.log("ControllerFocusContext canvas option required");
throw "ControllerFocusContext canvas option required";
}
let callback = () => {
const discardHidden = true;
const bbox = this.camera.boundingBox;
this.maxDatasetSize = Math.max(bbox.width(), bbox.height());
this.minDatasetSize = Math.min(bbox.width(), bbox.height());
this.setDatasetDimensions(bbox.width(), bbox.height());
};
this.canvas.addEvent('updateSize', callback);
this.imageSize = { w: 1, h: 1 };
this.FocusContextEnabled = true;
this.centerToClickOffset = { x: 0, y: 0 };
this.previousClickPos = { x: 0, y: 0 };
this.currentClickPos = { x: 0, y: 0 };
this.insideLens = { inside: false, border: false };
this.panning = false;
this.zooming = false;
this.panningCamera = false;
// Handle only camera panning
this.startPos = { x: 0, y: 0 };
this.initialTransform = this.camera.getCurrentTransform(performance.now());
// Handle pinchZoom
this.initialPinchDistance = 1;
this.initialPinchRadius = 1;
this.initialPinchPos = { x: 0, y: 0 };
addSignals(ControllerFocusContext, 'panStart', 'panEnd', 'pinchStart', 'pinchEnd');
}
/**
* 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;
this.insideLens = this.isInsideLens(p);
const startPos = this.getPixelPosition(e);
if (this.lensLayer.visible && this.insideLens.inside) {
const lc = CoordinateSystem.fromSceneToViewport(this.getFocus().position, this.camera, this.useGL);
this.centerToClickOffset = { x: startPos.x - lc.x, y: startPos.y - lc.y };
this.currentClickPos = { x: startPos.x, y: startPos.y };
this.panning = true;
} else {
if (this.enableDirectContextControl) {
this.startPos = startPos;
this.initialTransform = this.camera.getCurrentTransform(performance.now());
this.camera.target = this.initialTransform.copy(); //stop animation.
this.panningCamera = true;
}
}
e.preventDefault();
this.emit('panStart', Date.now());
// Activate a timeout to call update() in order to update position also when mouse is clicked but steady
// Stop the time out on panEnd
this.timeOut = setInterval(this.update.bind(this), 50);
}
/**
* Handles pan movement.
* @param {PointerEvent} e - Pan move event
* @override
*/
panMove(e) {
if (Math.abs(e.offsetX) > 64000 || Math.abs(e.offsetY) > 64000) return;
this.currentClickPos = this.getPixelPosition(e);
if (this.panning) {
// Update is performed within update() function
} else if (this.panningCamera) {
let m = this.initialTransform;
let dx = (this.currentClickPos.x - this.startPos.x);
let dy = (this.currentClickPos.y - this.startPos.y);
this.camera.setPosition(this.updateDelay, m.x + dx, m.y + dy, m.z, m.a);
}
}
/**
* 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 p = { x: (p0.x + p1.x) * 0.5, y: (p0.y + p1.y) * 0.5 };
this.initialPinchPos = { x: (e1.offsetX + e2.offsetX) * 0.5, y: (e1.offsetY + e2.offsetY) * 0.5 };
this.insideLens = this.isInsideLens(p);
this.zooming = true;
this.initialPinchDistance = this.distance(e1, e2);
this.initialPinchRadius = this.lensLayer.getRadius();
e1.preventDefault();
this.emit('pinchStart', Date.now());
}
/**
* Handles pinch movement.
* @param {PointerEvent} e1 - First finger event
* @param {PointerEvent} e2 - Second finger event
* @override
*/
pinchMove(e1, e2) {
if (this.zooming) {
const d = this.distance(e1, e2);
const scale = d / (this.initialPinchDistance + 0.00001);
if (this.lensLayer.visible && this.insideLens.inside) {
const newRadius = scale * this.initialPinchRadius;
const currentRadius = this.lensLayer.getRadius();
const dz = newRadius / currentRadius;
// Zoom around initial pinch pos, and not current center to avoid unwanted drifts
this.updateRadiusAndScale(dz);
//this.initialPinchDistance = d;
} else {
if (this.enableDirectContextControl) {
this.updateScale(this.initialPinchPos.x, this.initialPinchPos.y, scale);
this.initialPinchDistance = d;
}
}
}
}
/**
* 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;
this.emit('pinchEnd', Date.now());
}
/**
* Starts zoom operation when clicking on lens border.
* @param {PointerEvent} pe - Pointer event
*/
zoomStart(pe) {
if (this.lensLayer.visible) {
super.zoomStart(pe);
// Ask to call zoomUpdate at regular interval during zoommovement
this.timeOut = setInterval(this.zoomUpdate.bind(this), 50);
}
}
/**
* Handles zoom movement when dragging lens border.
* @param {PointerEvent} pe - Pointer event
*/
zoomMove(pe) {
if (this.zooming) {
this.oldCursorPos = pe;
let t = this.camera.getCurrentTransform(performance.now());
// let p = t.viewportToSceneCoords(this.camera.viewport, pe);
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 radiusRange = FocusContext.getRadiusRangeCanvas(this.camera.viewport);
const newRadius = Math.max(radiusRange.min / t.z, d - this.deltaR);
const dz = newRadius / lens.radius;
this.updateRadiusAndScale(dz);
}
}
/**
* Updates zoom during continuous operation.
* @private
*/
zoomUpdate() {
// Give continuity to zoom scale also when user is steady.
// If lens border is able to reach user pointer zoom stops.
// If this is not possible due to camera scale update,
// zoom will continue with a speed proportional to the radius/cursor distance
if (this.zooming) {
const p = this.getScenePosition(this.oldCursorPos);
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 radiusRange = FocusContext.getRadiusRangeCanvas(this.camera.viewport);
let t = this.camera.getCurrentTransform(performance.now());
const newRadius = Math.max(radiusRange.min / t.z, d - this.deltaR);
const dz = newRadius / lens.radius;
this.updateRadiusAndScale(dz);
}
}
/**
* Handles end of zoom operation.
*/
zoomEnd() {
if (this.lensLayer.visible) {
super.zoomEnd();
// Stop calling zoomUpdate
clearTimeout(this.timeOut);
}
}
/**
* Handles mouse wheel events to simulate a pinch event.
* @param {WheelEvent} e - Wheel event
* @override
*/
mouseWheel(e) {
if(!this.active) return;
const p = this.getScenePosition(e);
this.insideLens = this.isInsideLens(p);
const dz = e.deltaY > 0 ? this.zoomAmount : 1 / this.zoomAmount;
if (this.lensLayer.visible && this.insideLens.inside) {
this.updateRadiusAndScale(dz);
} else {
if (this.enableDirectContextControl) {
// Invert scale when updating scale instead of lens radius, to obtain the same zoom direction
const p = this.getPixelPosition(e);
this.updateScale(p.x, p.y, 1 / dz);
}
}
e.preventDefault();
}
/**
* Updates lens radius and adjusts camera to maintain Focus+Context condition.
* @param {number} dz - Scale factor for radius adjustment
*/
updateRadiusAndScale(dz) {
let focus = this.getFocus();
const now = performance.now();
let context = this.camera.getCurrentTransform(now);
// Subdivide zoom between focus and context
FocusContext.scale(this.camera, focus, context, dz);
// Bring focus within context constraints
FocusContext.adaptContextPosition(this.camera.viewport, focus, context);
// Set new focus and context in camera and lens
this.camera.setPosition(this.zoomDelay, context.x, context.y, context.z, context.a);
this.lensLayer.setRadius(focus.radius, this.zoomDelay);
}
/**
* Updates camera scale around a specific point.
* @param {number} x - X coordinate of zoom center
* @param {number} y - Y coordinate of zoom center
* @param {number} dz - Scale factor
* @private
*/
updateScale(x, y, dz) {
const now = performance.now();
let context = this.camera.getCurrentTransform(now);
const pos = CoordinateSystem.fromCanvasHtmlToScene({x,y}, this.camera, this.useGL);
//const pos = this.camera.mapToScene(x, y, context);
const maxDeltaZoom = this.camera.maxZoom / context.z;
const minDeltaZoom = this.camera.minZoom / context.z;
dz = Math.min(maxDeltaZoom, Math.max(minDeltaZoom, dz));
// Zoom around cursor position
this.camera.deltaZoom(this.updateDelay, dz, pos.x, pos.y);
}
/**
* Handles end of pan operation.
* @override
*/
panEnd() {
if (this.panning) { clearTimeout(this.timeOut); }
this.panning = false;
this.panningCamera = false;
this.zooming = false;
this.emit('panEnd', Date.now());
}
/**
* Updates lens and camera positions based on current interaction.
* @private
*/
update() {
if (this.panning) {
let context = this.camera.getCurrentTransform(performance.now());
let lensDeltaPosition = this.lastInteractionDelta();
lensDeltaPosition.x /= context.z;
lensDeltaPosition.y /= context.z;
let focus = this.getFocus();
if (this.FocusContextEnabled) {
FocusContext.pan(this.camera.viewport, focus, context, lensDeltaPosition, this.imageSize);
this.camera.setPosition(this.updateDelay, context.x, context.y, context.z, context.a);
} else {
focus.position.x += lensDeltaPosition.x;
focus.position.y += lensDeltaPosition.y;
}
this.lensLayer.setCenter(focus.position.x, focus.position.y, this.updateDelay);
this.previousClickPos = [this.currentClickPos.x, this.currentClickPos.y];
}
}
/**
* Calculates movement delta since last interaction.
* @returns {{x: number, y: number}} Position delta
* @private
*/
lastInteractionDelta() {
let result = { x: 0, y: 0 };
// Compute delta with respect to previous position
if (this.panning && this.insideLens.inside) {
// For lens pan Compute delta wrt previous lens position
const lc = CoordinateSystem.fromSceneToViewport(this.getFocus().position, this.camera, this.useGL);
result =
{
x: this.currentClickPos.x - lc.x - this.centerToClickOffset.x,
y: this.currentClickPos.y - lc.y - this.centerToClickOffset.y
};
} else {
// For camera pan Compute delta wrt previous click position
result =
{
x: this.currentClickPos.x - this.previousClickPos.x,
y: this.currentClickPos.y - this.previousClickPos.y
};
}
return result;
}
/**
* Sets the dimensions of the dataset (image) being visualized.
* @param {number} width - Dataset width
* @param {number} height - Dataset height
* @private
*/
setDatasetDimensions(width, height) {
this.imageSize = { w: width, h: height };
}
/**
* Initializes lens position and size.
*/
initLens() {
const t = this.camera.getCurrentTransform(performance.now());
const imageRadius = 100 / t.z;
this.lensLayer.setRadius(imageRadius);
this.lensLayer.setCenter(this.imageSize.w * 0.5, this.imageSize.h * 0.5);
}
}
export { ControllerFocusContext }