Home Reference Source Repository

src/layers/Trajectory.js

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

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

import {DEFAULT_COLOR} from './Point.js'
  
/**
 * Renderer for Coverages and Domains conforming to the CovJSON domain type `Trajectory`.
 * 
 * Displays the trajectory as a path with coloured points using
 * a given palette for a given parameter.
 * 
 * @example
 * var cov = ... // get Coverage data
 * var layer = new C.Trajectory(cov, {
 *   parameter: 'salinity',
 *   defaultColor: 'black',
 *   palette: C.linearPalette(['#FFFFFF', '#000000'])
 * })
 * 
 * @see https://covjson.org/domain-types/#trajectory
 * 
 * @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 a point was clicked
 * 
 * @extends {L.FeatureGroup}
 * @extends {CoverageMixin}
 * @extends {PaletteMixin}
 * @implements {DataLayer}
 * 
 */
export class Trajectory extends PaletteMixin(CoverageMixin(L.FeatureGroup)) {
  
  // TODO FeatureGroup is not ideal since click events etc should not be blindly propagated
  //    (we use it for now to have getBounds() which LayerGroup misses)
  
  /**
   * @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 {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.
   */
  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._defaultColor = options.defaultColor || DEFAULT_COLOR
  }
  
  /**
   * @ignore
   * @override
   */
  onAdd (map) {
    this._map = map
    
    this.load()
      .then(() => this.initializePalette())
      .then(() => {
        this._addTrajectoryLayers()
        this.fire('afterAdd')
      })
  }
    
  /**
   * 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
  }
  
  /**
   * See {@link PaletteMixin}.
   * 
   * @ignore
   */
  computePaletteExtent (extent) {
    let range = this.range
        
    if (extent === 'full') {
      // scan the whole range for min/max values
      
    } else if (extent === 'fov') {
      // scan the values that are currently in field of view on the map for min/max
      let bounds = this._map.getBounds()

      // TODO implement
      throw new Error('NOT IMPLEMENTED YET')      
    } else {
      throw new Error('Unknown extent specification: ' + extent)
    }

    extent = minMaxOfRange(range)
    extent = enlargeExtentIfEqual(extent)    
    return Promise.resolve(extent)
  }
  
  _addTrajectoryLayers () {
    // add a Polyline in black, and coloured CircleMarker's for each domain point
    let points = this.getLatLngs()
    for (let i=0; i < points.length; i++) {
      let marker = new L.CircleMarker(points[i], {
        color: this._getColor(this._getValue(i)),
        opacity: 1,
        fillOpacity: 1
      })
      this.addLayer(marker)
    }
    
    let polyline = L.polyline(points, {
      color: 'black',
      weight: 3
    })
    
    this.addLayer(polyline)
  }
  
  /**
   * Returns the trajectory points as LatLng objects in the order they appear in the composite domain axis.
   * 
   * @return {Array<L.LatLng>}
   */
  getLatLngs () {
    let axis = this.domain.axes.get('composite')
    let ix = axis.coordinates.indexOf(this._projX)
    let iy = axis.coordinates.indexOf(this._projY)
    let coords = []
    for (let i=0; i < axis.values.length; i++) {
      let x = axis.values[i][ix]
      let y = axis.values[i][iy]
      let latlng = this.projection.unproject({x,y})
      let coord = L.latLng(latlng)
      coords.push(coord)
    }
    return coords
  }
  
  /**
   * Return the displayed value closest to the circle centre.
   * If no point exists within the circle, 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 points = this.getLatLngs()
    let distances = points.map(p => p.distanceTo(latlng))
    let minDistance = Infinity
    let minIdx
    for (let i=0; i < points.length; i++) {
      let distance = distances[i]
      if (distance <= maxDistance && distance < minDistance) {
        minDistance = distance
        minIdx = i
      }
    }
    if (minIdx !== undefined) {
      return this._getValue(minIdx)
    }
  }
  
  _getValue (index) {
    if (this._param) {
      return this.range.get({composite: index})
    }
  }
  
  // NOTE: this returns a string, not an {r,g,b} object as in other classes!
  _getColor (val) {
    if (val === null) {
      // no-data
      return this._defaultColor
    } else if (val === undefined) {
      // not fixed to a param
      return this._defaultColor
    } else {
      // use a palette
      let idx = this.getPaletteIndex(val)
      let {red, green, blue} = this.palette
      return `rgb(${red[idx]}, ${green[idx]}, ${blue[idx]})`
    }
  }
  
  /**
   * Redraw the layer.
   */
  redraw () {
    this.clearLayers()
    this._addTrajectoryLayers()
  }
  
}