feat: v0.1.0 — scaffold + MapLibre map with EU airports
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseCsv, filterEuAirports, toGeoJson, airportsHandler } from './airports';
|
||||
|
||||
const HEADER =
|
||||
'"id","ident","type","name","latitude_deg","longitude_deg","elevation_ft","continent","iso_country","iso_region","municipality","scheduled_service","icao_code","iata_code","gps_code","local_code","home_link","wikipedia_link","keywords"';
|
||||
|
||||
function row(over: Record<string, string> = {}): string {
|
||||
const base: Record<string, string> = {
|
||||
id: '1',
|
||||
ident: 'XXXX',
|
||||
type: 'small_airport',
|
||||
name: 'Test Field',
|
||||
latitude_deg: '50.0',
|
||||
longitude_deg: '8.0',
|
||||
elevation_ft: '',
|
||||
continent: 'EU',
|
||||
iso_country: 'DE',
|
||||
iso_region: 'DE-BE',
|
||||
municipality: 'Nowhere',
|
||||
scheduled_service: 'no',
|
||||
icao_code: 'XXXX',
|
||||
iata_code: '',
|
||||
gps_code: '',
|
||||
local_code: '',
|
||||
home_link: '',
|
||||
wikipedia_link: '',
|
||||
keywords: '',
|
||||
};
|
||||
return Object.entries({ ...base, ...over })
|
||||
.map(([, v]) => `"${v.replace(/"/g, '""')}"`)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
describe('parseCsv', () => {
|
||||
it('parses simple rows into objects keyed by header', () => {
|
||||
const text = [HEADER, row()].join('\n');
|
||||
const rows = parseCsv(text);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ident).toBe('XXXX');
|
||||
expect(rows[0].continent).toBe('EU');
|
||||
});
|
||||
|
||||
it('handles quoted fields containing commas and escaped quotes', () => {
|
||||
const text = [HEADER, row({ name: 'A, B "South" Field' })].join('\n');
|
||||
const rows = parseCsv(text);
|
||||
expect(rows[0].name).toBe('A, B "South" Field');
|
||||
});
|
||||
|
||||
it('handles CRLF line endings and trailing newline', () => {
|
||||
const text = [HEADER, row(), row({ ident: 'YYYY' })].join('\r\n') + '\r\n';
|
||||
const rows = parseCsv(text);
|
||||
expect(rows.map((r) => r.ident)).toEqual(['XXXX', 'YYYY']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterEuAirports', () => {
|
||||
it('keeps EU fixed-wing airports and seaplane bases, drops the rest', () => {
|
||||
const rows = parseCsv(
|
||||
[
|
||||
HEADER,
|
||||
row({ ident: 'EU-SMALL', type: 'small_airport' }),
|
||||
row({ ident: 'EU-MED', type: 'medium_airport' }),
|
||||
row({ ident: 'EU-LRG', type: 'large_airport' }),
|
||||
row({ ident: 'EU-SEA', type: 'seaplane_base' }),
|
||||
row({ ident: 'EU-LEGACY', type: 'airport' }),
|
||||
row({ ident: 'EU-Helio', type: 'heliport' }),
|
||||
row({ ident: 'EU-CLOSED', type: 'closed' }),
|
||||
row({ ident: 'EU-BALLOON', type: 'balloonport' }),
|
||||
row({ ident: 'NA-SMALL', continent: 'NA' }),
|
||||
row({ ident: 'AS-LRG', continent: 'AS', type: 'large_airport' }),
|
||||
].join('\n'),
|
||||
);
|
||||
const kept = filterEuAirports(rows).map((r) => r.ident);
|
||||
expect(kept.sort()).toEqual(['EU-LEGACY', 'EU-LRG', 'EU-MED', 'EU-SEA', 'EU-SMALL']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toGeoJson', () => {
|
||||
it('builds a FeatureCollection with [lon, lat] coordinates and expected properties', () => {
|
||||
const fc = toGeoJson(parseCsv([HEADER, row({ ident: 'EDDF', icao_code: 'EDDF', name: 'Frankfurt Main Airport' })].join('\n')));
|
||||
expect(fc.type).toBe('FeatureCollection');
|
||||
expect(fc.features).toHaveLength(1);
|
||||
const f = fc.features[0];
|
||||
expect(f.geometry).toEqual({ type: 'Point', coordinates: [8.0, 50.0] });
|
||||
expect(f.properties).toMatchObject({ name: 'Frankfurt Main Airport', icao: 'EDDF', lat: 50.0, lon: 8.0 });
|
||||
});
|
||||
|
||||
it('falls back to ident when icao_code is empty and skips rows without valid coordinates', () => {
|
||||
const fc = toGeoJson(
|
||||
parseCsv(
|
||||
[
|
||||
HEADER,
|
||||
row({ ident: 'NOICAO', icao_code: '' }),
|
||||
row({ ident: 'BADLAT', latitude_deg: 'not-a-number' }),
|
||||
row({ ident: 'NONAME', name: '' }),
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
expect(fc.features).toHaveLength(1);
|
||||
expect(fc.features[0].properties.icao).toBe('NOICAO');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/airports handler', () => {
|
||||
it('returns a valid GeoJSON FeatureCollection of EU airports', async () => {
|
||||
const res = await airportsHandler();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('application/json');
|
||||
const fc = (await res.json()) as { type: string; features: Array<{ properties: Record<string, unknown> }> };
|
||||
expect(fc.type).toBe('FeatureCollection');
|
||||
expect(fc.features.length).toBeGreaterThan(0);
|
||||
for (const f of fc.features.slice(0, 50)) {
|
||||
expect(f.properties.name).toBeTruthy();
|
||||
expect(f.properties.icao).toBeTruthy();
|
||||
expect(typeof f.properties.lat).toBe('number');
|
||||
expect(typeof f.properties.lon).toBe('number');
|
||||
}
|
||||
});
|
||||
|
||||
it('caches the parsed dataset in memory', async () => {
|
||||
const a = await airportsHandler();
|
||||
const b = await airportsHandler();
|
||||
expect(await a.json()).toEqual(await b.json());
|
||||
});
|
||||
});
|
||||
+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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pitchForZoom, smoothstep, PITCH_ZOOM_MIN, PITCH_ZOOM_MAX, MAX_PITCH_DEG } from './camera';
|
||||
|
||||
describe('smoothstep', () => {
|
||||
it('clamps outside the edges and is 0.5 at the midpoint', () => {
|
||||
expect(smoothstep(0, 1, -5)).toBe(0);
|
||||
expect(smoothstep(0, 1, 2)).toBe(1);
|
||||
expect(smoothstep(0, 1, 0.5)).toBeCloseTo(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pitchForZoom', () => {
|
||||
it('is 0 at and below the lower zoom threshold', () => {
|
||||
expect(pitchForZoom(PITCH_ZOOM_MIN)).toBe(0);
|
||||
expect(pitchForZoom(4)).toBe(0);
|
||||
expect(pitchForZoom(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('is max pitch at and above the upper zoom threshold', () => {
|
||||
expect(pitchForZoom(PITCH_ZOOM_MAX)).toBe(MAX_PITCH_DEG);
|
||||
expect(pitchForZoom(15)).toBe(MAX_PITCH_DEG);
|
||||
});
|
||||
|
||||
it('rises smoothly between thresholds (monotonic, midpoint = half max)', () => {
|
||||
expect(pitchForZoom((PITCH_ZOOM_MIN + PITCH_ZOOM_MAX) / 2)).toBeCloseTo(MAX_PITCH_DEG / 2);
|
||||
let prev = -1;
|
||||
for (let z = PITCH_ZOOM_MIN; z <= PITCH_ZOOM_MAX; z += 0.25) {
|
||||
const p = pitchForZoom(z);
|
||||
expect(p).toBeGreaterThan(prev);
|
||||
prev = p;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export const PITCH_ZOOM_MIN = 6;
|
||||
export const PITCH_ZOOM_MAX = 12;
|
||||
export const MAX_PITCH_DEG = 45;
|
||||
|
||||
export function smoothstep(edge0: number, edge1: number, x: number): number {
|
||||
const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
// Pitch is a pure function of zoom so it never fights user pan/zoom gestures.
|
||||
export function pitchForZoom(zoom: number): number {
|
||||
return smoothstep(PITCH_ZOOM_MIN, PITCH_ZOOM_MAX, zoom) * MAX_PITCH_DEG;
|
||||
}
|
||||
Reference in New Issue
Block a user