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:
2026-09-17 16:13:28 +00:00
parent 9502cd1975
commit 62f9929b51
9 changed files with 232 additions and 51 deletions
+102 -33
View File
@@ -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>