UNPKG

gis-tools-ts

Version:

A collection of geospatial tools primarily designed for WGS84, Web Mercator, and S2.

271 lines 11.7 kB
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