// loaders.gl // SPDX-License-Identifier: MIT // Copyright vis.gl contributors // This code is forked from https://github.com/mapbox/vector-tile-js under BSD 3-clause license. import type {Feature, FlatFeature, FlatIndexedGeometry} from '@loaders.gl/schema'; import type {GeojsonGeometryInfo} from '@loaders.gl/gis'; import Protobuf from 'pbf'; import { classifyRings, classifyRingsFlat, projectToLngLat, projectToLngLatFlat, convertToLocalCoordinates, convertToLocalCoordinatesFlat } from '../utils/geometry-utils'; export class VectorTileFeature { properties: {[x: string]: string | number | boolean | null}; extent: any; type: number; id: number | null; _pbf: Protobuf; _geometry: number; _keys: string[]; _values: (string | number | boolean | null)[]; _geometryInfo: GeojsonGeometryInfo; static types: Readonly = ['Unknown', 'Point', 'LineString', 'Polygon']; // eslint-disable-next-line max-params constructor( pbf: Protobuf, end: number, extent: any, keys: string[], values: (string | number | boolean | null)[], geometryInfo?: GeojsonGeometryInfo ) { // Public this.properties = {}; this.extent = extent; this.type = 0; this.id = null; // Private this._pbf = pbf; this._geometry = -1; this._keys = keys; this._values = values; // Only used by binary tiles this._geometryInfo = geometryInfo!; pbf.readFields(readFeature, this, end); } toGeoJSONFeature( coordinates: 'wgs84' | 'local', tileIndex?: {x: number; y: number; z: number} ): Feature { const coords = this.loadGeometry(); switch (coordinates) { case 'wgs84': return _toGeoJSONFeature(this, coords, (line: number[][]) => projectToLngLat(line, tileIndex!, this.extent) ); default: return _toGeoJSONFeature(this, coords, convertToLocalCoordinates); } } /** * * @param options * @returns */ toBinaryFeature( coordinates: 'wgs84' | 'local', tileIndex?: {x: number; y: number; z: number} ): FlatFeature { const geom = this.loadFlatGeometry(); switch (coordinates) { case 'wgs84': return this._toBinaryCoordinates(geom, (coords: number[]) => projectToLngLatFlat(coords, tileIndex!, this.extent) ); default: return this._toBinaryCoordinates(geom, convertToLocalCoordinatesFlat); } } /** Read a bounding box from the feature */ // eslint-disable-next-line max-statements bbox() { const pbf = this._pbf; pbf.pos = this._geometry; const end = pbf.readVarint() + pbf.pos; let cmd = 1; let length = 0; let x = 0; let y = 0; let x1 = Infinity; let x2 = -Infinity; let y1 = Infinity; let y2 = -Infinity; while (pbf.pos < end) { if (length <= 0) { const cmdLen = pbf.readVarint(); cmd = cmdLen & 0x7; length = cmdLen >> 3; } length--; if (cmd === 1 || cmd === 2) { x += pbf.readSVarint(); y += pbf.readSVarint(); if (x < x1) x1 = x; if (x > x2) x2 = x; if (y < y1) y1 = y; if (y > y2) y2 = y; } else if (cmd !== 7) { throw new Error(`unknown command ${cmd}`); } } return [x1, y1, x2, y2]; } // BINARY HELPERS /** * * @param transform * @returns result */ _toBinaryCoordinates( geom: FlatIndexedGeometry, transform: (data: number[], extent: number) => void ) { let geometry; // Apply the supplied transformation to data transform(geom.data, this.extent); const coordLength = 2; // eslint-disable-next-line default-case switch (this.type) { case 1: // Point this._geometryInfo.pointFeaturesCount++; this._geometryInfo.pointPositionsCount += geom.indices.length; geometry = {type: 'Point', ...geom}; break; case 2: // LineString this._geometryInfo.lineFeaturesCount++; this._geometryInfo.linePathsCount += geom.indices.length; this._geometryInfo.linePositionsCount += geom.data.length / coordLength; geometry = {type: 'LineString', ...geom}; break; case 3: // Polygon geometry = classifyRingsFlat(geom); // Unlike Point & LineString geom.indices is a 2D array, thanks // to the classifyRings method this._geometryInfo.polygonFeaturesCount++; this._geometryInfo.polygonObjectsCount += geometry.indices.length; for (const indices of geometry.indices) { this._geometryInfo.polygonRingsCount += indices.length; } this._geometryInfo.polygonPositionsCount += geometry.data.length / coordLength; break; default: throw new Error(`Invalid geometry type: ${this.type}`); } const result: FlatFeature = {type: 'Feature', geometry, properties: this.properties}; if (this.id !== null) { result.id = this.id; } return result; } // GEOJSON HELPER // eslint-disable-next-line complexity, max-statements loadGeometry(): number[][][] { const pbf = this._pbf; pbf.pos = this._geometry; const end = pbf.readVarint() + pbf.pos; let cmd = 1; let length = 0; let x = 0; let y = 0; const lines: number[][][] = []; let line: number[][] | undefined; while (pbf.pos < end) { if (length <= 0) { const cmdLen = pbf.readVarint(); cmd = cmdLen & 0x7; length = cmdLen >> 3; } length--; switch (cmd) { case 1: case 2: x += pbf.readSVarint(); y += pbf.readSVarint(); if (cmd === 1) { // moveTo if (line) lines.push(line); line = []; } if (line) line.push([x, y]); break; case 7: // Workaround for https://github.com/mapbox/mapnik-vector-tile/issues/90 if (line) { line.push(line[0].slice()); // closePolygon } break; default: throw new Error(`unknown command ${cmd}`); } } if (line) lines.push(line); return lines; } /** * Expands the protobuf data to an intermediate Flat GeoJSON * data format, which maps closely to the binary data buffers. * It is similar to GeoJSON, but rather than storing the coordinates * in multidimensional arrays, we have a 1D `data` with all the * coordinates, and then index into this using the `indices` * parameter, e.g. * * geometry: { * type: 'Point', data: [1,2], indices: [0] * } * geometry: { * type: 'LineString', data: [1,2,3,4,...], indices: [0] * } * geometry: { * type: 'Polygon', data: [1,2,3,4,...], indices: [[0, 2]] * } * Thus the indices member lets us look up the relevant range * from the data array. * The Multi* versions of the above types share the same data * structure, just with multiple elements in the indices array */ // eslint-disable-next-line complexity, max-statements loadFlatGeometry(): FlatIndexedGeometry { const pbf = this._pbf; pbf.pos = this._geometry; const endPos = pbf.readVarint() + pbf.pos; let cmd = 1; let cmdLen: number; let length = 0; let x = 0; let y = 0; let i = 0; // Note: I attempted to replace the `data` array with a // Float32Array, but performance was worse, both using // `set()` and direct index access. Also, we cannot // know how large the buffer should be, so it would // increase memory usage const indices: number[] = []; // Indices where geometries start const data: number[] = []; // Flat array of coordinate data while (pbf.pos < endPos) { if (length <= 0) { cmdLen = pbf.readVarint(); cmd = cmdLen & 0x7; length = cmdLen >> 3; } length--; if (cmd === 1 || cmd === 2) { x += pbf.readSVarint(); y += pbf.readSVarint(); if (cmd === 1) { // New line indices.push(i); } data.push(x, y); i += 2; } else if (cmd === 7) { // Workaround for https://github.com/mapbox/mapnik-vector-tile/issues/90 if (i > 0) { const start = indices[indices.length - 1]; // start index of polygon data.push(data[start], data[start + 1]); // closePolygon i += 2; } } else { throw new Error(`unknown command ${cmd}`); } } return {data, indices}; } } function _toGeoJSONFeature( vtFeature: VectorTileFeature, coords: number[][][], transform: (data: number[][], extent: number) => void ): Feature { let type = VectorTileFeature.types[vtFeature.type]; let i: number; let j: number; let coordinates: number[][] | number[][][] | number[][][][]; switch (vtFeature.type) { case 1: const points: number[][] = []; for (i = 0; i < coords.length; i++) { points[i] = coords[i][0]; } coordinates = points; transform(coordinates, vtFeature.extent); break; case 2: coordinates = coords; for (i = 0; i < coordinates.length; i++) { transform(coordinates[i], vtFeature.extent); } break; case 3: coordinates = classifyRings(coords); for (i = 0; i < coordinates.length; i++) { for (j = 0; j < coordinates[i].length; j++) { transform(coordinates[i][j], vtFeature.extent); } } break; default: throw new Error('illegal vector tile type'); } if (coordinates.length === 1) { // @ts-expect-error coordinates = coordinates[0]; } else { type = `Multi${type}`; } const result: Feature = { type: 'Feature', geometry: { type: type as any, coordinates: coordinates as any }, properties: vtFeature.properties }; if (vtFeature.id !== null) { result.properties ||= {}; result.properties.id = vtFeature.id; } return result; } // PBF READER UTILS /** * * @param tag * @param feature * @param pbf */ function readFeature(tag: number, feature?: VectorTileFeature, pbf?: Protobuf): void { if (feature && pbf) { if (tag === 1) feature.id = pbf.readVarint(); else if (tag === 2) readTag(pbf, feature); else if (tag === 3) feature.type = pbf.readVarint(); else if (tag === 4) feature._geometry = pbf.pos; } } /** * * @param pbf * @param feature */ function readTag(pbf: Protobuf, feature: VectorTileFeature): void { const end = pbf.readVarint() + pbf.pos; while (pbf.pos < end) { const key = feature._keys[pbf.readVarint()]; const value = feature._values[pbf.readVarint()]; feature.properties[key] = value; } }