ui(climate-sim): 💄 Improve climate data visualization and scenario tab navigation in ClimateSimDisplay and ScenarioTabs

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-31 22:47:32 -07:00
parent 6753e8666d
commit 2e1a0d9bbc
2 changed files with 44 additions and 18 deletions

View file

@ -14,7 +14,7 @@ import { useSimulationWorker } from '@/hooks/useSimulationWorker'
import { HexGLRenderer } from './HexGLRenderer'
import { LayerPanel, ENVIRONMENT_DEFAULT_MASK, LIFE_DEFAULT_MASK } from './LayerPanel'
import type { SimCategory } from './LayerPanel'
import { ScenarioTabs } from './ScenarioTabs'
import { ScenarioTabs, SCENARIO_GROUPS } from './ScenarioTabs'
import { TerrainLegend } from './TerrainLegend'
import { EventControlPanel } from './EventControlPanel'
import { EventLog } from './EventLog'
@ -113,8 +113,9 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React
const noGui = (searchParams.get('noGui') ?? searchParams.get('nogui')) === 'true'
const initialPlanet = PLANETS.find((p) => p.id === urlPlanet && p.available) ? urlPlanet! : 'earth'
const initialId = urlName && VALID_IDS.has(urlName) ? urlName : (SCENARIOS.find((s) => (s.planet ?? 'earth') === initialPlanet)?.id ?? SCENARIOS[0]?.id ?? '')
const initialPlanet = PLANETS.find((p) => p.id === urlPlanet && p.available) ? urlPlanet! : PLANETS[0]!.id
const defaultScenarioId = SCENARIO_GROUPS.find((g) => g.planet === initialPlanet)?.scenarioIds[0] ?? SCENARIOS[0]?.id ?? ''
const initialId = urlName && VALID_IDS.has(urlName) ? urlName : defaultScenarioId
const initialView: ViewCenter = urlView && VALID_VIEWS.has(urlView as ViewCenter) ? (urlView as ViewCenter) : 'equator'
// URL turns are 1-indexed (first=1, last=totalTurns); internal state is 0-indexed
const initialTurn = urlTurn != null ? Math.max(0, Math.floor(Number(urlTurn)) - 1) : 0
@ -152,18 +153,22 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React
const planetRef = useRef<HTMLDivElement>(null)
const activePlanetDef = PLANETS.find((p) => p.id === activePlanet) ?? PLANETS[0]!
const rafRef = useRef<number>(0)
const lastRef = useRef<number>(0)
const turnRef = useRef<number>(initialTurn)
const pausedRef = useRef<boolean>(initialPause)
const speedRef = useRef<number>(1)
const loopingRef = useRef<boolean>(initialLoop)
const lastSyncedTurnRef = useRef<number>(-1)
const rafRef = useRef<number>(0)
const lastRef = useRef<number>(0)
const turnRef = useRef<number>(initialTurn)
const pausedRef = useRef<boolean>(initialPause)
const speedRef = useRef<number>(1)
const loopingRef = useRef<boolean>(initialLoop)
const lastSyncedTurnRef = useRef<number>(-1)
// Ref mirror of activeScenarioId so the RAF tick closure always reads the current value
// without the RAF effect needing activeScenarioId in its dependency array.
const activeScenarioIdRef = useRef<string>(activeScenarioId)
useEffect(() => { pausedRef.current = paused }, [paused])
useEffect(() => { speedRef.current = simSpeed }, [simSpeed])
useEffect(() => { loopingRef.current = looping }, [looping])
useEffect(() => { activeScenarioIdRef.current = activeScenarioId }, [activeScenarioId])
useEffect(() => {
if (!planetOpen) return
@ -201,10 +206,14 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React
const scenarioData = workerScenarios.get(activeScenarioId)
const totalTurns = scenarioData?.totalTurns ?? initialTurns
// Fires once when bufferReady becomes true and on scenario changes.
// Per-turn requests during animation are handled directly inside the RAF tick
// to avoid calling setCurrentFrame from inside a passive effect (which would
// accumulate nestedPassiveUpdateCount and hit React 19's 50-update limit).
useEffect(() => {
if (!scenarioData?.bufferReady) return
requestFrame(activeScenarioId, currentTurn)
}, [activeScenarioId, currentTurn, scenarioData, requestFrame])
requestFrame(activeScenarioId, turnRef.current)
}, [activeScenarioId, scenarioData?.bufferReady, requestFrame]) // eslint-disable-line react-hooks/exhaustive-deps
// ── URL sync ───────────────────────────────────────────────────────────
const viewRef = useRef<ViewCenter>(initialView)
@ -254,12 +263,13 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React
// Pressing play while at the end → restart from beginning
if (!next && turnRef.current >= totalTurns - 1) {
turnRef.current = 0
requestFrame(activeScenarioId, 0)
setCurrentTurn(0)
}
pausedRef.current = next
setPaused(next)
syncUrl(activeScenarioId, turnRef.current, next, {})
}, [activeScenarioId, totalTurns, syncUrl])
}, [activeScenarioId, totalTurns, syncUrl, requestFrame])
const handleToggleLoop = useCallback(() => {
const next = !loopingRef.current
@ -278,9 +288,10 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React
const handleScrub = useCallback((turn: number) => {
const t = clampTurn(turn, totalTurns)
turnRef.current = t
requestFrame(activeScenarioId, t)
setCurrentTurn(t)
syncUrl(activeScenarioId, t, pausedRef.current, {})
}, [activeScenarioId, totalTurns, syncUrl])
}, [activeScenarioId, totalTurns, syncUrl, requestFrame])
const handleExtend = useCallback(() => {
workerExtend(activeScenarioId, EXTEND_TURNS)
@ -331,16 +342,24 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React
// ── animation loop ─────────────────────────────────────────────────────
// requestFrame is called directly inside tick (not via a separate useEffect) so
// that setCurrentFrame is dispatched from a RAF macro-task rather than from a
// passive effect. Calling setState from useEffect increments React 19's
// nestedPassiveUpdateCount; at 80 ms/frame the counter reaches the 50-update
// limit in under 5 seconds, triggering "Maximum update depth exceeded".
useEffect(() => {
if (!scenarioData?.bufferReady) return
let cancelled = false
const tick = (now: number): void => {
if (cancelled) return
if (!pausedRef.current && now - lastRef.current >= FRAME_MS) {
lastRef.current = now
const next = turnRef.current + speedRef.current
if (next >= totalTurns) {
if (loopingRef.current) {
turnRef.current = 0
requestFrame(activeScenarioIdRef.current, 0)
setCurrentTurn(0)
} else {
pausedRef.current = true
@ -348,23 +367,30 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React
}
} else {
turnRef.current = next
requestFrame(activeScenarioIdRef.current, next)
setCurrentTurn(next)
}
}
rafRef.current = requestAnimationFrame(tick)
}
rafRef.current = requestAnimationFrame(tick)
return () => cancelAnimationFrame(rafRef.current)
}, [scenarioData, totalTurns]) // eslint-disable-line react-hooks/exhaustive-deps
return () => {
cancelled = true
cancelAnimationFrame(rafRef.current)
}
}, [scenarioData?.bufferReady, totalTurns, requestFrame]) // eslint-disable-line react-hooks/exhaustive-deps
// URL sync deferred to a useEffect so setSearchParams never fires inside the RAF
// callback — calling it there races with React 19's scheduler and throws.
// Skip in noGui mode: the URL is already fully specified on page load (screenshot/automation),
// and calling setSearchParams inside the tight RAF→effect cycle causes a render loop.
useEffect(() => {
if (noGui) return
if (currentTurn % 10 !== 0) return
if (currentTurn === lastSyncedTurnRef.current) return
lastSyncedTurnRef.current = currentTurn
syncUrl(activeScenarioId, currentTurn, pausedRef.current, {})
}, [currentTurn, activeScenarioId, syncUrl])
}, [noGui, currentTurn, activeScenarioId, syncUrl])
// ── derived values ─────────────────────────────────────────────────────
const planetScenarios = useMemo(

View file

@ -15,7 +15,7 @@ interface ScenarioGroup {
planet: string
}
const SCENARIO_GROUPS: ScenarioGroup[] = [
export const SCENARIO_GROUPS: ScenarioGroup[] = [
{
id: 'lifeless',
label: 'Lifeless Worlds',