import { Transform } from "./Transform"; import { CoordinateSystem } from "./CoordinateSystem"; /** * @typedef {Object} Viewport * @property {number} x - Viewport x position * @property {number} y - Viewport y position * @property {number} dx - Viewport horizontal offset * @property {number} dy - Viewport vertical offset * @property {number} w - Viewport width * @property {number} h - Viewport height */ /** * @typedef {Object} Focus * @property {Object} position - Lens center position in dataset coordinates * @property {number} position.x - X coordinate * @property {number} position.y - Y coordinate * @property {number} radius - Lens radius in dataset units */ /** * FocusContext manages the focus+context visualization technique for lens-based interaction. * It handles the distribution of user interactions between lens movement (focus) and camera * movement (context) to maintain optimal viewing conditions. * * Key responsibilities: * - Maintains proper spacing between lens and viewport boundaries * - Distributes pan and zoom operations between lens and camera * - Ensures lens stays within valid viewport bounds * - Adapts camera transform to accommodate lens position * - Manages lens radius constraints */ class FocusContext { /** * Distributes a pan operation between lens movement and camera transform to maintain focus+context * @param {Viewport} viewport - The current viewport * @param {Focus} focus - The lens object to be updated * @param {Transform} context - The camera transform to be updated * @param {Object} delta - Pan amount in dataset pixels * @param {number} delta.x - Horizontal pan amount * @param {number} delta.y - Vertical pan amount * @param {Object} imageSize - Dataset dimensions * @param {number} imageSize.w - Dataset width * @param {number} imageSize.h - Dataset height */ static pan(viewport, focus, context, delta, imageSize) { let txy = this.getAmountOfFocusContext(viewport, focus, context, delta); // When t is 1: already in focus&context, move only the lens. // When t is 0.5: border situation, move both focus & context to keep the lens steady on screen. // In this case the context should be moved of deltaFocus*scale to achieve steadyness. // Thus interpolate deltaContext between 0 and deltaFocus*s (with t ranging from 1 to 0.5) const deltaFocus = { x: delta.x * txy.x, y: delta.y * txy.y }; const deltaContext = { x: -deltaFocus.x * context.z * 2 * (1 - txy.x), y: -deltaFocus.y * context.z * 2 * (1 - txy.y) }; context.x += deltaContext.x; context.y += deltaContext.y; focus.position.x += deltaFocus.x; focus.position.y += deltaFocus.y; // Clamp lens position on dataset boundaries if (Math.abs(focus.position.x) > imageSize.w / 2) { focus.position.x = imageSize.w / 2 * Math.sign(focus.position.x); } if (Math.abs(focus.position.y) > imageSize.h / 2) { focus.position.y = imageSize.h / 2 * Math.sign(focus.position.y); } } /** * Distributes a scale operation between lens radius and camera zoom to maintain focus+context * @param {Camera} camera - The camera object containing viewport and zoom constraints * @param {Focus} focus - The lens object to be updated * @param {Transform} context - The camera transform to be updated * @param {number} dz - Scale factor to be applied (multiplier) */ static scale(camera, focus, context, dz) { const viewport = camera.viewport; const radiusRange = this.getRadiusRangeCanvas(viewport); const r = focus.radius * context.z; // Distribute lens scale between radius scale and context scale // When radius is going outside radius boundary, scale of the inverse amounts radius and zoom scale | screen size constant // When radius is changing from boundary condition to a valid one change only radius and no change to zoom scale. // From 0.5 to boundary condition, zoomScale vary is interpolated between 1 and 1/dz. const t = Math.max(0, Math.min(1, (r - radiusRange.min) / (radiusRange.max - radiusRange.min))); let zoomScaleAmount = 1; if (dz > 1 && t > 0.5) { const t1 = (t - 0.5) * 2; zoomScaleAmount = 1 * (1 - t1) + t1 / dz; } else if (dz < 1 && t < 0.5) { const t1 = 2 * t; zoomScaleAmount = (1 - t1) / dz + t1 * 1; } let radiusScaleAmount = dz; const newR = r * radiusScaleAmount; // Clamp radius if (newR < radiusRange.min) { radiusScaleAmount = radiusRange.min / r; } else if (newR > radiusRange.max) { radiusScaleAmount = radiusRange.max / r; } // Clamp scale if (context.z * zoomScaleAmount < camera.minZoom) { zoomScaleAmount = camera.minZoom / context.z; } else if (context.z * zoomScaleAmount > camera.maxZoom) { zoomScaleAmount = camera.maxZoom / context.z; } // Scale around lens center context.x += focus.position.x * context.z * (1 - zoomScaleAmount); context.y += focus.position.y * context.z * (1 - zoomScaleAmount); context.z = context.z * zoomScaleAmount; focus.radius *= radiusScaleAmount; } /** * Adjusts the camera transform to ensure focus+context conditions are met for a given lens * @param {Viewport} viewport - The current viewport * @param {Focus} focus - The lens object * @param {Transform} context - The camera transform to be updated * @param {number} desiredScale - Target scale for the camera transform */ static adaptContext(viewport, focus, context, desiredScale) { // Get current projected annotation center position //const pOld = context.sceneToViewportCoords(viewport, focus.position); const useGL = true; const pOld = CoordinateSystem.fromSceneToViewportNoCamera(focus.position, context, viewport, useGL); context.z = desiredScale; FocusContext.adaptContextScale(viewport, focus, context); // After scale, restore projected annotation position, in order to avoid // moving the annotation center outside the boundaries //const pNew = context.sceneToViewportCoords(viewport, focus.position); const pNew = CoordinateSystem.fromSceneToViewportNoCamera(focus.position, context, viewport, useGL); const delta = [pNew.x - pOld.x, pNew.y - pOld.y]; context.x -= delta.x; context.y += delta.y; // Force annotation inside the viewport FocusContext.adaptContextPosition(viewport, focus, context); } /** * Adjusts camera scale to ensure projected lens fits within viewport bounds * @param {Viewport} viewport - The current viewport * @param {Focus} focus - The lens object * @param {Transform} context - The camera transform to be updated * @private */ static adaptContextScale(viewport, focus, context) { const oldZ = context.z; const radiusRange = this.getRadiusRangeCanvas(viewport); const focusRadiusCanvas = focus.radius * context.z; let zoomScaleAmount = 1; if (focusRadiusCanvas < radiusRange.min) { context.z = radiusRange.min / focus.radius; // zoomScaleAmount = (radiusRange.min / focus.radius) / context.z; } else if (focusRadiusCanvas > radiusRange.max) { context.z = radiusRange.max / focus.radius; // zoomScaleAmount = (radiusRange.max / focus.radius) / context.z; } } /** * Adjusts camera position to maintain proper focus+context conditions * @param {Viewport} viewport - The current viewport * @param {Focus} focus - The lens object * @param {Transform} context - The camera transform to be updated * @private */ static adaptContextPosition(viewport, focus, context) { const delta = this.getCanvasBorder(focus, context); let box = this.getShrinkedBox(viewport, delta); const useGL = true; const screenP = CoordinateSystem.fromSceneToViewportNoCamera(focus.position, context, viewport, useGL); const deltaMinX = Math.max(0, (box.xLow - screenP.x)); const deltaMaxX = Math.min(0, (box.xHigh - screenP.x)); context.x += deltaMinX != 0 ? deltaMinX : deltaMaxX; const deltaMinY = Math.max(0, (box.yLow - screenP.y)); const deltaMaxY = Math.min(0, (box.yHigh - screenP.y)); context.y += deltaMinY != 0 ? deltaMinY : deltaMaxY; } /** * Calculates focus+context distribution factors for pan operations * @param {Viewport} viewport - The current viewport * @param {Focus} focus - The lens object * @param {Transform} context - The current camera transform * @param {Object} panDir - Pan direction vector * @param {number} panDir.x - Horizontal direction (-1 to 1) * @param {number} panDir.y - Vertical direction (-1 to 1) * @returns {Object} Distribution factors for x and y directions (0.5 to 1) * @private */ static getAmountOfFocusContext(viewport, focus, context, panDir) { // Returns a value t which is used to distribute pan between focus and context. // Return a value among 0.5 and 1. 1 is full focus and context, // 0.5 is borderline focus and context. const delta = this.getCanvasBorder(focus, context); const box = this.getShrinkedBox(viewport, delta); // const p = context.sceneToViewportCoords(viewport, focus.position); const useGL = true; const p = CoordinateSystem.fromSceneToViewportNoCamera(focus.position, context, viewport, useGL); const halfCanvasW = viewport.w / 2 - delta; const halfCanvasH = viewport.h / 2 - delta; let xDistance = (panDir.x > 0 ? Math.max(0, Math.min(halfCanvasW, box.xHigh - p.x)) / (halfCanvasW) : Math.max(0, Math.min(halfCanvasW, p.x - box.xLow)) / ( /** * Distributes a pan operation between lens movement and camera transform to maintain focus+context * @param {Viewport} viewport - The current viewport * @param {Focus} focus - The lens object to be updated * @param {Transform} context - The camera transform to be updated * @param {Object} delta - Pan amount in dataset pixels * @param {number} delta.x - Horizontal pan amount * @param {number} delta.y - Vertical pan amount * @param {Object} imageSize - Dataset dimensions * @param {number} imageSize.w - Dataset width * @param {number} imageSize.h - Dataset height */halfCanvasW)); xDistance = this.smoothstep(xDistance, 0, 0.75); let yDistance = (panDir.y > 0 ? Math.max(0, Math.min(halfCanvasH, box.yHigh - p.y)) / (halfCanvasH) : Math.max(0, Math.min(halfCanvasH, p.y - box.yLow)) / (halfCanvasH)); yDistance = this.smoothstep(yDistance, 0, 0.75); // Use d/2+05, because when d = 0.5 camera movement = lens movement // with the effect of the lens not moving from its canvas position. const txy = { x: xDistance / 2 + 0.5, y: yDistance / 2 + 0.5 }; return txy; } /** * Calculates minimum required distance between lens center and viewport boundary * @param {Focus} focus - The lens object * @param {Transform} context - The camera transform * @returns {number} Minimum distance in canvas pixels * @private */ static getCanvasBorder(focus, context) { // Return the min distance in canvas pixel of the lens center from the boundary. const radiusFactorFromBoundary = 1.5; return context.z * focus.radius * radiusFactorFromBoundary; // Distance Lens Center Canvas Border } /** * Creates a viewport box shrunk by specified padding * @param {Viewport} viewport - The current viewport * @param {number} delta - Padding amount in pixels * @returns {Object} Box with xLow, yLow, xHigh, yHigh coordinates * @private */ static getShrinkedBox(viewport, delta) { // Return the viewport box in canvas pixels, shrinked of delta pixels on the min,max corners const box = { xLow: delta, yLow: delta, xHigh: viewport.w - delta, yHigh: viewport.h - delta }; return box; } /** * Calculates acceptable lens radius range for current viewport * @param {Viewport} viewport - The current viewport * @returns {Object} Range object with min and max radius values in pixels * @private */ static getRadiusRangeCanvas(viewport) { // Returns the acceptable lens radius range in pixel for a certain viewport const maxMinRadiusRatio = 3; const minRadius = Math.min(viewport.w, viewport.h) * 0.1; const maxRadius = minRadius * maxMinRadiusRatio; return { min: minRadius, max: maxRadius }; } /** * Implements smoothstep interpolation between two values * @param {number} x - Input value * @param {number} x0 - Lower bound * @param {number} x1 - Upper bound * @returns {number} Smoothly interpolated value between 0 and 1 * @private */ static smoothstep(x, x0, x1) { // Return the smoothstep interpolation at x, between x0 and x1. if (x < x0) { return 0; } else if (x > x1) { return 1; } else { const t = (x - x0) / (x1 - x0); return t * t * (-2 * t + 3); } } } export { FocusContext }