feat: v0.18.0 part 2 — airport popup relocate options
This commit is contained in:
@@ -68,6 +68,40 @@ body {
|
|||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ISS-010 (v0.18.0) — Relocate row inside the airport popup (plain HTML, not React). */
|
||||||
|
.popup-relocate {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-relocate select {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-relocate button {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-relocate button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid='airport-popup-relocate-status'] {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
/* v0.9.0 — single-screen 3-pane shell: left Assets / center map / right Jobs & Market */
|
/* v0.9.0 — single-screen 3-pane shell: left Assets / center map / right Jobs & Market */
|
||||||
.app-shell {
|
.app-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
+4
-2
@@ -44,8 +44,10 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<LeftPane state={state} loading={loading} error={error} onRefresh={refresh} onHover={setHovered} onError={onError} />
|
<LeftPane state={state} loading={loading} error={error} onRefresh={refresh} onHover={setHovered} onError={onError} />
|
||||||
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines */}
|
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
|
||||||
<Map state={state} hovered={hovered} />
|
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
|
||||||
|
action response into the shared state, exactly like RightPane does */}
|
||||||
|
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} />
|
||||||
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
||||||
<ToastStack toasts={toasts} onDismiss={dismiss} />
|
<ToastStack toasts={toasts} onDismiss={dismiss} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+62
-2
@@ -45,11 +45,14 @@ interface MapProps {
|
|||||||
// ISS-002 (v0.15.0): the side panels' hover target — highlights the matching plane
|
// 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. Null = nothing hovered.
|
||||||
hovered?: HoverTarget | null;
|
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.
|
||||||
|
applySnapshot: (snapshot: StateSnapshot) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Named FlightMap (not Map): a component called `Map` would shadow the global
|
// 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.
|
// Map constructor inside its own body, breaking `new Map(...)` for the airport index.
|
||||||
export default function FlightMap({ state, hovered }: MapProps) {
|
export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
// /api/airports is fetched at most once per page life: every styledata event
|
// /api/airports is fetched at most once per page life: every styledata event
|
||||||
@@ -58,6 +61,12 @@ export default function FlightMap({ state, hovered }: MapProps) {
|
|||||||
// Latest snapshot + airport coordinate index, so the mount effect's styledata wiring
|
// Latest snapshot + airport coordinate index, so the mount effect's styledata wiring
|
||||||
// and the state-change effect can both push data without prop-drilling into closures.
|
// and the state-change effect can both push data without prop-drilling into closures.
|
||||||
const stateRef = useRef<StateSnapshot | null>(null);
|
const stateRef = useRef<StateSnapshot | null>(null);
|
||||||
|
// ISS-010 (v0.18.0): the shell's applySnapshot is stable (useCallback with no deps), but the
|
||||||
|
// mount effect captures it once — keep a ref so the popup wiring always calls the latest one.
|
||||||
|
const applySnapshotRef = useRef(applySnapshot);
|
||||||
|
useEffect(() => {
|
||||||
|
applySnapshotRef.current = applySnapshot;
|
||||||
|
}, [applySnapshot]);
|
||||||
const airportsIndexRef = useRef<Map<string, AirportCoord> | null>(null);
|
const airportsIndexRef = useRef<Map<string, AirportCoord> | null>(null);
|
||||||
const [airportCount, setAirportCount] = useState<number | null>(null);
|
const [airportCount, setAirportCount] = useState<number | null>(null);
|
||||||
const [styleId, setStyleId] = useState<string>(DEFAULT_STYLE_ID);
|
const [styleId, setStyleId] = useState<string>(DEFAULT_STYLE_ID);
|
||||||
@@ -156,16 +165,67 @@ export default function FlightMap({ state, hovered }: MapProps) {
|
|||||||
// deterministic generated weather — no gameplay effects per PLAN.md.
|
// deterministic generated weather — no gameplay effects per PLAN.md.
|
||||||
const w = weatherFor(p.icao);
|
const w = weatherFor(p.icao);
|
||||||
const localTime = approxLocalTime(p.lon, Date.now());
|
const localTime = approxLocalTime(p.lon, Date.now());
|
||||||
new maplibregl.Popup({ offset: 10 })
|
// ISS-010 (v0.18.0): Relocate row — grounded planes from the latest snapshot can be
|
||||||
|
// repositioned to this airport via POST /api/planes/{id}/fly (pure repositioning).
|
||||||
|
const st = stateRef.current;
|
||||||
|
const groundedPlanes = st ? st.planes.filter((pl) => pl.status === 'available') : [];
|
||||||
|
const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model;
|
||||||
|
const relocateHtml =
|
||||||
|
groundedPlanes.length > 0
|
||||||
|
? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` +
|
||||||
|
`<option value="">Select a plane\u2026</option>` +
|
||||||
|
groundedPlanes.map(
|
||||||
|
(pl) =>
|
||||||
|
`<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`,
|
||||||
|
).join('') +
|
||||||
|
`</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>`
|
||||||
|
: `<span data-testid="airport-popup-no-planes">No grounded planes to relocate.</span>`;
|
||||||
|
const popup = new maplibregl.Popup({ offset: 10 })
|
||||||
.setLngLat(f.geometry.coordinates as [number, number])
|
.setLngLat(f.geometry.coordinates as [number, number])
|
||||||
.setHTML(
|
.setHTML(
|
||||||
`<div data-testid="airport-popup">` +
|
`<div data-testid="airport-popup">` +
|
||||||
`<strong>${escapeHtml(p.name)}</strong><br/>${escapeHtml(p.icao)}<br/>` +
|
`<strong>${escapeHtml(p.name)}</strong><br/>${escapeHtml(p.icao)}<br/>` +
|
||||||
`<span data-testid="airport-local-time">${localTime} local (approx)</span><br/>` +
|
`<span data-testid="airport-local-time">${localTime} local (approx)</span><br/>` +
|
||||||
`<span data-testid="airport-weather">${w.condition}, wind ${w.windDirDeg}\u00B0 / ${w.windKt} kt, vis ${w.visibilityM} m</span>` +
|
`<span data-testid="airport-weather">${w.condition}, wind ${w.windDirDeg}\u00B0 / ${w.windKt} kt, vis ${w.visibilityM} m</span>` +
|
||||||
|
`<div class="popup-relocate">` + relocateHtml + `<span data-testid="airport-popup-relocate-status"></span></div>` +
|
||||||
`</div>`,
|
`</div>`,
|
||||||
)
|
)
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
|
|
||||||
|
// Popups are plain HTML, not React — the element only exists after addTo, so wire the
|
||||||
|
// controls via querySelector (the select enables the button once a plane is picked).
|
||||||
|
const el = popup.getElement();
|
||||||
|
const select = el?.querySelector<HTMLSelectElement>('[data-testid="airport-popup-plane"]');
|
||||||
|
const button = el?.querySelector<HTMLButtonElement>('[data-testid="airport-popup-relocate"]');
|
||||||
|
// The status span is always in the popup HTML (even with no grounded planes), so it
|
||||||
|
// exists whenever the select/button do.
|
||||||
|
const statusEl = el?.querySelector<HTMLElement>('[data-testid="airport-popup-relocate-status"]');
|
||||||
|
if (select && button && statusEl) {
|
||||||
|
select.addEventListener('change', () => {
|
||||||
|
button.disabled = select.value === '';
|
||||||
|
});
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
const planeId = select.value;
|
||||||
|
if (!planeId || button.disabled) return;
|
||||||
|
button.disabled = true;
|
||||||
|
statusEl.textContent = 'Relocating\u2026';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/planes/${planeId}/fly`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ destIcao: p.icao }),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as StateSnapshot & { error?: string };
|
||||||
|
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||||
|
applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction
|
||||||
|
statusEl.textContent = 'Relocation started.';
|
||||||
|
} catch (err) {
|
||||||
|
statusEl.textContent = err instanceof Error ? err.message : String(err);
|
||||||
|
button.disabled = false; // allow retry / picking another plane
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPlaneClick = (e: maplibregl.MapLayerMouseEvent) => {
|
const onPlaneClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user