Initial Commit
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user