• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Open-S2 / gis-tools / #49

20 Apr 2025 06:40AM UTC coverage: 93.857% (-1.6%) from 95.485%
#49

push

Mr Martian
tests pass; cleanup

85383 of 90971 relevant lines covered (93.86%)

1952.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.86
/src/dataStructures/tile.ts
1
import { convert } from '../geometry/tools/convert.js';
220✔
2
import { splitTile } from '../geometry/tools/clip.js';
216✔
3
import { buildSqDists, simplify } from '../geometry/index.js';
248✔
4
import {
76✔
5
  idFace as getFace,
422✔
6
  idContains,
7
  idFromFace,
8
  idIsFace,
9
  idLevel,
10
  idParent,
11
  idToFaceIJ,
12
} from '../geometry/id.js';
13

14
import type { FeatureIterator } from '../index.js';
15
import type {
16
  Face,
17
  JSONCollection,
18
  MValue,
19
  Projection,
20
  Properties,
21
  S2CellId,
22
  VectorFeatures,
23
  VectorGeometry,
24
  VectorPoint,
25
} from '../geometry/index.js';
26

27
/**
28
 * # Tile Class
29
 *
30
 * ## Description
31
 * Tile Class to contain the tile information for splitting or simplifying
32
 *
33
 * ## Fields
34
 *
35
 * - `face` - the tile's face
36
 * - `zoom` - the tile's zoom
37
 * - `i` - the tile's x position
38
 * - `j` - the tile's y position
39
 * - `layers` - the tile's layers
40
 * - `transformed` - whether the tile feature geometry has been transformed to tile coordinates
41
 *
42
 * ## Usage
43
 *
44
 * ```ts
45
 * import { Tile } from 'gis-tools-ts';
46
 *  // create a tile
47
 * const tile = new Tile(id);
48
 * // add a feature
49
 * tile.addFeature(feature);
50
 *  // transform the geometry to be relative to the tile
51
 * tile.transform();
52
 * ```
53
 *
54
 * If you have some kind reader you can use the `addReader` method to build the tiile
55
 * ```ts
56
 * import { Tile, JSONReader } from 'gis-tools-ts';
57
 * import { FileReader } from 'gis-tools-ts/file';
58
 * // create a tile
59
 * const tile = new Tile(id);
60
 * // add a reader
61
 * const fileReader = new FileReader(`${__dirname}/fixtures/points.geojson`);
62
 * const jsonReader = new JSONReader(fileReader);
63
 * await tile.addReader(jsonReader);
64
 * // then transform
65
 * tile.transform();
66
 * ```
67
 */
68
export class Tile<
69
  M = Record<string, unknown>,
70
  D extends MValue = Properties,
71
  P extends Properties = Properties,
