@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
@@ -36,4 +36,390 @@ test('publicly exposed cluster methods: return undefined when clustering is disa
36
36
  expect(index.getClusterExpansionZoom(123)).toBeNull();
37
37
  expect(index.getClusterChildren(123)).toBeNull();
38
38
  expect(index.getClusterLeaves(123, 10, 0)).toBeNull();
39
- });
39
+ });
40
+
41
+ test('a unit square without wrap cuts a line at the buffer around the world instead of wrapping or clamping it', () => {
42
+ const fromThreeWorldsNorthToEast = {type: 'LineString' as const, coordinates: [[0.25, -3], [0.75, 0.5], [1.25, 0.5]]};
43
+
44
+ const index = new GeoJSONVT(fromThreeWorldsNorthToEast, {coordinates: 'unitSquare', wrap: false});
45
+
46
+ expect(index.getTile(0, 0, 0).features.map(feature => feature.geometry)).toEqual([[[[2770, -64], [3072, 2048], [4160, 2048]]]]);
47
+ });
48
+
49
+ test('a unit square without wrap cuts the top tile at the world again on update, so a feature can move in from outside', () => {
50
+ const movingIn = {type: 'Feature' as const, id: 'moving in', geometry: {type: 'Point' as const, coordinates: [1.25, 0.5]}, properties: {}};
51
+ const stayingOut = {type: 'Feature' as const, id: 'staying out', geometry: {type: 'Point' as const, coordinates: [1.5, 0.5]}, properties: {}};
52
+ const index = new GeoJSONVT({type: 'FeatureCollection', features: [movingIn, stayingOut]}, {coordinates: 'unitSquare', wrap: false, updateable: true});
53
+
54
+ index.updateData({update: [{id: 'moving in', newGeometry: {type: 'Point', coordinates: [0.5, 0.5]}}]});
55
+
56
+ expect(index.getTile(0, 0, 0).features.map(feature => feature.id)).toEqual(['moving in']);
57
+ });
58
+
59
+ test('a unit square without wrap returns the data in the unit square', () => {
60
+ const features: GeoJSON.Feature[] = [
61
+ {type: 'Feature', id: 'point', geometry: {type: 'Point', coordinates: [0.25, 0.75]}, properties: {}},
62
+ {type: 'Feature', id: 'multi point', geometry: {type: 'MultiPoint', coordinates: [[0.25, 0.75], [0.5, 0.5]]}, properties: {}},
63
+ {type: 'Feature', id: 'line', geometry: {type: 'LineString', coordinates: [[0.25, 0.75], [0.5, 0.5]]}, properties: {}},
64
+ {type: 'Feature', id: 'multi line', geometry: {type: 'MultiLineString', coordinates: [[[0.25, 0.75], [0.5, 0.5]], [[0.5, 0.25], [0.75, 0.25]]]}, properties: {}},
65
+ {type: 'Feature', id: 'polygon', geometry: {type: 'Polygon', coordinates: [[[0.25, 0.25], [0.75, 0.25], [0.75, 0.75], [0.25, 0.25]]]}, properties: {}},
66
+ {type: 'Feature', id: 'multi polygon', geometry: {type: 'MultiPolygon', coordinates: [[[[0.25, 0.25], [0.75, 0.25], [0.75, 0.75], [0.25, 0.25]]]]}, properties: {}}
67
+ ];
68
+ const index = new GeoJSONVT({type: 'FeatureCollection', features}, {coordinates: 'unitSquare', wrap: false, updateable: true});
69
+
70
+ expect(index.getData()).toEqual({type: 'FeatureCollection', features});
71
+ });
72
+
73
+ test('a unit square without wrap keeps a MultiLineString it splits for lineMetrics in the unit square', () => {
74
+ const multiLine = {type: 'MultiLineString' as const, coordinates: [[[0.25, 0.75], [0.5, 0.5]], [[0.5, 0.25], [0.75, 0.25]]]};
75
+ const index = new GeoJSONVT(multiLine, {coordinates: 'unitSquare', wrap: false, lineMetrics: true, updateable: true});
76
+
77
+ expect(index.getData()).toEqual({type: 'FeatureCollection', features: [
78
+ {type: 'Feature', geometry: {type: 'LineString', coordinates: [[0.25, 0.75], [0.5, 0.5]]}},
79
+ {type: 'Feature', geometry: {type: 'LineString', coordinates: [[0.5, 0.25], [0.75, 0.25]]}}
80
+ ]});
81
+ });
82
+
83
+ test('a unit square without wrap hands the update filter features in the unit square', () => {
84
+ const point = {type: 'Feature' as const, id: 'p', geometry: {type: 'Point' as const, coordinates: [0.25, 0.75]}, properties: {}};
85
+ const index = new GeoJSONVT({type: 'FeatureCollection', features: [point]}, {coordinates: 'unitSquare', wrap: false, updateable: true});
86
+ const filtered: GeoJSON.Geometry[] = [];
87
+
88
+ index.updateData({add: [{...point, id: 'q'}]}, feature => {
89
+ filtered.push(feature.geometry);
90
+ return true;
91
+ });
92
+
93
+ expect(filtered).toContainEqual({type: 'Point', coordinates: [0.25, 0.75]});
94
+ });
95
+
96
+ test('a unit square without wrap leaves points outside the world out of the clusters', () => {
97
+ const data = {
98
+ type: 'FeatureCollection' as const,
99
+ features: [
100
+ {type: 'Feature' as const, id: 'inside', geometry: {type: 'Point' as const, coordinates: [0.99, 0.5]}, properties: {}},
101
+ {type: 'Feature' as const, id: 'east', geometry: {type: 'Point' as const, coordinates: [1.03, 0.5]}, properties: {}},
102
+ {type: 'Feature' as const, id: 'north', geometry: {type: 'Point' as const, coordinates: [0.5, -0.03]}, properties: {}}
103
+ ]
104
+ };
105
+
106
+ const index = new GeoJSONVT(data, {coordinates: 'unitSquare', wrap: false, cluster: true});
107
+
108
+ expect(index.getTile(0, 0, 0).features.map(feature => feature.geometry)).toEqual([[[507, 256]]]);
109
+ });
110
+
111
+ test('a unit square without wrap keeps points at either edge out of the clustered tile at the other edge', () => {
112
+ const data = {
113
+ type: 'FeatureCollection' as const,
114
+ features: [
115
+ {type: 'Feature' as const, id: 'west', geometry: {type: 'Point' as const, coordinates: [0.001, 0.5]}, properties: {}},
116
+ {type: 'Feature' as const, id: 'east', geometry: {type: 'Point' as const, coordinates: [0.999, 0.5]}, properties: {}}
117
+ ]
118
+ };
119
+
120
+ const index = new GeoJSONVT(data, {coordinates: 'unitSquare', wrap: false, cluster: true});
121
+
122
+ expect(index.getTile(0, 0, 0).features).toHaveLength(2);
123
+ });
124
+
125
+ test('a unit square without wrap returns cluster children and leaves in the unit square', () => {
126
+ const points = {
127
+ type: 'FeatureCollection' as const,
128
+ features: Array.from({length: 20}, (_, i) => ({
129
+ type: 'Feature' as const,
130
+ geometry: {type: 'Point' as const, coordinates: [0.5 + i * 0.000001, 0.5]},
131
+ properties: {name: `Point ${i}`}
132
+ }))
133
+ };
134
+ const index = new GeoJSONVT(points, {coordinates: 'unitSquare', wrap: false, cluster: true, clusterOptions: {radius: 100}});
135
+ const cluster = index.getTile(0, 0, 0).features.find(f => (f.tags as {cluster?: boolean})?.cluster);
136
+ const clusterId = (cluster.tags as {cluster_id: number}).cluster_id;
137
+
138
+ const childPositions = index.getClusterChildren(clusterId).map(child => (child.geometry as GeoJSON.Point).coordinates);
139
+ const leafPositions = index.getClusterLeaves(clusterId, 20, 0).map(leaf => (leaf.geometry as GeoJSON.Point).coordinates);
140
+
141
+ expect(childPositions.every(([x, y]) => Math.abs(x - 0.50001) < 0.00001 && y === 0.5)).toBe(true);
142
+ expect(leafPositions).toEqual(expect.arrayContaining(points.features.map(point => point.geometry.coordinates)));
143
+ });
144
+
145
+ test('a unit square without wrap stays without wrap for clusters after updateData', () => {
146
+ const point = (id: string, x: number) => ({type: 'Feature' as const, id, properties: {}, geometry: {type: 'Point' as const, coordinates: [x, 0.5]}});
147
+ const index = new GeoJSONVT({type: 'FeatureCollection', features: [point('a', 0.01)]}, {coordinates: 'unitSquare', wrap: false, cluster: true, updateable: true});
148
+
149
+ index.updateData({add: [point('b', 0.99)]});
150
+
151
+ expect(index.getTile(1, -1, 0)).toBeNull();
152
+ });
153
+
154
+ test('wraps positions in the unit square around the antimeridian by default', () => {
155
+ const nearTheEastEdge = {type: 'Feature' as const, properties: {}, geometry: {type: 'Point' as const, coordinates: [0.999, 0.25]}};
156
+ const index = new GeoJSONVT(nearTheEastEdge, {coordinates: 'unitSquare'});
157
+
158
+ expect(index.getTile(1, 0, 0).features.map(feature => feature.geometry)).toEqual([[[-8, 2048]]]);
159
+ });
160
+
161
+ test('cuts the top tile above and below for positions in a unit square that wraps', () => {
162
+ const fromThreeBelowToThreeAbove = {type: 'Feature' as const, properties: {}, geometry: {type: 'LineString' as const, coordinates: [[0.25, -3], [0.75, 3]]}};
163
+ const index = new GeoJSONVT(fromThreeBelowToThreeAbove, {coordinates: 'unitSquare'});
164
+
165
+ expect(index.getTile(0, 0, 0).features[0].geometry).toEqual([[[2043, -64], [2395, 4160]]]);
166
+ });
167
+
168
+ test('copies no lng/lat features across the antimeridian without wrap', () => {
169
+ const nearTheAntimeridian = {type: 'Feature' as const, properties: {}, geometry: {type: 'Point' as const, coordinates: [179.9, -10]}};
170
+ const index = new GeoJSONVT(nearTheAntimeridian, {wrap: false});
171
+
172
+ expect(index.getTile(1, 0, 1)?.features ?? []).toEqual([]);
173
+ expect(index.getTile(1, 1, 1).features).toHaveLength(1);
174
+ });
175
+
176
+ test('a unit square without wrap returns no tile for a column on either side of the world', () => {
177
+ const inTheTwoTopTiles = {type: 'MultiPoint' as const, coordinates: [[0.25, 0.25], [0.75, 0.25]]};
178
+
179
+ const index = new GeoJSONVT(inTheTwoTopTiles, {coordinates: 'unitSquare', wrap: false});
180
+
181
+ expect([index.getTile(1, -1, 0), index.getTile(1, 2, 0)]).toEqual([null, null]);
182
+ });
183
+
184
+ const weightedPoints = {
185
+ type: 'FeatureCollection' as const,
186
+ features: [[0, 1], [0.01, 2], [100, 10], [100.01, 20]].map(([lng, weight]) => ({
187
+ type: 'Feature' as const,
188
+ geometry: {type: 'Point' as const, coordinates: [lng, 0]},
189
+ properties: {weight}
190
+ }))
191
+ };
192
+
193
+ const sumWeights = {
194
+ map: (props: GeoJSON.GeoJsonProperties) => ({sum: (props as {weight: number}).weight}),
195
+ reduce: (a: Record<string, unknown>, b: Record<string, unknown>) => { (a as {sum: number}).sum += (b as {sum: number}).sum; }
196
+ };
197
+
198
+ test('updateClusterOptions aggregates cluster properties when it adds a reduce function', () => {
199
+ const index = new GeoJSONVT(weightedPoints, {
200
+ updateable: true,
201
+ cluster: true,
202
+ clusterOptions: {maxZoom: 2}
203
+ });
204
+
205
+ index.updateClusterOptions(true, {maxZoom: 2, ...sumWeights});
206
+
207
+ expect(index.getTile(0, 0, 0).features.map(f => (f.tags as {sum?: number}).sum)).toEqual([3, 30]);
208
+ });
209
+
210
+ test('updateClusterOptions stops aggregating cluster properties when it removes the reduce function', () => {
211
+ const index = new GeoJSONVT(weightedPoints, {
212
+ updateable: true,
213
+ cluster: true,
214
+ clusterOptions: {maxZoom: 2, ...sumWeights}
215
+ });
216
+
217
+ index.updateClusterOptions(true, {maxZoom: 2});
218
+
219
+ expect(index.getTile(0, 0, 0).features.map(f => (f.tags as {sum?: number}).sum)).toEqual([undefined, undefined]);
220
+ });
221
+
222
+ test('skips an empty polygon of a MultiPolygon', () => {
223
+ const multiPolygon = {
224
+ type: 'MultiPolygon' as const,
225
+ coordinates: [[[[0, 0], [10, 0], [10, 10], [0, 0]]], []]
226
+ };
227
+
228
+ const index = new GeoJSONVT(multiPolygon, {maxZoom: 5});
229
+
230
+ expect(index.getTile(0, 0, 0).features[0].geometry).toEqual([[[2048, 2048], [2162, 1934], [2162, 2048], [2048, 2048]]]);
231
+ });
232
+
233
+ test('leaves out a MultiPolygon that has only empty polygons', () => {
234
+ const multiPolygon = {
235
+ type: 'MultiPolygon' as const,
236
+ coordinates: [[]] as GeoJSON.Position[][][]
237
+ };
238
+
239
+ const index = new GeoJSONVT(multiPolygon, {updateable: true});
240
+
241
+ expect(index.getData()).toEqual({type: 'FeatureCollection', features: []});
242
+ });
243
+
244
+ test('updateData draws a feature again when an update gives back the coordinates an earlier one took away', () => {
245
+ const line = {type: 'Feature' as const, id: 'line', properties: {name: 'line'}, geometry: {type: 'LineString' as const, coordinates: [[0, 0], [10, 10]]}};
246
+ const index = new GeoJSONVT({type: 'FeatureCollection', features: [line]}, {updateable: true});
247
+
248
+ index.updateData({update: [{id: 'line', newGeometry: {type: 'LineString', coordinates: []}}]});
249
+ index.updateData({update: [{id: 'line', newGeometry: line.geometry}]});
250
+
251
+ expect(index.getTile(0, 0, 0).features.map(feature => feature.tags)).toEqual([{name: 'line'}]);
252
+ });
253
+
254
+ test('updateData draws a feature loaded without a geometry once an update gives it one', () => {
255
+ const later: GeoJSON.Feature = {type: 'Feature', id: 'later', properties: {name: 'later'}, geometry: null};
256
+ const index = new GeoJSONVT({type: 'FeatureCollection', features: [later]}, {updateable: true});
257
+
258
+ index.updateData({update: [{id: 'later', newGeometry: {type: 'Point', coordinates: [10, 10]}}]});
259
+
260
+ expect(index.getTile(0, 0, 0).features.map(feature => feature.tags)).toEqual([{name: 'later'}]);
261
+ });
262
+
263
+ test('updateData draws a feature added without coordinates once an update gives it some', () => {
264
+ const index = new GeoJSONVT({type: 'FeatureCollection', features: []}, {updateable: true});
265
+
266
+ index.updateData({add: [{type: 'Feature', id: 'added', properties: {name: 'added'}, geometry: {type: 'Polygon', coordinates: []}}]});
267
+ index.updateData({update: [{id: 'added', newGeometry: {type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 0]]]}}]});
268
+
269
+ expect(index.getTile(0, 0, 0).features.map(feature => feature.tags)).toEqual([{name: 'added'}]);
270
+ });
271
+
272
+ test('updateData leaves a feature without a geometry removed when an update gives it one', () => {
273
+ const kept = {type: 'Feature' as const, id: 'kept', properties: {}, geometry: {type: 'Point' as const, coordinates: [0, 0]}};
274
+ const removed: GeoJSON.Feature = {type: 'Feature', id: 'removed', properties: {}, geometry: null};
275
+ const index = new GeoJSONVT({type: 'FeatureCollection', features: [kept, removed]}, {updateable: true});
276
+
277
+ index.updateData({remove: ['removed']});
278
+ index.updateData({update: [{id: 'removed', newGeometry: {type: 'Point', coordinates: [10, 10]}}]});
279
+
280
+ expect(index.getTile(0, 0, 0).features.map(feature => feature.id)).toEqual(['kept']);
281
+ });
282
+
283
+ test('updateData leaves a feature without a geometry removed by removeAll when an update gives it one', () => {
284
+ const kept = {type: 'Feature' as const, id: 'kept', properties: {}, geometry: {type: 'Point' as const, coordinates: [0, 0]}};
285
+ const removed: GeoJSON.Feature = {type: 'Feature', id: 'removed', properties: {}, geometry: null};
286
+ const index = new GeoJSONVT({type: 'FeatureCollection', features: [removed]}, {updateable: true});
287
+
288
+ index.updateData({removeAll: true, add: [kept]});
289
+ index.updateData({update: [{id: 'removed', newGeometry: {type: 'Point', coordinates: [10, 10]}}]});
290
+
291
+ expect(index.getTile(0, 0, 0).features.map(feature => feature.id)).toEqual(['kept']);
292
+ });
293
+
294
+ test('updateData keeps the property changes made to a feature while it has no geometry', () => {
295
+ const renamed: GeoJSON.Feature = {type: 'Feature', id: 'renamed', properties: {name: 'before'}, geometry: null};
296
+ const index = new GeoJSONVT({type: 'FeatureCollection', features: [renamed]}, {updateable: true});
297
+
298
+ index.updateData({update: [{id: 'renamed', addOrUpdateProperties: [{key: 'name', value: 'after'}]}]});
299
+ index.updateData({update: [{id: 'renamed', newGeometry: {type: 'Point', coordinates: [10, 10]}}]});
300
+
301
+ expect(index.getTile(0, 0, 0).features.map(feature => feature.tags)).toEqual([{name: 'after'}]);
302
+ });
303
+
304
+ test('updateData does not bring back a feature the update filter took out after an update gave it a geometry', () => {
305
+ const kept = {type: 'Feature' as const, id: 'kept', properties: {}, geometry: {type: 'Point' as const, coordinates: [0, 0]}};
306
+ const hidden: GeoJSON.Feature = {type: 'Feature', id: 'hidden', properties: {hidden: true}, geometry: null};
307
+ const index = new GeoJSONVT({type: 'FeatureCollection', features: [kept, hidden]}, {updateable: true});
308
+
309
+ index.updateData({update: [{id: 'hidden', newGeometry: {type: 'Point', coordinates: [10, 10]}}]}, feature => !feature.properties.hidden);
310
+ index.updateData({update: [{id: 'hidden', newGeometry: {type: 'Point', coordinates: [20, 20]}}]});
311
+
312
+ expect(index.getTile(0, 0, 0).features.map(feature => feature.id)).toEqual(['kept']);
313
+ });
314
+
315
+ test('updateData does not bring back a feature the update filter took out while it had no coordinates', () => {
316
+ const point = {type: 'Feature' as const, id: 'point', properties: {keep: true}, geometry: {type: 'Point' as const, coordinates: [0, 0]}};
317
+ const index = new GeoJSONVT({type: 'FeatureCollection', features: [point]}, {updateable: true});
318
+ const keepIfMarked = (feature: GeoJSON.Feature) => feature.properties?.keep === true;
319
+
320
+ index.updateData({update: [{id: 'point', newGeometry: {type: 'Point', coordinates: []}, addOrUpdateProperties: [{key: 'keep', value: false}]}]}, keepIfMarked);
321
+ index.updateData({update: [{id: 'point', newGeometry: {type: 'Point', coordinates: [10, 10]}, addOrUpdateProperties: [{key: 'keep', value: true}]}]}, keepIfMarked);
322
+
323
+ expect(index.getData()).toEqual({type: 'FeatureCollection', features: []});
324
+ });
325
+
326
+ test('updateData draws a bare geometry loaded without coordinates once an update gives it some', () => {
327
+ const index = new GeoJSONVT({type: 'LineString', coordinates: []}, {updateable: true, generateId: true});
328
+
329
+ index.updateData({update: [{id: 0, newGeometry: {type: 'LineString', coordinates: [[0, 0], [10, 10]]}}]});
330
+
331
+ expect(index.getTile(0, 0, 0)?.features.map(feature => feature.id)).toEqual([0]);
332
+ });
333
+
334
+ test('updateData keeps a feature loaded without a geometry away from the update filter', () => {
335
+ const point = {type: 'Feature' as const, id: 'point', properties: {}, geometry: {type: 'Point' as const, coordinates: [0, 0]}};
336
+ const later: GeoJSON.Feature = {type: 'Feature', id: 'later', properties: {}, geometry: null};
337
+ const index = new GeoJSONVT({type: 'FeatureCollection', features: [point, later]}, {updateable: true});
338
+ const pointsOnly = (feature: GeoJSON.Feature) => feature.geometry.type === 'Point';
339
+
340
+ index.updateData({update: [{id: 'point', addOrUpdateProperties: [{key: 'name', value: 'p'}]}]}, pointsOnly);
341
+ index.updateData({update: [{id: 'later', newGeometry: {type: 'Point', coordinates: [10, 10]}}]}, pointsOnly);
342
+
343
+ expect(index.getTile(0, 0, 0)?.features.map(feature => feature.id).sort()).toEqual(['later', 'point']);
344
+ });
345
+
346
+ test('updateData hands the filter a feature without coordinates with its promoted id', () => {
347
+ const index = new GeoJSONVT({type: 'FeatureCollection', features: [{type: 'Feature', id: 'raw', properties: {key: 'promoted'}, geometry: {type: 'Point', coordinates: []}}]}, {updateable: true, promoteId: 'key'});
348
+
349
+ index.updateData({}, feature => feature.id === 'promoted');
350
+ index.updateData({update: [{id: 'promoted', newGeometry: {type: 'Point', coordinates: [10, 10]}}]});
351
+
352
+ expect(index.getTile(0, 0, 0)?.features.map(feature => feature.id)).toEqual(['promoted']);
353
+ });
354
+
355
+ test('updateData hands the filter a feature without coordinates with its generated id', () => {
356
+ const index = new GeoJSONVT({type: 'FeatureCollection', features: [{type: 'Feature', properties: {}, geometry: {type: 'Point', coordinates: []}}]}, {updateable: true, generateId: true});
357
+
358
+ index.updateData({}, feature => feature.id === 0);
359
+ index.updateData({update: [{id: 0, newGeometry: {type: 'Point', coordinates: [10, 10]}}]});
360
+
361
+ expect(index.getTile(0, 0, 0)?.features.map(feature => feature.id)).toEqual([0]);
362
+ });
363
+
364
+ test('keeps the corner of a polygon whose vertex lies on the left edge of a tile', () => {
365
+ const polygon = {
366
+ type: 'Polygon' as const,
367
+ coordinates: [[[-90, -90], [0, -90], [90, -90], [0, 0], [-90, -90]]]
368
+ };
369
+
370
+ const index = new GeoJSONVT(polygon, {indexMaxZoom: 0, maxZoom: 24, tolerance: 1.5, buffer: 0});
371
+
372
+ expect(index.getTile(1, 1, 1).features[0].geometry).toEqual([[[0, 4096], [0, 0], [0, 0], [2048, 4096], [0, 4096]]]);
373
+ });
374
+
375
+ test('keeps the corner of a polygon whose vertex lies on the right edge of a tile', () => {
376
+ const polygon = {
377
+ type: 'Polygon' as const,
378
+ coordinates: [[[20, 34.365234375], [80, 4.34326171875], [45, 4.34326171875], [20, 4.34326171875], [20, 34.365234375]]]
379
+ };
380
+
381
+ const index = new GeoJSONVT(polygon, {maxZoom: 5, buffer: 0});
382
+
383
+ expect(index.getTile(3, 4, 3).features[0].geometry).toEqual([[[1820, 762], [4096, 1986], [4096, 3700], [1820, 3700], [1820, 762]]]);
384
+ });
385
+
386
+ test('keeps the part of a line that enters a tile exactly at a vertex', () => {
387
+ const line = {
388
+ type: 'LineString' as const,
389
+ coordinates: [[-90, 20], [0, 20], [90, 20]]
390
+ };
391
+
392
+ const index = new GeoJSONVT(line, {lineMetrics: true, buffer: 0});
393
+
394
+ const feature = index.getTile(1, 1, 0).features[0];
395
+ expect(feature.geometry).toEqual([[[0, 3631], [2048, 3631]]]);
396
+ expect(feature.tags).toEqual({geojsonvt_clip_start: 0.5, geojsonvt_clip_end: 1});
397
+ });
398
+
399
+ test('clusters each position of a MultiPoint feature when clustering is on', () => {
400
+ const multiPoint = {
401
+ type: 'Feature' as const,
402
+ id: 7,
403
+ properties: {name: 'multi'},
404
+ geometry: {type: 'MultiPoint' as const, coordinates: [[20, 20], [20.0001, 20], [60, 20]]}
405
+ };
406
+
407
+ const index = new GeoJSONVT(multiPoint, {cluster: true});
408
+
409
+ expect(index.getTile(0, 0, 0).features).toEqual([
410
+ {type: 1, geometry: [[284, 227]], tags: {cluster: true, cluster_id: 20, point_count: 2, point_count_abbreviated: 2}, id: 20},
411
+ {type: 1, geometry: [[341, 227]], tags: {name: 'multi'}, id: 7}
412
+ ]);
413
+ });
414
+
415
+ test('a unit square without wrap leaves the positions of a MultiPoint outside the world out of the clusters', () => {
416
+ const multiPoint = {
417
+ type: 'Feature' as const,
418
+ properties: {},
419
+ geometry: {type: 'MultiPoint' as const, coordinates: [[0.99, 0.5], [1.03, 0.5], [0.5, -0.03]]}
420
+ };
421
+
422
+ const index = new GeoJSONVT(multiPoint, {coordinates: 'unitSquare', wrap: false, cluster: true});
423
+
424
+ expect(index.getTile(0, 0, 0).features.map(feature => feature.geometry)).toEqual([[[507, 256]]]);
425
+ });
package/src/geojsonvt.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import {convertToInternal} from './convert';
2
2
  import {convertToGeoJSON, featureToGeoJSON} from './deconvert';
