paint.js

/**
 * @module Paint
 */

import { Point } from './geo.js';
import { WriteStream } from "tty";

function rgbToAnsi256(r, g, b) {
    if (r === g && g === b) {
        if (r < 8) {
            return 16;
        }
        if (r > 248) {
            return 231;
        }
        return Math.round(((r - 8) / 247) * 24) + 232;
    }
    var ansi = 16
        + (36 * Math.round(r / 255 * 5))
        + (6 * Math.round(g / 255 * 5))
        + Math.round(b / 255 * 5);
    return ansi;
}

function rgbToAnsi16(r, g, b) {
    var ansi = 30
        + ((Math.round(b / 255) << 2)
            | (Math.round(g / 255) << 1)
            | Math.round(r / 255));
    return ansi;
}

/**
 * A paint object
 * @class
 * @example
 * // Create a new paint object with a red stroke and a blue fill
 * const paint = new Paint(Color.Red, Color.Blue);
 */
class Paint {
    /**
     * @enum {Symbol}
     * @property {Symbol} Clear
     * @property {Symbol} Paint
     * @property {Symbol} Gradient
     */
    static Mode = Object.freeze({
        Clear: Symbol("Clear"),
        Paint: Symbol("Paint"),
        Gradient: Symbol("Gradient")
    });
    /**
     * Creates a new paint object
     * @param {(Color|Gradient)} strokeColor 
     * @param {(Color|Gradient)} fillColor 
     */
    constructor(strokeColor, fillColor) {
        if (!(strokeColor instanceof Color || strokeColor instanceof Gradient)) {
            strokeColor = Color.Default;
        }

        if (!(fillColor instanceof Color || fillColor instanceof Gradient)) {
            fillColor = Color.Default;
        }
        /**
         * The stroke color
         * @type {(Color|Gradient)}
         */
        this.strokeColor = strokeColor;
        /**
         * The fill color
         * @type {(Color|Gradient)}
        */
        this.fillColor = fillColor;
    }
}

/**
 * A color object
 * @class
 * @example
 * // Create a new color object with the RGB values of (255, 0, 0)
 * const color = new Color(255, 0, 0);
 */
class Color {
    // Assuming that the terminal color depth will not change during runtime...
    /**
     * @type {number}
     * @readonly
     * @static
    */
    static depth = WriteStream.prototype.getColorDepth();
    // Default colors
    /**
     * Black
     * @type {Color}
     * @readonly
     * @static
     */
    static Black = new Color(0, 0, 0);
    /**
     * Red
     * @type {Color}
     * @readonly
     * @static
    */
    static Red = new Color(255, 0, 0);
    /**
     * Green
     * @type {Color}
     * @readonly
     * @static
    */
    static Green = new Color(0, 255, 0);
    /**
     * Blue
     * @type {Color}
     * @readonly
     * @static
    */
    static Blue = new Color(0, 0, 255);
    /**
     * White
     * @type {Color}
     * @readonly
     * @static
    */
    static White = new Color(255, 255, 255);

    /**
     * Do not paint
     * @type {Color}
     * @readonly
     * @static
     */
    static None = new Color(-1, -1, -1);

    /**
     * Creates a new color object
     * @param {number} r Red
     * @param {number} g Blue
     * @param {number} b Green
     */
    constructor(r, g, b) {
        /**
         * Red
         * @type {number}
         */
        this.r = Math.round(Math.min(Math.max(r, 0), 255));
        /**
         * Green
         * @type {number}
        */
        this.g = Math.round(Math.min(Math.max(g, 0), 255));
        /**
         * Blue
         * @type {number}
        */
        this.b = Math.round(Math.min(Math.max(b, 0), 255));
    }

    /**
     * Creates a color from a number
     * @param {number} num Number to convert
     * @returns {Color}
     */
    static fromNumber(num) {
        return new Color(
            (num >> 16) & 0xFF,
            (num >> 8) & 0xFF,
            num & 0xFF
        )
    }

    /**
     * Creates a new color from the given number
     * @returns {number}
     */
    toNumber() {
        return (this.r << 16) + (this.g << 8) + this.b;
    }

