2294 lines
80 KiB
JavaScript
2294 lines
80 KiB
JavaScript
(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<TypedArrayConstructor>} 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<TypedArrayConstructor>} 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<TypedArrayConstructor>} 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<TypedArrayConstructor>} 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;
|
|
|
|
}));
|