(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.geojsonvt = {})); })(this, (function (exports) { 'use strict'; /** * calculate simplification data using optimized Douglas-Peucker algorithm * @param coords - flat array of coordinates * @param first - index of the first coordinate in the segment * @param last - index of the last coordinate in the segment * @param sqTolerance - square tolerance value */ function simplify(coords, first, last, sqTolerance) { let maxSqDist = sqTolerance; const mid = first + ((last - first) >> 1); let minPosToMid = last - first; let index; const ax = coords[first]; const ay = coords[first + 1]; const bx = coords[last]; const by = coords[last + 1]; for (let i = first + 3; i < last; i += 3) { const d = getSqSegDist(coords[i], coords[i + 1], ax, ay, bx, by); if (d > maxSqDist) { index = i; maxSqDist = d; continue; } if (d === maxSqDist) { // a workaround to ensure we choose a pivot close to the middle of the list, // reducing recursion depth, for certain degenerate inputs // https://github.com/mapbox/geojson-vt/issues/104 const posToMid = Math.abs(i - mid); if (posToMid < minPosToMid) { index = i; minPosToMid = posToMid; } } } if (maxSqDist > sqTolerance) { if (index - first > 3) simplify(coords, first, index, sqTolerance); coords[index + 2] = maxSqDist; if (last - index > 3) simplify(coords, index, last, sqTolerance); } } /** * Claculates the square distance from a point to a segment * @param px - x coordinate of the point * @param py - y coordinate of the point * @param x - x coordinate of the first segment endpoint * @param y - y coordinate of the first segment endpoint * @param bx - x coordinate of the second segment endpoint * @param by - y coordinate of the second segment endpoint * @returns square distance from a point to a segment */ function getSqSegDist(px, py, x, y, bx, by) { let dx = bx - x; let dy = by - y; if (dx !== 0 || dy !== 0) { const t = ((px - x) * dx + (py - y) * dy) / (dx * dx + dy * dy); if (t > 1) { x = bx; y = by; } else if (t > 0) { x += dx * t; y += dy * t; } } dx = px - x; dy = py - y; return dx * dx + dy * dy; } /** * * @param id - the feature's ID * @param type - the feature's type * @param geom - the feature's geometry * @param tags - the feature's properties * @returns the created feature */ function createFeature(id, type, geom, tags) { // This is mostly for TypeScript type narrowing const data = { type, geom }; const feature = { id: id == null ? null : id, type: data.type, geometry: data.geom, tags, minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; switch (data.type) { case 'Point': case 'MultiPoint': case 'LineString': calcLineBBox(feature, data.geom); break; case 'Polygon': // the outer ring (ie [0]) contains all inner rings calcLineBBox(feature, data.geom[0]); break; case 'MultiLineString': for (const line of data.geom) { calcLineBBox(feature, line); } break; case 'MultiPolygon': for (const polygon of data.geom) { // the outer ring (ie [0]) contains all inner rings calcLineBBox(feature, polygon[0]); } break; } return feature; } function calcLineBBox(feature, geom) { for (let i = 0; i < geom.length; i += 3) { feature.minX = Math.min(feature.minX, geom[i]); feature.minY = Math.min(feature.minY, geom[i + 1]); feature.maxX = Math.max(feature.maxX, geom[i]); feature.maxY = Math.max(feature.maxY, geom[i + 1]); } } /** * converts GeoJSON to internal source features (an intermediate projected JSON vector format with simplification data) * @param data * @param options * @returns */ function convertToInternal(data, options) { const features = []; switch (data.type) { case 'FeatureCollection': for (let i = 0; i < data.features.length; i++) { featureToInternal(features, data.features[i], options, i); } break; case 'Feature': featureToInternal(features, data, options); break; default: featureToInternal(features, { geometry: data, properties: undefined }, options); } return features; } function featureToInternal(features, geojson, options, index) { if (!geojson.geometry) return; if (geojson.geometry.type === 'GeometryCollection') { convertGeometryCollection(features, geojson, geojson.geometry, options, index); return; } const coords = geojson.geometry.coordinates; if (!coords?.length) return; const id = getFeatureId(geojson, options, index); const tolerance = Math.pow(options.tolerance / ((1 << options.maxZoom) * options.extent), 2); switch (geojson.geometry.type) { case 'Point': convertPointFeature(features, id, geojson.geometry, geojson.properties); return; case 'MultiPoint': convertMultiPointFeature(features, id, geojson.geometry, geojson.properties); return; case 'LineString': convertLineStringFeature(features, id, geojson.geometry, tolerance, geojson.properties); return; case 'MultiLineString': convertMultiLineStringFeature(features, id, geojson.geometry, tolerance, options, geojson.properties); return; case 'Polygon': convertPolygonFeature(features, id, geojson.geometry, tolerance, geojson.properties); return; case 'MultiPolygon': convertMultiPolygonFeature(features, id, geojson.geometry, tolerance, geojson.properties); return; default: throw new Error('Input data is not a valid GeoJSON object.'); } } function getFeatureId(geojson, options, index) { if (options.promoteId) { return geojson.properties?.[options.promoteId]; } if (options.generateId) { return index || 0; } return geojson.id; } function convertGeometryCollection(features, geojson, geometry, options, index) { for (const geom of geometry.geometries) { featureToInternal(features, { id: geojson.id, geometry: geom, properties: geojson.properties }, options, index); } } function convertPointFeature(features, id, geom, properties) { const out = []; out.push(projectX(geom.coordinates[0]), projectY(geom.coordinates[1]), 0); features.push(createFeature(id, 'Point', out, properties)); } function convertMultiPointFeature(features, id, geom, properties) { const out = []; for (const coords of geom.coordinates) { out.push(projectX(coords[0]), projectY(coords[1]), 0); } features.push(createFeature(id, 'MultiPoint', out, properties)); } function convertLineStringFeature(features, id, geom, tolerance, properties) { const out = []; convertLine(geom.coordinates, out, tolerance, false); features.push(createFeature(id, 'LineString', out, properties)); } function convertMultiLineStringFeature(features, id, geom, tolerance, options, properties) { if (options.lineMetrics) { // explode into linestrings to be able to track metrics for (const line of geom.coordinates) { const out = []; convertLine(line, out, tolerance, false); features.push(createFeature(id, 'LineString', out, properties)); } } else { const out = []; convertLines(geom.coordinates, out, tolerance, false); features.push(createFeature(id, 'MultiLineString', out, properties)); } } function convertPolygonFeature(features, id, geom, tolerance, properties) { const out = []; convertLines(geom.coordinates, out, tolerance, true); features.push(createFeature(id, 'Polygon', out, properties)); } function convertMultiPolygonFeature(features, id, geom, tolerance, properties) { const out = []; for (const polygon of geom.coordinates) { const polygonOut = []; convertLines(polygon, polygonOut, tolerance, true); out.push(polygonOut); } features.push(createFeature(id, 'MultiPolygon', out, properties)); } function convertLine(ring, out, tolerance, isPolygon) { let x0, y0; let size = 0; for (let j = 0; j < ring.length; j++) { const x = projectX(ring[j][0]); const y = projectY(ring[j][1]); out.push(x, y, 0); if (j > 0) { if (isPolygon) { size += (x0 * y - x * y0) / 2; // area } else { size += Math.sqrt(Math.pow(x - x0, 2) + Math.pow(y - y0, 2)); // length } } x0 = x; y0 = y; } const last = out.length - 3; out[2] = 1; if (tolerance > 0) simplify(out, 0, last, tolerance); out[last + 2] = 1; out.size = Math.abs(size); out.start = 0; out.end = out.size; } function convertLines(rings, out, tolerance, isPolygon) { for (let i = 0; i < rings.length; i++) { const geom = []; convertLine(rings[i], geom, tolerance, isPolygon); out.push(geom); } } /** * Convert longitude to spherical mercator in [0..1] range */ function projectX(x) { return x / 360 + 0.5; } /** * Convert latitude to spherical mercator in [0..1] range */ function projectY(y) { const sin = Math.sin(y * Math.PI / 180); const y2 = 0.5 - 0.25 * Math.log((1 + sin) / (1 - sin)) / Math.PI; return y2 < 0 ? 0 : y2 > 1 ? 1 : y2; } /** * Converts internal source features back to GeoJSON format. */ function convertToGeoJSON(source) { const geojson = { type: 'FeatureCollection', features: source.map(feature => featureToGeoJSON(feature)) }; return geojson; } /** * Converts a single internal feature to GeoJSON format. */ function featureToGeoJSON(feature) { const geojsonFeature = { type: 'Feature', geometry: geometryToGeoJSON(feature), properties: feature.tags }; if (feature.id != null) { geojsonFeature.id = feature.id; } return geojsonFeature; } /** * Converts a single internal feature geometry to GeoJSON format. */ function geometryToGeoJSON(feature) { const { type, geometry } = feature; switch (type) { case 'Point': return { type: type, coordinates: unprojectPoint(geometry[0], geometry[1]) }; case 'MultiPoint': case 'LineString': return { type: type, coordinates: unprojectPoints(geometry) }; case 'MultiLineString': case 'Polygon': return { type: type, coordinates: geometry.map(ring => unprojectPoints(ring)) }; case 'MultiPolygon': return { type: type, coordinates: geometry.map(polygon => polygon.map(ring => unprojectPoints(ring))) }; } } function unprojectPoints(coords) { const result = []; for (let i = 0; i < coords.length; i += 3) { result.push(unprojectPoint(coords[i], coords[i + 1])); } return result; } function unprojectPoint(x, y) { return [unprojectX(x), unprojectY(y)]; } /** * Convert spherical mercator in [0..1] range to longitude */ function unprojectX(x) { return (x - 0.5) * 360; } /** * Convert spherical mercator in [0..1] range to latitude */ function unprojectY(y) { const y2 = (180 - y * 360) * Math.PI / 180; return 360 * Math.atan(Math.exp(y2)) / Math.PI - 90; } var AxisType; (function (AxisType) { AxisType[AxisType["X"] = 0] = "X"; AxisType[AxisType["Y"] = 1] = "Y"; })(AxisType || (AxisType = {})); /** * clip features between two vertical or horizontal axis-parallel lines: * | | * ___|___ | / * / | \____|____/ * | | * * @param features - the features to clip * @param scale - the scale to divide start and end inputs * @param start - the start of the clip range * @param end - the end of the clip range * @param axis - which axis to clip against * @param minAll - the minimum for all features in the relevant axis * @param maxAll - the maximum for all features in the relevant axis */ function clip(features, scale, start, end, axis, minAll, maxAll, options) { start /= scale; end /= scale; if (minAll >= start && maxAll < end) { // trivial accept return features; } if (maxAll < start || minAll >= end) { // trivial reject return null; } const clipped = []; for (const feature of features) { const min = axis === AxisType.X ? feature.minX : feature.minY; const max = axis === AxisType.X ? feature.maxX : feature.maxY; if (min >= start && max < end) { // trivial accept clipped.push(feature); continue; } if (max < start || min >= end) { // trivial reject continue; } switch (feature.type) { case 'Point': case 'MultiPoint': { clipPointFeature(feature, clipped, start, end, axis); continue; } case 'LineString': { clipLineStringFeature(feature, clipped, start, end, axis, options); continue; } case 'MultiLineString': { clipMultiLineStringFeature(feature, clipped, start, end, axis); continue; } case 'Polygon': { clipPolygonFeature(feature, clipped, start, end, axis); continue; } case 'MultiPolygon': { clipMultiPolygonFeature(feature, clipped, start, end, axis); continue; } } } if (!clipped.length) return null; return clipped; } function clipPointFeature(feature, clipped, start, end, axis) { const geom = []; clipPoints(feature.geometry, geom, start, end, axis); if (!geom.length) return; const type = geom.length === 3 ? 'Point' : 'MultiPoint'; clipped.push(createFeature(feature.id, type, geom, feature.tags)); } function clipLineStringFeature(feature, clipped, start, end, axis, options) { const geom = []; clipLine(feature.geometry, geom, start, end, axis, false, options.lineMetrics); if (!geom.length) return; if (options.lineMetrics) { for (const line of geom) { clipped.push(createFeature(feature.id, 'LineString', line, feature.tags)); } return; } if (geom.length > 1) { clipped.push(createFeature(feature.id, 'MultiLineString', geom, feature.tags)); return; } clipped.push(createFeature(feature.id, 'LineString', geom[0], feature.tags)); } function clipMultiLineStringFeature(feature, clipped, start, end, axis) { const geom = []; clipLines(feature.geometry, geom, start, end, axis, false); if (!geom.length) return; if (geom.length === 1) { clipped.push(createFeature(feature.id, 'LineString', geom[0], feature.tags)); return; } clipped.push(createFeature(feature.id, 'MultiLineString', geom, feature.tags)); } function clipPolygonFeature(feature, clipped, start, end, axis) { const geom = []; clipLines(feature.geometry, geom, start, end, axis, true); if (!geom.length) return; clipped.push(createFeature(feature.id, 'Polygon', geom, feature.tags)); } function clipMultiPolygonFeature(feature, clipped, start, end, axis) { const geom = []; for (const polygon of feature.geometry) { const newPolygon = []; clipLines(polygon, newPolygon, start, end, axis, true); if (!newPolygon.length) continue; geom.push(newPolygon); } if (!geom.length) return; clipped.push(createFeature(feature.id, 'MultiPolygon', geom, feature.tags)); } function clipPoints(geom, newGeom, start, end, axis) { for (let i = 0; i < geom.length; i += 3) { const a = geom[i + axis]; if (a >= start && a <= end) { addPoint(newGeom, geom[i], geom[i + 1], geom[i + 2]); } } } function clipLine(geom, newGeom, start, end, axis, isPolygon, trackMetrics) { let slice = newSlice(geom); const intersect = axis === AxisType.X ? intersectX : intersectY; let len = geom.start; let segLen, t; for (let i = 0; i < geom.length - 3; i += 3) { const ax = geom[i]; const ay = geom[i + 1]; const az = geom[i + 2]; const bx = geom[i + 3]; const by = geom[i + 4]; const a = axis === AxisType.X ? ax : ay; const b = axis === AxisType.X ? bx : by; let exited = false; if (trackMetrics) segLen = Math.sqrt(Math.pow(ax - bx, 2) + Math.pow(ay - by, 2)); if (a < start) { // ---|--> | (line enters the clip region from the left) if (b > start) { t = intersect(slice, ax, ay, bx, by, start); if (trackMetrics) slice.start = len + segLen * t; } } else if (a > end) { // | <--|--- (line enters the clip region from the right) if (b < end) { t = intersect(slice, ax, ay, bx, by, end); if (trackMetrics) slice.start = len + segLen * t; } } else { addPoint(slice, ax, ay, az); } if (b < start && a >= start) { // <--|--- | or <--|-----|--- (line exits the clip region on the left) t = intersect(slice, ax, ay, bx, by, start); exited = true; } if (b > end && a <= end) { // | ---|--> or ---|-----|--> (line exits the clip region on the right) t = intersect(slice, ax, ay, bx, by, end); exited = true; } if (!isPolygon && exited) { if (trackMetrics) slice.end = len + segLen * t; newGeom.push(slice); slice = newSlice(geom); } if (trackMetrics) len += segLen; } // add the last point let last = geom.length - 3; const ax = geom[last]; const ay = geom[last + 1]; const az = geom[last + 2]; const a = axis === AxisType.X ? ax : ay; if (a >= start && a <= end) addPoint(slice, ax, ay, az); // close the polygon if its endpoints are not the same after clipping last = slice.length - 3; if (isPolygon && last >= 3 && (slice[last] !== slice[0] || slice[last + 1] !== slice[1])) { addPoint(slice, slice[0], slice[1], slice[2]); } // add the final slice if (slice.length) { newGeom.push(slice); } } function newSlice(line) { const slice = []; slice.size = line.size; slice.start = line.start; slice.end = line.end; return slice; } function clipLines(geom, newGeom, start, end, axis, isPolygon) { for (const line of geom) { clipLine(line, newGeom, start, end, axis, isPolygon, false); } } function addPoint(out, x, y, z) { out.push(x, y, z); } function intersectX(out, ax, ay, bx, by, x) { const t = (x - ax) / (bx - ax); addPoint(out, x, ay + (by - ay) * t, 1); return t; } function intersectY(out, ax, ay, bx, by, y) { const t = (y - ay) / (by - ay); addPoint(out, ax + (bx - ax) * t, y, 1); return t; } function wrap(features, options) { const buffer = options.buffer / options.extent; let merged = features; const left = clip(features, 1, -1 - buffer, buffer, AxisType.X, -1, 2, options); // left world copy const right = clip(features, 1, 1 - buffer, 2 + buffer, AxisType.X, -1, 2, options); // right world copy if (!left && !right) return merged; merged = clip(features, 1, -buffer, 1 + buffer, AxisType.X, -1, 2, options) || []; // center world copy if (left) merged = shiftFeatureCoords(left, 1).concat(merged); // merge left into center if (right) merged = merged.concat(shiftFeatureCoords(right, -1)); // merge right into center return merged; } function shiftFeatureCoords(features, offset) { const newFeatures = []; for (const feature of features) { switch (feature.type) { case 'Point': case 'MultiPoint': case 'LineString': { const newGeometry = shiftCoords(feature.geometry, offset); newFeatures.push(createFeature(feature.id, feature.type, newGeometry, feature.tags)); continue; } case 'MultiLineString': case 'Polygon': { const newGeometry = []; for (const line of feature.geometry) { newGeometry.push(shiftCoords(line, offset)); } newFeatures.push(createFeature(feature.id, feature.type, newGeometry, feature.tags)); continue; } case 'MultiPolygon': { const newGeometry = []; for (const polygon of feature.geometry) { const newPolygon = []; for (const line of polygon) { newPolygon.push(shiftCoords(line, offset)); } newGeometry.push(newPolygon); } newFeatures.push(createFeature(feature.id, feature.type, newGeometry, feature.tags)); continue; } } } return newFeatures; } function shiftCoords(points, offset) { const newPoints = []; newPoints.size = points.size; if (points.start !== undefined) { newPoints.start = points.start; newPoints.end = points.end; } for (let i = 0; i < points.length; i += 3) { newPoints.push(points[i] + offset, points[i + 1], points[i + 2]); } return newPoints; } /** * Applies a GeoJSON Source Diff to an existing set of simplified features * @param source * @param dataDiff * @param options * @returns */ function applySourceDiff(source, dataDiff, options) { // convert diff to sets/maps for o(1) lookups const diff = diffToHashed(dataDiff); // collection for features that will be affected by this update and used to invalidate tiles let affected = []; if (diff.removeAll) { affected = source; source = []; } if (diff.remove.size || diff.add.size) { const removeFeatures = []; // Collect features to remove (explicit removals + replacements via add) for (const feature of source) { if (diff.remove.has(feature.id) || diff.add.has(feature.id)) { removeFeatures.push(feature); } } if (removeFeatures.length) { affected.push(...removeFeatures); const removeIds = new Set(removeFeatures.map(f => f.id)); source = source.filter(f => !removeIds.has(f.id)); } if (diff.add.size) { let addFeatures = convertToInternal({ type: 'FeatureCollection', features: Array.from(diff.add.values()) }, options); addFeatures = wrap(addFeatures, options); affected.push(...addFeatures); source.push(...addFeatures); } } if (diff.update.size) { // Features can be duplicated across the antimeridian (wrap) in a single tile, so must update all instances with the same id for (const [id, update] of diff.update) { const oldFeatures = []; const keepFeatures = []; for (const feature of source) { if (feature.id === id) { oldFeatures.push(feature); } else { keepFeatures.push(feature); } } if (!oldFeatures.length) continue; const updatedFeatures = getUpdatedFeatures(oldFeatures, update, options); if (!updatedFeatures.length) continue; affected.push(...oldFeatures, ...updatedFeatures); keepFeatures.push(...updatedFeatures); source = keepFeatures; } } return { affected, source }; } /** * Gets updated simplified feature(s) based on a diff update object. * @param vtFeatures - the original features * @param update - the update object to apply * @param options - the options to use for the wrap method * @returns Updated features. If geometry is updated, returns new feature(s) converted from geojson and wrapped. If only properties are updated, returns feature(s) with tags updated. */ function getUpdatedFeatures(vtFeatures, update, options) { const changeGeometry = !!update.newGeometry; const changeProps = update.removeAllProperties || update.removeProperties?.length > 0 || update.addOrUpdateProperties?.length > 0; // if geometry changed, need to create a new geojson feature and convert to internal format if (changeGeometry) { const vtFeature = vtFeatures[0]; const geojsonFeature = { type: 'Feature', id: vtFeature.id, geometry: update.newGeometry, properties: changeProps ? applyPropertyUpdates(vtFeature.tags, update) : vtFeature.tags }; let features = convertToInternal({ type: 'FeatureCollection', features: [geojsonFeature] }, options); features = wrap(features, options); return features; } if (changeProps) { const updated = []; for (const vtFeature of vtFeatures) { const feature = { ...vtFeature }; feature.tags = applyPropertyUpdates(feature.tags, update); updated.push(feature); } return updated; } return []; } /** * helper to apply property updates from a diff update object to a properties object */ function applyPropertyUpdates(tags, update) { if (update.removeAllProperties) { return {}; } const properties = { ...tags || {} }; if (update.removeProperties) { for (const key of update.removeProperties) { delete properties[key]; } } if (update.addOrUpdateProperties) { for (const { key, value } of update.addOrUpdateProperties) { properties[key] = value; } } return properties; } /** * Convert a GeoJSON Source Diff to an idempotent hashed representation using Sets and Maps */ function diffToHashed(diff) { if (!diff) return { remove: new Set(), add: new Map(), update: new Map() }; const hashed = { removeAll: diff.removeAll, remove: new Set(diff.remove || []), add: new Map(diff.add?.map(feature => [feature.id, feature])), update: new Map(diff.update?.map(update => [update.id, update])) }; return hashed; } const ARRAY_TYPES = [ Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array ]; /** @typedef {Int8ArrayConstructor | Uint8ArrayConstructor | Uint8ClampedArrayConstructor | Int16ArrayConstructor | Uint16ArrayConstructor | Int32ArrayConstructor | Uint32ArrayConstructor | Float32ArrayConstructor | Float64ArrayConstructor} TypedArrayConstructor */ const VERSION = 1; // serialized format version const HEADER_SIZE = 8; class KDBush { /** * Creates an index from raw `ArrayBuffer` data. * @param {ArrayBuffer} data */ static from(data) { if (!(data instanceof ArrayBuffer)) { throw new Error('Data must be an instance of ArrayBuffer.'); } const [magic, versionAndType] = new Uint8Array(data, 0, 2); if (magic !== 0xdb) { throw new Error('Data does not appear to be in a KDBush format.'); } const version = versionAndType >> 4; if (version !== VERSION) { throw new Error(`Got v${version} data when expected v${VERSION}.`); } const ArrayType = ARRAY_TYPES[versionAndType & 0x0f]; if (!ArrayType) { throw new Error('Unrecognized array type.'); } const [nodeSize] = new Uint16Array(data, 2, 1); const [numItems] = new Uint32Array(data, 4, 1); return new KDBush(numItems, nodeSize, ArrayType, data); } /** * Creates an index that will hold a given number of items. * @param {number} numItems * @param {number} [nodeSize=64] Size of the KD-tree node (64 by default). * @param {TypedArrayConstructor} [ArrayType=Float64Array] The array type used for coordinates storage (`Float64Array` by default). * @param {ArrayBuffer} [data] (For internal use only) */ constructor(numItems, nodeSize = 64, ArrayType = Float64Array, data) { if (isNaN(numItems) || numItems < 0) throw new Error(`Unpexpected numItems value: ${numItems}.`); this.numItems = +numItems; this.nodeSize = Math.min(Math.max(+nodeSize, 2), 65535); this.ArrayType = ArrayType; this.IndexArrayType = numItems < 65536 ? Uint16Array : Uint32Array; const arrayTypeIndex = ARRAY_TYPES.indexOf(this.ArrayType); const coordsByteSize = numItems * 2 * this.ArrayType.BYTES_PER_ELEMENT; const idsByteSize = numItems * this.IndexArrayType.BYTES_PER_ELEMENT; const padCoords = (8 - idsByteSize % 8) % 8; if (arrayTypeIndex < 0) { throw new Error(`Unexpected typed array class: ${ArrayType}.`); } if (data && (data instanceof ArrayBuffer)) { // reconstruct an index from a buffer this.data = data; this.ids = new this.IndexArrayType(this.data, HEADER_SIZE, numItems); this.coords = new this.ArrayType(this.data, HEADER_SIZE + idsByteSize + padCoords, numItems * 2); this._pos = numItems * 2; this._finished = true; } else { // initialize a new index this.data = new ArrayBuffer(HEADER_SIZE + coordsByteSize + idsByteSize + padCoords); this.ids = new this.IndexArrayType(this.data, HEADER_SIZE, numItems); this.coords = new this.ArrayType(this.data, HEADER_SIZE + idsByteSize + padCoords, numItems * 2); this._pos = 0; this._finished = false; // set header new Uint8Array(this.data, 0, 2).set([0xdb, (VERSION << 4) + arrayTypeIndex]); new Uint16Array(this.data, 2, 1)[0] = nodeSize; new Uint32Array(this.data, 4, 1)[0] = numItems; } } /** * Add a point to the index. * @param {number} x * @param {number} y * @returns {number} An incremental index associated with the added item (starting from `0`). */ add(x, y) { const index = this._pos >> 1; this.ids[index] = index; this.coords[this._pos++] = x; this.coords[this._pos++] = y; return index; } /** * Perform indexing of the added points. */ finish() { const numAdded = this._pos >> 1; if (numAdded !== this.numItems) { throw new Error(`Added ${numAdded} items when expected ${this.numItems}.`); } // kd-sort both arrays for efficient search sort(this.ids, this.coords, this.nodeSize, 0, this.numItems - 1, 0); this._finished = true; return this; } /** * Search the index for items within a given bounding box. * @param {number} minX * @param {number} minY * @param {number} maxX * @param {number} maxY * @returns {number[]} An array of indices correponding to the found items. */ range(minX, minY, maxX, maxY) { if (!this._finished) throw new Error('Data not yet indexed - call index.finish().'); const {ids, coords, nodeSize} = this; const stack = [0, ids.length - 1, 0]; const result = []; // recursively search for items in range in the kd-sorted arrays while (stack.length) { const axis = stack.pop() || 0; const right = stack.pop() || 0; const left = stack.pop() || 0; // if we reached "tree node", search linearly if (right - left <= nodeSize) { for (let i = left; i <= right; i++) { const x = coords[2 * i]; const y = coords[2 * i + 1]; if (x >= minX && x <= maxX && y >= minY && y <= maxY) result.push(ids[i]); } continue; } // otherwise find the middle index const m = (left + right) >> 1; // include the middle item if it's in range const x = coords[2 * m]; const y = coords[2 * m + 1]; if (x >= minX && x <= maxX && y >= minY && y <= maxY) result.push(ids[m]); // queue search in halves that intersect the query if (axis === 0 ? minX <= x : minY <= y) { stack.push(left); stack.push(m - 1); stack.push(1 - axis); } if (axis === 0 ? maxX >= x : maxY >= y) { stack.push(m + 1); stack.push(right); stack.push(1 - axis); } } return result; } /** * Search the index for items within a given radius. * @param {number} qx * @param {number} qy * @param {number} r Query radius. * @returns {number[]} An array of indices correponding to the found items. */ within(qx, qy, r) { if (!this._finished) throw new Error('Data not yet indexed - call index.finish().'); const {ids, coords, nodeSize} = this; const stack = [0, ids.length - 1, 0]; const result = []; const r2 = r * r; // recursively search for items within radius in the kd-sorted arrays while (stack.length) { const axis = stack.pop() || 0; const right = stack.pop() || 0; const left = stack.pop() || 0; // if we reached "tree node", search linearly if (right - left <= nodeSize) { for (let i = left; i <= right; i++) { if (sqDist(coords[2 * i], coords[2 * i + 1], qx, qy) <= r2) result.push(ids[i]); } continue; } // otherwise find the middle index const m = (left + right) >> 1; // include the middle item if it's in range const x = coords[2 * m]; const y = coords[2 * m + 1]; if (sqDist(x, y, qx, qy) <= r2) result.push(ids[m]); // queue search in halves that intersect the query if (axis === 0 ? qx - r <= x : qy - r <= y) { stack.push(left); stack.push(m - 1); stack.push(1 - axis); } if (axis === 0 ? qx + r >= x : qy + r >= y) { stack.push(m + 1); stack.push(right); stack.push(1 - axis); } } return result; } } /** * @param {Uint16Array | Uint32Array} ids * @param {InstanceType} coords * @param {number} nodeSize * @param {number} left * @param {number} right * @param {number} axis */ function sort(ids, coords, nodeSize, left, right, axis) { if (right - left <= nodeSize) return; const m = (left + right) >> 1; // middle index // sort ids and coords around the middle index so that the halves lie // either left/right or top/bottom correspondingly (taking turns) select(ids, coords, m, left, right, axis); // recursively kd-sort first half and second half on the opposite axis sort(ids, coords, nodeSize, left, m - 1, 1 - axis); sort(ids, coords, nodeSize, m + 1, right, 1 - axis); } /** * Custom Floyd-Rivest selection algorithm: sort ids and coords so that * [left..k-1] items are smaller than k-th item (on either x or y axis) * @param {Uint16Array | Uint32Array} ids * @param {InstanceType} coords * @param {number} k * @param {number} left * @param {number} right * @param {number} axis */ function select(ids, coords, k, left, right, axis) { while (right > left) { if (right - left > 600) { const n = right - left + 1; const m = k - left + 1; const z = Math.log(n); const s = 0.5 * Math.exp(2 * z / 3); const sd = 0.5 * Math.sqrt(z * s * (n - s) / n) * (m - n / 2 < 0 ? -1 : 1); const newLeft = Math.max(left, Math.floor(k - m * s / n + sd)); const newRight = Math.min(right, Math.floor(k + (n - m) * s / n + sd)); select(ids, coords, k, newLeft, newRight, axis); } const t = coords[2 * k + axis]; let i = left; let j = right; swapItem(ids, coords, left, k); if (coords[2 * right + axis] > t) swapItem(ids, coords, left, right); while (i < j) { swapItem(ids, coords, i, j); i++; j--; while (coords[2 * i + axis] < t) i++; while (coords[2 * j + axis] > t) j--; } if (coords[2 * left + axis] === t) swapItem(ids, coords, left, j); else { j++; swapItem(ids, coords, j, right); } if (j <= k) left = j + 1; if (k <= j) right = j - 1; } } /** * @param {Uint16Array | Uint32Array} ids * @param {InstanceType} coords * @param {number} i * @param {number} j */ function swapItem(ids, coords, i, j) { swap(ids, i, j); swap(coords, 2 * i, 2 * j); swap(coords, 2 * i + 1, 2 * j + 1); } /** * @param {InstanceType} arr * @param {number} i * @param {number} j */ function swap(arr, i, j) { const tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } /** * @param {number} ax * @param {number} ay * @param {number} bx * @param {number} by */ function sqDist(ax, ay, bx, by) { const dx = ax - bx; const dy = ay - by; return dx * dx + dy * dy; } const defaultClusterOptions = { minZoom: 0, maxZoom: 16, minPoints: 2, radius: 40, extent: 512, nodeSize: 64, log: false, generateId: false, reduce: null, map: (props) => props }; const OFFSET_ZOOM = 2; const OFFSET_ID = 3; const OFFSET_PARENT = 4; const OFFSET_NUM = 5; const OFFSET_PROP = 6; /** * This class allow clustering of geojson points. */ class ClusterTileIndex { constructor(options) { this.options = Object.assign(Object.create(defaultClusterOptions), options); this.trees = new Array(this.options.maxZoom + 1); this.stride = this.options.reduce ? 7 : 6; this.clusterProps = []; this.points = []; } /** * Loads GeoJSON point features and builds the internal clustering index. * @param points - GeoJSON point features to cluster. */ load(points) { const features = []; // Convert GeoJSON point features to GeoJSONVT internal point features for (const point of points) { if (!point.geometry) { continue; } const [lng, lat] = point.geometry.coordinates; const [x, y] = [projectX(lng), projectY(lat)]; const feature = { id: point.id, type: 'Point', geometry: [x, y], tags: point.properties }; features.push(feature); } this.createIndex(features); } /** * @internal * Loads internal GeoJSONVT point features from a data source and builds the clustering index. * @param features - {@link GeoJSONVTInternalFeature} data source features to filter and cluster. */ initialize(features) { const points = []; for (const feature of features) { if (feature.type !== 'Point') continue; points.push(feature); } this.createIndex(points); } /** * @internal * Updates the cluster data by rebuilding. * @param features */ updateIndex(features, _affected, options) { this.options = Object.assign(Object.create(defaultClusterOptions), options.clusterOptions); this.initialize(features); } createIndex(points) { const { log, minZoom, maxZoom } = this.options; if (log) console.time('total time'); const timerId = `prepare ${points.length} points`; if (log) console.time(timerId); this.points = points; // generate a cluster object for each point and index input points into a KD-tree const data = []; for (let i = 0; i < points.length; i++) { const p = points[i]; if (!p?.geometry) continue; let [x, y] = p.geometry; x = Math.fround(x); y = Math.fround(y); // store internal point/cluster data in flat numeric arrays for performance data.push(x, y, // projected point coordinates Infinity, // the last zoom the point was processed at i, // index of the source feature in the original input array -1, // parent cluster id 1 // number of points in a cluster ); if (this.options.reduce) data.push(0); // noop } let tree = this.trees[maxZoom + 1] = this.createTree(data); if (log) console.timeEnd(timerId); // cluster points on max zoom, then cluster the results on previous zoom, etc.; // results in a cluster hierarchy across zoom levels for (let z = maxZoom; z >= minZoom; z--) { const now = Date.now(); // create a new set of clusters for the zoom and index them with a KD-tree tree = this.trees[z] = this.createTree(this.cluster(tree, z)); if (log) console.log('z%d: %d clusters in %dms', z, tree.numItems, Date.now() - now); } if (log) console.timeEnd('total time'); } /** * Returns clusters and/or points within a bounding box at a given zoom level. * @param bbox - Bounding box in `[westLng, southLat, eastLng, northLat]` order. * @param zoom - Zoom level to query. */ getClusters(bbox, zoom) { const clusterInternal = this.getClustersInternal(bbox, zoom); return clusterInternal.map((f) => featureToGeoJSON(f)); } getClustersInternal(bbox, zoom) { let minLng = ((bbox[0] + 180) % 360 + 360) % 360 - 180; const minLat = Math.max(-90, Math.min(90, bbox[1])); let maxLng = bbox[2] === 180 ? 180 : ((bbox[2] + 180) % 360 + 360) % 360 - 180; const maxLat = Math.max(-90, Math.min(90, bbox[3])); if (bbox[2] - bbox[0] >= 360) { minLng = -180; maxLng = 180; } else if (minLng > maxLng) { const easternHem = this.getClustersInternal([minLng, minLat, 180, maxLat], zoom); const westernHem = this.getClustersInternal([-180, minLat, maxLng, maxLat], zoom); return easternHem.concat(westernHem); } const tree = this.trees[this.limitZoom(zoom)]; const ids = tree.range(projectX(minLng), projectY(maxLat), projectX(maxLng), projectY(minLat)); const data = tree.flatData; const clusters = []; for (const id of ids) { const k = this.stride * id; clusters.push(data[k + OFFSET_NUM] > 1 ? getClusterFeature(data, k, this.clusterProps) : this.points[data[k + OFFSET_ID]]); } return clusters; } /** * Returns the immediate children (clusters or points) of a cluster as GeoJSON. * @param clusterId - The target cluster id. */ getChildren(clusterId) { const originId = this.getOriginId(clusterId); const originZoom = this.getOriginZoom(clusterId); const clusterError = new Error('No cluster with the specified id: ' + clusterId); const tree = this.trees[originZoom]; if (!tree) throw clusterError; const data = tree.flatData; if (originId * this.stride >= data.length) throw clusterError; const r = this.options.radius / (this.options.extent * Math.pow(2, originZoom - 1)); const x = data[originId * this.stride]; const y = data[originId * this.stride + 1]; const ids = tree.within(x, y, r); const children = []; for (const id of ids) { const k = id * this.stride; if (data[k + OFFSET_PARENT] === clusterId) { children.push(data[k + OFFSET_NUM] > 1 ? getClusterGeoJSON(data, k, this.clusterProps) : featureToGeoJSON(this.points[data[k + OFFSET_ID]])); } } if (children.length === 0) throw clusterError; return children; } /** * Returns leaf point features under a cluster, paginated by `limit` and `offset`. * @param clusterId - The target cluster id. * @param limit - Maximum number of points to return (defaults to `10`). * @param offset - Number of points to skip before collecting results (defaults to `0`). */ getLeaves(clusterId, limit, offset) { limit = limit || 10; offset = offset || 0; const leaves = []; this.appendLeaves(leaves, clusterId, limit, offset, 0); return leaves; } /** * Generates a vector-tile-like representation of a single tile. * @param z - Tile zoom. * @param x - Tile x coordinate. * @param y - Tile y coordinate. */ getTile(z, x, y) { const tree = this.trees[this.limitZoom(z)]; if (!tree) { return null; } const z2 = Math.pow(2, z); const { extent, radius } = this.options; const p = radius / extent; const top = (y - p) / z2; const bottom = (y + 1 + p) / z2; const tile = { transformed: true, features: [], source: null, x: x, y: y, z: z }; this.addTileFeatures(tree.range((x - p) / z2, top, (x + 1 + p) / z2, bottom), tree.flatData, x, y, z2, tile); if (x === 0) { this.addTileFeatures(tree.range(1 - p / z2, top, 1, bottom), tree.flatData, z2, y, z2, tile); } if (x === z2 - 1) { this.addTileFeatures(tree.range(0, top, p / z2, bottom), tree.flatData, -1, y, z2, tile); } return tile; } /** * Returns the zoom level at which a cluster expands into multiple children. * @param clusterId - The target cluster id. */ getClusterExpansionZoom(clusterId) { return this.getOriginZoom(clusterId); } appendLeaves(result, clusterId, limit, offset, skipped) { const children = this.getChildren(clusterId); for (const child of children) { const props = child.properties; if (props?.cluster) { if (skipped + props.point_count <= offset) { // skip the whole cluster skipped += props.point_count; } else { // enter the cluster skipped = this.appendLeaves(result, props.cluster_id, limit, offset, skipped); // exit the cluster } } else if (skipped < offset) { // skip a single point skipped++; } else { // add a single point result.push(child); } if (result.length === limit) break; } return skipped; } createTree(data) { const tree = new KDBush(data.length / this.stride | 0, this.options.nodeSize, Float32Array); for (let i = 0; i < data.length; i += this.stride) tree.add(data[i], data[i + 1]); tree.finish(); tree.flatData = data; tree.data = null; // clear original data to free memory as it isn't used later on. return tree; } addTileFeatures(ids, data, x, y, z2, tile) { for (const i of ids) { const k = i * this.stride; const isCluster = data[k + OFFSET_NUM] > 1; let tags; let px; let py; if (isCluster) { tags = getClusterProperties(data, k, this.clusterProps); px = data[k]; py = data[k + 1]; } else { const p = this.points[data[k + OFFSET_ID]]; tags = p.tags; [px, py] = p.geometry; } const f = { type: 1, geometry: [[ Math.round(this.options.extent * (px * z2 - x)), Math.round(this.options.extent * (py * z2 - y)) ]], tags }; // assign id let id; if (isCluster || this.options.generateId) { // optionally generate id for points id = data[k + OFFSET_ID]; } else { // keep id if already assigned id = this.points[data[k + OFFSET_ID]].id; } if (id !== undefined) f.id = id; tile.features.push(f); } } limitZoom(z) { return Math.max(this.options.minZoom, Math.min(Math.floor(+z), this.options.maxZoom + 1)); } cluster(tree, zoom) { const { radius, extent, reduce, minPoints } = this.options; const r = radius / (extent * Math.pow(2, zoom)); const data = tree.flatData; const nextData = []; const stride = this.stride; // loop through each point for (let i = 0; i < data.length; i += stride) { // if we've already visited the point at this zoom level, skip it if (data[i + OFFSET_ZOOM] <= zoom) continue; data[i + OFFSET_ZOOM] = zoom; // find all nearby points const x = data[i]; const y = data[i + 1]; const neighborIds = tree.within(data[i], data[i + 1], r); const numPointsOrigin = data[i + OFFSET_NUM]; let numPoints = numPointsOrigin; // count the number of points in a potential cluster for (const neighborId of neighborIds) { const k = neighborId * stride; // filter out neighbors that are already processed if (data[k + OFFSET_ZOOM] > zoom) numPoints += data[k + OFFSET_NUM]; } // if there were neighbors to merge, and there are enough points to form a cluster if (numPoints > numPointsOrigin && numPoints >= minPoints) { let wx = x * numPointsOrigin; let wy = y * numPointsOrigin; let clusterProperties; let clusterPropIndex = -1; // encode both zoom and point index on which the cluster originated -- offset by total length of features const id = ((i / stride | 0) << 5) + (zoom + 1) + this.points.length; for (const neighborId of neighborIds) { const k = neighborId * stride; if (data[k + OFFSET_ZOOM] <= zoom) continue; data[k + OFFSET_ZOOM] = zoom; // save the zoom (so it doesn't get processed twice) const numPoints2 = data[k + OFFSET_NUM]; wx += data[k] * numPoints2; // accumulate coordinates for calculating weighted center wy += data[k + 1] * numPoints2; data[k + OFFSET_PARENT] = id; if (reduce) { if (!clusterProperties) { clusterProperties = this.map(data, i, true); clusterPropIndex = this.clusterProps.length; this.clusterProps.push(clusterProperties); } reduce(clusterProperties, this.map(data, k)); } } data[i + OFFSET_PARENT] = id; nextData.push(wx / numPoints, wy / numPoints, Infinity, id, -1, numPoints); if (reduce) nextData.push(clusterPropIndex); } else { // left points as unclustered for (let j = 0; j < stride; j++) nextData.push(data[i + j]); if (numPoints > 1) { for (const neighborId of neighborIds) { const k = neighborId * stride; if (data[k + OFFSET_ZOOM] <= zoom) continue; data[k + OFFSET_ZOOM] = zoom; for (let j = 0; j < stride; j++) nextData.push(data[k + j]); } } } } return nextData; } // get index of the point from which the cluster originated getOriginId(clusterId) { return (clusterId - this.points.length) >> 5; } // get zoom of the point from which the cluster originated getOriginZoom(clusterId) { return (clusterId - this.points.length) % 32; } map(data, i, clone) { if (data[i + OFFSET_NUM] > 1) { const props = this.clusterProps[data[i + OFFSET_PROP]]; return clone ? Object.assign({}, props) : props; } const original = this.points[data[i + OFFSET_ID]].tags; const result = this.options.map(original); return clone && result === original ? Object.assign({}, result) : result; } } function getClusterFeature(data, i, clusterProps) { return { id: data[i + OFFSET_ID], type: 'Point', tags: getClusterProperties(data, i, clusterProps), geometry: [data[i], data[i + 1]] }; } function getClusterGeoJSON(data, i, clusterProps) { return { type: 'Feature', id: data[i + OFFSET_ID], properties: getClusterProperties(data, i, clusterProps), geometry: { type: 'Point', coordinates: [unprojectX(data[i]), unprojectY(data[i + 1])] } }; } function getClusterProperties(data, i, clusterProps) { const count = data[i + OFFSET_NUM]; const abbrev = count >= 10000 ? `${Math.round(count / 1000)}k` : count >= 1000 ? `${Math.round(count / 100) / 10}k` : count; const propIndex = data[i + OFFSET_PROP]; const properties = propIndex === -1 ? {} : Object.assign({}, clusterProps[propIndex]); return Object.assign(properties, { cluster: true, cluster_id: data[i + OFFSET_ID], point_count: count, point_count_abbreviated: abbrev }); } const GEOJSONVT_CLIP_START = 'geojsonvt_clip_start'; const GEOJSONVT_CLIP_END = 'geojsonvt_clip_end'; /** * Creates a tile object from the given features * @param features - the features to include in the tile * @param z * @param tx * @param ty * @param options - the options object * @returns the created tile */ function createTile(features, z, tx, ty, options) { const tolerance = z === options.maxZoom ? 0 : options.tolerance / ((1 << z) * options.extent); const tile = { transformed: false, features: [], source: null, x: tx, y: ty, z: z, minX: 2, minY: 1, maxX: -1, maxY: 0, numPoints: 0, numSimplified: 0, numFeatures: features.length }; for (const feature of features) { addFeature(tile, feature, tolerance, options); } return tile; } function addFeature(tile, feature, tolerance, options) { tile.minX = Math.min(tile.minX, feature.minX); tile.minY = Math.min(tile.minY, feature.minY); tile.maxX = Math.max(tile.maxX, feature.maxX); tile.maxY = Math.max(tile.maxY, feature.maxY); switch (feature.type) { case 'Point': case 'MultiPoint': addPointsTileFeature(tile, feature); return; case 'LineString': addLineTileFeautre(tile, feature, tolerance, options); return; case 'MultiLineString': case 'Polygon': addLinesTileFeature(tile, feature, tolerance); return; case 'MultiPolygon': addMultiPolygonTileFeature(tile, feature, tolerance); return; } } function addPointsTileFeature(tile, feature) { const geometry = []; for (let i = 0; i < feature.geometry.length; i += 3) { geometry.push(feature.geometry[i], feature.geometry[i + 1]); tile.numPoints++; tile.numSimplified++; } if (!geometry.length) return; const tileFeature = { type: 1, tags: feature.tags || null, geometry: geometry }; if (feature.id !== null) { tileFeature.id = feature.id; } tile.features.push(tileFeature); } function addLineTileFeautre(tile, feature, tolerance, options) { const geometry = []; addLine(geometry, feature.geometry, tile, tolerance, false, false); if (!geometry.length) return; let tags = feature.tags || null; if (options.lineMetrics) { tags = {}; for (const key in feature.tags) tags[key] = feature.tags[key]; tags[GEOJSONVT_CLIP_START] = feature.geometry.start / feature.geometry.size; tags[GEOJSONVT_CLIP_END] = feature.geometry.end / feature.geometry.size; } const tileFeature = { type: 2, tags: tags, geometry: geometry }; if (feature.id !== null) { tileFeature.id = feature.id; } tile.features.push(tileFeature); } function addLinesTileFeature(tile, feature, tolerance) { const geometry = []; for (let i = 0; i < feature.geometry.length; i++) { addLine(geometry, feature.geometry[i], tile, tolerance, feature.type === 'Polygon', i === 0); } if (!geometry.length) return; const tileFeature = { type: feature.type === 'Polygon' ? 3 : 2, tags: feature.tags || null, geometry: geometry }; if (feature.id !== null) { tileFeature.id = feature.id; } tile.features.push(tileFeature); } function addMultiPolygonTileFeature(tile, feature, tolerance) { const geometry = []; for (let k = 0; k < feature.geometry.length; k++) { const polygon = feature.geometry[k]; for (let i = 0; i < polygon.length; i++) { addLine(geometry, polygon[i], tile, tolerance, true, i === 0); } } if (!geometry.length) return; const tileFeature = { type: 3, tags: feature.tags || null, geometry: geometry }; if (feature.id !== null) { tileFeature.id = feature.id; } tile.features.push(tileFeature); } function addLine(result, geom, tile, tolerance, isPolygon, isOuter) { const sqTolerance = tolerance * tolerance; if (tolerance > 0 && (geom.size < (isPolygon ? sqTolerance : tolerance))) { tile.numPoints += geom.length / 3; return; } const ring = []; for (let i = 0; i < geom.length; i += 3) { if (tolerance === 0 || geom[i + 2] > sqTolerance) { tile.numSimplified++; ring.push(geom[i], geom[i + 1]); } tile.numPoints++; } if (isPolygon) rewind(ring, isOuter); result.push(ring); } function rewind(ring, clockwise) { let area = 0; for (let i = 0, len = ring.length, j = len - 2; i < len; j = i, i += 2) { area += (ring[i] - ring[j]) * (ring[i + 1] + ring[j + 1]); } if (area > 0 !== clockwise) return; for (let i = 0, len = ring.length; i < len / 2; i += 2) { const x = ring[i]; const y = ring[i + 1]; ring[i] = ring[len - 2 - i]; ring[i + 1] = ring[len - 1 - i]; ring[len - 2 - i] = x; ring[len - 1 - i] = y; } } /** * Transforms the coordinates of each feature in the given tile from * mercator-projected space into (extent x extent) tile space. * @param tile - the tile to transform, this gets modified in place * @param extent - the tile extent (usually 4096) * @returns the transformed tile */ function transformTile(tile, extent) { if (tile.transformed) { return tile; } const z2 = 1 << tile.z; const tx = tile.x; const ty = tile.y; for (const feature of tile.features) { if (feature.type === 1) { transformPointFeature(feature, extent, z2, tx, ty); } else { transformNonPointFeature(feature, extent, z2, tx, ty); } } tile.transformed = true; return tile; } /** * Transforms a single point feature from mercator-projected space into (extent x extent) tile space. */ function transformPointFeature(feature, extent, z2, tx, ty) { const transformed = feature; const geometry = feature.geometry; const point = []; for (let i = 0; i < geometry.length; i += 2) { point.push(transformPoint(geometry[i], geometry[i + 1], extent, z2, tx, ty)); } transformed.geometry = point; return transformed; } /** * Transforms a single non-point feature from mercator-projected space into (extent x extent) tile space. */ function transformNonPointFeature(feature, extent, z2, tx, ty) { const transformed = feature; const geometry = feature.geometry; const nonPoint = []; for (const geom of geometry) { const ring = []; for (let i = 0; i < geom.length; i += 2) { ring.push(transformPoint(geom[i], geom[i + 1], extent, z2, tx, ty)); } nonPoint.push(ring); } transformed.geometry = nonPoint; return transformed; } function transformPoint(x, y, extent, z2, tx, ty) { return [ Math.round(extent * (x * z2 - tx)), Math.round(extent * (y * z2 - ty)) ]; } class TileIndex { constructor(options) { this.options = options; /** @internal */ this.stats = {}; /** @internal */ this.total = 0; this.tiles = {}; this.tileCoords = []; this.stats = {}; this.total = 0; } initialize(features) { // start slicing from the top tile down this.splitTile(features, 0, 0, 0); if (this.options.debug) { if (features.length) console.log('features: %d, points: %d', this.tiles[0].numFeatures, this.tiles[0].numPoints); console.timeEnd('generate tiles'); console.log('tiles generated:', this.total, JSON.stringify(this.stats)); } } /** {@inheritdoc} */ updateIndex(source, affected, options) { if (options.debug > 1) { console.log('invalidating tiles'); console.time('invalidating'); } this.invalidateTiles(affected); if (options.debug > 1) console.timeEnd('invalidating'); // re-generate root tile with updated feature set const [z, x, y] = [0, 0, 0]; const rootTile = createTile(source, z, x, y, options); rootTile.source = source; // update tile index with new root tile - ready for getTile calls const id = toID(z, x, y); this.tiles[id] = rootTile; this.tileCoords.push({ z, x, y, id }); if (options.debug) { const key = `z${z}`; this.stats[key] = (this.stats[key] || 0) + 1; this.total++; } } /** {@inheritdoc} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars getClusterExpansionZoom(_clusterId) { return null; } /** {@inheritdoc} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars getChildren(_clusterId) { return null; } /** {@inheritdoc} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars getLeaves(_clusterId, _limit, _offset) { return null; } /** {@inheritdoc} */ getTile(z, x, y) { const { extent, debug } = this.options; const z2 = 1 << z; x = (x + z2) & (z2 - 1); // wrap tile x coordinate const id = toID(z, x, y); if (this.tiles[id]) { return transformTile(this.tiles[id], extent); } if (debug > 1) console.log('drilling down to z%d-%d-%d', z, x, y); let z0 = z; let x0 = x; let y0 = y; let parent; while (!parent && z0 > 0) { z0--; x0 = x0 >> 1; y0 = y0 >> 1; parent = this.tiles[toID(z0, x0, y0)]; } if (!parent?.source) return null; // if we found a parent tile containing the original geometry, we can drill down from it if (debug > 1) { console.log('found parent tile z%d-%d-%d', z0, x0, y0); console.time('drilling down'); } this.splitTile(parent.source, z0, x0, y0, z, x, y); if (debug > 1) console.timeEnd('drilling down'); if (!this.tiles[id]) return null; return transformTile(this.tiles[id], extent); } /** * splits features from a parent tile to sub-tiles. * z, x, and y are the coordinates of the parent tile * cz, cx, and cy are the coordinates of the target tile * * If no target tile is specified, splitting stops when we reach the maximum * zoom or the number of points is low as specified in the options. * @internal * @param features - features to split * @param z - tile zoom level * @param x - tile x coordinate * @param y - tile y coordinate * @param cz - target tile zoom level * @param cx - target tile x coordinate * @param cy - target tile y coordinate */ splitTile(features, z, x, y, cz, cx, cy) { const stack = [features, z, x, y]; const options = this.options; const debug = options.debug; // avoid recursion by using a processing queue while (stack.length) { y = stack.pop(); x = stack.pop(); z = stack.pop(); features = stack.pop(); const z2 = 1 << z; const id = toID(z, x, y); let tile = this.tiles[id]; if (!tile) { if (debug > 1) console.time('creation'); tile = this.tiles[id] = createTile(features, z, x, y, options); this.tileCoords.push({ z, x, y, id }); if (debug) { if (debug > 1) { console.log('tile z%d-%d-%d (features: %d, points: %d, simplified: %d)', z, x, y, tile.numFeatures, tile.numPoints, tile.numSimplified); console.timeEnd('creation'); } const key = `z${z}`; this.stats[key] = (this.stats[key] || 0) + 1; this.total++; } } // save reference to original geometry in tile so that we can drill down later if we stop now tile.source = features; // if it's the first-pass tiling if (cz == null) { // stop tiling if we reached max zoom, or if the tile is too simple if (z === options.indexMaxZoom || tile.numPoints <= options.indexMaxPoints) continue; // if a drilldown to a specific tile } else if (z === options.maxZoom || z === cz) { // stop tiling if we reached base zoom or our target tile zoom continue; } else if (cz != null) { // stop tiling if it's not an ancestor of the target tile const zoomSteps = cz - z; if (x !== cx >> zoomSteps || y !== cy >> zoomSteps) continue; } // if we slice further down, no need to keep source geometry tile.source = null; if (!features.length) continue; if (debug > 1) console.time('clipping'); // values we'll use for clipping const k1 = 0.5 * options.buffer / options.extent; const k2 = 0.5 - k1; const k3 = 0.5 + k1; const k4 = 1 + k1; let tl = null; let bl = null; let tr = null; let br = null; const left = clip(features, z2, x - k1, x + k3, AxisType.X, tile.minX, tile.maxX, options); const right = clip(features, z2, x + k2, x + k4, AxisType.X, tile.minX, tile.maxX, options); if (left) { tl = clip(left, z2, y - k1, y + k3, AxisType.Y, tile.minY, tile.maxY, options); bl = clip(left, z2, y + k2, y + k4, AxisType.Y, tile.minY, tile.maxY, options); } if (right) { tr = clip(right, z2, y - k1, y + k3, AxisType.Y, tile.minY, tile.maxY, options); br = clip(right, z2, y + k2, y + k4, AxisType.Y, tile.minY, tile.maxY, options); } if (debug > 1) console.timeEnd('clipping'); stack.push(tl || [], z + 1, x * 2, y * 2); stack.push(bl || [], z + 1, x * 2, y * 2 + 1); stack.push(tr || [], z + 1, x * 2 + 1, y * 2); stack.push(br || [], z + 1, x * 2 + 1, y * 2 + 1); } } /** * Invalidates (removes) tiles affected by the provided features * @internal * @param features */ invalidateTiles(features) { if (!features.length) return; const options = this.options; const { debug } = options; // calculate bounding box of all features for trivial reject let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; for (const feature of features) { minX = Math.min(minX, feature.minX); maxX = Math.max(maxX, feature.maxX); minY = Math.min(minY, feature.minY); maxY = Math.max(maxY, feature.maxY); } // tile buffer clipping value - not halved as in splitTile above because checking against tile's own extent const k1 = options.buffer / options.extent; // track removed tile ids for o(1) lookup const removedLookup = new Set(); // iterate through existing tiles and remove ones that are affected by features for (const id in this.tiles) { const tile = this.tiles[id]; // calculate tile bounds including buffer const z2 = 1 << tile.z; const tileMinX = (tile.x - k1) / z2; const tileMaxX = (tile.x + 1 + k1) / z2; const tileMinY = (tile.y - k1) / z2; const tileMaxY = (tile.y + 1 + k1) / z2; // trivial reject if feature bounds don't intersect tile if (maxX < tileMinX || minX >= tileMaxX || maxY < tileMinY || minY >= tileMaxY) { continue; } // check if any feature intersects with the tile let intersects = false; for (const feature of features) { if (feature.maxX >= tileMinX && feature.minX < tileMaxX && feature.maxY >= tileMinY && feature.minY < tileMaxY) { intersects = true; break; } } if (!intersects) continue; if (debug) { if (debug > 1) { console.log('invalidate tile z%d-%d-%d (features: %d, points: %d, simplified: %d)', tile.z, tile.x, tile.y, tile.numFeatures, tile.numPoints, tile.numSimplified); } const key = `z${tile.z}`; this.stats[key] = (this.stats[key] || 0) - 1; this.total--; } delete this.tiles[id]; removedLookup.add(id); } // remove tile coords that are no longer in the index if (removedLookup.size) { this.tileCoords = this.tileCoords.filter(c => !removedLookup.has(c.id)); } } } function toID(z, x, y) { return (((1 << z) * y + x) * 32) + z; } const defaultOptions = { maxZoom: 14, indexMaxZoom: 5, indexMaxPoints: 100000, tolerance: 3, extent: 4096, buffer: 64, lineMetrics: false, promoteId: null, generateId: false, updateable: false, cluster: false, clusterOptions: defaultClusterOptions, debug: 0 }; /** * Main class for creating and managing a vector tile index from GeoJSON data. */ class GeoJSONVT { /** * @internal * This is for the tests */ get tiles() { // eslint-disable-next-line @typescript-eslint/no-explicit-any return this.tileIndex?.tiles ?? {}; } /** * @internal * This is for the tests */ get stats() { // eslint-disable-next-line @typescript-eslint/no-explicit-any return this.tileIndex.stats; } /** * @internal * This is for the tests */ get total() { // eslint-disable-next-line @typescript-eslint/no-explicit-any return this.tileIndex.total; } constructor(data, options) { options = this.options = Object.assign({}, defaultOptions, options); const debug = options.debug; if (debug) console.time('preprocess data'); if (options.maxZoom < 0 || options.maxZoom > 24) throw new Error('maxZoom should be in the 0-24 range'); if (options.promoteId && options.generateId) throw new Error('promoteId and generateId cannot be used together.'); // projects and adds simplification info let features = convertToInternal(data, options); if (debug) { console.timeEnd('preprocess data'); console.log('index: maxZoom: %d, maxPoints: %d', options.indexMaxZoom, options.indexMaxPoints); console.time('generate tiles'); } // wraps features (ie extreme west and extreme east) features = wrap(features, options); // for updateable indexes, store a copy of the original simplified features if (options.updateable) { this.source = features; } this.initializeIndex(features, options); } initializeIndex(features, options) { this.tileIndex = options.cluster ? new ClusterTileIndex(options.clusterOptions) : new TileIndex(options); if (!features.length) return; this.tileIndex.initialize(features); } /** * Given z, x, and y tile coordinates, returns the corresponding tile with geometries in tile coordinates, much like MVT data is stored. * @param z - tile zoom level * @param x - tile x coordinate * @param y - tile y coordinate * @returns the transformed tile or null if not found */ getTile(z, x, y) { z = +z; x = +x; y = +y; if (z < 0 || z > 24) return null; return this.tileIndex.getTile(z, x, y); } /** * Updates the source data feature set using a {@link GeoJSONVTSourceDiff} * @param diff - the source diff object */ updateData(diff, filter) { const options = this.options; if (!options.updateable) throw new Error('to update tile geojson `updateable` option must be set to true'); // apply diff and collect affected features and updated source that will be used to invalidate tiles let { affected, source } = applySourceDiff(this.source, diff, options); if (filter) { ({ affected, source } = this.filterUpdate(source, affected, filter)); } // nothing has changed if (!affected.length) return; // update source with new simplified feature set this.source = source; this.tileIndex.updateIndex(source, affected, options); } /** * Filter an update using a predicate function. Returns the affected and updated source features. */ filterUpdate(source, affected, predicate) { const removeIds = new Set(); for (const feature of source) { if (feature.id == undefined) continue; if (predicate(featureToGeoJSON(feature))) continue; affected.push(feature); removeIds.add(feature.id); } source = source.filter(feature => !removeIds.has(feature.id)); return { affected, source }; } /** * Returns source data as GeoJSON - only available when `updateable` option is set to true. */ getData() { if (!this.options.updateable) throw new Error('to retrieve data the `updateable` option must be set to true'); return convertToGeoJSON(this.source); } /** * Update supercluster options and regenerate the index. * @param cluster - whether to enable clustering * @param clusterOptions - {@link SuperclusterOptions} */ updateClusterOptions(cluster, clusterOptions) { const wasCluster = this.options.cluster; this.options.cluster = cluster; this.options.clusterOptions = clusterOptions; if (wasCluster == cluster) { this.tileIndex.updateIndex(this.source, [], this.options); return; } this.initializeIndex(this.source, this.options); } /** * Returns the zoom level at which a cluster expands into multiple children. * @param clusterId - The target cluster id. * @returns the expansion zoom or null in case of non-clustered source */ getClusterExpansionZoom(clusterId) { return this.tileIndex.getClusterExpansionZoom(clusterId); } /** * Returns the immediate children (clusters or points) of a cluster as GeoJSON. * @param clusterId - The target cluster id. * @returns the immediate children or null in case of non-clustered source */ getClusterChildren(clusterId) { return this.tileIndex.getChildren(clusterId); } /** * Returns leaf point features under a cluster, paginated by `limit` and `offset`. * @param clusterId - The target cluster id. * @param limit - Maximum number of points to return (defaults to `10`). * @param offset - Number of points to skip before collecting results (defaults to `0`). * @returns leaf point features under a cluster or null in case of non-clustered source */ getClusterLeaves(clusterId, limit, offset) { return this.tileIndex.getLeaves(clusterId, limit, offset); } } /** * Converts GeoJSON data directly to a single vector tile without building a tile index. * * Unlike the {@link GeoJSONVT} class which builds a hierarchical tile index for efficient * repeated tile access, this function generates a single tile on-demand. This is useful when: * - You only need one specific tile and don't need to query multiple tiles * - The source data is already spatially filtered to the tile's bounding box * - You want to avoid the overhead of building a full tile index * * @example * ```ts * import {geoJSONToTile} from '@maplibre/geojson-vt'; * * const geojson = { * type: 'FeatureCollection', * features: [{ * type: 'Feature', * geometry: { type: 'Point', coordinates: [-77.03, 38.90] }, * properties: { name: 'Washington, D.C.' } * }] * }; * * const tile = geoJSONToTile(geojson, 10, 292, 391, { extent: 4096 }); * ``` * * @param data - GeoJSON data (Feature, FeatureCollection, or Geometry) * @param z - Tile zoom level * @param x - Tile x coordinate * @param y - Tile y coordinate * @param options - Optional configuration for tile generation * @returns The generated tile with geometries in tile coordinates, or null if no features */ function geoJSONToTile(data, z, x, y, options = {}) { options = { ...defaultOptions, ...options }; const { wrap: shouldWrap = false, clip: shouldClip = false } = options; let features = convertToInternal(data, options); if (shouldWrap) { features = wrap(features, options); } if (shouldClip || options.lineMetrics) { const pow2 = 1 << z; const buffer = options.buffer / options.extent; const left = clip(features, pow2, (x - buffer), (x + 1 + buffer), AxisType.X, -1, 2, options); features = clip(left || [], pow2, (y - buffer), (y + 1 + buffer), AxisType.Y, -1, 2, options); } return transformTile(createTile(features ?? [], z, x, y, options), options.extent); } exports.GEOJSONVT_CLIP_END = GEOJSONVT_CLIP_END; exports.GEOJSONVT_CLIP_START = GEOJSONVT_CLIP_START; exports.GeoJSONVT = GeoJSONVT; exports.Supercluster = ClusterTileIndex; exports.geoJSONToTile = geoJSONToTile; }));