    /**
     * Converts the color to an escape code
     * @returns {Color} The escape code
     * @private
     */
    toEscapeCode() {
        switch (Color.depth) {
            case 1:
                // We only have black and white, so we figure out which one is closer
                let gray = (this.r + this.g + this.b) / 3;
                return gray > 127 ? "37m" : "30m";
            case 4:
                return `38;5;${rgbToAnsi16(this.r, this.g, this.b)}m`;
            case 8:
                return `38;5;${rgbToAnsi256(this.r, this.g, this.b)}m`;
            case 24:
                return `38;2;${this.r};${this.g};${this.b}m`;
            default:
                // Just return 24-bit color
                return `38;2;${this.r};${this.g};${this.b}m`;
        }
    }
}

/**
 * A gradient
 * @class
 * @abstract
 * @private
 */
class Gradient {
    /**
     * The color stops in the gradient
     * @type {Array<{offset: number, color: Color}>}
     * @private
     */
    colorStops = [];

    /**
     * A gradient
     * @param {Type} type  The type of gradient
     * @param {number} angle The angle direction of the gradient (Radians)
     */
    constructor(type, angle) {
        /**
         * The type of gradient
         * @type {GradientType}
         * @readonly
        */
        this.type = type;
        /**
         * The angle direction of the gradient (Radians)
         * @type {number}
        */
        this.angle = angle;
    }

    /**
     * Adds a color stop to the gradient
     * @param {number} offset The offset of the color stop (0-1)
     * @param {Color} color The color to add
     */
    addColorStop(offset, color) {
        if (offset < 0) offset = 0;
        if (offset > 1) offset = 1;

        let index = this.colorStops.findIndex((stop) => stop.offset === offset);
        if (index !== -1) {
            this.colorStops[index].color = color;
        } else {
            this.colorStops.push({ offset, color });
        }
    }

    /**
     * 
     * @param {import('./geo.js').Point} fillStart The start point of the fill
     * @param {import('./geo.js').Point} point The point to get the color for
     * @param {import('./geo.js').Point} fillEnd The end point of the fill
     */
    getColorAt(fillStart, point, fillEnd) {
        throw new Error("Do not use the base Gradient class. Use a subclass instead.");
    }

    /**
     * Gets the color for a percentage of the gradient
     * @param {number} percent The percentage of the gradient to get the color for
     * @returns {Color}
     */
    getColorFor(percent) {
        percent = Math.min(Math.max(percent, 0), 1);
        if (this.colorStops.length === 0) return Color.Black;
        if (this.colorStops[0].offset !== 0) {
            this.colorStops.unshift({ offset: 0, color: this.colorStops[0].color });
        }
        if (this.colorStops.length === 1) return this.colorStops[0].color;
        if (this.colorStops[this.colorStops.length - 1].offset !== 1) {
            this.colorStops.push({ offset: 1, color: this.colorStops[this.colorStops.length - 1].color });
            this.colorStops.splice(this.colorStops.length - 2, 1);
        }

        let before = this.colorStops.findLast((stop) => stop.offset <= percent);
        let after = this.colorStops.find((stop) => stop.offset >= percent);

        // console.log(before, percent, after)
        if (before === after) return before.color;

        let percentBetween = (percent - before.offset) / (after.offset - before.offset);

        let r = Math.round((before.color.r * (1 - percentBetween)) + (after.color.r * percentBetween));
        let g = Math.round((before.color.g * (1 - percentBetween)) + (after.color.g * percentBetween));
        let b = Math.round((before.color.b * (1 - percentBetween)) + (after.color.b * percentBetween));

        return new Color(r, g, b);
    }
}

const GradientType = {
    Linear: Symbol("Linear"),
    Radial: Symbol("Radial"),
    Conic: Symbol("Conic"),
}

/**
 * A linear gradient
 * @extends Gradient
 * @class
 * @example
 * // Create a new gradient that goes from red to blue
 * let gradient = new LinearGradient(0 /* The angle of the gradient in radians *\/);
 * gradient.addColorStop(0, Color.Red);
 * gradient.addColorStop(1, Color.Blue);
 */
