Source: player/Camera.js

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/** @module */

import {CameraState} from "../model/CameraState";

/** Constant: the XML SVG namespace URI.
 *
 * @type {string} */
const SVG_NS = "http://www.w3.org/2000/svg";

/** Check that an SVG element is usable as a reference element.
 *
 * The function helps work around a bug in web browsers where text elements
 * are known to return unreliable bounding box data.
 *
 * @param {SVGElement} elt - An SVG element to check.
 * @returns {boolean} - `true` if the element can be used as a reference element.
 */
export function hasReliableBoundaries(elt) {
    return !/text|textpath|tspan/i.test(elt.tagName);
}

/** Camera.
 *
 * @extends module:model/CameraState.CameraState
 */
export class Camera extends CameraState {

    /** Initialize a new camera.
     *
     * @param {module:player/Viewport.Viewport} viewport - The viewport attached to this camera.
     * @param {module:model/Presentation.Layer} layer - The layer where this camera operates.
     */
    constructor(viewport, layer) {
        super(viewport.svgRoot);

        /** The viewport attached to this camera.
         *
         * @type {module:player/Viewport.Viewport} */
        this.viewport = viewport;

        /** The layer where this camera operates.
         *
         * @type {module:model/Presentation.Layer} */
        this.layer = layer;

        /** Is the layer for this camera selected for manipulation by the user?
         *
         * When playing the presentation, all cameras are always selected.
         *
         * @default
         * @type {boolean} */
        this.selected = true;

        /** The clipping rectangle of this camera.
         *
         * @type {SVGRectElement} */
        this.svgClipRect = document.createElementNS(SVG_NS, "rect");

        let svgClipId;

        if (viewport.editMode) {
            /** In edit mode, the opacity of the mask outside the clipping rectangle.
             *
             * @default
             * @type {number} */
            this.maskValue = 0;

            const svgMask = document.createElementNS(SVG_NS, "mask");
            svgClipId = viewport.makeUniqueId("sozi-mask-");
            svgMask.setAttribute("id", svgClipId);
            viewport.svgRoot.appendChild(svgMask);

            /** In edit mode, a rectangle that will be combined to the clipping rectangle to create a mask.
             *
             * @type {SVGRectElement} */
            this.svgMaskRect = document.createElementNS(SVG_NS, "rect");
            svgMask.appendChild(this.svgMaskRect);

            this.svgClipRect.setAttribute("fill", "white");
            svgMask.appendChild(this.svgClipRect);

            /** A rectangle with black stroke that indicates the boundary of the clipped region.
             *
             * This rectangle is painted below {@linkcode module:player/Camera.Camera#svgClipOutlineRect2|svgClipOutlineRect2}
             * to create an alternating black-and-white pattern.
             *
             * @type {SVGRectElement} */
            this.svgClipOutlineRect1 = document.createElementNS(SVG_NS, "rect");
            this.svgClipOutlineRect1.setAttribute("stroke", "black");
            this.svgClipOutlineRect1.setAttribute("fill", "none");
            viewport.svgRoot.appendChild(this.svgClipOutlineRect1);

            /** A rectangle with dashed white stroke that indicates the boundary of the clipped region.
             *
             * This rectangle is painted over {@linkcode module:player/Camera.Camera#svgClipOutlineRect1|svgClipOutlineRect1}
             * to create an alternating black-and-white pattern.
             *
             * @type {SVGRectElement} */
            this.svgClipOutlineRect2 = document.createElementNS(SVG_NS, "rect");
            this.svgClipOutlineRect2.setAttribute("stroke", "white");
            this.svgClipOutlineRect2.setAttribute("fill", "none");
            this.svgClipOutlineRect2.setAttribute("stroke-dasharray", "2,2");
            viewport.svgRoot.appendChild(this.svgClipOutlineRect2);

            this.concealClipping();
        }
        else {
            // When playing the presentation, we use the default SVG
            // clipping technique.
            const svgClipPath = document.createElementNS(SVG_NS, "clipPath");
            svgClipId = viewport.makeUniqueId("sozi-clip-path-");
            svgClipPath.setAttribute("id", svgClipId);
            svgClipPath.appendChild(this.svgClipRect);
            viewport.svgRoot.appendChild(svgClipPath);
        }

        /** The groups that will support transformations.
         *
         * One SVG group is created for each group listed in each layer.
         *
         * @type {SVGGElement[]} */
        this.svgTransformGroups = layer.svgNodes.map(svgNode => {
            // The group that will support the clipping operation
            const svgClippedGroup = document.createElementNS(SVG_NS, "g");
            viewport.svgRoot.insertBefore(svgClippedGroup, svgNode);

            if (viewport.editMode) {
                svgClippedGroup.setAttribute("mask", "url(#" + svgClipId + ")");
            }
            else {
                svgClippedGroup.setAttribute("clip-path", "url(#" + svgClipId + ")");
            }

            const svgGroup = document.createElementNS(SVG_NS, "g");
            svgGroup.appendChild(svgNode);
            svgClippedGroup.appendChild(svgGroup);
            return svgGroup;
        });
    }

