Home Reference Source Repository

src/layers/PointCollection.js

import L from 'leaflet'

import {PaletteMixin} from './PaletteMixin.js'
import {EventMixin} from '../util/EventMixin.js'
import {Point, DEFAULT_COLOR} from './Point.js'
import {enlargeExtentIfEqual} from './palettes.js'
import {kdTree} from '../util/kdTree.js'

/**
 * A collection of points sharing the same parameters and coordinate referencing system.
 * 
 * @see https://covjson.org/domain-types/#point
 * 
 * @emits {DataLayer#afterAdd} Layer is initialized and was added to the map
 * @emits {DataLayer#error} Error when loading data
 * @emits {PaletteMixin#paletteChange} Palette has changed
 * @emits {PaletteMixin#paletteExtentChange} Palette extent has changed
 * @emits {Point#click} when the point was clicked
 * 
 * @extends {L.Layer}
 * @extends {PaletteMixin} 
 */
export class PointCollection extends PaletteMixin(L.Layer) {
  /**
   * @param {CoverageCollection} covcoll The coverage collection to visualize.
   * @param {Object} [options] The options object.
   * @param {string} [options.parameter] The key of the parameter to display.
   * @param {Palette} [options.palette] The initial color palette to use, the default depends on the parameter type.
   * @param {string} [options.paletteExtent='full'] The initial palette extent, either 'full', 'fov', or specific: [-10,10].
   * @param {string} [options.defaultColor='black'] The color to use for missing data or if no parameter is set.
   * @param {class} [options.pointClass=Point] The {@link PointDataLayer} class to use for the individual points.
   * @param {function} [options.pointOptionsFn] A function that returns additional options to apply for each point class instance.  
   */
  constructor (covcoll, options={}) {
    super()
    
    // TODO how should we handle collection paging?
    
    if (!options.paletteExtent) {
      options.paletteExtent = 'full'
    }
    
    L.Util.setOptions(this, options)

    this._covcoll = covcoll
    let paramKey = options.keys ? options.keys[0] : options.parameter
    this._param = paramKey ? covcoll.parameters.get(paramKey) : null
    this._defaultColor = options.defaultColor || DEFAULT_COLOR
    this._pointClass = options.pointClass || Point
        
    this._layerGroup = L.layerGroup()
    this._layers = []
    this._kdtree = undefined
    
    this.on('paletteChange', () => {
      for (let layer of this._layers) {
        layer.palette = this.palette
      }
    })
    this.on('paletteExtentChange', () => {
      for (let layer of this._layers) {
        layer.paletteExtent = this.paletteExtent
      }
    })
  }
  
  /**
   * @ignore
   * @override
   */
  onAdd (map) {
    this._map = map
    this._layerLoadCount = 0
    this._layerErrors = []
    
    let options = {
      keys: this._param ? [this._param.key] : undefined,
      defaultColor: this._defaultColor,
      palette: this.palette,
      paletteExtent: this.paletteExtent
    }
    if (this.options.pointOptionsFn) {
      let opts = this.options.pointOptionsFn()
      for (let key in opts) {
        options[key] = opts[key]
      }
    }
    for (let cov of this._covcoll.coverages) {
      let layer = new this._pointClass(cov, options)
      this._attachListeners(layer, cov)
      this._layerGroup.addLayer(layer)
      this._layers.push(layer)
      layer.load()
      if (this._popupFn) {
        let popup = this._popupFn(layer.coverage)
        layer.bindPopup(popup)
      }
    }
    
  }
  
  /**
   * @ignore
   * @override
   */
  onRemove (map) {
    map.removeLayer(this._layerGroup)
    this._layerGroup = L.layerGroup()
    this._layers = []
  }
  
  /**
   * Binds a popup to each point instance.
   * 
   * @param {function(cov: Coverage):String|HTMLElement|L.Popup} fn Returns the popup for a given point coverage. 
   */
  bindPopupEach (fn) {
    this._popupFn = fn
  }
  
  _attachListeners (layer, cov) {
    layer.once('dataLoad', () => {
      ++this._layerLoadCount
      this._fireIfOnAddDone()
    }).once('error', e => {
      this._layerErrors.push(e)
    }).on('click', e => {
      e.coverage = cov
      this.fire('click', e)
    })
  }
  
  _fireIfOnAddDone () {
    if (this._layerLoadCount === this._layers.length) {
      if (this._layerErrors.length > 0) {
        this.fire('error', {errors: this._layerErrors})
      } else {
        this._initKdtree()
        this.initializePalette().then(() => {
          this._layerGroup.addTo(this._map)
          this.fire('afterAdd')
        })
      }
    }
  }
  
  _initKdtree () {
    let points = this._layers.map(layer => {
      let point = layer.getLatLng()
      point.layer = layer
      return point
    })
    let distance = (point1, point2) => L.LatLng.prototype.distanceTo.call(point1, point2)
    let dimensions = ['lat', 'lng']
    this._kdtree = new kdTree(points, distance, dimensions)
  }
  
  /**
   * Returns the geographic bounds of the coverage collection.
   * 
   * @return {L.LatLngBounds}
   */
  getBounds () {
    return L.latLngBounds(this._layers.map(layer => layer.getLatLng()))
  }
  
  /**
   * Return the displayed value of the point coverage closest to
   * the given position and within the given maximum distance.
   * If no coverage is found, undefined is returned, otherwise
   * a number or null (no-data).
   * 
   * @param {L.LatLng} latlng reference position
   * @param {number} maxDistance
   *   Maximum distance in meters that the point coverage may be
   *   apart from the given position.
   * @return {number|null|undefined}
   */
  getValueAt (latlng, maxDistance) {
    let points = this._kdtree.nearest(latlng, 1, maxDistance)
    if (points.length > 0) {
      let point = points[0][0]
      let val = point.layer.getValue()
      return val
    }
  }
  
  /**
   * The parameter that is visualized.
   * 
   * @type {Parameter}
   */
  get parameter () {
    return this._param
  }
    
  /**
   * See {@link PaletteMixin}.
   * 
   * @ignore
   */
  computePaletteExtent (extent) {
    if (!this._param) {
      throw new Error('palette extent cannot be set when no parameter has been chosen')
    }
    
    let layers
    if (extent === 'full') {
      layers = this._layers
    } else if (extent === 'fov') {
      let bounds = this._map.getBounds()
      layers = this._layers.filter(layer => bounds.contains(layer.getLatLng()))
    } else {
      throw new Error('Unsupported: ' + extent)
    }
    
    let min = Infinity
    let max = -Infinity
    for (let layer of layers) {
      let val = layer.getValue()
      if (val != null) {
        min = Math.min(min, val)
        max = Math.max(max, val)
      }
    }
    extent = enlargeExtentIfEqual([min, max])
    return Promise.resolve(extent)
  }
  
  /**
   * Redraw each point layer.
   */
  redraw () {
    for (let layer of this._layers) {
      layer.redraw()
    }
  }
}