class LinearGradient extends Gradient {
    /**
     * Creates a new linear gradient
     * @param {number} angle The angle direction of the gradient (Radians)
     */
    constructor(angle) {
        super(GradientType.Linear, angle);
    }

    /**
     * Gets the color for a point in the gradient 
     * @param {import('./geo.js').Point} fillStart The start point of the fill
     * @param {import('./geo.js').Point} point The point to get the color for
     * @param {import('./geo.js').Point} fillEnd The end point of the fill
     * @returns {Color}
     */
    getColorAt(fillStart, point, fillEnd) {
        let distance = Math.sqrt(Math.pow(fillEnd.x - fillStart.x, 2) + Math.pow(fillEnd.y - fillStart.y, 2));
        let x = Math.cos(this.angle) * distance;
        let y = Math.sin(this.angle) * distance;
        let percentX = (point.x - fillStart.x) / x;
        let percentY = (point.y - fillStart.y) / y;
        let percent = (percentX + percentY) / 2;

        return this.getColorFor(percent);
    }
}

/**
 * A radial gradient
 * @extends Gradient
 * @class
 * @example
 * // Create a new gradient that goes from red to blue
 * let gradient = new RadialGradient();
 * gradient.addColorStop(0, Color.Red);
 * gradient.addColorStop(1, Color.Blue);
 */
class RadialGradient extends Gradient {
    /**
     * Creates a new radial gradient
     */
    constructor() {
        super(GradientType.Radial, 0);
    }

    /**
     * Gets the color for a point in the gradient 
     * @param {import('./geo.js').Point} fillStart The start point of the fill
     * @param {import('./geo.js').Point} point The point to get the color for
     * @param {import('./geo.js').Point} fillEnd The end point of the fill
     * @returns {Color}
     */
    getColorAt(fillStart, point, fillEnd) {
        // Figure out the center of the circle (midpoint of fillStart and fillEnd)
        let center = new Point((fillStart.x + fillEnd.x) / 2, (fillStart.y + fillEnd.y) / 2);
        // Figure out the radius of the circle (distance between fillStart and fillEnd)
        let radius = Math.sqrt(Math.pow(fillEnd.x - fillStart.x, 2) + Math.pow(fillEnd.y - fillStart.y, 2)) / 2;
        // Figure out the distance from the center to the point
        let distance = Math.sqrt(Math.pow(point.x - center.x, 2) + Math.pow(point.y - center.y, 2));
        // Figure out the percent of the distance from the center to the point
        let percent = distance / radius;

        return this.getColorFor(percent);
    }
}

/**
 * A conic gradient
 * @extends Gradient
 * @class
 * @example
 * // Create a new gradient that goes from red to blue
 * let gradient = new ConicGradient(0 /* The angle of the gradient in radians *\/);
 * gradient.addColorStop(0, Color.Red);
 * gradient.addColorStop(1, Color.Blue);
 */
class ConicGradient extends Gradient {
    /**
     * Creates a new conic gradient
     * @param {number} angle The angle direction of the gradient (Radians)
     */
    constructor(angle) {
        super(GradientType.Conic, angle);
    }

    /**
     * Gets the color for a point in the gradient 
     * @param {import('./geo.js').Point} fillStart The start point of the fill
     * @param {import('./geo.js').Point} point The point to get the color for
     * @param {import('./geo.js').Point} fillEnd The end point of the fill
     * @returns {Color}
     */
    getColorAt(fillStart, point, fillEnd) {
        let center = new Point((fillStart.x + fillEnd.x) / 2, (fillStart.y + fillEnd.y) / 2);

        let pointRelativeToCenter = new Point(point.x - center.x, point.y - center.y);

        let angle = Math.atan2(pointRelativeToCenter.y, pointRelativeToCenter.x) + this.angle;

        let percent = 1 / (angle + Math.PI);

        return this.getColorFor(percent);
    }
}

export { Paint, Color, LinearGradient, RadialGradient, ConicGradient };