modules_position_position.js

import { constrain, equals, radianCircle, modulo } from "@pencil.js/math";

/**
 * @module Position
 */

/**
 * Pair of value in 2d space
 * @class
 */
export default class Position {
    /**
     * Position constructor
     * @param {Number} x - Vertical component
     * @param {Number} y - Horizontal component
     */
    constructor (x = 0, y = 0) {
        this.x = +x;
        this.y = +y;
    }

    /**
     * Define this position value
     * @param {PositionDefinition|Number} definition - Horizontal position or another position
     * @param {Number} [diffY] - Vertical position if "definition" is a number
     * @return {Position} Itself
     */
    set (definition, diffY) {
        let x;
        let y;
        if (typeof definition === "number") {
            x = definition;
            y = diffY === undefined ? definition : diffY;
        }
        else {
            const position = Position.from(definition);
            ({ x, y } = position);
        }

        this.x = x;
        this.y = y;
        return this;
    }

    /**
     * Create a copy of this position
     * @return {Position} New instance
     */
    clone () {
        return new Position(this.x, this.y);
    }

    /**
     * Determine if is equal to another position
     * @param {PositionDefinition} positionDefinition - Any other position
     * @return {Boolean}
     */
    equals (positionDefinition) {
        const position = Position.from(positionDefinition);
        return equals(this.x, position.x) && equals(this.y, position.y);
    }

    /**
     * Apply an operation to this position values
     * @param {Function} operation - Function to apply on value
     * @param {PositionDefinition|Number} definition - Another position or a number
     * @param {Number} [diffY] - Value to apply on "y" if "definition" is a number
     * @return {Position} Itself
     */
    calc (operation, definition, diffY) {
        let x = 0;
        let y = 0;
        if (typeof definition === "number") {
            x = operation(this.x, definition);
            y = operation(this.y, diffY === undefined ? definition : diffY);
        }
        else {
            const position = Position.from(definition);
            x = operation(this.x, position.x);
            y = operation(this.y, position.y);
        }

        return this.set(x, y);
    }

    /**
     * Add another position or number
     * @param {PositionDefinition|Number} definition - Another position or a number
     * @param {Number} [y] - Value for "y" if "position" is a number
     * @return {Position} Itself
     */
    add (definition, y) {
        return this.calc((self, other) => self + other, definition, y);
    }

    /**
     * Subtract another position or number
     * @param {PositionDefinition|Number} definition - Another position or a number
     * @param {Number} [y] - Value for "y" if "position" is a number
     * @return {Position} Itself
     */
    subtract (definition, y) {
        return this.calc((self, other) => self - other, definition, y);
    }

    /**
     * Multiply by another position or number
     * @param {PositionDefinition|Number} definition - Another position or a number
     * @param {Number} [y] - Value for "y" if "position" is a number
     * @return {Position} Itself
     */
    multiply (definition, y) {
        return this.calc((self, other) => self * other, definition, y);
    }

    /**
     * Divide by another position or number
     * @param {PositionDefinition|Number} definition - Another position or a number
     * @param {Number} [y] - Value for "y" if "position" is a number
     * @return {Position} Itself
     */
    divide (definition, y) {
        return this.calc((self, other) => self / other, definition, y);
    }

    /**
     * Gives the modulo by another position or number
     * @param {PositionDefinition|Number} definition - Another position or a number
     * @param {Number} [y] - Value for "y" if "position" is a number
     * @return {Position} Itself
     */
    modulo (definition, y) {
        return this.calc((self, other) => modulo(self, other), definition, y);
    }

    /**
     * Raise to a power
     * @param {PositionDefinition|Number} definition - Another position or a number
     * @param {Number} [y] - Value for "y" if "position" is a number
     * @return {Position} Itself
     */
    power (definition, y) {
        return this.calc((self, other) => self ** other, definition, y);
    }

    /**
     * Rotate the position around the origin clockwise
     * @param {Number} [angle=0] - Angle of rotation in ratio of full circle
     * (0 means no rotation, 1 means go full circle back to same position)
     * @param {PositionDefinition} [originDefinition] - Point of origin to rotate around (by default (0, 0))
     * @return {Position} Itself
     */
    rotate (angle = 0, originDefinition) {
        const { cos, sin } = Math;
        const degree = angle * radianCircle;
        const origin = Position.from(originDefinition);
        const clone = this.clone().subtract(origin);
        const x = (clone.x * cos(degree)) - (clone.y * sin(degree));
        const y = (clone.y * cos(degree)) + (clone.x * sin(degree));
        return this.set(x, y).add(origin);
    }