3
+ import {getWorldCoordinateHelper, type WorldCoordinateHelper} from './world-coordinate-helper';
3
4
  import {wrap} from './wrap';
4
5
  import {applySourceDiff, type GeoJSONVTSourceDiff} from './difference';
5
6
  import {ClusterTileIndex, defaultClusterOptions} from './cluster-tile-index';
6
7
  import {TileIndex} from './tile-index';
7
- import type {ClusterOrPointFeature, GeoJSONVTTileIndex, GeoJSONVTInternalFeature, GeoJSONVTOptions, GeoJSONVTTile, SuperclusterOptions} from './definitions';
8
+ import type {ClusterOrPointFeature, GeoJSONVTTileIndex, GeoJSONVTInternalFeature, GeoJSONVTOptions, GeoJSONVTTile} from './definitions';
8
9
 
9
10
  export const defaultOptions: GeoJSONVTOptions = {
10
11
  maxZoom: 14,
@@ -19,7 +20,9 @@ export const defaultOptions: GeoJSONVTOptions = {
19
20
  updateable: false,
20
21
  cluster: false,
21
22
  clusterOptions: defaultClusterOptions,
22
- debug: 0
23
+ debug: 0,
24
+ coordinates: 'lngLat',
25
+ wrap: true
23
26
  };
24
27
 
25
28
  /**
@@ -27,12 +30,15 @@ export const defaultOptions: GeoJSONVTOptions = {
27
30
  */
28
31
  export class GeoJSONVT {
29
32
  private options: GeoJSONVTOptions;
33
+ private worldCoordinateHelper: WorldCoordinateHelper;
30
34
 
31
35
  private source?: GeoJSONVTInternalFeature[];
36
+ private unlocated?: Map<string | number, GeoJSON.Feature>;
32
37
  private tileIndex: GeoJSONVTTileIndex;
33
38
 
34
39
  constructor(data: GeoJSON.GeoJSON, options?: GeoJSONVTOptions) {
35
40
  options = this.options = Object.assign({}, defaultOptions, options);
41
+ this.worldCoordinateHelper = getWorldCoordinateHelper(options.coordinates);
36
42
 
37
43
  const debug = options.debug;
38
44
 
@@ -41,8 +47,11 @@ export class GeoJSONVT {
41
47
  if (options.maxZoom < 0 || options.maxZoom > 24) throw new Error('maxZoom should be in the 0-24 range');
42
48
  if (options.promoteId && options.generateId) throw new Error('promoteId and generateId cannot be used together.');
43
49
 
50
+ // for updateable indexes, keep the properties of the features without coordinates, so that an update can give them some
51
+ if (options.updateable) this.unlocated = new Map();
52
+
44
53
  // projects and adds simplification info
45
- let features = convertToInternal(data, options);
54
+ let features = convertToInternal(data, options, this.unlocated);
46
55
 
47
56
  if (debug) {
48
57
  console.timeEnd('preprocess data');
@@ -62,7 +71,7 @@ export class GeoJSONVT {
62
71
  }
63
72
 
64
73
  private initializeIndex(features: GeoJSONVTInternalFeature[], options: GeoJSONVTOptions) {
65
- this.tileIndex = options.cluster ? new ClusterTileIndex(options.clusterOptions) : new TileIndex(options);
74
+ this.tileIndex = options.cluster ? new ClusterTileIndex({...options.clusterOptions, coordinates: options.coordinates, wrap: options.wrap}) : new TileIndex(options);
66
75
  if (!features.length) return;
67
76
  this.tileIndex.initialize(features);
68
77
  }
@@ -94,7 +103,7 @@ export class GeoJSONVT {
94
103
  if (!options.updateable) throw new Error('to update tile geojson `updateable` option must be set to true');
95
104
 
96
105
  // apply diff and collect affected features and updated source that will be used to invalidate tiles
97
- let {affected, source} = applySourceDiff(this.source, diff, options);
106
+ let {affected, source} = applySourceDiff(this.source, diff, options, this.unlocated);
98
107
 
99
108
  if (filter) {
100
109
  ({affected, source} = this.filterUpdate(source, affected, filter));
@@ -110,19 +119,25 @@ export class GeoJSONVT {
110
119
  }
111
120
 
112
121
  /**
113
- * Filter an update using a predicate function. Returns the affected and updated source features.
122
+ * Filter an update using a predicate function, the features an update emptied included, so that one the filter
123
+ * rejects can't come back with a later update. A feature without a geometry at all isn't passed to the predicate.
124
+ * Returns the affected and updated source features.
114
125
  */
115
126
  private filterUpdate(source: GeoJSONVTInternalFeature[], affected: GeoJSONVTInternalFeature[], predicate: (feature: GeoJSON.Feature) => boolean) {
116
127
  const removeIds = new Set();
117
128
 
118
129
  for (const feature of source) {
119
130
  if (feature.id == undefined) continue;
120
- if (predicate(featureToGeoJSON(feature))) continue;
131
+ if (predicate(featureToGeoJSON(feature, this.worldCoordinateHelper))) continue;
121
132
  affected.push(feature);
122
133
  removeIds.add(feature.id);
123
134
  }
124
135
  source = source.filter(feature => !removeIds.has(feature.id));
125
136
 
137
+ for (const [id, feature] of this.unlocated ?? []) {
138
+ if (feature.geometry && !predicate(feature)) this.unlocated.delete(id);
139
+ }
140
+
126
141
  return {affected, source};
127
142
  }
128
143
 
@@ -131,15 +146,15 @@ export class GeoJSONVT {
131
146
  */
132
147
  getData(): GeoJSON.GeoJSON {
133
148
  if (!this.options.updateable) throw new Error('to retrieve data the `updateable` option must be set to true');
134
- return convertToGeoJSON(this.source);
149
+ return convertToGeoJSON(this.source, this.worldCoordinateHelper);
135
150
  }
136
151
 
137
152
  /**
138
153
  * Update supercluster options and regenerate the index.
139
154
  * @param cluster - whether to enable clustering
140
- * @param clusterOptions - {@link SuperclusterOptions}
155
+ * @param clusterOptions - {@link SuperclusterOptions} without `coordinates` and `wrap`, which come from the index options
141
156
  */
142
- updateClusterOptions(cluster: boolean, clusterOptions: SuperclusterOptions) {
157
+ updateClusterOptions(cluster: boolean, clusterOptions: GeoJSONVTOptions['clusterOptions']) {
143
158
  const wasCluster = this.options.cluster;
144
159
  this.options.cluster = cluster;
145
160
  this.options.clusterOptions = clusterOptions;
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
 
2
2
  import type {GeoJSONVTFeatureDiff, GeoJSONVTSourceDiff} from './difference';
3
- import type {GeoJSONVTInternalFeature, GeoJSONVTInternalLineStringFeature, GeoJSONVTInternalMultiLineStringFeature, GeoJSONVTInternalMultiPointFeature, GeoJSONVTInternalMultiPolygonFeature, GeoJSONVTInternalPointFeature, GeoJSONVTInternalPolygonFeature, GeoJSONVTOptions, GeoJSONToTileOptions, PartialGeoJSONVTFeature, SliceArray, SliceFixedArray, ClusterProperties, ClusterFeature, ClusterOrPointFeature, GeoJSONVTTile, GeoJSONVTFeature, GeoJSONVTFeaturePoint, GeoJSONVTFeatureNonPoint, GeoJSONVTInternalTileFeaturePoint, GeoJSONVTInternalTileFeatureNonPoint, GeoJSONVTInternalTile, GeoJSONVTInternalTileFeature, GeoJSONVTTileIndex, SuperclusterOptions} from './definitions';
3
+ import type {GeoJSONVTInternalFeature, GeoJSONVTInternalLineStringFeature, GeoJSONVTInternalMultiLineStringFeature, GeoJSONVTInternalMultiPointFeature, GeoJSONVTInternalMultiPolygonFeature, GeoJSONVTInternalPointFeature, GeoJSONVTInternalPolygonFeature, GeoJSONVTOptions, GeoJSONToTileOptions, PartialGeoJSONVTFeature, SliceArray, SliceFixedArray, ClusterProperties, ClusterFeature, ClusterOrPointFeature, GeoJSONVTTile, GeoJSONVTFeature, GeoJSONVTFeaturePoint, GeoJSONVTFeatureNonPoint, GeoJSONVTInternalTileFeaturePoint, GeoJSONVTInternalTileFeatureNonPoint, GeoJSONVTInternalTile, GeoJSONVTInternalTileFeature, GeoJSONVTTileIndex, SuperclusterOptions, GeoJSONVTCoordinatesType} from './definitions';
4
4
  import type {KDBushWithData} from './cluster-tile-index';
5
5
  import {GeoJSONVT} from './geojsonvt';
6
6
  import {ClusterTileIndex} from './cluster-tile-index';
@@ -39,6 +39,7 @@ export type {
39
39
  GeoJSONVTInternalPolygonFeature,
40
40
  GeoJSONVTInternalMultiPolygonFeature,
41
41
  SuperclusterOptions,
42
+ GeoJSONVTCoordinatesType,
42
43
  ClusterProperties,
43
44
  ClusterFeature,
44
45
  ClusterOrPointFeature,
package/src/tile-index.ts CHANGED
@@ -23,7 +23,7 @@ export class TileIndex implements GeoJSONVTTileIndex {
23
23
 
24
24
  initialize(features: GeoJSONVTInternalFeature[]): void {
25
25
  // start slicing from the top tile down
26
- this.splitTile(features, 0, 0, 0);
26
+ this.splitTile(this.clipToWorld(features), 0, 0, 0);
27
27
 
28
28
  if (this.options.debug) {
29
29
  if (features.length) console.log('features: %d, points: %d', this.tiles[0].numFeatures, this.tiles[0].numPoints);
@@ -45,8 +45,9 @@ export class TileIndex implements GeoJSONVTTileIndex {
45
45
 
46
46
  // re-generate root tile with updated feature set
47
47
  const [z, x, y] = [0, 0, 0];
48
- const rootTile = createTile(source, z, x, y, options);
49
- rootTile.source = source;
48
+ const rootSource = this.clipToWorld(source);
49
+ const rootTile = createTile(rootSource, z, x, y, options);
50
+ rootTile.source = rootSource;
50
51
 
51
52
  // update tile index with new root tile - ready for getTile calls
52
53
  const id = toID(z, x, y);
@@ -83,6 +84,7 @@ export class TileIndex implements GeoJSONVTTileIndex {
83
84
  const {extent, debug} = this.options;
84
85
 
85
86
  const z2 = 1 << z;
87
+ if (!this.options.wrap && (x < 0 || x >= z2)) return null; // a row outside the world has no tile either
86
88
  x = (x + z2) & (z2 - 1); // wrap tile x coordinate
87
89
 
88
90
  const id = toID(z, x, y);
@@ -119,6 +121,20 @@ export class TileIndex implements GeoJSONVTTileIndex {
119
121
  return transformTile(this.tiles[id], extent);
120
122
  }
121
123
 
124
+ /**
125
+ * The features the top tile starts from. A world that wraps keeps them all left and right, where wrapping already
126
+ * put them inside, and one that doesn't only what lies inside the world square and its buffer. Positions in the
127
+ * unit square are also cut above and below in a world that wraps, where lng/lat is clamped already. The index keeps
128
+ * the rest, so an update can still move a feature in from outside.
129
+ */
130
+ private clipToWorld(features: GeoJSONVTInternalFeature[]): GeoJSONVTInternalFeature[] {
131
+ const {wrap, coordinates} = this.options;
132
+ if (wrap && coordinates !== 'unitSquare') return features;
133
+ const buffer = this.options.buffer / this.options.extent;
134
+ const inX = wrap ? features : clip(features, 1, -buffer, 1 + buffer, AxisType.X, -Infinity, Infinity, this.options) || [];
135
+ return clip(inX, 1, -buffer, 1 + buffer, AxisType.Y, -Infinity, Infinity, this.options) || [];
136
+ }
137
+
122
138
  /**
123
139
  * splits features from a parent tile to sub-tiles.
124
140
  * z, x, and y are the coordinates of the parent tile
@@ -0,0 +1,63 @@
1
+ import type {GeoJSONVTCoordinatesType} from './definitions';
2
+
3
+ /**
4
+ * How the positions of the input relate to the world square the tiles cover, in which x and y run from 0 to 1.
5
+ */
6
+ export interface WorldCoordinateHelper {
7
+ projectX(x: number): number;
8
+ projectY(y: number): number;
9
+ unprojectX(x: number): number;
10
+ unprojectY(y: number): number;
11
+ }
12
+
13
+ /**
14
+ * Longitude and latitude, which spherical mercator puts in the world square.
15
+ */
16
+ class MercatorWorldCoordinateHelper implements WorldCoordinateHelper {
17
+ projectX(lng: number): number {
18
+ return lng / 360 + 0.5;
19
+ }
20
+
21
+ projectY(lat: number): number {
22
+ const sin = Math.sin(lat * Math.PI / 180);
23
+ const y = 0.5 - 0.25 * Math.log((1 + sin) / (1 - sin)) / Math.PI;
24
+ return y < 0 ? 0 : y > 1 ? 1 : y;
25
+ }
26
+
27
+ unprojectX(x: number): number {
28
+ return (x - 0.5) * 360;
29
+ }
30
+
31
+ unprojectY(y: number): number {
32
+ const y2 = (180 - y * 360) * Math.PI / 180;
33
+ return 360 * Math.atan(Math.exp(y2)) / Math.PI - 90;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Positions in the unit square already, see `coordinates` in {@link GeoJSONVTOptions}.
39
+ */
40
+ class IdentityWorldCoordinateHelper implements WorldCoordinateHelper {
41
+ projectX(x: number): number {
42
+ return x;
43
+ }
44
+
45
+ projectY(y: number): number {
46
+ return y;
47
+ }
48
+
49
+ unprojectX(x: number): number {
50
+ return x;
51
+ }
52
+
53
+ unprojectY(y: number): number {
54
+ return y;
55
+ }
56
+ }
57
+
58
+ export const mercatorWorldCoordinateHelper: WorldCoordinateHelper = new MercatorWorldCoordinateHelper();
59
+ const identityWorldCoordinateHelper: WorldCoordinateHelper = new IdentityWorldCoordinateHelper();
60
+
61
+ export function getWorldCoordinateHelper(coordinates?: GeoJSONVTCoordinatesType): WorldCoordinateHelper {
62
+ return coordinates === 'unitSquare' ? identityWorldCoordinateHelper : mercatorWorldCoordinateHelper;
63
+ }
package/src/wrap.ts CHANGED
@@ -4,6 +4,8 @@ import type { GeoJSONVTInternalFeature, GeoJSONVTOptions, SliceArray, SliceFixed
4
4
  import {createFeature, optimizeLineMemory} from './feature';
5
5
 
6
6
  export function wrap(features: GeoJSONVTInternalFeature[], options: GeoJSONVTOptions): GeoJSONVTInternalFeature[] {
7
+ if (!options.wrap) return features;
8
+
7
9
  const buffer = options.buffer / options.extent;
8
10
  let merged = features;
9
11