diff --git a/app/globals.css b/app/globals.css index 4fa72e1..d9dba6f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -68,6 +68,40 @@ body { 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 */ .app-shell { display: grid; diff --git a/app/page.tsx b/app/page.tsx index 893be6e..5777fb8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -44,8 +44,10 @@ export default function Home() { return (
- {/* 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; + 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 */} +
diff --git a/components/Map.tsx b/components/Map.tsx index e88c89a..be2b8c6 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -45,11 +45,14 @@ interface MapProps { // 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; + // 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 // 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(null); const mapRef = useRef(null); // /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 // and the state-change effect can both push data without prop-drilling into closures. const stateRef = useRef(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 | null>(null); const [airportCount, setAirportCount] = useState(null); const [styleId, setStyleId] = useState(DEFAULT_STYLE_ID); @@ -156,16 +165,67 @@ export default function FlightMap({ state, hovered }: MapProps) { // deterministic generated weather — no gameplay effects per PLAN.md. const w = weatherFor(p.icao); 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 + ? `` + : `No grounded planes to relocate.`; + const popup = new maplibregl.Popup({ offset: 10 }) .setLngLat(f.geometry.coordinates as [number, number]) .setHTML( `
` + `${escapeHtml(p.name)}
${escapeHtml(p.icao)}
` + `${localTime} local (approx)
` + `${w.condition}, wind ${w.windDirDeg}\u00B0 / ${w.windKt} kt, vis ${w.visibilityM} m` + + `` + `
`, ) .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('[data-testid="airport-popup-plane"]'); + const button = el?.querySelector('[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('[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) => {