graphics/thing.js

/**
 * A generic class that other elements inherit from.
 * @class Thing
 */
class Thing {
    static DEGREES = 0;
    static RADIANS = 1;
    static thingID = 0;

    type = 'Thing';
    anchor = { horizontal: 0, vertical: 0 };

    /**
     * Constructs a new Thing.
     */
    constructor() {
        /**
         * Unique identifier for a Thing.
         * @type {number}
         * @private
         */
        this._id = Thing.thingID++;
        this.alive = true;
        this._x = 0;
        this._y = 0;
        /**@private**/
        this._height;
        /**@private**/
        this._width;
        this.color = '#000000';
        this.stroke = '#000000';
        this.lineWidth = 1;
        this.filled = true;
        this.hasBorder = false;
        this.focused = false;
        /**@private**/
        this._rotation = 0;
        /**
         * Used to record the layer of the element for sorting when drawing.
         * @type {number}
         * @private
         */
        this._layer = 1;
        /**
         * Used to record when the bounds of this element were last calculated.
         * Groups containing elements need to recalculate their own bounds whenever
         * an element's bounds change.
         * @type {number}
         * @private
         */
        this._lastCalculatedBoundsID = 0;
        /**
         * Used to record when this element's sort value was changed, so the GraphicsManager
         * can perform a resort.
         * @type {boolean}
         * @private
         */
        this._sortInvalidated = true;
        /**
         * Used to record when this element's bounds are invalidated,
         * so that when needed, they can be recalculated.
         * @type {boolean}
         * @private
         */
        this._boundsInvalidated = true;
        /**
         * Elements whose bounds should be invalidated when this element's bounds are invalidated.
         * @type {Thing[]}
         * @private
         */
        this._invalidationDependants = [];
        this.bounds = null;
    }

    /**
     * Sets the layer of the Thing and marks the sortInvalidated flag
     * so any Graphics instances drawing it know to re-sort.
     */
    set layer(newLayer) {
        this._sortInvalidated = true;
        this._layer = newLayer;
    }

    get layer() {
        return this._layer;
    }

    set width(width) {
        this._width = width;
        this._invalidateBounds();
    }

    get width() {
        return this._width;
    }

    set height(height) {
        this._height = height;
        this._invalidateBounds();
    }

    get height() {
        return this._height;
    }

    set rotation(rotation) {
        this._rotation = rotation;
        this._invalidateBounds();
    }

    get rotation() {
        return this._rotation;
    }

    /**
     * Gets the x position of the Thing.
     * @example
     * thing.x === thing.getX();
     *
     * @return {number} The x position of the Thing.
     */
    getX() {
        return this.x;
    }

    /**
     * Gets the y position of the Thing.
     *
     * @example
     * thing.y === thing.getY();
     *
     * @return {number} The y position of the Thing.
     */
    getY() {
        return this.y;
    }

    set x(x) {
        this._x = x;
        this._invalidateBounds();
    }

    get x() {
        return this._x;
    }

    set y(y) {
        this._y = y;
        this._invalidateBounds();
    }

    get y() {
        return this._y;
    }

    /**
     * Set the .type of the Thing
     * @param {string} type new type
     */
    setType(type) {
        this.type = type;
    }

    /**
     * Get the .type of the Thing
     * @returns {string}
     */
    getType() {
        return this.type;
    }

    /**
     * Sets a Thing object to filled.
     * Throws an error if an argument is not passed.
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * thing.setFilled(false);
     *
     * @param {bool} filled - A boolean of whether or not Thing is filled.
     */
    setFilled(filled) {
        if (arguments.length !== 1) {
            throw new Error('You should pass exactly 1 argument to `setFilled`.');
        }
        if (typeof filled !== 'boolean') {
            throw new Error(
                'Invalid value passed to `setFilled`. Make sure you are passing a boolean value.'
            );
        }
        this.filled = filled;
    }
    /**
     * Returns if a Thing is filled.
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * thing.isFilled();
     *
     * @return {boolean} True if the Thing is filled.
     */
    isFilled() {
        return this.filled;
    }

    /**
     * Sets a Thing object to filled.
     * Throws an error if an argument is not passed.
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * thing.setBorder(true);
     *
     * @param {bool} hasBorder - A boolean of whether or not Thing has a border.
     */
    setBorder(hasBorder) {
        if (arguments.length !== 1) {
            throw new Error('You should pass exactly 1 argument to `setBorder(hasBorder)`.');
        }
        if (typeof hasBorder !== 'boolean') {
            throw new Error(
                'Invalid value passed to `setBorder`. Make sure you are passing a boolean value.'
            );
        }
        this.hasBorder = hasBorder;
    }

