diff --git a/src/client.js b/src/client.js index 2808d4a..1bbb9f5 100644 --- a/src/client.js +++ b/src/client.js @@ -1,6 +1,7 @@ import * as API from './api.js' import * as arrays from './arrays.js' import {shallowcopy, mergeInto} from './util.js' +import {isISODateAxis, isLongitudeAxis, getLongitudeWrapper} from './referencing.js' // Note: We currently can't handle Hydra data in non-default graphs due to lack of support in JSON-LD framing. @@ -299,6 +300,8 @@ function wrappedSubsetByValue (coverage, wrappedCoverage, api, wrapOptions) { * A safe start/stop would then be a newly calculated axis value which is in the middle * of the bounds. */ + + // TODO if API axis-value-subsetting is not supported, try to emulate with API axis-index-subsetting // we split the subsetting constraints into API-compatible and local ones let apiConstraints = { @@ -310,7 +313,7 @@ function wrappedSubsetByValue (coverage, wrappedCoverage, api, wrapOptions) { let useApi = false let constraint = constraints[axis] let cap = caps[axisMap[axis]] - let isTimeString = axisMap[axis] === 'time' + let isTimeString = isISODateAxis(domain, axis) if (!cap) { // leave useApi = false @@ -319,8 +322,7 @@ function wrappedSubsetByValue (coverage, wrappedCoverage, api, wrapOptions) { useApi = true } else if (cap.start && cap.stop) { // emulate identity match via start/stop if we find a matching axis value - // FIXME handle longitude wrapping - let idx = getClosestIndex(domain, axis, constraint.target, isTimeString) + let {idx} = getClosestIndex(domain, axis, constraint.target) let val = domain.axes.get(axis).values[idx] if (isTimeString) { if (new Date(val).getTime() === new Date(constraint).getTime()) { @@ -337,15 +339,33 @@ function wrappedSubsetByValue (coverage, wrappedCoverage, api, wrapOptions) { useApi = true } else if (cap.start && cap.stop) { // emulate target via start/stop - // FIXME handle longitude wrapping - let idx = getClosestIndex(domain, axis, constraint.target, isTimeString) - let val = domain.axes.get(axis).values[idx] - constraint = {start: val, stop: val} - useApi = true + let {idx,outside} = getClosestIndex(domain, axis, constraint.target) + // if the target is outside the axis extent then the API can't be used (as there is no intersection then) + if (!outside) { + let val = domain.axes.get(axis).values[idx] + constraint = {start: val, stop: val} + useApi = true + } } } else { // start / stop useApi = cap.start && cap.stop + + if (useApi) { + // TODO handle bounds + + // snap start/stop to axis values to increase the chance of using a cached request + let [vals, start, stop] = prepareForAxisArraySearch(domain, axis, constraint.start, constraint.stop) + let resStart = getClosestIndexArr(vals, start) + let resStop = getClosestIndexArr(vals, stop) + if (resStart.outside && resStop.outside) { + // if both start and stop are outside the axis extent, then snapping would be wrong (has to be an error) + throw new Error('start or stop must be inside the axis extent') + } else { + let axisVals = domain.axes.get(axis).values + constraint = {start: axisVals[resStart.idx], stop: axisVals[resStop.idx]} + } + } } if (useApi) { @@ -436,15 +456,30 @@ function toLocalConstraintsIfDependencyMissing (apiConstraints, localConstraints } } -function getClosestIndex (domain, axis, val, isTimeString) { - let vals = domain.axes.get(axis).values - if (isTimeString) { +function getClosestIndex (domain, axis, val) { + let [axisVals, searchVal] = prepareForAxisArraySearch(domain, axis, val) + return getClosestIndexArr(axisVals, searchVal) +} + +function getClosestIndexArr (vals, val) { + let [lo,hi] = arrays.indicesOfNearest(vals, val) + let idx = Math.abs(val - vals[lo]) <= Math.abs(val - vals[hi]) ? lo : hi + return {idx, outside: lo === hi} +} + +function prepareForAxisArraySearch (domain, axis, ...searchVal) { + let axisVals = domain.axes.get(axis).values + searchVal = [...searchVal] // to array + if (isISODateAxis(domain, axis)) { // convert to unix timestamps as we need numbers - val = new Date(val).getTime() - vals = vals.map(t => new Date(t).getTime()) + let toUnix = v => new Date(v).getTime() + searchVal = searchVal.map(toUnix) + axisVals = axisVals.map(toUnix) + } else if (isLongitudeAxis(domain, axis)) { + let lonWrapper = getLongitudeWrapper(domain, axis) + searchVal = searchVal.map(lonWrapper) } - let idx = arrays.indexOfNearest(vals, val) - return idx + return [axisVals, ...searchVal] } /** diff --git a/src/referencing.js b/src/referencing.js new file mode 100644 index 0000000..3bf5b14 --- /dev/null +++ b/src/referencing.js @@ -0,0 +1,111 @@ +// COPIED FROM covjson-reader -> DRY!!!!! + +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 +} + +/** + * 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) { + // 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()) +} + +/** + * Return the reference system connection object for the given domain component, + * or undefined if none exists. + */ +function getReferenceObject (domain, component) { + let ref = domain.referencing.find(ref => ref.components.indexOf(component) !== -1) + return ref +}