// 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' }; }