From fb369a1afb13a0b04a2355522b39c2a6eaf6733e Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 23 Sep 2026 22:53:21 +0000 Subject: [PATCH 1/2] =?UTF-8?q?v0.70.0:=20default=20map=20view=20=E2=80=94?= =?UTF-8?q?=20world=20at=20[0,0]=20zoom=202,=20minZoom=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 +++ components/Map.tsx | 11 ++++--- e2e/hover.spec.ts | 16 +++++----- e2e/map-interactions.spec.ts | 29 ++++++++++++++--- e2e/map.spec.ts | 61 ++++++++++++++++++++++++++++-------- e2e/relocation.spec.ts | 27 ++++++++++++++-- package.json | 2 +- 7 files changed, 117 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84a4c21..d79cbb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.69.0 — 2026-09-23 21:54 - **Job ignore removed end-to-end.** The "Ignore" button on open jobs (v0.29.0, ISS-047 part B) is gone with everything behind it: the `POST /api/packages/{id}/ignore` route, the `ignorePackage` action, and `'ignored'` from the package status enum (schema, snapshot type, OpenAPI — now twelve endpoints). Packages can only be loaded, delivered, expired, or abandoned. - **Legacy 'ignored' rows self-heal.** Production databases already contain rows with `status='ignored'`; nothing ever cleaned them up (`expireOpenPackages` only selected `'open'`). The sweep now selects `IN ('open','ignored')` and flips any row past `createdRealMs + JOB_LIFETIME_MS` to `'expired'`, so every legacy row heals on the first tick after its lifetime lapses (in practice: the first tick after deploy) and no new ones can ever be created. Reads before that first tick are safe: Drizzle does no runtime validation, nothing renders a non-open/loaded/expired package, and `GET /api/state` ticks first anyway. diff --git a/components/Map.tsx b/components/Map.tsx index eb13bbe..7d868f3 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -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, diff --git a/e2e/hover.spec.ts b/e2e/hover.spec.ts index f646c76..3c9efe3 100644 --- a/e2e/hover.spec.ts +++ b/e2e/hover.spec.ts @@ -162,8 +162,8 @@ async function unhover(page: Page): Promise { } // 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 { 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(); diff --git a/e2e/map-interactions.spec.ts b/e2e/map-interactions.spec.ts index ec0bc76..45126d2 100644 --- a/e2e/map-interactions.spec.ts +++ b/e2e/map-interactions.spec.ts @@ -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 }>) + .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 diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index 04e768f..2c0c981 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -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 { + 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 }>) + .some((f) => f.properties?.icao === 'LFRS'); + }, LFRS), + { timeout: 15_000 }, + ).toBe(true); +} + async function waitForAirportCount(page: Page): Promise { 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'); diff --git a/e2e/relocation.spec.ts b/e2e/relocation.spec.ts index 709f1e0..54686b9 100644 --- a/e2e/relocation.spec.ts +++ b/e2e/relocation.spec.ts @@ -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 { + 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 }>) + .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]); diff --git a/package.json b/package.json index 91f52ab..ad41b7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.69.0", + "version": "0.70.0", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0 From 0aca4d63aa99df2f1a9314dba78d97a4847fca4a Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 23 Sep 2026 23:35:22 +0000 Subject: [PATCH 2/2] v0.70.0: e2e wheel-clamp test asserts MapLibre height fit floor --- e2e/map.spec.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index 2c0c981..b4df140 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -168,12 +168,18 @@ test('wheel zoom is clamped between min and max zoom', async ({ page }) => { await waitForStableZoom(); expect(await getZoom()).toBeCloseTo(MAX_ZOOM); - // Zoom out well below MIN_ZOOM with several large deltas. - await jumpTo(MIN_ZOOM + 1); - await wheelToLimit(600, (z) => z <= MIN_ZOOM + 1e-9); + // Zoom out to the effective minimum. MapLibre won't zoom out past the point where + // the world no longer covers the viewport height (fit floor = log2(h/512)), so with + // MIN_ZOOM=0 the reachable limit is max(MIN_ZOOM, fitFloor) — "just far enough to see + // the whole Earth", which is exactly what min zoom should give. + const mapBox = await page.getByTestId('map').boundingBox(); + if (!mapBox) throw new Error('map bounding box unavailable'); + const minLimit = Math.max(MIN_ZOOM, Math.log2(mapBox.height / 512)); + await jumpTo(minLimit + 1); + await wheelToLimit(600, (z) => z <= minLimit + 1e-9); await waitForStableZoom(); const zoom = await getZoom(); - expect(zoom).toBeCloseTo(MIN_ZOOM); + expect(zoom).toBeCloseTo(minLimit); expect(zoom).toBeGreaterThanOrEqual(MIN_ZOOM); expect(zoom).toBeLessThanOrEqual(MAX_ZOOM); }); -- 2.54.0