Home Reference Source Repository

src/layers/PaletteMixin.js

import {linearPalette, directPalette, paletteFromObject, scale} from './palettes.js'

const DEFAULT_CONTINUOUS_PALETTE = () => linearPalette(['#deebf7', '#3182bd']) // blues
const DEFAULT_CATEGORICAL_PALETTE = n => {
  if (n > 12) {
    throw new Error('not enough built-in categorical colors, must supply custom colors')
  }
  return directPalette(['#a6cee3','#1f78b4','#b2df8a','#33a02c','#fb9a99','#e31a1c',
                        '#fdbf6f','#ff7f00','#cab2d6','#6a3d9a','#ffff99','#b15928'].slice(0,n))
}

/**
 * The `paletteChange` event, signalling that the palette has changed.
 * 
 * @typedef {L.Event} PaletteMixin#paletteChange
 */

/**
 * The `paletteExtentChange` event, signalling that the palette extent has changed.
 * 
 * @typedef {L.Event} PaletteMixin#paletteExtentChange
 */

/**
 * A mixin that encapsulates the palette logic of a coverage layer,
 * supporting categorical and continuous coverage parameters.
 * 
 * The following functions/properties are supplied:
 * 
 * - initializePalette() - to be called once data has been loaded so that computePaletteExtent can be called
 * - get/set palette
 * - get/set paletteExtent
 * - setPaletteExtent(extent) - like set paletteExtent, but returns a Promise to know when calculations etc. are done
 * - getPaletteIndex(val) - returns the color index for the given value
 * 
 * The base class must supply the following functions/properties:
 * 
 * - options.palette (optional)
 * - options.paletteExtent (optional) - initial value that computePaletteExtent is called with
 * - parameter
 * - redraw()
 * - computePaletteExtent(extent) - returns a Promise with the computed extent; gets called when .paletteExtent is set to a string value
 * - canUsePalette() (optional) - if this method exists and returns false, then .palette returns undefined
 * 
 * @param {class} base The base class.
 * @return {class} The base class with PaletteMixin.
 */
export function PaletteMixin (base) {
  return class extends base {
    
    initializePalette () {
      let options = this.options
      let parameter = this.parameter
      if (!parameter) {
        return Promise.resolve()
      }
      let categories = parameter.observedProperty.categories
      
      if (categories) {
        this._initCategoryIdxMap()
      }
      
      if (this._palette) {
        // do nothing, already set
      } else if (options.palette) {
        this._palette = options.palette
      } else if (parameter.preferredPalette) {
        this._palette = paletteFromObject(parameter.preferredPalette)
      } else if (categories) {
        if (categories.every(cat => cat.preferredColor)) {
          this._palette = directPalette(categories.map(cat => cat.preferredColor))
        } else {
          this._palette = DEFAULT_CATEGORICAL_PALETTE(categories.length)
        }
      } else {
        this._palette = DEFAULT_CONTINUOUS_PALETTE()
      }
      
      if (categories && categories.length !== this._palette.steps) {
        throw new Error('Categorical palettes must match the number of categories of the parameter')
      }
            
      this._paletteExtent = this._paletteExtent || options.paletteExtent
      
      if (this.parameter.categoryEncoding) {
        // categorical parameter, does not depend on palette extent
        let valIdxMap = this._categoryIdxMap
        let max = valIdxMap.length - 1
        this.getPaletteIndex = val => {
          if (val === null || val < 0 || val > max) return
          let idx = valIdxMap[val]
          if (idx === 255) return
          return idx
        }
      }
      
      if (!this.canUsePalette || this.canUsePalette()) {
        return this.setPaletteExtent(this._paletteExtent, true)
          .then(() => this._updatePaletteIndexFn())
      } else {
        return Promise.resolve()
      }
    }
    
    _updatePaletteIndexFn () {
      if (!this.parameter.categoryEncoding) {
        // continuous parameter
        let palette = this.palette
        let extent = this.paletteExtent
        this.getPaletteIndex = val => {
          if (val === null) return
          let idx = scale(val, palette, extent)
          return idx
        }
      }
    }
    
    get palette () {
      if (this.parameter && (!this.canUsePalette || this.canUsePalette())) {
        return this._palette
      }
    }
    
    set palette (p) {
      this._palette = p
      this._updatePaletteIndexFn()
      this.redraw()
      this.fire('paletteChange')
    }
    
    set paletteExtent (extent) {
      this.setPaletteExtent(extent)
    }
    
    get paletteExtent () {
      return this._paletteExtent
    }
    
    setPaletteExtent (extent, skipRedraw) {
      if (this.parameter.observedProperty.categories) {
        return Promise.resolve()
      }
      
      let oldExtent = this.paletteExtent
      let hasChanged = newExtent => {
        if (!Array.isArray(oldExtent)) return true
        if (oldExtent[0] !== newExtent[0] || oldExtent[1] !== newExtent[1]) return true
        return false
      }
      let res = Array.isArray(extent) ? Promise.resolve(extent) : this.computePaletteExtent(extent)
      return res.then(newExtent => {
        // ignore invalid extents (may come from using ParameterSync)
        if (Array.isArray(newExtent) && isNaN(newExtent[0])) return
        if (!hasChanged(newExtent)) return
        this._paletteExtent = newExtent
        this._updatePaletteIndexFn()
        if (!skipRedraw) {
          this.redraw()
        }
        this.fire('paletteExtentChange')
      })
    }
            
    /**
     * Sets up a lookup table from categorical range value to palette index.
     */
    _initCategoryIdxMap () {
      let param = this.parameter
      if (!param.categoryEncoding) return
      
      // categorical parameter with integer encoding
      // Note: The palette order is equal to the categories array order.
      let max = -Infinity
      let min = Infinity
      let categories = param.observedProperty.categories
      let encoding = param.categoryEncoding
      for (let category of categories) {
        if (encoding.has(category.id)) {
          for (let val of encoding.get(category.id)) {
            max = Math.max(max, val)
            min = Math.min(min, val)
          }
        }
      }
      let valIdxMap
      if (categories.length < 256) {
        if (max > 10000 || min < 0) {
          // TODO implement fallback to Map implementation
          throw new Error('category values too high (>10000) or low (<0)')
        }
        valIdxMap = new Uint8Array(max+1)
        for (let i=0; i <= max; i++) {
          // the above length < 256 check ensures that no palette index is ever 255
          valIdxMap[i] = 255
        }
        
        for (let idx=0; idx < categories.length; idx++) {
          let category = categories[idx]
          if (encoding.has(category.id)) {
            for (let val of param.categoryEncoding.get(category.id)) {
              valIdxMap[val] = idx
            }
          }
        }
      } else {
        throw new Error('Too many categories: ' + categories.length)
      }
      this._categoryIdxMap = valIdxMap
    }
  }
}