72
> {
73
  extent = 1;
74
  face: Face;
75
  zoom: number;
76
  i: number;
77
  j: number;
2✔
78
  /**
79
   * @param id - the tile id
80
   * @param layers - the tile's layers
81
   * @param transformed - whether the tile feature geometry has been transformed to tile coordinates
82
   */
83
  constructor(
26✔
84
    id: S2CellId,
16✔
85
    public layers: Record<string, Layer<M, D, P>> = {},
152✔
86
    public transformed = false,
224✔
87
  ) {
8✔
88
    const [face, zoom, i, j] = idToFaceIJ(id);
184✔
89
    this.face = face;
84✔
90
    this.zoom = zoom;
84✔
91
    this.i = i;
60✔
92
    this.j = j;
72✔
93
  }
94

95
  /** @returns true if the tile is empty of features */
96
  isEmpty(): boolean {
34✔
97
    for (const layer of Object.values(this.layers)) {
210✔
98
      if (layer.features.length > 0) return false;
228✔
99
    }
6✔
100
    return true;
80✔
101
  }
102

103
  /**
104
   * Add features from a reader to the tile, optionally to a specific layer to store it in. Defaults to "default".
105
   * @param reader - the reader containing the input data
106
   * @param layer - layer to store the feature to
107
   */
108
  async addReader(reader: FeatureIterator<M, D, P>, layer?: string): Promise<void> {
90✔
109
    for await (const feature of reader) {
162✔
110
      const vectorFeatures = convert(feature.type === 'S2Feature' ? 'S2' : 'WG', feature);
346✔
111
      for (const vf of vectorFeatures) this.addFeature(vf, layer);
300✔
112
    }
20✔
113
  }
114

115
  /**
116
   * Add a vector feature to the tile, optionally to a specific layer to store it in. Defaults to "default".
117
   * @param feature - Vector Feature
118
   * @param layer - layer to store the feature to
119
   */
120
  addFeature(feature: VectorFeatures<M, D, P>, layer?: string): void {
96✔
121
    const { metadata = {} } = feature;
152✔
122
    // @ts-expect-error - ignore if meta doesn't have layer. its ok
123
    const layerName = (metadata.layer as string) ?? layer ?? 'default';
236✔
124
    if (this.layers[layerName] === undefined) this.layers[layerName] = new Layer(layerName, []);
400✔
125
    this.layers[layerName].features.push(feature);
212✔
126
  }
127

128
  /**
129
   * Simplify the geometry to have a tolerance which will be relative to the tile's zoom level.
130
   * NOTE: This should be called after the tile has been split into children if that functionality
131
   * is needed.
132
   * @param tolerance - tolerance
133
   * @param maxzoom - max zoom at which to simplify
134
   */
135
  transform(tolerance: number, maxzoom?: number): void {
110✔
136
    const { transformed, zoom, i, j, layers } = this;
212✔
137
    if (transformed) return;
116✔
138

139
    for (const layer of Object.values(layers)) {
190✔
140
      for (const feature of layer.features) {
178✔
141
        if (tolerance > 0) simplify(feature.geometry, tolerance, zoom, maxzoom);
352✔
142
        _transform(feature.geometry, zoom, i, j);
216✔
143
      }
6✔
144
      // remove empty features
145
      layer.features = layer.features.filter(
174✔
146
        ({ geometry }) => geometry.type === 'Point' || geometry.coordinates.length !== 0,
318✔
147
      );
24✔
148
    }
6✔
149

150
    this.transformed = true;
122✔
151
  }
152
}
4✔
153

154
/**
155
 * @param geometry - input vector geometry to be mutated in place
156
 * @param zoom - tile zoom
157
 * @param ti - tile i
158
 * @param tj - tile j
159
 */
160
function _transform<M extends MValue = Properties>(
40✔
161
  geometry: VectorGeometry<M>,
40✔
162
  zoom: number,
24✔
163
  ti: number,
16✔
164
  tj: number,
16✔
165
): void {
8✔
166
  const { type, coordinates } = geometry;
164✔
167
  zoom = 1 << zoom;
76✔
168

169
  if (type === 'Point') transformPoint(coordinates, zoom, ti, tj);
312✔
170
  else if (type === 'MultiPoint' || type === 'LineString')
192✔
171
    coordinates.forEach((p) => transformPoint(p, zoom, ti, tj));
288✔
172
  else if (type === 'MultiLineString' || type === 'Polygon')
200✔
173
    coordinates.forEach((l) => l.forEach((p) => transformPoint(p, zoom, ti, tj)));
334✔
174
  else if (type === 'MultiPolygon')
50✔
175
    coordinates.forEach((p) => p.forEach((l) => l.forEach((p) => transformPoint(p, zoom, ti, tj))));
206✔
176
}
177

178
/**
179
 * Mutates the point in place to a tile coordinate
180
 * @param vp - input vector point that we are mutating in place
181
 * @param zoom - current zoom
182
 * @param ti - x translation
183
 * @param tj - y translation
184
 */
185
export function transformPoint<M extends MValue = Properties>(
72✔
186
  vp: VectorPoint<M>,
16✔
187
  zoom: number,
24✔
188
  ti: number,
16✔
189
  tj: number,
16✔
190
): void {
8✔
191
  vp.x = vp.x * zoom - ti;
104✔
192
  vp.y = vp.y * zoom - tj;
106✔
193
}
194

195
/** Layer Class to contain the layer information for splitting or simplifying */
196
export class Layer<
72✔
197
  M = Record<string, unknown>,
198
  D extends MValue = Properties,
199
  P extends Properties = Properties,
200
> {
12✔
201
  extent = 1;
50✔
202
  /**
203
   * @param name - the layer name
204
   * @param features - the layer's features
205
   */
206
  constructor(
26✔
207
    public name: string,
136✔
208
    public features: VectorFeatures<M, D, P>[] = [],
×
209
  ) {}
×
210

×
211
  /** @returns The number of features in the layer */
×
212
  get length(): number {
×
213
    return this.features.length;
72✔
214
  }
215
}
4✔
216

