@maplibre/geojson-vt 6.1.2 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +8 -0
  2. package/dist/cluster-tile-index.d.ts +43 -5
  3. package/dist/cluster-tile-index.d.ts.map +1 -1
  4. package/dist/convert.d.ts +2 -9
  5. package/dist/convert.d.ts.map +1 -1
  6. package/dist/deconvert.d.ts +4 -11
  7. package/dist/deconvert.d.ts.map +1 -1
  8. package/dist/definitions.d.ts +47 -7
  9. package/dist/definitions.d.ts.map +1 -1
  10. package/dist/difference.d.ts +2 -1
  11. package/dist/difference.d.ts.map +1 -1
  12. package/dist/geojson-vt-dev.js +462 -181
  13. package/dist/geojson-vt.js +1 -1
  14. package/dist/geojson-vt.mjs +459 -180
  15. package/dist/geojson-vt.mjs.map +1 -1
  16. package/dist/geojsonvt.d.ts +8 -4
  17. package/dist/geojsonvt.d.ts.map +1 -1
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/tile-index.d.ts +7 -0
  21. package/dist/tile-index.d.ts.map +1 -1
  22. package/dist/world-coordinate-helper.d.ts +13 -0
  23. package/dist/world-coordinate-helper.d.ts.map +1 -0
  24. package/dist/wrap.d.ts.map +1 -1
  25. package/package.json +5 -5
  26. package/src/clip.ts +2 -2
  27. package/src/cluster-tile-index.test.ts +123 -0
  28. package/src/cluster-tile-index.ts +270 -99
  29. package/src/convert.ts +49 -44
  30. package/src/deconvert.test.ts +4 -4
  31. package/src/deconvert.ts +15 -29
  32. package/src/definitions.ts +48 -7
  33. package/src/difference.test.ts +71 -0
  34. package/src/difference.ts +50 -21
  35. package/src/geojson-to-tile.ts +2 -2
  36. package/src/geojsonvt.test.ts +387 -1
  37. package/src/geojsonvt.ts +25 -10
  38. package/src/index.ts +2 -1
  39. package/src/tile-index.ts +19 -3
  40. package/src/world-coordinate-helper.ts +63 -0
  41. package/src/wrap.ts +2 -0
@@ -1,6 +1,6 @@
1
1
  import KDBush from 'kdbush';
2
- import {projectX, projectY} from './convert';
3
- import {unprojectX, unprojectY, featureToGeoJSON} from './deconvert';
2
+ import {getWorldCoordinateHelper, type WorldCoordinateHelper} from './world-coordinate-helper';
3
+ import {featureToGeoJSON} from './deconvert';
4
4
  import type {ClusterFeature, ClusterOrPointFeature, ClusterProperties, GeoJSONVTTileIndex, GeoJSONVTFeature, GeoJSONVTInternalFeature, GeoJSONVTInternalPointFeature, GeoJSONVTOptions, GeoJSONVTTile, SuperclusterOptions} from './definitions';
5
5
 
