color.mjs

/**
 * @module color
 */
import {createSVG} from './common.mjs';

let gradientIdCounter = 0;


/**
 * @param {number} h - Hue 0 -> 1 float (0 = 0deg, 1 = 360deg)
 * @param {number} s - Saturation 0 -> 1 float
 * @param {number} l - Lightness 0 -> 1 float
 * @param {number} [a] - Alpha 0 -> 1 float
 */
export class Color {

    /**
     * @param {number} r - Red 0 -> 1 float
     * @param {number} g - Green 0 -> 1 float
     * @param {number} b - Blue 0 -> 1 float
     * @param {number} [a] - Alpha 0 -> 1 float
     * @returns {Color}
     */
    static fromRGB(r, g, b, a) {
        const maxC = Math.max(r, g, b);
        const minC = Math.min(r, g, b);
        const d = maxC - minC;
        let h = 0;
        if (!d) {
            h = 0;
        } else if (maxC === r) {
            h = ((g - b) / d) % 6;
        } else if (maxC === g) {
            h = (b - r) / d + 2; } else {
            h = (r - g) / d + 4;
        }
        h = Math.round(h * 60);
        if (h < 0) {
            h += 360;
        }
        h /= 360;
        const l = (maxC + minC) / 2;
        const s = d ? d / (1 - Math.abs(2 * l - 1)) : 0;
        return new this(h, s, l, a);
    }

    /**
     * @param {string} hex - RGB in 3, 4, 6, or 8 character format. a.la., #123, #112233, etc.
     * @returns {Color}
     */
    static fromHex(hex) {
        if (hex.length >= 7) {
            const r = parseInt(hex.substr(1, 2), 16) / 0xff;
            const g = parseInt(hex.substr(3, 2), 16) / 0xff;
            const b = parseInt(hex.substr(5, 2), 16) / 0xff;
            const a = (hex.length === 9) ? parseInt(hex.substr(7, 2), 16)  / 0xff : undefined;
            return this.fromRGB(r, g, b, a);
        } else if (hex.length >= 4) {
            const r = parseInt(''.padStart(2, hex.substr(1, 1)), 16) / 0xff;
            const g = parseInt(''.padStart(2, hex.substr(2, 1)), 16) / 0xff;
            const b = parseInt(''.padStart(2, hex.substr(3, 1)), 16) / 0xff;
            const a = (hex.length === 5) ? parseInt(''.padStart(2, hex.substr(4, 1)), 16) / 0xff : undefined;
            return this.fromRGB(r, g, b, a);
        } else {
            throw new Error('Invalid hex color');
        }
    }

    constructor(h, s, l, a) {
        this.h = h;
        this.s = s;
        this.l = l;
        this.a = a;
    }

    /**
     * @returns {Color} A copy of this color
     */
    clone() {
        return new this.constructor(this.h, this.s, this.l, this.a);
    }

    /**
     * Create clone with new Hue value
     *
     * @param {number} h - Hue 0 -> 1
     * @returns {Color}
     */
    hue(h) {
        const c = this.clone();
        c.h = h;
        return c;
    }

    /**
     * Create clone with new Saturation value
     *
     * @param {number} s - Saturation 0 -> 1
     * @returns {Color}
     */
    saturation(s) {
        const c = this.clone();
        c.s = s;
        return c;
    }

    /**
     * Create clone with new Lightness value
     *
     * @param {number} l - Lightness 0 -> 1
     * @returns {Color}
     */
    light(l) {
        const c = this.clone();
        c.l = l;
        return c;
    }

    /**
     * Create clone with new Alpha value
     *
     * @param {number} a - Alpha 0 -> 1
     * @returns {Color}
     */
    alpha(a) {
        const c = this.clone();
        c.a = a;
        return c;
    }

    /**
     * Create clone with adjusted Hue value
     *
     * @param {number} hd - Hue Delta -1 -> 1
     * @returns {Color}
     */
    adjustHue(hd) {
        const c = this.clone();
        c.h += hd;
        return c;
    }

    /**
     * Create clone with adjusted Lightness value
     *
     * @param {number} hd - Lightness Delta -1 -> 1
     * @returns {Color}
     */
    adjustLight(ld) {
        const c = this.clone();
        c.l += ld;
        return c;
    }

    /**
     * Create clone with adjusted Saturation value
     *
     * @param {number} sd - Saturation Delta -1 -> 1
     * @returns {Color}
     */
    adjustSaturation(sd) {
        const c = this.clone();
        c.s += sd;
        return c;
    }