217
/** Options for creating a TileStore */
218
export interface TileStoreOptions {
219
  /** manually set the projection, otherwise it defaults to whatever the data type is */
220
  projection?: Projection;
221
  /** min zoom to generate data on */
222
  minzoom?: number;
223
  /** max zoom level to cluster the points on */
224
  maxzoom?: number;
225
  /** max zoom to index data on construction */
226
  indexMaxzoom?: number;
227
  /**
228
   * simplification tolerance (higher means simpler). 3 is a good default.
229
   * Since the default extent is 1, the algorithm will build a unit square of 4_096x4_096 for you.
230
   */
231
  tolerance?: number;
232
  /** tile buffer on each side so lines and polygons don't get clipped */
233
  buffer?: number;
234
  /** whether to build the bounding box for each tile feature */
235
  buildBBox?: boolean;
236
}
237

238
/**
239
 * # Tile Store
240
 *
241
 * ## Description
242
 * TileStore Class is a tile-lookup system that splits and simplifies as needed for each tile request
243
 *
244
 * ## Usage
245
 * ```ts
246
 * import { TileStore } from 'gis-tools-ts';
247
 * const tileStore = new TileStore(data, {
248
 *  projection: 'WG',
249
 *  minzoom: 0,
250
 *  maxzoom: 9,
251
 *  indexMaxzoom: 4,
252
 *  tolerance: 3,
253
 *  buffer: 0.0625
254
 *  buildBBox: false
255
 * });
256
 *
257
 * // get a tile
258
 * const tile = tileStore.getTile(id);
259
 * ```
260
 */
261
export class TileStore<
88✔
262
  M = Record<string, unknown>,
263
  D extends MValue = Properties,
264
  P extends Properties = Properties,
265
> {
12✔
266
  minzoom = 0; // min zoom to preserve detail on
56✔
267
  maxzoom = 16; // max zoom to preserve detail on
60✔
268
  faces = new Set<Face>(); // store which faces are active. 0 face could be entire WM projection
72✔
269
  indexMaxzoom = 4; // max zoom in the tile index
76✔
270
  tolerance = 3 / 4_096; // simplification tolerance (higher means simpler)
116✔
271
  buffer = 0.0625; // tile buffer for lines and polygons
72✔
272
  tiles: Map<S2CellId, Tile<M, D, P>> = new Map(); // stores both WM and S2 tiles
72✔
273
  projection: Projection; // projection to build tiles for
52✔
274
  buildBBox = false; // whether to build the bounding box for each tile feature
78✔
275
  /**
276
   * @param data - input data may be WM or S2 as a Feature or a Collection of Features
277
   * @param options - options to define how to store the data
278
   */
279
  constructor(data: JSONCollection<M, D, P>, options?: TileStoreOptions) {
94✔
280
    // set options should they exist
281
    this.minzoom = options?.minzoom ?? 0;
164✔
282
    this.maxzoom = options?.maxzoom ?? 16;
168✔
283
    this.indexMaxzoom = options?.indexMaxzoom ?? 4;
204✔
284
    this.tolerance = (options?.tolerance ?? 3) / 4_096;
216✔
285
    this.buffer = options?.buffer ?? 0.0625;
176✔
286
    this.buildBBox = options?.buildBBox ?? false;
196✔
287
    // update projection
288
    if (options?.projection !== undefined) this.projection = options.projection;
358✔
289
    else if (data.type === 'Feature' || data.type === 'FeatureCollection') this.projection = 'WG';
198✔
290
    else this.projection = 'S2';
60✔
291
    // sanity check
292
    if (this.maxzoom < 0 || this.maxzoom > 20)
184✔
293
      throw new Error('maxzoom should be in the 0-20 range');
132✔
294
    // convert features
295
    const features = convert(this.projection, data, this.buildBBox, true);
296✔
296
    for (const feature of features) this.#addFeature(feature);
264✔
297
    for (let face = 0; face < 6; face++) {
162✔
298
      const id = idFromFace(face as Face);
136✔
299
      this.#splitTile(id);
116✔
300
    }
18✔
301
  }
302

303
  /**
304
   * @param id - the tile id to acquire
305
   * @returns - the tile if it exists
306
   */
307
  getTile(id: S2CellId): undefined | Tile<M, D, P> {
42✔
308
    const { tiles, faces } = this;
136✔
309
    const zoom = idLevel(id);
116✔
310
    const face = getFace(id);
116✔
311
    // If the zoom is out of bounds, return nothing
312
    if (zoom < 0 || zoom > 20 || !faces.has(face) || zoom < this.minzoom || zoom > this.maxzoom)
384✔
313
      return;
48✔
314

315
    // we want to find the closest tile to the data.
316
    let pID = id;
68✔
317
    while (!tiles.has(pID) && !idIsFace(pID)) pID = idParent(pID);
242✔
318
    // split as necessary, the algorithm will know if the tile is already split
319
    this.#splitTile(pID, id, zoom);
140✔
320

321
    return tiles.get(id);
106✔
322
  }
323

324
  /**
325
   * Stores a feature to a tile, creating the tile if it doesn't exist and tracking the faces we use
326
   * @param feature - the feature to store to a face tile. Creates the tile if it doesn't exist
327
   */
328
  #addFeature(feature: VectorFeatures<M, D, P>): void {
70✔
329
    const { faces, tiles, tolerance, maxzoom } = this;
216✔
330
    // Prep Douglas-Peucker simplification by setting t-values.
331
    buildSqDists(feature.geometry, tolerance, maxzoom);
220✔
332
    const face = feature.face ?? 0;
140✔
333
    const id = idFromFace(face);
128✔
334
    let tile = tiles.get(id);
116✔
335
    if (tile === undefined) {
114✔
336
      faces.add(face);
88✔
337
      tile = new Tile(id);
104✔
338
      tiles.set(id, tile);
116✔
339
    }
6✔
340
    tile?.addFeature(feature);
132✔
341
  }
