feat: v0.9.1 — left-pane time controls refined: log-scale slider (t→round(60^t), 250 ms debounced commit with release flush + in-flight queue) replaces the scale select; game clock now shows seconds (HH:MM:SS UTC)
This commit is contained in:
+102
-33
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type ChangeEvent } from 'react';
|
||||
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 { scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
||||
|
||||
interface LeftPaneProps {
|
||||
state: StateSnapshot | null;
|
||||
@@ -13,7 +14,10 @@ interface LeftPaneProps {
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const SCALE_OPTIONS = Array.from({ length: 60 }, (_, i) => i + 1); // 1x–60x, matching POST /api/sim's clamp range
|
||||
// Log-scale slider (v0.9.1): position t ∈ [0,1] → scale = round(60^t), so equal distances
|
||||
// are equal multiplicative steps. 60 discrete positions keep keyboard arrows usable (~7% per step).
|
||||
const SCALE_SLIDER_STEP = 1 / 59;
|
||||
const SCALE_COMMIT_DEBOUNCE_MS = 250;
|
||||
|
||||
function planeLocation(p: PlaneState, contracts: ContractState[]): string {
|
||||
if (p.status === 'flying' && p.contractId) {
|
||||
@@ -28,29 +32,95 @@ export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneP
|
||||
|
||||
// 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.
|
||||
const applySimControls = async (body: { timeScale?: number; paused?: boolean }) => {
|
||||
if (busy) 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);
|
||||
}
|
||||
};
|
||||
// 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,60] 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,60] clamp stays as the backstop.
|
||||
const [pendingScale, setPendingScaleState] = useState<number | null>(null);
|
||||
const pendingRef = useRef<number | null>(null);
|
||||
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>(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 (
|
||||
<aside data-testid="left-pane" className="pane left-pane">
|
||||
<h2 className="pane-title">Assets</h2>
|
||||
@@ -109,19 +179,18 @@ export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneP
|
||||
>
|
||||
{paused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
<select
|
||||
data-testid="time-scale-select"
|
||||
<input
|
||||
ref={sliderRef}
|
||||
type="range"
|
||||
data-testid="time-scale-slider"
|
||||
aria-label="Time scale"
|
||||
value={String(timeScale)}
|
||||
disabled={busy}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => void applySimControls({ timeScale: Number(e.target.value) })}
|
||||
>
|
||||
{SCALE_OPTIONS.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}x
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
min={0}
|
||||
max={1}
|
||||
step={SCALE_SLIDER_STEP}
|
||||
value={scaleToSlider(displayedScale)}
|
||||
onChange={onSliderInput}
|
||||
/>
|
||||
<span data-testid="time-scale-value">{displayedScale}×</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user