diff --git a/CHANGELOG.md b/CHANGELOG.md index f24cb16..1c3d6df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.26.0 — 2026-09-20 11:28 +- **Flight queue UI + map rendering (ISS-043, part 2).** Builds on the part-1 backend (`PlaneState.queue`, `POST /api/planes/{id}/queue` + `/queue/cancel`, auto-chain on landing) — no new endpoints, so the OpenAPI spec and its e2e path list are untouched +- **Dotted queue line on the map (ISS-043).** New pure builder `queuedRoutesGeoJson(planes, airportsByIcao)` in `lib/mapData.ts`: one LineString per FLYING plane with a non-empty queue, chaining `dest → queue[0] → …` — ONLY the not-yet-started legs (the active leg stays in the solid `routes-legs` layer). Reuses `greatCircleArc`, same feature/properties shape as `routesGeoJson` (`{planeId}`); arcs concatenated with the shared joint point dropped once; a plane is skipped, never guessed, when its dest or any queued ICAO is missing from the airport index. New GeoJSON source + line layer in `components/Map.tsx` — id `queue-routes`, muted slate `#64748b`, width 3, dotted via `'line-dasharray': [0.1, 1.5]` (distinct from the solid amber active leg and the `[2,2]` yellow hover dash), added after `routes-legs` but before the hover layers so a hovered trace still draws on top; pushed each poll in `applyDynamicRef` alongside the solid legs +- **Queue sub-section in the left pane's On board block (ISS-043).** Each on-board plane block now carries a "Queue" sub-section after its cargo rows: one row per queued leg (`queue-leg-{i}`) showing destination name + ICAO with a Cancel button (`queue-cancel-{i}` → `POST …/queue/cancel {index:i}`), plus an add control — airport ` setQueueAddDestIcao(e.target.value || null)} + > + + {airportOptions?.map((a) => ( + + ))} + + + + ); }) diff --git a/components/Map.tsx b/components/Map.tsx index 851d7ed..c0a925c 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -8,7 +8,7 @@ import { DEFAULT_STYLE_ID, STYLES, STYLE_STORAGE_KEY, isStyleId, localAssetsAvai import type { GeoJsonFeatureCollection } from '../lib/airports'; import type { StateSnapshot } from '../lib/game/state'; import type { HoverTarget } from './hoverTypes'; -import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData'; +import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, queuedRoutesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData'; import { weatherFor } from '../lib/weather'; import { approxLocalTime } from '../lib/localTime'; @@ -28,6 +28,10 @@ const PLANES_GROUNDED_LAYER_ID = 'planes-grounded'; const PLANES_FLYING_LAYER_ID = 'planes-flying'; const ROUTES_SOURCE_ID = 'routes'; const ROUTES_LEGS_LAYER_ID = 'routes-legs'; +// ISS-043 (v0.26.0): the dotted queue line — a flying plane's NOT-yet-started legs +// (dest → queue[0] → …) as their own source + layer, drawn under everything else dynamic. +const QUEUE_ROUTES_SOURCE_ID = 'queue-routes'; +const QUEUE_ROUTES_LAYER_ID = 'queue-routes'; // ISS-002 (v0.15.0): top-most hover-highlight layers, added after the base layers so they // render above them. Each starts filtered to match nothing; applyHoverRef drives the filters. const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight'; @@ -95,6 +99,11 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) { if (routesSource && airportsIndexRef.current) { void routesSource.setData(routesGeoJson(st, airportsIndexRef.current)); } + // ISS-043 (v0.26.0): the dotted queue line — same airport-index guard as the solid legs above. + const queueRoutesSource = map.getSource(QUEUE_ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined; + if (queueRoutesSource && airportsIndexRef.current) { + void queueRoutesSource.setData(queuedRoutesGeoJson(st.planes, airportsIndexRef.current)); + } }; useEffect(() => { @@ -357,6 +366,27 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) { }, }); } + // ISS-043 (v0.26.0): dotted queue line — added after routes-legs but BEFORE the hover + // layers so a hovered trace still draws on top of it. Muted slate + fine dots reads as + // "planned/future", distinct from the solid amber active leg and the [2,2] yellow hover dash. + if (!map.getSource(QUEUE_ROUTES_SOURCE_ID)) { + map.addSource(QUEUE_ROUTES_SOURCE_ID, { + type: 'geojson', + data: queuedRoutesGeoJson(stateRef.current?.planes ?? [], airportsIndexRef.current ?? new Map()), + }); + } + if (!map.getLayer(QUEUE_ROUTES_LAYER_ID)) { + map.addLayer({ + id: QUEUE_ROUTES_LAYER_ID, + type: 'line', + source: QUEUE_ROUTES_SOURCE_ID, + paint: { + 'line-color': '#64748b', // muted slate — planned legs, not actively flying + 'line-width': 3, + 'line-dasharray': [0.1, 1.5], // dotted — distinct from solid legs and the [2,2] hover dash + }, + }); + } if (!map.getSource(PLANES_SOURCE_ID)) { map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current) }); } diff --git a/e2e/queue.spec.ts b/e2e/queue.spec.ts new file mode 100644 index 0000000..18fc5aa --- /dev/null +++ b/e2e/queue.spec.ts @@ -0,0 +1,384 @@ +import { test, expect, type Page } from '@playwright/test'; + +// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a +// Queue sub-section of its on-board block (add via airport select + button, cancel per leg), and +// a flying plane with queued legs draws them as a DOTTED line (`queue-routes` layer) chaining +// dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts +// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API. + +const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time +const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts +const DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000) +const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and destination + +interface PlaneState { + id: string; + status: 'available' | 'flying'; + locationIcao: string; + destIcao: string | null; + queue: string[]; +} + +interface PackageState { + id: string; + originIcao: string; + destIcao: string; + status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; + planeId: string | null; +} + +interface StateBody { + account: { cash: number; reputation: number }; + simState: { clockMs: number; timeScale: number; paused: boolean }; + planes: PlaneState[]; + packages: PackageState[]; +} + +async function getState(page: Page): Promise { + const res = await page.request.get('/api/state'); + expect(res.status()).toBe(200); + return (await res.json()) as StateBody; +} + +async function postSim(page: Page, body: unknown) { + return page.request.post('/api/sim', { data: body }); +} + +// Set the time scale and VERIFY from the response that it stuck. ISS-041 also clears pause: a +// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen. +async function setScale(page: Page, scale: number): Promise { + const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } }); + expect(res.status()).toBe(200); + const body = (await res.json()) as { timeScale: number }; + expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); +} + +// Leave the live instance in a sane state for the other specs (scale 1, running). +test.afterEach(async ({ page }) => { + const res = await postSim(page, { timeScale: 1, paused: false }); + expect(res.status()).toBe(200); +}); + +// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. +async function createPackage(page: Page, fromIcao: string, toIcao: string): Promise { + const created = await page.request.post('/api/test/create-package', { + data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 }, + }); + expect(created.status()).toBe(201); + return (await created.json()) as PackageState; +} + +// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon +// leftover cargo so every test starts from the canonical state even after an interrupted run. +async function flyHomeViaApi(page: Page): Promise { + let s = await getState(page); + let plane = s.planes.find((p) => p.id === 'plane-starter')!; + if (plane.status === 'flying' || plane.locationIcao !== HUB) { + // Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×). + await setScale(page, SCALE); + } + if (plane.status === 'flying') { + // While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing. + await expect + .poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? ''), { + timeout: 240_000, + message: 'waiting for an in-flight plane-starter to land', + }) + .toBe('available'); + s = await getState(page); + plane = s.planes.find((p) => p.id === 'plane-starter')!; + } + if (plane.locationIcao !== HUB) { + // ISS-041: the landing above paused the sim — setScale clears pause so the return leg fast-forwards. + await setScale(page, SCALE); + const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } }); + expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight) + await expect + .poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { + timeout: 240_000, + message: 'waiting for plane-starter to land back at the hub', + }) + .toBe(HUB); + } + // Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup). + const s1 = await getState(page); + for (const pkg of s1.packages) { + if (pkg.planeId === 'plane-starter' && (pkg.status === 'loaded' || pkg.status === 'expired')) { + const res = await page.request.post(`/api/packages/${pkg.id}/abandon`); + expect(res.ok()).toBeTruthy(); + } + } +} + +async function waitForAirportCount(page: Page): Promise { + 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); +} + +// Rendered features of the given layers in the current viewport, [] until the map exists. +function renderedFeatures( + page: Page, + layers: string[], +): Promise> { + return page.evaluate((ls: string[]) => { + const m = (window as any).__flightMap; + if (!m) return []; + try { + // queryRenderedFeatures returns live GeoJSONFeature instances whose `geometry` is a lazy + // PROTOTYPE getter — Playwright's evaluate serialization copies only own enumerable props, so + // geometry silently vanishes across the page boundary. Map to plain objects IN-PAGE (ORCHESTRATOR §6). + return (m.queryRenderedFeatures(undefined, { layers: ls }) as Array<{ + type?: unknown; + id?: unknown; + properties?: unknown; + geometry?: unknown; + }>).map((f) => ({ + type: f.type, + id: f.id, + properties: f.properties, + geometry: f.geometry, + })); + } catch { + return []; + } + }, layers); +} + +// Fit the map so all given airports are in-viewport — queryRenderedFeatures only returns features +// rendered inside the CURRENT viewport (same helper as e2e/hover.spec.ts). +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 } }>; + }; + const points = icaos.map((icao) => { + const f = fc.features.find((x) => x.properties.icao === icao); + if (!f) throw new Error(`airport ${icao} not found in /api/airports`); + return [f.properties.lon, f.properties.lat] as [number, number]; + }); + await page.evaluate((pts: [number, number][]) => { + const m = (window as any).__flightMap; + if (!m) throw new Error('map not ready'); + const lons = pts.map((p) => p[0]); + const lats = pts.map((p) => p[1]); + // duration 0 → instant camera jump, no animation to wait out. + m.fitBounds( + [ + [Math.min(...lons), Math.min(...lats)], + [Math.max(...lons), Math.max(...lats)], + ], + { padding: 40, duration: 0 }, + ); + }, points); + // Give the renderer a beat after the camera jump before asserting on rendered features. + await page.waitForTimeout(300); +} + +// [lon, lat] of an airport from the same /api/airports dataset the map draws its lines from. +async function airportLonLat(page: Page, icao: string): Promise<[number, number]> { + const fc = (await (await page.request.get('/api/airports')).json()) as { + features: Array<{ properties: { icao: string; lat: number; lon: number } }>; + }; + const f = fc.features.find((x) => x.properties.icao === icao); + if (!f) throw new Error(`airport ${icao} not found in /api/airports`); + return [f.properties.lon, f.properties.lat]; +} + +// MapLibre stores GeoJSON geometry in per-tile INTEGER grids and clips long lines across tiles, so +// queryRenderedFeatures returns ONE FEATURE PER TILE the line crosses with coordinates quantized to +// the grid — check endpoints ACROSS all segments with an EPS (same pattern as expectHoverRoute in +// e2e/hover.spec.ts). ~5 km: above worst-case quantization at any zoom, below the distance between +// any two EU airports. +const QUEUE_ROUTE_EPS_DEG = 0.05; + +test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file + +test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => { + test.setTimeout(300_000); // self-heal can take minutes after an interrupted run + await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF + + // Full tank before departure — keeps the long EDDF→LEMD leg fuel-feasible (same as tests 2 & 3). + const refuel = await page.request.post('/api/planes/plane-starter/refuel'); + expect(refuel.status()).toBe(200); + + const pkg = await createPackage(page, HUB, DEST); + + await page.goto('/'); + await waitForAirportCount(page); + + // LOAD via the UI (right-pane job list) — the starter is grounded at the package's origin. + const loadBtn = page.getByTestId(`load-${pkg.id}`); + await expect(loadBtn, 'Load button enabled for a package at the hub').toBeEnabled({ timeout: 15_000 }); + await loadBtn.click(); + const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`); + await expect(onboardRow, 'loaded package appears in the On board section').toBeVisible({ timeout: 15_000 }); + + // LeftPane's own /api/airports fetch populates the queue-add select — wait for it. + const addSelect = page.getByTestId('queue-add-dest'); + await expect(addSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 }); + + // START leg 1 via the UI (fly-to button in the on-board block). + const flyBtn = page.getByTestId(`fly-to-${pkg.id}`); + await expect(flyBtn).toBeEnabled(); + await flyBtn.click(); + // The click fires the fly POST asynchronously — poll until it took effect server-side, then FREEZE + // immediately: at scale 1000 (left over from self-heal) the ~10 s leg would otherwise land under us. + await expect.poll(async () => { + const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); + return !!p && p.status === 'flying' && p.destIcao === DEST; + }, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true); + await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks + + // ADD a leg to the queue via the UI. The button is disabled until an airport is selected… + const addButton = page.getByTestId('queue-add'); + await expect(addButton).toBeDisabled(); + await addSelect.selectOption(QUEUE); + await expect(addButton).toBeEnabled(); + await addButton.click(); + + // …and the new leg appears as queue-leg-0 with its destination. + const legRow = page.getByTestId('queue-leg-0'); + await expect(legRow, 'the queued leg appears in the Queue sub-section').toContainText(QUEUE, { timeout: 15_000 }); + + // CANCEL it via the UI and assert it's gone. + await page.getByTestId('queue-cancel-0').click(); + await expect(page.getByTestId('queue-leg-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 }); + + // Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home. + await setScale(page, SCALE); + await expect.poll(async () => { + const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); + return !!p && p.status === 'available' && p.locationIcao === DEST; + }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true); + + // ISS-041: the arrival above paused the sim — setScale clears pause for the return leg. + await setScale(page, SCALE); + const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); + expect(home.status()).toBe(200); + await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { + timeout: 120_000, + message: 'waiting for plane-starter to land back at the hub', + }).toBe(HUB); +}); + +test('dotted queue line renders the queued path on the map (ISS-043 part 2)', async ({ page }) => { + test.setTimeout(300_000); // self-heal + one flight at scale 1000 + fly-home cleanup + await flyHomeViaApi(page); + + // Full tank before departure — keeps the state canonical for whatever the cleanup does. + const refuel = await page.request.post('/api/planes/plane-starter/refuel'); + expect(refuel.status()).toBe(200); + + // Load + queue + fly via the API: a package EDDF→LEMD, one queued leg LEMD→EHAM (works while + // grounded — part 1), then start leg 1. The dotted line must show ONLY dest → queue[0]. + const pkg = await createPackage(page, HUB, DEST); + const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } }); + expect(load.status()).toBe(200); + const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } }); + expect(queueRes.status()).toBe(200); + const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } }); + expect(flyRes.status()).toBe(200); + // The endpoint's OWN post-action snapshot already shows the plane flying — no poll race. + const flyBody = (await flyRes.json()) as StateBody; + expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying'); + + // Freeze mid-flight so the ~10 s leg can't land under us while we assert. + await postSim(page, { paused: true }); + + await page.goto('/'); + await waitForAirportCount(page); + // All line endpoints must be in-viewport for queryRenderedFeatures to see them. + await fitToAirports(page, [DEST, QUEUE]); + + const destLL = await airportLonLat(page, DEST); + const queueLL = await airportLonLat(page, QUEUE); + const hubLL = await airportLonLat(page, HUB); + const near = (a: [number, number], b: [number, number]) => + Math.abs(a[0] - b[0]) <= QUEUE_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= QUEUE_ROUTE_EPS_DEG; + + await expect.poll(async () => { + const feats = (await renderedFeatures(page, ['queue-routes'])) as Array<{ + properties?: Record; + geometry?: { type: string; coordinates: [number, number][] }; + }>; + // Filter to real LineString segments — keeps this poll callback total (a throw inside + // expect.poll fails the test instantly instead of retrying). "None yet" → keep polling. + const segs = feats.filter( + (f): f is { properties?: Record; geometry: { type: 'LineString'; coordinates: [number, number][] } } => + !!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2, + ); + if (!segs.length) return false; + // The line may be clipped into several per-tile segments — check the endpoints across all of them: + // some segment STARTS within EPS of DEST (the active leg's destination, NOT the plane's origin)… + const startsAtDest = segs.some((f) => near(f.geometry.coordinates[0], destLL)); + // …and some ENDS within EPS of the queued airport. + const endsAtQueue = segs.some((f) => { + const c = f.geometry.coordinates; + return near(c[c.length - 1], queueLL); + }); + // The active leg (HUB→DEST) must NOT be in this layer — no segment may start at the hub. + const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLL)); + // Every rendered feature belongs to plane-starter (the only queued plane). + const ownedByStarter = feats.every((f) => f.properties?.planeId === 'plane-starter'); + return startsAtDest && endsAtQueue && noneStartsAtHub && ownedByStarter; + }, { timeout: 15_000, message: `waiting for the dotted queue line ${DEST} → ${QUEUE}` }).toBe(true); + + // Cleanup: cancel the queued leg (so landing at DEST is a plain grounding), resume + fast-forward + // to arrival, then fly home. + const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); + expect(cancelRes.status()).toBe(200); + await setScale(page, SCALE); + await expect.poll(async () => { + const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); + return !!p && p.status === 'available' && p.locationIcao === DEST; + }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true); + + // ISS-041: the arrival above paused the sim — setScale clears pause for the return leg. + await setScale(page, SCALE); + const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); + expect(home.status()).toBe(200); + await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { + timeout: 120_000, + message: 'waiting for plane-starter to land back at the hub', + }).toBe(HUB); +}); + +test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async ({ page }) => { + test.setTimeout(300_000); // self-heal + two chained legs at scale 1000 + await flyHomeViaApi(page); + + // Full tank so fuel covers the chained return leg (same feasibility rule as /fly). + const refuel = await page.request.post('/api/planes/plane-starter/refuel'); + expect(refuel.status()).toBe(200); + + // Package EDDF→LEMD + a queued return leg LEMD→EDDF, then start leg 1. + const pkg = await createPackage(page, HUB, DEST); + const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } }); + expect(load.status()).toBe(200); + const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: HUB } }); + expect(queueRes.status()).toBe(200); + const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } }); + expect(flyRes.status()).toBe(200); + + // Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued + // return leg — no manual dispatch. The chained state persists for the whole ~10 s return leg, so a + // poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO. + await setScale(page, SCALE); + await expect.poll(async () => { + const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); + return !!p && p.status === 'flying' && p.destIcao === HUB; + }, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${HUB} to start` }).toBe(true); + + // Cargo delivery ran BEFORE chaining (part 1 ordering) — the package is already delivered. + const s = await getState(page); + expect(s.packages.find((x) => x.id === pkg.id)?.status).toBe('delivered'); + + // Let the chained leg finish: it lands back at the hub, leaving the canonical onboarding state. + await expect.poll(async () => { + const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); + return !!p && p.status === 'available' && p.locationIcao === HUB; + }, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true); +}); diff --git a/lib/mapData.test.ts b/lib/mapData.test.ts index 92304c0..eac448e 100644 --- a/lib/mapData.test.ts +++ b/lib/mapData.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { StateSnapshot } from './game/state'; -import { planesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData'; +import { planesGeoJson, queuedRoutesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData'; import { haversineKm } from './geo'; const AIRPORTS = new Map([ @@ -140,6 +140,69 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)', }); }); +describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → queued legs only)', () => { + it('draws nothing for a grounded plane, even with a non-empty queue', () => { + const fc = queuedRoutesGeoJson( + [ + { id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: ['EHAM'] }, + ], + AIRPORTS, + ); + expect(fc.features).toHaveLength(0); + }); + + it('draws nothing for a flying plane with an empty queue', () => { + const fc = queuedRoutesGeoJson( + [ + { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: [] }, + ], + AIRPORTS, + ); + expect(fc.features).toHaveLength(0); + }); + + it('flying plane with a 2-leg queue → one feature chaining dest → q0 → q1 with exact endpoints', () => { + const fc = queuedRoutesGeoJson( + [ + // Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD. + { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['EDDV', 'LEMD'] }, + ], + AIRPORTS, + ); + expect(fc.features).toHaveLength(1); + expect(fc.features[0].properties).toEqual({ planeId: 'p1' }); + const coords = fc.features[0].geometry.coordinates; + // Two concatenated arcs with the shared joint point dropped once: n_legs × SEGMENTS + 1 points. + expect(coords.length).toBe(2 * ROUTE_ARC_SEGMENTS + 1); + expect(coords[0]).toEqual([4.76, 52.31]); // starts at DEST (EHAM), not at the plane's origin + expect(coords[coords.length - 1]).toEqual([-3.56, 40.47]); // ends at the last queued airport (LEMD) + expect(coords[ROUTE_ARC_SEGMENTS]).toEqual([9.68, 52.46]); // joint point = first queued airport (EDDV), exact + }); + + it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => { + const fc = queuedRoutesGeoJson( + [ + { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, queue: ['EHAM'] }, + { id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['ZZZZ'] }, + ], + AIRPORTS, + ); + expect(fc.features).toHaveLength(0); + }); + + it('one feature per qualifying plane — grounded and empty-queue planes draw nothing', () => { + const fc = queuedRoutesGeoJson( + [ + { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['LEMD'] }, + { id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, queue: [] }, + { id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, queue: ['LEMD'] }, + ], + AIRPORTS, + ); + expect(fc.features.map((f) => f.properties.planeId)).toEqual(['p1']); + }); +}); + describe('hoverRouteGeoJson (ISS-022/025/027 v0.22.0 — hover route trace)', () => { it('returns an empty collection for the no-hover "" sentinel', () => { expect(hoverRouteGeoJson('', '', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] }); diff --git a/lib/mapData.ts b/lib/mapData.ts index f6ba2d7..15ee945 100644 --- a/lib/mapData.ts +++ b/lib/mapData.ts @@ -1,4 +1,4 @@ -import type { StateSnapshot } from './game/state'; +import type { PlaneState, StateSnapshot } from './game/state'; import { formatFuel } from './format'; import { positionAt } from './sim/movement'; @@ -164,3 +164,31 @@ export function routesGeoJson( } return { type: 'FeatureCollection', features }; } + +// ISS-043 (v0.26.0): the dotted queue line — one LineString per FLYING plane with a non-empty +// queue, chaining dest → queue[0] → … → queue[n−1]: ONLY the not-yet-started legs (the active +// leg locationIcao→dest stays in routesGeoJson's solid layer). Arcs are concatenated with the +// shared joint point dropped once. A plane is skipped — never guessed — when its dest or any +// queued ICAO is missing from the airport index; empty collection when nothing qualifies. +export function queuedRoutesGeoJson( + planes: PlaneState[], + airportsByIcao: ReadonlyMap, +): FeatureCollection { + const features: MapLineFeature[] = []; + for (const p of planes) { + if (p.status !== 'flying' || !p.destIcao || p.queue.length === 0) continue; + const pts = [p.destIcao, ...p.queue].map((icao) => airportsByIcao.get(icao)); + if (pts.some((c) => c === undefined)) continue; // an endpoint is missing from the dataset — skip + const coords: [number, number][] = []; + for (let i = 0; i < pts.length - 1; i++) { + const arc = greatCircleArc(pts[i]!, pts[i + 1]!); + coords.push(...(i === 0 ? arc : arc.slice(1))); // drop the duplicated joint point + } + features.push({ + type: 'Feature', + geometry: { type: 'LineString', coordinates: coords }, + properties: { planeId: p.id }, + }); + } + return { type: 'FeatureCollection', features }; +} diff --git a/package.json b/package.json index f467ce1..3618029 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.25.0", + "version": "0.26.0", "private": true, "scripts": { "dev": "next dev -p 3000",