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:
+12
-2
@@ -5,6 +5,7 @@ import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import { formatCash, formatFuel, formatSimClock } from '@/lib/format';
|
||||
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
||||
import type { HoverTarget } from './hoverTypes';
|
||||
|
||||
interface LeftPaneProps {
|
||||
state: StateSnapshot | null;
|
||||
@@ -12,6 +13,8 @@ interface LeftPaneProps {
|
||||
error: string | null;
|
||||
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
|
||||
onRefresh: () => void;
|
||||
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
|
||||
onHover: (target: HoverTarget | null) => void;
|
||||
}
|
||||
|
||||
// Discrete ladder slider (v0.9.2, values reworked in v0.14.0): position t ∈ [0,1] → nearest
|
||||
@@ -42,7 +45,7 @@ export function pendingScaleConfirmed(pendingScale: number | null, serverTimeSca
|
||||
return pendingScale !== null && serverTimeScale === pendingScale;
|
||||
}
|
||||
|
||||
export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneProps) {
|
||||
export default function LeftPane({ state, loading, error, onRefresh, onHover }: LeftPaneProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// POST /api/sim, then refetch the full snapshot immediately so the pane reflects the new
|
||||
@@ -189,7 +192,14 @@ export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneP
|
||||
) : (
|
||||
<ul data-testid="fleet-list" className="fleet-list">
|
||||
{state.planes.map((p) => (
|
||||
<li key={p.id} data-testid={`plane-${p.id}`} className="plane-row">
|
||||
// ISS-002: hovering a fleet row highlights the plane on the map.
|
||||
<li
|
||||
key={p.id}
|
||||
data-testid={`plane-${p.id}`}
|
||||
className="plane-row"
|
||||
onMouseEnter={() => onHover({ kind: 'plane', id: p.id })}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
>
|
||||
<div className="plane-main">
|
||||
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
|
||||
<span className={`plane-status status-${p.status}`}>{p.status}</span>
|
||||
|
||||
+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();
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import { abandonPenalty } from '@/lib/game/penalty';
|
||||
import { formatCash, formatDeadlineRelative } from '@/lib/format';
|
||||
import type { HoverTarget } from './hoverTypes';
|
||||
|
||||
interface RightPaneProps {
|
||||
state: StateSnapshot | null;
|
||||
@@ -12,6 +13,9 @@ interface RightPaneProps {
|
||||
// Push the full snapshot returned by an action endpoint into the shared shell state (v0.10.0),
|
||||
// so the pane updates on click without waiting for the next poll tick.
|
||||
applySnapshot: (snapshot: StateSnapshot) => void;
|
||||
// ISS-002 (v0.15.0): report row hovers to the shell so the map can highlight the plane or
|
||||
// the package's endpoint airports.
|
||||
onHover: (target: HoverTarget | null) => void;
|
||||
}
|
||||
|
||||
const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model;
|
||||
@@ -100,7 +104,7 @@ function LoadControls({
|
||||
);
|
||||
}
|
||||
|
||||
export default function RightPane({ state, loading, applySnapshot }: RightPaneProps) {
|
||||
export default function RightPane({ state, loading, applySnapshot, onHover }: RightPaneProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Inline error from a failed action — auto-clears on the next successful poll (the shell's
|
||||
// snapshot prop changes every tick) or the next successful action.
|
||||
@@ -192,7 +196,14 @@ export default function RightPane({ state, loading, applySnapshot }: RightPanePr
|
||||
{open.map((pkg) => {
|
||||
const eligible = loadEligiblePlanes(state, pkg);
|
||||
return (
|
||||
<li key={pkg.id} data-testid={`open-package-${pkg.id}`} className="contract-row">
|
||||
// ISS-002: hovering an open package highlights its two endpoint airports on the map.
|
||||
<li
|
||||
key={pkg.id}
|
||||
data-testid={`open-package-${pkg.id}`}
|
||||
className="contract-row"
|
||||
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao })}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
>
|
||||
<div className="contract-main">
|
||||
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
|
||||
{eligible.length > 0 ? (
|
||||
@@ -232,7 +243,16 @@ export default function RightPane({ state, loading, applySnapshot }: RightPanePr
|
||||
(pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
|
||||
);
|
||||
return (
|
||||
<div key={plane.id} data-testid={`onboard-${plane.id}`} className="onboard-block">
|
||||
// ISS-002: hovering the on-board block (including its cargo rows) highlights the plane.
|
||||
// Handlers live only on the outer div — React's enter/leave don't fire when moving to a
|
||||
// child, so this covers the whole block without flicker between sub-elements.
|
||||
<div
|
||||
key={plane.id}
|
||||
data-testid={`onboard-${plane.id}`}
|
||||
className="onboard-block"
|
||||
onMouseEnter={() => onHover({ kind: 'plane', id: plane.id })}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
>
|
||||
<div className="contract-main">
|
||||
<span className="plane-model">{planeName(plane)}</span>
|
||||
{plane.status === 'flying' ? (
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// ISS-002 — what a side-panel hover highlights on the map (v0.15.0): a plane marker
|
||||
// (+ its route leg if flying) or both endpoint airports of an open package. The shell
|
||||
// owns this state and passes it to <Map>; panes report it via their onHover callback.
|
||||
|
||||
export type HoverTarget =
|
||||
| { kind: 'plane'; id: string }
|
||||
| { kind: 'package'; originIcao: string; destIcao: string };
|
||||
Reference in New Issue
Block a user