gis-tools-ts
Version:
A collection of geospatial tools primarily designed for WGS84, Web Mercator, and S2.
250 lines • 9.48 kB
JavaScript
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