graphics/arc.js

import Thing from './thing.js';
import { getDistance } from './graphics-utils.js';

/**
 * An Arc is a continuous slice of a circle described by the position of its center, its radius,
 * and the angles it is drawn between.
 * An Arc draws relative to its center, just like a {@link Circle}.
 * @extends Thing
 */
class Arc extends Thing {
    static COUNTER_CLOCKWISE = true;
    static CLOCKWISE = false;
    static DEGREES = 0;
    static RADIANS = 1;

    type = 'Arc';
    anchor = { vertical: 0.5, horizontal: 0.5 };

    /**
     * Constructs a new arc.
     * @constructor
     * @example
     * // create an Arc with radius 30 with an angle from 0 to -90
     * const arc = new Arc(30, 0, 90, Arc.DEGREES);
     * add(arc);
     * @param {number} radius - Desired radius of the arc.
     * @param {number} startAngle - Start angle of the arc.
     * @param {number} endAngle - End angle of the arc.
     * @param {number} angleUnit - Integer representing unit: Degrees:0, Radians:1
     */
    constructor(radius, startAngle, endAngle, angleUnit) {
        super();
        if (arguments.length !== 4) {
            throw new Error(
                'You should pass exactly 4 arguments to `new Arc(radius, startAngle, endAngle, angleUnit)`'
            );
        }
        if (typeof radius !== 'number' || !isFinite(radius) || isNaN(radius)) {
            throw new TypeError(
                'Invalid value for `radius`. Make sure you are passing finite numbers to `new Arc(radius, startAngle, endAngle, angleUnit)`'
            );
        }
        if (typeof startAngle !== 'number' || !isFinite(startAngle) || isNaN(startAngle)) {
            throw new TypeError(
                'Invalid value for `startAngle`. Make sure you are passing finite numbers to `new Arc(radius, startAngle, endAngle, angleUnit)`'
            );
        }
        if (typeof endAngle !== 'number' || !isFinite(endAngle) || isNaN(endAngle)) {
            throw new TypeError(
                'Invalid value for `endAngle`. Make sure you are passing finite numbers to `new Arc(radius, startAngle, endAngle, angleUnit)`'
            );
        }
        if (
            typeof angleUnit !== 'number' ||
            !isFinite(angleUnit) ||
            isNaN(angleUnit) ||
            angleUnit > 1 ||
            angleUnit < 0
        ) {
            throw new TypeError(
                'Invalid value for `angleUnit`. Make sure you are passing finite numbers to `new Arc(radius, startAngle, endAngle, angleUnit)`'
            );
        }

        /**
         * The radius of the arc
         * @member
         * @type {number}
         */
        this.radius = radius;
        this.angleUnit = angleUnit ?? Arc.RADIANS;

        this.counterclockwise = Arc.COUNTER_CLOCKWISE;

        if (this.angleUnit == Arc.DEGREES) {
            startAngle = degreesToRadians(startAngle);
            endAngle = degreesToRadians(endAngle);
        }

        this.startAngle = startAngle;
        this.endAngle = endAngle;
    }

    get width() {
        return this.radius * 2;
    }

    get height() {
        return this.radius * 2;
    }

    /**
     * Draws the arc in the canvas.
     *
     * @private
     * @param {CanvasRenderingContext2D} context - Context to draw on.
     */
    draw(context) {
        super.draw(context, () => {
            context.translate(this.radius, this.radius);
            context.beginPath();
            context.arc(
                0,
                0,
                this.radius,
                prepareAngle(this.startAngle),
                prepareAngle(this.endAngle),
                this.counterclockwise
            );
            context.lineTo(0, 0);
            context.closePath();
            context.translate(-this.radius, -this.radius);
        });
    }

    /**
     * Sets the starting angle of the arc.
     * Note: All angles are stored in radians, so we must first convert
     * to radians (if the unit is degrees) before storing the new angle.
     * @example
     * const a = new Arc(30, 0, 180, 0);
     * a.setStartAngle(90);
     * a.startAngle === Math.PI / 2;
     * @param {number} angle - The desired start angle of the arc.
     */
    setStartAngle(angle) {
        if (arguments.length !== 1) {
            throw new Error('You should pass exactly 1 argument to `setStartAngle`');
        }
        if (typeof angle !== 'number' || !isFinite(angle)) {
            throw new Error(
                'Invalid value passed to `setStartAngle`. Make sure you are passing a finite number.'
            );
        }
        if (this.angleUnit == Arc.DEGREES) {
            angle = degreesToRadians(angle);
        }
        this.startAngle = angle;
    }

