feat: v0.1.0 — scaffold + MapLibre map with EU airports
This commit is contained in:
+114
@@ -0,0 +1,114 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
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.
|
||||
const INCLUDED_TYPES = new Set(['airport', 'small_airport', 'medium_airport', 'large_airport', 'seaplane_base']);
|
||||
|
||||
export function filterEuAirports(rows: CsvRow[]): CsvRow[] {
|
||||
return rows.filter((r) => r.continent === 'EU' && INCLUDED_TYPES.has(r.type));
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
let cached: GeoJsonFeatureCollection | null = null;
|
||||
|
||||
export async function loadAirportsGeoJson(): Promise<GeoJsonFeatureCollection> {
|
||||
if (cached) return cached;
|
||||
const file = path.join(process.cwd(), 'data', 'airports_eu.csv');
|
||||
const text = await fs.readFile(file, 'utf8');
|
||||
cached = toGeoJson(filterEuAirports(parseCsv(text)));
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user