@loaders.gl/mvt
Version:
Loader for Mapbox Vector Tiles
268 lines (267 loc) • 8.8 kB
JavaScript
// loaders.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
import { getSchemaFromTileJSONLayer } from "./get-schemas-from-tilejson.js";
const isObject = (x) => x !== null && typeof x === 'object';
/**
* Parse TileJSON from metadata
* @param jsonMetadata - metadata object
* @param options - options
* @returns - parsed TileJSON
*/
// eslint-disable-next-line complexity
export function parseTileJSON(jsonMetadata, options) {
if (!jsonMetadata || !isObject(jsonMetadata)) {
return null;
}
let tileJSON = {
name: jsonMetadata.name || '',
description: jsonMetadata.description || ''
};
// tippecanoe
if (typeof jsonMetadata.generator === 'string') {
tileJSON.generator = jsonMetadata.generator;
}
if (typeof jsonMetadata.generator_options === 'string') {
tileJSON.generatorOptions = jsonMetadata.generator_options;
}
// Tippecanoe emits `antimeridian_adjusted_bounds` instead of `bounds`
tileJSON.boundingBox =
parseBounds(jsonMetadata.bounds) || parseBounds(jsonMetadata.antimeridian_adjusted_bounds);
// TODO - can be undefined - we could set to center of bounds...
tileJSON.center = parseCenter(jsonMetadata.center);
// TODO - can be undefined, we could extract from layers...
tileJSON.maxZoom = safeParseFloat(jsonMetadata.maxzoom);
// TODO - can be undefined, we could extract from layers...
tileJSON.minZoom = safeParseFloat(jsonMetadata.minzoom);
// Look for nested metadata embedded in .json field
// TODO - document what source this applies to, when is this needed?
if (typeof jsonMetadata?.json === 'string') {
// try to parse json
try {
tileJSON.metaJson = JSON.parse(jsonMetadata.json);
}
catch (error) {
// eslint-disable-next-line no-console
console.warn('Failed to parse tilejson.json field', error);
// do nothing
}
}
// Look for fields in tilestats
const tilestats = jsonMetadata.tilestats || tileJSON.metaJson?.tilestats;
const tileStatsLayers = parseTilestatsLayers(tilestats, options);
const tileJSONlayers = parseTileJSONLayers(jsonMetadata.vector_layers); // eslint-disable-line camelcase
// TODO - merge in description from tilejson
const layers = mergeLayers(tileJSONlayers, tileStatsLayers);
tileJSON = {
...tileJSON,
layers
};
if (tileJSON.maxZoom === null && layers.length > 0) {
tileJSON.maxZoom = layers[0].maxZoom || null;
}
if (tileJSON.minZoom === null && layers.length > 0) {
tileJSON.minZoom = layers[0].minZoom || null;
}
return tileJSON;
}
function parseTileJSONLayers(layers) {
// Look for fields in vector_layers
if (!Array.isArray(layers)) {
return [];
}
return layers.map((layer) => parseTileJSONLayer(layer));
}
function parseTileJSONLayer(layer) {
const fields = Object.entries(layer.fields || []).map(([key, datatype]) => ({
name: key,
...attributeTypeToFieldType(String(datatype))
}));
const layer2 = { ...layer };
delete layer2.fields;
return {
name: layer.id || '',
...layer2,
fields
};
}
/** parse Layers array from tilestats */
function parseTilestatsLayers(tilestats, options) {
if (isObject(tilestats) && Array.isArray(tilestats.layers)) {
// we are in luck!
return tilestats.layers.map((layer) => parseTilestatsForLayer(layer, options));
}
return [];
}
function parseTilestatsForLayer(layer, options) {
const fields = [];
const indexedAttributes = {};
const attributes = layer.attributes || [];
for (const attribute of attributes) {
const name = attribute.attribute;
if (typeof name === 'string') {
// TODO - code copied from kepler.gl, need sample tilestats files to test
if (name.split('|').length > 1) {
// indexed field
const fname = name.split('|')[0];
indexedAttributes[fname] = indexedAttributes[fname] || [];
indexedAttributes[fname].push(attribute);
// eslint-disable-next-line no-console
console.warn('ignoring tilestats indexed field', fname);
}
else if (!fields[name]) {
fields.push(attributeToField(attribute, options));
}
else {
// return (fields[name], attribute);
}
}
}
return {
name: layer.layer || '',
dominantGeometry: layer.geometry,
fields
};
}
function mergeLayers(layers, tilestatsLayers) {
return layers.map((layer) => {
const tilestatsLayer = tilestatsLayers.find((tsLayer) => tsLayer.name === layer.name);
const fields = tilestatsLayer?.fields || layer.fields || [];
const mergedLayer = {
...layer,
...tilestatsLayer,
fields
};
mergedLayer.schema = getSchemaFromTileJSONLayer(mergedLayer);
return mergedLayer;
});
}
/**
* bounds should be [minLng, minLat, maxLng, maxLat]
*`[[w, s], [e, n]]`, indicates the limits of the bounding box using the axis units and order of the specified CRS.
*/
function parseBounds(bounds) {
// supported formats
// string: "-96.657715,40.126127,-90.140061,43.516689",
// array: [ -180, -85.05112877980659, 180, 85.0511287798066 ]
const result = fromArrayOrString(bounds);
// validate bounds
if (Array.isArray(result) &&
result.length === 4 &&
[result[0], result[2]].every(isLng) &&
[result[1], result[3]].every(isLat)) {
return [
[result[0], result[1]],
[result[2], result[3]]
];
}
return undefined;
}
function parseCenter(center) {
// supported formats
// string: "-96.657715,40.126127,-90.140061,43.516689",
// array: [-91.505127,41.615442,14]
const result = fromArrayOrString(center);
if (Array.isArray(result) &&
result.length === 3 &&
isLng(result[0]) &&
isLat(result[1]) &&
isZoom(result[2])) {
return result;
}
return null;
}
function safeParseFloat(input) {
const result = typeof input === 'string' ? parseFloat(input) : typeof input === 'number' ? input : null;
return result === null || isNaN(result) ? null : result;
}
// https://github.com/mapbox/tilejson-spec/tree/master/2.2.0
function isLat(num) {
return Number.isFinite(num) && num <= 90 && num >= -90;
}
function isLng(num) {
return Number.isFinite(num) && num <= 180 && num >= -180;
}
function isZoom(num) {
return Number.isFinite(num) && num >= 0 && num <= 22;
}
function fromArrayOrString(data) {
if (typeof data === 'string') {
return data.split(',').map(parseFloat);
}
else if (Array.isArray(data)) {
return data;
}
return null;
}
// possible types https://github.com/mapbox/tippecanoe#modifying-feature-attributes
const attrTypeMap = {
number: {
type: 'float32'
},
numeric: {
type: 'float32'
},
string: {
type: 'utf8'
},
vachar: {
type: 'utf8'
},
float: {
type: 'float32'
},
int: {
type: 'int32'
},
int4: {
type: 'int32'
},
boolean: {
type: 'boolean'
},
bool: {
type: 'boolean'
}
};
function attributeToField(attribute = {}, options) {
const fieldTypes = attributeTypeToFieldType(attribute.type);
const field = {
name: attribute.attribute,
// what happens if attribute type is string...
// filterProps: getFilterProps(fieldTypes.type, attribute),
...fieldTypes
};
// attribute: "_season_peaks_color"
// count: 1000
// max: 0.95
// min: 0.24375
// type: "number"
if (typeof attribute.min === 'number') {
field.min = attribute.min;
}
if (typeof attribute.max === 'number') {
field.max = attribute.max;
}
if (typeof attribute.count === 'number') {
field.uniqueValueCount = attribute.count;
}
if (attribute.values) {
// Too much data? Add option?
field.values = attribute.values;
}
if (field.values && typeof options.maxValues === 'number') {
// Too much data? Add option?
field.values = field.values?.slice(0, options.maxValues);
}
return field;
}
function attributeTypeToFieldType(aType) {
const type = aType.toLowerCase();
if (!type || !attrTypeMap[type]) {
// console.warn(
// `cannot convert attribute type ${type} to loaders.gl data type, use string by default`
// );
}
return attrTypeMap[type] || { type: 'string' };
}