gis-tools-ts
Version:
A collection of geospatial tools primarily designed for WGS84, Web Mercator, and S2.
271 lines • 11.7 kB
JavaScript
import { pointFromST } from '../geometry/s2/point.js';
import { KV, PointIndex, defaultGetInterpolateCurrentValue, getInterpolation, getRGBAInterpolation, } from '../index.js';
import { idBoundsST, idChildrenIJ, idFace, idFromST, idParent, idToFaceIJ, } from '../geometry/index.js';
/**
* # Grid Cluster
*
* ## Description
* A cluster store to build grid data of gridSize x gridSize. The resultant tiles are filled.
* Useful for building raster tiles or other grid like data (temperature, precipitation, wind, etc).
*
* ## Usage
* ```ts
* import { PointGrid } from 'gis-tools-ts';
* const PointGrid = new PointGrid();
*
* // add a lon-lat
* PointGrid.insertLonLat(lon, lat, data);
* // add an STPoint
* PointGrid.insertFaceST(face, s, t, data);
*
* // after adding data build the clusters
* await PointGrid.buildClusters();
*
* // get the clusters for a tile
* const tile = await PointGrid.getTile(id);
* ```
*/
export class PointGrid {
projection;
layerName;
minzoom;
maxzoom;
bufferSize;
maxzoomInterpolation;
interpolation;
getValue;
gridSize; // a default is a 512x512 pixel tile
pointIndex;
gridTileStore;
nullValue;
isRGBA;
/**
* @param options - cluster options on how to build the cluster
* @param store - the store to use for storing all the grid tiles
*/
constructor(options, store = (KV)) {
this.gridTileStore = new store();
this.projection = options?.projection ?? 'S2';
this.layerName = options?.layerName ?? 'default';
this.minzoom = Math.max(options?.minzoom ?? 0, 0);
this.maxzoom = Math.min(options?.maxzoom ?? 16, 29);
this.bufferSize = options?.bufferSize ?? 0;
this.gridSize = options?.gridSize ?? 512;
const isRGBA = (this.isRGBA = options?.getInterpolationValue === 'rgba');
this.nullValue = options?.nullValue ?? (isRGBA ? { r: 0, g: 0, b: 0, a: 255 } : 0);
const interpolation = options?.interpolation ?? 'lanczos';
const maxzoomInterpolation = options?.maxzoomInterpolation ?? 'idw';
this.interpolation = isRGBA
? getRGBAInterpolation(interpolation)
: getInterpolation(interpolation);
this.maxzoomInterpolation = isRGBA
? getRGBAInterpolation(maxzoomInterpolation)
: getInterpolation(maxzoomInterpolation);
this.getValue =
options?.getInterpolationValue === 'rgba'
? () => 1
: (options?.getInterpolationValue ?? defaultGetInterpolateCurrentValue);
// one extra zoom incase its a cell search system (bottom zoom isn't clustered to a cell)
this.pointIndex = new PointIndex(options?.store, this.projection);
}
/**
* Add a point to the maxzoom index. The point is a Point3D
* @param point - the point to add
*/
insert(point) {
this.pointIndex?.insert(point);
}
/**
* Add all points from a reader. It will try to use the M-value first, but if it doesn't exist
* it will use the feature properties data
* @param reader - a reader containing the input data
*/
async insertReader(reader) {
await this.pointIndex?.insertReader(reader);
}
/**
* Add a vector feature. It will try to use the M-value first, but if it doesn't exist
* it will use the feature properties data
* @param data - any source of data like a feature collection or features themselves
*/
insertFeature(data) {
this.pointIndex?.insertFeature(data);
}
/**
* Add a lon-lat pair to the cluster
* @param ll - lon-lat vector point in degrees
*/
insertLonLat(ll) {
this.pointIndex?.insertLonLat(ll);
}
/**
* Insert an STPoint to the index
* @param face - the face of the cell
* @param s - the s coordinate
* @param t - the t coordinate
* @param data - the data associated with the point
*/
insertFaceST(face, s, t, data) {
this.pointIndex?.insertFaceST(face, s, t, data);
}
/** Build the grid cluster tiles */
async buildClusters() {
// build tiles at maxzoom
let parents = await this.#clusterMaxzoom();
// work upwards, take the 4 children and merge them
for (let zoom = this.maxzoom - 1; zoom >= this.minzoom; zoom--) {
parents = await this.#custerZoom(zoom, parents);
}
}
/**
* Using the point index, build grids at maxzoom by doing searches for each gridpoint.
* @returns - the parent cells
*/
async #clusterMaxzoom() {
const { projection, nullValue, maxzoom, pointIndex, isRGBA } = this;
const { maxzoomInterpolation, getValue, gridSize, bufferSize, gridTileStore } = this;
const { min, floor, log2 } = Math;
const parents = new Set();
const gridLength = gridSize + bufferSize * 2;
// if the grid is 512 x 512, log2 is 9, meaning the quadtree must split 9 times to analyze
// each individual pixel. Make sure we don't dive past 30 levels as that's the limit of the spec.
const zoomGridLevel = min(maxzoom + floor(log2(gridSize)) - 1, 30);
for await (const { cell } of pointIndex) {
const maxzoomID = idParent(cell, maxzoom);
// if maxzoomID grid tile already exists, skip
if (await gridTileStore.has(maxzoomID))
continue;
// prep variables and grid result
const face = idFace(cell);
const [sMin, tMin, sMax, tMax] = idBoundsST(maxzoomID, maxzoom);
const sPixel = (sMax - sMin) / gridSize;
const tPixel = (tMax - tMin) / gridSize;
const sStart = sMin - sPixel * bufferSize;
const tStart = tMin - tPixel * bufferSize;
const grid = new Array(gridLength * gridLength).fill(nullValue);
// iterate through the grid and do searches for each position. Interpolate the data to the
// position and store the result in the grid.
for (let y = 0; y < gridLength; y++) {
for (let x = 0; x < gridLength; x++) {
const t = tStart + y * tPixel;
let s = sStart + x * sPixel;
if (projection === 'WG')
s = (s + 1) % 1; // ensure within 0-1 range via wrapping to the other side
// search for points within a reasonable cell size
let gridLevelSearch = zoomGridLevel;
let pointShapes;
let stCell = idFromST(face, s, t, zoomGridLevel);
do {
pointShapes = await pointIndex.searchRange(stCell);
stCell = idParent(stCell, --gridLevelSearch);
} while (pointShapes.length === 0 &&
gridLevelSearch > 0 &&
gridLevelSearch > zoomGridLevel - 3);
if (pointShapes.length === 0)
continue;
const cluster = pointShapes.map(({ point }) => point);
grid[y * gridLength + x] = maxzoomInterpolation(pointFromST(face, s, t),
// @ts-expect-error - RGBA is already accounted for, typescript is being lame
cluster, isRGBA ? undefined : getValue);
}
}
// store the grid and add the parent cell for future upscaling
gridTileStore.set(maxzoomID, grid);
if (maxzoom !== 0)
parents.add(idParent(maxzoomID, maxzoom - 1));
}
return parents;
}
/**
* Build the parent cells. We simply search for the children of the cell and merge/downsample.
* @param zoom - the current zoom we are upscaling to
* @param cells - the cells to build grids for
* @returns - the parent cells for the next round of upscaling
*/
async #custerZoom(zoom, cells) {
const { gridSize, bufferSize, gridTileStore } = this;
const parents = new Set();
const gridLength = gridSize + bufferSize * 2;
const halfGridLength = gridLength / 2;
for (const cell of cells) {
const grid = new Array(gridLength * gridLength).fill(this.nullValue);
const [face, cellZoom, i, j] = idToFaceIJ(cell);
const [blID, brID, tlID, trID] = idChildrenIJ(face, cellZoom, i, j);
// for each child, downsample into the result grid
await this.#downsampleGrid(blID, grid, 0, 0);
await this.#downsampleGrid(brID, grid, halfGridLength, 0);
await this.#downsampleGrid(tlID, grid, 0, halfGridLength);
await this.#downsampleGrid(trID, grid, halfGridLength, halfGridLength);
// store the grid and add the parent cell for future upscaling
gridTileStore.set(cell, grid);
if (zoom !== 0)
parents.add(idParent(cell, zoom - 1));
}
return parents;
}
/**
* Upscale a grid into the target grid at x,y position
* @param cellID - the cell id for the grid to downsample
* @param target - the target grid
* @param x - the x offset
* @param y - the y offset
*/
async #downsampleGrid(cellID, target, x, y) {
const grid = await this.gridTileStore.get(cellID);
if (grid === undefined)
return;
const { gridSize, bufferSize, interpolation, getValue, isRGBA } = this;
const gridLength = gridSize + bufferSize * 2;
const halfGridLength = gridLength / 2;
const halfPoint = { x: 0.5, y: 0.5 };
for (let j = 0; j < halfGridLength; j++) {
for (let i = 0; i < halfGridLength; i++) {
// Filter "dead/null" pixels from sourcePoints
const sourcePoints = [
{ x: 0, y: 0, m: grid[j * 2 * gridLength + i * 2] },
{ x: 1, y: 0, m: grid[j * 2 * gridLength + (i * 2 + 1)] },
{ x: 0, y: 1, m: grid[(j * 2 + 1) * gridLength + i * 2] },
{ x: 1, y: 1, m: grid[(j * 2 + 1) * gridLength + (i * 2 + 1)] },
].filter((p) => !this.#isNullValue(p.m));
if (sourcePoints.length === 0)
continue;
target[(j + y) * gridLength + (i + x)] = interpolation(halfPoint,
// @ts-expect-error: RGBA and number handling is abstract
sourcePoints, isRGBA ? undefined : getValue);
}
}
}
/**
* Check if a value is null
* @param value - the value to check
* @returns - true if the value is equal to the null
*/
#isNullValue(value) {
const { nullValue } = this;
if (typeof value === 'number' && typeof nullValue === 'number')
return value === nullValue;
else if (typeof value === 'object' && typeof nullValue === 'object')
return (value.r === nullValue.r &&
value.g === nullValue.g &&
value.b === nullValue.b &&
value.a === nullValue.a);
return false;
}
/**
* Get the point data as a grid of a tile
* @param id - the cell id
* @returns - a tile grid
*/
async getTile(id) {
const { layerName, gridSize, bufferSize } = this;
const data = await this.gridTileStore.get(id);
if (data === undefined)
return;
return {
name: layerName,
size: gridSize + bufferSize * 2,
data,
};
}
}
//# sourceMappingURL=pointGrid.js.map