import { promises as fs } from 'node:fs'; import path from 'node:path'; import { haversineKm } from './geo'; export type CsvRow = Record; // Minimal RFC-4180-ish parser: quoted fields, escaped quotes (""), CRLF. export function parseCsv(text: string): CsvRow[] { const rows: string[][] = []; let row: string[] = []; let field = ''; let inQuotes = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (inQuotes) { if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else { inQuotes = false; } } else { field += c; } } else if (c === '"') { inQuotes = true; } else if (c === ',') { row.push(field); field = ''; } else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; } else if (c !== '\r') { field += c; } } if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row); } const [header, ...rest] = rows; return rest .filter((r) => r.length === header.length && !(r.length === 1 && r[0] === '')) .map((r) => Object.fromEntries(header.map((h, i) => [h, r[i]]))); } // OurAirports types kept for the sim: all fixed-wing airports plus seaplane bases. // The 2026 schema splits "airport" into small/medium/large_airport (older dumps used a // single "airport" type + category column) — both spellings are accepted. Heliports are // excluded — no fixed-wing transport ops; other types (closed, balloonport, ...) are // irrelevant to an airline-ops game. The committed dataset is pre-curated by // scripts/build-world-airports.mjs: every large_airport plus the small/medium/seaplane rows // that are REMOTE (see isRemote) — this check stays as a cheap guard against an uncurated // dump being swapped in. const INCLUDED_TYPES = new Set(['airport', 'small_airport', 'medium_airport', 'large_airport', 'seaplane_base']); export function filterWorldAirports(rows: CsvRow[]): CsvRow[] { return rows.filter((r) => INCLUDED_TYPES.has(r.type)); } // v0.61.0 (ISS-097): "special" remote airports — the issue's loneliness test: an airport is // remote when no other member of `airports` sits within this radius, so it stands alone in a // genuinely empty region (Antarctica, high Arctic, far islands) rather than being one of many. export const REMOTE_RADIUS_KM = 1000; export interface RemoteAirport { icao: string; lat: number; lon: number; } // True when the airport with this ICAO has no OTHER member of `airports` within REMOTE_RADIUS_KM // (haversine). Unknown ICACOs are not remote — callers only pass rows they intend to include. export function isRemote(airports: readonly RemoteAirport[], icao: string): boolean { const a = airports.find((x) => x.icao === icao); if (!a) return false; for (const b of airports) { if (b.icao !== a.icao && haversineKm(a.lat, a.lon, b.lat, b.lon) <= REMOTE_RADIUS_KM) return false; } return true; } export interface AirportProperties { name: string; icao: string; lat: number; lon: number; } export interface GeoJsonFeature { type: 'Feature'; geometry: { type: 'Point'; coordinates: [number, number] }; properties: AirportProperties; } export interface GeoJsonFeatureCollection { type: 'FeatureCollection'; features: GeoJsonFeature[]; } export function toGeoJson(rows: CsvRow[]): GeoJsonFeatureCollection { const features: GeoJsonFeature[] = []; for (const r of rows) { // OurAirports schema: latitude_deg / longitude_deg; ICAO in icao_code, ident as fallback. const lat = Number(r.latitude_deg); const lon = Number(r.longitude_deg); if (!r.ident || !r.name || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [lon, lat] }, properties: { name: r.name, icao: r.icao_code || r.ident, lat, lon }, }); } return { type: 'FeatureCollection', features }; } // Three consumers key features by ICAO with silent-overwrite semantics (the map's airport // index, the route/queue GeoJSON builders and the sim's airport lookups) — a duplicate would // corrupt state without any error, so fail loud at load instead. export function assertUniqueIcaos(fc: GeoJsonFeatureCollection): void { const seen = new Set(); for (const f of fc.features) { if (seen.has(f.properties.icao)) throw new Error(`duplicate ICAO in airport dataset: ${f.properties.icao}`); seen.add(f.properties.icao); } } let cached: GeoJsonFeatureCollection | null = null; export async function loadAirportsGeoJson(): Promise { if (cached) return cached; const file = path.join(process.cwd(), 'data', 'airports_world.csv'); const text = await fs.readFile(file, 'utf8'); const fc = toGeoJson(filterWorldAirports(parseCsv(text))); assertUniqueIcaos(fc); cached = fc; return cached; } export async function airportsHandler(): Promise { try { const fc = await loadAirportsGeoJson(); return Response.json(fc); } catch (err) { console.error('failed to load airport data', err); return Response.json({ error: 'failed to load airport data' }, { status: 500 }); } }