• 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

99.35
/src/dataStructures/pointGrid.ts
1
import { pointFromST } from '../geometry/s2/point.js';
216✔
2
import {
496✔
3
  KV,
4
  PointIndex,
5
  PointShape,
6
  defaultGetInterpolateCurrentValue,
7
  getInterpolation,
8
  getRGBAInterpolation,
9
} from '../index.js';
10
import {
434✔
11
  idBoundsST,
12
  idChildrenIJ,
13
  idFace,
14
  idFromST,
15
  idParent,
16
  idToFaceIJ,
17
} from '../geometry/index.js';
18

19
import type {
20
  Face,
21
  JSONCollection,
22
  MValue,
23
  Projection,
24
  Properties,
25
  S2CellId,
26
  VectorPoint,
27
  VectorPointM,
28
} from '../geometry/index.js';
29
import type {
30
  FeatureIterator,
31
  GetInterpolateValue,
32
  InterpolationFunction,
33
  InterpolationMethod,
34
  RGBA,
35
  RGBAInterpolationFunction,
36
} from '../index.js';
37

38
import type { KVStore, KVStoreConstructor, VectorStoreConstructor } from '../dataStore/index.js';
39

40
/** Options for grid clustering */
41
export interface BaseGridOptions<M extends MValue = Properties | RGBA> {
42
  /** type of point index store to use. Defaults to an in memory store */
43
  store?: VectorStoreConstructor<PointShape<M>>;
44
  /** projection to use */
45
  projection?: Projection;
46
  /** Name of the layer to build when requesting a tile */
47
  layerName?: string;
48
  /** min zoom to generate clusters on */
49
  minzoom?: number;
50
  /** max zoom level to cluster the points on */
51
  maxzoom?: number;
52
  /**
53
   * Used by cell search to specify the type of interpolation to use.
54
   * The recommendation is IDW as you want to prioritize closest data points. [default: 'idw']
55
   */
56
  maxzoomInterpolation?: InterpolationMethod;
57
  /**
58
   * Used by cell search to specify the type of interpolation to use.
59
   * From experimentation, lanczos is a fast algorithm that maintains the quality of the data
60
   * [default: 'lanczos']
61
   */
62
  interpolation?: InterpolationMethod;
63
  /** Used by cell search to specify the interpolation function to use [default: 'z' value of the point] */
64
  getInterpolationValue?: 'rgba' | GetInterpolateValue<M>;
65
  /** Grid size, assumed pixel ratio. */
66
  gridSize?: number;
67
  /** Used by the cell search to specify the tile buffer size in pixels. [default: 0] */
68
  bufferSize: number;
69
  /** Set a null value for grid cells that are empty */
70
  nullValue?: number | RGBA;
71
}
72

73
/** Options for grid clustering */
74
export interface GridValueOptions<M extends MValue = Properties> extends BaseGridOptions<M> {
75
  /** Used by cell search to specify the interpolation function to use [default: 'z' value of the point] */
76
  getInterpolationValue: GetInterpolateValue<M>;
77
  /** Set a null value for grid cells that are empty */
78
  nullValue?: number;
79
}
80

81
/** Options for raster clustering */
82
export interface GridRasterOptions<M extends MValue = RGBA> extends BaseGridOptions<M> {
83
  /** Used by cell search to specify the interpolation function to use [default: 'z' value of the point] */
84
  getInterpolationValue: 'rgba';
85
  /** Set a null value for grid cells that are empty */
86
  nullValue?: RGBA;
87
}
88

89
/** An export of the data as a grid */
90
export interface TileGrid extends Properties {
91
  /** name of the layer */
92
  name: string;
93
  /** size of the grid including the buffer */
94
  size: number;
95
  /**
96
   * flattened array of number or RGBA.
97
   * The size of the array is gridSize * gridSize
98
   * Access the position as `gridSize * y + x`
99
   */
100
  data: number[] | RGBA[];
101
}
102

103
/**
104
 * # Grid Cluster
105
 *
106
 * ## Description
107
 * A cluster store to build grid data of gridSize x gridSize. The resultant tiles are filled.
108
 * Useful for building raster tiles or other grid like data (temperature, precipitation, wind, etc).
109
 *
110
 * ## Usage
111
 * ```ts
112
 * import { PointGrid } from 'gis-tools-ts';
113
 * const PointGrid = new PointGrid();
114
 *
115
 * // add a lon-lat
116
 * PointGrid.insertLonLat(lon, lat, data);
117
 * // add an STPoint
118
 * PointGrid.insertFaceST(face, s, t, data);
119
 *
120
 * // after adding data build the clusters
121
 * await PointGrid.buildClusters();
122
 *
123
 * // get the clusters for a tile
124
 * const tile = await PointGrid.getTile(id);
125
 * ```
126
 */
