Home Reference Source Repository

src/domain/referencing.js

import * as uriproj from 'uriproj'
import { COVJSON_DATATYPE_TUPLE, COVJSON_DATATYPE_POLYGON } from '../constants.js'

const OPENGIS_CRS_PREFIX = 'http://www.opengis.net/def/crs/'

/** 3D WGS84 in lat-lon-height order */
const EPSG4979 = OPENGIS_CRS_PREFIX + 'EPSG/0/4979'

/** 2D WGS84 in lat-lon order */
const EPSG4326 = OPENGIS_CRS_PREFIX + 'EPSG/0/4326'

/** 2D WGS84 in lon-lat order */
const CRS84 = OPENGIS_CRS_PREFIX + 'OGC/1.3/CRS84'

/** CRSs in which position is specified by geodetic latitude and longitude */
const EllipsoidalCRSs = [EPSG4979, EPSG4326, CRS84]

/** Position of longitude axis */
const LongitudeAxisIndex = {
  [EPSG4979]: 1,
  [EPSG4326]: 1,
  [CRS84]: 0
}

/**
 * Return the reference system connection object for the given domain component,
 * or undefined if none exists.
 */
export function getReferenceObject (domain, component) {
  let ref = domain.referencing.find(ref => ref.components.indexOf(component) !== -1)
  return ref
}

/**
 * Return the reference system connection object of the horizontal CRS of the domain,
 * or ``undefined`` if none found.
 * A horizontal CRS is either geodetic (typically ellipsoidal, meaning lat/lon)
 * or projected, and may be 2D or 3D (including height).
 */
export function getHorizontalCRSReferenceObject (domain) {
  let isHorizontal = ref =>
    ['GeodeticCRS', 'ProjectedCRS'].indexOf(ref.system.type) !== -1
  let ref = domain.referencing.find(isHorizontal)
  return ref
}

/**
 * Return whether the reference system is a CRS in which
 * horizontal position is specified by geodetic latitude and longitude.
 */
export function isEllipsoidalCRS (rs) {
  // TODO should support unknown CRSs with embedded axis information
  // this also covers the case when there is no ID property
  return EllipsoidalCRSs.indexOf(rs.id) !== -1
}

/**
 * Return a projection object based on the CRS found in the coverage domain.
 * If no CRS is found or it is unsupported, then ``undefined`` is returned.
 * For non-built-in projections, this function returns already-cached projections
 * that were loaded via {@link loadProjection}.
 *
 * A projection converts between geodetic lat/lon and projected x/y values.
 *
 * For lat/lon CRSs the projection is defined such that an input lat/lon
 * position gets projected/wrapped to the longitude range used in the domain, for example
 * [0,360]. The purpose of this is to make intercomparison between different coverages easier.
 *
 * The following limitations currently apply:
 * - only primitive axes and Tuple/Polygon composite axes are supported for lat/lon CRSs
 *
 * @param {Domain} domain A coverage domain object.
 * @return {IProjection} A stripped-down Leaflet IProjection object.
 */
export function getProjection (domain) {
  let isEllipsoidal = domain.referencing.some(ref => isEllipsoidalCRS(ref.system))
  if (isEllipsoidal) {
    return getLonLatProjection(domain)
  }

  // try to get projection via uriproj library
  let ref = getHorizontalCRSReferenceObject(domain)
  if (!ref) {
    throw new Error('No horizontal CRS found in coverage domain')
  }

  let uri = ref.system.id
  let proj = uriproj.get(uri)
  if (!proj) {
    throw new Error('Projection ' + uri + ' not cached in uriproj, use loadProjection() instead')
  }
  return wrapProj4(proj)
}

/**
 * Like {@link getProjection} but will also try to remotely load a projection definition via the uriproj library.
 * On success, the loaded projection is automatically cached for later use and can be directly requested
 * with {@link getProjection}.
 *
 * @param {Domain} domain A coverage domain object.
 * @return {Promise<IProjection>} A Promise succeeding with a stripped-down Leaflet IProjection object.
 */
export function loadProjection (domain) {
  try {
    // we try the local one first so that we get our special lon/lat projection (which doesn't exist in uriproj)
    return getProjection(domain)
  } catch (e) {}

  // try to load projection remotely via uriproj library
  let ref = getHorizontalCRSReferenceObject(domain)
  if (!ref) {
    throw new Error('No horizontal CRS found in coverage domain')
  }

  let uri = ref.system.id
  return uriproj.load(uri).then(proj => wrapProj4(proj))
}

/**
 * Return the component names of the horizontal CRS of the domain.
 *
 * @example
 * var [xComp,yComp] = getHorizontalCRSComponents(domain)
 */
export function getHorizontalCRSComponents (domain) {
  let ref = getHorizontalCRSReferenceObject(domain)
  return ref.components
}

/**
 * Wraps a proj4 Projection object into an IProjection object.
 */
function wrapProj4 (proj) {
  return {
    project: ({lon, lat}) => {
      let [x, y] = proj.forward([lon, lat])
      return {x, y}
    },
    unproject: ({x, y}) => {
      let [lon, lat] = proj.inverse([x, y])
      return {lon, lat}
    }
  }
}

