Source: FocusContext.js

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 }