    /**
     * Returns if a Thing has a border.
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * thing.hasBorder();
     *
     * @return {boolean} True if the Thing has a border.
     */
    hasBorder() {
        return this.hasBorder;
    }

    /**
     * Set the opacity of the Thing.
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * thing.setOpacity(0.5);
     *
     * @param {number} opacity
     */
    setOpacity(opacity) {
        this.opacity = opacity;
    }

    /**
     * Sets the position of a Thing.
     * Throws an error if there are fewer than 2 params or if
     * they are not numbers.
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * thing.setPosition(30, 30);
     *
     * @param {number} x - The destination x coordinate of this Thing.
     * @param {number} y - The destination y coordinate of this Thing.
     */
    setPosition(x, y) {
        if (arguments.length !== 2) {
            throw new Error('You should pass exactly 2 arguments to `setPosition(x, y)`.');
        }
        if (typeof x !== 'number' || !isFinite(x)) {
            throw new TypeError(
                'Invalid value for x-coordinate. Make sure you are passing finite numbers to `setPosition(x, y)`. Did you forget the parentheses in `getWidth()` or `getHeight()`? Or did you perform a calculation on a variable that is not a number?'
            );
        }
        if (typeof y !== 'number' || !isFinite(y)) {
            throw new TypeError(
                'Invalid value for y-coordinate. Make sure you are passing finite numbers to `setPosition(x, y)`. Did you forget the parentheses in `getWidth()` or `getHeight()`? Or did you perform a calculation on a variable that is not a number?'
            );
        }
        this.x = x;
        this.y = y;
    }

    /**
     * Sets the rotation of a Thing in degrees.
     * Throws an error if there are fewer than 1 params or if they
     * are not numbers.
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * thing.setRotation(90);
     * thing.setRotation(Math.PI / 2, Thing.RADIANS);
     *
     * @param {number} degrees - The degrees to rotate degrees.
     * @param {number} angleUnit - Whether it is degrees or radians. Defaults to
     *                             degrees.
     */
    setRotation(degrees, angleUnit) {
        if (arguments.length < 1 || arguments.length > 2) {
            throw new Error(
                'You should pass 1 or 2 arguments to `setRotation(degrees, angleUnit)`.'
            );
        }
        if (typeof degrees !== 'number' || !isFinite(degrees)) {
            throw new TypeError(
                'Invalid value for degrees. Make sure you are passing finite numbers to `setRotation(degrees, angleUnit)`. Did you perform a calculation on a variable that is not a number?'
            );
        }
        if (!angleUnit) {
            angleUnit = Thing.DEGREES;
        }
        if (typeof angleUnit !== 'number' || !isFinite(angleUnit)) {
            throw new TypeError(
                'Invalid value for `angleUnit`. Make sure you are passing finite numbers to `setRotation(degrees, angleUnit)`.'
            );
        }
        if (angleUnit === Thing.DEGREES) {
            this._rotation = (degrees * Math.PI) / 180;
        } else {
            this._rotation = degrees;
        }
    }

    /**
     * Rotates a Thing an additional amount of degrees.
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * thing.rotate(90);
     * thing.rotate(Math.PI / 2, Thing.RADIANS);
     *
     * @param {number} degrees - The degrees to rotate degrees.
     * @param {number} angleUnit - Whether it is degrees or radians. Defaults to
     *                             degrees.
     */
    rotate(degrees, angleUnit) {
        if (arguments.length < 1 || arguments.length > 2) {
            throw new Error('You should pass exactly 1 argument to `rotate(degrees, angleUnit)`.');
        }
        if (typeof degrees !== 'number' || !isFinite(degrees)) {
            throw new TypeError(
                'Invalid value for degrees. Make sure you are passing finite numbers to `rotate(degrees, angleUnit)`. Did you perform a calculation on a variable that is not a number?'
            );
        }
        if (!angleUnit) {
            angleUnit = Thing.DEGREES;
        }
        if (typeof angleUnit !== 'number' || !isFinite(angleUnit)) {
            throw new TypeError(
                'Invalid value for `angleUnit`. Make sure you are passing finite numbers to `rotate(degrees, angleUnit)`.'
            );
        }
        if (angleUnit == Thing.DEGREES) {
            this.rotation += (degrees * Math.PI) / 180;
        } else {
            this.rotation += degrees;
        }
        this._invalidateBounds();
    }

    /**
     * Sets the color of a Thing.
     * Throws an error if there are fewer than 1 params or if
     * the param is undefined.
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * thing.setColor('red');
     * thing.setColor(Color.orange);
     * thing.setColor('#ff0000');
     *
     * @param {Color} color - The resulting color of Thing.
     */
    setColor(color) {
        if (arguments.length !== 1) {
            throw new Error(
                'You should pass exactly 1 argument to <span ' + 'class="code">setColor`'
            );
        }
        if (color === undefined) {
            throw new TypeError('Invalid color');
        }
        this.color = color;
    }

