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

Open-S2 / gis-tools / #6

23 Jan 2025 11:00PM UTC coverage: 92.693% (-0.3%) from 93.021%
#6

push

Mr Martian
gridCluster done; toTiles setup without tests; dataStores add has; fixed geometry 3dpoint to normalize from wm and s2 data

120 of 399 new or added lines in 24 files covered. (30.08%)

26 existing lines in 9 files now uncovered.

59825 of 64541 relevant lines covered (92.69%)

95.07 hits per line

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

5.99
/src/dataStructures/gridCluster.ts
1
import { fromS2Points } from '../geometry/s1/chordAngle';
228✔
2
import {
460✔
3
  KV,
4
  PointIndex,
5
  PointShape,
6
  defaultGetInterpolateCurrentValue,
7
  getInterpolation,
8
  getRGBAInterpolation,
9
} from '..';
10
import {
204✔
11
  boundsST,
12
  childrenIJ,
13
  convert,
14
  face as faceST,
352✔
15
  fromFacePosLevel,
16
  getVertices,
17
  parent,
18
  toFaceIJ,
19
  toWM,
20
} from '../geometry';
21
import { fromLonLat, fromST } from '../geometry/s2/point';
230✔
22

23
import type { S1ChordAngle } from '../geometry/s1/chordAngle';
24
import type {
25
  Face,
26
  JSONCollection,
27
  MValue,
28
  Projection,
29
  Properties,
30
  S2CellId,
31
  VectorPoint,
32
} from '../geometry';
33
import type {
34
  FeatureIterator,
35
  GetInterpolateValue,
36
  InterpolationFunction,
37
  InterpolationMethod,
38
  RGBA,
39
  RGBAInterpolationFunction,
40
  VectorFeatures,
41
} from '..';
42

43
import type { KVStore, KVStoreConstructor, VectorStoreConstructor } from '../dataStore';
44

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

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

80
/** Options for raster clustering */
81
export interface ClusterRasterOptions<M extends MValue = Properties | RGBA>
82
  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
 *
109
 * ## Usage
110
 * ```ts
111
 * import { GridCluster } from 'gis-tools-ts';
112
 * const pointCluster = new GridCluster();
113
 *
114
 * // add a lon-lat
115
 * pointCluster.insertLonLat(lon, lat, data);
116
 * // add an STPoint
117
 * pointCluster.insertFaceST(face, s, t, data);
118
 *
119
 * // after adding data build the clusters
120
 * await pointCluster.buildClusters();
121
 *
122
 * // get the clusters for a tile
123
 * const tile = await pointCluster.getTile(id);
124
 * // or get the raw cluster data
125
 * const clusters = await pointCluster.getCellData(id);
126
 * ```
127
 */
