Home Reference Source Repository

src/layers/Grid.js

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

import {enlargeExtentIfEqual} from './palettes.js'
import {PaletteMixin} from './PaletteMixin.js'
import {CoverageMixin} from './CoverageMixin.js'
  
/**
 * Renderer for Coverages and Domains conforming to the `Grid` domain type of CovJSON.
 * For Domain objects, a dummy parameter and range data is created.
 * 
 * @example
 * var cov = ... // get Coverage data
 * var layer = new C.Grid(cov, {
 *   parameter: 'salinity',
 *   time: new Date('2015-01-01T12:00:00Z'),
 *   vertical: 50,
 *   palette: C.linearPalette(['#FFFFFF', '#000000']),
 *   paletteExtent: 'subset'
 * })
 * 
 * @see https://covjson.org/domain-types/#grid
 * 
 * @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 {DataLayer#axisChange} Axis coordinate has changed (e.axis === 'time'|'vertical')
 * @emits {PaletteMixin#paletteChange} Palette has changed
 * @emits {PaletteMixin#paletteExtentChange} Palette extent has changed
 * 
 * @extends {L.GridLayer}
 * @extends {CoverageMixin}
 * @extends {PaletteMixin}
 * @implements {DataLayer}
 */
export class Grid extends PaletteMixin(CoverageMixin(L.GridLayer)) {
  
  /**
   * The key of the parameter to display must be given in the 'parameter' options property,
   * except when the coverage data object is a Domain object.
   * 
   * Optional time and vertical axis target values can be defined with the 'time' and
   * 'vertical' options properties. The closest values on the respective axes are 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 slice to display, defaults to the first one.
   * @param {number} [options.vertical] The initial vertical slice to display, defaults to the first one.
   * @param {Palette} [options.palette] The initial color palette to use, the default depends on the parameter type.
   * @param {string} [options.paletteExtent='subset'] The initial palette extent, one of 
   *  `subset` (computed from data of current time/vertical slice),
   *  `fov` (computed from data in map field of view; not implemented yet),
   *  or specific: [-10,10].
   */
  constructor (cov, options={}) {
    super()
    
    if (isDomain(cov)) {
      cov = fromDomain(cov)
      options.parameter = cov.parameters.keys().next().value
      delete options.keys
    }
    
    if (!options.paletteExtent) {
      options.paletteExtent = 'subset'
    }
    
    L.Util.setOptions(this, options)
    
    this._cov = cov
    this._param = cov.parameters.get(options.keys ? options.keys[0] :options.parameter)
    this._axesSubset = { // x and y are not subsetted
        t: {coordPref: options.time ? options.time.toISOString() : undefined},
        z: {coordPref: options.vertical}
    }

    /**
     * The vertical reference system object, used by {@link VerticalAxis}.
     * @type {Object}
     */
    this.crsVerticalAxis = undefined
  }
  
  /**
   * @ignore
   * @override
   */
  onAdd (map) {
    // "loading" and "load" events are provided by the underlying GridLayer class
    this._map = map

    this.load()
      .then(() => this.initializePalette())
      .then(() => {
        // used in controls/VerticalAxis.js
        let vertRef = getReferenceObject(this.domain, 'z')
        if (vertRef && vertRef.coordinates.length === 1) {
          let vertRefSys = vertRef.system
          if (vertRefSys.cs && (vertRefSys.cs.csAxes || vertRefSys.cs.axes)) {
            this.crsVerticalAxis = vertRefSys.cs.csAxes ? vertRefSys.cs.csAxes[0] : vertRefSys.cs.axes[0]
          }
        } else {
          // TODO handle vertical axis part of 3D CRS
        }
      })
      .then(() => {
        this._errored = false
        super.onAdd(map)
        this.fire('afterAdd')
      })
      .catch(e => {
        this._errored = true
        console.log(e)
        super.onAdd(map)
      })
  }
  
  /**
   * @ignore
   * @override
   */
  onRemove (map) {
    delete this._map
    // TODO delete references to domain/range, caching logic should happen elsewhere
    super.onRemove(map)
  }
  
