feat: v0.22.0 — ISS-022/025/027 hover route trace on job list, on-board rows and relocation panel

This commit is contained in:
2026-09-19 21:47:00 +00:00
parent 096a6bfe61
commit f2dbad5e03
8 changed files with 285 additions and 11 deletions
+26 -2
View File
@@ -385,7 +385,17 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
{cargo.map((pkg) => {
const penalty = abandonPenalty(pkg, clockMs);
return (
<div key={pkg.id} data-testid={`onboard-package-${pkg.id}`} className="contract-row">
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
// the block's plain plane highlight; leaving the whole block still ends at null (the child's
// mouseleave fires before the parent's).
<div
key={pkg.id}
data-testid={`onboard-package-${pkg.id}`}
className="contract-row"
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: plane.id })}
onMouseLeave={() => onHover({ kind: 'plane', id: plane.id })}
>
<button
type="button"
data-testid={`fly-to-${pkg.id}`}
@@ -438,7 +448,21 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
{/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with
everything else about our planes, between On board and Time. */}
<section className="pane-section">
{/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map —
dashed trace from the selected plane's airport to the destination + a ring on the target.
With no grounded plane or no loaded airport options there is nothing to preview → null. */}
<section
className="pane-section"
data-testid="relocate-section"
onMouseEnter={() =>
onHover(
relocatePlane && relocateDest
? { kind: 'route', fromIcao: relocatePlane.locationIcao, toIcao: relocateDest.icao }
: null,
)
}
onMouseLeave={() => onHover(null)}
>
<h3 className="pane-heading">Relocate</h3>
{/* ISS-010 (v0.18.0): relocate a grounded plane — the fly endpoint already allows pure
repositioning, this row just exposes it for planes with nothing on board. */}
+53 -6
View File
@@ -8,7 +8,7 @@ import { DEFAULT_STYLE_ID, STYLES, STYLE_STORAGE_KEY, isStyleId, localAssetsAvai
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 { hoverRouteGeoJson, planesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
import { weatherFor } from '../lib/weather';
import { approxLocalTime } from '../lib/localTime';
@@ -33,6 +33,11 @@ const ROUTES_LEGS_LAYER_ID = 'routes-legs';
const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight';
const ROUTES_HIGHLIGHT_LAYER_ID = 'routes-highlight';
const AIRPORTS_HIGHLIGHT_LAYER_ID = 'airports-highlight';
// ISS-022/025/027 (v0.22.0): the hover route trace — its OWN source + top-most dashed line,
// because the shared `routes` source only holds FLYING legs: an open package's or a grounded
// 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';
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -43,7 +48,8 @@ interface MapProps {
// 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.
// (+ its route leg if flying) or a package's endpoint airports, and (ISS-022/025/027 v0.22.0)
// draws the full great-circle trace between any hovered target's two endpoints. Null = nothing hovered.
hovered?: HoverTarget | null;
// ISS-010 (v0.18.0): push an action endpoint's full response snapshot into the shared
// shell state — same callback RightPane receives, used by the airport popup's Relocate row.
@@ -101,16 +107,39 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
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 : '';
// The empty-string sentinel matches nothing: no feature has id/icao ''. A package hover may
// name its plane (ISS-025 on-board cargo rows), so that plane's halo stays lit too.
let planeId = '';
let fromIcao = '';
let toIcao = '';
if (h) {
if (h.kind === 'plane') planeId = h.id;
else if (h.kind === 'package') {
planeId = h.planeId ?? '';
fromIcao = h.originIcao;
toIcao = h.destIcao;
} else {
// 'route' — the Relocate panel's planned flight: trace + target airport only.
fromIcao = h.fromIcao;
toIcao = h.toIcao;
}
}
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 : '';
// ISS-022/025/027: the hover route trace — one great-circle line for any target carrying two
// ICAOs (package origin→dest / relocate fromIcao→toIcao); empty when either endpoint is unknown.
const hoverSource = map.getSource(HOVER_ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (hoverSource) {
void hoverSource.setData(hoverRouteGeoJson(fromIcao, toIcao, airportsIndexRef.current ?? new Map()));
}
// Package hovers ring BOTH endpoints (v0.15.0 behavior); 'route' hovers ring the TARGET only —
// the relocation's destination is what ISS-027 asks to see. Plane hovers ring nothing.
const originIcao = h?.kind === 'package' ? fromIcao : '';
const destIcao = h?.kind === 'package' || h?.kind === 'route' ? toIcao : '';
if (map.getLayer(AIRPORTS_HIGHLIGHT_LAYER_ID)) {
map.setFilter(
AIRPORTS_HIGHLIGHT_LAYER_ID,
@@ -384,6 +413,24 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
},
});
}
// ISS-022/025/027 (v0.22.0): the hover route trace — added LAST so it sits above every
// other layer, including the highlight rings at its endpoints. Own source: the shared
// `routes` source only holds flying legs, never a planned one. Empty until a hover sets data.
if (!map.getSource(HOVER_ROUTES_SOURCE_ID)) {
map.addSource(HOVER_ROUTES_SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
}
if (!map.getLayer(HOVER_ROUTE_LAYER_ID)) {
map.addLayer({
id: HOVER_ROUTE_LAYER_ID,
type: 'line',
source: HOVER_ROUTES_SOURCE_ID,
paint: {
'line-color': '#facc15', // matches the airports-highlight ring — one "trace" color
'line-width': 4,
'line-dasharray': [2, 2], // dashed = planned route, distinct from solid flying legs
},
});
}
// 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);
+6 -1
View File
@@ -1,7 +1,12 @@
// 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.
// ISS-022/025/027 (v0.22.0): any target carrying two ICAOs also makes the map draw the full
// great-circle trace between them — package hovers carry origin→dest, 'route' hovers (the
// Relocate panel) carry fromIcao→toIcao; a package hover may additionally name its plane so
// that plane's halo stays lit alongside the trace.
export type HoverTarget =
| { kind: 'plane'; id: string }
| { kind: 'package'; originIcao: string; destIcao: string };
| { kind: 'package'; originIcao: string; destIcao: string; planeId?: string }
| { kind: 'route'; fromIcao: string; toIcao: string };