128
export class GridCluster<M extends MValue = Properties | RGBA> {
129
  projection: Projection;
130
  layerName: string;
131
  minzoom: number;
132
  maxzoom: number;
133
  radius: number;
134
  bufferSize: number;
135
  interpolation: InterpolationFunction<M> | RGBAInterpolationFunction;
136
  getValue: GetInterpolateValue<M>;
137
  gridSize: number; // a default is a 512x512 pixel tile
138
  pointIndex: PointIndex<M>;
139
  gridTileStore: KVStore<number[] | RGBA[]>;
140
  nullValue: number | RGBA;
2✔
141

142
  /**
143
   * @param data - if provided, the data to index
144
   * @param options - cluster options on how to build the cluster
145
   * @param store - the store to use for storing all the grid tiles
146
   */
NEW
147
  constructor(
×
NEW
148
    data?: JSONCollection<Record<string, unknown>, M, M>,
×
NEW
149
    options?: ClusterGridOptions<M> | ClusterRasterOptions<M>,
×
NEW
150
    store: KVStoreConstructor<number[] | RGBA[]> = KV<number[] | RGBA[]>,
×
NEW
151
  ) {
×
NEW
152
    this.gridTileStore = new store();
×
NEW
153
    this.projection = options?.projection ?? 'S2';
×
NEW
154
    this.layerName = options?.layerName ?? 'default';
×
NEW
155
    this.minzoom = Math.max(options?.minzoom ?? 0, 0);
×
NEW
156
    this.maxzoom = Math.min(options?.maxzoom ?? 16, 29);
×
NEW
157
    this.radius = options?.radius ?? 40;
×
NEW
158
    this.bufferSize = options?.bufferSize ?? 0;
×
NEW
159
    this.gridSize = options?.gridSize ?? 512;
×
NEW
160
    const isRGBA = options?.getInterpolationValue === 'rgba';
×
NEW
161
    this.nullValue = options?.nullValue ?? (isRGBA ? { r: 0, g: 0, b: 0, a: 255 } : 0);
×
NEW
162
    const interpolation = options?.interpolation ?? 'lanczos';
×
NEW
163
    this.interpolation = isRGBA
×
NEW
164
      ? getRGBAInterpolation(interpolation)
×
NEW
165
      : getInterpolation<M>(interpolation);
×
NEW
166
    this.getValue =
×
NEW
167
      options?.getInterpolationValue === 'rgba'
×
NEW
168
        ? () => 1
×
NEW
169
        : (options?.getInterpolationValue ?? defaultGetInterpolateCurrentValue);
×
NEW
170
    // one extra zoom incase its a cell search system (bottom zoom isn't clustered to a cell)
×
NEW
171
    this.pointIndex = new PointIndex<M>(options?.store);
×
NEW
172
    // convert features if provided
×
NEW
173
    if (data !== undefined) {
×
NEW
174
      const features = convert(this.projection, data, false, undefined, this.maxzoom, true);
×
NEW
175
      for (const feature of features) {
×
NEW
176
        const face = feature.face ?? 0;
×
NEW
177
        const { type, coordinates } = feature.geometry;
×
NEW
178
        if (type === 'Point') {
×
NEW
179
          const { x: s, y: t } = coordinates;
×
NEW
180
          this.insertFaceST(face, s, t, feature.properties);
×
NEW
181
        }
×
NEW
182
      }
×
183
    }
14✔
184
  }
185

186
  /**
187
   * Add a point to the maxzoom index. The point is a Point3D
188
   * @param point - the point to add
189
   */
NEW
190
  insert(point: VectorPoint<M>): void {
×
NEW
191
    this.pointIndex?.insert(point);
×
NEW
192
  }
×
NEW
193

×
NEW
194
  /**
×
NEW
195
   * Add all points from a reader. It will try to use the M-value first, but if it doesn't exist
×
NEW
196
   * it will use the feature properties data
×
NEW
197
   * @param reader - a reader containing the input data
×
NEW
198
   */
×
NEW
199
  async insertReader(reader: FeatureIterator<Record<string, unknown>, M, M>): Promise<void> {
×
200
    for await (const feature of reader) this.insertFeature(feature);
156✔
201
  }
202

203
  /**
204
   * Add a vector feature. It will try to use the M-value first, but if it doesn't exist
205
   * it will use the feature properties data
206
   * @param feature - vector feature (either S2 or WM)
207
   */
NEW
208
  insertFeature(feature: VectorFeatures<Record<string, unknown>, M, M>): void {
×
NEW
209
    if (feature.geometry.type !== 'Point' && feature.geometry.type !== 'MultiPoint') return;
×
NEW
210
    const {
×
NEW
211
      geometry: { coordinates, type },
×
NEW
212
    } = feature.type === 'S2Feature' ? toWM(feature) : feature;
×
NEW
213
    if (type === 'Point') {
×
NEW
214
      if (coordinates.m === undefined) coordinates.m = feature.properties;
×
NEW
215
      this.insertLonLat(coordinates);
×
NEW
216
    } else if (type === 'MultiPoint') {
×
NEW
217
      for (const point of coordinates) {
×
NEW
218
        if (point.m === undefined) point.m = feature.properties;
×
NEW
219
        this.insertLonLat(point);
×
NEW
220
      }
×
221
    }
14✔
222
  }
223

224
  /**
225
   * Add a lon-lat pair to the cluster
226
   * @param ll - lon-lat vector point in degrees
227
   */
NEW
228
  insertLonLat(ll: VectorPoint<M>): void {
×
229
    this.insert(fromLonLat(ll));
74✔
230
  }
231

232
  /**
233
   * Insert an STPoint to the index
234
   * @param face - the face of the cell
235
   * @param s - the s coordinate
236
   * @param t - the t coordinate
237
   * @param data - the data associated with the point
238
   */
NEW
239
  insertFaceST(face: Face, s: number, t: number, data: M): void {
×
NEW
240
    this.insert(fromST(face, s, t, data));
×
NEW
241
  }
×
NEW
242

×
NEW
243
  /**
×
NEW
244
   * Build the grid cluster tiles
×
NEW
245
   */
×
NEW
246
  async buildClusters(): Promise<void> {
×
NEW
247
    // build tiles at maxzoom
×
NEW
248
    let parents = await this.#clusterMaxzoom();
×
NEW
249
    // work upwards, take the 4 children and merge them
×
NEW
250
    for (let zoom = this.maxzoom - 1; zoom >= this.minzoom; zoom--) {
×
NEW
251
      parents = await this.#custerZoom(zoom, parents);
×
NEW
252
    }
×
NEW
253
  }
×
NEW
254

×
NEW
255
  /**
×
NEW
256
   * Using the point index, build grids at maxzoom by doing searches for each gridpoint.
×
NEW
257
   * @returns - the parent cells
×
NEW
258
   */
×
NEW
259
  async #clusterMaxzoom(): Promise<Set<S2CellId>> {
×
NEW
260
    const { maxzoom, pointIndex, interpolation, getValue, gridSize, bufferSize, gridTileStore } =
×
NEW
261
      this;
×
NEW
262
    const parents = new Set<S2CellId>();
×
NEW
263
    const gridLength = gridSize + bufferSize * 2;
×
NEW
264
    const radius = this.#getLevelRadius(maxzoom);
×
NEW
265

×
NEW
266
    for await (const { cell } of pointIndex) {
×
NEW
267
      const maxzoomID = parent(cell, maxzoom);
×
NEW
268
      // if maxzoomID grid tile already exists, skip
×
NEW
269
      if (await gridTileStore.has(maxzoomID)) continue;
×
NEW
270
      // prep variables and grid result
×
NEW
271
      const face = faceST(cell);
×
NEW
272
      const [sMin, tMin, sMax, tMax] = boundsST(maxzoomID, maxzoom);
×
NEW
273
      const sPixel = (sMax - sMin) / gridSize;
×
NEW
274
      const tPixel = (tMax - tMin) / gridSize;
×
NEW
275
      const sStart = sMin - sPixel * bufferSize;
×
NEW
276
      const tStart = tMin - tPixel * bufferSize;
×
NEW
277
      const grid: number[] | RGBA[] = new Array(gridLength * gridLength).fill(this.nullValue);
×
NEW
278
      // iterate through the grid and do searches for each position. Interpolate the data to the
×
NEW
279
      // position and store the result in the grid.
×
NEW
280
      for (let y = 0; y < gridLength; y++) {
×
NEW
281
        for (let x = 0; x < gridLength; x++) {
×
NEW
282
          const s = sStart + x * sPixel;
×
NEW
283
          const t = tStart + y * tPixel;
×
NEW
284
          const point = fromST(face, s, t);
×
NEW
285
          const pointShapes = await pointIndex.searchRadius(point, radius);
×
NEW
286
          const cluster = pointShapes.map(({ point }) => point);
×
NEW
287
          if (cluster.length === 0) continue;
×
NEW
288
          // @ts-expect-error - RGBA is already accounted for, typescript is being lame
×
NEW
289
          grid[y * gridLength + x] = interpolation(point, cluster, getValue);
×
NEW
290
        }
×
NEW
291
      }
×
NEW
292
      // store the grid and add the parent cell for future upscaling
×
NEW
293
      gridTileStore.set(maxzoomID, grid);
×
NEW
294
      if (maxzoom !== 0) parents.add(parent(maxzoomID, maxzoom - 1));
×
NEW
295
    }
×
NEW
296

×
NEW
297
    return parents;
×
NEW
298
  }