  /**
   * Returns the geographic bounds of the coverage.
   * 
   * For projected coverages this is an approximation based on unprojecting the four bounding box corners
   * and fitting all four points into a geographic bounding box.
   * 
   * @returns {L.LatLngBounds}
   */
  getBounds () {
    let bbox
    if (this._cov.bbox) {
      bbox = this._cov.bbox
    } else {
      bbox = this._getDomainBbox()
      let proj = this.projection
      // for projected CRSs this approximates the geographic bbox by unprojecting the projected bbox corners
      // for geographic CRSs the result will be identical 
      let p1 = proj.unproject({x: bbox[0], y: bbox[1]})
      let p2 = proj.unproject({x: bbox[0], y: bbox[3]})
      let p3 = proj.unproject({x: bbox[2], y: bbox[1]})
      let p4 = proj.unproject({x: bbox[2], y: bbox[3]})
      return L.latLngBounds([p1, p2, p3, p4])
    }
    let southWest = L.latLng(bbox[1], bbox[0])
    let northEast = L.latLng(bbox[3], bbox[2])
    let bounds = L.latLngBounds(southWest, northEast)
    return bounds
  }
  
  /**
   * Subsets the temporal and vertical axes based on the _axesSubset.*.coordPref property,
   * which is regarded as a preference and does not have to exactly match a coordinate.
   * 
   * The return value is a promise that succeeds with an empty result and
   * sets this._subsetCov to the subsetted coverage.
   * The subsetting always fixes a single time and vertical slice, choosing the first
   * axis value if no preference was given.
   * 
   * After calling this method, _axesSubset.*.idx and _axesSubset.*.coord have
   * values from the actual axes.
   */
  _loadCoverageSubset () {        
    let spec = {}
    for (let axis of Object.keys(this._axesSubset)) {
      let ax = this._axesSubset[axis]
      if (!this.domain.axes.has(axis)) {
        continue
      }
      if (ax.coordPref == undefined) { // == also handles null
        spec[axis] = {target: this.domain.axes.get(axis).values[0]}
      } else {
        spec[axis] = {target: ax.coordPref}
      }
    }
    
    this.fire('dataLoading') // for supporting loading spinners
    return this._cov.subsetByValue(spec)
      .then(subsetCov => {
        this._subsetCov = subsetCov
        //  the goal is to avoid reloading data when approximating palette extent via subsetting
        //  but: memory has to be freed when the layer is removed from the map
        //      -> therefore cacheRanges is set on subsetCov whose reference is removed on onRemove
        subsetCov.cacheRanges = true
        return Promise.all([subsetCov.loadDomain(), subsetCov.loadRange(this._param.key)])
      })
      .then(([subsetDomain, subsetRange]) => {
        this._subsetDomain = subsetDomain
        this._subsetRange = subsetRange
        this.fire('dataLoad')
      })
      .catch(e => {
        this.fire('dataLoad')
        throw e
      })
  }
  
