covjson-reader/reader.js
import ndarray from 'ndarray'
import cbor from 'cbor'
export const PREFIX = 'http://coveragejson.org/def#'
const MEDIA = {
COVCBOR: 'application/prs.coverage+cbor',
COVJSON: 'application/prs.coverage+json',
JSONLD: 'application/ld+json',
JSON: 'application/json',
OCTETSTREAM: 'application/octet-stream',
TEXT: 'text/plain'
}
const ACCEPT = MEDIA.COVCBOR + '; q=1.0, ' +
MEDIA.COVJSON + '; q=0.5, ' +
MEDIA.JSONLD + '; q=0.1, ' +
MEDIA.JSON + '; q=0.1'
const EXT = {
COVJSON: '.covjson',
COVCBOR: '.covcbor'
}
/**
* Reads a CoverageJSON document and returns a {@link Promise} that succeeds with
* a {@link Coverage} object or an array of such.
*
* Note that if the document references external domain or range documents,
* then these are not loaded immediately.
*
*
* @example <caption>ES6 module</caption>
* read('http://example.com/coverage.covjson').then(cov => {
* // work with Coverage object
* }).catch(e => {
* // there was an error when loading the coverage
* console.log(e)
* })
* @example <caption>ES5 global</caption>
* CovJSON.read('http://example.com/coverage.covjson').then(function (cov) {
* // work with Coverage object
* }).catch(function (e) {
* // there was an error when loading the coverage
* console.log(e)
* })
* @param {Object|string} input
* Either a URL pointing to a CoverageJSON Coverage or Coverage Collection document
* or a CoverageJSON Coverage or Coverage Collection object.
* @return {Promise}
* A promise object having a {@link Coverage} object or, for CoverageJSON Coverage Collections,
* an array of {@link Coverage} objects as data. In the error case, an {@link Error} object is supplied
* from the {@link Promise}.
*/
export function read (input) {
if (typeof input === 'object') {
return new Promise(resolve => resolve(transformCovJSON(input)))
} else {
// it's a URL, load it
return load(input).then(transformCovJSON)
}
}
/**
* Transforms a CoverageJSON object into one or more Coverage objects.
*
* @param obj A CoverageJSON object of type Coverage or CoverageCollection.
* @return {Coverage|Array of Coverage}
*/
function transformCovJSON (obj) {
checkValidCovJSON(obj)
if (!endsWith(obj.type, 'Coverage') && obj.type !== 'CoverageCollection') {
throw new Error('CoverageJSON document must be of *Coverage or CoverageCollection type')
}
if (endsWith(obj.type, 'Coverage')) {
var cov = new Coverage(obj)
} else { // Collection
var cov = []
let rootParams = obj.parameters ? obj.parameters : {}
for (let coverage of obj.coverages) {
if (coverage.parameters) {
for (let key of Object.keys(rootParams)) {
if (key in coverage.ranges) {
coverage.parameters[key] = rootParams[key]
}
}
} else {
coverage.parameters = rootParams
}
cov.push(new Coverage(coverage))
}
}
console.log('reading done:', cov)
return cov
}
/**
* Performs basic structural checks to validate whether a given object is a CoverageJSON object.
*
* Note that this method is not comprehensive and should not be used for checking
* whether an object fully conforms to the CoverageJSON specification.
*
* @param obj
* @throws {Error} when obj is not a valid CoverageJSON document
*/
function checkValidCovJSON (obj) {
assert('type' in obj, '"type" missing')
if (endsWith(obj.type, 'Coverage')) {
assert('parameters' in obj, '"parameters" missing')
assert('domain' in obj, '"domain" missing')
assert('ranges' in obj, '"ranges" missing')
} else if (obj.type === 'CoverageCollection') {
assert(Array.isArray(obj.coverages), '"coverages" must be an array')
}
}
function endsWith (subject, search) {
// IE support
let position = subject.length - search.length
let lastIndex = subject.indexOf(search, position)
return lastIndex !== -1 && lastIndex === position
}
/**
* Loads a CoverageJSON document from a given URL and returns a {@link Promise} object
* that succeeds with the unmodified CoverageJSON object.
*
* @param {string} url
* @return {Promise}
* The data is the CoverageJSON object. The promise fails if the resource at
* the given URL is not a valid JSON or CBOR document.
*/
export function load(url, responseType='arraybuffer') {
if (['arraybuffer', 'text'].indexOf(responseType) === -1) {
throw new Error()
}
return new Promise((resolve, reject) => {
var req = new XMLHttpRequest()
req.open('GET', url)
req.responseType = responseType
req.setRequestHeader('Accept', ACCEPT)
req.addEventListener('load', () => {
if (!(req.status >= 200 && req.status < 300 || req.status === 304)) { // as in jquery
reject(new Error('Resource "' + url + '" not found, HTTP status code: ' + req.status))
return
}
var type = req.getResponseHeader('Content-Type')
if (type.indexOf(MEDIA.OCTETSTREAM) === 0 || type.indexOf(MEDIA.TEXT) === 0) {
// wrong media type, try to infer type from extension
if (endsWith(url, EXT.COVJSON)) {
type = MEDIA.COVJSON
} else if (endsWith(url, EXT.COVCBOR)) {
type = MEDIA.COVCBOR
}
}
if (type === MEDIA.COVCBOR) {
var arrayBuffer = req.response
var data = cbor.decode(arrayBuffer)
} else if ([MEDIA.COVJSON, MEDIA.JSONLD, MEDIA.JSON].indexOf(type) > -1) {
if (responseType === 'arraybuffer') {
// load again (from cache) to get correct response type
// Note we use 'text' and not 'json' as we want to throw parsing errors.
// With 'json', the response is just 'null'.
reject({responseType: 'text'})
return
}
var data = JSON.parse(req.response)
} else {
reject(new Error('Unsupported media type: ' + type))
return
}
resolve(data)
})
req.addEventListener('error', () => {
reject(new Error('Network error loading resource at ' + url))
})
req.send()
}).catch(e => {
if (e.responseType) {
return load(url, e.responseType)
} else {
throw e
}
})
}
/**
* Wraps a CoverageJSON Coverage object as a Coverage API object.
*
* @see https://github.com/Reading-eScience-Centre/coverage-jsapi
*
*/
export class Coverage {
/**
* @param {Object} covjson A CoverageJSON Coverage object.
* @param {boolean} cacheRanges
* If true, then any range that was loaded remotely is cached.
* (The domain is always cached.)
*
*/
constructor(covjson, cacheRanges = false) {
this._covjson = covjson
/** @type {boolean} */
this.cacheRanges = cacheRanges
/** @type {Map} */
this.parameters = new Map()
for (let key of Object.keys(covjson.parameters)) {
transformParameter(covjson.parameters, key)
this.parameters.set(key, covjson.parameters[key])
}
/** @type {string} */
this.type = PREFIX + this._covjson.type
// we extract the domain type from the coverage type
// this is possible with CoverageJSON since there is a 1:1 relationship
let withoutSuffix = this._covjson.type.substr(0, this._covjson.type.length - 'Coverage'.length)
/** @type {string} */
this.domainType = PREFIX + withoutSuffix
/**
* A bounding box array with elements [westLon, southLat, eastLon, northLat].
*
* @type {Array|undefined}
*/
this.bbox = this._covjson.bbox
}
/**
* @return {Promise}
*/
loadDomain () {
console.log('loading domain')
let domainOrUrl = this._covjson.domain
if (this._domainPromise) return this._domainPromise
if (typeof domainOrUrl === 'object') {
transformDomain(domainOrUrl)
console.log('loading domain: done (inline)')
var promise = Promise.resolve(domainOrUrl)
} else { // URL
var promise = load(domainOrUrl).then(domain => {
transformDomain(domain)
this._covjson.domain = domain
console.log('loading domain: done (URL)')
return domain
})
}
/* The promise gets cached so that the domain is not loaded twice remotely.
* This might otherwise happen when loadDomain and loadRange is used
* with Promise.all(). Remember that loadRange also invokes loadDomain.
*/
this._domainPromise = promise
return promise
}
/**
* Returns the requested range data as a Promise.
*
* Note that this method implicitly loads the domain as well.
*
* @example
* cov.loadRange('salinity').then(function (sal) {
* // work with Range object
* }).catch(function (e) {
* // there was an error when loading the range
* console.log(e.message)
* })
* @param {string} paramKey The key of the Parameter for which to load the range.
* @return {Promise} A Promise object which loads the requested range data and succeeds with a Range object.
*/
loadRange (paramKey) {
console.log('loading range "' + paramKey + '"')
// Since the shape of the range array is derived from the domain, it has to be loaded as well.
return this.loadDomain().then(domain => {
let rangeOrUrl = this._covjson.ranges[paramKey]
let isCategorical = 'categories' in this.parameters.get(paramKey)
if (typeof rangeOrUrl === 'object') {
transformRange(rangeOrUrl, domain.shape, isCategorical)
console.log('loading range "' + paramKey + '": done (inline)')
return Promise.resolve(rangeOrUrl)
} else { // URL
return load(rangeOrUrl).then(range => {
transformRange(range, domain.shape, isCategorical)
if (this.cacheRanges) {
this._covjson.ranges[paramKey] = range
}
console.log('loading range "' + paramKey + '": done (URL)')
return range
})
}
})
}
/**
* Returns the requested range data as a Promise.
*
* Note that this method implicitly loads the domain as well.
*
* @example
* cov.loadRanges(['salinity','temp']).then(function (ranges) {
* // work with Map object
* console.log(ranges.get('salinity').values)
* }).catch(function (e) {
* // there was an error when loading the range data
* console.log(e)
* })
* @param {iterable} [paramKeys] An iterable of parameter keys for which to load the range data. If not given, loads all range data.
* @return {Promise} A Promise object which loads the requested range data and succeeds with a Map object.
*/
loadRanges (paramKeys) {
if (paramKeys === undefined) paramKeys = this.parameters.keys()
paramKeys = Array.from(paramKeys)
return Promise.all(paramKeys.map(k => this.loadRange(k))).then(ranges => {
let map = new Map()
for (let i=0; i < paramKeys.length; i++) {
map.set(paramKeys[i], ranges[i])
}
return map
})
}
}
/**
* Currently unused, but may need in future.
* This determines the best array type for categorical data which
* doesn't have missing values.
*/
function arrayType(validMin, validMax) {
let type
if (validMin !== undefined) {
if (validMin >= 0) {
if (validMax < Math.pow(2,8)) {
type = Uint8Array
} else if (validMax < Math.pow(2,16)) {
type = Uint16Array
} else if (validMax < Math.pow(2,32)) {
type = Uint32Array
} else {
type = Array
}
} else {
let max = Math.max(Math.abs(validMin), validMax)
if (max < Math.pow(2,8)) {
type = Int8Array
} else if (validMax < Math.pow(2,16)) {
type = Int16Array
} else if (validMax < Math.pow(2,32)) {
type = Int32Array
} else {
type = Array
}
}
} else {
type = Array
}
return type
}
/**
* Transforms a CoverageJSON parameter to the Coverage API format, that is,
* language maps become real Maps. Transformation is made in-place.
*
* @param {Object} param The original parameter.
*/
function transformParameter (params, key) {
let param = params[key]
param.key = key
let maps = [
[param, 'description'],
[param.observedProperty, 'label'],
[param.observedProperty, 'description'],
[param.unit, 'label']
]
for (let cat of param.categories || []) {
maps.push([cat, 'label'])
maps.push([cat, 'description'])
}
for (let entry of maps) {
transformLanguageMap(entry[0], entry[1])
}
}
function transformLanguageMap (obj, key) {
if (!obj || !(key in obj)) {
return
}
var map = new Map()
for (let tag of Object.keys(obj[key])) {
map.set(tag, obj[key][tag])
}
obj[key] = map
}
/**
* Transforms a CoverageJSON range to the Coverage API format, that is,
* no special encoding etc. is left. Transformation is made in-place.
*
* @param {Object} range The original range.
* @param {Array} shape The array shape of the range values as determined by the domain.
* @param {bool} isCategorical
* Whether the range represents categories and should be treated as integers.
* This hint is currently not used. It may come in handy for typed arrays later.
* @return {Object} The transformed range.
*/
function transformRange (range, shape, isCategorical) {
if ('__transformDone' in range) return
const values = range.values
const isTyped = ArrayBuffer.isView(values)
const missingIsEncoded = range.missing === 'nonvalid'
const hasOffsetFactor = 'offset' in range
if ('offset' in range) {
assert('factor' in range)
}
const offset = range.offset
const factor = range.factor
if (missingIsEncoded) {
assert('validMin' in range)
assert('validMax' in range)
}
const validMin = range.validMin
const validMax = range.validMax
let vals
if (!missingIsEncoded && !hasOffsetFactor) {
// No transformation necessary.
vals = values
} else {
// Transformation is necessary.
// we use a regular array so that missing values can be represented as null
vals = new Array(values.length)
// TODO can we use typed arrays here without having to scan for missing values first?
// When typed arrays with missing value encoding was used we could keep that and provide
// a higher abstraction on the array similar to an ndarray interface. This means that [] syntax
// would be impossible and change to .get(index).
if (hasOffsetFactor) {
for (let i=0; i < values.length; i++) {
const val = values[i]
if (missingIsEncoded && (val < validMin || val > validMax)) {
// This is necessary as the default value is "undefined".
vals[i] = null
} else if (!missingIsEncoded && val === null) {
vals[i] = null
} else {
vals[i] = val * factor + offset
}
}
if (validMin !== undefined) {
range.validMin = validMin * factor + offset
range.validMax = validMax * factor + offset
}
} else { // missingIsEncoded == true
for (let i=0; i < values.length; i++) {
const val = values[i]
if (val < validMin || val > validMax) {
vals[i] = null
} else {
vals[i] = val
}
}
}
delete range.offset
delete range.factor
delete range.missing
}
if (validMin === undefined) {
[min,max] = minMax(vals)
if (min !== null) {
range.validMin = min
range.validMax = max
}
}
range.values = ndarray(vals, shape)
range.__transformDone = true
return range
}
function minMax (arr) {
var len = arr.length
var min = Infinity
var max = -Infinity
while (len--) {
var el = arr[len]
if (el == null) {
// do nothing
} else if (el < min) {
min = el
} else if (el > max) {
max = el
}
}
if (min === Infinity) {
min = max
} else if (max === -Infinity) {
max = min
}
if (min === Infinity) {
// all values were null
min = null
max = null
}
return [min, max]
}
/**
* Transforms a CoverageJSON domain to the Coverage API format.
* Transformation is made in-place.
*
* @param {Object} domain The original domain object.
* @return {Object} The transformed domain object.
*/
function transformDomain (domain) {
if ('__transformDone' in domain) return
let type = domain.type
let x = axisSize(domain.x)
let y = axisSize(domain.y)
let z = axisSize(domain.z)
let t = axisSize(domain.t)
domain.type = PREFIX + type
const T = 't'
const Z = 'z'
const Y = 'y'
const X = 'z'
const P = 'p'
const SEQ = 'seq'
let shape
let names
switch (type) {
case 'Grid':
shape = [t,z,y,x]; names = [T,Z,Y,X]; break
case 'Profile':
shape = [z]; names = [Z]; break
case 'PointSeries':
shape = [t]; names = [T]; break
case 'Point':
shape = [1]; names = [P]; break
case 'Trajectory':
assert(x === y && y === t, 'Trajectory cannot have x, y, t arrays of different lengths')
assert(!Array.isArray(domain.z) || x === z, 'Trajectory z array must be of same length as x, y, t arrays')
let seq = domain.sequence.join('')
assert((Array.isArray(domain.z) && seq === 'xyzt') || (!Array.isArray(domain.z) && seq === 'xyt'),
'Trajectory must have "sequence" property ["x","y","t"] or ["x","y","z","t"]')
shape = [x]; names = [SEQ]; break
case 'Section':
assert(x === y && y === t, 'Section cannot have x, y, t arrays of different lengths')
assert(domain.sequence.join('') === 'xyt', 'Section must have "sequence" property ["x","y","t"]')
shape = [z,x]; names = [Z,SEQ]; break
case 'Polygon':
shape = [1]; names = [P]; break
case 'PolygonSeries':
shape = [t]; names = [T]; break
case 'MultiPolygon':
shape = [axisSize(domain.polygon)]; names = [P]; break
case 'MultiPolygonSeries':
shape = [t,axisSize(domain.polygon)]; names = [T,P]; break
default:
throw new Error('Unknown domain type: ' + type)
}
domain.shape = shape
// replace 1D numeric axis arrays with typed arrays for efficiency
for (let field of ['x', 'y', 'z', 't']) {
if (field in domain) {
let axis = domain[field]
if (ArrayBuffer.isView(axis)) {
// already a typed array
continue
}
if (Array.isArray(axis) && typeof axis[0] === 'number') {
let arr = new Float64Array(axis.length)
for (let i=0; i < axis.length; i++) {
arr[i] = axis[i]
}
domain[field] = arr
}
}
}
domain.__transformDone = true
return domain
}
/**
*
* @param {Array|scalar|undefined} axis
* @returns the elements within the axis or 1 if not defined
*/
function axisSize(axis) {
if (Array.isArray(axis)) {
return axis.length
}
return 1
}
function assert (condition, message) {
if (!condition) {
message = message || 'Assertion failed'
throw new Error(message)
}
}