ColorMap.js

import * as util from './utils.js'
import Color from './Color.js'
// /** @module */
// /** @exports ColorMap */

/**
 * A colormap is simply an array of typedColors with several utilities such
 * as randomColor, closestColor etc.
 * This allows the colors to be simple integer indices
 * into the Array. They are also designed to be webgl-ready, being
 * composed of typedColors.
 */

/** @namespace */
const ColorMap = {
    // ### Color Array Utilities
    // Several utilities for creating color arrays

    /**
     * Ask the browser to use the canvas gradient feature
     * to create nColors given the gradient color stops and locs.
     * See Mozilla [Gradient Doc](
     * https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createLinearGradient),
     *
     * This is a powerful browser feature, can be
     * used to create all the MatLab colormaps.
     *
     * Stops are css strings.
     * Locs are floats from 0-1, default is equally spaced.
     *
     * @param {number} nColors integer number of colors to be returned
     * @param {Array} stops Array of nColors css colors, placed at locs below
     * @param {Array} locs Array of nColors floats in [0.1]. Default to even distribution
     * @returns {Array} Returns Array of nColors rgba color arrays
     */
    gradientImageData(nColors, stops, locs) {
        const ctx = util.createCtx(nColors, 1)
        // Install default locs if none provide
        if (!locs) locs = util.floatRamp(0, 1, stops.length)

        // Create a new gradient and fill it with the color stops
        const grad = ctx.createLinearGradient(0, 0, nColors, 0)
        util.repeat(stops.length, i => grad.addColorStop(locs[i], stops[i]))

        // Draw the gradient, returning the image colors typed arrays
        ctx.fillStyle = grad
        ctx.fillRect(0, 0, nColors, 1)
        return util.ctxImageColors(ctx)
    },

    // ### Array Conversion Utilities

    // Convert a Uint8Array into Array of 4 element typedColors.
    // Useful for converting ImageData objects like gradients to colormaps.
    // WebGL ready: the array.typedArray is suitable for Uniforms.
    // typedArraytoColors(typedArray) {
    //     const array = []
    //     util.step(
    //         typedArray.length,
    //         4,
    //         // Note: can't share subarray as color's typed array:
    //         // it's buffer is for entire array, not just subarray.
    //         i => array.push(Color.typedColor(...typedArray.subarray(i, i + 4)))
    //     )
    //     array.typedArray = typedArray
    //     return array
    // },
    // Convert an Array of Arrays to an Array of typedColors.
    // Webgl ready as above.
    // arraysToColors(array) {
    //     const typedArray = new Uint8ClampedArray(array.length * 4)
    //     util.repeat(array.length, i => {
    //         const a = array[i]
    //         if (a.length === 3) a.push(255)
    //         typedArray.set(a, i * 4)
    //     })
    //     return this.typedArraytoColors(typedArray)
    // },

    /**
     * Convert an Array of rgba color arrays into Array of 4 element typedColors.
     *
     * @param {Array} Array of rgba color arrays
     * @returns  Array of the rgba arrays to Color.typedColors
     */
    arrayToTypedColors(array) {
        return array.map(a => Color.toTypedColor(a))
    },

    // Permute the values of 3 arrays. Ex:
    //
    // [1,2],[3],[4,5] -> [ [1,3,4],[1,3,5],[2,3,4],[2,3,5] ]
    permuteArrays(A1, A2 = A1, A3 = A1) {
        const array = []
        for (const a3 of A3) {
            // sorta odd const works with ths, but...
            for (const a2 of A2) for (const a1 of A1) array.push([a1, a2, a3])
        }
        return array
    },
    // Use permuteArrays to create uniformly spaced color ramp permutation.
    // Ex: if numRs is 3, permuteArrays's A1 would be [0, 127, 255]
    permuteRGBColors(numRs, numGs = numRs, numBs = numRs) {
        const toRamp = num => util.integerRamp(0, 255, num)
        const ramps = [numRs, numGs, numBs].map(toRamp)
        return this.permuteArrays(...ramps)
    },

    // ### ColorMaps

    // ColorMaps are Arrays of TypedColors with these additional methods.
    // Used to be memory effecent (shared colors), webgl compatible,  and for
    // MatLab-like color-as-data.
    ColorMapProto: {
        // Inherit from Array
        __proto__: Array.prototype,
        // Create a [sparse array](https://goo.gl/lQlq5k) of index[pixel] = pixel.
        // Used by indexOf below for exact match of a color within the colormap.
        createIndex() {
            this.index = []
            util.repeat(this.length, i => {
                const px = this[i].getPixel()
                this.index[px] = i
                if (this.cssNames) this.index[this.cssNames[i]] = i
            })
        },
        // Return a random color within the colormap
        randomColor() {
            return this[util.randomInt(this.length)]
        },

        // Set alpha of all the colors ih the map
        // Note this will be shared by all users of this map!
        // Use clone() to have your own copy of a shared map.
        setAlpha(alpha) {
            util.forLoop(this, color => color.setAlpha(alpha))
        },

        // Clone this colorMap
        clone() {
            return this.cloneColorMap(this)
        },

        // Return the color at index of this array.
        // Wrap the index to be within the array.
        atIndex(index) {
            return this[index % this.length]
        },
        // Return the index of a typedColor within the colormap,
        // undefined if no exact match.
        // Use the `closest` methods below for nearest, not exact, match.
        indexOf(color) {
            if (this.index) return this.index[color.getPixel()]
            for (let i = 0; i < this.length; i++) {
                if (color.equals(this[i])) return i
            }
            return undefined
        },
        // Return color scaled by number within [min, max].
        // A linear interpolation (util.lerp) in [0, length-1].
        // Used to match data directly to a color as in MatLab.
        //
        // Ex: scaleColor(25, 0, 50) returns the color in the middle of the colormap
        scaleColor(number, min = 0, max = this.length - 1) {
            // number = util.clamp(number, min, max)
            if (min === max) return this[min]
            const scale = util.lerpScale(number, min, max)
            const index = Math.round(util.lerp(0, this.length - 1, scale))
            return this[index]
        },
        // Return the Uint8 array used to create the typedColors,
        // undefined if not webgl ready.
        // webglArray() {
        //     return this.typedArray
        // },

        // Debugging: Return a string with length and array of colors
        toString() {
            return `${this.length} ${util.arraysToString(this)}`
        },

        // Iterate through the colormap colors, returning the index of the
        // min typedColor.rgbDistance value from r, g, b
        rgbClosestIndex(r, g, b) {
            let minDist = Infinity
            let ixMin = 0
            for (var i = 0; i < this.length; i++) {
                const d = this[i].rgbDistance(r, g, b)
                if (d < minDist) {
                    minDist = d
                    ixMin = i
                    if (d === 0) return ixMin
                }
            }
            return ixMin
        },
        // Return the color with the rgbClosestIndex value
        rgbClosestColor(r, g, b) {
            return this[this.rgbClosestIndex(r, g, b)]
        },

        // Calculate the closest cube index for the given r, g, b values.
        // Faster than rgbClosestIndex, does direct calculation, not iteration.
        cubeClosestIndex(r, g, b) {
            const cube = this.cube
            if (!cube) throw Error('cubeClosestIndex: requires the cube arrays')
            const rgbSteps = cube.map(c => 255 / (c - 1))
            const rgbLocs = [r, g, b].map((c, i) => Math.round(c / rgbSteps[i]))
            const [rLoc, gLoc, bLoc] = rgbLocs
            return rLoc + gLoc * cube[0] + bLoc * cube[0] * cube[1]
        },
        cubeClosestColor(r, g, b) {
            return this[this.cubeClosestIndex(r, g, b)]
        },

        // Choose the appropriate method for finding closest index.
        // Lets the user specify any color, and let the colormap
        // use the best match.
        closestIndex(r, g, b) {
            return this.cube
                ? this.cubeClosestIndex(r, g, b)
                : this.rgbClosestIndex(r, g, b)
        },
        // Choose the appropriate method for finding closest color
        closestColor(r, g, b) {
            return this[this.closestIndex(r, g, b)]
        },
    },

    // ### Utilities for constructing ColorMaps

    // Convert an array of colors to a colormap.
    // The colors can be strings, numbers, rgb arrays
    // They are converted to typedColors.
    basicColorMap(colors) {
        // colors = this.arraysToColors(colors)
        colors = this.arrayToTypedColors(colors)
        Object.setPrototypeOf(colors, this.ColorMapProto)
        return colors
    },
    // Create a gray map (gray: r=g=b)
    // These are typically 256 entries but can be smaller
    // by passing a size parameter and the min/max range.
    grayColorMap(min = 0, max = 255, size = max - min + 1) {
        const ramp = util.integerRamp(min, max, size)
        return this.basicColorMap(ramp.map(i => [i, i, i]))
    },

    // Create a colormap by permuted rgb values.
    //
    // numRs, numGs, numBs are numbers, the number of steps beteen 0-255.
    // Ex: numRs = 3, corresponds to 0, 128, 255.
    // NOTE: the defaults: rgbColorCube(6) creates a `6 * 6 * 6` cube.
    rgbColorCube(numRs, numGs = numRs, numBs = numRs) {
        const array = this.permuteRGBColors(numRs, numGs, numBs)
        const map = this.basicColorMap(array)
        // Save the parameters for fast color calculations.
        map.cube = [numRs, numGs, numBs]
        return map
    },
    // Create a colormap by permuting the values of the given arrays.
    // Similar to above but with arrays that may have arbitrary values.
    rgbColorMap(R, G, B) {
        const array = this.permuteArrays(R, G, B)
        return this.basicColorMap(array)
    },

    // Create an hsl map, inputs are arrays to be permutted like rgbColorMap.
    // Convert the HSL values to Color.colors, default to bright hue ramp (L=50).
    hslColorMap(num = 360, S = 100, L = 50) {
        const hues = util.integerRamp(1, 360, num)
        const colors = hues.map(h => Color.hslCssColor(h))
        const typedColors = colors.map(c => Color.toTypedColor(c))
        return this.basicColorMap(typedColors)
    },

    transparentColorMap(num = 1) {
        // const array = Array(num).fill(0)
        // return this.basicColorMap(array)
        return this.staticColorMap(0, num)
    },
    staticColorMap(color, num = 1) {
        color = Color.toTypedColor(color)
        const array = Array(num).fill(color)
        return this.basicColorMap(array)
    },

    // Use gradient to build an rgba array, then convert to colormap.
    // Stops are css strings or typedColors.
    // locs defaults to evenly spaced, probably what you want.
    //
    // This easily creates all the MatLab colormaps like "jet" below.
    gradientColorMap(nColors, stops, locs) {
        stops = stops.map(c => c.css || c) // convert stops to css strings

        // get gradient colors as typed arrays & convert them to typedColors
        const uint8arrays = this.gradientImageData(nColors, stops, locs)
        const typedColors = this.arrayToTypedColors(uint8arrays)

        Object.setPrototypeOf(typedColors, this.ColorMapProto)
        return typedColors
    },
    // The most popular MatLab gradient, "jet":
    jetColors: [
        'rgb(0, 0, 127)',
        'rgb(0, 0, 255)',
        'rgb(0, 127, 255)',
        'rgb(0, 255, 255)',
        'rgb(127, 255, 127)',
        'rgb(255, 255, 0)',
        'rgb(255, 127, 0)',
        'rgb(255, 0, 0)',
        'rgb(127, 0, 0)',
    ],
    // Two other popular MatLab 'ramp' gradients are:
    // * One color: from black/white to color, optionally back to white/black.
    // stops = ['black', 'red'] or ['white', 'orange', 'black']
    // The NetLogo map is a concatenation of 14 of these.
    // * Two colors: stops = ['red', 'orange'] (blends the two, center is white)

    // The 16 unique [CSS Color Names](https://goo.gl/sxo36X), case insensitive.
    // In CSS 2.1, the color 'orange' was added to the 16 colors as a 17th color
    // Aqua == Cyan and Fuchsia == Magenta, 19 total color names.
    // These sorted by hue/saturation/light, hue in 0-300 degrees.
    // See [Mozilla Color Docs](https://goo.gl/tolSnS) for *lots* more!

    basicColorNames:
        'white silver gray black red maroon yellow orange olive lime green cyan teal blue navy magenta purple'.split(
            ' '
        ),
    brightColorNames:
        'white silver red maroon yellow orange olive lime green cyan teal blue navy magenta purple'.split(
            ' '
        ),
    // Create a named colors colormap
    cssColorMap(cssArray, createNameIndex = false) {
        const array = cssArray.map(str => Color.cssToUint8Array(str))
        const map = this.basicColorMap(array)
        map.cssNames = cssArray
        // REMIND: kinda tacky? Maybe map.name.yellow? Maybe generalize for other
        // map types: map.closest(name)
        if (createNameIndex) {
            cssArray.forEach((name, ix) => {
                map[name] = map[ix]
            })
            if (map.cyan) map.aqua = map.cyan
            if (map.magenta) map.fuchsia = map.magenta
        }
        return map
    },

    // Clone a colorMap. Useful if you want to mutate an existing shared map
    cloneColorMap(colorMap) {
        const keys = Object.keys(colorMap)
        const clone = this.basicColorMap(colorMap)
        util.forLoop(keys, (val, i) => {
            if (clone[i] === undefined) clone[val] = colorMap[val]
        })
        return clone
    },

    // ### Shared Global ColorMaps
    // NOTE: Do NOT modify one of these, they are shared and would
    // surprise anyone useing them. Use cloneColorMap() to have your own
    // private one, or call any of the map factories above .. see below.

    // The shared global colormaps are lazy evaluated to minimize memory use.
    // NOTE: these are shared, so any change in them are seen by all users!
    LazyMap(name, map) {
        Object.defineProperty(this, name, { value: map, enumerable: true })
        return map
    },
    get Gray() {
        return this.LazyMap('Gray', this.grayColorMap())
    },
    get Hue() {
        return this.LazyMap('Hue', this.hslColorMap())
    },
    get LightGray() {
        return this.LazyMap('LightGray', this.grayColorMap(200))
    },
    get DarkGray() {
        return this.LazyMap('DarkGray', this.grayColorMap(0, 100))
    },
    get Jet() {
        return this.LazyMap('Jet', this.gradientColorMap(256, this.jetColors))
    },
    get Rgb256() {
        return this.LazyMap('Rgb256', this.rgbColorCube(8, 8, 4))
    },
    get Rgb() {
        return this.LazyMap('Rgb', this.rgbColorCube(16))
    },
    get Transparent() {
        return this.LazyMap('Transparent', this.transparentColorMap())
    },
    get Basic16() {
        // 17 unique + 2 "aliases" = 19 names. "16" historic
        return this.LazyMap(
            'Basic16',
            this.cssColorMap(this.basicColorNames, true)
        )
    },
    // get Bright16() {
    //     // Basic16 w/o grays: white, black
    //     return this.LazyMap(
    //         'Bright16',
    //         this.cssColorMap(this.brightColorNames, true)
    //     )
    // },
}

export default ColorMap