UNPKG

gis-tools-ts

Version:

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

250 lines 9.48 kB
import { K_AVG_ANGLE_SPAN, convert, idLevel, idRange } from '../geometry/index.js'; import { PointIndex, Tile } from '../index.js'; import { pointAddMut as addMut, pointDivMutScalar as divMutScalar, pointFromST as fromST, pointMulScalar as mulScalar, pointNormalize as normalize, pointToST as toST, } from '../geometry/s2/point.js'; /** * Create a cluster with the correct value * @param data - the m-value of the point * @param value - the interpolated value as a number or RGBA of the cluster * @returns - a new cluster with the correct value and m-value */ function toCluster(data, value) { return { data, visited: false, value }; } /** * # Point Cluster * * ## Description * A cluster store to index points at each zoom level * * ## Usage * ```ts * import { PointCluster } from 'gis-tools-ts'; * const pointCluster = new PointCluster(); * * // add a lon-lat * pointCluster.insertLonLat(lon, lat, data); * // add an STPoint * pointCluster.insertFaceST(face, s, t, data); * * // after adding data build the clusters * await pointCluster.buildClusters(); * * // get the clusters for a tile * const tile = await pointCluster.getTile(id); * // or get the raw cluster data * const clusters = await pointCluster.getCellData(id); * ``` */ export class PointCluster { projection; layerName; minzoom; maxzoom; radius; gridSize = 512; // a default is a 512x512 pixel tile indexes = new Map(); avgAngleSpan = K_AVG_ANGLE_SPAN(); /** * @param data - if provided, the data to index * @param options - cluster options on how to build the cluster * @param maxzoomStore - the store to use for the maxzoom index */ constructor(data, options, maxzoomStore) { 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.radius = options?.radius ?? 40; // one extra zoom incase its a cell search system (bottom zoom isn't clustered to a cell) for (let zoom = this.minzoom; zoom <= this.maxzoom + 1; zoom++) { this.indexes.set(zoom, new PointIndex(options?.store, this.projection)); } if (maxzoomStore !== undefined) { const maxzoomIndex = this.indexes.get(this.maxzoom); maxzoomIndex?.setStore(maxzoomStore); } // convert features if provided if (data !== undefined) this.insertFeature(data); } /** * Add a point to the maxzoom index. The point is a Point3D * @param point - the point to add */ insert(point) { const { x, y, z, m } = point; const maxzoomIndex = this.indexes.get(this.maxzoom + 1); maxzoomIndex?.insert({ x, y, z, m: toCluster(m, 1) }); } /** * 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) { for await (const feature of reader) this.insertFeature(feature); } /** * 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) { const features = convert(this.projection, data, undefined, true); for (const { face = 0, geometry, properties } of features) { const { type, coordinates } = geometry; if (type === 'Point') { const { x: s, y: t, m } = coordinates; this.#insertFaceST(face, s, t, m ?? properties); } else if (type === 'MultiPoint') { for (const point of coordinates) { const { x: s, y: t, m } = point; this.#insertFaceST(face, s, t, m ?? properties); } } } } /** * Add a lon-lat pair to the cluster * @param ll - lon-lat vector point in degrees */ insertLonLat(ll) { this.insertFeature({ type: 'VectorFeature', properties: ll.m ?? {}, geometry: { type: 'Point', coordinates: ll, is3D: false }, }); } /** * 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.insertFeature({ type: 'S2Feature', face, properties: data, geometry: { type: 'Point', coordinates: { x: s, y: t, m: data }, is3D: false }, }); } /** * 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.insert(fromST(face, s, t, data)); } /** * Build the clusters when done adding points * @param cmp_ - custom compare function */ async buildClusters(cmp_) { const { minzoom, maxzoom } = this; const cmp = cmp_ ?? ((_a, _b) => true); for (let zoom = maxzoom; zoom >= minzoom; zoom--) { const curIndex = this.indexes.get(zoom); const queryIndex = this.indexes.get(zoom + 1); if (curIndex === undefined || queryIndex === undefined) throw new Error('Index not found'); await this.#clusterRadius(zoom, queryIndex, curIndex, cmp); } // ensure all point indexes are sorted for (const index of this.indexes.values()) await index.sort(); } /** * Radial clustering * @param zoom - the zoom level * @param queryIndex - the index to query * @param currIndex - the index to insert into * @param cmp - the compare function */ async #clusterRadius(zoom, queryIndex, currIndex, cmp) { const radius = this.#getLevelRadius(zoom); for await (const clusterPoint of queryIndex) { const { point } = clusterPoint; const clusterData = point.m; if (clusterData.visited) continue; clusterData.visited = true; // setup a new weighted cluster point const newClusterPoint = mulScalar(point, clusterData.value); let newNumPoints = clusterData.value; // joining all points found within radius const points = await queryIndex.searchRadius(point, radius); for (const { point: foundPoint } of points) { const foundData = foundPoint.m; // only add points that match or have not been visited already if (!cmp(clusterData.data, foundData.data) || foundData.visited) continue; foundData.visited = true; // weighted add to newClusterPoint position addMut(newClusterPoint, mulScalar(foundPoint, foundData.value)); newNumPoints += foundData.value; } // finish position average divMutScalar(newClusterPoint, newNumPoints); normalize(newClusterPoint); // store the new cluster point const { x, y, z } = newClusterPoint; currIndex.insert({ x, y, z, m: toCluster(clusterData.data, newNumPoints) }); } } /** * @param id - the cell id * @returns - the data within the range of the tile id */ async getCellData(id) { const { minzoom, maxzoom, indexes } = this; const zoom = idLevel(id); if (zoom < minzoom) return; const [min, max] = idRange(id); const levelIndex = indexes.get(Math.min(zoom, maxzoom)); return await levelIndex?.searchRange(min, max); } /** * @param id - the id of the vector tile * @returns - the vector tile */ async getTile(id) { const data = await this.getCellData(id); if (data === undefined) return; const tile = new Tile(id); for (const { point } of data) { const [face, s, t] = toST(point); const { value, data } = point.m; tile.addFeature({ type: 'VectorFeature', face, geometry: { is3D: false, type: 'Point', coordinates: { x: s, y: t, m: { value } } }, properties: data, }, this.layerName); } // transform the geometry to be relative to the tile tile.transform(0, this.maxzoom); return tile; } /** * Get a S1ChordAngle relative to a tile zoom level * @param zoom - the zoom level to build a radius * @returns - the appropriate radius for the given zoom */ #getLevelRadius(zoom) { const { min, floor, log2 } = Math; const { gridSize, avgAngleSpan, radius } = this; const zoomGridCellLevel = min(zoom + floor(log2(gridSize)), 30); const angleSpan = avgAngleSpan.getValue(zoomGridCellLevel) / 2; return angleSpan * radius; } } //# sourceMappingURL=pointCluster.js.map