Compare commits

..
Author SHA1 Message Date
kamilkrzywda eef305aa27 v0.70.0: default map view — world at [0,0] zoom 2, minZoom 0
CI / build + unit + e2e (pull_request) Failing after 15m5s
2026-09-23 22:13:56 +00:00
kamilkrzywda 7d117f6809 Merge pull request 'Special airports re-curation — one per ~1000 km region (ISS-144)' (#145) from fix/special-airports-curation into master
CI / build + unit + e2e (push) Successful in 13m14s
Special airports re-curation — one per ~1000 km region (ISS-144)
2026-09-23 22:47:08 +02:00
7 changed files with 117 additions and 33 deletions
+4
View File
@@ -1,5 +1,9 @@
# Changelog
## v0.70.0 — 2026-09-23 22:07
- **Default map view is now the whole world.** The initial camera in `components/Map.tsx` moves from the Europe overview `[10,52]` z4 to null island `[0,0]` (renamed constant `WORLD_CENTER`) at zoom 2 — at z2 the world is 2048 px wide and the Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator, so both continents are fully visible on a typical desktop map pane. `MIN_ZOOM` is lowered 3 → 0: when fully zoomed out the whole earth (512 px world) fits any viewport; `MAX_ZOOM = 11` and the flat-2D view are unchanged.
- **E2E.** The new default view shows both Africa + Europe with overlapping markers — LFRS's nearest neighbour (LFRB Brest, 254 km) is only ~13–19 px away at z2, so every marker-click spec now frames its target first: `e2e/map.spec.ts` gains a shared `frameLfrs` helper (`jumpTo` z5 + wait until the marker renders), used by all three of its click tests; `e2e/map-interactions.spec.ts` and `e2e/relocation.spec.ts` frame LFRS at z5 before their popup clicks. The plane-marker test in `e2e/map-interactions.spec.ts` fits EDDF+LEMD into the viewport first — `queryRenderedFeatures` is viewport-scoped and EDDF (50°N) sits right at the edge of the new default view. `e2e/hover.spec.ts`: the antimeridian test's `setMinZoom(0)` workaround is deleted — minZoom is natively 0 now, so `fitToAirports` can reach the z≈1 that EDDF→NFTF needs without lifting any floor. New assertion in `e2e/map.spec.ts` pins the initial camera to [0,0] z2; the wheel-clamp spec's MIN_ZOOM mirror is updated 3 → 0 (its zoom-out side now clamps at 0).
## v0.68.0 — 2026-09-23 20:32
- **Special remote airports re-curated per #144.** The old remote pass kept EVERY non-large fixed-wing row with no large airport within 1000 km, so regions without big cities (central Australia, Canada edges) got filled with dozens of tiny strips. `scripts/build-world-airports.mjs` now greedily thins the same candidate pool to one special per ~1000 km region: candidates are sorted by priority (scheduled service first, then type rank medium < small < seaplane, then numeric id) and a candidate is accepted only if it is at least `REMOTE_RADIUS_KM` from EVERY already-accepted special — no special airport within 1000 km of another. This subsumes the old 40 km remote clustering (deleted). Regenerated `data/airports_world.csv`: 1483 → 1161 rows (1119 large unchanged, specials 364 → 42; min pairwise special distance 1001.6 km).
- **Tests.** New committed-dataset invariant in `lib/airports.test.ts` (same style as the existing remote-rule test): every pair of non-large rows is at least `REMOTE_RADIUS_KM` apart (pairwise haversine, O(n²) — n≈42). The existing "no large airport within 1000 km" invariant stays and still holds. No runtime code changes in `lib/airports.ts`.
+7 -4
View File
@@ -12,11 +12,14 @@ import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, queuedRoutesGeoJso
import { weatherFor } from '../lib/weather';
import { approxLocalTime } from '../lib/localTime';
const EUROPE_CENTER: [number, number] = [10, 52];
const INITIAL_ZOOM = 4;
// v0.70.0 default view: null island [0,0] at zoom 2 — the world is 2048 px wide there and the
// Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator,
// so both continents are fully visible on a typical desktop map pane.
const WORLD_CENTER: [number, number] = [0, 0];
const INITIAL_ZOOM = 2;
// Flat 2D view only. Max zoom ≈ ~100 km across a typical desktop viewport width at
// European latitudes (~47 m/px at z11, 52°N); street-level zoom has no gameplay value.
export const MIN_ZOOM = 3;
export const MIN_ZOOM = 0; // v0.70.0: fully zoomed out the whole earth (512 px world) fits any viewport
export const MAX_ZOOM = 11;
const AIRPORTS_SOURCE_ID = 'airports';
@@ -218,7 +221,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const map = new maplibregl.Map({
container: containerRef.current,
style: styleUrlFor(initialId, available), // local pack or remote OFM fallback
center: EUROPE_CENTER,
center: WORLD_CENTER,
zoom: INITIAL_ZOOM,
minZoom: MIN_ZOOM,
maxZoom: MAX_ZOOM,
+7 -9
View File
@@ -162,8 +162,8 @@ async function unhover(page: Page): Promise<void> {
}
// Fit the map so all given airports are in-viewport — queryRenderedFeatures only returns
// features rendered inside the CURRENT viewport, and the default Frankfurt-centered view leaves
// far endpoints (LEMD) off-screen even when they match a layer filter.
// features rendered inside the CURRENT viewport, and the v0.70.0 default world view ([0,0] z2) leaves
// far endpoints (e.g. NFTF) off-screen even when they match a layer filter.
async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
@@ -321,8 +321,7 @@ test('hovering an open package highlights its endpoint airports', async ({ page
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// Both endpoints must be in-viewport for queryRenderedFeatures to see them (LEMD is off-screen
// at the default Frankfurt-centered view).
// Both endpoints must be in-viewport for queryRenderedFeatures to see them.
await fitToAirports(page, [HUB, DEST]);
expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0);
@@ -379,11 +378,10 @@ test('hovering an antimeridian-crossing package draws one continuous trace, no c
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport.
// EDDF→NFTF spans ~176° of longitude — it can't fit at MIN_ZOOM=3 (components/Map.tsx), so the camera
// clamps to z=3 and both endpoints are off-viewport → queryRenderedFeatures never sees the trace.
// Lift the floor for this test's camera; quantization stays ≤~0.02° even near z=0, below EPS (:214–228).
await page.evaluate(() => (window as any).__flightMap?.setMinZoom(0));
// fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport. EDDF→NFTF spans
// ~176° of longitude — fitting it needs z≈1, below the old MIN_ZOOM=3 floor; since v0.70.0 minZoom is
// natively 0 (components/Map.tsx), no setMinZoom workaround is needed anymore. Quantization stays
// ≤~0.02° even near z=0, below EPS (:214–228).
await fitToAirports(page, [HUB, 'NFTF']);
await row.hover();
+25 -4
View File
@@ -121,7 +121,7 @@ async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise
}
// Features in LOADED tiles only — MapLibre loads GeoJSON per viewport tile, so the camera must be over
// the area of interest first ([] until then). Same trap as run-176: Europe view + Pacific plane = [].
// the area of interest first ([] until then). Same trap as run-176: default world view + Pacific plane = [].
function sourceFeatures(page: Page, sourceId: string) {
return page.evaluate((srcId: string) => {
const m = (window as any).__flightMap;
@@ -155,7 +155,23 @@ test('airport popup shows name, ICAO, local time and generated weather', async (
{ timeout: 15_000 },
).toBeGreaterThan(0);
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts.
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts. The v0.70.0 default view
// ([0,0] z2) shows both Africa + Europe with overlapping markers (nearest neighbour LFRB Brest is
// only ~13–19 px away at z2), so frame LFRS at z5 first — its nearest marker is then ~104 px away.
await page.evaluate(() => {
(window as any).__flightMap.jumpTo({ center: [-1.61073, 47.153198], zoom: 5 });
});
// Wait until LFRS's marker is actually rendered at the new camera before clicking it.
await expect.poll(async () =>
page.evaluate(() => {
const map = (window as any).__flightMap;
const p = map.project([-1.61073, 47.153198]);
return (map.queryRenderedFeatures([p.x, p.y], { layers: ['airports-circles'] }) as Array<{ properties?: Record<string, unknown> }>)
.some((f) => f.properties?.icao === 'LFRS');
}),
{ timeout: 15_000 },
).toBe(true);
const pt = await page.evaluate(() => {
const map = (window as any).__flightMap;
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique
@@ -281,6 +297,11 @@ test('plane markers move while flying and the plane popup shows status + route',
}, { timeout: 15_000, message: 'waiting for the scale-1 clock to slow the plane to click-safe drift' })
.toBe(true);
// queryRenderedFeatures is viewport-scoped, and at the v0.70.0 default view ([0,0] z2) this pane
// only shows latitudes ~±51° — EDDF (50°N) sits right at its edge, so frame both endpoints of the
// leg to guarantee the plane marker is rendered wherever it is on it.
await fitToAirports(page, [HUB, DEST]);
// Click the RENDERED marker position, not a fresh /api/state fetch. With the clock at scale 1
// above the server's analytic position barely moves — but the map renders from its own ~1 s
// poll, so right after the freeze confirmation the last rendered frame can still be up to
@@ -500,8 +521,8 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
await setScale(page, SCALE);
console.log(`[antimeridian] AFTER dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`);
// querySourceFeatures only sees LOADED tiles — the default view is over Europe and this leg is in the
// Pacific: fit the camera over both endpoints first or the plane feature never appears in queries.
// querySourceFeatures only sees LOADED tiles — the v0.70.0 default view ([0,0] z2) doesn't cover this
// Pacific leg: fit the camera over both endpoints first or the plane feature never appears in queries.
await fitToAirports(page, [ORIGIN, CROSS_DEST]);
// Wait until SERVER state shows plane-starter flying on THIS specific leg (origin/dest pair match) AND
+48 -13
View File
@@ -8,9 +8,32 @@ const APP_URL = '/';
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')) as { version: string };
// Must match MIN_ZOOM / MAX_ZOOM exported from components/Map.tsx.
const MIN_ZOOM = 3;
const MIN_ZOOM = 0; // v0.70.0: lowered 3 → 0 so the whole earth is visible when fully zoomed out
const MAX_ZOOM = 11;
// LFRS Nantes Atlantique — the marker-click target of this file (and e2e/map-interactions.spec.ts,
// e2e/relocation.spec.ts). The v0.70.0 default view ([0,0] z2) shows both Africa + Europe with
// overlapping markers (nearest neighbour LFRB Brest is only ~13–19 px away at z2), so every click
// test first frames LFRS at z5 — its nearest marker is then ~104 px away and the projected center
// cannot hit a neighbouring circle.
const LFRS: [number, number] = [-1.61073, 47.153198];
async function frameLfrs(page: Page): Promise<void> {
await page.evaluate((c) => (window as any).__flightMap.jumpTo({ center: c, zoom: 5 }), LFRS);
// Wait until LFRS's marker is actually rendered at the new camera before clicking it. The
// coordinate is passed AS AN ARGUMENT — evaluate callbacks run in the browser, where Node-scope
// values don't exist (same trap as e2e/relocation.spec.ts).
await expect.poll(async () =>
page.evaluate((c) => {
const map = (window as any).__flightMap;
const p = map.project(c);
return (map.queryRenderedFeatures([p.x, p.y], { layers: ['airports-circles'] }) as Array<{ properties?: Record<string, unknown> }>)
.some((f) => f.properties?.icao === 'LFRS');
}, LFRS),
{ timeout: 15_000 },
).toBe(true);
}
async function waitForAirportCount(page: Page): Promise<number> {
const countEl = page.getByTestId('airport-count');
await expect.poll(async () => {
@@ -27,6 +50,16 @@ test('page loads with the map and world airport count', async ({ page }) => {
await expect(page.getByTestId('map')).toBeVisible();
const count = await waitForAirportCount(page);
expect(count).toBeGreaterThan(0);
// v0.70.0 default view: null island [0,0] at zoom 2 (Africa + Europe both visible). The map
// instance exists by the time the airport count renders — it is created before airports load.
const view = await page.evaluate(() => {
const m = (window as any).__flightMap;
return { lng: m.getCenter().lng, lat: m.getCenter().lat, z: m.getZoom() };
});
expect(view.z).toBeCloseTo(2);
expect(view.lng).toBeCloseTo(0);
expect(view.lat).toBeCloseTo(0);
});
test('header shows the app version sourced from package.json', async ({ page }) => {
@@ -56,18 +89,15 @@ test('clicking an airport marker opens a popup with name and ICAO', async ({ pag
{ timeout: 15_000 },
).toBeGreaterThan(0);
// Project LFRS (Nantes Atlantique) to screen space via the dev hook, then click it.
// Nantes is used because the v0.9.0 three-pane layout narrows the map column (~560 px at the
// default 1280×720 viewport), which puts most of the world outside the initial view; LFRS stays
// isolated in it even with the v0.44.0 world dataset — its nearest markers are LFRB Brest and
// LFBD Bordeaux, ~39 px away at the initial zoom [10,52] z4 (verified against data/airports_world.csv),
// so its marker cannot be overlapped by a neighbouring circle.
const pt = await page.evaluate(() => {
// Frame LFRS (Nantes Atlantique) so its marker is isolated again — see frameLfrs above for why
// the v0.70.0 default view needs this — then project it to screen space via the dev hook and click.
await frameLfrs(page);
const pt = await page.evaluate((c) => {
const map = (window as any).__flightMap;
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why
const p = map.project(c); // LFRS Nantes Atlantique — see frameLfrs above for why it's framed at z5
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
});
}, LFRS);
await page.mouse.click(pt.x, pt.y);
const popup = page.getByTestId('airport-popup');
@@ -189,6 +219,9 @@ test('style switcher defaults to Fiord, switches styles, and keeps the airports
await expect(page.getByTestId('airport-count')).toContainText(/world airports/);
await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0);
// Frame LFRS at z5 first — see frameLfrs above for why the v0.70.0 default view needs this.
await frameLfrs(page);
// Clicking a marker after the switch opens exactly one popup (the click handler is
// re-wired idempotently — no duplicate listeners). MapLibre's diff-based setStyle removes our
// custom source/layer before ensureAirportsLayer re-adds it on the next styledata event, so
@@ -258,12 +291,14 @@ test('falls back to remote OFM styles when the local asset pack is absent', asyn
{ timeout: 15_000 },
).toBeGreaterThan(0);
const pt = await page.evaluate(() => {
// Frame LFRS at z5 first — see frameLfrs above for why the v0.70.0 default view needs this.
await frameLfrs(page);
const pt = await page.evaluate((c) => {
const map = (window as any).__flightMap;
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why
const p = map.project(c); // LFRS Nantes Atlantique — see frameLfrs above for why it's framed at z5
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
});
}, LFRS);
await page.mouse.click(pt.x, pt.y);
const popup = page.getByTestId('airport-popup');
+25 -2
View File
@@ -12,10 +12,27 @@ import { expect, test } from '@playwright/test';
const SCALE = 1000; // test rate for fast-forwarding legs — keeps cleanup flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts: its nearest neighbour is ~78 px
// away at the initial zoom, so a click on its projected center cannot hit another circle.
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts. The v0.70.0 default view ([0,0] z2)
// shows both Africa + Europe with overlapping markers (LFRS's nearest neighbour LFRB Brest is only
// ~13–19 px away at z2), so framePopupAirport below frames it at z5 first — its nearest marker is then
// ~104 px away and a click on the projected center cannot hit another circle.
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
async function framePopupAirport(page: Page): Promise<void> {
await page.evaluate((a) => (window as any).__flightMap.jumpTo({ center: [a.lon, a.lat], zoom: 5 }), POPUP_AIRPORT);
// Wait until the marker is actually rendered at the new camera before clicking it. The airport is
// passed AS AN ARGUMENT — evaluate callbacks run in the browser, where Node-scope values don't exist.
await expect.poll(async () =>
page.evaluate((a) => {
const map = (window as any).__flightMap;
const p = map.project([a.lon, a.lat]);
return (map.queryRenderedFeatures([p.x, p.y], { layers: ['airports-circles'] }) as Array<{ properties?: Record<string, unknown> }>)
.some((f) => f.properties?.icao === a.icao);
}, POPUP_AIRPORT),
{ timeout: 15_000 },
).toBe(true);
}
interface StateBody {
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>;
}
@@ -161,6 +178,9 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
{ timeout: 15_000 },
).toBe(true);
// Frame the popup airport at z5 first — see framePopupAirport above for why the v0.70.0 default view needs this.
await framePopupAirport(page);
// Click the LFRS marker (projected center — same pattern as e2e/map.spec.ts). The constant is
// passed AS AN ARGUMENT: the evaluate callback runs in the browser, where Node-scope values don't exist.
const pt = await page.evaluate(({ lon, lat }) => {
@@ -241,6 +261,9 @@ test('airport popup Relocate queues a leg for a flying plane', async ({ page })
{ timeout: 15_000 },
).toBe(true);
// Frame the popup airport at z5 first — see framePopupAirport above for why the v0.70.0 default view needs this.
await framePopupAirport(page);
const pt = await page.evaluate(({ lon, lat }) => {
const map = (window as any).__flightMap;
const p = map.project([lon, lat]);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.68.0",
"version": "0.70.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",