    /** Show the clipping rectangle and its surroundings.
     *
     * This method makes the clipping rectangle visible and dims the
     * elements outside this rectangle by making the clipping mask partially
     * transparent.
     *
     * @see {@linkcode module:player/Camera.Camera#maskValue|maskValue}
     * @see {@linkcode module:player/Camera.Camera#svgClipOutlineRect2|svgClipOutlineRect1}
     * @see {@linkcode module:player/Camera.Camera#svgClipOutlineRect2|svgClipOutlineRect2}
     */
    revealClipping() {
        this.maskValue = 64;
        this.svgClipOutlineRect1.style.display = "initial";
        this.svgClipOutlineRect2.style.display = "initial";
    }

    /** Hide the clipping rectangle and its surroundings.
     *
     * This method makes the clipping rectangle invisible and masks the
     * elements outside this rectangle.
     *
     * @see {@linkcode module:player/Camera.Camera#maskValue|maskValue}
     * @see {@linkcode module:player/Camera.Camera#svgClipOutlineRect2|svgClipOutlineRect1}
     * @see {@linkcode module:player/Camera.Camera#svgClipOutlineRect2|svgClipOutlineRect2}
     */
    concealClipping() {
        this.maskValue = 0;
        this.svgClipOutlineRect1.style.display = "none";
        this.svgClipOutlineRect2.style.display = "none";
    }

    /** The scaling factor applied to this camera to fit the viewport.
     *
     * @readonly
     * @type {number}
     */
    get scale() {
        return Math.min(this.viewport.width / this.width, this.viewport.height / this.height);
    }

    /** Rotate this camera.
     *
     * @param {number} angle - The rotation angle, in degrees.
     */
    rotate(angle) {
        this.restoreAspectRatio();
        this.angle += angle;
        this.update();
    }

    /** Zoom by a given factor.
     *
     * A zoom-in operation will shrink the width and height of the image
     * seen by this camera.
     * It will be automatically scaled to fit the viewport.
     *
     * @param {number} factor - The scaling factor, above 1 to zoom in, below 1 to zoom out.
     * @param {number} x - The X coordinate of the transformation center (this point will not move during the operation).
     * @param {number} y - The Y coordinate of the transformation center (this point will not move during the operation).
     */
    zoom(factor, x, y) {
        this.width /= factor;
        this.height /= factor;
        this.restoreAspectRatio();
        this.translate(
            (1 - factor) * (x - this.viewport.width  / 2),
            (1 - factor) * (y - this.viewport.height / 2)
        );
    }

    /** Translate the canvas.
     *
     * The given coordinates represent the displacement of the canvas,
     * e.g. when the user uses a drag-and-drop gesture.
     * For this reason, they are negated when computing the new coordinates
     * of this camera.
     *
     * The translation is applied after the rotation and zoom.
     *
     * @param {number} deltaX - The displacement along the X axis.
     * @param {number} deltaY - The displacement along the Y axis.
     */
    translate(deltaX, deltaY) {
        const scale = this.scale;
        const angleRad = this.angle * Math.PI / 180;
        const si = Math.sin(angleRad);
        const co = Math.cos(angleRad);
        this.cx -= (deltaX * co - deltaY * si) / scale;
        this.cy -= (deltaX * si + deltaY * co) / scale;
        this.restoreAspectRatio();
        this.update();
    }

    /** Clip the image seen by the camera.
     *
     * This method will set the `clipped` property to `true`
     * and will compute the geometry of the clipping rectangle.
     *
     * @param {number} x0 - The X coordinate of the first corner of the clipping rectangle.
     * @param {number} y0 - The Y coordinate of the first corner of the clipping rectangle.
     * @param {number} x1 - The X coordinate of the opposite corner of the clipping rectangle.
     * @param {number} y1 - The Y coordinate of the opposite corner of the clipping rectangle.
     */
    clip(x0, y0, x1, y1) {
        this.clipped = true;
        const scale = this.scale;
        const clipWidth = Math.abs(x1 - x0) + 1;
        const clipHeight = Math.abs(y1 - y0) + 1;
        this.clipXOffset = (Math.min(x0, x1) - (this.viewport.width  - clipWidth)  / 2) * this.width  / clipWidth;
        this.clipYOffset = (Math.min(y0, y1) - (this.viewport.height - clipHeight) / 2) * this.height / clipHeight;
        this.clipWidthFactor  = clipWidth  / this.width  / scale;
        this.clipHeightFactor = clipHeight / this.height / scale;
        this.update();
    }

    /** Update the dimensions of the image to match the aspect ratio of the viewport. */
    restoreAspectRatio() {
        const viewportRatio = this.viewport.width / this.viewport.height;
        const camRatio = this.width / this.height;
        const ratio = viewportRatio / camRatio;
        if (ratio > 1) {
            this.width *= ratio;
            if (this.clipped) {
                this.clipWidthFactor /= ratio;
            }
        }
        else {
            this.height /= ratio;
            if (this.clipped) {
                this.clipHeightFactor *= ratio;
            }
        }
    }

    /** Find an SVG element that can be used as an anchor for this camera.
     *
     * When the SVG is modified, the reference element will be used to
     * recalculate to location, rotation, ans scaling factor of this camera.
     *
     * The reference element is selected according to these criteria:
     * - It must be completely, or partially, visible in the field of this camera.
     * - Its bounding box has the biggest intersection area with the viewport, and the smallest area outside the viewport.
     *
     * @returns {object} - An object `{element, score}` with an SVG element and a number that indicates how well the element fits the viewport.
     */
    getCandidateReferenceElement() {
        // FIXME getIntersectionList is not supported in Gecko
        if (!this.layer.svgNodes.length || !this.svgRoot.getIntersectionList) {
            return {element: null, score: null};
        }

        // Get all elements that intersect with the viewport.
        const viewportArea     = this.viewport.width * this.viewport.height;
        const viewportRect     = this.svgRoot.createSVGRect();
        viewportRect.x         = 0;
        viewportRect.y         = 0;
        viewportRect.width     = this.viewport.width;
        viewportRect.height    = this.viewport.height;
        const intersectionList = this.svgRoot.getIntersectionList(viewportRect, this.layer.svgNodes[0]);

        // Find the element whose bounding box best fits in the viewport.
        let element = null;
        let score   = null;

        for (let elt of intersectionList) {
            if (elt.hasAttribute("id") && hasReliableBoundaries(elt)) {
                // FIXME getBoundingClientRect returns bounding box of bounding box
                const eltRect = elt.getBoundingClientRect();

                // Candidate elements are ranked by the "distance" between their
                // bounding rectangle and the viewport.
                const dl = this.viewport.x                        - eltRect.left;
                const dt = this.viewport.y                        - eltRect.top;
                const dr = this.viewport.x + this.viewport.width  - eltRect.right;
                const db = this.viewport.y + this.viewport.height - eltRect.bottom;
                const eltScore = dl * dl + dt * dt + dr * dr + db * db;

                if (score === null || eltScore < score) {
                    score   = eltScore;
                    element = elt;
                }
            }
        }

        return {element, score};
    }

    /** The geometry of the clipping rectangle.
     *
     * @returns {object} - An object of the form `{width, height, x, y}` where `x` and `y` are the coordinates of the top-left corner.
     */
    get clipRect() {
        let width, height, x, y;
        if (this.clipped) {
            const scale = this.scale;
            width = Math.round(this.width  * this.clipWidthFactor  * scale);
            height = Math.round(this.height * this.clipHeightFactor * scale);
            x = Math.round((this.viewport.width  - width)  / 2 + this.clipXOffset * this.clipWidthFactor  * scale);
            y = Math.round((this.viewport.height - height) / 2 + this.clipYOffset * this.clipHeightFactor * scale);
        }
        else {
            width = this.viewport.width;
            height = this.viewport.height;
            x = 0;
            y = 0;
        }
        return {width, height, x, y};
    }

    /** Update the current layer of the SVG document to reflect the properties of this camera.
     *
     * This method applies geometrical transformations to the current layer,
     * and updates the clipping rectangles and mask.
     */
    update() {
        // Adjust the location and size of the clipping rectangle
        const rect = this.clipRect;
        this.svgClipRect.setAttribute("x", rect.x);
        this.svgClipRect.setAttribute("y", rect.y);
        this.svgClipRect.setAttribute("width",  rect.width);
        this.svgClipRect.setAttribute("height", rect.height);

        if (this.viewport.editMode) {
            this.svgMaskRect.setAttribute("fill", "rgb(" + this.maskValue + "," + this.maskValue + "," + this.maskValue + ")");
            this.svgMaskRect.setAttribute("x", 0);
            this.svgMaskRect.setAttribute("y", 0);
            this.svgMaskRect.setAttribute("width",  this.viewport.width);
            this.svgMaskRect.setAttribute("height", this.viewport.height);

            this.svgClipOutlineRect1.setAttribute("x", rect.x);
            this.svgClipOutlineRect1.setAttribute("y", rect.y);
            this.svgClipOutlineRect1.setAttribute("width",  rect.width);
            this.svgClipOutlineRect1.setAttribute("height", rect.height);

            this.svgClipOutlineRect2.setAttribute("x", rect.x);
            this.svgClipOutlineRect2.setAttribute("y", rect.y);
            this.svgClipOutlineRect2.setAttribute("width",  rect.width);
            this.svgClipOutlineRect2.setAttribute("height", rect.height);
        }

        // Compute and apply the geometrical transformation to the layer group
        const scale = this.scale;
        const translateX = this.viewport.width  / scale / 2 - this.cx;
        const translateY = this.viewport.height / scale / 2 - this.cy;

        for (let svgGroup of this.svgTransformGroups) {
            svgGroup.setAttribute("transform",
                "scale(" + scale + ")" +
                "translate(" + translateX + "," + translateY + ")" +
                "rotate(" + (-this.angle) + ',' + this.cx + "," + this.cy + ")"
            );
            svgGroup.setAttribute("opacity", this.opacity);
            svgGroup.style.display = this.opacity === 0 ? "none" : "initial";
        }
    }

