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) { 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 = 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 }