    /**
     * Sets the ending angle of the arc.
     * Note: All angles are stored in radians, so we must first convert
     * to radians (if the unit is degrees) before storing the new angle.
     * @example
     * const a = new Arc(30, 0, 180, 0);
     * a.setEndAngle(90);
     * a.endAngle === Math.PI / 2;
     * @param {number} angle - The desired end angle of the arc.
     */
    setEndAngle(angle) {
        if (arguments.length !== 1) {
            throw new Error('You should pass exactly 1 argument to `setEndAngle`');
        }
        if (typeof angle !== 'number' || !isFinite(angle)) {
            throw new Error(
                'Invalid value passed to `setEndAngle`. Make sure you are passing a finite number.'
            );
        }
        if (this.angleUnit == Arc.DEGREES) {
            angle = degreesToRadians(angle);
        }
        this.endAngle = angle;
    }

    /**
     * Gets the starting angle of the arc.
     * @return {number}
     */
    getStartAngle() {
        if (this.angleUnit == Arc.DEGREES) {
            return Math.round(radiansToDegrees(this.startAngle));
        } else {
            return this.startAngle;
        }
    }

    /**
     * Gets the end angle of the arc.
     * @return {number}
     */
    getEndAngle() {
        if (this.angleUnit == Arc.DEGREES) {
            return Math.round(radiansToDegrees(this.endAngle));
        } else {
            return this.endAngle;
        }
    }

    /**
     * Gets the direction of the arc (CW or CCW).
     * @param {boolean} val - Boolean representing CW or CCW.
     * `True` sets counterclockwise to true.
     */
    setDirection(val) {
        if (arguments.length !== 1) {
            throw new Error('You should pass exactly 1 argument to `setDirection`');
        }
        if (typeof val !== 'boolean') {
            throw new Error(
                'Invalid value passed to `setDirection`. Make sure you are passing a boolean value. `true` for counterclockwise, false for clockwise.'
            );
        }
        this.counterclockwise = val;
    }

    /**
     * Checks if a given point is contained within the arc.
     *
     * @alias Arc#containsPoint
     * @param {number} x - x coordinate of the point being tested.
     * @param {number} y - y coordinate of the point being tested.
     * @return {boolean}
     */
    _containsPoint(x, y) {
        // First check whether the point is in the circle
        var dist = getDistance(this.x, this.y, x, y);
        if (dist > this.radius) {
            return false;
        }

        // Get vector/ angle for the point
        const vx = x - this.x;
        const vy = this.y - y;
        let theta = Math.atan(vy / vx);

        // Adjust the arctan based on the quadran the point is in using the
        // position of the arc as the origin
        // Quadrant II and III
        if (vx < 0) {
            theta += Math.PI;
            // Quadrant IV
        } else if (vy < 0) {
            theta += 2 * Math.PI;
        }

        // Check whether angle is between start and end, take into account fill
        // direction
        var betweenCCW = theta >= this.startAngle && theta <= this.endAngle;
        if (this.counterclockwise) {
            return betweenCCW;
        } else {
            return !betweenCCW;
        }
    }
}

/**
 * Converts an angle to counterclockwise to match how HTML5 canvas draws angles.
 * TODO: Any math people know how we can do this without converting to degrees?
 *
 * @private
 * @param {number} angle - The angle to be prepared.
 * @return {number} The prepared angle.
 */
export const prepareAngle = function (angle) {
    // First, convert to degrees (may lose some accuracy)
    angle = radiansToDegrees(angle);
    angle = Math.round(angle);

    // The canvas arc angles go clockwise, but we want them
    // to go counterclockwise (like the unit circle). Here,
    // we adjust the angle for that.
    angle = (360 - angle) % 360;
    angle = degreesToRadians(angle);

    return angle;
};

/**
 * Helper to convert degrees to radians.
 *
 * @private
 * @param {number} angleInDegrees - The angle represented as degrees.
 * @return {number} The angle represented as radians.
 */
export const degreesToRadians = function (angleInDegrees) {
    return (angleInDegrees / 180) * Math.PI;
};

/**
 * Helper to convert radians to degrees.
 *
 * @private
 * @param {number} angleInRadians - The angle represented as radians.
 * @return {number} The angle represented as degrees.
 */
export const radiansToDegrees = function (angleInRadians) {
    return (angleInRadians / Math.PI) * 180;
};

export default Arc;