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
CI / build + unit + e2e (pull_request) Failing after 6m8s
This commit is contained in:
+88
-2
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user