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:
parent
6753e8666d
commit
2e1a0d9bbc
2 changed files with 44 additions and 18 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ interface ScenarioGroup {
|
|||
planet: string
|
||||
}
|
||||
|
||||
const SCENARIO_GROUPS: ScenarioGroup[] = [
|
||||
export const SCENARIO_GROUPS: ScenarioGroup[] = [
|
||||
{
|
||||
id: 'lifeless',
|
||||
label: 'Lifeless Worlds',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue