Source: model/CameraState.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 */

/** Copy a property from an object to another.
 *
 * If the source object has a property with the given name,
 * this property is copied to the target object.
 *
 * @param {object} dest - The destination object.
 * @param {object} src - The source object.
 * @param {string} prop - The name of the property to copy.
 */
function copyIfSet(dest, src, prop) {
    if (src.hasOwnProperty(prop)) {
        dest[prop] = src[prop];
    }
}

/** Camera state.
 *
 * This class models the state of a camera in the context of a Sozi document.
 * It is attached to a given SVG document and contains the properties that need
 * to be stored in the Sozi presentation file.
 *
 * @see {@linkcode module:player/Camera.Camera|Camera} for a camera implementation in the context of the Sozi player.
 */
export class CameraState {
    /** Initialize a new camera state object.
     *
     * If the argument is a camera state, this constructor will create a copy of
     * of that state.
     * If the argument is an SVG root element, a camera state with default properties
     * will be created.
     *
     * @param {(CameraState|SVGSVGElement)} obj - A camera state to copy, or an SVG root element.
     */
    constructor(obj) {
        if (obj instanceof CameraState) {
            this.copy(obj);
        }
        else {
            const initialBBox = obj.getBBox();

            /** The root SVG element attached to this camera.
             *
             * @type {SVGSVGElement} */
            this.svgRoot = obj;

            /** The opacity level of the layer attached to this camera.
             *
             * A floating-point number between 0 and 1.
             *
             * @default
             * @type {number} */
            this.opacity = 1.0;

            /** Indicates that the content outside a specified rectangle must be clipped.
             *
             * @default
             * @type {boolean} */
            this.clipped = false;

            /** The horizontal offset of the clipping rectangle with respect to the current camera location.
             *
             * @default
             * @type {number} */
            this.clipXOffset = 0;

            /** The vertical offset of the clipping rectangle with respect to the current camera location.
             *
             * @default
             * @type {number} */
            this.clipYOffset = 0;

            /** The width of the clipping rectangle with respect to the width of the image seen by the camera.
             *
             * @default
             * @type {number} */
            this.clipWidthFactor = 1;

            /** The height of the clipping rectangle with respect to the height of the image seen by the camera.
             *
             * @default
             * @type {number} */
            this.clipHeightFactor = 1;

            /** The horizontal coordinate of the camera.
             *
             * This is also the horizontal coordinate of the center of the image seen by the camera.
             *
             * @default The center of the bounding box of the SVG content.
             * @type {number} */
            this.cx = initialBBox.x + initialBBox.width / 2;

            /** The vertical coordinate of the camera.
             *
             * This is also the vertical coordinate of the center of the image seen by the camera.
             *
             * @default The center of the bounding box of the SVG content.
             * @type {number} */
            this.cy = initialBBox.y + initialBBox.height / 2;

            // These are assigned through setters. See below.
            this.width  = initialBBox.width;
            this.height = initialBBox.height;
            this.angle  = 0;
        }
    }

    /** Copy another camera state into the current instance.
     *
     * @param {module:model/CameraState.CameraState} state - The camera state to copy.
     */
    copy(state) {
        this.svgRoot          = state.svgRoot;
        this.cx               = state.cx;
        this.cy               = state.cy;
        this.width            = state.width;
        this.height           = state.height;
        this.opacity          = state.opacity;
        this.angle            = state.angle;
        this.clipped          = state.clipped;
        this.clipXOffset      = state.clipXOffset;
        this.clipYOffset      = state.clipYOffset;
        this.clipWidthFactor  = state.clipWidthFactor;
        this.clipHeightFactor = state.clipHeightFactor;
    }

    /** The width of the image seen by the camera.
     *
     * Cannot be lower than 1.
     *
     * @default The width of the bounding box of the SVG content.
     * @type {number}
     */
    get width() {
        return this._width;
    }

    set width(w) {
        this._width = !isNaN(w) && w >= 1 ? w : 1;
    }

    /** The height of the image seen by the camera.
     *
     * Cannot be lower than 1.
     *
     * @default The height of the bounding box of the SVG content.
     * @type {number} */
    get height() {
        return this._height;
    }

    set height(h) {
        this._height = !isNaN(h) && h >= 1 ? h : 1;
    }

    /** The rotation angle applied to the camera, in degrees.
     *
     * The angle is automatically normalized in the interval [-180 ; 180].
     *
     * @default 0
     * @type {number}
     */
    get angle() {
        return this._angle;
    }

    set angle(a) {
        this._angle = !isNaN(a) ? (a + 180) % 360 : 180;
        if (this._angle < 0) {
            this._angle += 180;
        }
        else {
            this._angle -= 180;
        }
    }

    /** Convert this instance to a plain object that can be stored as JSON.
     *
     * The result contains all the properties needed by the editor to restore
     * the state of this instance.
     *
     * @returns {object} - A plain object with the properties needed by the editor.
     */
    toStorable() {
        return {
            cx              : this.cx,
            cy              : this.cy,
            width           : this.width,
            height          : this.height,
            opacity         : this.opacity,
            angle           : this.angle,
            clipped         : this.clipped,
            clipXOffset     : this.clipXOffset,
            clipYOffset     : this.clipYOffset,
            clipWidthFactor : this.clipWidthFactor,
            clipHeightFactor: this.clipHeightFactor
        };
    }

    /** Convert this instance to a plain object that can be stored as JSON.
     *
     * The result contains only the properties needed by the Sozi player to
     * show and animate the presentation.
     *
     * @returns {object} - A plain object with the properties needed by the player.
     */
    toMinimalStorable() {
        return this.toStorable();
    }

