feat: v0.22.0 — ISS-022 target airport distinct-color ring on job-list / relocation hover
CI / build + unit + e2e (pull_request) Failing after 8m51s

This commit is contained in:
2026-09-19 22:01:02 +00:00
parent f2dbad5e03
commit ac4e3fa316
3 changed files with 51 additions and 1 deletions
+1
View File
@@ -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
+25
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -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);
+25 -1
View File
@@ -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<string, unknown> }>;
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<string, unknown> }>;
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 }) => {