'use client'; import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react'; import type { ContractState, 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'; interface LeftPaneProps { state: StateSnapshot | null; loading: boolean; error: string | null; // The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately. onRefresh: () => void; } // Power-of-two slider (v0.9.2): position t ∈ [0,1] → nearest step of the ladder // 1×/2×/4×/8×/16×/32×/64× — each of the 7 positions doubles the speed. const SCALE_SLIDER_STEP = 1 / (TIME_SCALES.length - 1); // one doubling per keyboard-arrow step const SCALE_COMMIT_DEBOUNCE_MS = 250; function planeLocation(p: PlaneState, contracts: ContractState[]): string { if (p.status === 'flying' && p.contractId) { const c = contracts.find((c) => c.id === p.contractId); if (c) return `${p.locationName} → ${c.destName}`; } return p.locationName; } export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneProps) { const [busy, setBusy] = useState(false); // POST /api/sim, then refetch the full snapshot immediately so the pane reflects the new // controls without waiting for the next poll tick. On failure keep the stale UI — the // running poll retries on its own and surfaces the error state. A commit that lands while // another POST is in flight is held in a single slot and flushed right after, so the last // user intent always reaches the server (the server's [1,64] clamp stays as backstop). const queuedRef = useRef<{ timeScale?: number; paused?: boolean } | null>(null); const applySimControls = useCallback( async (body: { timeScale?: number; paused?: boolean }) => { if (busy) { queuedRef.current = body; return; } setBusy(true); try { const res = await fetch('/api/sim', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', }); if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`); onRefresh(); } catch (err) { console.error('failed to apply sim controls', err); } finally { setBusy(false); const queued = queuedRef.current; if (queued !== null) { queuedRef.current = null; void applySimControls(queued); } } }, [busy, onRefresh], ); const paused = state?.simState.paused ?? false; const timeScale = state?.simState.timeScale ?? 1; // Slider commit (v0.9.1): the displayed value follows the drag optimistically via // pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the // user releases the slider (browsers fire a native 'change' on release). The server's // [1,64] clamp stays as the backstop. const [pendingScale, setPendingScaleState] = useState(null); const pendingRef = useRef(null); const commitTimerRef = useRef | null>(null); const setPending = useCallback((s: number | null) => { pendingRef.current = s; setPendingScaleState(s); }, []); const commitNow = useCallback(() => { if (commitTimerRef.current !== null) { clearTimeout(commitTimerRef.current); commitTimerRef.current = null; } const s = pendingRef.current; if (s === null) return; setPending(null); void applySimControls({ timeScale: s }); }, [applySimControls, setPending]); const onSliderInput = useCallback( (e: ChangeEvent) => { setPending(sliderToScale(Number(e.target.value))); if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current); commitTimerRef.current = setTimeout(commitNow, SCALE_COMMIT_DEBOUNCE_MS); }, [commitNow, setPending], ); const sliderRef = useRef(null); useEffect(() => { const el = sliderRef.current; if (!el) return; el.addEventListener('change', commitNow); // flush on pointer-up / keyboard release return () => el.removeEventListener('change', commitNow); }, [commitNow]); // Drop any pending debounce timer on unmount. useEffect( () => () => { if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current); }, [], ); const displayedScale = pendingScale ?? timeScale; return ( ); }