    /** Copy the properties of the given object into this instance.
     *
     * @param {object} storable - A plain object with the properties to copy.
     */
    fromStorable(storable) {
        copyIfSet(this, storable, "cx");
        copyIfSet(this, storable, "cy");
        copyIfSet(this, storable, "width");
        copyIfSet(this, storable, "height");
        copyIfSet(this, storable, "opacity");
        copyIfSet(this, storable, "angle");
        copyIfSet(this, storable, "clipped");
        copyIfSet(this, storable, "clipXOffset");
        copyIfSet(this, storable, "clipYOffset");
        copyIfSet(this, storable, "clipWidthFactor");
        copyIfSet(this, storable, "clipHeightFactor");
    }

    /** Fit the current camera state to the given SVG element.
     *
     * The default behavior is to fit the image seen by the camera to the bounding
     * box of the SVG element.
     * Translation, scaling and rotation may be applied.
     *
     * @param {SVGElement} svgElement - The target SVG element.
     * @param {number} [deltaX=0] - An horizontal offset from the center of the SVG element.
     * @param {number} [deltaY=0] - A vertical offset from the center of the SVG element.
     * @param {number} [widthFactor=1] - A scaling factor applied to the width of the SVG element.
     * @param {number} [heightFactor=1] - A scaling factor applied to the height of the SVG element.
     * @param {number} [deltaAngle=0] - A relative angle from the orientation of the SVG element.
     *
     * @see {@linkcode module:model/CameraState.CameraState#offsetFromElement|offsetFromElement}
     * @see {@linkcode module:model/CameraState.CameraState#applyOffset|applyOffset}
     */
    setAtElement(svgElement, deltaX = 0, deltaY = 0, widthFactor = 1, heightFactor = 1, deltaAngle = 0) {
        // Read the raw bounding box of the given SVG element.
        // Fix the width or height if it is zero.
        const bbox = svgElement.getBBox();
        if (bbox.width === 0) {
            bbox.width = 1;
        }
        if (bbox.height === 0) {
            bbox.height = 1;
        }

        // Compute the raw coordinates of the center
        // of the given SVG element
        let bboxCenter = this.svgRoot.createSVGPoint();
        bboxCenter.x = bbox.x + bbox.width  / 2;
        bboxCenter.y = bbox.y + bbox.height / 2;

        // Find the transform group corresponding to the layer
        // that contains the given element
        let layerGroup = svgElement;
        while (layerGroup.parentNode.parentNode !== this.svgRoot) {
            layerGroup = layerGroup.parentNode;
        }

        // Compute the coordinates of the center of the given SVG element
        // after its current transformation
        const matrix = layerGroup.getCTM().inverse().multiply(svgElement.getCTM());
        bboxCenter = bboxCenter.matrixTransform(matrix);

        // Compute the scaling factor applied to the given SVG element
        const scale = Math.sqrt(matrix.a * matrix.a + matrix.b * matrix.b);

        // Update the camera to match the bounding box information of the
        // given SVG element after its current transformation
        this.cx     = bboxCenter.x + deltaX;
        this.cy     = bboxCenter.y + deltaY;
        this.width  = bbox.width  * scale * widthFactor;
        this.height = bbox.height * scale * heightFactor;
        this.angle  = Math.atan2(matrix.b, matrix.a) * 180 / Math.PI + deltaAngle;
    }

    /** Set the clipping properties to their default values.
     *
     * This method will fit the clipping rectangle to the image seen by the camera.
     */
    resetClipping() {
        this.clipXOffset     = this.clipYOffset      = 0;
        this.clipWidthFactor = this.clipHeightFactor = 1;
    }

    /** Compute a transformation from the bounding box of an SVG element to the current camera state.
     *
     * The result has the same type as the argument of {@linkcode module:model/CameraState.CameraState#applyOffset|applyOffset}.
     *
     * @param {SVGElement} svgElement - A source SVG element.
     * @returns {object} - The translation coordinates, scaling factors, and rotation angle.
     *
     * @see {@linkcode module:model/CameraState.CameraState#setAtElement|setAtElement}
     */
    offsetFromElement(svgElement) {
        const cam = new CameraState(this.svgRoot);
        cam.setAtElement(svgElement);
        return {
            deltaX:       this.cx     - cam.cx,
            deltaY:       this.cy     - cam.cy,
            widthFactor:  this.width  / cam.width,
            heightFactor: this.height / cam.height,
            deltaAngle:   this.angle  - cam.angle
        };
    }

    /** Apply a transformation to the current camera state.
     *
     * This method accepts an object with the same type as the result of
     * {@linkcode module:model/CameraState.CameraState#offsetFromElement|offsetFromElement}.
     *
     * @param {object} arg - A transformation object to apply.
     * @param {number} arg.deltaX - An horizontal offset from the center of the SVG element.
     * @param {number} arg.deltaY - A vertical offset from the center of the SVG element.
     * @param {number} arg.widthFactor - A scaling factor applied to the width of the SVG element.
     * @param {number} arg.heightFactor - A scaling factor applied to the height of the SVG element.
     * @param {number} arg.deltaAngle - A relative angle from the orientation of the SVG element.
     *
     * @see {@linkcode module:model/CameraState.CameraState#setAtElement|setAtElement}
     */
    applyOffset({deltaX, deltaY, widthFactor, heightFactor, deltaAngle}) {
        this.cx     -= deltaX;
        this.cy     -= deltaY;
        this.width  /= widthFactor;
        this.height /= heightFactor;
        this.angle  -= deltaAngle;
    }
}