    /**
     * Gets the color of a Thing.
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * thing.getColor(); // #000000, by default
     *
     * @return {Color} The destination y coordinate of this Thing.
     */
    getColor() {
        return this.color;
    }

    /**
     * Sets the border color of a Thing.
     * Throws an error if there are fewer than 1 params or if
     * the param is undefined.
     * This will automatically give the Thing a border, as if you had called
     * thing.setBorder(true);
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * thing.setBorderColor('orange');
     *
     *
     * @param {Color} color - The resulting color of the Thing's border.
     */
    setBorderColor(color) {
        if (arguments.length !== 1) {
            throw new Error('You should pass exactly 1 argument to `setBorderColor(color)`.');
        }
        if (color === undefined) {
            throw new TypeError('Invalid color.');
        }
        this.stroke = color;
        this.hasBorder = true;
    }

    /**
     * Gets the border color of a Thing.
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * thing.getBorderColor();
     *
     * @return {Color} The color of the Thing's border.
     */
    getBorderColor() {
        return this.stroke;
    }

    /**
     * Sets the width of a Thing's border.
     * Throws an error if there is not 1 argument.
     * This will automatically set the Thing to draw with a border, as if you had called
     * thing.setBorder(true);
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * thing.setBorderWidth(5);
     *
     * @param {number} width - The resulting width of the Thing's border.
     */
    setBorderWidth(width) {
        if (arguments.length !== 1) {
            throw new Error('You should pass exactly 1 argument to `setBorderWidth(width)`.');
        }
        if (typeof width !== 'number' || !isFinite(width)) {
            throw new Error(
                'Invalid value for border width. Make sure you are passing a finite number to `setBorderWidth(width)`.'
            );
        }
        this.lineWidth = width;
        this.hasBorder = true;
    }

    /**
     * Gets the width of the Thing's border.
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * thing.getBorderWidth();
     *
     * @return {number} The width of the Thing's border.
     */
    getBorderWidth() {
        return this.lineWidth;
    }

    /**
     * Changes the possition of a thing by a specified x and y amount.
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * thing.move(10, 10);
     *
     * @param {number} dx - The resulting change in the Thing's x position.
     * @param {number} dy - The resulting change in the Thing's y position.
     */
    move(dx, dy) {
        if (arguments.length !== 2) {
            throw new Error('You should pass exactly 2 arguments to `move(dx, dy)`.');
        }
        if (typeof dx !== 'number' || !isFinite(dx)) {
            throw new TypeError(
                'Invalid number passed for `dx`. Make sure you are passing finite numbers to `move(dx, dy)`.'
            );
        }
        if (typeof dy !== 'number' || !isFinite(dy)) {
            throw new TypeError(
                'Invalid number passed for `dy`. Make sure you are passing finite numbers to `move(dx, dy)`.'
            );
        }
        this.x += dx;
        this.y += dy;
    }

    /**
     * This function is invoked by subclassed, and exists to add
     * common, shared functionality all classes share.
     * @param {CanvasRenderingContext2D} context
     * @param {function} subclassDraw
     */
    draw(context, subclassDraw) {
        context.save();
        if (this.hasBorder) {
            context.strokeStyle = this.stroke.toString();
            context.lineWidth = this.lineWidth;
        }
        if (this.focused) {
            context.shadowColor = '#0066ff';
            context.shadowBlur = 20;
        }
        if (this.filled) {
            context.fillStyle = this.color.toString();
        }
        context.globalAlpha = this.opacity;

        const anchorX = this.width * this.anchor.horizontal;
        const anchorY = this.height * this.anchor.vertical;
        const drawX = this.x - anchorX;
        const drawY = this.y - anchorY;

        // translate to the location of the shape
        context.translate(drawX, drawY);

        if (this.rotation) {
            // translate to the shape's center to perform rotation around its center,
            // then translate back
            context.translate(this.width / 2, this.height / 2);
            context.rotate(this.rotation);
            context.translate(-this.width / 2, -this.height / 2);
        }

        subclassDraw?.();

        if (this.filled) {
            context.fill();
        }

        if (this.hasBorder) {
            context.stroke();
        }

        if (this.debug) {
            // draw the origin when debugging
            context.beginPath();
            context.arc(anchorX, anchorY, 3, 0, 2 * Math.PI);
            context.closePath();
            context.fillStyle = 'red';
            context.strokeStyle = 'red';
            context.fill();
            const bounds = this.getBounds();
            // move back to the origin
            context.translate(-drawX, -drawY);
            context.strokeRect(
                bounds.left,
                bounds.top,
                bounds.right - bounds.left,
                bounds.bottom - bounds.top
            );
        }

        context.restore();
    }

