@loaders.gl/mvt
Version:
Loader for Mapbox Vector Tiles
434 lines (383 loc) • 12.7 kB
text/typescript
// loaders.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
import {Schema} from '@loaders.gl/schema';
import {getSchemaFromTileJSONLayer} from './get-schemas-from-tilejson';
export type TileJSONOptions = {
/** max number of values. If not provided, include all values in the source tilestats */
maxValues?: number;
};
/** Parsed and typed TileJSON, merges Tilestats information if present */
export type TileJSON = {
/** Name of the tileset (for presentation in UI) */
name?: string;
/** A description of the contents or purpose of the tileset */
description?: string;
/** The version of the tileset */
version?: string;
tileFormat?: string;
tilesetType?: string;
/** Generating application. Tippecanoe adds this. */
generator?: string;
/** Generating application options. Tippecanoe adds this. */
generatorOptions?: string;
/** Tile indexing scheme */
scheme?: 'xyz' | 'tms';
/** Sharded URLs */
tiles?: string[];
/** `[[w, s], [e, n]]`, indicates the limits of the bounding box using the axis units and order of the specified CRS. */
boundingBox?: [min: [w: number, s: number], max: [e: number, n: number]];
/** May be set to the maxZoom of the first layer */
maxZoom?: number | null;
/** May be set to the minZoom of the first layer */
minZoom?: number | null;
center?: number[] | null;
htmlAttribution?: string;
htmlLegend?: string;
// Combination of tilestats (if present) and tilejson layer information
layers?: TileJSONLayer[];
/** Any nested JSON metadata */
metaJson?: any | null;
};
export type TileJSONLayer = {
/** The name (id) of this layer (tilejson.vector_layers[].id / tilestats.layers[].layer) */
name: string;
/** The description of this layer (tilejson.layer.description) */
description?: string;
// tilestats
/** The number of features in this layer (tilestats.layer.count) */
featureCount?: number;
/** The dominant geometry type in this layer (tilestats.layer.geometry) */
dominantGeometry?: string;
/** An array of details about the first 100 attributes in this layer */
/** */
minZoom?: number;
maxZoom?: number;
fields: TileJSONField[];
schema?: Schema;
};
export type TileJSONField = {
/** The name of this attribute */
name: string;
description?: string;
// tilestats
type: string;
/** min value (if there are *any* numbers in the values) */
min?: number;
/** max value (if there are *any* numbers in the values) */
max?: number;
/** Number of unique values across the tileset */
uniqueValueCount?: number;
/** An array of this attribute's first 100 unique values */
values?: unknown[];
};
/**
* The raw/unparsed tilestats layer type
* @see https://github.com/mapbox/mapbox-geostats#output-the-stats
*/
type TilestatsLayer = {
/** The name of this layer */
layer: string;
/** The number of features in this layer */
count: number;
/** The dominant geometry type in this layer */
geometry: string;
/** The number of unique attributes in this layer (max. 1000) */
attributeCount: number;
/** Fields for this layer */
attributes?: TilestatsLayerAttribute[];
};
/**
* The raw/unparsed tilestats attribute type
* @see https://github.com/mapbox/mapbox-geostats#output-the-stats
*/
type TilestatsLayerAttribute = {
/** The name of this layer */
attribute?: string;
/** Each attribute has one of the following types:
* - 'string' if all its values are strings (or null).
* - 'number' if all its values are numbers (or null).
* - 'boolean' if all its values are booleans (or null).
* - 'null' if its only value is null.
* - 'mixed' if it has values of multiple types.
* - Array and object values are coerced to strings.
*/
type?: string;
/** min value (if there are *any* numbers in the values) */
min?: number;
/** max value (if there are *any* numbers in the values) */
max?: number;
/** Number of unique values */
count?: number;
/** First 100 values */
values?: unknown[];
};
const isObject: (x: unknown) => boolean = (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: any, options: TileJSONOptions): TileJSON | null {
if (!jsonMetadata || !isObject(jsonMetadata)) {
return null;
}
let tileJSON: 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: any[]): TileJSONLayer[] {
// Look for fields in vector_layers
if (!Array.isArray(layers)) {
return [];
}
return layers.map((layer) => parseTileJSONLayer(layer));
}
function parseTileJSONLayer(layer: any): TileJSONLayer {
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: any, options: TileJSONOptions): TileJSONLayer[] {
if (isObject(tilestats) && Array.isArray(tilestats.layers)) {
// we are in luck!
return tilestats.layers.map((layer) => parseTilestatsForLayer(layer, options));
}
return [];
}
function parseTilestatsForLayer(layer: TilestatsLayer, options: TileJSONOptions): TileJSONLayer {
const fields: TileJSONField[] = [];
const indexedAttributes: {[key: string]: TilestatsLayerAttribute[]} = {};
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: TileJSONLayer[], tilestatsLayers: TileJSONLayer[]): TileJSONLayer[] {
return layers.map((layer: TileJSONLayer): TileJSONLayer => {
const tilestatsLayer = tilestatsLayers.find((tsLayer) => tsLayer.name === layer.name);
const fields = tilestatsLayer?.fields || layer.fields || [];
const mergedLayer = {
...layer,
...tilestatsLayer,
fields
} as TileJSONLayer;
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: string | number[]
): [[east: number, south: number], [west: number, north: number]] | undefined {
// 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: string | number[]): number[] | null {
// 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: unknown): number | null {
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: any): boolean {
return Number.isFinite(num) && num <= 90 && num >= -90;
}
function isLng(num: any): boolean {
return Number.isFinite(num) && num <= 180 && num >= -180;
}
function isZoom(num: any): boolean {
return Number.isFinite(num) && num >= 0 && num <= 22;
}
function fromArrayOrString(data: string | number[]): number[] | null {
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: TilestatsLayerAttribute = {},
options: TileJSONOptions
): TileJSONField {
const fieldTypes = attributeTypeToFieldType(attribute.type!);
const field: TileJSONField = {
name: attribute.attribute as string,
// 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: string): {type: string} {
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'};
}