6
6
  type ClusterFeatureInternal = GeoJSONVTInternalPointFeature & {
@@ -11,7 +11,14 @@ type ClusterOrPointFeatureInternal = ClusterFeatureInternal | GeoJSONVTInternalP
11
11
 
12
12
  /** @internal */
13
13
  export type KDBushWithData = KDBush & {
14
- flatData: number[];
14
+ /**
15
+ * The projected x and y of each point or cluster, as integers, see `encode`.
16
+ */
17
+ flatCoords: Int32Array;
18
+ /**
19
+ * The other values of each point or cluster, at the `OFFSET_` positions.
20
+ */
21
+ flatData: Int32Array;
15
22
  };
16
23
 
17
24
  export const defaultClusterOptions: Required<SuperclusterOptions> = {
@@ -24,38 +31,81 @@ export const defaultClusterOptions: Required<SuperclusterOptions> = {
24
31
  log: false,
25
32
  generateId: false,
26
33
  reduce: null,
27
- map: (props) => props as Record<string, unknown>
34
+ map: (props) => props as Record<string, unknown>,
35
+ coordinates: 'lngLat',
36
+ wrap: true
28
37
  };
29
38
 
30
- const OFFSET_ZOOM = 2;
31
- const OFFSET_ID = 3;
32
- const OFFSET_PARENT = 4;
33
- const OFFSET_NUM = 5;
34
- const OFFSET_PROP = 6;
39
+ /**
40
+ * The highest zoom level to cluster on. A cluster id keeps its zoom level in five bits.
41
+ */
42
+ const MAX_ZOOM = 30;
43
+
44
+ /**
45
+ * Projected coordinates, which are 0 to 1 inside the world, are stored as integers with this many steps per world,
46
+ * so that they take half the memory of doubles. One step is ~4 cm at the equator.
47
+ */
48
+ const SCALE = 0x40000000;
49
+
50
+ /**
51
+ * The largest value a 32-bit integer holds.
52
+ */
53
+ const MAX_INTEGER = 0x7fffffff;
54
+
55
+ /**
56
+ * A projected coordinate in steps from the center of the world, see `SCALE`.
57
+ */
58
+ function encode(coord: number): number {
59
+ return (coord - 0.5) * SCALE;
60
+ }
61
+
62
+ function decode(value: number): number {
63
+ return value / SCALE + 0.5;
64
+ }
65
+
66
+ /**
67
+ * Whether a projected coordinate fits in a 32-bit integer. One that is more than two worlds from the center of the
68
+ * world does not, and neither does one that is not a number.
69
+ */
70
+ function fitsInteger(coord: number): boolean {
71
+ return Math.abs(encode(coord)) < MAX_INTEGER;
72
+ }
73
+
74
+ const OFFSET_ZOOM = 0;
75
+ const OFFSET_ID = 1;
76
+ const OFFSET_PARENT = 2;
77
+ const OFFSET_NUM = 3;
78
+ const OFFSET_PROP = 4;
35
79
 
36
80
  /**
37
81
  * This class allow clustering of geojson points.
38
82
  */
39
83
  export class ClusterTileIndex implements GeoJSONVTTileIndex {
40
84
  options: Required<SuperclusterOptions>;
85
+ /**
86
+ * A tree per zoom level. A zoom level where no clusters form shares the tree of the zoom level above it.
87
+ */
41
88
  trees: KDBushWithData[];
42
89
  stride: number;
43
90
  clusterProps: Record<string, unknown>[];
44
91
  points: GeoJSONVTInternalPointFeature[];
92
+ private worldCoordinateHelper: WorldCoordinateHelper;
45
93
 
46
94
  constructor(options?: SuperclusterOptions) {
47
- this.options = Object.assign(Object.create(defaultClusterOptions), options) as Required<SuperclusterOptions>;
95
+ this.options = createOptions(options);
96
+ this.worldCoordinateHelper = getWorldCoordinateHelper(this.options.coordinates);
48
97
  this.trees = new Array(this.options.maxZoom + 1);
49
- this.stride = this.options.reduce ? 7 : 6;
98
+ this.stride = this.options.reduce ? 5 : 4;
50
99
  this.clusterProps = [];
51
100
  this.points = [];
52
101
  }
53
102
 
54
103
  /**
55
- * Loads GeoJSON point features and builds the internal clustering index.
56
- * @param points - GeoJSON point features to cluster.
104
+ * Loads GeoJSON Point and MultiPoint features and builds the internal clustering index.
105
+ * Each position of a MultiPoint is clustered as a point of its own, with the id and properties of its feature.
106
+ * @param points - GeoJSON Point and MultiPoint features to cluster.
57
107
  */
58
- load(points: GeoJSON.Feature<GeoJSON.Point>[]): void {
108
+ load(points: GeoJSON.Feature<GeoJSON.Point | GeoJSON.MultiPoint>[]): void {
59
109
  const features: GeoJSONVTInternalPointFeature[] = [];
60
110
 
61
111
  // Convert GeoJSON point features to GeoJSONVT internal point features
@@ -64,17 +114,20 @@ export class ClusterTileIndex implements GeoJSONVTTileIndex {
64
114
  continue;
65
115
  }
66
116
 
67
- const [lng, lat] = point.geometry.coordinates;
68
- const [x, y] = [projectX(lng), projectY(lat)];
69
-
70
- const feature: GeoJSONVTInternalPointFeature = {
71
- id: point.id,
72
- type: 'Point',
73
- geometry: [x, y],
74
- tags: point.properties
75
- };
76
-
77
- features.push(feature);
117
+ const positions = point.geometry.type === 'MultiPoint' ? point.geometry.coordinates : [point.geometry.coordinates];
118
+ for (const [lng, lat] of positions) {
119
+ const [x, y] = [this.worldCoordinateHelper.projectX(lng), this.worldCoordinateHelper.projectY(lat)];
120
+ if (!this.options.wrap && !isInWorld([x, y])) continue;
121
+
122
+ const feature: GeoJSONVTInternalPointFeature = {
123
+ id: point.id,
124
+ type: 'Point',
125
+ geometry: [x, y],
126
+ tags: point.properties
127
+ };
128
+
129
+ features.push(feature);
130
+ }
78
131
  }
79
132
 
80
133
  this.createIndex(features);
@@ -83,14 +136,25 @@ export class ClusterTileIndex implements GeoJSONVTTileIndex {
83
136
  /**
84
137
  * @internal
85
138
  * Loads internal GeoJSONVT point features from a data source and builds the clustering index.
139
+ * Each position of a MultiPoint feature becomes a point of its own, with the id and properties of its feature.
86
140
  * @param features - {@link GeoJSONVTInternalFeature} data source features to filter and cluster.
87
141
  */
88
142
  initialize(features: GeoJSONVTInternalFeature[]): void {
89
143
  const points: GeoJSONVTInternalPointFeature[] = [];
90
144
 
91
145
  for (const feature of features) {
92
- if (feature.type !== 'Point') continue;
93
- points.push(feature);
146
+ if (feature.type === 'Point') {
147
+ if (!this.options.wrap && !isInWorld(feature.geometry)) continue;
148
+ points.push(feature);
149
+ continue;
150
+ }
151
+ if (feature.type !== 'MultiPoint') continue;
152
+
153
+ for (let i = 0; i < feature.geometry.length; i += 3) {
154
+ const geometry = [feature.geometry[i], feature.geometry[i + 1]];
155
+ if (!this.options.wrap && !isInWorld(geometry)) continue;
156
+ points.push({id: feature.id, type: 'Point', geometry, tags: feature.tags});
157
+ }
94
158
  }
95
159
 
96
160
  this.createIndex(points);
@@ -102,13 +166,22 @@ export class ClusterTileIndex implements GeoJSONVTTileIndex {
102
166
  * @param features
103
167
  */
104
168
  updateIndex(features: GeoJSONVTInternalFeature[], _affected: GeoJSONVTInternalFeature[], options: GeoJSONVTOptions) {
105
- this.options = Object.assign(Object.create(defaultClusterOptions), options.clusterOptions) as Required<SuperclusterOptions>;
169
+ this.options = createOptions({...options.clusterOptions, coordinates: this.options.coordinates, wrap: this.options.wrap});
106
170
  this.initialize(features);
107
171
  }
108
172
 
173
+ /**
174
+ * Builds a tree per zoom level from the points. A point whose position does not fit in an integer is left out,
175
+ * see `fitsInteger`.
176
+ */
109
177
  private createIndex(points: GeoJSONVTInternalPointFeature[]): void {
110
178
  const {log, minZoom, maxZoom} = this.options;
111
179
 
180
+ // start over, since the options can have changed since the last build
181
+ this.trees = new Array(maxZoom + 1);
182
+ this.stride = this.options.reduce ? 5 : 4;
183
+ this.clusterProps = [];
184
+
112
185
  if (log) console.time('total time');
113
186
 
114
187
  const timerId = `prepare ${points.length} points`;
@@ -116,38 +189,46 @@ export class ClusterTileIndex implements GeoJSONVTTileIndex {
116
189
 
117
190
  this.points = points;
118
191
 
192
+ const stride = this.stride;
193
+ const notProcessed = maxZoom + 1;
194
+
119
195
  // generate a cluster object for each point and index input points into a KD-tree
120
- const data: number[] = [];
196
+ const coords = new Int32Array(points.length * 2);
197
+ const data = new Int32Array(points.length * stride);
198
+ let numItems = 0;
121
199
 
122
200
  for (let i = 0; i < points.length; i++) {
123
201
  const p = points[i];
124
- if (!p?.geometry) continue;
125
-
126
- let [x, y] = p.geometry;
127
- x = Math.fround(x);
128
- y = Math.fround(y);
202
+ if (!p?.geometry || !fitsInteger(p.geometry[0]) || !fitsInteger(p.geometry[1])) continue;
129
203
 
130
204
  // store internal point/cluster data in flat numeric arrays for performance
131
- data.push(
132
- x, y, // projected point coordinates
133
- Infinity, // the last zoom the point was processed at
134
- i, // index of the source feature in the original input array
135
- -1, // parent cluster id
136
- 1 // number of points in a cluster
137
- );
138
- if (this.options.reduce) data.push(0); // noop
205
+ coords[numItems * 2] = Math.round(encode(p.geometry[0])); // projected point coordinates
206
+ coords[numItems * 2 + 1] = Math.round(encode(p.geometry[1]));
207
+ const k = numItems * stride;
208
+ data[k + OFFSET_ZOOM] = notProcessed; // the last zoom the point was processed at
209
+ data[k + OFFSET_ID] = i; // index of the source feature in the original input array
210
+ data[k + OFFSET_PARENT] = -1; // parent cluster id
211
+ data[k + OFFSET_NUM] = 1; // number of points in a cluster
212
+ numItems++;
139
213
  }
140
- let tree = this.trees[maxZoom + 1] = this.createTree(data);
214
+ let tree = this.trees[maxZoom + 1] = this.createTree(coords, data, numItems);
141
215
 
142
216
  if (log) console.timeEnd(timerId);
143
217
 
144
218
  // cluster points on max zoom, then cluster the results on previous zoom, etc.;
145
219
  // results in a cluster hierarchy across zoom levels
220
+ const nextCoords = new Int32Array(numItems * 2);
221
+ const nextData = new Int32Array(numItems * stride);
146
222
  for (let z = maxZoom; z >= minZoom; z--) {
147
223
  const now = Date.now();
148
224
 
149
- // create a new set of clusters for the zoom and index them with a KD-tree
150
- tree = this.trees[z] = this.createTree(this.cluster(tree, z));
225
+ const nextNumItems = this.cluster(tree, z, nextCoords, nextData);
226
+
227
+ if (!this.isSameAsTree(tree, nextData, nextNumItems)) {
228
+ // create a new set of clusters for the zoom and index them with a KD-tree
229
+ tree = this.createTree(nextCoords.slice(0, nextNumItems * 2), nextData.slice(0, nextNumItems * stride), nextNumItems);
230
+ }
231
+ this.trees[z] = tree;
151
232
 
152
233
  if (log) console.log('z%d: %d clusters in %dms', z, tree.numItems, Date.now() - now);
153
234
  }
@@ -157,12 +238,42 @@ export class ClusterTileIndex implements GeoJSONVTTileIndex {
157
238
 
158
239
  /**
159
240
  * Returns clusters and/or points within a bounding box at a given zoom level.
160
- * @param bbox - Bounding box in `[westLng, southLat, eastLng, northLat]` order.
241
+ * @param bbox - Bounding box in `[westLng, southLat, eastLng, northLat]` order, or `[minX, minY, maxX, maxY]` in the unit square with `coordinates: 'unitSquare'`.
242
+ * In a world that wraps, the box can reach across the antimeridian. In one that doesn't, only its part inside the world counts.
161
243
  * @param zoom - Zoom level to query.
162
244
  */
163
245
  public getClusters(bbox: [number, number, number, number], zoom: number): ClusterOrPointFeature[] {
164
- const clusterInternal = this.getClustersInternal(bbox, zoom);
165
- return clusterInternal.map((f) => featureToGeoJSON(f) as ClusterOrPointFeature);
246
+ const clusterInternal =
247
+ !this.options.wrap ? this.getClustersInWorld(bbox, zoom) :
248
+ this.options.coordinates === 'unitSquare' ? this.getClustersAcrossEdge(bbox, zoom) :
249
+ this.getClustersInternal(bbox, zoom);
250
+ return clusterInternal.map((f) => featureToGeoJSON(f, this.worldCoordinateHelper) as ClusterOrPointFeature);
251
+ }
252
+
253
+ /**
254
+ * The clusters and points in the part of a box inside a world that ends at its edges.
255
+ */
256
+ private getClustersInWorld(bbox: [number, number, number, number], zoom: number): ClusterOrPointFeatureInternal[] {
257
+ const lngLat = this.options.coordinates !== 'unitSquare';
258
+ const y1 = this.worldCoordinateHelper.projectY(lngLat ? Math.max(-90, Math.min(90, bbox[1])) : bbox[1]);
259
+ const y2 = this.worldCoordinateHelper.projectY(lngLat ? Math.max(-90, Math.min(90, bbox[3])) : bbox[3]);
260
+ const minX = Math.max(0, this.worldCoordinateHelper.projectX(bbox[0]));
261
+ const maxX = Math.min(1, this.worldCoordinateHelper.projectX(bbox[2]));
262
+ if (minX > maxX) return [];
263
+ return this.getClustersInRange([minX, Math.min(y1, y2), maxX, Math.max(y1, y2)], zoom);
264
+ }
265
+
266
+ /**
267
+ * The clusters and points in a box of a unit square that wraps, where the box can reach across its left or right edge.
268
+ */
269
+ private getClustersAcrossEdge([minX, minY, maxX, maxY]: [number, number, number, number], zoom: number): ClusterOrPointFeatureInternal[] {
270
+ if (maxX < minX) maxX += 1;
271
+ if (maxX - minX >= 1) return this.getClustersInRange([0, minY, 1, maxY], zoom);
272
+ const shift = Math.floor(minX);
273
+ const west = minX - shift;
274
+ const east = maxX - shift;
275
+ if (east <= 1) return this.getClustersInRange([west, minY, east, maxY], zoom);
276
+ return this.getClustersInRange([west, minY, 1, maxY], zoom).concat(this.getClustersInRange([0, minY, east - 1, maxY], zoom));
166
277
  }
167
278
 
168
279
  private getClustersInternal(bbox: [number, number, number, number], zoom: number): ClusterOrPointFeatureInternal[] {
@@ -180,13 +291,21 @@ export class ClusterTileIndex implements GeoJSONVTTileIndex {
180
291
  return easternHem.concat(westernHem);
181
292
  }
182
293
 
294
+ return this.getClustersInRange([this.worldCoordinateHelper.projectX(minLng), this.worldCoordinateHelper.projectY(maxLat), this.worldCoordinateHelper.projectX(maxLng), this.worldCoordinateHelper.projectY(minLat)], zoom);
295
+ }
296
+
297
+ /**
298
+ * The clusters and points in a box of the internal coordinates, `[minX, minY, maxX, maxY]`.
299
+ */
300
+ private getClustersInRange([minX, minY, maxX, maxY]: [number, number, number, number], zoom: number): ClusterOrPointFeatureInternal[] {
183
301
  const tree = this.trees[this.limitZoom(zoom)];
184
- const ids = tree.range(projectX(minLng), projectY(maxLat), projectX(maxLng), projectY(minLat));
302
+ const ids = tree.range(encode(minX), encode(minY), encode(maxX), encode(maxY));
303
+ const coords = tree.flatCoords;
185
304
  const data = tree.flatData;
186
305
  const clusters: ClusterOrPointFeatureInternal[] = [];
187
306
  for (const id of ids) {
188
307
  const k = this.stride * id;
189
- clusters.push(data[k + OFFSET_NUM] > 1 ? getClusterFeature(data, k, this.clusterProps) : this.points[data[k + OFFSET_ID]]);
308
+ clusters.push(data[k + OFFSET_NUM] > 1 ? getClusterFeature(coords, id, data, k, this.clusterProps) : this.points[data[k + OFFSET_ID]]);
190
309
  }
191
310
  return clusters;
192
311
  }
@@ -203,18 +322,19 @@ export class ClusterTileIndex implements GeoJSONVTTileIndex {
203
322
  const tree = this.trees[originZoom];
204
323
  if (!tree) throw clusterError;
205
324
 
325
+ const coords = tree.flatCoords;
206
326
  const data = tree.flatData;
207
- if (originId * this.stride >= data.length) throw clusterError;
327
+ if (originId >= tree.numItems) throw clusterError;
208
328
 
209
- const r = this.options.radius / (this.options.extent * Math.pow(2, originZoom - 1));
210
- const x = data[originId * this.stride];
211
- const y = data[originId * this.stride + 1];
329
+ const r = this.options.radius / (this.options.extent * Math.pow(2, originZoom - 1)) * SCALE;
330
+ const x = coords[originId * 2];
331
+ const y = coords[originId * 2 + 1];
212
332
  const ids = tree.within(x, y, r);
213
333
  const children: ClusterOrPointFeature[] = [];
214
334
  for (const id of ids) {
215
335
  const k = id * this.stride;
216
336
  if (data[k + OFFSET_PARENT] === clusterId) {
217
- children.push(data[k + OFFSET_NUM] > 1 ? getClusterGeoJSON(data, k, this.clusterProps) : featureToGeoJSON(this.points[data[k + OFFSET_ID]]) as GeoJSON.Feature<GeoJSON.Point>);
337
+ children.push(data[k + OFFSET_NUM] > 1 ? getClusterGeoJSON(coords, id, data, k, this.clusterProps, this.worldCoordinateHelper) : featureToGeoJSON(this.points[data[k + OFFSET_ID]], this.worldCoordinateHelper) as GeoJSON.Feature<GeoJSON.Point>);
218
338
  }
219
339
  }
220
340
 
@@ -251,10 +371,11 @@ export class ClusterTileIndex implements GeoJSONVTTileIndex {
251
371
  return null;
252
372
  }
253
373
  const z2 = Math.pow(2, z);
374
+ if (!this.options.wrap && (x < 0 || x >= z2 || y < 0 || y >= z2)) return null;
254
375
  const {extent, radius} = this.options;
255
376
  const p = radius / extent;
256
- const top = (y - p) / z2;
257
- const bottom = (y + 1 + p) / z2;
377
+ const top = encode((y - p) / z2);
378
+ const bottom = encode((y + 1 + p) / z2);
258
379
 
259
380
  const tile: GeoJSONVTTile = {
260
381
  transformed: true,
@@ -266,18 +387,18 @@ export class ClusterTileIndex implements GeoJSONVTTileIndex {
266
387
  };
267
388
 
268
389
  this.addTileFeatures(
269
- tree.range((x - p) / z2, top, (x + 1 + p) / z2, bottom),
270
- tree.flatData, x, y, z2, tile);
390
+ tree.range(encode((x - p) / z2), top, encode((x + 1 + p) / z2), bottom),
391
+ tree, x, y, z2, tile);
271
392
 
272
- if (x === 0) {
393
+ if (this.options.wrap && x === 0) {
273
394
  this.addTileFeatures(
274
- tree.range(1 - p / z2, top, 1, bottom),
275
- tree.flatData, z2, y, z2, tile);
395
+ tree.range(encode(1 - p / z2), top, encode(1), bottom),
396
+ tree, z2, y, z2, tile);
276
397
  }
277
- if (x === z2 - 1) {
398
+ if (this.options.wrap && x === z2 - 1) {
278
399
  this.addTileFeatures(
279
- tree.range(0, top, p / z2, bottom),
280
- tree.flatData, -1, y, z2, tile);
400
+ tree.range(encode(0), top, encode(p / z2), bottom),
401
+ tree, -1, y, z2, tile);
281
402
  }
282
403
 
283
404
  return tile;
@@ -319,16 +440,32 @@ export class ClusterTileIndex implements GeoJSONVTTileIndex {
319
440
  return skipped;
320
441
  }
321
442
 
322
- private createTree(data: number[]): KDBushWithData {
323
- const tree = new KDBush(data.length / this.stride | 0, this.options.nodeSize, Float32Array) as unknown as KDBushWithData;
324
- for (let i = 0; i < data.length; i += this.stride) tree.add(data[i], data[i + 1]);
443
+ private createTree(coords: Int32Array, data: Int32Array, numItems: number): KDBushWithData {
444
+ const tree = new KDBush(numItems, this.options.nodeSize, Int32Array) as unknown as KDBushWithData;
445
+ for (let i = 0; i < numItems; i++) tree.add(coords[i * 2], coords[i * 2 + 1]);
325
446
  tree.finish();
447
+ tree.flatCoords = coords;
326
448
  tree.flatData = data;
327
449
  tree.data = null; // clear original data to free memory as it isn't used later on.
328
450
  return tree;
329
451
  }
330
452
 
331
- private addTileFeatures(ids: number[], data: number[], x: number, y: number, z2: number, tile: GeoJSONVTTile): void {
453
+ /**
454
+ * Whether clustering a tree left all its points and clusters as they were, in the same order.
455
+ */
456
+ private isSameAsTree(tree: KDBushWithData, nextData: Int32Array, nextNumItems: number): boolean {
457
+ if (nextNumItems !== tree.numItems) return false;
458
+
459
+ const data = tree.flatData;
460
+ for (let k = OFFSET_ID; k < nextNumItems * this.stride; k += this.stride) {
461
+ if (nextData[k] !== data[k]) return false;
462
+ }
463
+ return true;
464
+ }
465
+
466
+ private addTileFeatures(ids: number[], tree: KDBushWithData, x: number, y: number, z2: number, tile: GeoJSONVTTile): void {
467
+ const coords = tree.flatCoords;
468
+ const data = tree.flatData;
332
469
  for (const i of ids) {
333
470
  const k = i * this.stride;
334
471
  const isCluster = data[k + OFFSET_NUM] > 1;
@@ -338,8 +475,8 @@ export class ClusterTileIndex implements GeoJSONVTTileIndex {
338
475
  let py: number;
339
476
  if (isCluster) {
340
477
  tags = getClusterProperties(data, k, this.clusterProps);
341
- px = data[k];
342
- py = data[k + 1];
478
+ px = decode(coords[i * 2]);
479
+ py = decode(coords[i * 2 + 1]);
343
480
  } else {
344
481
  const p = this.points[data[k + OFFSET_ID]];
345
482
  tags = p.tags;
@@ -375,30 +512,38 @@ export class ClusterTileIndex implements GeoJSONVTTileIndex {
375
512
  return Math.max(this.options.minZoom, Math.min(Math.floor(+z), this.options.maxZoom + 1));
376
513
  }
377
514
 
378
- private cluster(tree: KDBushWithData, zoom: number): number[] {
379
- const {radius, extent, reduce, minPoints} = this.options;
380
- const r = radius / (extent * Math.pow(2, zoom));
515
+ /**
516
+ * Clusters the points of a tree on a zoom level, writing the result to `nextCoords` and `nextData`.
517
+ * @returns the number of points and clusters written
518
+ */
519
+ private cluster(tree: KDBushWithData, zoom: number, nextCoords: Int32Array, nextData: Int32Array): number {
520
+ const {radius, extent, reduce, minPoints, maxZoom} = this.options;
521
+ const r = radius / (extent * Math.pow(2, zoom)) * SCALE;
522
+ const notProcessed = maxZoom + 1;
523
+ const coords = tree.flatCoords;
381
524
  const data = tree.flatData;
382
- const nextData: number[] = [];
383
525
  const stride = this.stride;
526
+ const neighborIds = new Uint32Array(tree.numItems);
527
+ let nextNumItems = 0;
384
528
 
385
529
  // loop through each point
386
- for (let i = 0; i < data.length; i += stride) {
530
+ for (let index = 0; index < tree.numItems; index++) {
531
+ const i = index * stride;
387
532
  // if we've already visited the point at this zoom level, skip it
388
533
  if (data[i + OFFSET_ZOOM] <= zoom) continue;
389
534
  data[i + OFFSET_ZOOM] = zoom;
390
535
 
391
536
  // find all nearby points
392
- const x = data[i];
393
- const y = data[i + 1];
394
- const neighborIds = tree.within(data[i], data[i + 1], r);
537
+ const x = coords[index * 2];
538
+ const y = coords[index * 2 + 1];
539
+ const numNeighbors = tree.withinInto(x, y, r, neighborIds);
395
540
 
396
541
  const numPointsOrigin = data[i + OFFSET_NUM];
397
542
  let numPoints = numPointsOrigin;
398
543
 
399
544
  // count the number of points in a potential cluster
400
- for (const neighborId of neighborIds) {
401
- const k = neighborId * stride;
545
+ for (let n = 0; n < numNeighbors; n++) {
546
+ const k = neighborIds[n] * stride;
402
547
  // filter out neighbors that are already processed
403
548
  if (data[k + OFFSET_ZOOM] > zoom) numPoints += data[k + OFFSET_NUM];
404
549
  }
@@ -412,17 +557,18 @@ export class ClusterTileIndex implements GeoJSONVTTileIndex {
412
557
  let clusterPropIndex = -1;
413
558
 
414
559
  // encode both zoom and point index on which the cluster originated -- offset by total length of features
415
- const id = ((i / stride | 0) << 5) + (zoom + 1) + this.points.length;
560
+ const id = (index << 5) + (zoom + 1) + this.points.length;
416
561
 
417
- for (const neighborId of neighborIds) {
418
- const k = neighborId * stride;
562
+ for (let n = 0; n < numNeighbors; n++) {
563
+ const neighborIndex = neighborIds[n];
564
+ const k = neighborIndex * stride;
419
565
 
420
566
  if (data[k + OFFSET_ZOOM] <= zoom) continue;
421
567
  data[k + OFFSET_ZOOM] = zoom; // save the zoom (so it doesn't get processed twice)
422
568
 
423
569
  const numPoints2 = data[k + OFFSET_NUM];
424
- wx += data[k] * numPoints2; // accumulate coordinates for calculating weighted center
425
- wy += data[k + 1] * numPoints2;
570
+ wx += coords[neighborIndex * 2] * numPoints2; // accumulate coordinates for calculating weighted center
571
+ wy += coords[neighborIndex * 2 + 1] * numPoints2;
426
572
 
427
573
  data[k + OFFSET_PARENT] = id;
428
574
 
@@ -437,24 +583,38 @@ export class ClusterTileIndex implements GeoJSONVTTileIndex {
437
583
  }
438
584
 
439
585
  data[i + OFFSET_PARENT] = id;
440
- nextData.push(wx / numPoints, wy / numPoints, Infinity, id, -1, numPoints);
441
- if (reduce) nextData.push(clusterPropIndex);
586
+ nextCoords[nextNumItems * 2] = Math.round(wx / numPoints);
587
+ nextCoords[nextNumItems * 2 + 1] = Math.round(wy / numPoints);
588
+ const next = nextNumItems * stride;
589
+ nextData[next + OFFSET_ZOOM] = notProcessed;
590
+ nextData[next + OFFSET_ID] = id;
591
+ nextData[next + OFFSET_PARENT] = -1;
592
+ nextData[next + OFFSET_NUM] = numPoints;
593
+ if (reduce) nextData[next + OFFSET_PROP] = clusterPropIndex;
594
+ nextNumItems++;
442
595
 
443
596
  } else { // left points as unclustered
444
- for (let j = 0; j < stride; j++) nextData.push(data[i + j]);
597
+ nextCoords[nextNumItems * 2] = x;
598
+ nextCoords[nextNumItems * 2 + 1] = y;
599
+ for (let j = 0; j < stride; j++) nextData[nextNumItems * stride + j] = data[i + j];
600
+ nextNumItems++;
445
601
 
446
602
  if (numPoints > 1) {
447
- for (const neighborId of neighborIds) {
448
- const k = neighborId * stride;
603
+ for (let n = 0; n < numNeighbors; n++) {
604
+ const neighborIndex = neighborIds[n];
605
+ const k = neighborIndex * stride;
449
606
  if (data[k + OFFSET_ZOOM] <= zoom) continue;
450
607
  data[k + OFFSET_ZOOM] = zoom;
451
- for (let j = 0; j < stride; j++) nextData.push(data[k + j]);
608
+ nextCoords[nextNumItems * 2] = coords[neighborIndex * 2];
609
+ nextCoords[nextNumItems * 2 + 1] = coords[neighborIndex * 2 + 1];
610
+ for (let j = 0; j < stride; j++) nextData[nextNumItems * stride + j] = data[k + j];
611
+ nextNumItems++;
452
612
  }
453
613
  }
454
614
  }
455
615
  }
456
616
 
457
- return nextData;
617
+ return nextNumItems;
458
618
  }
459
619
 
460
620
  // get index of the point from which the cluster originated
@@ -467,7 +627,7 @@ export class ClusterTileIndex implements GeoJSONVTTileIndex {
467
627
  return (clusterId - this.points.length) % 32;
468
628
  }
469
629
 
470
- private map(data: number[], i: number, clone?: boolean): Record<string, unknown> {
630
+ private map(data: Int32Array, i: number, clone?: boolean): Record<string, unknown> {
471
631
  if (data[i + OFFSET_NUM] > 1) {
472
632
  const props = this.clusterProps[data[i + OFFSET_PROP]];
473
633
  return clone ? Object.assign({}, props) : props;
@@ -478,28 +638,35 @@ export class ClusterTileIndex implements GeoJSONVTTileIndex {
478
638
  }
479
639
  }
480
640
 
481
- function getClusterFeature(data: number[], i: number, clusterProps: Record<string, unknown>[]): ClusterFeatureInternal {
641
+ function createOptions(options?: SuperclusterOptions): Required<SuperclusterOptions> {
642
+ const result = Object.assign(Object.create(defaultClusterOptions), options) as Required<SuperclusterOptions>;
643
+ result.minZoom = Math.min(result.minZoom, MAX_ZOOM);
644
+ result.maxZoom = Math.min(result.maxZoom, MAX_ZOOM);
645
+ return result;
646
+ }
647
+
648
+ function getClusterFeature(coords: Int32Array, index: number, data: Int32Array, i: number, clusterProps: Record<string, unknown>[]): ClusterFeatureInternal {
482
649
  return {
483
650
  id: data[i + OFFSET_ID],
484
651
  type: 'Point',
485
652
  tags: getClusterProperties(data, i, clusterProps),
486
- geometry: [data[i], data[i + 1]]
653
+ geometry: [decode(coords[index * 2]), decode(coords[index * 2 + 1])]
487
654
  };
488
655
  }
489
656
 
490
- function getClusterGeoJSON(data: number[], i: number, clusterProps: Record<string, unknown>[]): ClusterFeature {
657
+ function getClusterGeoJSON(coords: Int32Array, index: number, data: Int32Array, i: number, clusterProps: Record<string, unknown>[], worldCoordinateHelper: WorldCoordinateHelper): ClusterFeature {
491
658
  return {
492
659
  type: 'Feature',
493
660
  id: data[i + OFFSET_ID],
494
661
  properties: getClusterProperties(data, i, clusterProps),
495
662
  geometry: {
496
663
  type: 'Point',
497
- coordinates: [unprojectX(data[i]), unprojectY(data[i + 1])]
664
+ coordinates: [worldCoordinateHelper.unprojectX(decode(coords[index * 2])), worldCoordinateHelper.unprojectY(decode(coords[index * 2 + 1]))]
498
665
  }
499
666
  };
500
667
  }
501
668
 
502
- function getClusterProperties(data: number[], i: number, clusterProps: Record<string, unknown>[]): ClusterProperties {
669
+ function getClusterProperties(data: Int32Array, i: number, clusterProps: Record<string, unknown>[]): ClusterProperties {
503
670
  const count = data[i + OFFSET_NUM];
504
671
  const abbrev =
505
672
  count >= 10000 ? `${Math.round(count / 1000) }k` :
@@ -514,3 +681,7 @@ function getClusterProperties(data: number[], i: number, clusterProps: Record<st
514
681
  point_count_abbreviated: abbrev
515
682
  });
516
683
  }
684
+
685
+ function isInWorld(geometry: number[]): boolean {
686
+ return geometry[0] >= 0 && geometry[0] <= 1 && geometry[1] >= 0 && geometry[1] <= 1;
687
+ }