@maplibre/geojson-vt 6.2.0 → 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.
package/src/deconvert.ts CHANGED
@@ -1,12 +1,13 @@
1
+ import {mercatorWorldCoordinateHelper, type WorldCoordinateHelper} from './world-coordinate-helper';
1
2
  import type {GeoJSONVTInternalFeature} from './definitions';
2
3
 
3
4
  /**
4
5
  * Converts internal source features back to GeoJSON format.
5
6
  */
6
- export function convertToGeoJSON(source: GeoJSONVTInternalFeature[], worldCoordinates = false): GeoJSON.GeoJSON {
7
+ export function convertToGeoJSON(source: GeoJSONVTInternalFeature[], worldCoordinateHelper: WorldCoordinateHelper = mercatorWorldCoordinateHelper): GeoJSON.GeoJSON {
7
8
  const geojson: GeoJSON.GeoJSON = {
8
9
  type: 'FeatureCollection',
9
- features: source.map(feature => featureToGeoJSON(feature, worldCoordinates))
10
+ features: source.map(feature => featureToGeoJSON(feature, worldCoordinateHelper))
10
11
  };
11
12
 
12
13
  return geojson;
@@ -15,10 +16,10 @@ export function convertToGeoJSON(source: GeoJSONVTInternalFeature[], worldCoordi
15
16
  /**
16
17
  * Converts a single internal feature to GeoJSON format.
17
18
  */
18
- export function featureToGeoJSON(feature: GeoJSONVTInternalFeature, worldCoordinates = false): GeoJSON.Feature {
19
+ export function featureToGeoJSON(feature: GeoJSONVTInternalFeature, worldCoordinateHelper: WorldCoordinateHelper = mercatorWorldCoordinateHelper): GeoJSON.Feature {
19
20
  const geojsonFeature: GeoJSON.Feature = {
20
21
  type: 'Feature',
21
- geometry: geometryToGeoJSON(feature, worldCoordinates),
22
+ geometry: geometryToGeoJSON(feature, worldCoordinateHelper),
22
23
  properties: feature.tags
23
24
  };
24
25
  if (feature.id != null) {
@@ -31,68 +32,51 @@ export function featureToGeoJSON(feature: GeoJSONVTInternalFeature, worldCoordin
31
32
  /**
32
33
  * Converts a single internal feature geometry to GeoJSON format.
33
34
  */
34
- function geometryToGeoJSON(feature: GeoJSONVTInternalFeature, worldCoordinates: boolean): GeoJSON.Geometry {
35
+ function geometryToGeoJSON(feature: GeoJSONVTInternalFeature, worldCoordinateHelper: WorldCoordinateHelper): GeoJSON.Geometry {
35
36
  const {type, geometry} = feature;
36
37
 
37
38
  switch (type) {
38
39
  case 'Point':
39
40
  return {
40
41
  type: type,
41
- coordinates: unprojectPoint(geometry[0], geometry[1], worldCoordinates)
42
+ coordinates: unprojectPoint(geometry[0], geometry[1], worldCoordinateHelper)
42
43
  };
43
44
  case 'MultiPoint':
44
45
  return {
45
46
  type: type,
46
- coordinates: unprojectPoints(geometry, worldCoordinates)
47
+ coordinates: unprojectPoints(geometry, worldCoordinateHelper)
47
48
  };
48
49
  case 'LineString':
49
50
  return {
50
51
  type: type,
51
- coordinates: unprojectPoints(geometry.points, worldCoordinates)
52
+ coordinates: unprojectPoints(geometry.points, worldCoordinateHelper)
52
53
  };
53
54
  case 'MultiLineString':
54
55
  case 'Polygon':
55
56
  return {
56
57
  type: type,
57
- coordinates: geometry.map(ring => unprojectPoints(ring.points, worldCoordinates))
58
+ coordinates: geometry.map(ring => unprojectPoints(ring.points, worldCoordinateHelper))
58
59
  };
59
60
  case 'MultiPolygon':
60
61
  return {
61
62
  type: type,
62
63
  coordinates: geometry.map(polygon =>
63
- polygon.map(ring => unprojectPoints(ring.points, worldCoordinates))
64
+ polygon.map(ring => unprojectPoints(ring.points, worldCoordinateHelper))
64
65
  )
65
66
  };
66
67
  }
67
68
  }
68
69
 
69
- export function unprojectPoints(coords: number[] | Float64Array, worldCoordinates = false): GeoJSON.Position[] {
70
+ export function unprojectPoints(coords: number[] | Float64Array, worldCoordinateHelper: WorldCoordinateHelper = mercatorWorldCoordinateHelper): GeoJSON.Position[] {
70
71
  const result: GeoJSON.Position[] = [];
71
72
 
72
73
  for (let i = 0; i < coords.length; i += 3) {
73
- result.push(unprojectPoint(coords[i], coords[i + 1], worldCoordinates));
74
+ result.push(unprojectPoint(coords[i], coords[i + 1], worldCoordinateHelper));
74
75
  }
75
76
 
76
77
  return result;
77
78
  }
78
79
 
79
- function unprojectPoint(x: number, y: number, worldCoordinates: boolean): GeoJSON.Position {
80
- return [unprojectX(x, worldCoordinates), unprojectY(y, worldCoordinates)];
81
- }
82
-
83
- /**
84
- * Convert spherical mercator in [0..1] range to longitude, or keep a world coordinate as it is
85
- */
86
- export function unprojectX(x: number, worldCoordinates = false): number {
87
- if (worldCoordinates) return x;
88
- return (x - 0.5) * 360;
89
- }
90
-
91
- /**
92
- * Convert spherical mercator in [0..1] range to latitude, or keep a world coordinate as it is
93
- */
94
- export function unprojectY(y: number, worldCoordinates = false): number {
95
- if (worldCoordinates) return y;
96
- const y2 = (180 - y * 360) * Math.PI / 180;
97
- return 360 * Math.atan(Math.exp(y2)) / Math.PI - 90;
80
+ function unprojectPoint(x: number, y: number, worldCoordinateHelper: WorldCoordinateHelper): GeoJSON.Position {
81
+ return [worldCoordinateHelper.unprojectX(x), worldCoordinateHelper.unprojectY(y)];
98
82
  }
@@ -1,3 +1,9 @@
1
+ /**
2
+ * The space positions are given in, see `coordinates` in {@link GeoJSONVTOptions}.
3
+ * @experimental
4
+ */
5
+ export type GeoJSONVTCoordinatesType = 'lngLat' | 'unitSquare';
6
+
1
7
  export type GeoJSONVTOptions = {
2
8
  /**
3
9
  * Max zoom to preserve detail on
@@ -54,15 +60,23 @@ export type GeoJSONVTOptions = {
54
60
  */
55
61
  debug?: number;
56
62
  /**
57
- * Whether positions are world coordinates instead of lng/lat: x and y from 0 to 1 across the world square, y growing
58
- * downward, where web mercator would put a lng/lat. The world is then a square that ends at its edges, for data in a
59
- * world of its own such as a map projection's: positions are neither projected nor wrapped around or clamped,
60
- * features are cut at the tile buffer around the square, points outside it are left out of the clusters, `getTile`
61
- * returns `null` outside it, and `getData` and the cluster methods return world coordinates too.
63
+ * The space the positions are in. `'lngLat'` is longitude and latitude, which web mercator puts in the square from 0
64
+ * to 1 that tile 0/0/0 covers, with latitudes beyond ~85.05 degrees clamped onto its top and bottom. `'unitSquare'`
65
+ * is x and y in that square already, y growing downward, taken as they are, for data in another projection.
66
+ * `getData` and the cluster methods return positions in the same space.
62
67
  * @experimental
63
- * @default false
68
+ * @default 'lngLat'
64
69
  */
65
- worldCoordinates?: boolean;
70
+ coordinates?: GeoJSONVTCoordinatesType;
71
+ /**
72
+ * Whether the world wraps around the antimeridian. With `true`, features near the left or right edge are copied
73
+ * across it, and the tiles at either edge include what lies past it. With `false`, the world is the square and ends
74
+ * at its edges. Features are cut at the tile buffer around it, points outside it are left out of the clusters, and
75
+ * `getTile` returns `null` outside it. `geoJSONToTile` doesn't wrap unless this is `true`.
76
+ * @experimental
77
+ * @default true
78
+ */
79
+ wrap?: boolean;
66
80
  /**
67
81
  * Enable Supercluster for point features. Each position of a MultiPoint feature is clustered as a point of its own.
68
82
  * Other geometry types are left out.
@@ -70,15 +84,15 @@ export type GeoJSONVTOptions = {
70
84
  */
71
85
  cluster?: boolean;
72
86
  /**
73
- * Options for the Supercluster point clustering algorithm.
87
+ * Options for the Supercluster point clustering algorithm. `coordinates` and `wrap` come from the options above.
74
88
  * @see {@link SuperclusterOptions}
75
89
  */
76
- clusterOptions?: SuperclusterOptions;
90
+ clusterOptions?: Omit<SuperclusterOptions, 'coordinates' | 'wrap'>;
77
91
  };
78
92
 
79
93
  export type GeoJSONToTileOptions = GeoJSONVTOptions & {
80
94
  /**
81
- * Whether to wrap features around the antimeridian. Has no effect with `worldCoordinates`.
95
+ * Whether to wrap features around the antimeridian, see `wrap` in {@link GeoJSONVTOptions}.
82
96
  * @default false
83
97
  */
84
98
  wrap?: boolean;
@@ -275,4 +289,19 @@ export type SuperclusterOptions = {
275
289
  * @default props => props
276
290
  */
277
291
  map?: (props: GeoJSON.GeoJsonProperties) => Record<string, unknown>;
292
+ /**
293
+ * The space the points are in, see `coordinates` in {@link GeoJSONVTOptions}. The bounding box of `getClusters` is
294
+ * in the same space.
295
+ * @experimental
296
+ * @default 'lngLat'
297
+ */
298
+ coordinates?: GeoJSONVTCoordinatesType;
299
+ /**
300
+ * Whether the world wraps around the antimeridian, see `wrap` in {@link GeoJSONVTOptions}. With `false`, points
301
+ * outside the square are left out of the clusters, the tiles at an edge get no points from the opposite one, and
302
+ * `getTile` returns `null` outside the square.
303
+ * @experimental
304
+ * @default true
305
+ */
306
+ wrap?: boolean;
278
307
  };
package/src/difference.ts CHANGED
@@ -70,9 +70,10 @@ type HashedGeoJSONVTSourceDiff = {
70
70
  * @param source
71
71
  * @param dataDiff
72
72
  * @param options
73
+ * @param unlocated - the features without coordinates, by id, so that an update can give them some
73
74
  * @returns
74
75
  */
75
- export function applySourceDiff(source: GeoJSONVTInternalFeature[], dataDiff: GeoJSONVTSourceDiff, options: GeoJSONVTOptions): ApplySourceDiffResult {
76
+ export function applySourceDiff(source: GeoJSONVTInternalFeature[], dataDiff: GeoJSONVTSourceDiff, options: GeoJSONVTOptions, unlocated?: Map<string | number, GeoJSON.Feature>): ApplySourceDiffResult {
76
77
  // convert diff to sets/maps for o(1) lookups
77
78
  const diff = diffToHashed(dataDiff, options);
78
79
 
@@ -82,9 +83,12 @@ export function applySourceDiff(source: GeoJSONVTInternalFeature[], dataDiff: Ge
82
83
  if (diff.removeAll) {
83
84
  affected = source;
84
85
  source = [];
86
+ unlocated?.clear();
85
87
  }
86
88
 
87
89
  if (diff.remove.size || diff.add.size) {
90
+ for (const id of diff.remove) unlocated?.delete(id);
91
+
88
92
  const removeFeatures = [];
89
93
 
90
94
  // Collect features to remove (explicit removals + replacements via add)
@@ -101,7 +105,7 @@ export function applySourceDiff(source: GeoJSONVTInternalFeature[], dataDiff: Ge
101
105
  }
102
106
 
103
107
  if (diff.add.size) {
104
- let addFeatures = convertToInternal({type: 'FeatureCollection', features: Array.from(diff.add.values())}, options);
108
+ let addFeatures = convertToInternal({type: 'FeatureCollection', features: Array.from(diff.add.values())}, options, unlocated);
105
109
  addFeatures = wrap(addFeatures, options);
106
110
  affected = affected.concat(addFeatures);
107
111
  source = source.concat(addFeatures);
@@ -121,8 +125,14 @@ export function applySourceDiff(source: GeoJSONVTInternalFeature[], dataDiff: Ge
121
125
  }
122
126
  for (const [id, update] of diff.update) {
123
127
  const oldFeatures = oldFeaturesMap.get(id);
124
- if (!oldFeatures || oldFeatures.length === 0) continue;
125
- const updatedFeatures = getUpdatedFeatures(oldFeatures, update, options);
128
+ if (!oldFeatures || oldFeatures.length === 0) {
129
+ if (!unlocated?.has(id)) continue;
130
+ const locatedFeatures = getUpdatedUnlocatedFeatures(id, update, options, unlocated);
131
+ affected = affected.concat(locatedFeatures);
132
+ keepFeatures = keepFeatures.concat(locatedFeatures);
133
+ continue;
134
+ }
135
+ const updatedFeatures = getUpdatedFeatures(oldFeatures, update, options, unlocated);
126
136
 
127
137
  affected = affected.concat(oldFeatures, updatedFeatures);
128
138
  keepFeatures = keepFeatures.concat(updatedFeatures);
@@ -138,28 +148,18 @@ export function applySourceDiff(source: GeoJSONVTInternalFeature[], dataDiff: Ge
138
148
  * @param vtFeatures - the original features
139
149
  * @param update - the update object to apply
140
150
  * @param options - the options to use for the wrap method
151
+ * @param unlocated - the features without coordinates, by id, which a new geometry without coordinates adds the feature to
141
152
  * @returns Updated features. If geometry is updated, returns new feature(s) converted from geojson and wrapped, with the id the feature already has. If only properties are updated, returns feature(s) with tags updated.
142
153
  */
143
- function getUpdatedFeatures(vtFeatures: GeoJSONVTInternalFeature[], update: GeoJSONVTFeatureDiff, options: GeoJSONVTOptions): GeoJSONVTInternalFeature[] {
154
+ function getUpdatedFeatures(vtFeatures: GeoJSONVTInternalFeature[], update: GeoJSONVTFeatureDiff, options: GeoJSONVTOptions, unlocated?: Map<string | number, GeoJSON.Feature>): GeoJSONVTInternalFeature[] {
144
155
  const changeGeometry = !!update.newGeometry;
145
- const changeProps =
146
- update.removeAllProperties ||
147
- update.removeProperties?.length > 0 ||
148
- update.addOrUpdateProperties?.length > 0;
156
+ const changeProps = hasPropertyChanges(update);
149
157
 
150
158
  // if geometry changed, need to create a new geojson feature and convert to internal format
151
159
  if (changeGeometry) {
152
160
  const vtFeature = vtFeatures[0];
153
- const geojsonFeature = {
154
- type: 'Feature' as const,
155
- id: vtFeature.id,
156
- geometry: update.newGeometry,
157
- properties: changeProps ? applyPropertyUpdates(vtFeature.tags, update) : vtFeature.tags
158
- };
159
-
160
- let features = convertToInternal({type: 'FeatureCollection', features: [geojsonFeature]}, {...options, promoteId: undefined, generateId: false});
161
- features = wrap(features, options);
162
- return features;
161
+ const properties = changeProps ? applyPropertyUpdates(vtFeature.tags, update) : vtFeature.tags;
162
+ return convertNewGeometry(vtFeature.id, update.newGeometry, properties, options, unlocated);
163
163
  }
164
164
 
165
165
  if (changeProps) {
@@ -175,6 +175,35 @@ function getUpdatedFeatures(vtFeatures: GeoJSONVTInternalFeature[], update: GeoJ
175
175
  return vtFeatures;
176
176
  }
177
177
 
178
+ /**
179
+ * Gets the features an update gives a feature without coordinates: none, unless its new geometry has some.
180
+ * Its property changes apply either way.
181
+ */
182
+ function getUpdatedUnlocatedFeatures(id: string | number, update: GeoJSONVTFeatureDiff, options: GeoJSONVTOptions, unlocated: Map<string | number, GeoJSON.Feature>): GeoJSONVTInternalFeature[] {
183
+ const feature = unlocated.get(id);
184
+ const properties = hasPropertyChanges(update) ? applyPropertyUpdates(feature.properties, update) : feature.properties;
185
+ if (!update.newGeometry) {
186
+ unlocated.set(id, {...feature, properties});
187
+ return [];
188
+ }
189
+ return convertNewGeometry(id, update.newGeometry, properties, options, unlocated);
190
+ }
191
+
192
+ /**
193
+ * Converts the new geometry of a feature to internal format, keeping the id the feature already has
194
+ */
195
+ function convertNewGeometry(id: GeoJSONVTInternalFeature['id'], geometry: GeoJSON.Geometry, properties: GeoJSON.GeoJsonProperties, options: GeoJSONVTOptions, unlocated?: Map<string | number, GeoJSON.Feature>): GeoJSONVTInternalFeature[] {
196
+ const geojsonFeature = {type: 'Feature' as const, id, geometry, properties};
197
+ const features = convertToInternal({type: 'FeatureCollection', features: [geojsonFeature]}, {...options, promoteId: undefined, generateId: false}, unlocated);
198
+ return wrap(features, options);
199
+ }
200
+
201
+ function hasPropertyChanges(update: GeoJSONVTFeatureDiff): boolean {
202
+ return update.removeAllProperties ||
203
+ update.removeProperties?.length > 0 ||
204
+ update.addOrUpdateProperties?.length > 0;
205
+ }
206
+
178
207
  /**
179
208
  * helper to apply property updates from a diff update object to a properties object
180
209
  */
@@ -40,8 +40,8 @@ import type {GeoJSONToTileOptions, GeoJSONVTTile} from './definitions';
40
40
  */
41
41
 
42
42
  export function geoJSONToTile(data: GeoJSON.GeoJSON, z: number, x: number, y: number, options: GeoJSONToTileOptions = {}): GeoJSONVTTile {
43
- options = {...defaultOptions, ...options};
44
- const {wrap: shouldWrap = false, clip: shouldClip = false} = options;
43
+ options = {...defaultOptions, wrap: false, ...options};
44
+ const {wrap: shouldWrap, clip: shouldClip = false} = options;
45
45
 
46
46
  let features = convertToInternal(data, options);
47
47
  if (shouldWrap) {
@@ -38,25 +38,25 @@ test('publicly exposed cluster methods: return undefined when clustering is disa
38
38
  expect(index.getClusterLeaves(123, 10, 0)).toBeNull();
39
39
  });
40
40
 
41
- test('worldCoordinates cuts a line at the buffer around the world instead of wrapping or clamping it', () => {
41
+ test('a unit square without wrap cuts a line at the buffer around the world instead of wrapping or clamping it', () => {
42
42
  const fromThreeWorldsNorthToEast = {type: 'LineString' as const, coordinates: [[0.25, -3], [0.75, 0.5], [1.25, 0.5]]};
43
43
 
44
- const index = new GeoJSONVT(fromThreeWorldsNorthToEast, {worldCoordinates: true});
44
+ const index = new GeoJSONVT(fromThreeWorldsNorthToEast, {coordinates: 'unitSquare', wrap: false});
45
45
 
46
46
  expect(index.getTile(0, 0, 0).features.map(feature => feature.geometry)).toEqual([[[[2770, -64], [3072, 2048], [4160, 2048]]]]);
47
47
  });
48
48
 
49
- test('worldCoordinates cuts the top tile at the world again on update, so a feature can move in from outside', () => {
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
50
  const movingIn = {type: 'Feature' as const, id: 'moving in', geometry: {type: 'Point' as const, coordinates: [1.25, 0.5]}, properties: {}};
51
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]}, {worldCoordinates: true, updateable: true});
52
+ const index = new GeoJSONVT({type: 'FeatureCollection', features: [movingIn, stayingOut]}, {coordinates: 'unitSquare', wrap: false, updateable: true});
53
53
 
54
54
  index.updateData({update: [{id: 'moving in', newGeometry: {type: 'Point', coordinates: [0.5, 0.5]}}]});
55
55
 
56
56
  expect(index.getTile(0, 0, 0).features.map(feature => feature.id)).toEqual(['moving in']);
57
57
  });
58
58
 
59
- test('worldCoordinates returns the data in world coordinates', () => {
59
+ test('a unit square without wrap returns the data in the unit square', () => {
60
60
  const features: GeoJSON.Feature[] = [
61
61
  {type: 'Feature', id: 'point', geometry: {type: 'Point', coordinates: [0.25, 0.75]}, properties: {}},
62
62
  {type: 'Feature', id: 'multi point', geometry: {type: 'MultiPoint', coordinates: [[0.25, 0.75], [0.5, 0.5]]}, properties: {}},
@@ -65,14 +65,14 @@ test('worldCoordinates returns the data in world coordinates', () => {
65
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
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
67
  ];
68
- const index = new GeoJSONVT({type: 'FeatureCollection', features}, {worldCoordinates: true, updateable: true});
68
+ const index = new GeoJSONVT({type: 'FeatureCollection', features}, {coordinates: 'unitSquare', wrap: false, updateable: true});
69
69
 
70
70
  expect(index.getData()).toEqual({type: 'FeatureCollection', features});
71
71
  });
72
72
 
73
- test('worldCoordinates keeps a MultiLineString it splits for lineMetrics in world coordinates', () => {
73
+ test('a unit square without wrap keeps a MultiLineString it splits for lineMetrics in the unit square', () => {
74
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, {worldCoordinates: true, lineMetrics: true, updateable: true});
75
+ const index = new GeoJSONVT(multiLine, {coordinates: 'unitSquare', wrap: false, lineMetrics: true, updateable: true});
76
76
 
77
77
  expect(index.getData()).toEqual({type: 'FeatureCollection', features: [
78
78
  {type: 'Feature', geometry: {type: 'LineString', coordinates: [[0.25, 0.75], [0.5, 0.5]]}},
@@ -80,9 +80,9 @@ test('worldCoordinates keeps a MultiLineString it splits for lineMetrics in worl
80
80
  ]});
81
81
  });
82
82
 
83
- test('worldCoordinates hands the update filter features in world coordinates', () => {
83
+ test('a unit square without wrap hands the update filter features in the unit square', () => {
84
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]}, {worldCoordinates: true, updateable: true});
85
+ const index = new GeoJSONVT({type: 'FeatureCollection', features: [point]}, {coordinates: 'unitSquare', wrap: false, updateable: true});
86
86
  const filtered: GeoJSON.Geometry[] = [];
87
87
 
88
88
  index.updateData({add: [{...point, id: 'q'}]}, feature => {
@@ -93,7 +93,7 @@ test('worldCoordinates hands the update filter features in world coordinates', (
93
93
  expect(filtered).toContainEqual({type: 'Point', coordinates: [0.25, 0.75]});
94
94
  });
95
95
 
96
- test('worldCoordinates leaves points outside the world out of the clusters', () => {
96
+ test('a unit square without wrap leaves points outside the world out of the clusters', () => {
97
97
  const data = {
98
98
  type: 'FeatureCollection' as const,
99
99
  features: [
@@ -103,12 +103,12 @@ test('worldCoordinates leaves points outside the world out of the clusters', ()
103
103
  ]
104
104
  };
105
105
 
106
- const index = new GeoJSONVT(data, {worldCoordinates: true, cluster: true});
106
+ const index = new GeoJSONVT(data, {coordinates: 'unitSquare', wrap: false, cluster: true});
107
107
 
108
108
  expect(index.getTile(0, 0, 0).features.map(feature => feature.geometry)).toEqual([[[507, 256]]]);
109
109
  });
110
110
 
111
- test('worldCoordinates keeps points at either edge out of the clustered tile at the other edge', () => {
111
+ test('a unit square without wrap keeps points at either edge out of the clustered tile at the other edge', () => {
112
112
  const data = {
113
113
  type: 'FeatureCollection' as const,
114
114
  features: [
@@ -117,12 +117,12 @@ test('worldCoordinates keeps points at either edge out of the clustered tile at
117
117
  ]
118
118
  };
119
119
 
120
- const index = new GeoJSONVT(data, {worldCoordinates: true, cluster: true});
120
+ const index = new GeoJSONVT(data, {coordinates: 'unitSquare', wrap: false, cluster: true});
121
121
 
122
122
  expect(index.getTile(0, 0, 0).features).toHaveLength(2);
123
123
  });
124
124
 
125
- test('worldCoordinates returns cluster children and leaves in world coordinates', () => {
125
+ test('a unit square without wrap returns cluster children and leaves in the unit square', () => {
126
126
  const points = {
127
127
  type: 'FeatureCollection' as const,
128
128
  features: Array.from({length: 20}, (_, i) => ({
@@ -131,7 +131,7 @@ test('worldCoordinates returns cluster children and leaves in world coordinates'
131
131
  properties: {name: `Point ${i}`}
132
132
  }))
133
133
  };
134
- const index = new GeoJSONVT(points, {worldCoordinates: true, cluster: true, clusterOptions: {radius: 100}});
134
+ const index = new GeoJSONVT(points, {coordinates: 'unitSquare', wrap: false, cluster: true, clusterOptions: {radius: 100}});
135
135
  const cluster = index.getTile(0, 0, 0).features.find(f => (f.tags as {cluster?: boolean})?.cluster);
136
136
  const clusterId = (cluster.tags as {cluster_id: number}).cluster_id;
137
137
 
@@ -142,10 +142,41 @@ test('worldCoordinates returns cluster children and leaves in world coordinates'
142
142
  expect(leafPositions).toEqual(expect.arrayContaining(points.features.map(point => point.geometry.coordinates)));
143
143
  });
144
144
 
145
- test('worldCoordinates returns no tile for a column on either side of the world', () => {
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', () => {
146
177
  const inTheTwoTopTiles = {type: 'MultiPoint' as const, coordinates: [[0.25, 0.25], [0.75, 0.25]]};
147
178
 
148
- const index = new GeoJSONVT(inTheTwoTopTiles, {worldCoordinates: true});
179
+ const index = new GeoJSONVT(inTheTwoTopTiles, {coordinates: 'unitSquare', wrap: false});
149
180
 
150
181
  expect([index.getTile(1, -1, 0), index.getTile(1, 2, 0)]).toEqual([null, null]);
151
182
  });
@@ -210,6 +241,126 @@ test('leaves out a MultiPolygon that has only empty polygons', () => {
210
241
  expect(index.getData()).toEqual({type: 'FeatureCollection', features: []});
211
242
  });
212
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
+
213
364
  test('keeps the corner of a polygon whose vertex lies on the left edge of a tile', () => {
214
365
  const polygon = {
215
366
  type: 'Polygon' as const,
@@ -261,14 +412,14 @@ test('clusters each position of a MultiPoint feature when clustering is on', ()
261
412
  ]);
262
413
  });
263
414
 
264
- test('worldCoordinates leaves the positions of a MultiPoint outside the world out of the clusters', () => {
415
+ test('a unit square without wrap leaves the positions of a MultiPoint outside the world out of the clusters', () => {
265
416
  const multiPoint = {
266
417
  type: 'Feature' as const,
267
418
  properties: {},
268
419
  geometry: {type: 'MultiPoint' as const, coordinates: [[0.99, 0.5], [1.03, 0.5], [0.5, -0.03]]}
269
420
  };
270
421
 
271
- const index = new GeoJSONVT(multiPoint, {worldCoordinates: true, cluster: true});
422
+ const index = new GeoJSONVT(multiPoint, {coordinates: 'unitSquare', wrap: false, cluster: true});
272
423
 
273
424
  expect(index.getTile(0, 0, 0).features.map(feature => feature.geometry)).toEqual([[[507, 256]]]);
274
425
  });