Color.js

import * as util from './utils.js'

// /** @module  */

/**
 *
 * A general color module, supporting css string colors, canvas2d pixel
 * colors, webgl and canvas2d Uint8ClampedArray r,g,b,a arrays.
 *
 * #### CSS Color Strings.
 *
 * CSS colors in HTML are strings, see [Mozillas Color Reference](
 * https://developer.mozilla.org/en-US/docs/Web/CSS/color_value),
 * taking one of 7 forms:
 *
 * - Names: over 140 color case-insensitive names like
 *   Red, Green, CadetBlue, etc.
 * - Hex, short and long form: #0f0, #ff10a0
 * - RGB: rgb(255, 0, 0), rgba(255, 0, 0, 0.5)
 * - HSL: hsl(120, 100%, 50%), hsla(120, 100%, 50%, 0.8)
 *
 * See [this wikipedia article](https://goo.gl/ev8Kw0)
 * on differences between HSL and HSB/HSV.
 *
 */

/** @namespace */
const Color = {
    /**
     * Convert 4 r,g,b,a ints in [0-255] ("a" defaulted to 255) to a
     * css color string.
     *
     * @param {number} r integer in [0, 255] for red channel
     * @param {number} g integer in [0, 255] for green channel
     * @param {number} b integer in [0, 255] for blue channel
     * @param {number} [a=255] integer in [0, 255] for alpha/opacity channel
     * @returns {string} A rgb(r,g,b) or rgba(r,g,b,a) css color string
     */
    rgbaCssColor(r, g, b, a = 255) {
        a = a / 255
        const a2 = a.toPrecision(2)
        return a === 1 ? `rgb(${r},${g},${b})` : `rgba(${r},${g},${b},${a2})`
    },

    /**
     * Convert 4 ints, h,s,l,a, h in [0-360], s,l in [0-100]% a in [0-255] to a
     * css color string.
     * See [hsl()](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hsl).
     *
     * NOTE: h=0 and h=360 are the same, use h in 0-359 for unique colors.
     *
     * @param {number} h
     * @param {number} [s=100]
     * @param {number} [l=50]
     * @param {number} [a=255]
     * @returns {string} A css HSL color string
     */
    hslCssColor(h, s = 100, l = 50, a = 255) {
        a = a / 255
        const a4 = a.toPrecision(4)
        return a === 1
            ? `hsl(${h},${s}%,${l}%)`
            : `hsla(${h},${s}%,${l}%,${a4})`
    },
    /**
     * Return a html/css hex color string for an r,g,b opaque color (a=255).
     * Hex strings do not support alpha.
     * Both #nnn and #nnnnnn forms supported.
     * Default is to check for the short hex form.
     * @param {number} r Integer value for red channel
     * @param {number} g Integer value for green channel
     * @param {number} b Integer value for blue channel
     * @returns {string} A css hex color string #nnn or #nnnnnn, n in [0,F] hex
     */
    hexCssColor(r, g, b) {
        return `#${(0x1000000 | (b | (g << 8) | (r << 16)))
            .toString(16)
            .slice(-6)}`
    },

    // cssColor is a hybrid string and is our standard.  It returns:
    //
    // * rgbaCssColor if a not 255 (i.e. not opaque)
    // * hexCssColor otherwise
    cssColor(r, g, b, a = 255) {
        return a === 255
            ? this.hexCssColor(r, g, b)
            : this.rgbaCssColor(r, g, b, a)
    },

    randomCssColor() {
        const r255 = () => util.randomInt(256) // random int in [0,255]
        return this.cssColor(r255(), r255(), r255())
    },

    randomGrayCssColor(min = 0, max = 255) {
        const gray = util.randomInt2(min, max) // random int in [min,max]
        return this.cssColor(gray, gray, gray)
    },

    // ### Pixels

    cssToPixel(string) {
        const rgba = this.cssToUint8Array(string)
        return this.rgbaToPixel(...rgba)
    },

    rgbaToPixel(r, g, b, a = 255) {
        const rgba = new Uint8Array([r, g, b, a])
        const pixels = new Uint32Array(rgba.buffer)
        return pixels[0]
    },

    randomPixel() {
        const r255 = () => util.randomInt(256) // random int in [0,255]
        return this.rgbaToPixel(r255(), r255(), r255())
    },

    randomGrayPixel(min = 0, max = 255) {
        const gray = util.randomInt2(min, max) // random int in [min,max]
        return this.rgbaToPixel(gray, gray, gray)
    },

    // ### CSS String Conversions

    // Return 4 element array given any legal CSS string color.
    //
    // Because strings vary widely: CadetBlue, #0f0, rgb(255,0,0),
    // hsl(120,100%,50%), we do not parse strings, instead we let
    // the browser do our work: we fill a 1x1 canvas with the css string color,
    // returning the r,g,b,a canvas ImageData TypedArray.

    // The shared 1x1 canvas 2D context.
    sharedCtx1x1: util.createCtx(1, 1), // share across calls.
    // Convert any css string to 4 element Uint8ClampedArray TypedArray.
    // If you need a JavaScript Array, use `new Array(...TypedArray)`
    // Slow, but works for all css strings: hsl, rgb, .. as well as names.
    cssToUint8Array(string) {
        this.sharedCtx1x1.clearRect(0, 0, 1, 1)
        this.sharedCtx1x1.fillStyle = string
        this.sharedCtx1x1.fillRect(0, 0, 1, 1)
        return this.sharedCtx1x1.getImageData(0, 0, 1, 1).data
    },

    // ### Typed Color
    // A TypedColor is a 4 element ArrayBuffer, with two views:
    //
    // * pixelArray: A single element Uint32Array view
    // * u8array: A 4 element r,g,b,a Uint8ClampedArray view
    //
    // getters/setters are provided for multiple other color types:
    //  'css', 'pixel', 'rgb', 'webgl'
    // If g is undefinec, returns toTypedColor(g)
    typedColor(r, g, b, a = 255) {
        if (g === undefined) return this.toTypedColor(r)
        const u8array = new Uint8ClampedArray([r, g, b, a])
        u8array.pixelArray = new Uint32Array(u8array.buffer) // one element array
        // Make this an instance of TypedColorProto
        Object.setPrototypeOf(u8array, TypedColorProto)
        return u8array
    },

    isTypedColor(any) {
        return any && any.constructor === Uint8ClampedArray && any.pixelArray
    },

    // Return a typedColor given a value and optional colorType
    // If the value already is a typedColor, simply return it
    // If colorType not defined, assume css (string) or pixel (number)
    // or array [r,g,b,a=255]
    // The colorType can be: 'css', 'pixel', 'rgb', 'webgl'
    // Note rgb and webgl are int arrays & float arrays respectively.
    toTypedColor(value, colorType) {
        if (this.isTypedColor(value)) return value

        const tc = this.typedColor(0, 0, 0, 0) // "empty" typed color
        if (colorType == null) {
            if (util.isString(value)) tc.css = value
            else if (util.isNumber(value)) tc.pixel = value
            else if (util.isArray(value)) tc.rgb = value
            else if (util.isTypedArray(value)) tc.rgb = value
            else throw Error(`toTypedColor: illegal value ${value}`)
        } else {
            // REMIND: type check value & colorType?
            tc[colorType] = value
        }
        return tc
    },

    // Random typedColor, rgb or gray, alpha = 255 for both:
    randomTypedColor() {
        const r255 = () => util.randomInt(256) // random int in [0,255]
        return this.typedColor(r255(), r255(), r255())
    },
    // Random gray color, alpha = 255
    randomGrayTypedColor(min = 0, max = 255) {
        const gray = util.randomInt2(min, max) // random int in [min,max]
        return this.typedColor(gray, gray, gray)
    },
    // Arrays of random typed colors, very useful for patch colors
    randomColorArray(length) {
        const colors = new Array(length)
        util.forLoop(colors, (c, i) => (colors[i] = this.randomTypedColor()))
        return colors
    },
    randomGrayArray(length, min = 0, max = 255) {
        const grays = new Array(length)
        util.forLoop(
            grays,
            (g, i) => (grays[i] = this.randomGrayTypedColor(min, max))
        )
        return grays
    },
}

