Compare commits
1
Commits
v0.73.0
...
eef305aa27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef305aa27 |
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.68.0",
|
||||
"version": "0.70.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user