127
export class PointGrid<M extends MValue = Properties | RGBA> {
128
  projection: Projection;
129
  layerName: string;
130
  minzoom: number;
131
  maxzoom: number;
132
  bufferSize: number;
133
  maxzoomInterpolation: InterpolationFunction<M> | RGBAInterpolationFunction;
134
  interpolation: InterpolationFunction<M> | RGBAInterpolationFunction;
135
  getValue: GetInterpolateValue<M>;
136
  gridSize: number; // a default is a 512x512 pixel tile
137
  pointIndex: PointIndex<M>;
138
  gridTileStore: KVStore<number[] | RGBA[]>;
139
  nullValue: number | RGBA;
140
  isRGBA: boolean;
2✔
141

142
  /**
143
   * @param options - cluster options on how to build the cluster
144
   * @param store - the store to use for storing all the grid tiles
145
   */
146
  constructor(
26✔
147
    options?: BaseGridOptions<M> | GridRasterOptions<M>,
36✔
148
    store: KVStoreConstructor<number[] | RGBA[]> = KV<number[] | RGBA[]>,
48✔
149
  ) {
8✔
150
    this.gridTileStore = new store();
140✔
151
    this.projection = options?.projection ?? 'S2';
200✔
152
    this.layerName = options?.layerName ?? 'default';
212✔
153
    this.minzoom = Math.max(options?.minzoom ?? 0, 0);
216✔
154
    this.maxzoom = Math.min(options?.maxzoom ?? 16, 29);
224✔
155
    this.bufferSize = options?.bufferSize ?? 0;
188✔
156
    this.gridSize = options?.gridSize ?? 512;
180✔
157
    const isRGBA = (this.isRGBA = options?.getInterpolationValue === 'rgba');
300✔
158
    this.nullValue = options?.nullValue ?? (isRGBA ? { r: 0, g: 0, b: 0, a: 255 } : 0);
336✔
159
    const interpolation = options?.interpolation ?? 'lanczos';
248✔
160
    const maxzoomInterpolation = options?.maxzoomInterpolation ?? 'idw';
288✔
161
    this.interpolation = isRGBA
130✔
162
      ? getRGBAInterpolation(interpolation)
150✔
163
      : getInterpolation<M>(interpolation);
130✔
164
    this.maxzoomInterpolation = isRGBA
158✔
165
      ? getRGBAInterpolation(maxzoomInterpolation)
178✔
166
      : getInterpolation<M>(maxzoomInterpolation);
158✔
167
    this.getValue =
76✔
168
      options?.getInterpolationValue === 'rgba'
176✔
169
        ? () => 1
24✔
170
        : (options?.getInterpolationValue ?? defaultGetInterpolateCurrentValue);
274✔
171
    // one extra zoom incase its a cell search system (bottom zoom isn't clustered to a cell)
172
    this.pointIndex = new PointIndex<M>(options?.store, this.projection);
292✔
173
  }
174

175
  /**
176
   * Add a point to the maxzoom index. The point is a Point3D
177
   * @param point - the point to add
178
   */
179
  insert(point: VectorPointM<M>): void {
×
180
    this.pointIndex?.insert(point);
90✔
181
  }
182

183
  /**
184
   * Add all points from a reader. It will try to use the M-value first, but if it doesn't exist
185
   * it will use the feature properties data
186
   * @param reader - a reader containing the input data
187
   */
188
  async insertReader(reader: FeatureIterator<unknown, M, M>): Promise<void> {
68✔
189
    await this.pointIndex?.insertReader(reader);
206✔
190
  }
191

192
  /**
193
   * Add a vector feature. It will try to use the M-value first, but if it doesn't exist
194
   * it will use the feature properties data
195
   * @param data - any source of data like a feature collection or features themselves
196
   */
197
  insertFeature(data: JSONCollection<unknown, M, M>): void {
62✔
198
    this.pointIndex?.insertFeature(data);
176✔
199
  }
200

201
  /**
202
   * Add a lon-lat pair to the cluster
203
   * @param ll - lon-lat vector point in degrees
204
   */
205
  insertLonLat(ll: VectorPoint<M>): void {
52✔
206
    this.pointIndex?.insertLonLat(ll);
164✔
207
  }
208

209
  /**
210
   * Insert an STPoint to the index
211
   * @param face - the face of the cell
212
   * @param s - the s coordinate
213
   * @param t - the t coordinate
214
   * @param data - the data associated with the point
215
   */
216
  insertFaceST(face: Face, s: number, t: number, data: M): void {
108✔
217
    this.pointIndex?.insertFaceST(face, s, t, data);
230✔
218
  }
219

220
  /** Build the grid cluster tiles */
221
  async buildClusters(): Promise<void> {
46✔
222
    // build tiles at maxzoom
223
    let parents = await this.#clusterMaxzoom();
188✔
224
    // work upwards, take the 4 children and merge them
225
    for (let zoom = this.maxzoom - 1; zoom >= this.minzoom; zoom--) {
270✔
226
      parents = await this.#custerZoom(zoom, parents);
228✔
227
    }
30✔
228
  }
229

230
  /**
231
   * Using the point index, build grids at maxzoom by doing searches for each gridpoint.
232
   * @returns - the parent cells
233
   */
234
  async #clusterMaxzoom(): Promise<Set<S2CellId>> {
48✔
235
    const { projection, nullValue, maxzoom, pointIndex, isRGBA } = this;
288✔
236
    const { maxzoomInterpolation, getValue, gridSize, bufferSize, gridTileStore } = this;
356✔
237
    const { min, floor, log2 } = Math;
152✔
238
    const parents = new Set<S2CellId>();
112✔
239
    const gridLength = gridSize + bufferSize * 2;
196✔
240
    // if the grid is 512 x 512, log2 is 9, meaning the quadtree must split 9 times to analyze
241
    // each individual pixel. Make sure we don't dive past 30 levels as that's the limit of the spec.
242
    const zoomGridLevel = min(maxzoom + floor(log2(gridSize)) - 1, 30);
284✔
243

244
    for await (const { cell } of pointIndex) {
182✔
245
      const maxzoomID = idParent(cell, maxzoom);
192✔
246
      // if maxzoomID grid tile already exists, skip
247
      if (await gridTileStore.has(maxzoomID)) continue;
244✔
248
      // prep variables and grid result
249
      const face = idFace(cell);
128✔
250
      const [sMin, tMin, sMax, tMax] = idBoundsST(maxzoomID, maxzoom);
280✔
251
      const sPixel = (sMax - sMin) / gridSize;
184✔
252
      const tPixel = (tMax - tMin) / gridSize;
184✔
253
      const sStart = sMin - sPixel * bufferSize;
192✔
254
      const tStart = tMin - tPixel * bufferSize;
192✔
255
      const grid: number[] | RGBA[] = new Array(gridLength * gridLength).fill(nullValue);
280✔
256
      // iterate through the grid and do searches for each position. Interpolate the data to the
257
      // position and store the result in the grid.
258
      for (let y = 0; y < gridLength; y++) {
170✔
259
        for (let x = 0; x < gridLength; x++) {
178✔
260
          const t = tStart + y * tPixel;
160✔
261
          let s = sStart + x * sPixel;
152✔
262
          if (projection === 'WG') s = (s + 1) % 1; // ensure within 0-1 range via wrapping to the other side
244✔
263
          // search for points within a reasonable cell size
264
          let gridLevelSearch = zoomGridLevel;
184✔
265
          let pointShapes: PointShape<M>[];
104✔
266
          let stCell = idFromST(face, s, t, zoomGridLevel);
236✔
267
          do {
56✔
268
            pointShapes = await pointIndex.searchRange(stCell);
252✔
269
            stCell = idParent(stCell, --gridLevelSearch);
264✔
270
          } while (
36✔
271
            pointShapes.length === 0 &&
112✔
272
            gridLevelSearch > 0 &&
92✔
273
            gridLevelSearch > zoomGridLevel - 3
152✔
274
          );
275
          if (pointShapes.length === 0) continue;
236✔
276
          const cluster = pointShapes.map(({ point }) => point);
252✔
277
          grid[y * gridLength + x] = maxzoomInterpolation(
228✔
278
            pointFromST(face, s, t),
100✔
279
            // @ts-expect-error - RGBA is already accounted for, typescript is being lame
280
            cluster,
36✔
281
            isRGBA ? undefined : getValue,
112✔
282
          );
38✔
283
        }
26✔
284
      }
6✔
285
      // store the grid and add the parent cell for future upscaling
286
      gridTileStore.set(maxzoomID, grid);
164✔
287
      if (maxzoom !== 0) parents.add(idParent(maxzoomID, maxzoom - 1));
320✔
288
    }
6✔
289

290
    return parents;
94✔
291
  }
