Home Reference Source Repository

src/layers/PointSeries.js

import L from 'leaflet'
import {isDomain, fromDomain, indexOfNearest, minMaxOfRange} from 'covutils'

import {enlargeExtentIfEqual} from './palettes.js'
import {CoverageMixin} from './CoverageMixin.js'
import {CircleMarkerMixin} from './CircleMarkerMixin.js'
import {PaletteMixin} from './PaletteMixin.js'
import {EventMixin} from '../util/EventMixin.js'

import {DEFAULT_COLOR} from './Point.js'

// TODO nearly identical to VerticalProfile

/**
 * Renderer for Coverages conforming to the CovJSON domain type `PointSeries`.
 * 
 * This will simply display a dot on the map and fire a click event when a user clicks on it.
 * The dot either has a defined standard color, or it uses a palette if a parameter is chosen.
 * 
 * @example
 * var cov = ... // get Coverage data
 * var layer = new C.PointSeries(cov, {
 *   parameter: 'salinity',
 *   time: new Date('2015-01-01T12:00:00Z'),
 *   defaultColor: 'black',
 *   palette: C.linearPalette(['#FFFFFF', '#000000'])
 * })
 * 
 * @see https://covjson.org/domain-types/#pointseries
 * 
 * @emits {DataLayer#afterAdd} Layer is initialized and was added to the map
 * @emits {DataLayer#dataLoading} Data loading has started
 * @emits {DataLayer#dataLoad} Data loading has finished (also in case of errors)
 * @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 {CoverageMixin}
 * @extends {CircleMarkerMixin}
 * @extends {PaletteMixin}
 * @implements {DataLayer}
 * @implements {PointDataLayer}
 */
export class PointSeries extends PaletteMixin(CircleMarkerMixin(CoverageMixin(L.Layer))) {
  
  /**
   * An optional time axis target value can be defined with the 'time' property.
   * The closest values on the time axis is chosen.
   * 
   * @param {Coverage|Domain} cov The coverage or domain object to visualize.
   * @param {Object} [options] The options object.
   * @param {string} [options.parameter] The key of the parameter to display, not needed for domain objects.
   * @param {Date} [options.time] The initial time step 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' or specific: [-10,10].
   * @param {string} [options.defaultColor='black'] The color to use for missing data or if no parameter is set.
   * @param {boolean} [options.showNoData=false] Whether to draw the point if there is no data.
   */
  constructor (cov, options) {
    super()
    
    if (isDomain(cov)) {
      cov = fromDomain(cov)
      delete options.keys
      options.parameter = cov.parameters.keys().next().value
    }
    
    if (!options.paletteExtent) {
      options.paletteExtent = 'full'
    }
    
    L.Util.setOptions(this, options)

    this._cov = cov
    let paramKey = options.keys ? options.keys[0] : options.parameter
    this._param = paramKey ? cov.parameters.get(paramKey) : null
    this._axesSubset = {
      t: {coordPref: options.time}
    }
    this._defaultColor = options.defaultColor || DEFAULT_COLOR

    /** @ignore */
    this.showNoData = options.showNoData // if true, draw with default color
  }
  
  /**
   * @ignore
   * @override
   */
  onAdd (map) {
    this._map = map

    this.load()
      .then(() => this.initializePalette())
      .then(() => {
        this._addMarker()
        this.fire('afterAdd')
      })
  }
  
  _loadCoverageSubset () {
    // adapted from Grid.js
    let t = this._axesSubset.t
    if (t.coordPref == undefined) {
      t.idx = t.coord = undefined
    } else {
      let vals = this.domain.axes.get('t').values.map(v => v.getTime())
      t.idx = indexOfNearest(vals, t.coordPref.getTime())
      t.coord = vals[t.idx]
    }
    
    // Note that we don't subset the coverage currently, since there is no real need for it
  }
  
  /**
   * @ignore
   * @override
   */
  onRemove () {
    this._removeMarker()
  }
  
  /**
   * Returns the geographic bounds of the coverage, which is a degenerate box collapsed to a point.
   * 
   * @return {L.LatLngBounds}
   */
  getBounds () {
    return L.latLngBounds([this.getLatLng()])
  }
  
  /**
   * Returns the geographical position of the coverage.
   * 
   * @return {L.LatLng}
   */
  getLatLng () {
    let x = this.domain.axes.get(this._projX).values[0]
    let y = this.domain.axes.get(this._projY).values[0]
    let latlng = this.projection.unproject({x,y})
    return L.latLng(latlng)
  }
  
  /**
   * The coverage object associated to this layer.
   * 
   * @type {Coverage}
   */
  get coverage () {
    return this._cov
  }
    
  /**
   * The parameter that is visualized.
   * 
   * @type {Parameter}
   */
  get parameter () {
    return this._param
  }
  
  /**
   * Sets the currently active time to the one closest to the given Date object.
   * 
   * @type {Date|undefined}
   */
  set time (val) {
    let old = this.time
    this._axesSubset.t.coordPref = val ? val.toISOString() : undefined
    
    this._loadCoverageSubset()
    if (old === this.time) return
    this.redraw()
    this.fire('axisChange', {axis: 'time'})
  }
  
  /**
   * The currently active time on the temporal axis as Date object, 
   * or undefined if no time is set.
   * 
   * @type {Date|undefined}
   */
  get time () {
    if (!this._axesSubset.t.coord) {
      return
    }
    let time = this.domain.axes.get('t').values[this._axesSubset.t.idx]
    return new Date(time)
  }
  
  /**
   * The time slices that make up the coverage.
   * 
   * @type {Array<Date>}
   */
  get timeSlices () {
    return this.domain.axes.get('t').values.map(t => new Date(t))
  }
  
  /**
   * See {@link PaletteMixin}.
   * 
   * @ignore
   */
  canUsePalette () {
    return this.time !== undefined
  }
  
  /**
   * See {@link PaletteMixin}.
   * 
   * @ignore
   */
  computePaletteExtent (extent) {
    if (extent === 'full') {
      if (!this.parameter) {
        throw new Error('palette extent cannot be set when no parameter has been chosen')
      }
      
      extent = minMaxOfRange(this.range)
      extent = enlargeExtentIfEqual(extent)
      return Promise.resolve(extent)
    } else {
      throw new Error('Unknown extent specification: ' + extent)
    }
  }
    
  /**
   * Return the displayed value (number, or null for no-data),
   * or undefined if not fixed to a t-coordinate or parameter.
   * 
   * @returns {number|null|undefined}
   */
  getValue () {
    if (this._param && this._axesSubset.t.coord !== undefined) {
      let val = this.range.get({t: this._axesSubset.t.idx})
      return val
    }    
  }
  
  /**
   * Return the displayed value if within the given distance of the reference point.
   * If out of bounds, then undefined is returned, otherwise a number or null (for no data).
   * 
   * @param {L.LatLng} latlng
   * @param {number} maxDistance Maximum distance in meters between both points.
   * @returns {number|null|undefined}
   */
  getValueAt (latlng, maxDistance) {
    let point = this.getLatLng()
    if (point.distanceTo(latlng) <= maxDistance) {
      return this.getValue()
    }
  }
  
  _getColor () {
    let val = this.getValue()
    if (val === null) {
      // no-data
      return this._defaultColor
    } else if (val === undefined) {
      // not fixed to a param or z-coordinate
      return this._defaultColor
    } else {
      // use a palette
      let idx = this.getPaletteIndex(val)
      let {red, green, blue} = this.palette
      return {r: red[idx], g: green[idx], b: blue[idx]}
    }
  }
}