s2-tools
Version:
A collection of geospatial tools primarily designed for WGS84, Web Mercator, and S2.
389 lines • 15.4 kB
JavaScript
import { EARTH_RADIUS } from '..';
import { KDSpatialIndex } from '../dataStore';
import { PriorityQueue } from './priorityQueue';
import { fromST } from '../geometry/s2/point';
import { xyzToLonLat } from '../geometry/s2/coords';
const RAD = 0.017453292519943295; // Math.PI / 180;
/**
* # Point Index Fast
*
* ## Description
* An index of cells with radius queries
* Assumes the data is {@link Stringifiable}
* Because of the nature of low level language like Javascript, using u64 is slow. This index
* uses f64 which Number supports. So it is fast and efficient.
*
* ## Usage
* ```ts
* import { PointIndexFast } from 's2-tools';
* import { KDMMapSpatialIndex } from 's2-tools/mmap';
*
* const pointIndex = new PointIndexFast();
* // or used a mmap based store
* const pointIndex = new PointIndex(KDMMapSpatialIndex);
*
* // insert a lon-lat
* pointIndex.insertLonLat(lon, lat, data);
* // insert an STPoint
* pointIndex.insertFaceST(face, s, t, data);
*
* // after adding data build the index. NOTE: You don't have to call this, it will be called
* // automatically when making a query
* await pointIndex.sort();
*
* // you can search a range
* const points = await pointIndex.searchRange(minX, minY, maxX, maxY);
* // or a standard radius search
* const points = await pointIndex.searchRadius(qx, qy, r);
* // or a spherical radius search that wraps around the -180/180 boundary
* const points = await pointIndex.searchRadiusSphere(lon, lat, dist);
* ```
*/
export class PointIndexFast {
nodeSize;
#store;
#sorted = false;
/**
* @param store - the store to index. May be an in memory or disk
* @param nodeSize - the size of each kd-tree node
*/
constructor(store = KDSpatialIndex, nodeSize = 64) {
this.nodeSize = nodeSize;
this.#store = new store(nodeSize);
}
/**
* Add a properly shaped point with it's x, y, and data values
* @param point - the point to be indexed
*/
insert(point) {
this.#store.push(point);
this.#sorted = false;
}
/**
* Add a lon-lat pair to the cluster
* @param lon - longitude in degrees
* @param lat - latitude in degrees
* @param data - the data associated with the point
*/
insertLonLat(lon, lat, data) {
this.insert({ x: lon, y: lat, data });
}
/**
* 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) {
const [lon, lat] = xyzToLonLat(fromST(face, s, t));
this.insert({ x: lon, y: lat, data });
}
/**
* iterate through the points
* @yields a PointShapeFast<T>
*/
*[Symbol.iterator]() {
this.sort();
for (const value of this.#store)
yield value;
}
/** Perform indexing of the added points. */
sort() {
if (this.#sorted)
return;
// kd-sort both arrays for efficient search
this.#store.sort();
this.#sorted = true;
}
/**
* Search the index for items within a given bounding box.
* @param minX - the min x coordinate
* @param minY - the min y coordinate
* @param maxX - the max x coordinate
* @param maxY - the max y coordinate
* @param maxResults - the maximum number of results
* @returns - the items that are in range
*/
searchRange(minX, minY, maxX, maxY, maxResults = Infinity) {
this.sort();
const { nodeSize } = this;
const stack = [
[0, this.#store.length - 1, 0],
];
const result = []; // ids of items that are in range
// recursively search for items in range in the kd-sorted arrays
while (stack.length > 0) {
const [left, right, axis] = stack.pop();
// if we reached "tree node", search linearly
if (right - left <= nodeSize) {
for (const point of this.#store.getRange(left, right + 1)) {
const { x, y } = point;
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
result.push(point);
if (result.length >= maxResults)
return result;
}
}
continue;
}
// otherwise find the middle index
const m = (left + right) >> 1;
// include the middle item if it's in range
const mPoint = this.#store.get(m);
const { x, y } = mPoint;
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
result.push(mPoint);
if (result.length >= maxResults)
return result;
}
// queue search in halves that intersect the query
if (axis === 0 ? minX <= x : minY <= y)
stack.push([left, m - 1, 1 - axis]);
if (axis === 0 ? maxX >= x : maxY >= y)
stack.push([m + 1, right, 1 - axis]);
}
return result;
}
/**
* Search the index for items within a given radius.
* @param qx - the query x coordinate
* @param qy - the query y coordinate
* @param r - the radius
* @param maxResults - the maximum number of results
* @returns - the items that are in range
*/
searchRadius(qx, qy, r, maxResults = Infinity) {
this.sort();
const { nodeSize } = this;
const stack = [
[0, this.#store.length - 1, 0],
]; // left, right, axis
const result = []; // ids of items that are in range
const r2 = r * r;
// recursively search for items within radius in the kd-sorted arrays
while (stack.length > 0) {
const [left, right, axis] = stack.pop();
// if we reached "tree node", search linearly
if (right - left <= nodeSize) {
for (const point of this.#store.getRange(left, right + 1)) {
if (this.#sqDist(point.x, point.y, qx, qy) <= r2) {
result.push(point);
if (result.length >= maxResults)
return result;
}
}
continue;
}
// otherwise find the middle index
const m = (left + right) >> 1;
// include the middle item if it's in range
const pointM = this.#store.get(m);
const { x, y } = pointM;
if (this.#sqDist(x, y, qx, qy) <= r2) {
result.push(pointM);
if (result.length >= maxResults)
return result;
}
// queue search in halves that intersect the query
if (axis === 0 ? qx - r <= x : qy - r <= y)
stack.push([left, m - 1, 1 - axis]);
if (axis === 0 ? qx + r >= x : qy + r >= y)
stack.push([m + 1, right, 1 - axis]);
}
return result;
}
/**
* Search the index for items within a given radius using a spherical query.
* NOTE: Assumes the input points are lon-lat pairs in degrees.
* @param lon - longitude
* @param lat - latitude
* @param dist - max distance in meters
* @param maxResults - max number of results
* @param planetRadius - the radius of the planet (Earth by default)
* @returns - the items that are in range
*/
searchRadiusSphere(lon, lat, dist, maxResults = Infinity, planetRadius = EARTH_RADIUS) {
this.sort();
const { nodeSize } = this;
const result = []; // ids of items that are in range
const maxHaverSinDist = haverSin(dist / planetRadius);
const cosLat = Math.cos(lat * RAD);
// a distance-sorted priority queue that will contain both points and kd-tree nodes
const pq = new PriorityQueue([], (a, b) => a.dist - b.dist);
// an object that represents the top kd-tree node (the whole Earth)
let node = {
axis: 0, // 0 for longitude axis and 1 for latitude axis
dist: 0, // will hold the lower bound of children's distances to the query point
};
while (node !== undefined) {
const right = node.right ?? this.#store.length - 1;
const left = node.left ?? 0;
if (right - left <= nodeSize) {
// leaf node case
// add all points of the leaf node to the queue
for (const point of this.#store.getRange(left, right + 1)) {
const dist = haverSinDist(lon, lat, point.x, point.y, cosLat);
pq.push({ point, dist });
}
}
else {
// not a leaf node (has child nodes)
// middle index point
const m = (left + right) >> 1;
const midPoint = this.#store.get(m);
// add middle point to the queue
const dist = haverSinDist(lon, lat, midPoint.x, midPoint.y, cosLat);
pq.push({ point: midPoint, dist });
const nextAxis = (node.axis ?? 0 + 1) % 2;
// first half of the node
const leftNode = {
left,
right: m - 1,
axis: nextAxis,
minLon: node.minLon,
minLat: node.minLat,
maxLon: node.axis === 0 ? midPoint.x : node.maxLon,
maxLat: node.axis === 1 ? midPoint.y : node.maxLat,
dist: 0,
};
// second half of the node
const rightNode = {
left: m + 1,
right,
axis: nextAxis,
minLon: node.axis === 0 ? midPoint.x : node.minLon,
minLat: node.axis === 1 ? midPoint.y : node.minLat,
maxLon: node.maxLon,
maxLat: node.maxLat,
dist: 0,
};
leftNode.dist = boxDist(lon, lat, cosLat, leftNode);
rightNode.dist = boxDist(lon, lat, cosLat, rightNode);
// add child nodes to the queue
pq.push(leftNode);
pq.push(rightNode);
}
// fetch closest points from the queue; they're guaranteed to be closer
// than all remaining points (both individual and those in kd-tree nodes),
// since each node's distance is a lower bound of distances to its children
while (pq.length !== 0 && pq.peek()?.point !== undefined) {
const candidate = pq.pop();
if (candidate?.point === undefined)
break;
if (candidate.dist > maxHaverSinDist)
return result;
result.push(candidate.point);
if (result.length === maxResults)
return result;
}
// the next closest kd-tree node
node = pq.pop();
}
return result;
}
/**
* Compute the squared distance between two points
* @param ax - the first x coordinate
* @param ay - the first y coordinate
* @param bx - the second x coordinate
* @param by - the second y coordinate
* @returns - the squared distance
*/
#sqDist(ax, ay, bx, by) {
const dx = ax - bx;
const dy = ay - by;
return dx * dx + dy * dy;
}
}
/**
* lower bound for distance from a location to points inside a bounding box
* @param lon - the longitude
* @param lat - the latitude
* @param cosLat - the cosine of the latitude
* @param node - the query node to test against
* @returns - the box distance
*/
function boxDist(lon, lat, cosLat, node) {
const minLon = node.minLon ?? -180;
const maxLon = node.maxLon ?? 180;
const minLat = node.minLat ?? -90;
const maxLat = node.maxLat ?? 90;
// query point is between minimum and maximum longitudes
if (lon >= minLon && lon <= maxLon) {
if (lat < minLat)
return haverSin((lat - minLat) * RAD);
if (lat > maxLat)
return haverSin((lat - maxLat) * RAD);
return 0;
}
// query point is west or east of the bounding box;
// calculate the extremum for great circle distance from query point to the closest longitude;
const haverSinDLon = Math.min(haverSin((lon - minLon) * RAD), haverSin((lon - maxLon) * RAD));
const extremumLat = vertexLat(lat, haverSinDLon);
// if extremum is inside the box, return the distance to it
if (extremumLat > minLat && extremumLat < maxLat) {
return haverSinDistPartial(haverSinDLon, cosLat, lat, extremumLat);
}
// otherwise return the distan e to one of the bbox corners (whichever is closest)
return Math.min(haverSinDistPartial(haverSinDLon, cosLat, lat, minLat), haverSinDistPartial(haverSinDLon, cosLat, lat, maxLat));
}
/**
* Returns the square of the haversine of the angle
* @param theta - the angle
* @returns - the square of the haversine
*/
function haverSin(theta) {
const s = Math.sin(theta / 2);
return s * s;
}
/**
* Returns the haversine of the angle
* @param haverSinDLon - the haversine of the longitude difference
* @param cosLat1 - the cosine of the first latitude
* @param lat1 - the first latitude
* @param lat2 - the second latitude
* @returns - the haversine of the angle
*/
function haverSinDistPartial(haverSinDLon, cosLat1, lat1, lat2) {
return cosLat1 * Math.cos(lat2 * RAD) * haverSinDLon + haverSin((lat1 - lat2) * RAD);
}
/**
* Returns the square of the haversine of the distance
* @param lon1 - the first longitude
* @param lat1 - the first latitude
* @param lon2 - the second longitude
* @param lat2 - the second latitude
* @param cosLat1 - the cosine of the first latitude
* @returns - the square of the haversine
*/
function haverSinDist(lon1, lat1, lon2, lat2, cosLat1) {
const haverSinDLon = haverSin((lon1 - lon2) * RAD);
return haverSinDistPartial(haverSinDLon, cosLat1, lat1, lat2);
}
/**
* Returns the distance between two points given the spherical radius in meters (defaults to earth's radius)
* @param lon1 - the first longitude
* @param lat1 - the first latitude
* @param lon2 - the second longitude
* @param lat2 - the second latitude
* @param planetRadius - the radius of the planet (Earth by default)
* @returns - the distance
*/
export function sphericalDistance(lon1, lat1, lon2, lat2, planetRadius = EARTH_RADIUS) {
const h = haverSinDist(lon1, lat1, lon2, lat2, Math.cos(lat1 * RAD));
return 2 * planetRadius * Math.asin(Math.sqrt(h));
}
/**
* Returns the latitude of a vertex given the latitude and the haversine of the longitude difference
* @param lat - the latitude
* @param haverSinDLon - the haversine of the longitude difference
* @returns - the latitude
*/
function vertexLat(lat, haverSinDLon) {
const cosDLon = 1 - 2 * haverSinDLon;
if (cosDLon <= 0)
return lat > 0 ? 90 : -90;
return Math.atan(Math.tan(lat * RAD) / cosDLon) / RAD;
}
//# sourceMappingURL=pointIndexFast.js.map