    /**
     * Constrain the position to a rectangle define by two positions
     * @param {PositionDefinition} startDefinition - Starting position of the constrain (upper-left corner)
     * @param {PositionDefinition} endDefinition - Ending position of the constrain (lower-right corner)
     * @return {Position} Itself
     */
    constrain (startDefinition, endDefinition) {
        const start = Position.from(startDefinition);
        const end = Position.from(endDefinition);
        const x = constrain(this.x, Math.min(start.x, end.x), Math.max(start.x, end.x));
        const y = constrain(this.y, Math.min(start.y, end.y), Math.max(start.y, end.y));
        return this.set(x, y);
    }

    /**
     * Move the position towards another by a ratio
     * @param {PositionDefinition} positionDefinition - Any other position
     * @param {Number} ratio - Ratio of distance to move, 0 mean no change, 1 mean arrive at position
     * @return {Position} Itself
     */
    lerp (positionDefinition, ratio) {
        const difference = Position.from(positionDefinition).clone().subtract(this).multiply(ratio);
        return this.add(difference);
    }

    /**
     * Compute distance with another position
     * @param {PositionDefinition} positionDefinition - Any position
     * @return {Number}
     */
    distance (positionDefinition) {
        const position = Position.from(positionDefinition);
        return Math.hypot(position.x - this.x, position.y - this.y);
    }

    /**
     * Dot product
     * @param {PositionDefinition} positionDefinition - Another position
     * @return {Number}
     */
    dotProduct (positionDefinition) {
        const position = Position.from(positionDefinition);
        return (this.x * position.x) + (this.y * position.y);
    }

    /**
     * Cross product
     * @param {PositionDefinition} positionDefinition - Another position
     * @return {Number}
     */
    crossProduct (positionDefinition) {
        const position = Position.from(positionDefinition);
        return (this.x * position.y) - (this.y * position.x);
    }

    /**
     * Define if this is on the same side of a vector as another position
     * @param {PositionDefinition} positionDefinition - Another position
     * @param {Vector} vector - Any vector
     * @return {Boolean}
     */
    isOnSameSide (positionDefinition, vector) {
        const position = Position.from(positionDefinition);
        const thisMoved = this.clone().subtract(vector.start);
        const positionMoved = position.clone().subtract(vector.start);
        const delta = vector.getDelta();
        const { sign } = Math;
        return sign(thisMoved.crossProduct(delta)) === sign(positionMoved.crossProduct(delta));
    }

    /**
     * Get vector length
     * @return {Number}
     */
    get length () {
        return this.distance();
    }

    /**
     * Get the angle of a position relative to the horizontal axis
     * @return {Number}
     */
    get angle () {
        if (this.x === 0 && this.y === 0) {
            return 0;
        }

        return (Math.atan(this.y / this.x) / radianCircle) + (this.x < 0 ? 0.75 : 0.25);
    }

    /**
     * Return a JSON ready Position definition
     * @return {Array<Number>}
     */
    toJSON () {
        const { x, y } = this;
        return [
            x,
            y,
        ];
    }

    /**
     * @typedef {Object} AbstractPosition
     * @prop {Number} [x=0] - Vertical position
     * @prop {Number} [y=0] - Horizontal position
     */
    /**
     * @typedef {Array<Number>|AbstractPosition} PositionDefinition
     */
    /**
     * Create a Position from a generic definition or do nothing if already a Position
     * @param {PositionDefinition} [positionDefinition] - Position definition
     * @return {Position}
     */
    static from (positionDefinition = new Position()) {
        if (positionDefinition instanceof Position) {
            return positionDefinition;
        }
        if (Array.isArray(positionDefinition)) {
            return new Position(...positionDefinition);
        }

        try {
            return new Position(positionDefinition.x, positionDefinition.y);
        }
        catch {
            throw TypeError(`Unexpected type for position: ${JSON.stringify(positionDefinition)}.`);
        }
    }

    /**
     * Compute the average for a set of positions
     * @param {...PositionDefinition} positionDefinitions - List of positions to average
     * @return {Position}
     */
    static average (...positionDefinitions) {
        let result = new Position();
        positionDefinitions.forEach(one => result = result.add(Position.from(one)));
        const nbPositions = positionDefinitions.length;
        return result.divide(nbPositions);
    }
}