diff --git a/CHANGELOG.md b/CHANGELOG.md index 9135cee..f4d6ddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - **Hover route trace on job-list rows (ISS-022).** Hovering an open package in the right pane now draws the ENTIRE great-circle trace between origin and destination, not just the two endpoint rings. Zero pane edits — the rows already sent `{kind:'package', originIcao, destIcao}`; `components/Map.tsx` now derives from/to for any hover target carrying two ICAOs and pushes a new pure builder's output (`hoverRouteGeoJson(fromIcao, toIcao, airportsByIcao)` in `lib/mapData.ts`: one LineString via the existing `greatCircleArc` slerp, empty FeatureCollection when either endpoint is missing from the airport index or the `''` no-hover sentinel) into a NEW dedicated `hover-routes` GeoJSON source + top-most dashed `hover-route` LineLayer (paint: `#facc15` matching the airports-highlight ring, width 4, `line-dasharray [2,2]`). The shared `routes` source can't carry this — it only holds FLYING legs, so an open package's or a grounded relocation's planned route was never in it. The layer is added in `ensureLayers` after the highlight layers so style switches keep it; `applyHoverRef` re-pushes data on every hover - **Same trace on On-board cargo rows (ISS-025).** Each `onboard-package-{id}` row in the left pane's On board section now reports `{kind:'package', originIcao, destIcao, planeId: plane.id}` on mouseenter — same full trace as job-list rows PLUS the carrying plane's halo stays lit via the new optional `planeId` field (Map uses `h.planeId ?? ''` for the planes/routes-highlight filters). Mouseleave restores the block's plain `{kind:'plane', id}` highlight; leaving the whole block still ends at null because React fires the child's mouseleave before the parent's - **Relocate panel hover previews the planned flight (ISS-027).** The left pane's Relocate section gains `data-testid="relocate-section"` + enter/leave handlers: with a grounded plane AND destination selected it reports `{kind:'route', fromIcao: relocatePlane.locationIcao, toIcao: relocateDest.icao}` (else null), and Map draws the planned trace AND rings ONLY the target airport in airports-highlight (origin stays unmarked — the destination is what the issue asks to see). `HoverTarget` (`components/hoverTypes.ts`) extends to `{kind:'plane';id} | {kind:'package';originIcao;destIcao;planeId?} | {kind:'route';fromIcao;toIcao}`. Existing semantics unchanged: plane hovers filter the three highlight layers exactly as before, package hovers keep BOTH endpoint rings (now plus the trace) +- **Target airport highlighted in a distinct color on job-list / relocation hover (ISS-022).** The destination of any two-endpoint hover now gets its own ring in a NEW top-most `airports-target-highlight` circle layer (`components/Map.tsx`, same `airports` source and r=9 transparent-fill geometry as airports-highlight, but green `#4ade80` stroke width 3 vs the yellow `#facc15` endpoint rings) — added last in `ensureLayers` so it also sits above the hover-route line at its endpoint; `applyHoverRef` filters it to the destination icao for BOTH `'package'` and `'route'` hovers (empty-string sentinel otherwise). airports-highlight itself is untouched, so the existing both-endpoints / target-only assertions stay valid - Tests: unit — new `describe('hoverRouteGeoJson …')` in `lib/mapData.test.ts`: no-hover sentinel → empty; two known airports → one arc with exact dataset endpoints; reversed-input symmetry; missing from/to ICAO → empty (baseline 283 → 288). e2e (`e2e/hover.spec.ts`) — open-package test extended with hover-route endpoint assertions (line geometry first/last coords compared against `/api/airports` lookups) + trace-clear check; new `hovering an on-board cargo row draws its trace and keeps the plane halo` (load via API, hover the row, assert trace endpoints + planes-highlight for that plane, abandon the package afterwards to leave a clean instance); new `hovering the Relocate section previews the planned route and target airport` (selects starter + LEMD via the existing testids, hovers `[data-testid="relocate-section"]`, asserts trace endpoints + target-only ring). New helpers: `airportLonLat(page, icao)` and `expectHoverRoute(page, fromIcao, toIcao)`; all new/extended assertions call `fitToAirports` first since `queryRenderedFeatures` is viewport-scoped - Verified: `tsc --noEmit` clean; unit suite green locally (288); `npx playwright test --list` = 41 tests (was 39, +2 new in hover.spec.ts); e2e not run locally by design — CI verifies against an isolated stack diff --git a/components/Map.tsx b/components/Map.tsx index ce1b93f..dece843 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -38,6 +38,10 @@ const AIRPORTS_HIGHLIGHT_LAYER_ID = 'airports-highlight'; // relocation's planned route is never in it. applyHoverRef pushes data into it on every hover. const HOVER_ROUTES_SOURCE_ID = 'hover-routes'; const HOVER_ROUTE_LAYER_ID = 'hover-route'; +// ISS-022 (v0.22.0): the DESTINATION airport's ring — a second, differently-colored circle over +// the same `airports` source so the target stands out from the origin's plain yellow ring. +// applyHoverRef filters it to the hovered target's destination icao only. +const AIRPORTS_TARGET_HIGHLIGHT_LAYER_ID = 'airports-target-highlight'; function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>'); @@ -146,6 +150,10 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) { ['any', ['==', ['get', 'icao'], originIcao], ['==', ['get', 'icao'], destIcao]], ); } + // ISS-022: the destination gets its own distinct-colored ring on top (package AND route hovers). + if (map.getLayer(AIRPORTS_TARGET_HIGHLIGHT_LAYER_ID)) { + map.setFilter(AIRPORTS_TARGET_HIGHLIGHT_LAYER_ID, ['==', ['get', 'icao'], destIcao]); + } }; useEffect(() => { @@ -431,6 +439,23 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) { }, }); } + // ISS-022 (v0.22.0): the destination's ring — same geometry as airports-highlight but a + // DIFFERENT color so the target stands out over the origin's yellow ring; added last so it + // also sits above the hover-route line at its endpoint. Starts filtered to match nothing. + if (!map.getLayer(AIRPORTS_TARGET_HIGHLIGHT_LAYER_ID)) { + map.addLayer({ + id: AIRPORTS_TARGET_HIGHLIGHT_LAYER_ID, + type: 'circle', + source: AIRPORTS_SOURCE_ID, + filter: ['==', ['get', 'icao'], ''], + paint: { + 'circle-radius': 9, // same ring size as airports-highlight (r=5 base circles) + 'circle-color': 'rgba(0, 0, 0, 0)', // transparent fill — stroke-only ring + 'circle-stroke-width': 3, + 'circle-stroke-color': '#4ade80', // green — distinct from the yellow endpoint rings + }, + }); + } // Layer-scoped listeners survive setStyle (they filter by layer id at event // time), so off+on with the same reference re-wires without duplicating. map.off('click', AIRPORTS_LAYER_ID, onAirportClick); diff --git a/e2e/hover.spec.ts b/e2e/hover.spec.ts index 4d87207..6370cf8 100644 --- a/e2e/hover.spec.ts +++ b/e2e/hover.spec.ts @@ -8,7 +8,8 @@ import { test, expect, type Page } from '@playwright/test'; // queryRenderedFeatures, same pattern as e2e/map.spec.ts and e2e/map-interactions.spec.ts. // ISS-022/025/027 (v0.22.0): any hover carrying two endpoints ALSO draws the full great-circle // trace between them in a fourth top-most layer (`hover-route`, dashed) — job-list rows, on-board -// cargo rows and the Relocate section; the relocate preview rings only its TARGET airport. +// cargo rows and the Relocate section; the relocate preview rings only its TARGET airport, which +// additionally gets a distinct green ring in `airports-target-highlight` (ISS-022). 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 @@ -262,6 +263,7 @@ test('hovering an open package highlights its endpoint airports', async ({ page await fitToAirports(page, [HUB, DEST]); expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0); + expect(await renderedFeatures(page, ['airports-target-highlight'])).toHaveLength(0); await row.hover(); await expect.poll(async () => { @@ -274,6 +276,13 @@ test('hovering an open package highlights its endpoint airports', async ({ page // not just the endpoint rings. await expectHoverRoute(page, HUB, DEST); + // And the TARGET airport gets its own distinct-colored ring — only the destination, never the origin. + await expect.poll(async () => { + const feats = (await renderedFeatures(page, ['airports-target-highlight'])) as Array<{ properties: Record }>; + return JSON.stringify(feats.map((f) => f.properties.icao).sort()); + }, { timeout: 15_000, message: 'waiting for the airports-target-highlight layer to ring only the destination' }) + .toBe(JSON.stringify([DEST])); + await unhover(page); await expect.poll(async () => (await renderedFeatures(page, ['airports-highlight'])).length, { timeout: 15_000, @@ -283,6 +292,10 @@ test('hovering an open package highlights its endpoint airports', async ({ page timeout: 15_000, message: 'waiting for the hover route trace to clear after mouseleave', }).toBe(0); + await expect.poll(async () => (await renderedFeatures(page, ['airports-target-highlight'])).length, { + timeout: 15_000, + message: 'waiting for the target ring to clear after mouseleave', + }).toBe(0); }); test('hovering an on-board cargo row draws its trace and keeps the plane halo', async ({ page }) => { @@ -350,6 +363,7 @@ test('hovering the Relocate section previews the planned route and target airpor expect(await renderedFeatures(page, ['hover-route'])).toHaveLength(0); expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0); + expect(await renderedFeatures(page, ['airports-target-highlight'])).toHaveLength(0); // ISS-027 (v0.22.0): hovering the panel with a plane + destination selected previews the flight — // dashed trace from the plane's airport to the destination… @@ -361,6 +375,12 @@ test('hovering the Relocate section previews the planned route and target airpor return JSON.stringify(feats.map((f) => f.properties.icao).sort()); }, { timeout: 15_000, message: 'waiting for the airports-highlight layer to ring only the target' }) .toBe(JSON.stringify([DEST])); + // …plus the destination's own distinct-colored ring (ISS-022) — same single icao. + await expect.poll(async () => { + const feats = (await renderedFeatures(page, ['airports-target-highlight'])) as Array<{ properties: Record }>; + return JSON.stringify(feats.map((f) => f.properties.icao).sort()); + }, { timeout: 15_000, message: 'waiting for the airports-target-highlight layer to ring only the target' }) + .toBe(JSON.stringify([DEST])); await unhover(page); await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, { @@ -371,6 +391,10 @@ test('hovering the Relocate section previews the planned route and target airpor timeout: 15_000, message: 'waiting for the highlight to clear after mouseleave', }).toBe(0); + await expect.poll(async () => (await renderedFeatures(page, ['airports-target-highlight'])).length, { + timeout: 15_000, + message: 'waiting for the target ring to clear after mouseleave', + }).toBe(0); }); test('hovering an on-board block of a flying plane highlights its route', async ({ page }) => {