Home Reference Source Repository

src/layers/palettes.js

/**
 * The `change` event, signalling that a different vertical coordinate value has been selected.
 * 
 * @typedef {Object} Palette
 * @property {number} steps The number of colors in the palette.
 * @property {Array<number>} red Array of integers in [0,255] of length `steps`.
 * @property {Array<number>} green Array of integers in [0,255] of length `steps`.
 * @property {Array<number>} blue Array of integers in [0,255] of length `steps`.
 */

/**
 * Returns a linearly interpolated palette out of CSS colors.
 * 
 * @example
 * var grayscale = C.linearPalette(['#FFFFFF', '#000000'])
 * var rainbow = C.linearPalette(['#0000FF', '#00FFFF', '#00FF00', '#FFFF00', '#FF0000'])
 * 
 * @param {Array<string>} colors An array of CSS colors.
 * @param {number} [steps=256] The number of palette colors to generate.
 * @return {Palette}
 */
export function linearPalette (colors, steps=256) {
  if (steps === 1) {
    // work-around, a gradient with 1 pixel becomes black otherwise
    return directPalette([colors[0]])
  }
  // draw the gradient in a canvas
  var canvas = document.createElement('canvas')
  canvas.width = steps
  canvas.height = 1
  var ctx = canvas.getContext('2d')
  var gradient = ctx.createLinearGradient(0, 0, steps - 1, 0)
  var num = colors.length
  for (var i = 0; i < num; i++) {
    gradient.addColorStop(i / (num - 1), colors[i])
  }
  ctx.fillStyle = gradient
  ctx.fillRect(0, 0, steps, 1)

  // now read back values into arrays
  var red = new Uint8Array(steps)
  var green = new Uint8Array(steps)
  var blue = new Uint8Array(steps)

  var pix = ctx.getImageData(0, 0, steps, 1).data
  for (let i = 0, j = 0; i < pix.length; i += 4, j++) {
    red[j] = pix[i]
    green[j] = pix[i + 1]
    blue[j] = pix[i + 2]
  }

  return {
    steps: red.length,
    red: red,
    green: green,
    blue: blue
  }
}

/**
 * Converts an array of CSS colors to a palette of the same size.
 * 
 * @example
 * var bw = C.directPalette(['#000000', '#FFFFFF'])
 * // bw.steps == 2
 * 
 * @param {Array<string>} colors An array of CSS colors.
 * @return {Palette}
 */
export function directPalette (colors) {
  var canvas = document.createElement('canvas')
  canvas.width = 1
  canvas.height = 1
  var ctx = canvas.getContext('2d')
  
  var steps = colors.length
  
  var red = new Uint8Array(steps)
  var green = new Uint8Array(steps)
  var blue = new Uint8Array(steps)
  
  for (var i=0; i < colors.length; i++) {
    ctx.fillStyle = colors[i]
    ctx.fillRect(0, 0, 1, 1)
    var pix = ctx.getImageData(0, 0, 1, 1).data
    red[i] = pix[0]
    green[i] = pix[1]
    blue[i] = pix[2]
  }
  
  return {
    steps: red.length,
    red: red,
    green: green,
    blue: blue
  }
}

/**
 * Converts any CSS color to an `{r,g,b}` object.
 * 
 * @param {string} cssColor The CSS color
 * @return {Object} An object with r,g,b members with each a number in [0,255].
 * 
 * @example
 * let rgb = cssToRGB('white') // {r: 255, g: 255, b: 255}
 */
export function cssToRGB (cssColor) {
  let palette = directPalette([cssColor])
  return {
    r: palette.red[0],
    g: palette.green[0],
    b: palette.blue[0],
  }
}

/**
 * Create a palette from a description object.
 * 
 * Currently, two forms are supported:
 * 
 * {
 *   "colors": ["red", "blue", ..]
 *   "interpolation": "linear",
 *   "steps": 200
 * }
 * 
 * {
 *   "colors": ["red", "blue", ..]
 * }
 * 
 * @return {Palette}
 */
export function paletteFromObject (paletteSpec) {
  if (!paletteSpec) {
    return
  }
  let colors = paletteSpec.colors
  let palette
  if (paletteSpec.interpolation === 'linear') {
    palette = linearPalette(colors, paletteSpec.steps)
  } else {
    palette = directPalette(colors)
  }
  return palette
}

/**
 * Linearly scales a value to a given palette and value extent.
 * 
 * @example
 * var value = 20
 * var grayscale = C.linearPalette(['#FFFFFF', '#000000'], 50) // 50 steps
 * var scaled = C.scale(value, grayscale, [0,100])
 * // scaled == 10
 * 
 * @param {number} val The value to scale.
 * @param {object} palette The palette onto which the value is scaled.
 * @param {Array} extent The lower and upper bound within which the value is scaled,
 *   typically the value extent of a legend.
 * @return {number} The scaled value.
 * 
 * @private
 */
