Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -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);
}
}