292

293
  /**
294
   * Build the parent cells. We simply search for the children of the cell and merge/downsample.
295
   * @param zoom - the current zoom we are upscaling to
296
   * @param cells - the cells to build grids for
297
   * @returns - the parent cells for the next round of upscaling
298
   */
299
  async #custerZoom(zoom: number, cells: Set<S2CellId>): Promise<Set<S2CellId>> {
84✔
300
    const { gridSize, bufferSize, gridTileStore } = this;
228✔
301
    const parents = new Set<S2CellId>();
112✔
302
    const gridLength = gridSize + bufferSize * 2;
196✔
303
    const halfGridLength = gridLength / 2;
168✔
304

305
    for (const cell of cells) {
122✔
306
      const grid: number[] | RGBA[] = new Array(gridLength * gridLength).fill(this.nullValue);
300✔
307
      const [face, cellZoom, i, j] = idToFaceIJ(cell);
216✔
308
      const [blID, brID, tlID, trID] = idChildrenIJ(face, cellZoom, i, j);
296✔
309
      // for each child, downsample into the result grid
310
      await this.#downsampleGrid(blID, grid, 0, 0);
204✔
311
      await this.#downsampleGrid(brID, grid, halfGridLength, 0);
256✔
312
      await this.#downsampleGrid(tlID, grid, 0, halfGridLength);
256✔
313
      await this.#downsampleGrid(trID, grid, halfGridLength, halfGridLength);
308✔
314
      // store the grid and add the parent cell for future upscaling
315
      gridTileStore.set(cell, grid);
144✔
316
      if (zoom !== 0) parents.add(idParent(cell, zoom - 1));
202✔
317
    }