function getLonLatProjection (domain) {
  let ref = domain.referencing.find(ref => isEllipsoidalCRS(ref.system))
  let lonIdx = LongitudeAxisIndex[ref.system.id]
  if (lonIdx > 1) {
    // this should never happen as longitude is always the first or second axis
    throw new Error()
  }

  let lonComponent = ref.components[lonIdx]

  // we find the min and max longitude occuring in the domain by inspecting the axis values
  // Note: this is inefficient for big composite axes.
  //       In that case, something like a domain extent might help which has the min/max values for each component.
  // TODO handle bounds
  let lonMin, lonMax
  if (domain.axes.has(lonComponent)) {
    // longitude is a grid axis
    let lonAxisName = lonComponent
    let lonAxisVals = domain.axes.get(lonAxisName).values
    lonMin = lonAxisVals[0]
    lonMax = lonAxisVals[lonAxisVals.length - 1]
    if (lonMin > lonMax) {
      [lonMin, lonMax] = [lonMax, lonMin]
    }
  } else {
    // TODO there should be no dependency to CovJSON

    // longitude is not a primitive grid axis but a component of a composite axis

    // find the composite axis containing the longitude component
    let axes = [...domain.axes.values()]
    let axis = axes.find(axis => axis.components.indexOf(lonComponent) !== -1)
    let lonCompIdx = axis.components.indexOf(lonComponent)

    // scan the composite axis for min/max longitude values
    lonMin = Infinity
    lonMax = -Infinity
    if (axis.dataType === COVJSON_DATATYPE_TUPLE) {
      for (let tuple of axis.values) {
        let lon = tuple[lonCompIdx]
        lonMin = Math.min(lon, lonMin)
        lonMax = Math.max(lon, lonMax)
      }
    } else if (axis.dataType === COVJSON_DATATYPE_POLYGON) {
      for (let poly of axis.values) {
        for (let ring of poly) {
          for (let point of ring) {
            let lon = point[lonCompIdx]
            lonMin = Math.min(lon, lonMin)
            lonMax = Math.max(lon, lonMax)
          }
        }
      }
    } else {
      throw new Error('Unsupported data type: ' + axis.dataType)
    }
  }

  let lonMid = (lonMax + lonMin) / 2
  let lonMinExtended = lonMid - 180
  let lonMaxExtended = lonMid + 180

  return {
    project: ({lon, lat}) => {
      let lonProjected
      if (lonMinExtended <= lon && lon <= lonMaxExtended) {
        // use unchanged to avoid introducing rounding errors
        lonProjected = lon
      } else {
        lonProjected = ((lon - lonMinExtended) % 360 + 360) % 360 + lonMinExtended
      }

      let [x, y] = lonIdx === 0 ? [lonProjected, lat] : [lat, lonProjected]
      return {x, y}
    },
    unproject: ({x, y}) => {
      let [lon, lat] = lonIdx === 0 ? [x, y] : [y, x]
      return {lon, lat}
    }
  }
}

/**
 * Reprojects coordinates from one projection to another.
 */
export function reprojectCoords (pos, fromProjection, toProjection) {
  return toProjection.project(fromProjection.unproject(pos))
}

/**
 * Returns a function which converts an arbitrary longitude to the
 * longitude extent used in the coverage domain.
 * This only supports primitive axes since this is what subsetByValue supports.
 * The longitude extent is extended to 360 degrees if the actual extent is smaller.
 * The extension is done equally on both sides of the extent.
 *
 * For example, the domain may have longitudes within [0,360].
 * An input longitude of -70 is converted to 290.
 * All longitudes within [0,360] are returned unchanged.
 *
 * If the domain has longitudes within [10,50] then the
 * extended longitude range is [-150,210] (-+180 from the middle point).
 * An input longitude of -170 is converted to 190.
 * All longitudes within [-150,210] are returned unchanged.
 *
 * @ignore
 */
export function getLongitudeWrapper (domain, axisName) {
  // TODO deprecate this in favour of getProjection, check leaflet-coverage

  // for primitive axes, the axis identifier = component identifier
  if (!isLongitudeAxis(domain, axisName)) {
    throw new Error(`'${axisName}' is not a longitude axis`)
  }

  let vals = domain.axes.get(axisName).values
  let lon_min = vals[0]
  let lon_max = vals[vals.length - 1]
  if (lon_min > lon_max) {
    [lon_min, lon_max] = [lon_max, lon_min]
  }

  let x_mid = (lon_max + lon_min) / 2
  let x_min = x_mid - 180
  let x_max = x_mid + 180

  return lon => {
    if (x_min <= lon && lon <= x_max) {
      // directly return to avoid introducing rounding errors
      return lon
    } else {
      return ((lon - x_min) % 360 + 360) % 360 + x_min
    }
  }
}

/**
 * Return whether the given domain axis represents longitudes.
 *
 * @ignore
 */
export function isLongitudeAxis (domain, axisName) {
  let ref = getReferenceObject(domain, axisName)
  if (!ref) {
    return false
  }

  let crsId = ref.system.id
  // TODO should support unknown CRSs with embedded axis information
  if (EllipsoidalCRSs.indexOf(crsId) === -1) {
    // this also covers the case when there is no ID property
    return false
  }

  let compIdx = ref.components.indexOf(axisName)
  let isLongitude = LongitudeAxisIndex[crsId] === compIdx
  return isLongitude
}

/**
 * Returns true if the given axis has ISO8601 date strings
 * as axis values.
 */
export function isISODateAxis (domain, axisName) {
  let val = domain.axes.get(axisName).values[0]
  if (typeof val !== 'string') {
    return false
  }
  return !isNaN(new Date(val).getTime())
}

export function asTime (inp) {
  let res
  let err = false
  if (typeof inp === 'string') {
    res = new Date(inp).getTime()
  } else if (inp instanceof Date) {
    res = inp.getTime()
  } else {
    err = true
  }
  if (isNaN(res)) {
    err = true
  }
  if (err) {
    throw new Error('Invalid date: ' + inp)
  }
  return res
}