v0.18.0 — ISS-010 relocate empty planes + bigger airport markers + popup options #20
@@ -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;
|
||||
|
||||
+4
-2
@@ -44,8 +44,10 @@ export default function Home() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<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 */}
|
||||
<Map state={state} hovered={hovered} />
|
||||
{/* 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 */}
|
||||
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} />
|
||||
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
||||
<ToastStack toasts={toasts} onDismiss={dismiss} />
|
||||
</div>
|
||||
|
||||
+62
-2
@@ -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<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(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<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 [airportCount, setAirportCount] = useState<number | null>(null);
|
||||
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.
|
||||
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
|
||||
? `<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])
|
||||
.setHTML(
|
||||
`<div data-testid="airport-popup">` +
|
||||
`<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-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>`,
|
||||
)
|
||||
.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) => {
|
||||
|
||||
Reference in New Issue
Block a user