feat: v0.15.0 — ISS-002 hovering a plane or package in the side panels highlights it on the map
CI / build + unit + e2e (pull_request) Failing after 6m8s

This commit is contained in:
2026-09-19 09:37:42 +00:00
parent 7db8fcc549
commit 3b6506832f
8 changed files with 386 additions and 11 deletions
+88 -2
View File
@@ -7,6 +7,7 @@ import pkg from '../package.json';
import { DEFAULT_STYLE_ID, STYLES, STYLE_STORAGE_KEY, isStyleId, localAssetsAvailable, styleUrlFor } from '../lib/mapStyles';
import type { GeoJsonFeatureCollection } from '../lib/airports';
import type { StateSnapshot } from '../lib/game/state';
import type { HoverTarget } from './hoverTypes';
import { planesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
import { weatherFor } from '../lib/weather';
import { approxLocalTime } from '../lib/localTime';
@@ -27,6 +28,11 @@ 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-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';
const ROUTES_HIGHLIGHT_LAYER_ID = 'routes-highlight';
const AIRPORTS_HIGHLIGHT_LAYER_ID = 'airports-highlight';
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -36,11 +42,14 @@ interface MapProps {
// The shell's polled /api/state snapshot (v0.11.0) — drives the plane markers and
// route lines. Null until the first successful poll.
state?: StateSnapshot | null;
// ISS-002 (v0.15.0): the side panels' hover target — highlights the matching plane
// (+ its route leg if flying) or a package's endpoint airports. Null = nothing hovered.
hovered?: HoverTarget | null;
}
// Named FlightMap (not Map): a component called `Map` would shadow the global
// Map constructor inside its own body, breaking `new Map(...)` for the airport index.
export default function FlightMap({ state }: MapProps) {
export default function FlightMap({ state, hovered }: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
// /api/airports is fetched at most once per page life: every styledata event
@@ -74,6 +83,38 @@ export default function FlightMap({ state }: MapProps) {
applyDynamicRef.current();
}, [state]);
// ISS-002 (v0.15.0): hover highlight — same ref + effect pattern as the snapshot above.
// No-op until the layers exist; ensureLayers re-applies the current filter after every
// styledata re-establish, so a hover that outlives a style switch stays highlighted.
const hoveredRef = useRef<HoverTarget | null>(null);
const applyHoverRef = useRef<() => void>(() => {});
applyHoverRef.current = () => {
const map = mapRef.current;
if (!map) return;
const h = hoveredRef.current;
// The empty-string sentinel matches nothing: no feature has id/icao ''.
const planeId = h && h.kind === 'plane' ? h.id : '';
if (map.getLayer(PLANES_HIGHLIGHT_LAYER_ID)) {
map.setFilter(PLANES_HIGHLIGHT_LAYER_ID, ['==', ['get', 'id'], planeId]);
}
if (map.getLayer(ROUTES_HIGHLIGHT_LAYER_ID)) {
map.setFilter(ROUTES_HIGHLIGHT_LAYER_ID, ['==', ['get', 'planeId'], planeId]);
}
const originIcao = h && h.kind === 'package' ? h.originIcao : '';
const destIcao = h && h.kind === 'package' ? h.destIcao : '';
if (map.getLayer(AIRPORTS_HIGHLIGHT_LAYER_ID)) {
map.setFilter(
AIRPORTS_HIGHLIGHT_LAYER_ID,
['any', ['==', ['get', 'icao'], originIcao], ['==', ['get', 'icao'], destIcao]],
);
}
};
useEffect(() => {
hoveredRef.current = hovered ?? null;
applyHoverRef.current();
}, [hovered]);
useEffect(() => {
if (!containerRef.current) return;
@@ -240,6 +281,49 @@ export default function FlightMap({ state }: MapProps) {
},
});
}
// ISS-002 (v0.15.0): hover-highlight layers — added last so they sit top-most over
// both plane layers / the route line / the airport circles. Each starts filtered to
// match nothing; applyHoverRef sets the real filter on hover and re-applies it here.
if (!map.getLayer(PLANES_HIGHLIGHT_LAYER_ID)) {
map.addLayer({
id: PLANES_HIGHLIGHT_LAYER_ID,
type: 'circle',
source: PLANES_SOURCE_ID,
filter: ['==', ['get', 'id'], ''],
paint: {
'circle-radius': 10, // halo well above the base circles (3 grounded / 6 flying)
'circle-color': '#38bdf8',
'circle-stroke-width': 2.5,
'circle-stroke-color': '#ffffff',
},
});
}
if (!map.getLayer(ROUTES_HIGHLIGHT_LAYER_ID)) {
map.addLayer({
id: ROUTES_HIGHLIGHT_LAYER_ID,
type: 'line',
source: ROUTES_SOURCE_ID,
filter: ['==', ['get', 'planeId'], ''],
paint: {
'line-color': '#ef4444', // red — distinct from the amber routes-legs line
'line-width': 6,
},
});
}
if (!map.getLayer(AIRPORTS_HIGHLIGHT_LAYER_ID)) {
map.addLayer({
id: AIRPORTS_HIGHLIGHT_LAYER_ID,
type: 'circle',
source: AIRPORTS_SOURCE_ID,
filter: ['any', ['==', ['get', 'icao'], ''], ['==', ['get', 'icao'], '']],
paint: {
'circle-radius': 8, // ring around the base airport circles (r=3)
'circle-color': 'rgba(0, 0, 0, 0)', // transparent fill — stroke-only ring
'circle-stroke-width': 2.5,
'circle-stroke-color': '#facc15',
},
});
}
// 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);
@@ -248,8 +332,10 @@ export default function FlightMap({ state }: MapProps) {
map.on('click', PLANES_GROUNDED_LAYER_ID, onPlaneClick);
map.off('click', PLANES_FLYING_LAYER_ID, onPlaneClick);
map.on('click', PLANES_FLYING_LAYER_ID, onPlaneClick);
// Sources were (re)created above — push the latest game state into them.
// Sources were (re)created above — push the latest game state into them, and re-apply
// the current hover filter to the (re-created) highlight layers.
applyDynamicRef.current();
applyHoverRef.current();
};
if (map.isStyleLoaded()) void ensureLayers();