  /**
   * 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.
   * Throws an exception if there is no time axis.
   * 
   * @type {Date}
   */
  set time (val) {
    if (!this.domain.axes.has('t')) {
      throw new Error('No time axis found')
    }
    let old = this.time
    this._axesSubset.t.coordPref = val.toISOString()
    this._loadCoverageSubset().then(() => {
      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 the grid has no time axis.
   * 
   * @type {Date|undefined}
   */
  get time () {
    if (this.domain.axes.has('t')) {
      let time = this._subsetDomain.axes.get('t').values[0]
      return new Date(time)
    }
  }
  
  /**
   * The time slices that make up the coverage, or undefined if the grid has no time axis .
   * 
   * @type {Array<Date>|undefined}
   */
  get timeSlices () {
    if (this.domain.axes.has('t')) {
      return this.domain.axes.get('t').values.map(t => new Date(t))
    }
  }
  
  /**
   * Sets the currently active vertical coordinate to the one closest to the given value.
   * 
   * @type {number}
   */
  set vertical (val) {
    if (!this.domain.axes.has('z')) {
      throw new Error('No vertical axis found')
    }
    let old = this.vertical
    this._axesSubset.z.coordPref = val
    this._loadCoverageSubset().then(() => {
      if (old === this.vertical) return
      this.redraw()
      this.fire('axisChange', {axis: 'vertical'}) 
    })  
  }
  
  /**
   * The currently active vertical coordinate as a number, 
   * or undefined if the grid has no vertical axis.
   * 
   * @type {number|undefined}
   */
  get vertical () {
    if (this.domain.axes.has('z')) {
      let val = this._subsetDomain.axes.get('z').values[0]
      return val
    }
  }
  
  /**
   * The vertical slices that make up the coverage, or undefined if the grid has no vertical axis .
   * 
   * @type {Array<number>|undefined}
   */
  get verticalSlices () {
    if (this.domain.axes.has('z')) {
      let vals = this.domain.axes.get('z').values
      if (ArrayBuffer.isView(vals)) {
        // convert to plain Array to allow easier use
        vals = [...vals]
      }
      return vals
    }
  }
  
  /**
   * See {@link PaletteMixin}.
   * 
   * @ignore
   */
  computePaletteExtent (extent) {
    if (extent === 'subset') {
      // scan the current subset for min/max values
      
      let xlen = this._subsetRange.shape.get(this._projX)
      let ylen = this._subsetRange.shape.get(this._projY)

      // check if subsetted size is manageable
      if (xlen * ylen < 1000*1000) {
        extent = minMaxOfRange(this._subsetRange)
        extent = enlargeExtentIfEqual(extent)
        return Promise.resolve(extent)
      } else {
        // subset x and y to get a fast estimate of the palette extent
        // since it is an estimate, the lower and upper bound needs a small buffer
        // (to prevent out-of-bounds colours)
        let xconstraint = {start: 0, stop: xlen, step: Math.max(Math.round(xlen/1000), 1)}
        let yconstraint = {start: 0, stop: ylen, step: Math.max(Math.round(ylen/1000), 1)}
        
        return this._subsetCov.subsetByIndex({[this._projX]: xconstraint, [this._projY]: yconstraint})        
          .then(subsetCov => {
            return subsetCov.loadRange(this._param.key).then(subsetRange => {
               let [min,max] = minMaxOfRange(subsetRange)
               let buffer = (max-min)*0.1 // 10% buffer on each side
               extent = [min-buffer, max+buffer]
               return extent
            })
          })
      }
    } else if (extent === 'fov') {
      // scan the values that are currently in field of view on the map for min/max
      // this implies using the current subset
      let bounds = this._map.getBounds()

      // TODO implement
      throw new Error('NOT IMPLEMENTED YET')      
    } else {
      throw new Error('Unknown extent specification: ' + extent)
    }
  }
  
  /**
   * Return the displayed value at a given geographic position.
   * If out of bounds, then undefined is returned, otherwise a number or null (for no data).
   * 
   * @param {L.LatLng} latlng
   * @returns {number|null|undefined}
   */
  getValueAt (latlng) {
    let X = this.domain.axes.get(this._projX).values
    let Y = this.domain.axes.get(this._projY).values
    let bbox = this._getDomainBbox()

    let {lat, lng} = latlng
    let {x,y} = this.projection.project({lat, lon: lng})

    // we first check whether the tile pixel is outside the domain bounding box
    // in that case we skip it as we do not want to extrapolate
    if (x < bbox[0] || x > bbox[2] || y < bbox[1] || y > bbox[3]) {
      return
    }
    
    let iy = indexOfNearest(Y, y)
    let ix = indexOfNearest(X, x)

    return this._subsetRange.get({[this._projY]: iy, [this._projX]: ix})
  }

  /**
   * @ignore
   * @override
   * 
   * @param {L.Point} coords The tile coordinates (with z being zoom level).
   * @return {HTMLCanvasElement}
   */
  createTile (coords) {
    let tile = L.DomUtil.create('canvas', 'leaflet-tile')

    // setup tile width and height according to the options
    var size = this.getTileSize()
    tile.width = size.x
    tile.height = size.y

    this.drawTile(tile, coords)

    return tile
  }
  
  /**
   * @ignore
   * 
   * @param {HTMLCanvasElement} The canvas to draw on.
   * @param {L.Point} coords The tile coordinates (with z being zoom level).
   */
  drawTile (canvas, coords) {
    if (this._errored) return
    
    let ctx = canvas.getContext('2d')
    let tileSize = this.getTileSize()
    
    let imgData = ctx.getImageData(0, 0, tileSize.x, tileSize.y)
    // Uint8ClampedArray, 1-dimensional, in order R,G,B,A,R,G,B,A,... row-major
    let rgba = ndarray(imgData.data, [tileSize.y, tileSize.x, 4])
    
    let {red, green, blue} = this.palette
        
    let getPaletteIndex = this.getPaletteIndex
    let setPixel = (tileY, tileX, val) => {
      let idx = getPaletteIndex(val)
      if (idx !== undefined) {
        rgba.set(tileY, tileX, 0, red[idx])
        rgba.set(tileY, tileX, 1, green[idx])
        rgba.set(tileY, tileX, 2, blue[idx])
        rgba.set(tileY, tileX, 3, 255)
      }
    }
    
    let vals = this._subsetRange.get
    
    if (this._isDomainUsingEllipsoidalCRS()) {
      if (this._isRectilinearGeodeticMap()) {
        // here we can apply heavy optimizations as the map CRS matches the domain CRS 
        this._drawGeodeticCRSWithRectilinearMapProjection(setPixel, coords, vals)
      } else {
        // this is for any random map projection
        // here we have to unproject each map pixel individually and find the matching domain index coordinates
        this._drawGeodeticCRSWithAnyMapProjection(setPixel, coords, vals)
      }
    } else {
      // here we have to unproject each map pixel individually, 
      // project it into domain projection coordinates, and find the domain index coordinates
      if (this._isRectilinearGeodeticMap()) {
        this._drawProjectedCRSWithRectilinearMapProjection(setPixel, coords, vals)
      } else {
        this._drawProjectedCRSWithAnyMapProjection(setPixel, coords, vals)
      }
    }
    
    ctx.putImageData(imgData, 0, 0)    
  }
  
  /**
   * Derives the bounding box of the x,y CRS axes in domain CRS coordinates.
   * 
   * @return {Array} [xmin,ymin,xmax,ymax]
   */
  _getDomainBbox () {
    let extent = (x, xBounds) => {
      let xend = x.length - 1
      let xmin, xmax
      if (xBounds) {
        [xmin,xmax] = [xBounds.get(0)[0], xBounds.get(xend)[1]]
      } else {
        [xmin,xmax] = [x[0], x[xend]]
      }
      let xDescending = xmin > xmax
      if (xDescending) {
        [xmin,xmax] = [xmax,xmin]
      }
      if (!xBounds && x.length > 1) {
        if (xDescending) {
          xmin -= (x[xend - 1] - x[xend]) / 2
          xmax += (x[0] - x[1]) / 2
        } else {
          xmin -= (x[1] - x[0]) / 2
          xmax += (x[xend] - x[xend - 1]) / 2 
        }
      }
      return [xmin, xmax]
    }
    
    let xAxis = this.domain.axes.get(this._projX)
    let yAxis = this.domain.axes.get(this._projY)
    let [xmin, xmax] = extent(xAxis.values, xAxis.bounds)
    let [ymin, ymax] = extent(yAxis.values, yAxis.bounds)

    return [xmin,ymin,xmax,ymax]
  }
  
  /**
   * Draws a geodetic rectilinear domain grid on a map with arbitrary projection.
   * 
   * @param {Function} setPixel A function with parameters (y,x,val) which 
   *                            sets the color of a pixel on a tile.
   * @param {L.Point} coords The tile coordinates.
   * @param {function(idx: Object): number|null} vals Range value function.
   */
  _drawGeodeticCRSWithAnyMapProjection (setPixel, coords, vals) {
    // usable for any map projection, but computationally more intensive
    // there are two hotspots in the loops: map.unproject and indexOfNearest
    
    // Note that this function is slightly more specialized and optimized than _drawProjectedCRSWithAnyMapProjection().
    // It targets the case when the domain is lat/lon, whereas _drawProjectedCRSWithAnyMapProjection() works
    // with any projected CRS in the grid domain.

    let tileSize = this.getTileSize()
    let startX = coords.x * tileSize.x
    let startY = coords.y * tileSize.y
    let zoom = coords.z

    let map = this._map
    let x = this.domain.axes.get('x').values
    let y = this.domain.axes.get('y').values
    let bbox = this._getDomainBbox()
    
    // a bit hacky
    if (this._projX === 'y') {
      bbox = [bbox[1], bbox[0], bbox[3], bbox[2]]
    }
    
    let lonRange = [bbox[0], bbox[0] + 360]

    
    
    for (let tileX = 0; tileX < tileSize.x; tileX++) {
      for (let tileY = 0; tileY < tileSize.y; tileY++) {
        let {lat,lng} = map.unproject(L.point(startX + tileX, startY + tileY), zoom)

        // we first check whether the tile pixel is outside the domain bounding box
        // in that case we skip it as we do not want to extrapolate
        if (lat < bbox[1] || lat > bbox[3]) {
          continue
        }

        lng = wrapLongitude(lng, lonRange)
        if (lng < bbox[0] || lng > bbox[2]) {
          continue
        }

        // now we find the closest grid cell using simple binary search
        // for finding the closest latitude/longitude we use a simple binary search
        // (as there is no discontinuity)
        let iLat = indexOfNearest(y, lat)
        let iLon = indexOfNearest(x, lng)

        setPixel(tileY, tileX, vals({y: iLat, x: iLon}))
      }
    }
  }
  
  /**
   * Draws a domain with projected CRS on a map with arbitrary projection.
   * 
   * @param {Function} setPixel A function with parameters (y,x,val) which 
   *                            sets the color of a pixel on a tile.
   * @param {L.Point} coords The tile coordinates.
   * @param {function(idx: Object): number|null} vals Range value function.
   */
  _drawProjectedCRSWithAnyMapProjection (setPixel, coords, vals) {
    let map = this._map
    let X = this.domain.axes.get(this._projX).values
    let Y = this.domain.axes.get(this._projY).values
    let bbox = this._getDomainBbox()
    
    let proj = this.projection

    let tileSize = this.getTileSize()
    let startX = coords.x * tileSize.x
    let startY = coords.y * tileSize.y
    let zoom = coords.z
    
    for (let tileX = 0; tileX < tileSize.x; tileX++) {
      for (let tileY = 0; tileY < tileSize.y; tileY++) {
        let {lat,lng} = map.unproject(L.point(startX + tileX, startY + tileY), zoom)
        let {x,y} = proj.project({lat, lon: lng})

        // we first check whether the tile pixel is outside the domain bounding box
        // in that case we skip it as we do not want to extrapolate
        if (x < bbox[0] || x > bbox[2] || y < bbox[1] || y > bbox[3]) {
          continue
        }

        // now we find the closest grid cell using simple binary search
        let iy = indexOfNearest(Y, y)
        let ix = indexOfNearest(X, x)

        setPixel(tileY, tileX, vals({y: iy, x: ix}))
      }
    }
  }
  
  /**
   * Draws a domain with projected CRS on a map with rectilinear lon/lat projection.
   * 
   * @param {Function} setPixel A function with parameters (y,x,val) which 
   *                            sets the color of a pixel on a tile.
   * @param {L.Point} coords The tile coordinates.
   * @param {function(idx: Object): number|null} vals Range value function.
   */
  _drawProjectedCRSWithRectilinearMapProjection (setPixel, coords, vals) {
    let map = this._map
    let X = this.domain.axes.get(this._projX).values
    let Y = this.domain.axes.get(this._projY).values
    let bbox = this._getDomainBbox()
    
    let proj = this.projection

    let tileSize = this.getTileSize()
    let startX = coords.x * tileSize.x
    let startY = coords.y * tileSize.y
    let zoom = coords.z
    
    // since the map projection is a rectilinear lat/lon grid,
    // we only have to unproject the the first row and column to get the lat/lon coordinates of all tile pixels
    let lons = new Float64Array(tileSize.x)
    for (let tileX = 0; tileX < tileSize.x; tileX++) {
      let {lng} = map.unproject(L.point(startX + tileX, startY), zoom)
      lons[tileX] = lng
    }
    let lats = new Float64Array(tileSize.y)
    for (let tileY = 0; tileY < tileSize.y; tileY++) {
      let {lat} = map.unproject(L.point(startX, startY + tileY), zoom)
      lats[tileY] = lat
    }    
    
    for (let tileX = 0; tileX < tileSize.x; tileX++) {
      for (let tileY = 0; tileY < tileSize.y; tileY++) {
        let lat = lats[tileY]
        let lon = lons[tileX]
        let {x,y} = proj.project({lat, lon})

        // we first check whether the tile pixel is outside the domain bounding box
        // in that case we skip it as we do not want to extrapolate
        if (x < bbox[0] || x > bbox[2] || y < bbox[1] || y > bbox[3]) {
          continue
        }

        // now we find the closest grid cell using simple binary search
        let iy = indexOfNearest(Y, y)
        let ix = indexOfNearest(X, x)

        setPixel(tileY, tileX, vals({y: iy, x: ix}))
      }
    }
  }
  
  /**
   * Draws a geodetic rectilinear domain grid on a map whose grid is, or can be directly
   * mapped to, a geodetic rectilinear grid.
   * 
   * @param {Function} setPixel A function with parameters (y,x,val) which 
   *                            sets the color of a pixel on a tile.
   * @param {L.Point} coords The tile coordinates.
   * @param {function(idx: Object): number|null} vals Range value function.
   */
  _drawGeodeticCRSWithRectilinearMapProjection (setPixel, coords, vals) {
    // optimized version for map projections that are equal to a rectilinear geodetic grid
    // this can be used when lat and lon can be computed independently for a given pixel

    let map = this._map
    let x = this.domain.axes.get('x').values
    let y = this.domain.axes.get('y').values
    let bbox = this._getDomainBbox()
    
    // a bit hacky
    if (this._projX === 'y') {
      bbox = [bbox[1], bbox[0], bbox[3], bbox[2]]
    }
      
    let lonRange = [bbox[0], bbox[0] + 360]

    let tileSize = this.getTileSize()
    let startX = coords.x * tileSize.x
    let startY = coords.y * tileSize.y
    let zoom = coords.z
    
    var latCache = new Float64Array(tileSize.y)
    var iLatCache = new Uint32Array(tileSize.y)
    for (let tileY = 0; tileY < tileSize.y; tileY++) {
      var lat = map.unproject(L.point(startX, startY + tileY), zoom).lat
      latCache[tileY] = lat
      // find the index of the closest latitude in the grid using simple binary search
      iLatCache[tileY] = indexOfNearest(y, lat)
    }

    for (let tileX = 0; tileX < tileSize.x; tileX++) {
      let lon = map.unproject(L.point(startX + tileX, startY), zoom).lng
      lon = wrapLongitude(lon, lonRange)
      if (lon < bbox[0] || lon > bbox[2]) {
        continue
      }

      // find the index of the closest longitude in the grid using simple binary search
      // (as there is no discontinuity)
      let iLon = indexOfNearest(x, lon)

      for (let tileY = 0; tileY < tileSize.y; tileY++) {
        // get geographic coordinates of tile pixel
        let lat = latCache[tileY]

        // we first check whether the tile pixel is outside the domain bounding box
        // in that case we skip it as we do not want to extrapolate
        if (lat < bbox[1] || lat > bbox[3]) {
          continue
        }

        let iLat = iLatCache[tileY]

        setPixel(tileY, tileX, vals({y: iLat, x: iLon}))
      }
    }
  }
  
  /**
   * Return true if the map projection grid can be mapped to a rectilinear
   * geodetic grid. For that, it must satisfy:
   * for all x, there is a longitude lon, for all y, unproject(x,y).lon = lon
   * for all y, there is a latitude lat, for all x, unproject(x,y).lat = lat
   * 
   * Returns false if this is not the case or unknown.
   */
  _isRectilinearGeodeticMap () {
    let crs = this._map.options.crs
    // these are the ones included in Leaflet
    let recti = [L.CRS.EPSG3857, L.CRS.EPSG4326, L.CRS.EPSG3395, L.CRS.Simple]
    let isRecti = recti.indexOf(crs) !== -1
    // TODO for unknown ones, how do we test that?
    return isRecti
  }
  
  /**
   * Return whether the coverage domain is using a geodetic CRS with WGS84 datum.
   */
  _isDomainUsingEllipsoidalCRS () {
    return this.domain.referencing.some(ref => isEllipsoidalCRS(ref.system))
  }
  
  /**
   * @ignore
   * @override
   */
  redraw () {
    // we check getContainer() to prevent errors when trying to redraw when the layer has not
    // fully initialized yet
    if (this.getContainer()) {
      L.GridLayer.prototype.redraw.call(this)
    }
  }
}

function wrapLongitude (lon, range) {
  return L.Util.wrapNum(lon, range, true)
}