    /**
     * Focuses the element for use with screen readers.
     * This isn't something you should need to call manually, but you can if you'd
     * like to provide focus to an element even if it wasn't navigated to with the keyboard.
     */
    focus() {
        this.focused = true;
    }

    /**
     * Unfocuses the element for use with screen readers.
     * This isn't something you should need to call manually, but you can if you'd
     * like to unfocus to an element even if it wasn't navigated from with the keyboard.
     */
    unfocus() {
        this.focused = false;
    }

    /**
     * Describes the element for use with screen readers.
     * This isn't something you should need to call manually, but you can if you'd like
     * to print a text descriptino of the Thing.
     */
    describe() {
        const color = this.color.startsWith('#') ? this.color.toUpperCase() : this.color;
        return `A ${this.type} at ${this.x}, ${this.y}. Colored: ${color}.`;
    }

    /**
     * Check if a given point is within the Thing.
     * This function only works in subclasses of Thing.
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * if (thing.containsPoint(100, 100)) {
     *     alert('contains 100, 100!');
     * }
     *
     * @param {number} x - The x coordinate of the point being checked.
     * @param {number} y - The y coordinate of the point being checked.
     * @return {boolean} Whether the point x, y is within the Thing.
     */
    containsPoint(x, y) {
        if (this.rotation) {
            const anchorX = this.width * this.anchor.horizontal;
            const anchorY = this.height * this.anchor.vertical;
            const rotX = this.x - anchorX + this.width / 2;
            const rotY = this.y - anchorY + this.height / 2;
            [x, y] = rotatePointAboutPosition([x, y], [rotX, rotY], -this.rotation);
        }
        return this._containsPoint(x, y);
    }

    /**
     * Sets the Anchor for the object.
     * This alters how the shape will draw relative to its position.
     * An anchor of 0, 0 will cause the shape to draw with its position at its top left corner.
     * An anchor of 1, 1 will cause the shape to draw with its position at its bottom right corner.
     *
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * // center the object around its position
     * thing.setPosition({vertical: 0.5, horizontal: 0.5});
     *
     * @param {{vertical: number, horizontal: number}} anchor
     */
    setAnchor(anchor) {
        this.anchor = anchor;
        this._invalidateBounds();
    }

    /**
     * Gets the element's anchor.
     * @returns {{vertical: number, horizontal: number}}
     */
    getAnchor() {
        return this.anchor;
    }

    /**
     * Get the elements bounds.
     * This is an internal property that you shouldn't need to use, but it can be useful
     * for doing quick calculations for the bounding box of a shape.
     * @example
     * // this method is on every Shape
     * let thing = new Thing();
     * let height = thing.getBounds().bottom - this.getBounds().top;
     *
     * @returns {{top: number, bottom: number, left: number, right: number}}
     */
    getBounds() {
        if (this._boundsInvalidated) {
            this._updateBounds();
        }
        return this.bounds;
    }

    /**
     * Mark this element's bounds as invalidated.
     * @private
     */
    _invalidateBounds() {
        this._boundsInvalidated = true;
        this._invalidationDependants.forEach(element => {
            element._invalidateBounds();
        });
    }

    /**
     * Invalidate the bounds of this Thing, so that any Groups containing it can update.
     * @private
     */
    _updateBounds() {
        let left = Math.ceil(this.x - this.anchor.horizontal * this.width);
        let right = Math.ceil(this.x + (1 - this.anchor.horizontal) * this.width);
        let top = Math.ceil(this.y - this.anchor.vertical * this.height);
        let bottom = Math.ceil(this.y + (1 - this.anchor.vertical) * this.height);
        this.bounds = {
            left,
            right,
            top,
            bottom,
        };
        this._lastCalculatedBoundsID++;
        this._boundsInvalidated = false;
    }
}

/**
 * Rotate a point defined by an [x, y] pair around another point defined by an [x, y] pair by
 * an angle in radians.
 * @example
 * let center = [100, 100];
 * let point = [20, 30];
 * let rotated = rotatePointAboutPosition(center, point, Math.PI / 2);
 *
 * @param {number[]} point - [x, y] of the point to rotate
 * @param {number[]} origin - [x, y] point of rotation
 * @param {number} angle - angle in radians
 * @returns {number[]} - [x, y] rotated point
 */
export function rotatePointAboutPosition([x, y], [rotX, rotY], angle) {
    return [
        (x - rotX) * Math.cos(angle) - (y - rotY) * Math.sin(angle) + rotX,
        (x - rotX) * Math.sin(angle) + (y - rotY) * Math.cos(angle) + rotY,
    ];
}

export default Thing;