export function scale (val, palette, extent) {
  // scale val to [0,paletteSize-1] using the palette extent
  // (IDL bytscl formula: http://www.exelisvis.com/docs/BYTSCL.html)
  let scaled = Math.trunc((palette.steps - 1 + 0.9999) * (val - extent[0]) / (extent[1] - extent[0]))
  return scaled
}

/**
 * Return enlarged extent if start and end are the same value,
 * otherwise return unchanged.
 * 
 * @param {Array<number>} extent The extent [min,max] to enlarge.
 * @param {number} [amount] The ratio by which to extend on each side.
 * @return {Array<number>} The enlarged extent.
 * 
 * @private
 */
export function enlargeExtentIfEqual (extent, amount=0.1) {
  if (extent[0] === extent[1]) {
    let buffer = extent[0]*amount
    return [extent[0]-buffer, extent[1]+buffer]
  } else {
    return extent
  }
}

/**
 * Manages palettes under common names.
 *  
 * @example
 * var palettes = new C.PaletteManager({defaultSteps: 10})
 * palettes.addLinear('grayscale', ['#FFFFFF', '#000000']) // 10 steps
 * palettes.addLinear('grayscalehd', ['#FFFFFF', '#000000'], {steps: 200}) // high-resolution palette
 * palettes.add('breweroranges3', ['#fee6ce', '#fdae6b', '#e6550d']) // palette of those 3 colors
 * palettes.add('mycustom', {red: [0,255], green: [0,0], blue: [10,20]}) // different syntax
 */
export class PaletteManager {
  
  /**
   * @param {Integer} defaultSteps The default number of steps when adding palettes with addLinear().
   */
  constructor({defaultSteps=256} = {}) {
    this._defaultSteps = defaultSteps
    this._palettes = new Map()
  }
  
  /**
   * Store a supplied generic palette under the given name.
   * 
   * @example
   * var palettes = new C.PaletteManager()
   * palettes.add('breweroranges3', ['#fee6ce', '#fdae6b', '#e6550d']) // palette of those 3 colors
   * palettes.add('mycustom', {red: [0,255], green: [0,0], blue: [10,20]}) // different syntax
   * 
   * @param {string} name The unique name of the palette.
   * @param {Palette|Array<string>} palette A palette object or an array of CSS colors.
   */
  add (name, palette) {
    if (Array.isArray(palette)) {
      palette = directPalette(palette)
    }
    
    if (![palette.red, palette.green, palette.blue].every(arr => arr.length === palette.red.length)) {
      throw new Error('The red, green, blue arrays of the palette must be of equal lengths')
    }
    if (!(palette.red instanceof Uint8Array)) {
      palette.red = _asUint8Array(palette.red)
      palette.green = _asUint8Array(palette.green)
      palette.blue = _asUint8Array(palette.blue)
    }
    palette.steps = palette.red.length // for convenience in clients
    this._palettes.set(name, palette)
  }
  
  /**
   * Store a linear palette under the given name created with the given CSS color specifications.
   * 
   * @example
   * var palettes = new C.PaletteManager()
   * palettes.addLinear('grayscale', ['#FFFFFF', '#000000']) // 10 steps
   * palettes.addLinear('grayscalehd', ['#FFFFFF', '#000000'], {steps: 200})
   * 
   * @param {String} name The unique name of the palette
   * @param {Array<string>} colors An array of CSS color specifications
   * @param {number} steps Use a different number of steps than the default of this manager.
   */
  addLinear (name, colors, {steps} = {}) {
    this.add(name, linearPalette(colors, steps ? steps : this._defaultSteps))
  }
  
  /**
   * Return the palette stored under the given name, or throw an error if not found.
   * The palette is an object with properties steps, red, green, and blue.
   * Each of the color arrays is an Uint8Array of length steps.
   * 
   * @param {string} name The unique name of the palette
   * @returns {Palette}
   */
  get (name) {
    var palette = this._palettes.get(name)
    if (palette === undefined) {
      throw new Error('Palette "' + name + '" not found')
    }
    return palette
  }
  
  get [Symbol.iterator] () {
    return this._palettes[Symbol.iterator]
  }
}

function _asUint8Array (arr) {
  var ta = new Uint8Array(arr.length)
  for (var i=0; i < arr.length; i++) {
    let val = arr[i]
    if (val < 0 || val > 255) {
      throw new Error('Array value must be within [0,255], but is: ' + val)
    }
    ta[i] = val
  }
  return ta
}