Files
suaveplan/packages/geospatial/logistics/ports/src/spatial-index/spatial-index.ts
2026-05-27 23:47:53 +01:00

107 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SphericalCoord } from "@suaveplan/geo";
import { distanceKm } from "../distance/distance.js";
import type { NearestPortResult, Port } from "../types/port.types.js";
const CELL_DEGREES = 5;
function cellKey(latitude: number, longitude: number): string {
return `${Math.floor(latitude / CELL_DEGREES)}:${Math.floor(longitude / CELL_DEGREES)}`;
}
/**
* Bucket-grid spatial index keyed by 5° lat/lng cells. Queries fetch the
* 3×3 cell neighbourhood and run exact great-circle distance against the
* candidate set. For ~120 curated ports the absolute saving is small but
* the API stays O(neighbours) as the dataset grows, and the result
* shape is identical to a full scan.
*/
export class PortSpatialIndex {
private readonly cells: Map<string, Port[]>;
private readonly ports: readonly Port[];
public constructor(ports: readonly Port[]) {
this.ports = ports;
this.cells = new Map();
for (const port of ports) {
const key = cellKey(
port.coordinate.latitude,
port.coordinate.longitude
);
const bucket = this.cells.get(key);
if (bucket === undefined) {
this.cells.set(key, [port]);
} else {
bucket.push(port);
}
}
}
/** Total number of indexed ports. */
public get size(): number {
return this.ports.length;
}
/** Return every port in the 3×3 cell neighbourhood of `coord`. */
public neighbourhood(coord: SphericalCoord): Port[] {
const lat0 = Math.floor(coord.latitude / CELL_DEGREES);
const lon0 = Math.floor(coord.longitude / CELL_DEGREES);
const out: Port[] = [];
for (let di = -1; di <= 1; di++) {
for (let dj = -1; dj <= 1; dj++) {
const key = `${lat0 + di}:${lon0 + dj}`;
const bucket = this.cells.get(key);
if (bucket !== undefined) {
for (const p of bucket) out.push(p);
}
}
}
return out;
}
/**
* Find the nearest port to `coord`. Tries the 3×3 neighbourhood first;
* falls back to a full scan when the neighbourhood is empty (sparse
* regions of the globe).
*/
public findNearest(coord: SphericalCoord): NearestPortResult | undefined {
if (this.ports.length === 0) return undefined;
let candidates = this.neighbourhood(coord);
if (candidates.length === 0) candidates = [...this.ports];
let best: NearestPortResult = {
port: candidates[0] as Port,
distanceKm: distanceKm(coord, (candidates[0] as Port).coordinate),
};
for (let i = 1; i < candidates.length; i++) {
const port = candidates[i] as Port;
const d = distanceKm(coord, port.coordinate);
if (d < best.distanceKm) {
best = { port, distanceKm: d };
}
}
return best;
}
/**
* Return every port within `radiusKm` of `coord`. When the radius
* exceeds the half-width of a 3×3 cell neighbourhood the full set is
* scanned to guarantee correctness.
*/
public findInRadius(
coord: SphericalCoord,
radiusKm: number
): NearestPortResult[] {
const neighbourhoodHalfWidthKm = CELL_DEGREES * 111 * 1.5;
const candidates =
radiusKm > neighbourhoodHalfWidthKm
? this.ports
: this.neighbourhood(coord);
const results: NearestPortResult[] = [];
for (const port of candidates) {
const d = distanceKm(coord, port.coordinate);
if (d <= radiusKm) results.push({ port, distanceKm: d });
}
return results.sort((a, b) => a.distanceKm - b.distanceKm);
}
}