107 lines
3.8 KiB
TypeScript
107 lines
3.8 KiB
TypeScript
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);
|
||
}
|
||
}
|