feat: v0.1.0 — scaffold + MapLibre map with EU airports
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
|||||||
CESIUM_ION_TOKEN=your-cesium-ion-token-here
|
# No environment variables required yet.
|
||||||
|
|||||||
+6
-3
@@ -10,6 +10,9 @@ dist/
|
|||||||
test-results/
|
test-results/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# local data (DB + CSV cache) — keep folder marker
|
# agent tooling state
|
||||||
data/*
|
.codenomad/
|
||||||
!data/.gitkeep
|
|
||||||
|
# runtime DB files in data/ are ignored; CSV datasets are committed seed data
|
||||||
|
data/*.db
|
||||||
|
data/*.sqlite*
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## v0.1.0 — 2026-09-16 18:24
|
||||||
|
- Scaffolded Next.js (App Router) + TypeScript project; dev server on port 3000 (`npm run dev`)
|
||||||
|
- Full-viewport MapLibre GL map with OpenFreeMap "liberty" vector basemap (attribution included via style)
|
||||||
|
- Zoom-driven camera controller: top-down at zoom ≤ 6, smoothstep tilt to 45° pitch by zoom ≥ 12; pitch is a pure function of zoom so it never fights pan/zoom gestures
|
||||||
|
- Lite 3D terrain from AWS terrarium raster-DEM tiles (`map.setTerrain`); toggleable off via `?terrain=0` or `NEXT_PUBLIC_TERRAIN=0` (off in e2e for deterministic headless rendering)
|
||||||
|
- EU airport seed data: OurAirports CSV (official mirror at davidmegginson.github.io/ourairports-data — the legacy ourairports.com/data URL now 404s) filtered to continent=EU with types small/medium/large_airport + seaplane_base → committed as `data/airports_eu.csv` (7445 airports; heliports, closed and balloon sites excluded — rationale documented in `lib/airports.ts`)
|
||||||
|
- `GET /api/airports`: server-side CSV parse with in-memory cache, returns GeoJSON FeatureCollection (properties: name, icao, lat, lon)
|
||||||
|
- Client renders airports as a GeoJSON circle layer (no DOM markers); click → popup with name + ICAO; header shows loaded airport count (`data-testid="airport-count"`)
|
||||||
|
- MapLibre worker served from node_modules via `/api/mlworker/*` route — bundlers break the URL MapLibre derives for its worker, which silently hangs GeoJSON source updates
|
||||||
|
- Tests: vitest unit suite (CSV parse/filter, GeoJSON build, pitch curve, API handler) + self-contained Playwright e2e suite (`webServer` auto-starts `npm run dev`; page load + airport count + marker click popup)
|
||||||
@@ -13,7 +13,8 @@ Accept job → dispatch a plane → watch it fly the route on the map → earn m
|
|||||||
|
|
||||||
## Map & rendering
|
## Map & rendering
|
||||||
- MapLibre GL JS with vector tiles (OpenFreeMap/OSM-based) — crisp detail down to ~10–100 m/px at city zoom, smooth continent overview; no Cesium needed
|
- MapLibre GL JS with vector tiles (OpenFreeMap/OSM-based) — crisp detail down to ~10–100 m/px at city zoom, smooth continent overview; no Cesium needed
|
||||||
- Lite 3D: terrain/hillshade from free DEM tiles if it stays lightweight, otherwise flat 2D
|
- Camera: top-down when zoomed out, tilts to ~45° as you zoom in (zoom-driven pitch transition)
|
||||||
|
- Lite 3D once tilted: terrain/hillshade from free DEM tiles + building extrusion if it stays lightweight, otherwise flat 2D
|
||||||
- Game-logic data (airports, runways, countries/regions): downloaded CSV dataset cached in `data/`
|
- Game-logic data (airports, runways, countries/regions): downloaded CSV dataset cached in `data/`
|
||||||
|
|
||||||
## Simulation engine
|
## Simulation engine
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { airportsHandler } from '@/lib/airports';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return airportsHandler();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// Serves maplibre-gl dist files (worker + its relative imports) straight from
|
||||||
|
// node_modules so they always match the installed version. MapLibre cannot derive
|
||||||
|
// working URLs for these when bundled by Turbopack/webpack, and the worker script
|
||||||
|
// uses a relative import ("./maplibre-gl-shared.mjs") that must resolve against
|
||||||
|
// this same prefix.
|
||||||
|
export async function GET(_req: Request, ctx: { params: Promise<{ file: string }> }) {
|
||||||
|
const { file } = await ctx.params;
|
||||||
|
if (!/^[\w.-]+\.mjs$/.test(file)) return new Response('not found', { status: 404 });
|
||||||
|
try {
|
||||||
|
const body = await fs.readFile(
|
||||||
|
path.join(process.cwd(), 'node_modules', 'maplibre-gl', 'dist', file),
|
||||||
|
);
|
||||||
|
return new Response(body, { headers: { 'content-type': 'text/javascript' } });
|
||||||
|
} catch {
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Higher specificity than maplibre-gl.css's .maplibregl-map{position:relative},
|
||||||
|
which MapLibre adds to the same element and would otherwise win on sheet order. */
|
||||||
|
.map-wrap .map-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: rgba(15, 23, 42, 0.75);
|
||||||
|
color: #f8fafc;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid='airport-count'] {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Flight Ops',
|
||||||
|
description: 'Run a small airplane transport company in Europe.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const Map = dynamic(() => import('@/components/Map'), { ssr: false });
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <Map />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import * as maplibregl from 'maplibre-gl';
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
import { pitchForZoom } from '@/lib/camera';
|
||||||
|
|
||||||
|
const EUROPE_CENTER: [number, number] = [10, 52];
|
||||||
|
const INITIAL_ZOOM = 4;
|
||||||
|
const OPENFREEMAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
|
||||||
|
const TERRAIN_TILES = ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'];
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Map() {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [airportCount, setAirportCount] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const terrainEnabled =
|
||||||
|
new URLSearchParams(window.location.search).get('terrain') !== '0' &&
|
||||||
|
process.env.NEXT_PUBLIC_TERRAIN !== '0';
|
||||||
|
|
||||||
|
// Bundlers (Turbopack) break MapLibre's derived worker URL, so point it at a
|
||||||
|
// route that serves the worker script (and its relative imports) from node_modules.
|
||||||
|
maplibregl.setWorkerUrl('/api/mlworker/maplibre-gl-worker.mjs');
|
||||||
|
|
||||||
|
const map = new maplibregl.Map({
|
||||||
|
container: containerRef.current,
|
||||||
|
style: OPENFREEMAP_STYLE,
|
||||||
|
center: EUROPE_CENTER,
|
||||||
|
zoom: INITIAL_ZOOM,
|
||||||
|
pitch: 0,
|
||||||
|
});
|
||||||
|
// Dev/e2e hook so tests can project coordinates to screen space.
|
||||||
|
(window as unknown as Record<string, unknown>).__flightMap = map;
|
||||||
|
|
||||||
|
map.on('move', () => {
|
||||||
|
const target = pitchForZoom(map.getZoom());
|
||||||
|
if (Math.abs(map.getPitch() - target) > 0.05) {
|
||||||
|
map.setPitch(target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Airports are GeoJSON, so they don't depend on basemap tiles: initialize as soon
|
||||||
|
// as the style is applied ('styledata') rather than waiting for 'load', which also
|
||||||
|
// requires all tile sources to finish.
|
||||||
|
let initialized = false;
|
||||||
|
const init = async () => {
|
||||||
|
if (initialized) return;
|
||||||
|
initialized = true;
|
||||||
|
try {
|
||||||
|
if (terrainEnabled) {
|
||||||
|
map.addSource('dem', {
|
||||||
|
type: 'raster-dem',
|
||||||
|
tiles: TERRAIN_TILES,
|
||||||
|
encoding: 'terrarium',
|
||||||
|
tileSize: 256,
|
||||||
|
});
|
||||||
|
map.setTerrain({ source: 'dem', exaggeration: 1.4 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/airports');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json()) as { features: unknown[] };
|
||||||
|
setAirportCount(data.features.length);
|
||||||
|
|
||||||
|
map.addSource('airports', { type: 'geojson', data });
|
||||||
|
map.addLayer({
|
||||||
|
id: 'airports-circles',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'airports',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 3,
|
||||||
|
'circle-color': '#e11d48',
|
||||||
|
'circle-stroke-width': 1,
|
||||||
|
'circle-stroke-color': '#ffffff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on('click', 'airports-circles', (e) => {
|
||||||
|
const f = e.features?.[0];
|
||||||
|
if (!f) return;
|
||||||
|
const p = f.properties as { name: string; icao: string };
|
||||||
|
new maplibregl.Popup({ offset: 10 })
|
||||||
|
.setLngLat(f.geometry.coordinates as [number, number])
|
||||||
|
.setHTML(
|
||||||
|
`<div data-testid="airport-popup"><strong>${escapeHtml(p.name)}</strong><br/>${escapeHtml(p.icao)}</div>`,
|
||||||
|
)
|
||||||
|
.addTo(map);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('failed to initialize map layers', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (map.isStyleLoaded()) void init();
|
||||||
|
else map.on('styledata', () => void init());
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
delete (window as unknown as Record<string, unknown>).__flightMap;
|
||||||
|
map.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="map-wrap">
|
||||||
|
<header data-testid="app-header" className="app-header">
|
||||||
|
<h1>Flight Ops</h1>
|
||||||
|
<span data-testid="airport-count">
|
||||||
|
{airportCount === null ? 'loading airports…' : `${airportCount} EU airports`}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div ref={containerRef} data-testid="map" className="map-container" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
// Terrain is disabled in e2e (?terrain=0) for deterministic headless rendering;
|
||||||
|
// the terrain code path stays toggleable on by default in the app.
|
||||||
|
const APP_URL = '/?terrain=0';
|
||||||
|
|
||||||
|
async function waitForAirportCount(page: Page): Promise<number> {
|
||||||
|
const countEl = page.getByTestId('airport-count');
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const text = (await countEl.textContent()) ?? '';
|
||||||
|
const m = text.match(/(\d+)/);
|
||||||
|
return m ? parseInt(m[1], 10) : -1;
|
||||||
|
}).toBeGreaterThan(0);
|
||||||
|
const text = (await countEl.textContent()) ?? '';
|
||||||
|
return parseInt(text.match(/(\d+)/)?.[1] ?? '0', 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('page loads with the map and EU airport count', async ({ page }) => {
|
||||||
|
await page.goto(APP_URL);
|
||||||
|
await expect(page.getByTestId('map')).toBeVisible();
|
||||||
|
const count = await waitForAirportCount(page);
|
||||||
|
expect(count).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => {
|
||||||
|
await page.goto(APP_URL);
|
||||||
|
await waitForAirportCount(page);
|
||||||
|
|
||||||
|
// Wait until the map instance exists and airport circles are actually rendered
|
||||||
|
// (the GeoJSON source is parsed in a worker, so features arrive after addSource).
|
||||||
|
await expect.poll(async () =>
|
||||||
|
page.evaluate(
|
||||||
|
() =>
|
||||||
|
(window as any).__flightMap?.queryRenderedFeatures(undefined, {
|
||||||
|
layers: ['airports-circles'],
|
||||||
|
})?.length ?? 0,
|
||||||
|
),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Project EKRN (Bornholm) to screen space via the dev hook, then click it.
|
||||||
|
// Bornholm is used because its nearest other airport is ~0.8° away (~19 px at the
|
||||||
|
// initial zoom), so its marker cannot be overlapped by a neighbouring circle.
|
||||||
|
const pt = await page.evaluate(() => {
|
||||||
|
const map = (window as any).__flightMap;
|
||||||
|
const p = map.project([14.7596, 55.063301]);
|
||||||
|
const rect = map.getContainer().getBoundingClientRect();
|
||||||
|
return { x: rect.left + p.x, y: rect.top + p.y };
|
||||||
|
});
|
||||||
|
await page.mouse.click(pt.x, pt.y);
|
||||||
|
|
||||||
|
const popup = page.getByTestId('airport-popup');
|
||||||
|
await expect(popup).toBeVisible();
|
||||||
|
await expect(popup).toContainText('Bornholm Airport');
|
||||||
|
await expect(popup).toContainText('EKRN');
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
import "./.next/dev/types/root-params.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+2679
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "flight-ops",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3000",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 3000",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"maplibre-gl": "^6.10.0",
|
||||||
|
"next": "^16.3.5",
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.20.3",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vitest": "^5.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
timeout: 30_000,
|
||||||
|
retries: 1,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
url: 'http://localhost:3000/api/airports',
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': path.resolve(process.cwd()) },
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user