    /** Update this camera by interpolating between two camera states.
     *
     * This method is typically used when animating a transition between two
     * frames of a Sozi presentation.
     *
     * @param {module:model/CameraState.CameraState} initialState - The initial camera state.
     * @param {module:model/CameraState.CameraState} finalState - The final camera state.
     * @param {number} progress - The relative time already elapsed between the initial and final states (between 0 and 1).
     * @param {Function} timingFunction - A function that maps the progress indicator to the relative distance already completed between the initial and final states (between 0 and 1).
     * @param {number} relativeZoom - An additional zooming factor to apply during the transition.
     * @param {SVGPathElement} svgPath - An SVG path to follow during the transition.
     * @param {boolean} reversePath - If `true`, follow the path in the opposite direction.
     */
    interpolate(initialState, finalState, progress, timingFunction, relativeZoom, svgPath, reversePath) {
        const tfProgress = timingFunction(progress);
        const tfRemaining = 1 - tfProgress;

        function linear(initial, final) {
            return final * tfProgress + initial * tfRemaining;
        }

        function quadratic(u0, u1) {
            const um = (relativeZoom > 0 ? Math.min(u0, u1) : Math.max(u0, u1)) * (1 - relativeZoom);
            const du0 = u0 - um;
            const du1 = u1 - um;
            const r = Math.sqrt(du0 / du1);
            const tm = r / (1 + r);
            const k = du0 / tm / tm;
            const dt = progress - tm;
            return k * dt * dt + um;
        }

        // Interpolate camera width and height
        if (relativeZoom) {
            this.width  = quadratic(initialState.width,  finalState.width);
            this.height = quadratic(initialState.height, finalState.height);
        }
        else {
            this.width  = linear(initialState.width,  finalState.width);
            this.height = linear(initialState.height, finalState.height);
        }

        // Interpolate camera location
        if (svgPath) {
            const pathLength   = svgPath.getTotalLength();
            const startPoint   = svgPath.getPointAtLength(reversePath ? pathLength : 0);
            const endPoint     = svgPath.getPointAtLength(reversePath ? 0 : pathLength);
            const currentPoint = svgPath.getPointAtLength(pathLength * (reversePath ? tfRemaining : tfProgress));

            this.cx = currentPoint.x + linear(initialState.cx - startPoint.x, finalState.cx - endPoint.x);
            this.cy = currentPoint.y + linear(initialState.cy - startPoint.y, finalState.cy - endPoint.y);
        }
        else {
            this.cx = linear(initialState.cx, finalState.cx);
            this.cy = linear(initialState.cy, finalState.cy);
        }

        // Interpolate opacity
        this.opacity = linear(initialState.opacity, finalState.opacity);

        // Interpolate camera angle
        // Keep the smallest angle between the initial and final states
        if (finalState.angle - initialState.angle > 180) {
            this.angle = linear(initialState.angle, finalState.angle - 360);
        }
        else if (finalState.angle - initialState.angle < -180) {
            this.angle = linear(initialState.angle - 360, finalState.angle);
        }
        else {
            this.angle = linear(initialState.angle, finalState.angle);
        }

        // Interpolate clip rectangle
        this.clipped = true;
        const scale = this.scale;
        const clipDefaults = {
            clipXOffset: 0,
            clipYOffset: 0,
            clipWidthFactor:  this.viewport.width  / this.width  / scale,
            clipHeightFactor: this.viewport.height / this.height / scale
        };
        const initialClipping = initialState.clipped ? initialState : clipDefaults;
        const finalClipping   = finalState.clipped   ? finalState   : clipDefaults;
        for (let clipProp in clipDefaults) {
            this[clipProp] = linear(initialClipping[clipProp], finalClipping[clipProp]);
        }
    }
}