154 lines
5.2 KiB
TypeScript
154 lines
5.2 KiB
TypeScript
import { promises as fs } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { haversineKm } from './geo';
|
|
|
|
export type CsvRow = Record<string, string>;
|
|
|
|
// 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<string>();
|
|
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<GeoJsonFeatureCollection> {
|
|
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<Response> {
|
|
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 });
|
|
}
|
|
}
|