// Prototype for Color. Getters/setters for usability, may be slower.
const TypedColorProto = {
    // Inherit from Uint8ClampedArray
    __proto__: Uint8ClampedArray.prototype,

    // Set the Color to new rgba values.
    setColor(r, g, b, a = 255) {
        this.checkColorChange()
        this[0] = r
        this[1] = g
        this[2] = b
        this[3] = a
    },
    // No real need for getColor, it *is* the typed Uint8 array
    set rgb(rgbaArray) {
        this.setColor(...rgbaArray)
    },
    get rgb() {
        return this
    },

    // Set opacity to a value in 0-255
    setAlpha(alpha) {
        this.checkColorChange()
        this[3] = alpha // Uint8ClampedArray will clamp to 0-255
    },
    getAlpha() {
        return this[3]
    },
    get alpha() {
        return this.getAlpha()
    },
    set alpha(alpha) {
        this.setAlpha(alpha)
    },

    // Set the Color to a new pixel value
    setPixel(pixel) {
        this.checkColorChange()
        this.pixelArray[0] = pixel
    },
    // Get the pixel value
    getPixel() {
        return this.pixelArray[0]
    },
    get pixel() {
        return this.getPixel()
    },
    set pixel(pixel) {
        this.setPixel(pixel)
    },

    // Set pixel/rgba values to equivalent of the css string.
    // 'red', '#f00', 'ff0000', 'rgb(255,0,0)', etc.
    //
    // Does *not* set the chached this.string, which will be lazily evaluated
    // to its common cssColor by getCss(). The above would all return '#f00'.
    setCss(string) {
        return this.setColor(...Color.cssToUint8Array(string))
    },
    // Return the cssColor for this Color, cached in the @string value
    getCss() {
        if (this.string == null) this.string = Color.cssColor(...this)
        return this.string
    },
    get css() {
        return this.getCss()
    },
    set css(string) {
        this.setCss(string)
    },

    // Note: webgl colors are 3 RGB floats (no A)
    setWebgl(array) {
        if (array.length !== 3)
            throw Error(
                'setWebgl array length must be 3, length:',
                array.length
            )
        this.setColor(
            // OK if float * 255 non-int, setColor stores into uint8 array
            array[0] * 255,
            array[1] * 255,
            array[2] * 255
        )
    },
    getWebgl() {
        return [this[0] / 255, this[1] / 255, this[2] / 255]
    },
    get webgl() {
        return this.getWebgl()
    },
    set webgl(array) {
        this.setWebgl(array)
    },

    // Housekeeping when the color is modified.
    checkColorChange() {
        // Reset string & webgl on color change.
        this.string = null // will be lazy evaluated via getCss.
        // this.floatArray = null
    },
    // Return true if color is same value as myself, comparing pixels
    equals(color) {
        return this.getPixel() === color.getPixel()
    },
    toString() {
        return `[${Array.from(this).toString()}]`
    },
    // Return a [distance metric](
    // http://www.compuphase.com/cmetric.htm) between two colors.
    // Max distance is roughly 765 (3*255), for black & white.
    // For our purposes, omitting the sqrt will not effect our results
    rgbDistance(r, g, b) {
        const [r1, g1, b1] = this
        const rMean = Math.round((r1 + r) / 2)
        const [dr, dg, db] = [r1 - r, g1 - g, b1 - b]
        const [dr2, dg2, db2] = [dr * dr, dg * dg, db * db]
        const distanceSq =
            (((512 + rMean) * dr2) >> 8) +
            4 * dg2 +
            (((767 - rMean) * db2) >> 8)
        return distanceSq // Math.sqrt(distanceSq)
    },
}

export default Color