Initial commit

This commit is contained in:
2026-04-15 17:08:39 +02:00
parent ae164c47a8
commit 47fd1c2b7a
1819 changed files with 685388 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
import {test, expect} from 'vitest';
import {clip} from './clip';
import type { StartEndSizeArray } from './definitions';
const geom1 = [0,0,0,50,0,0,50,10,0,20,10,0,20,20,0,30,20,0,30,30,0,50,30,0,50,40,0,25,40,0,25,50,0,0,50,0,0,60,0,25,60,0];
const geom2 = [0,0,0,50,0,0,50,10,0,0,10,0];
test('clips polylines', () => {
const clipped = clip([
{geometry: geom1, type: 'LineString', tags: { "1": 1}, minX: 0, minY: 0, maxX: 50, maxY: 60},
{geometry: geom2, type: 'LineString', tags: { "2": 2}, minX: 0, minY: 0, maxX: 50, maxY: 10}
], 1, 10, 40, 0, -Infinity, Infinity, {});
const expected = [
{id: null as string, type: 'MultiLineString', geometry: [
[10,0,1,40,0,1],
[40,10,1,20,10,0,20,20,0,30,20,0,30,30,0,40,30,1],
[40,40,1,25,40,0,25,50,0,10,50,1],
[10,60,1,25,60,0]], tags: {"1": 1}, minX: 10, minY: 0, maxX: 40, maxY: 60},
{id: null as string, type: 'MultiLineString', geometry: [
[10,0,1,40,0,1],
[40,10,1,10,10,1]], tags: {"2": 2}, minX: 10, minY: 0, maxX: 40, maxY: 10}
];
expect(JSON.stringify(clipped)).toEqual(JSON.stringify(expected));
});
test('clips lines with line metrics on', () => {
const geom = geom1.slice() as StartEndSizeArray;
geom.size = 0;
for (let i = 0; i < geom.length - 3; i += 3) {
const dx = geom[i + 3] - geom[i];
const dy = geom[i + 4] - geom[i + 1];
geom.size += Math.sqrt(dx * dx + dy * dy);
}
geom.start = 0;
geom.end = geom.size;
const clipped = clip([{id: 1, geometry: geom, type: 'LineString', tags: {}, minX: 0, minY: 0, maxX: 50, maxY: 60}],
1, 10, 40, 0, -Infinity, Infinity, {lineMetrics: true});
expect(
clipped.map(f => [(f.geometry as StartEndSizeArray).start, (f.geometry as StartEndSizeArray).end])).toEqual(
[[10, 40], [70, 130], [160, 200], [230, 245]]
);
});
function closed(geometry: number[]): number[][] {
return [geometry.concat(geometry.slice(0, 3))];
}
test('clips polygons', () => {
const clipped = clip([
{geometry: closed(geom1), type: 'Polygon', tags: {"1": 1}, minX: 0, minY: 0, maxX: 50, maxY: 60},
{geometry: closed(geom2), type: 'Polygon', tags: {"2": 2}, minX: 0, minY: 0, maxX: 50, maxY: 10}
], 1, 10, 40, 0, -Infinity, Infinity, {});
const expected = [
{id: null as string, type: 'Polygon', geometry: [[10,0,1,40,0,1,40,10,1,20,10,0,20,20,0,30,20,0,30,30,0,40,30,1,40,40,1,25,40,0,25,50,0,10,50,1,10,60,1,25,60,0,10,24,1,10,0,1]], tags: {"1": 1}, minX: 10, minY: 0, maxX: 40, maxY: 60},
{id: null as string, type: 'Polygon', geometry: [[10,0,1,40,0,1,40,10,1,10,10,1,10,0,1]], tags: {"2": 2}, minX: 10, minY: 0, maxX: 40, maxY: 10}
];
expect(JSON.stringify(clipped)).toEqual(JSON.stringify(expected));
});
test('clips points', () => {
const clipped = clip([
{geometry: geom1, type: 'MultiPoint', tags: {"1": 1}, minX: 0, minY: 0, maxX: 50, maxY: 60},
{geometry: geom2, type: 'MultiPoint', tags: {"2": 2}, minX: 0, minY: 0, maxX: 50, maxY: 10}
], 1, 10, 40, 0, -Infinity, Infinity, {});
expect(clipped).toEqual([{id: null, type: 'MultiPoint',
geometry: [20,10,0,20,20,0,30,20,0,30,30,0,25,40,0,25,50,0,25,60,0], tags: {"1": 1}, minX: 20, minY: 10, maxX: 30, maxY: 60}]);
});
+270
View File
@@ -0,0 +1,270 @@
import {createFeature} from './feature';
import type { GeoJSONVTInternalFeature, GeoJSONVTInternalLineStringFeature, GeoJSONVTInternalMultiLineStringFeature, GeoJSONVTInternalMultiPointFeature, GeoJSONVTInternalMultiPolygonFeature, GeoJSONVTInternalPointFeature, GeoJSONVTInternalPolygonFeature, GeoJSONVTOptions, StartEndSizeArray } from './definitions';
export const enum AxisType {
X = 0,
Y = 1
}
/**
* 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
*/
export function clip(features: GeoJSONVTInternalFeature[], scale: number, start: number, end: number, axis: AxisType, minAll: number, maxAll: number, options: GeoJSONVTOptions): GeoJSONVTInternalFeature[] | null {
start /= scale;
end /= scale;
if (minAll >= start && maxAll < end) { // trivial accept
return features;
}
if (maxAll < start || minAll >= end) { // trivial reject
return null;
}
const clipped: GeoJSONVTInternalFeature[] = [];
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: GeoJSONVTInternalPointFeature | GeoJSONVTInternalMultiPointFeature, clipped: GeoJSONVTInternalFeature[], start: number, end: number, axis: AxisType) {
const geom: number[] = [];
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: GeoJSONVTInternalLineStringFeature, clipped: GeoJSONVTInternalFeature[], start: number, end: number, axis: AxisType, options: GeoJSONVTOptions) {
const geom: StartEndSizeArray[] = [];
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: GeoJSONVTInternalMultiLineStringFeature, clipped: GeoJSONVTInternalFeature[], start: number, end: number, axis: AxisType) {
const geom: StartEndSizeArray[] = [];
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: GeoJSONVTInternalPolygonFeature, clipped: GeoJSONVTInternalFeature[], start: number, end: number, axis: AxisType) {
const geom: StartEndSizeArray[] = [];
clipLines(feature.geometry, geom, start, end, axis, true);
if (!geom.length) return;
clipped.push(createFeature(feature.id, 'Polygon', geom, feature.tags));
}
function clipMultiPolygonFeature(feature: GeoJSONVTInternalMultiPolygonFeature, clipped: GeoJSONVTInternalFeature[], start: number, end: number, axis: AxisType) {
const geom: StartEndSizeArray[][] = [];
for (const polygon of feature.geometry) {
const newPolygon: StartEndSizeArray[] = [];
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: number[], newGeom: number[], start: number, end: number, axis: AxisType) {
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: StartEndSizeArray, newGeom: StartEndSizeArray[], start: number, end: number, axis: AxisType, isPolygon: boolean, trackMetrics: boolean) {
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: StartEndSizeArray): StartEndSizeArray {
const slice: StartEndSizeArray = [];
slice.size = line.size;
slice.start = line.start;
slice.end = line.end;
return slice;
}
function clipLines(geom: StartEndSizeArray[], newGeom: StartEndSizeArray[], start: number, end: number, axis: AxisType, isPolygon: boolean) {
for (const line of geom) {
clipLine(line, newGeom, start, end, axis, isPolygon, false);
}
}
function addPoint(out: number[], x: number, y: number, z: number) {
out.push(x, y, z);
}
function intersectX(out: StartEndSizeArray, ax: number, ay: number, bx: number, by: number, x: number) {
const t = (x - ax) / (bx - ax);
addPoint(out, x, ay + (by - ay) * t, 1);
return t;
}
function intersectY(out: StartEndSizeArray, ax: number, ay: number, bx: number, by: number, y: number) {
const t = (y - ay) / (by - ay);
addPoint(out, ax + (bx - ax) * t, y, 1);
return t;
}
+205
View File
@@ -0,0 +1,205 @@
import {test, expect} from 'vitest';
import {readFileSync} from 'fs';
import {ClusterTileIndex} from './cluster-tile-index';
import type {ClusterProperties, GeoJSONVTTile} from './definitions';
const places = JSON.parse(readFileSync(new URL('../test/fixtures/places.json', import.meta.url), 'utf-8')) as GeoJSON.FeatureCollection<GeoJSON.Point>;
const placesTile = JSON.parse(readFileSync(new URL('../test/fixtures/places-z0-0-0.json', import.meta.url), 'utf-8')) as GeoJSONVTTile;
const placesTileMin5 = JSON.parse(readFileSync(new URL('../test/fixtures/places-z0-0-0-min5.json', import.meta.url), 'utf-8')) as GeoJSONVTTile;
test('generates clusters properly', () => {
const index = new ClusterTileIndex();
index.load(places.features);
const tile = index.getTile(0, 0, 0);
expect(tile?.features).toEqual(placesTile.features);
});
test('supports minPoints option', () => {
const index = new ClusterTileIndex({minPoints: 5});
index.load(places.features);
const tile = index.getTile(0, 0, 0);
expect(tile?.features).toEqual(placesTileMin5.features);
});
test('returns children of a cluster', () => {
const index = new ClusterTileIndex();
index.load(places.features);
const childCounts = index.getChildren(163).map(p => (p.properties as ClusterProperties)?.point_count || 1);
expect(childCounts).toEqual([6, 7, 2, 1]);
});
test('returns leaves of a cluster', () => {
const index = new ClusterTileIndex();
index.load(places.features);
const leafNames = index.getLeaves(163, 10, 5).map(p => (p.properties as {name: string} | null)?.name);
expect(leafNames).toEqual([
'Niagara Falls',
'Cape San Blas',
'Cape Sable',
'Cape Canaveral',
'San Salvador',
'Cabo Gracias a Dios',
'I. de Cozumel',
'Grand Cayman',
'Miquelon',
'Cape Bauld'
]);
});
test('generates unique ids with generateId option', () => {
const index = new ClusterTileIndex({generateId: true});
index.load(places.features);
const tile = index.getTile(0, 0, 0)!;
const ids = tile.features.filter(f => !(f.tags as ClusterProperties)?.cluster).map(f => f.id);
expect(ids).toEqual([12, 20, 21, 22, 24, 28, 30, 62, 81, 118, 119, 125, 81, 118]);
});
test('getLeaves handles null-property features', () => {
const index = new ClusterTileIndex();
index.load(places.features.concat([{
type: 'Feature',
properties: null,
geometry: {
type: 'Point',
coordinates: [-79.04411780507252, 43.08771393436908]
}
}]));
const leaves = index.getLeaves(164, 1, 6);
expect(leaves[0].properties).toBe(null);
});
test('returns cluster expansion zoom', () => {
const index = new ClusterTileIndex();
index.load(places.features);
expect(index.getClusterExpansionZoom(163)).toBe(1);
expect(index.getClusterExpansionZoom(195)).toBe(1);
expect(index.getClusterExpansionZoom(580)).toBe(2);
expect(index.getClusterExpansionZoom(1156)).toBe(2);
expect(index.getClusterExpansionZoom(4133)).toBe(3);
});
test('returns cluster expansion zoom for maxZoom', () => {
const index = new ClusterTileIndex({
radius: 60,
extent: 256,
maxZoom: 4,
});
index.load(places.features);
expect(index.getClusterExpansionZoom(2503)).toBe(5);
});
test('aggregates cluster properties with reduce', () => {
const index = new ClusterTileIndex({
map: (props) => ({sum: (props as {scalerank: number})?.scalerank}),
reduce: (a, b) => { (a as {sum: number}).sum += (b as {sum: number}).sum; },
radius: 100
});
index.load(places.features);
expect(index.getTile(1, 0, 0)!.features.map(f => (f.tags as {sum?: number})?.sum).filter(Boolean)).toEqual(
[146, 84, 63, 23, 34, 12, 19, 29, 8, 8, 80, 35]);
expect(index.getTile(0, 0, 0)!.features.map(f => (f.tags as {sum?: number})?.sum).filter(Boolean)).toEqual(
[298, 122, 12, 36, 98, 7, 24, 8, 125, 98, 125, 12, 36, 8]);
});
test('uses default map function with reduce', () => {
const index = new ClusterTileIndex({
reduce: () => {},
radius: 100
});
index.load(places.features);
expect(index.getTile(0, 0, 0)).toBeTruthy();
});
test('returns clusters when query crosses international dateline', () => {
const index = new ClusterTileIndex();
index.load([
{
type: 'Feature',
properties: null,
geometry: {
type: 'Point',
coordinates: [-178.989, 0]
}
}, {
type: 'Feature',
properties: null,
geometry: {
type: 'Point',
coordinates: [-178.990, 0]
}
}, {
type: 'Feature',
properties: null,
geometry: {
type: 'Point',
coordinates: [-178.991, 0]
}
}, {
type: 'Feature',
properties: null,
geometry: {
type: 'Point',
coordinates: [-178.992, 0]
}
}
]);
const nonCrossing = index.getClusters([-179, -10, -177, 10], 1);
const crossing = index.getClusters([179, -10, -177, 10], 1);
expect(nonCrossing.length).toBeGreaterThan(0);
expect(crossing.length).toBeGreaterThan(0);
expect(nonCrossing.length).toBe(crossing.length);
});
test('does not crash on weird bbox values', () => {
const index = new ClusterTileIndex();
index.load(places.features);
expect(index.getClusters([129.426390, -103.720017, -445.930843, 114.518236], 1).length).toBe(26);
expect(index.getClusters([112.207836, -84.578666, -463.149397, 120.169159], 1).length).toBe(27);
expect(index.getClusters([129.886277, -82.332680, -445.470956, 120.390930], 1).length).toBe(26);
expect(index.getClusters([458.220043, -84.239039, -117.137190, 120.206585], 1).length).toBe(25);
expect(index.getClusters([456.713058, -80.354196, -118.644175, 120.539148], 1).length).toBe(25);
expect(index.getClusters([453.105328, -75.857422, -122.251904, 120.732760], 1).length).toBe(25);
expect(index.getClusters([-180, -90, 180, 90], 1).length).toBe(61);
});
test('does not crash on non-integer zoom values', () => {
const index = new ClusterTileIndex();
index.load(places.features);
expect(index.getClusters([179, -10, -177, 10], 1.25)).toBeTruthy();
});
test('makes sure same-location points are clustered', () => {
const index = new ClusterTileIndex({
maxZoom: 20,
extent: 8192,
radius: 16
});
index.load([
{type: 'Feature', properties: null, geometry: {type: 'Point', coordinates: [-1.426798, 53.943034]}},
{type: 'Feature', properties: null, geometry: {type: 'Point', coordinates: [-1.426798, 53.943034]}}
]);
expect(index.trees[20].ids.length).toBe(1);
});
test('makes sure unclustered point coords are not rounded', () => {
const index = new ClusterTileIndex({maxZoom: 19});
index.load([
{type: 'Feature', properties: null, geometry: {type: 'Point', coordinates: [173.19150559062456, -41.340357424709275]}}
]);
expect(index.getTile(20, 1028744, 656754)!.features[0].geometry[0]).toEqual([421, 281]);
});
test('does not throw on zero items', () => {
expect(() => {
const index = new ClusterTileIndex();
index.load([]);
expect(index.getClusters([-180, -85, 180, 85], 0)).toEqual([]);
}).not.toThrow();
});
+516
View File
@@ -0,0 +1,516 @@
import KDBush from 'kdbush';
import {projectX, projectY} from './convert';
import {unprojectX, unprojectY, featureToGeoJSON} from './deconvert';
import type {ClusterFeature, ClusterOrPointFeature, ClusterProperties, GeoJSONVTTileIndex, GeoJSONVTFeature, GeoJSONVTInternalFeature, GeoJSONVTInternalPointFeature, GeoJSONVTOptions, GeoJSONVTTile, SuperclusterOptions} from './definitions';
type ClusterFeatureInternal = GeoJSONVTInternalPointFeature & {
tags: ClusterProperties;
};
type ClusterOrPointFeatureInternal = ClusterFeatureInternal | GeoJSONVTInternalPointFeature;
/** @internal */
export type KDBushWithData = KDBush & {
flatData: number[];
};
export const defaultClusterOptions: Required<SuperclusterOptions> = {
minZoom: 0,
maxZoom: 16,
minPoints: 2,
radius: 40,
extent: 512,
nodeSize: 64,
log: false,
generateId: false,
reduce: null,
map: (props) => props as Record<string, unknown>
};
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.
*/
export class ClusterTileIndex implements GeoJSONVTTileIndex {
options: Required<SuperclusterOptions>;
trees: KDBushWithData[];
stride: number;
clusterProps: Record<string, unknown>[];
points: GeoJSONVTInternalPointFeature[];
constructor(options?: SuperclusterOptions) {
this.options = Object.assign(Object.create(defaultClusterOptions), options) as Required<SuperclusterOptions>;
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: GeoJSON.Feature<GeoJSON.Point>[]): void {
const features: GeoJSONVTInternalPointFeature[] = [];
// 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: GeoJSONVTInternalPointFeature = {
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: GeoJSONVTInternalFeature[]): void {
const points: GeoJSONVTInternalPointFeature[] = [];
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: GeoJSONVTInternalFeature[], _affected: GeoJSONVTInternalFeature[], options: GeoJSONVTOptions) {
this.options = Object.assign(Object.create(defaultClusterOptions), options.clusterOptions) as Required<SuperclusterOptions>;
this.initialize(features);
}
private createIndex(points: GeoJSONVTInternalPointFeature[]): void {
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: number[] = [];
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.
*/
public getClusters(bbox: [number, number, number, number], zoom: number): ClusterOrPointFeature[] {
const clusterInternal = this.getClustersInternal(bbox, zoom);
return clusterInternal.map((f) => featureToGeoJSON(f) as ClusterOrPointFeature);
}
private getClustersInternal(bbox: [number, number, number, number], zoom: number): ClusterOrPointFeatureInternal[] {
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: ClusterOrPointFeatureInternal[] = [];
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: number): ClusterOrPointFeature[] {
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: ClusterOrPointFeature[] = [];
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]]) as GeoJSON.Feature<GeoJSON.Point>);
}
}
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: number, limit?: number, offset?: number): GeoJSON.Feature<GeoJSON.Point>[] {
limit = limit || 10;
offset = offset || 0;
const leaves: GeoJSON.Feature<GeoJSON.Point>[] = [];
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: number, x: number, y: number): GeoJSONVTTile | null {
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: GeoJSONVTTile = {
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: number): number {
return this.getOriginZoom(clusterId);
}
private appendLeaves(result: GeoJSON.Feature<GeoJSON.Point>[], clusterId: number, limit: number, offset: number, skipped: number): number {
const children = this.getChildren(clusterId);
for (const child of children) {
const props = child.properties as ClusterProperties | null;
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 as GeoJSON.Feature<GeoJSON.Point>);
}
if (result.length === limit) break;
}
return skipped;
}
private createTree(data: number[]): KDBushWithData {
const tree = new KDBush(data.length / this.stride | 0, this.options.nodeSize, Float32Array) as unknown as KDBushWithData;
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;
}
private addTileFeatures(ids: number[], data: number[], x: number, y: number, z2: number, tile: GeoJSONVTTile): void {
for (const i of ids) {
const k = i * this.stride;
const isCluster = data[k + OFFSET_NUM] > 1;
let tags: GeoJSON.GeoJsonProperties | ClusterProperties;
let px: number;
let py: number;
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: GeoJSONVTFeature = {
type: 1,
geometry: [[
Math.round(this.options.extent * (px * z2 - x)),
Math.round(this.options.extent * (py * z2 - y))
]],
tags
};
// assign id
let id: number | string | undefined;
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 as number | string | undefined;
}
if (id !== undefined) f.id = id;
tile.features.push(f);
}
}
private limitZoom(z: number): number {
return Math.max(this.options.minZoom, Math.min(Math.floor(+z), this.options.maxZoom + 1));
}
private cluster(tree: KDBushWithData, zoom: number): number[] {
const {radius, extent, reduce, minPoints} = this.options;
const r = radius / (extent * Math.pow(2, zoom));
const data = tree.flatData;
const nextData: number[] = [];
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: Record<string, unknown> | undefined;
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
private getOriginId(clusterId: number): number {
return (clusterId - this.points.length) >> 5;
}
// get zoom of the point from which the cluster originated
private getOriginZoom(clusterId: number): number {
return (clusterId - this.points.length) % 32;
}
private map(data: number[], i: number, clone?: boolean): Record<string, unknown> {
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: number[], i: number, clusterProps: Record<string, unknown>[]): ClusterFeatureInternal {
return {
id: data[i + OFFSET_ID],
type: 'Point',
tags: getClusterProperties(data, i, clusterProps),
geometry: [data[i], data[i + 1]]
};
}
function getClusterGeoJSON(data: number[], i: number, clusterProps: Record<string, unknown>[]): ClusterFeature {
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: number[], i: number, clusterProps: Record<string, unknown>[]): ClusterProperties {
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 as const,
cluster_id: data[i + OFFSET_ID],
point_count: count,
point_count_abbreviated: abbrev
});
}
+200
View File
@@ -0,0 +1,200 @@
import {simplify} from './simplify';
import {createFeature} from './feature';
import type {GeoJSONVTInternalFeature, GeoJSONVTOptions, StartEndSizeArray} from './definitions';
/**
* converts GeoJSON to internal source features (an intermediate projected JSON vector format with simplification data)
* @param data
* @param options
* @returns
*/
export function convertToInternal(data: GeoJSON.GeoJSON, options: GeoJSONVTOptions): GeoJSONVTInternalFeature[] {
const features: GeoJSONVTInternalFeature[] = [];
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, {type: "Feature" as const, geometry: data, properties: undefined}, options);
}
return features;
}
function featureToInternal(features: GeoJSONVTInternalFeature[], geojson: GeoJSON.Feature, options: GeoJSONVTOptions, index?: number) {
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: GeoJSON.Feature, options: GeoJSONVTOptions, index?: number): number | string | undefined {
if (options.promoteId) {
return geojson.properties?.[options.promoteId];
}
if (options.generateId) {
return index || 0;
}
return geojson.id;
}
function convertGeometryCollection(features: GeoJSONVTInternalFeature[], geojson: GeoJSON.Feature, geometry: GeoJSON.GeometryCollection, options: GeoJSONVTOptions, index?: number) {
for (const geom of geometry.geometries) {
featureToInternal(features, {
id: geojson.id,
type: 'Feature',
geometry: geom,
properties: geojson.properties
}, options, index);
}
}
function convertPointFeature(features: GeoJSONVTInternalFeature[], id: number | string | undefined, geom: GeoJSON.Point, properties: GeoJSON.GeoJsonProperties) {
const out: number[] = [];
out.push(projectX(geom.coordinates[0]), projectY(geom.coordinates[1]), 0);
features.push(createFeature(id, 'Point', out, properties));
}
function convertMultiPointFeature(features: GeoJSONVTInternalFeature[], id: number | string | undefined, geom: GeoJSON.MultiPoint, properties: GeoJSON.GeoJsonProperties) {
const out: number[] = [];
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: GeoJSONVTInternalFeature[], id: number | string | undefined, geom: GeoJSON.LineString, tolerance: number, properties: GeoJSON.GeoJsonProperties) {
const out: StartEndSizeArray = [];
convertLine(geom.coordinates, out, tolerance, false);
features.push(createFeature(id, 'LineString', out, properties));
}
function convertMultiLineStringFeature(features: GeoJSONVTInternalFeature[], id: number | string | undefined, geom: GeoJSON.MultiLineString, tolerance: number, options: GeoJSONVTOptions, properties: GeoJSON.GeoJsonProperties) {
if (options.lineMetrics) {
// explode into linestrings to be able to track metrics
for (const line of geom.coordinates) {
const out: StartEndSizeArray = [];
convertLine(line, out, tolerance, false);
features.push(createFeature(id, 'LineString', out, properties));
}
} else {
const out: StartEndSizeArray[] = [];
convertLines(geom.coordinates, out, tolerance, false);
features.push(createFeature(id, 'MultiLineString', out, properties));
}
}
function convertPolygonFeature(features: GeoJSONVTInternalFeature[], id: number | string | undefined, geom: GeoJSON.Polygon, tolerance: number, properties: GeoJSON.GeoJsonProperties) {
const out: StartEndSizeArray[] = [];
convertLines(geom.coordinates, out, tolerance, true);
features.push(createFeature(id, 'Polygon', out, properties));
}
function convertMultiPolygonFeature(features: GeoJSONVTInternalFeature[], id: number | string | undefined, geom: GeoJSON.MultiPolygon, tolerance: number, properties: GeoJSON.GeoJsonProperties) {
const out: StartEndSizeArray[][] = [];
for (const polygon of geom.coordinates) {
const polygonOut: StartEndSizeArray[] = [];
convertLines(polygon, polygonOut, tolerance, true);
out.push(polygonOut);
}
features.push(createFeature(id, 'MultiPolygon', out, properties));
}
function convertLine(ring: GeoJSON.Position[], out: StartEndSizeArray, tolerance: number, isPolygon: boolean) {
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: GeoJSON.Position[][], out: StartEndSizeArray[], tolerance: number, isPolygon: boolean) {
for (let i = 0; i < rings.length; i++) {
const geom: StartEndSizeArray = [];
convertLine(rings[i], geom, tolerance, isPolygon);
out.push(geom);
}
}
/**
* Convert longitude to spherical mercator in [0..1] range
*/
export function projectX(x: number) {
return x / 360 + 0.5;
}
/**
* Convert latitude to spherical mercator in [0..1] range
*/
export function projectY(y: number) {
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;
}
+153
View File
@@ -0,0 +1,153 @@
import {test, expect} from 'vitest';
import {featureToGeoJSON, unprojectX, unprojectY} from './deconvert';
import {projectX, projectY} from './convert';
import type {GeoJSONVTInternalFeature} from './definitions';
test('project/unproject roundtrip retains precision', () => {
const coords = [
{lng: 0, lat: 0},
{lng: 90, lat: 45.0564839289},
{lng: -90, lat: -45.0564839289},
{lng: 180, lat: 85.0511287798},
{lng: -180, lat: -85.0511287798},
{lng: 45.1234567895, lat: 23.5456789012},
{lng: -123.9876543210, lat: -67.8901234564},
{lng: 0.0000000001, lat: 0.0000000001},
{lng: 179.9999999999, lat: 85},
{lng: -179.9999999999, lat: -85}
];
for (const {lng, lat} of coords) {
expect(unprojectX(projectX(lng))).toBeCloseTo(lng, 10);
expect(unprojectY(projectY(lat))).toBeCloseTo(lat, 10);
}
});
test('featureToGeoJSON: converts Point geometry', () => {
const feature: GeoJSONVTInternalFeature = {
type: 'Point',
id: 'point1',
geometry: [0.5, 0.5, 0],
tags: {name: 'Test Point'},
minX: 0.5, minY: 0.5, maxX: 0.5, maxY: 0.5
};
const result = featureToGeoJSON(feature);
expect(result.type).toBe('Feature');
expect(result.geometry.type).toBe('Point');
expect((result.geometry as GeoJSON.Point).coordinates).toEqual([0, 0]);
expect(result.id).toBe('point1');
expect(result.properties).toEqual({name: 'Test Point'});
});
test('featureToGeoJSON: converts MultiPoint geometry', () => {
const feature: GeoJSONVTInternalFeature = {
type: 'MultiPoint',
id: 'multipoint1',
geometry: [0.5, 0.5, 0, 0.525, 0.5, 0],
tags: {},
minX: 0.5, minY: 0.5, maxX: 0.525, maxY: 0.5
};
const result = featureToGeoJSON(feature);
expect(result.geometry.type).toBe('MultiPoint');
expect((result.geometry as GeoJSON.MultiPoint).coordinates.length).toBe(2);
});
test('featureToGeoJSON: converts LineString geometry', () => {
const feature: GeoJSONVTInternalFeature = {
type: 'LineString',
id: 'line1',
geometry: [0.5, 0.5, 0, 0.525, 0.5, 0, 0.525, 0.525, 0],
tags: {highway: 'primary'},
minX: 0.5, minY: 0.5, maxX: 0.525, maxY: 0.525
};
const result = featureToGeoJSON(feature);
expect(result.geometry.type).toBe('LineString');
expect((result.geometry as GeoJSON.LineString).coordinates.length).toBe(3);
});
test('featureToGeoJSON: converts MultiLineString geometry', () => {
const feature: GeoJSONVTInternalFeature = {
type: 'MultiLineString',
id: 'multiline1',
geometry: [
[0.5, 0.5, 0, 0.525, 0.5, 0],
[0.55, 0.55, 0, 0.575, 0.55, 0]
],
tags: {},
minX: 0.5, minY: 0.5, maxX: 0.575, maxY: 0.55
};
const result = featureToGeoJSON(feature);
expect(result.geometry.type).toBe('MultiLineString');
expect((result.geometry as GeoJSON.MultiLineString).coordinates.length).toBe(2);
});
test('featureToGeoJSON: converts Polygon geometry', () => {
const feature: GeoJSONVTInternalFeature = {
type: 'Polygon',
id: 'polygon1',
geometry: [
[0.5, 0.5, 0, 0.6, 0.5, 0, 0.6, 0.6, 0, 0.5, 0.6, 0, 0.5, 0.5, 0],
[0.52, 0.52, 0, 0.58, 0.52, 0, 0.58, 0.58, 0, 0.52, 0.58, 0, 0.52, 0.52, 0]
],
tags: {landuse: 'residential'},
minX: 0.5, minY: 0.5, maxX: 0.6, maxY: 0.6
};
const result = featureToGeoJSON(feature);
expect(result.geometry.type).toBe('Polygon');
expect((result.geometry as GeoJSON.Polygon).coordinates.length).toBe(2);
});
test('featureToGeoJSON: converts MultiPolygon geometry', () => {
const feature: GeoJSONVTInternalFeature = {
type: 'MultiPolygon',
id: 'multipolygon1',
geometry: [
[[0.5, 0.5, 0, 0.52, 0.5, 0, 0.52, 0.52, 0, 0.5, 0.52, 0, 0.5, 0.5, 0]],
[[0.55, 0.55, 0, 0.57, 0.55, 0, 0.57, 0.57, 0, 0.55, 0.57, 0, 0.55, 0.55, 0]]
],
tags: {},
minX: 0.5, minY: 0.5, maxX: 0.57, maxY: 0.57
};
const result = featureToGeoJSON(feature);
expect(result.geometry.type).toBe('MultiPolygon');
expect((result.geometry as GeoJSON.MultiPolygon).coordinates.length).toBe(2);
});
test('featureToGeoJSON: handles various id types', () => {
const noId: GeoJSONVTInternalFeature = {type: 'Point', geometry: [0.5, 0.5, 0], tags: {}, minX: 0.5, minY: 0.5, maxX: 0.5, maxY: 0.5};
const stringId: GeoJSONVTInternalFeature = {type: 'Point', id: 'string-id', geometry: [0.5, 0.5, 0], tags: {}, minX: 0.5, minY: 0.5, maxX: 0.5, maxY: 0.5};
const numberId: GeoJSONVTInternalFeature = {type: 'Point', id: 42, geometry: [0.5, 0.5, 0], tags: {}, minX: 0.5, minY: 0.5, maxX: 0.5, maxY: 0.5};
expect(featureToGeoJSON(noId).id).toBeUndefined();
expect(featureToGeoJSON(stringId).id).toBe('string-id');
expect(featureToGeoJSON(numberId).id).toBe(42);
});
test('featureToGeoJSON: correctly unprojects known coordinates', () => {
const feature: GeoJSONVTInternalFeature = {
type: 'MultiPoint',
geometry: [0.5, 0.5, 0, 0, 0.5, 0, 1, 0.5, 0],
tags: {},
minX: 0, minY: 0.5, maxX: 1, maxY: 0.5
};
const result = featureToGeoJSON(feature);
const coords = (result.geometry as GeoJSON.MultiPoint).coordinates;
expect(coords[0]).toEqual([0, 0]);
expect(coords[1]).toEqual([-180, 0]);
expect(coords[2]).toEqual([180, 0]);
});
+92
View File
@@ -0,0 +1,92 @@
import type {GeoJSONVTInternalFeature} from './definitions';
/**
* Converts internal source features back to GeoJSON format.
*/
export function convertToGeoJSON(source: GeoJSONVTInternalFeature[]): GeoJSON.GeoJSON {
const geojson: GeoJSON.GeoJSON = {
type: 'FeatureCollection',
features: source.map(feature => featureToGeoJSON(feature))
};
return geojson;
}
/**
* Converts a single internal feature to GeoJSON format.
*/
export function featureToGeoJSON(feature: GeoJSONVTInternalFeature): GeoJSON.Feature {
const geojsonFeature: GeoJSON.Feature = {
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: GeoJSONVTInternalFeature): GeoJSON.Geometry {
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))
)
};
}
}
export function unprojectPoints(coords: number[]): GeoJSON.Position[] {
const result: GeoJSON.Position[] = [];
for (let i = 0; i < coords.length; i += 3) {
result.push(unprojectPoint(coords[i], coords[i + 1]));
}
return result;
}
function unprojectPoint(x: number, y: number): GeoJSON.Position {
return [unprojectX(x), unprojectY(y)];
}
/**
* Convert spherical mercator in [0..1] range to longitude
*/
export function unprojectX(x: number): number {
return (x - 0.5) * 360;
}
/**
* Convert spherical mercator in [0..1] range to latitude
*/
export function unprojectY(y: number): number {
const y2 = (180 - y * 360) * Math.PI / 180;
return 360 * Math.atan(Math.exp(y2)) / Math.PI - 90;
}
+264
View File
@@ -0,0 +1,264 @@
export type GeoJSONVTOptions = {
/**
* Max zoom to preserve detail on
* @default 14
*/
maxZoom?: number;
/**
* Max zoom in the tile index
* @default 5
*/
indexMaxZoom?: number;
/**
* Max number of points per tile in the tile index
* @default 100000
*/
indexMaxPoints?: number;
/**
* Simplification tolerance (higher means simpler)
* @default 3
*/
tolerance?: number;
/**
* Tile extent
* @default 4096
*/
extent?: number;
/**
* Tile buffer on each side
* @default 64
*/
buffer?: number;
/**
* Whether to calculate line metrics
* @default false
*/
lineMetrics?: boolean;
/**
* Name of a feature property to be promoted to feature.id
*/
promoteId?: string | null;
/**
* Whether to generate feature ids. Cannot be used with promoteId
* @default false
*/
generateId?: boolean;
/**
* Whether geojson can be updated (with caveat of a stored simplified copy)
* @default false
*/
updateable?: boolean;
/**
* Logging level (0, 1 or 2)
* @default 0
*/
debug?: number;
/**
* Enable Supercluster for point features.
* @default false
*/
cluster?: boolean;
/**
* Options for the Supercluster point clustering algorithm.
* @see {@link SuperclusterOptions}
*/
clusterOptions?: SuperclusterOptions;
};
export type GeoJSONToTileOptions = GeoJSONVTOptions & {
/**
* Whether to wrap features around the antimeridian
* @default false
*/
wrap?: boolean;
/**
* Whether to clip features to the tile boundary
* @default false
*/
clip?: boolean;
};
export type StartEndSizeArray = number[] & { start?: number; end?: number; size?: number };
export type PartialGeoJSONVTFeature = {
id?: number | string | undefined;
tags: GeoJSON.GeoJsonProperties;
minX?: number;
minY?: number;
maxX?: number;
maxY?: number;
}
export type GeoJSONVTInternalPointFeature = PartialGeoJSONVTFeature & {
type: 'Point';
geometry: number[];
};
export type GeoJSONVTInternalMultiPointFeature = PartialGeoJSONVTFeature & {
type: 'MultiPoint';
geometry: number[];
};
export type GeoJSONVTInternalLineStringFeature = PartialGeoJSONVTFeature & {
type: 'LineString';
geometry: StartEndSizeArray;
};
export type GeoJSONVTInternalMultiLineStringFeature = PartialGeoJSONVTFeature & {
type: 'MultiLineString';
geometry: StartEndSizeArray[];
};
export type GeoJSONVTInternalPolygonFeature = PartialGeoJSONVTFeature & {
type: 'Polygon';
geometry: StartEndSizeArray[];
};
export type GeoJSONVTInternalMultiPolygonFeature = PartialGeoJSONVTFeature & {
type: 'MultiPolygon';
geometry: StartEndSizeArray[][];
};
export type GeoJSONVTInternalFeature =
| GeoJSONVTInternalPointFeature
| GeoJSONVTInternalMultiPointFeature
| GeoJSONVTInternalLineStringFeature
| GeoJSONVTInternalMultiLineStringFeature
| GeoJSONVTInternalPolygonFeature
| GeoJSONVTInternalMultiPolygonFeature;
/**
* The geojson properies related to a cluster.
*/
export type ClusterProperties = {
cluster: true;
cluster_id: number;
point_count: number;
point_count_abbreviated: string | number;
[key: string]: unknown;
};
/**
* A geojson point with cluster properties, see {@link ClusterProperties}.
*/
export type ClusterFeature = GeoJSON.Feature<GeoJSON.Point, ClusterProperties>;
/**
* A geojson point that is either a regular point or a cluster, which is a point with cluster properties.
* See {@link ClusterFeature} for more information
*/
export type ClusterOrPointFeature = ClusterFeature | GeoJSON.Feature<GeoJSON.Point>;
export type GeoJSONVTInternalTileFeaturePoint = {
id? : number | string | undefined;
type: 1;
tags: GeoJSON.GeoJsonProperties | null;
geometry: number[];
}
export type GeoJSONVTInternalTileFeatureNonPoint = {
id? : number | string | undefined;
type: 2 | 3;
tags: GeoJSON.GeoJsonProperties | null;
geometry: number[][];
}
export type GeoJSONVTInternalTileFeature = GeoJSONVTInternalTileFeaturePoint | GeoJSONVTInternalTileFeatureNonPoint;
export type GeoJSONVTInternalTile = {
transformed: boolean;
features: GeoJSONVTInternalTileFeature[];
source: GeoJSONVTInternalFeature[] | null;
x: number;
y: number;
z: number;
minX?: number;
minY?: number;
maxX?: number;
maxY?: number;
numPoints?: number;
numSimplified?: number;
numFeatures?: number;
}
export type GeoJSONVTFeaturePoint = {
id? : number | string | undefined;
type: 1;
tags: GeoJSON.GeoJsonProperties | null;
geometry: [number, number][]
}
export type GeoJSONVTFeatureNonPoint = {
id? : number | string | undefined;
type: 2 | 3;
tags: GeoJSON.GeoJsonProperties | null;
geometry: [number, number][][]
}
export type GeoJSONVTFeature = GeoJSONVTFeaturePoint | GeoJSONVTFeatureNonPoint;
export type GeoJSONVTTile = GeoJSONVTInternalTile & {
transformed: true;
features: GeoJSONVTFeature[]
}
export interface GeoJSONVTTileIndex {
initialize(features: GeoJSONVTInternalFeature[]): void;
updateIndex(source: GeoJSONVTInternalFeature[], affected: GeoJSONVTInternalFeature[], options: GeoJSONVTOptions): void;
getClusterExpansionZoom(clusterId: number): number | null;
getChildren(clusterId: number): ClusterOrPointFeature[] | null;
getLeaves(clusterId: number, limit?: number, offset?: number): GeoJSON.Feature<GeoJSON.Point>[] | null
getTile(z: number, x: number, y: number): GeoJSONVTTile | null
}
export type SuperclusterOptions = {
/**
* Min zoom to generate clusters on
* @default 0
*/
minZoom?: number;
/**
* Max zoom level to cluster the points on
* @default 16
*/
maxZoom?: number;
/**
* Minimum points to form a cluster
* @default 2
*/
minPoints?: number;
/**
* Cluster radius in pixels
* @default 40
*/
radius?: number;
/**
* Tile extent (radius is calculated relative to it)
* @default 512
*/
extent?: number;
/**
* Size of the KD-tree leaf node, affects performance
* @default 64
*/
nodeSize?: number;
/**
* Whether to log timing info
* @default false
*/
log?: boolean;
/**
* Whether to generate numeric ids for input features (in vector tiles)
* @default false
*/
generateId?: boolean;
/**
* A reduce function for calculating custom cluster properties
* @default null
*/
reduce?: ((accumulated: Record<string, unknown>, props: Record<string, unknown>) => void) | null;
/**
* Properties to use for individual points when running the reducer
* @default props => props
*/
map?: (props: GeoJSON.GeoJsonProperties) => Record<string, unknown>;
};
+270
View File
@@ -0,0 +1,270 @@
import {test, expect} from 'vitest';
import {applySourceDiff} from './difference';
const options = {
maxZoom: 14,
indexMaxZoom: 5,
indexMaxPoints: 100000,
tolerance: 3,
extent: 4096,
buffer: 64,
updateable: true
};
test('applySourceDiff: adds a feature using the feature id', () => {
const point = {
type: 'Feature' as const,
id: 'point',
geometry: {
type: 'Point' as const,
coordinates: [0, 0]
},
properties: {},
};
const {source} = applySourceDiff([], {
add: [point]
}, options);
expect(source.length).toBe(1);
expect(source[0].id).toBe('point');
});
test('applySourceDiff: adds a feature using the promoteId', () => {
const point2 = {
type: 'Feature' as const,
geometry: {
type: 'Point' as const,
coordinates: [0, 0],
},
properties: {
promoteId: 'point2'
},
};
const {source} = applySourceDiff([], {
add: [point2]
}, {promoteId: 'promoteId'});
expect(source.length).toBe(1);
expect(source[0].id).toBe('point2');
});
test('applySourceDiff: removes a feature by its id', () => {
const point = {
type: 'Point' as const,
id: 'point',
geometry: [0, 0],
tags: {},
minX: 0,
minY: 0,
maxX: 0,
maxY: 0
};
const point2 = {
type: 'Point' as const,
id: 'point2',
geometry: [0, 0],
tags: {},
minX: 0,
minY: 0,
maxX: 0,
maxY: 0
};
const {source} = applySourceDiff([point, point2], {
remove: ['point2'],
}, options);
expect(source.length).toBe(1);
expect(source[0].id).toBe('point');
});
test('applySourceDiff: removeAll clears all features', () => {
const point = {
type: 'Point' as const,
id: 'point',
geometry: [0, 0],
tags: {},
minX: 0,
minY: 0,
maxX: 0,
maxY: 0
};
const point2 = {
type: 'Point' as const,
id: 'point2',
geometry: [0, 0],
tags: {},
minX: 0,
minY: 0,
maxX: 0,
maxY: 0
};
const source = [point, point2];
const result = applySourceDiff(source, {
removeAll: true
}, options);
expect(source).toEqual(result.affected);
expect(result.source).toEqual([]);
});
test('applySourceDiff: updates a feature geometry', () => {
const point = {
type: 'Point' as const,
id: 'point',
geometry: [0, 0],
tags: {},
minX: 0,
minY: 0,
maxX: 0,
maxY: 0
};
const {source} = applySourceDiff([point], {
update: [{
id: 'point',
newGeometry: {
type: 'Point',
coordinates: [1, 0]
}
}]
}, options);
expect(source.length).toBe(1);
expect(source[0].id).toBe('point');
expect(source[0].geometry[0]).toBe(0.5027777777777778);
expect(source[0].geometry[1]).toBe(0.5);
});
test('applySourceDiff: adds properties', () => {
const point = {
type: 'Point' as const,
id: 'point',
geometry: [0, 0],
tags: {},
minX: 0,
minY: 0,
maxX: 0,
maxY: 0
};
const {source} = applySourceDiff([point], {
update: [{
id: 'point',
addOrUpdateProperties: [
{key: 'prop', value: 'value'},
{key: 'prop2', value: 'value2'}
]
}]
}, options);
expect(source.length).toBe(1);
const tags = source[0].tags;
expect(Object.keys(tags).length).toBe(2);
expect(tags.prop).toBe('value');
expect(tags.prop2).toBe('value2');
});
test('applySourceDiff: updates properties', () => {
const point = {
type: 'Point' as const,
id: 'point',
geometry: [0, 0],
tags: {prop: 'value', prop2: 'value2'},
minX: 0,
minY: 0,
maxX: 0,
maxY: 0
};
const {source} = applySourceDiff([point], {
update: [{
id: 'point',
addOrUpdateProperties: [
{key: 'prop2', value: 'value3'}
]
}]
}, options);
expect(source.length).toBe(1);
const tags2 = source[0].tags;
expect(Object.keys(tags2).length).toBe(2);
expect(tags2.prop).toBe('value');
expect(tags2.prop2).toBe('value3');
});
test('applySourceDiff: removes properties', () => {
const point = {
type: 'Point' as const,
id: 'point',
geometry: [0, 0],
tags: {prop: 'value', prop2: 'value2'},
minX: 0,
minY: 0,
maxX: 0,
maxY: 0
};
const {source} = applySourceDiff([point], {
update: [{
id: 'point',
removeProperties: ['prop2']
}]
}, options);
expect(source.length).toBe(1);
const tags3 = source[0].tags;
expect(Object.keys(tags3).length).toBe(1);
expect(tags3.prop).toBe('value');
});
test('applySourceDiff: removes all properties', () => {
const point = {
type: 'Point' as const,
id: 'point',
geometry: [0, 0],
tags: {prop: 'value', prop2: 'value2'},
minX: 0,
minY: 0,
maxX: 0,
maxY: 0
};
const {source} = applySourceDiff([point], {
update: [{
id: 'point',
removeAllProperties: true,
}]
}, options);
expect(source.length).toBe(1);
expect(Object.keys(source[0].tags).length).toBe(0);
});
test('applySourceDiff: empty update preserves properties', () => {
const point = {
type: 'Point' as const,
id: 'point',
geometry: [0, 0],
tags: {prop: 'value', prop2: 'value2'},
minX: 0,
minY: 0,
maxX: 0,
maxY: 0
};
const {source} = applySourceDiff([point], {
update: [{id: 'point'}]
}, options);
expect(source.length).toBe(1);
const tags2 = source[0].tags;
expect(Object.keys(tags2).length).toBe(2);
expect(tags2.prop).toBe('value');
expect(tags2.prop2).toBe('value2');
});
+223
View File
@@ -0,0 +1,223 @@
import {convertToInternal} from './convert';
import {wrap} from './wrap';
import type { GeoJSONVTInternalFeature, GeoJSONVTOptions } from './definitions';
export type GeoJSONVTSourceDiff = {
/**
* If true, clear all existing features
*/
removeAll?: boolean;
/**
* Array of feature IDs to remove
*/
remove?: (string | number)[];
/**
* Array of GeoJSON features to add
*/
add?: GeoJSON.Feature[];
/**
* Array of per-feature updates
*/
update?: GeoJSONVTFeatureDiff[];
};
export type GeoJSONVTFeatureDiff = {
/**
* ID of the feature being updated
*/
id: string | number;
/**
* Optional new geometry
*/
newGeometry?: GeoJSON.Geometry;
/**
* Remove all properties if true
*/
removeAllProperties?: boolean;
/**
* Specific properties to delete
*/
removeProperties?: string[];
/**
* Properties to add or update
*/
addOrUpdateProperties?: {
key: string;
value: unknown;
}[];
};
export type ApplySourceDiffResult = {
/**
* The features affected by this update, which should be used to invalidate tiles
*/
affected: GeoJSONVTInternalFeature[];
/**
* The updated source data, which should replace the existing source data in the index
*/
source: GeoJSONVTInternalFeature[];
};
type HashedGeoJSONVTSourceDiff = {
removeAll?: boolean | undefined;
remove: Set<string | number>;
add: Map<string | number | undefined, GeoJSON.Feature>;
update: Map<string | number, GeoJSONVTFeatureDiff>;
};
/**
* Applies a GeoJSON Source Diff to an existing set of simplified features
* @param source
* @param dataDiff
* @param options
* @returns
*/
export function applySourceDiff(source: GeoJSONVTInternalFeature[], dataDiff: GeoJSONVTSourceDiff, options: GeoJSONVTOptions): ApplySourceDiffResult {
// 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: GeoJSONVTInternalFeature[] = [];
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: GeoJSONVTInternalFeature[], update: GeoJSONVTFeatureDiff, options: GeoJSONVTOptions): GeoJSONVTInternalFeature[] {
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' as const,
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: GeoJSON.GeoJsonProperties, update: GeoJSONVTFeatureDiff): GeoJSON.GeoJsonProperties {
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
*/
export function diffToHashed(diff: GeoJSONVTSourceDiff): HashedGeoJSONVTSourceDiff {
if (!diff) return {
remove: new Set(),
add: new Map(),
update: new Map()
};
const hashed: HashedGeoJSONVTSourceDiff = {
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;
}
+71
View File
@@ -0,0 +1,71 @@
import type { GeoJSONVTInternalFeature, GeoJSONVTInternalLineStringFeature, GeoJSONVTInternalMultiLineStringFeature, GeoJSONVTInternalMultiPointFeature, GeoJSONVTInternalMultiPolygonFeature, GeoJSONVTInternalPointFeature, GeoJSONVTInternalPolygonFeature } from "./definitions";
type FeatureTypeMap = {
Point: GeoJSONVTInternalPointFeature["geometry"];
MultiPoint: GeoJSONVTInternalMultiPointFeature["geometry"];
LineString: GeoJSONVTInternalLineStringFeature["geometry"];
MultiLineString: GeoJSONVTInternalMultiLineStringFeature["geometry"];
Polygon: GeoJSONVTInternalPolygonFeature["geometry"];
MultiPolygon: GeoJSONVTInternalMultiPolygonFeature["geometry"];
};
/**
*
* @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
*/
export function createFeature<T extends GeoJSONVTInternalFeature["type"]>(id: number | string | undefined, type: T, geom: FeatureTypeMap[T], tags: GeoJSON.GeoJsonProperties): GeoJSONVTInternalFeature {
// This is mostly for TypeScript type narrowing
const data = { type, geom } as { [K in GeoJSONVTInternalFeature["type"]]: { type: K, geom: FeatureTypeMap[K] } }[GeoJSONVTInternalFeature["type"]];
const feature = {
id: id == null ? null : id,
type: data.type,
geometry: data.geom,
tags,
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity
} as GeoJSONVTInternalFeature;
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: GeoJSONVTInternalFeature, geom: number[]) {
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]);
}
}
+58
View File
@@ -0,0 +1,58 @@
import {AxisType, clip} from './clip';
import {convertToInternal} from './convert';
import {defaultOptions} from './geojsonvt';
import {createTile} from './tile';
import {transformTile} from './transform';
import {wrap} from './wrap';
import type {GeoJSONToTileOptions, GeoJSONVTTile} from './definitions';
/**
* 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
*/
export function geoJSONToTile(data: GeoJSON.GeoJSON, z: number, x: number, y: number, options: GeoJSONToTileOptions = {}): GeoJSONVTTile {
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);
}
+39
View File
@@ -0,0 +1,39 @@
import {test, expect} from 'vitest';
import {GeoJSONVT} from '.';
test('publicly exposed cluster methods: getClusterExpansionZoom, getClusterChildren, getClusterLeaves', () => {
const points = {
type: 'FeatureCollection' as const,
features: Array.from({length: 20}, (_, i) => ({
type: 'Feature' as const,
geometry: {type: 'Point' as const, coordinates: [i * 0.0001, i * 0.0001]},
properties: {name: `Point ${i}`}
}))
};
const index = new GeoJSONVT(points, {
updateable: true,
cluster: true,
clusterOptions: {radius: 100}
});
const tile = index.getTile(0, 0, 0);
const cluster = tile.features.find(f => (f.tags as {cluster?: boolean})?.cluster);
const clusterId = (cluster.tags as {cluster_id: number}).cluster_id;
expect(index.getClusterExpansionZoom(clusterId)).toBeGreaterThan(0);
expect(index.getClusterChildren(clusterId).length).toBeGreaterThan(0);
expect(index.getClusterLeaves(clusterId, 5, 0).length).toBeLessThanOrEqual(5);
});
test('publicly exposed cluster methods: return undefined when clustering is disabled', () => {
const index = new GeoJSONVT({type: 'FeatureCollection', features: []}, {
updateable: true,
cluster: false,
clusterOptions: {radius: 100}
});
expect(index.getClusterExpansionZoom(123)).toBeNull();
expect(index.getClusterChildren(123)).toBeNull();
expect(index.getClusterLeaves(123, 10, 0)).toBeNull();
});
+209
View File
@@ -0,0 +1,209 @@
import {convertToInternal} from './convert';
import {convertToGeoJSON, featureToGeoJSON} from './deconvert';
import {wrap} from './wrap';
import {applySourceDiff, type GeoJSONVTSourceDiff} from './difference';
import {ClusterTileIndex, defaultClusterOptions} from './cluster-tile-index';
import {TileIndex} from './tile-index';
import type {ClusterOrPointFeature, GeoJSONVTTileIndex, GeoJSONVTInternalFeature, GeoJSONVTOptions, GeoJSONVTTile, SuperclusterOptions} from './definitions';
export const defaultOptions: GeoJSONVTOptions = {
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.
*/
export class GeoJSONVT {
/**
* @internal
* This is for the tests
*/
public get tiles() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (this.tileIndex as any)?.tiles ?? {};
}
/**
* @internal
* This is for the tests
*/
public get stats() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (this.tileIndex as any).stats;
}
/**
* @internal
* This is for the tests
*/
public get total() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (this.tileIndex as any).total;
}
private options: GeoJSONVTOptions;
private source?: GeoJSONVTInternalFeature[];
private tileIndex: GeoJSONVTTileIndex;
constructor(data: GeoJSON.GeoJSON, options?: GeoJSONVTOptions) {
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);
}
private initializeIndex(features: GeoJSONVTInternalFeature[], options: GeoJSONVTOptions) {
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: number | string, x: number | string, y: number | string): GeoJSONVTTile | null {
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: GeoJSONVTSourceDiff, filter?: (feature: GeoJSON.Feature) => boolean) {
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.
*/
private filterUpdate(source: GeoJSONVTInternalFeature[], affected: GeoJSONVTInternalFeature[], predicate: (feature: GeoJSON.Feature) => boolean) {
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(): GeoJSON.GeoJSON {
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: boolean, clusterOptions: SuperclusterOptions) {
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: number): number | null {
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: number): ClusterOrPointFeature[] | null {
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: number, limit: number, offset: number): GeoJSON.Feature<GeoJSON.Point>[] | null {
return this.tileIndex.getLeaves(clusterId, limit, offset);
}
}
+46
View File
@@ -0,0 +1,46 @@
import type {GeoJSONVTFeatureDiff, GeoJSONVTSourceDiff} from './difference';
import type {GeoJSONVTInternalFeature, GeoJSONVTInternalLineStringFeature, GeoJSONVTInternalMultiLineStringFeature, GeoJSONVTInternalMultiPointFeature, GeoJSONVTInternalMultiPolygonFeature, GeoJSONVTInternalPointFeature, GeoJSONVTInternalPolygonFeature, GeoJSONVTOptions, GeoJSONToTileOptions, PartialGeoJSONVTFeature, StartEndSizeArray, ClusterProperties, ClusterFeature, ClusterOrPointFeature, GeoJSONVTTile, GeoJSONVTFeature, GeoJSONVTFeaturePoint, GeoJSONVTFeatureNonPoint, GeoJSONVTInternalTileFeaturePoint, GeoJSONVTInternalTileFeatureNonPoint, GeoJSONVTInternalTile, GeoJSONVTInternalTileFeature, GeoJSONVTTileIndex, SuperclusterOptions} from './definitions';
import type {KDBushWithData} from './cluster-tile-index';
import {GeoJSONVT} from './geojsonvt';
import {ClusterTileIndex} from './cluster-tile-index';
import {geoJSONToTile} from './geojson-to-tile';
import {GEOJSONVT_CLIP_START, GEOJSONVT_CLIP_END} from './tile';
export {
GeoJSONVT,
ClusterTileIndex as Supercluster,
geoJSONToTile,
GEOJSONVT_CLIP_START,
GEOJSONVT_CLIP_END
}
export type {
GeoJSONVTInternalFeature,
GeoJSONVTOptions,
GeoJSONToTileOptions,
GeoJSONVTInternalTile,
GeoJSONVTInternalTileFeature,
PartialGeoJSONVTFeature,
StartEndSizeArray,
GeoJSONVTTile,
GeoJSONVTFeature,
GeoJSONVTSourceDiff,
GeoJSONVTFeatureDiff,
GeoJSONVTFeaturePoint,
GeoJSONVTFeatureNonPoint,
GeoJSONVTInternalTileFeaturePoint,
GeoJSONVTInternalTileFeatureNonPoint,
GeoJSONVTInternalPointFeature,
GeoJSONVTInternalMultiPointFeature,
GeoJSONVTInternalLineStringFeature,
GeoJSONVTInternalMultiLineStringFeature,
GeoJSONVTInternalPolygonFeature,
GeoJSONVTInternalMultiPolygonFeature,
SuperclusterOptions,
ClusterProperties,
ClusterFeature,
ClusterOrPointFeature,
KDBushWithData,
GeoJSONVTTileIndex
};
+73
View File
@@ -0,0 +1,73 @@
import {test, expect} from 'vitest';
import {simplify} from './simplify';
const points = [
[0.22455,0.25015],[0.22691,0.24419],[0.23331,0.24145],[0.23498,0.23606],
[0.24421,0.23276],[0.26259,0.21531],[0.26776,0.21381],[0.27357,0.20184],
[0.27312,0.19216],[0.27762,0.18903],[0.28036,0.18141],[0.28651,0.17774],
[0.29241,0.15937],[0.29691,0.15564],[0.31495,0.15137],[0.31975,0.14516],
[0.33033,0.13757],[0.34148,0.13996],[0.36998,0.13789],[0.38739,0.14251],
[0.39128,0.13939],[0.40952,0.14114],[0.41482,0.13975],[0.42772,0.12730],
[0.43960,0.11974],[0.47493,0.10787],[0.48651,0.10675],[0.48920,0.10945],
[0.49379,0.10863],[0.50474,0.11966],[0.51296,0.12235],[0.51863,0.12089],
[0.52409,0.12688],[0.52957,0.12786],[0.53421,0.14093],[0.53927,0.14724],
[0.56769,0.14891],[0.57525,0.15726],[0.58062,0.15815],[0.60153,0.15685],
[0.61774,0.15986],[0.62200,0.16704],[0.62955,0.19460],[0.63890,0.19561],
[0.64126,0.20081],[0.65177,0.20456],[0.67155,0.22255],[0.68368,0.21745],
[0.69525,0.21915],[0.70064,0.21798],[0.70312,0.21436],[0.71226,0.21587],
[0.72149,0.21281],[0.72781,0.21336],[0.72998,0.20873],[0.73532,0.20820],
[0.73994,0.20477],[0.76998,0.20842],[0.77960,0.21687],[0.78420,0.21816],
[0.80024,0.21462],[0.81053,0.21973],[0.81719,0.22682],[0.82077,0.23617],
[0.82723,0.23616],[0.82989,0.23989],[0.85100,0.24894],[0.85988,0.25549],
[0.86521,0.26853],[0.85795,0.28030],[0.86548,0.29145],[0.86681,0.29866],
[0.86468,0.30271],[0.86779,0.30617],[0.85987,0.31137],[0.86008,0.31435],
[0.85829,0.31494],[0.85810,0.32760],[0.85454,0.33540],[0.86092,0.34300],
[0.85643,0.35015],[0.85142,0.35296],[0.84984,0.35959],[0.85456,0.36553],
[0.84974,0.37038],[0.84409,0.37189],[0.84475,0.38044],[0.84152,0.38367],
[0.83957,0.39040],[0.84559,0.39905],[0.84840,0.40755],[0.84371,0.41130],
[0.84409,0.41988],[0.83951,0.43276],[0.84133,0.44104],[0.84762,0.44922],
[0.84716,0.45844],[0.85138,0.46279],[0.85397,0.47115],[0.86636,0.48077]
];
const simplified = [
[0.22455,0.25015],[0.26776,0.21381],[0.29691,0.15564],[0.33033,0.13757],
[0.40952,0.14114],[0.4396,0.11974],[0.48651,0.10675],[0.52957,0.12786],
[0.53927,0.14724],[0.56769,0.14891],[0.61774,0.15986],[0.62955,0.1946],
[0.67155,0.22255],[0.72781,0.21336],[0.73994,0.20477],[0.76998,0.20842],
[0.7842,0.21816],[0.80024,0.21462],[0.82077,0.23617],[0.85988,0.25549],
[0.86521,0.26853],[0.85795,0.2803],[0.86779,0.30617],[0.85829,0.31494],
[0.85454,0.3354],[0.86092,0.343],[0.84984,0.35959],[0.85456,0.36553],
[0.84409,0.37189],[0.83957,0.3904],[0.8484,0.40755],[0.83951,0.43276],
[0.85397,0.47115],[0.86636,0.48077]
];
test('simplifies points correctly with the given tolerance', () => {
const coords = [];
for (let i = 0; i < points.length; i++) {
coords.push(points[i][0], points[i][1], 0);
}
coords[2] = 1;
coords[coords.length - 1] = 1;
simplify(coords, 0, coords.length - 3, 0.001 * 0.001);
const result = [];
for (let i = 0; i < coords.length; i += 3) {
if (coords[i + 2] > 0.005 * 0.005) {
result.push([coords[i], coords[i + 1]]);
}
}
expect(result).toEqual(simplified);
});
test('does not throw max call stack error on bad long input', () => {
const coords: number[] = [];
for (let i = 0; i < 1400; i++) {
coords.push(0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0);
}
expect(() => {
simplify(coords, 0, coords.length, 2e-15);
}).not.toThrow();
});
+78
View File
@@ -0,0 +1,78 @@
/**
* 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
*/
export function simplify(coords: number[], first: number, last: number, sqTolerance: number) {
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: number, py: number, x: number, y: number, bx: number, by: number): number {
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;
}
+310
View File
@@ -0,0 +1,310 @@
import { AxisType, clip } from "./clip";
import { createTile } from "./tile";
import { transformTile } from "./transform";
import type { GeoJSONVTInternalFeature, GeoJSONVTOptions, ClusterOrPointFeature, GeoJSONVTTileIndex, GeoJSONVTInternalTile, GeoJSONVTTile } from "./definitions";
export class TileIndex implements GeoJSONVTTileIndex {
private tileCoords: {z: number, x: number, y: number, id: number}[];
/** @internal */
public tiles: {[key: string]: GeoJSONVTInternalTile};
/** @internal */
public stats: {[key: string]: number} = {};
/** @internal */
public total: number = 0;
constructor(private options: GeoJSONVTOptions) {
this.tiles = {};
this.tileCoords = [];
this.stats = {};
this.total = 0;
}
initialize(features: GeoJSONVTInternalFeature[]): void {
// 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: GeoJSONVTInternalFeature[], affected: GeoJSONVTInternalFeature[], options: GeoJSONVTOptions): void {
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: number): number | null {
return null;
}
/** {@inheritdoc} */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getChildren(_clusterId: number): ClusterOrPointFeature[] | null {
return null;
}
/** {@inheritdoc} */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getLeaves(_clusterId: number, _limit?: number, _offset?: number): GeoJSON.Feature<GeoJSON.Point>[] | null {
return null;
}
/** {@inheritdoc} */
getTile(z: number, x: number, y: number): GeoJSONVTTile | null {
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
*/
private splitTile(features: GeoJSONVTInternalFeature[], z: number, x: number, y: number, cz?: number, cx?: number, cy?: number) {
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() as number;
x = stack.pop() as number;
z = stack.pop() as number;
features = stack.pop() as GeoJSONVTInternalFeature[];
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
*/
private invalidateTiles(features: GeoJSONVTInternalFeature[]) {
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: number, x: number, y: number): number {
return (((1 << z) * y + x) * 32) + z;
}
+184
View File
@@ -0,0 +1,184 @@
import type { GeoJSONVTInternalFeature, GeoJSONVTInternalLineStringFeature, GeoJSONVTInternalMultiLineStringFeature, GeoJSONVTInternalMultiPointFeature, GeoJSONVTInternalMultiPolygonFeature, GeoJSONVTInternalPointFeature, GeoJSONVTInternalPolygonFeature, GeoJSONVTInternalTile, GeoJSONVTInternalTileFeature, GeoJSONVTOptions, StartEndSizeArray } from "./definitions";
export const GEOJSONVT_CLIP_START = 'geojsonvt_clip_start';
export 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
*/
export function createTile(features: GeoJSONVTInternalFeature[], z: number, tx: number, ty: number, options: GeoJSONVTOptions): GeoJSONVTInternalTile {
const tolerance = z === options.maxZoom ? 0 : options.tolerance / ((1 << z) * options.extent);
const tile = {
transformed: false,
features: [] as GeoJSONVTInternalTileFeature[],
source: null as GeoJSONVTInternalFeature[],
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: GeoJSONVTInternalTile, feature: GeoJSONVTInternalFeature, tolerance: number, options: GeoJSONVTOptions) {
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: GeoJSONVTInternalTile, feature: GeoJSONVTInternalPointFeature | GeoJSONVTInternalMultiPointFeature) {
const geometry: number[] = [];
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: GeoJSONVTInternalTileFeature = {
type: 1,
tags: feature.tags || null,
geometry: geometry
};
if (feature.id !== null) {
tileFeature.id = feature.id;
}
tile.features.push(tileFeature);
}
function addLineTileFeautre(tile: GeoJSONVTInternalTile, feature: GeoJSONVTInternalLineStringFeature, tolerance: number, options: GeoJSONVTOptions) {
const geometry: number[][] = [];
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: GeoJSONVTInternalTileFeature = {
type: 2,
tags: tags,
geometry: geometry
}
if (feature.id !== null) {
tileFeature.id = feature.id;
}
tile.features.push(tileFeature);
}
function addLinesTileFeature(tile: GeoJSONVTInternalTile, feature: GeoJSONVTInternalPolygonFeature | GeoJSONVTInternalMultiLineStringFeature, tolerance: number) {
const geometry: number[][] = [];
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: GeoJSONVTInternalTileFeature = {
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: GeoJSONVTInternalTile, feature: GeoJSONVTInternalMultiPolygonFeature, tolerance: number) {
const geometry: number[][] = [];
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: GeoJSONVTInternalTileFeature = {
type: 3,
tags: feature.tags || null,
geometry: geometry
}
if (feature.id !== null) {
tileFeature.id = feature.id;
}
tile.features.push(tileFeature);
}
function addLine(result: number[][], geom: StartEndSizeArray, tile: GeoJSONVTInternalTile, tolerance: number, isPolygon: boolean, isOuter: boolean) {
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: number[], clockwise: boolean) {
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;
}
}
+72
View File
@@ -0,0 +1,72 @@
import type { GeoJSONVTFeatureNonPoint, GeoJSONVTFeaturePoint, GeoJSONVTInternalTile, GeoJSONVTInternalTileFeatureNonPoint, GeoJSONVTInternalTileFeaturePoint, GeoJSONVTTile } from "./definitions";
/**
* 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
*/
export function transformTile(tile: GeoJSONVTInternalTile, extent: number): GeoJSONVTTile {
if (tile.transformed) {
return tile as GeoJSONVTTile;
}
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 as GeoJSONVTTile;
}
/**
* Transforms a single point feature from mercator-projected space into (extent x extent) tile space.
*/
function transformPointFeature(feature: GeoJSONVTInternalTileFeaturePoint, extent: number, z2: number, tx: number, ty: number): GeoJSONVTFeaturePoint {
const transformed = feature as unknown as GeoJSONVTFeaturePoint;
const geometry = feature.geometry;
const point: GeoJSONVTFeaturePoint["geometry"] = [];
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: GeoJSONVTInternalTileFeatureNonPoint, extent: number, z2: number, tx: number, ty: number): GeoJSONVTFeatureNonPoint {
const transformed = feature as unknown as GeoJSONVTFeatureNonPoint;
const geometry = feature.geometry;
const nonPoint: GeoJSONVTFeatureNonPoint["geometry"] = [];
for (const geom of geometry) {
const ring: GeoJSONVTFeaturePoint["geometry"] = [];
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: number, y: number, extent: number, z2: number, tx: number, ty: number): [number, number] {
return [
Math.round(extent * (x * z2 - tx)),
Math.round(extent * (y * z2 - ty))
];
}
+81
View File
@@ -0,0 +1,81 @@
import {AxisType, clip} from './clip';
import type { GeoJSONVTInternalFeature, GeoJSONVTOptions, StartEndSizeArray } from './definitions';
import {createFeature} from './feature';
export function wrap(features: GeoJSONVTInternalFeature[], options: GeoJSONVTOptions): GeoJSONVTInternalFeature[] {
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: GeoJSONVTInternalFeature[], offset: number): GeoJSONVTInternalFeature[] {
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: StartEndSizeArray, offset: number): number[] | StartEndSizeArray {
const newPoints: StartEndSizeArray = [];
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;
}