342

343
  /**
344
   * Splits a tile into smaller tiles given a start and end range, stopping at maxzoom
345
   * @param startID - where to start tiling
346
   * @param endID - where to stop tiling
347
   * @param endZoom - stop tiling at this zoom
348
   */
349
  #splitTile(startID: S2CellId, endID?: S2CellId, endZoom: number = this.maxzoom): void {
192✔
350
    const { buffer, tiles, tolerance, maxzoom, indexMaxzoom } = this;
276✔
351
    const stack: S2CellId[] = [startID];
112✔
352
    // avoid recursion by using a processing queue
353
    while (stack.length > 0) {
118✔
354
      // find our next tile to split
355
      const stackID = stack.pop();
136✔
356
      if (stackID === undefined) break;
180✔
357
      const tile = tiles.get(stackID);
152✔
358
      // if the tile we need does not exist, is empty, or already transformed, skip it
359
      if (tile === undefined || tile.isEmpty() || tile.transformed) continue;
332✔
360
      const tileZoom = tile.zoom;
132✔
361
      // 1: stop tiling if we reached a defined end
362
      // 2: stop tiling if it's the first-pass tiling, and we reached max zoom for indexing
363
      // 3: stop at currently needed maxzoom OR current tile does not include child
364
      if (
36✔
365
        tileZoom >= maxzoom || // 1
92✔
366
        (endID === undefined && tileZoom >= indexMaxzoom) || // 2
204✔
367
        (endID !== undefined && (tileZoom > endZoom || !idContains(stackID, endID))) // 3
304✔
368
      )
369
        continue;
64✔
370

371
      // split the tile and store the children
372
      const [
88✔
373
        { id: blID, tile: blTile },
140✔
374
        { id: brID, tile: brTile },
140✔
375
        { id: tlID, tile: tlTile },
140✔
376
        { id: trID, tile: trTile },
136✔
377
      ] = splitTile(tile, buffer);
100✔
378
      tiles.set(blID, blTile);
120✔
379
      tiles.set(brID, brTile);
120✔
380
      tiles.set(tlID, tlTile);
120✔
381
      tiles.set(trID, trTile);
120✔
382
      // now that the tile has been split, we can transform it
383
      tile.transform(tolerance, maxzoom);
164✔
384
      // push the new features to the stack
385
      stack.push(blID, brID, tlID, trID);
176✔
386
    }
16✔
387
  }
388
}
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc