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