util/color.js

/*
 *  Author: Vlad Seryakov vseryakov@gmail.com
 *  backendjs 2018
 */

/**
 * @module color
 */

const lib = require(__dirname + '/../lib');

const color = {
    name: "color",
};

/**
 * RGB color convertions
 */

module.exports = color;

/**
 * Convert a hex color string into an RGB object.
 *
 * Supports:
 * - #RGB (shorthand)
 * - #RRGGBB
 * - #RRGGBBAA (alpha is returned as 0..1 in `alpha`)
 *
 * @param {string} hex - Hex color string, typically starting with "#".
 * @returns {object} RGB components in 0..255 and optional alpha in 0..1.
 * @memberof module:color
 */
color.hex2rgb = function(hex)
{
    var d, rgb = { r: 0, g: 0, b: 0 };
    hex = lib.isString(hex);
    if (hex.length == 4) {
        d = hex.match(/^#([0-9A-Z])([0-9A-Z])([0-9A-Z])$/i);
        if (d) {
            for (let i = 1; i < 4; i++) d[i] = d[i] + d[i];
        }
    } else {
        d = hex.match(/^#([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})?$/i)
    }
    if (d) {
        rgb.r = parseInt(d[1], 16);
        rgb.g = parseInt(d[2], 16);
        rgb.b = parseInt(d[3], 16);
        const alpha = parseInt(d[4], 16);
        if (alpha) rgb.alpha = alpha / 255;
    }
    return rgb;
}

/**
 * Return an array as [r, g, b, a]
 * @param {number[]|object}
 * return {number[]}
 */
color.rgb = function(rgb)
{
    return Array.isArray(rgb) ? [rgb[0], rgb[1], rgb[2], rgb[3]] :
           rgb ? [rgb.r, rgb.g, rgb.b, rgb.alpha] : [0, 0, 0];
}

/**
 * Convert RGB to a hex color string (#RRGGBB).
 *
 * @param {object|number[]} rgb - RGB object or [r,g,b] array (0..255).
 * @returns {string} Hex color string in uppercase, e.g. "#FF00AA".
 * @memberof module:color
 */
color.hex = function(rgb)
{
    rgb = color.rgb(rgb)
    return "#" + rgb.map((x, i) => (i < 3 || x ? lib.toNumber(x).toString(16).padStart(2, '0') : "")).join("")
}

/**
 * Compute a simple (non-gamma-corrected) luminance value.
 *
 * Uses coefficients similar to Rec.709 on sRGB values without linearization.
 *
 * @param {object|number[]} rgb - RGB object or [r,g,b] array (0..255).
 * @returns {number} Luminance in roughly 0..1.
 * @memberof module:color
 */
color.luminance = function(rgb)
{
    const [r, g, b] = color.rgb(rgb)
    return 0.2126 * (r / 255) + 0.7152 * (g / 255) + 0.0722 * (b / 255);
}

/**
 * Compute relative luminance per WCAG (gamma-corrected / linearized sRGB).
 *
 * @param {object|number[]} rgb - RGB object or [r,g,b] array (0..255).
 * @returns {number} Relative luminance (0..1).
 * @memberof module:color
 */
color.rluminance = function(rgb)
{
    const [r, g, b] = color.rgb(rgb)
    const x = [r / 255, g / 255, b / 255].map((i) => (i <= 0.04045 ? i / 12.92 : Math.pow((i + 0.055) / 1.055, 2.4)));
    return 0.2126 * x[0] + 0.7152 * x[1] + 0.0722 * x[2];
}

/**
 * Convert RGB to HSL.
 *
 * Output H is degrees (0..360), S and L are percentages (0..100).
 *
 * @param {object|number[]} rgb - RGB object or [r,g,b] array (0..255).
 * @returns {number[]} HSL values.
 * @memberof module:color
 */
color.hsl = function(rgb)
{
    var [r, g, b] = color.rgb(rgb)

    r /= 255;
    g /= 255;
    b /= 255;

    const min = Math.min(r, g, b);
    const max = Math.max(r, g, b);
    const delta = max - min;

    let h = 0, s = 0;
    const l = (min + max) / 2;

    if (delta !== 0) {
        if (max === r) {
            h = ((g - b) / delta) % 6;
        } else if (max === g) {
            h = (b - r) / delta + 2;
        } else {
            h = (r - g) / delta + 4;
        }

        h *= 60;
        if (h < 0) h += 360;

        s = delta / (1 - Math.abs(2 * l - 1));
    }

    return [h, s * 100, l * 100];
}

/**
 * Convert HSL to RGB.
 *
 * Input H is degrees (0..360), S and L are percentages (0..100).
 *
 * @param {object|number[]} hsl - HSL object or [h,s,l] array.
 * @returns {number[]} RGB components in 0..255.
 * @memberof module:color
 */
color.hsl2rgb = function(hsl)
{
    let [h, s, l] = Array.isArray(hsl) ? [hsl[0], hsl[1], hsl[2]] :
                    hsl ? [hsl.h, hsl.s, hsl.l] : [0, 0, 0];

    // Normalize inputs
    h = ((h % 360) + 360) % 360;
    s = Math.max(0, Math.min(100, s)) / 100;
    l = Math.max(0, Math.min(100, l)) / 100;

    if (s === 0) {
        const v = Math.round(l * 255);
        return [v, v, v];
    }

    const hk = h / 360;
    const t2 = l < 0.5 ? l * (1 + s) : l + s - l * s;
    const t1 = 2 * l - t2;

    function hue2rgb(t) {
        if (t < 0) t += 1;
        if (t > 1) t -= 1;
        let v;
        if (6 * t < 1) v = t1 + (t2 - t1) * 6 * t;
        else if (2 * t < 1) v = t2;
        else if (3 * t < 2) v = t1 + (t2 - t1) * (2 / 3 - t) * 6;
        else v = t1;
        return Math.round(Math.max(0, Math.min(1, v)) * 255);
    }

    return [
        hue2rgb(hk + 1/3),
        hue2rgb(hk),
        hue2rgb(hk - 1/3),
    ];
}

/**
 * Rotate an RGB color around the hue wheel by a number of degrees, i.e. complement color
 *
 * Uses RGB -> HSL -> RGB conversion; keeps S and L the same.
 *
 * @param {object|number[]} rgb - RGB object or [r,g,b] array (0..255).
 * @param {number} [degrees=180] - Hue rotation amount in degrees (can be negative).
 * @returns {{r:number,g:number,b:number}} Rotated RGB color.
 * @memberof module:color
 */
color.rotate = function(rgb, degrees = 180)
{
    var [h, s, l] = color.hsl(rgb);
    h = (h + degrees) % 360;
    h = h < 0 ? 360 + h : h;
    return color.hsl2rgb([h, s, l]);
}

/**
 * Convert RGB to YIQ color space.
 *
 * Commonly used for legacy TV signal encoding and quick brightness-ish computations.
 *
 * @param {object|number[]} rgb - RGB object or [r,g,b] array (0..255).
 * @returns {{y:number,i:number,q:number}} YIQ components.
 * @memberof module:color
 */
color.yiq = function(rgb)
{
    const [r, g, b] = color.rgb(rgb)
    return {
        y: 0.299 * r + 0.587 * g + 0.114 * b,
        i: 0.596 * r - 0.275 * g - 0.321 * b,
        q: 0.212 * r - 0.523 * g + 0.311 * b,
    }
}

/**
 * Compute contrast ratio between two colors using WCAG relative luminance
 *
 * @param {object|number[]} rgb1 - First RGB color (0..255).
 * @param {object|number[]} rgb2 - Second RGB color (0..255).
 * @returns {number} Contrast ratio (>= 1).
 * @memberof module:color
 */
color.contrast = function(rgb1, rgb2)
{
    const lum1 = color.rluminance(rgb1);
    const lum2 = color.rluminance(rgb2);
    return lum1 > lum2 ? (lum1 + 0.05) / (lum2 + 0.05) : (lum2 + 0.05) / (lum1 + 0.05);
}

/**
 * Invert/negate an RGB color.
 *
 * @param {object|number[]} rgb - RGB object or [r,g,b] array (0..255).
 * @returns {{r:number,g:number,b:number}} Negated RGB color.
 * @memberof module:color
 */
color.negate = function(rgb)
{
    const [r, g, b] = color.rgb(rgb)
    return { r: 255 - r, g: 255 - g, b: 255 - b }
}

/**
 * Make a color lighter by scaling its HSL lightness.
 *
 * New lightness is `l + (l * ratio)`.
 *
 * @param {object|number[]} rgb - RGB object or [r,g,b] array (0..255).
 * @param {number} [ratio=0.5] - Multiplier applied to HSL lightness.
 * @returns {{r:number,g:number,b:number}} Adjusted RGB color.
 * @memberof module:color
 */
color.lighter = function(rgb, ratio = 0.5)
{
    const [h, s, l] = color.hsl(rgb);
    return color.hsl2rgb([h, s, l + (l * ratio)]);
}

/**
 * Make a color darker by reducing its HSL lightness by a ratio.
 *
 * New lightness is `l - (l * ratio)`.
 *
 * @param {object|number[]} rgb - RGB object or [r,g,b] array (0..255).
 * @param {number} [ratio=0.5] - Fraction of lightness to remove.
 * @returns {{r:number,g:number,b:number}} Adjusted RGB color.
 * @memberof module:color
 */
color.darker = function(rgb, ratio = 0.5)
{
    const [h, s, l] = color.hsl(rgb);
    return color.hsl2rgb([h, s, l - (l * ratio)]);
}

/**
 * Mix (blend) two RGB/RGBA colors together using a weight.
 *
 * The `weight` controls how much of `rgb1` is in the result:
 * - `weight = 1`   -> returns `rgb1`
 * - `weight = 0`   -> returns `rgb2`
 * - `weight = 0.5` -> equal mix
 *
 * Note: channels are returned as numbers (not clamped/rounded).
 *
 * @function mix
 * @memberof module:color
 * @param {number[]|object} rgb1 First color (array or object).
 * @param {number[]|object} rgb2 Second color (array or object).
 * @param {number} [weight=0.5] Blend weight from 0..1 (higher means more of `rgb1`).
 * @returns {{r:number,g:number,b:number,alpha:number}} Mixed color as an object.
 *
 * @example
 * color.mix({ r: 255, g: 0, b: 0, alpha: 1 }, { r: 0, g: 0, b: 255, alpha: 1 })
 * { r: 127.5, g: 0, b: 127.5, alpha: 1 }
 *
 * @example <caption>closer to black (because weight favors rgb2)</caption>
 * color.mix([255, 255, 255, 1], [0, 0, 0, 1], 0.25)
 * { r: 63.75, g: 63.75, b: 63.75, alpha: 1 }
 */
color.mix = function(rgb1, rgb2, weight = 0.5)
{
    const [r1, g1, b1, a1] = color.rgb(rgb1)
    const [r2, g2, b2, a2] = color.rgb(rgb2)

    const w = 2 * weight - 1;
    const a = (a1 ?? 0) - (a2 ?? 0);
    const w1 = ((w * a === -1 ? w : (w + a) / (1 + w * a)) + 1) / 2;
    const w2 = 1 - w1;
    return {
        r: w1 * r1 + w2 * r2,
        g: w1 * g1 + w2 * g2,
        b: w1 * b1 + w2 * b2,
        alpha: (a1 ?? 0) * weight + (a2 ?? 0) * (1 - weight)
    };
  }