6✔
318

319
    return parents;
94✔
320
  }
321

322
  /**
323
   * Upscale a grid into the target grid at x,y position
324
   * @param cellID - the cell id for the grid to downsample
325
   * @param target - the target grid
326
   * @param x - the x offset
327
   * @param y - the y offset
328
   */
329
  async #downsampleGrid(
34✔
330
    cellID: S2CellId,
32✔
331
    target: number[] | RGBA[],
32✔
332
    x: number,
12✔
333
    y: number,
10✔
334
  ): Promise<void> {
8✔
335
    const grid = await this.gridTileStore.get(cellID);
216✔
336
    if (grid === undefined) return;
144✔
337

338
    const { gridSize, bufferSize, interpolation, getValue, isRGBA } = this;
300✔
339
    const gridLength = gridSize + bufferSize * 2;
196✔
340
    const halfGridLength = gridLength / 2;
168✔
341
    const halfPoint = { x: 0.5, y: 0.5 };
164✔
342

343
    for (let j = 0; j < halfGridLength; j++) {
178✔
344
      for (let i = 0; i < halfGridLength; i++) {
186✔
345
        // Filter "dead/null" pixels from sourcePoints
346
        const sourcePoints = [
156✔
347
          { x: 0, y: 0, m: grid[j * 2 * gridLength + i * 2] },
248✔
348
          { x: 1, y: 0, m: grid[j * 2 * gridLength + (i * 2 + 1)] },
272✔
349
          { x: 0, y: 1, m: grid[(j * 2 + 1) * gridLength + i * 2] },
272✔
350
          { x: 1, y: 1, m: grid[(j * 2 + 1) * gridLength + (i * 2 + 1)] },
284✔
351
        ].filter((p) => !this.#isNullValue(p.m));
164✔
352
        if (sourcePoints.length === 0) continue;
224✔
353
        target[(j + y) * gridLength + (i + x)] = interpolation(
248✔
354
          halfPoint,
44✔
355
          // @ts-expect-error: RGBA and number handling is abstract
356
          sourcePoints,
56✔
357
          isRGBA ? undefined : getValue,
92✔
358
        );
30✔
359
      }
18✔
360
    }
20✔
361
  }
362

363
  /**
364
   * Check if a value is null
365
   * @param value - the value to check
366
   * @returns - true if the value is equal to the null
367
   */
368
  #isNullValue(value: number | RGBA): boolean {
64✔
369
    const { nullValue } = this;
124✔
370
    if (typeof value === 'number' && typeof nullValue === 'number') return value === nullValue;
420✔
371
    else if (typeof value === 'object' && typeof nullValue === 'object')
212✔
372
      return (
46✔
373
        value.r === nullValue.r &&
108✔
374
        value.g === nullValue.g &&
108✔
375
        value.b === nullValue.b &&
108✔
376
        value.a === nullValue.a
96✔
377
      );
378
    return false;
54✔
379
  }
380

381
  /**
382
   * Get the point data as a grid of a tile
383
   * @param id - the cell id
384
   * @returns - a tile grid
385
   */
386
  async getTile(id: S2CellId): Promise<undefined | TileGrid> {
42✔
387
    const { layerName, gridSize, bufferSize } = this;
212✔
388
    const data = await this.gridTileStore.get(id);
200✔
389
    if (data === undefined) return;
156✔
390

391
    return {
68✔
392
      name: layerName,
88✔
393
      size: gridSize + bufferSize * 2,
152✔
394
      data,
32✔
395
    };
18✔
396
  }
397
}
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