×
NEW
299

×
NEW
300
  /**
×
NEW
301
   * Build the parent cells. We simply search for the children of the cell and merge/upscale.
×
NEW
302
   * @param zoom - the current zoom we are upscaling to
×
NEW
303
   * @param cells - the cells to build grids for
×
NEW
304
   * @returns - the parent cells for the next round of upscaling
×
NEW
305
   */
×
NEW
306
  async #custerZoom(zoom: number, cells: Set<S2CellId>): Promise<Set<S2CellId>> {
×
NEW
307
    const { gridSize, bufferSize, gridTileStore } = this;
×
NEW
308
    const parents = new Set<S2CellId>();
×
NEW
309
    const gridLength = gridSize + bufferSize * 2;
×
NEW
310
    const halfGridLength = gridLength / 2;
×
NEW
311

×
NEW
312
    for (const cell of cells) {
×
NEW
313
      const grid: number[] | RGBA[] = new Array(gridLength * gridLength).fill(this.nullValue);
×
NEW
314
      const [face, cellZoom, i, j] = toFaceIJ(cell);
×
NEW
315
      const [blID, brID, tlID, trID] = childrenIJ(face, cellZoom, i, j);
×
NEW
316
      // for each child, upscale into the result grid
×
NEW
317
      await this.#upscaleGrid(blID, grid, 0, 0);
×
NEW
318
      await this.#upscaleGrid(brID, grid, halfGridLength, 0);
×
NEW
319
      await this.#upscaleGrid(tlID, grid, 0, halfGridLength);
×
NEW
320
      await this.#upscaleGrid(trID, grid, halfGridLength, halfGridLength);
×
NEW
321
      // store the grid and add the parent cell for future upscaling
×
NEW
322
      gridTileStore.set(cell, grid);
×
NEW
323
      if (zoom !== 0) parents.add(parent(cell, zoom - 1));
×
NEW
324
    }
×
NEW
325

×
NEW
326
    return parents;
×
NEW
327
  }
×
NEW
328

×
NEW
329
  /**
×
NEW
330
   * Upscale a grid into the target grid at x,y position
×
NEW
331
   * @param cellID - the cell id for the grid to upscale
×
NEW
332
   * @param target - the target grid
×
NEW
333
   * @param x - the x offset
×
NEW
334
   * @param y - the y offset
×
NEW
335
   */
×
NEW
336
  async #upscaleGrid(
×
NEW
337
    cellID: S2CellId,
×
NEW
338
    target: number[] | RGBA[],
×
NEW
339
    x: number,
×
NEW
340
    y: number,
×
NEW
341
  ): Promise<void> {
×
NEW
342
    const grid = await this.gridTileStore.get(cellID);
×
NEW
343
    if (grid === undefined) return;
×
NEW
344

×
NEW
345
    const { gridSize, bufferSize, interpolation, getValue } = this;
×
NEW
346
    const gridLength = gridSize + bufferSize * 2;
×
NEW
347
    const halfGridLength = gridLength / 2;
×
NEW
348
    const halfPoint = { x: 0.5, y: 0.5 };
×
NEW
349

×
NEW
350
    for (let j = 0; j < halfGridLength; j++) {
×
NEW
351
      for (let i = 0; i < halfGridLength; i++) {
×
NEW
352
        const sourcePoints = [
×
NEW
353
          { x: 0, y: 0, m: grid[j * 2 * gridLength + i * 2] },
×
NEW
354
          { x: 1, y: 0, m: grid[j * 2 * gridLength + (i * 2 + 1)] },
×
NEW
355
          { x: 0, y: 1, m: grid[(j * 2 + 1) * gridLength + i * 2] },
×
NEW
356
          { x: 1, y: 1, m: grid[(j * 2 + 1) * gridLength + (i * 2 + 1)] },
×
NEW
357
        ];
×
NEW
358
        // @ts-expect-error: RGBA and number handling is abstract
×
NEW
359
        target[(j + y) * gridLength + (i + x)] = interpolation(halfPoint, sourcePoints, getValue);
×
NEW
360
      }
×
NEW
361
    }
×
NEW
362
  }
×
NEW
363

×
NEW
364
  /**
×
NEW
365
   * Get the point data as a grid of a tile
×
NEW
366
   * @param id - the cell id
×
NEW
367
   * @returns - a tile grid
×
NEW
368
   */
×
NEW
369
  async getTile(id: S2CellId): Promise<undefined | TileGrid> {
×
NEW
370
    const { layerName, gridSize, bufferSize } = this;
×
NEW
371
    const data = await this.gridTileStore.get(id);
×
NEW
372
    if (data === undefined) return;
×
NEW
373

×
NEW
374
    return {
×
NEW
375
      name: layerName,
×
NEW
376
      size: gridSize + bufferSize * 2,
×
NEW
377
      data,
×
378
    };
16✔
379
  }
380

381
  /**
382
   * Get a S1ChordAngle relative to a tile zoom level
383
   * @param zoom - the zoom level to build a radius
384
   * @returns - the appropriate radius for the given zoom
385
   */
NEW
386
  #getLevelRadius(zoom: number): S1ChordAngle {
×
NEW
387
    const multiplier = this.radius / this.gridSize;
×
NEW
388
    const cell = fromFacePosLevel(0, 0n, zoom);
×
NEW
389
    const [lo, hi] = getVertices(cell);
×
NEW
390
    const angle = fromS2Points(lo, hi);
×
391
    return angle * multiplier;
68✔
392
  }
393
}
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