    /**
     * Create clone with adjusted Alpha value
     *
     * @param {number} ad - Alpha Delta -1 -> 1
     * @returns {Color}
     */
    adjustAlpha(ad) {
        const c = this.clone();
        c.a += ad;
        return c;
    }

    /**
     * @returns {external:CSS_Color}
     */
    toString(options={}) {
        const h = Number((this.h * 360).toFixed(3));
        const s = Number((this.s * 100).toFixed(3));
        const l = Number((this.l * 100).toFixed(3));
        const a = this.a !== undefined ? ` / ${Number((this.a * 100).toFixed(3))}%` : '';
        return `hsl(${h} ${s} ${l}%${a})`;
    }
}


/**
 * @typedef {object} GradientOptions
 * @property {string} type
 * @property {Array<(string|Color)>} [colors]
 */


/**
 * @param {GradientOptions} options
 */
export class Gradient {

    /**
     * Return a typed gradient subclass based on the `type` option
     *
     * @param {GradientOptions} obj
     * @param {"linear"} obj.type
     */
    static fromObject(obj) {
        if (obj.type === 'linear') {
            return new LinearGradient(obj);
        } else {
            throw new TypeError("unsupported type");
        }
    }

    constructor({type, colors}={}) {
        this.type = type;
        this.colors = [];
        this.id = `color-gradient-${type}-${gradientIdCounter++}`;
        if (colors) {
            for (const x of colors) {
                if (typeof x === 'string' || (x instanceof Color)) {
                    this.addColor(x);
                } else {
                    this.addColor(x.color, x.offset);
                }
            }
        }
    }

    /**
     * @param {string|Color} color
     * @param {number} offset
     */
    addColor(color, offset) {
        this.colors.push({
            color: (color instanceof Color) ? color : parse(color),
            offset
        });
    }
}


/**
 * @extends {Gradient}
 * @param {GradientOptions|object} options
 * @param {number} options.rotate
 */
export class LinearGradient extends Gradient {
    constructor(options) {
        super(options);
        this.rotate = options.rotate;
        this.el = createSVG({
            name: 'linearGradient',
            id: this.id,
            attrs: {
                x1: 0,
                y1: 1,
                x2: 0,
                y2: 0,
            }
        });
    }

    render() {
        this.el.setAttribute('class', 'sc-gradient');
        const rotate = (this.rotate || 0) % 360;
        if (rotate) {
            this.el.setAttribute('gradientTransform', `rotate(${rotate} 0.5 0.5)`);
        } else {
            this.el.removeAttribute('gradientTransform');
        }
        let left = 0;
        const stops = [];
        for (const [i, x] of this.colors.entries()) {
            let offset = x.offset;
            if (offset == null) {
                if (i === 0) {
                    offset = 0;
                } else if (i === this.colors.length - 1) {
                    offset = 1;
                } else {
                    const nextBorderJump = this.colors.slice(i).findIndex(x => x.offset != null);
                    let steps, right;
                    if (nextBorderJump !== -1) {
                        steps = nextBorderJump + 1;
                        right = this.colors[nextBorderJump + i].offset;
                    } else {
                        steps = this.colors.length - i;
                        right = 1;
                    }
                    offset = left + (right - left) / steps;
                }
            }
            left = offset;
            const stop = createSVG({
                name: 'stop',
                attrs: {
                    offset: `${offset * 100}%`,
                },
                style: {
                    'stop-color': x.color,
                }
            });
            stops.push(stop);
        }
        this.el.replaceChildren(...stops);
    }
}


let _colorCanvasCtx;
/**
 * @returns {Color}
 */
export function parse(value) {
    if (value == null) {
        throw new TypeError('invalid color or gradient');
    }
    if (value instanceof Color) {
        return value;
    } else if (value instanceof Gradient) {
        return value;
    } else if (typeof value === 'object') {
        return Gradient.fromObject(value);
    }
    if (!_colorCanvasCtx) {
        _colorCanvasCtx = (new OffscreenCanvas(1, 1)).getContext('2d', {willReadFrequently: true});
    }
    const ctx = _colorCanvasCtx;
    ctx.clearRect(0, 0, 1, 1);
    ctx.fillStyle = value;
    ctx.fillRect(0, 0, 1, 1);
    const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1, {colorSpace: 'srgb'}).data;
    return Color.fromRGB(r / 255, g / 255, b / 255, a / 255);
}