diff --git a/guide/engine/src/components/climate-sim/BiomeReference.tsx b/guide/engine/src/components/climate-sim/BiomeReference.tsx index 10d76814..197baa8c 100644 --- a/guide/engine/src/components/climate-sim/BiomeReference.tsx +++ b/guide/engine/src/components/climate-sim/BiomeReference.tsx @@ -131,7 +131,7 @@ interface BiomeReferenceProps { presentBiomes?: ReadonlySet } -export function BiomeReference({ layerMask, presentBiomes }: BiomeReferenceProps): ReactElement | null { +export function BiomeReferenceContent({ layerMask, presentBiomes }: BiomeReferenceProps): ReactElement | null { const [hoveredBiome, setHoveredBiome] = useState(null) // Only relevant when Terrain (bit 5) or Biomes (bit 0) layer is active @@ -146,7 +146,7 @@ export function BiomeReference({ layerMask, presentBiomes }: BiomeReferenceProps }) return ( - + <> Classification tree from classifyBiome(). Each biome shows the decision path and example input values that produce it. @@ -189,6 +189,15 @@ export function BiomeReference({ layerMask, presentBiomes }: BiomeReferenceProps T = temperature [0,1] · M = moisture [0,1] · E = elevation [0,1] · C = canopy [0,1] Decision priority: Special → Mangrove → Wetland → Elevation → Temperature → Polar + + ) +} + +export function BiomeReference({ layerMask, presentBiomes }: BiomeReferenceProps): ReactElement | null { + if ((layerMask & ((1 << 5) | (1 << 0))) === 0) return null + return ( + + ) } diff --git a/guide/engine/src/components/climate-sim/ClimateSimDisplay.tsx b/guide/engine/src/components/climate-sim/ClimateSimDisplay.tsx index 3c190fb5..ca496658 100644 --- a/guide/engine/src/components/climate-sim/ClimateSimDisplay.tsx +++ b/guide/engine/src/components/climate-sim/ClimateSimDisplay.tsx @@ -16,7 +16,6 @@ import { LayerPanel, ENVIRONMENT_DEFAULT_MASK, LIFE_DEFAULT_MASK } from './Layer import type { SimCategory } from './LayerPanel' import { ScenarioTabs } from './ScenarioTabs' import { TerrainLegend } from './TerrainLegend' -import { BiomeReference } from './BiomeReference' import { EventControlPanel } from './EventControlPanel' import { EventLog } from './EventLog' import { EventTimeline } from './EventTimeline' @@ -39,11 +38,13 @@ interface PlanetDef { } const PLANETS: PlanetDef[] = [ - { id: 'earth', label: 'Earth', glyph: '◉', available: true }, - { id: 'mars', label: 'Mars', glyph: '◎', available: false }, + { id: 'earth', label: 'Earth', glyph: '◉', available: true }, + { id: 'venus', label: 'Venus', glyph: '◈', available: true }, + { id: 'mars', label: 'Mars', glyph: '◎', available: true }, ] const VALID_IDS = new Set(SCENARIOS.map((s) => s.id)) +const SCENARIO_BY_ID = new Map(SCENARIOS.map((s) => [s.id, s])) const VALID_VIEWS = new Set(['equator', 'north', 'south'] as const) type ViewCenter = 'equator' | 'north' | 'south' @@ -109,7 +110,8 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React const noGui = (searchParams.get('noGui') ?? searchParams.get('nogui')) === 'true' - const initialId = urlName && VALID_IDS.has(urlName) ? urlName : (SCENARIOS[0]?.id ?? '') + 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 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 @@ -119,7 +121,6 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React const initialLayers = urlLayers != null ? Math.max(0, Math.floor(Number(urlLayers))) : (resolvedCategory === 'life' ? LIFE_DEFAULT_MASK : ENVIRONMENT_DEFAULT_MASK) const initialSpeed = urlSpeed != null ? Math.max(1, Math.min(5, Math.floor(Number(urlSpeed)))) : 1 const initialTurns = urlTotalTurns != null ? Math.max(50, Math.floor(Number(urlTotalTurns))) : DEFAULT_SCENARIO_TURNS - const initialPlanet = PLANETS.find((p) => p.id === urlPlanet && p.available) ? urlPlanet! : 'earth' const worldSeed = useMemo(() => { const n = urlSeed != null ? Math.floor(Number(urlSeed)) : 42 return isNaN(n) ? 42 : Math.max(1, n) @@ -308,14 +309,22 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React }, [activeScenarioId, syncUrl]) const handlePlanetChange = useCallback((planetId: string) => { + const firstScenario = SCENARIOS.find((s) => (s.planet ?? 'earth') === planetId) + const newScenarioId = firstScenario?.id ?? activeScenarioId + cancelScenario(activeScenarioId) setActivePlanet(planetId) setPlanetOpen(false) + setActiveScenarioId(newScenarioId) + turnRef.current = 0 + setCurrentTurn(0) setSearchParams((prev) => { const next = new URLSearchParams(prev) next.set('planet', planetId) + next.set('name', newScenarioId) + next.set('turn', '1') return next }, { replace: true }) - }, [setSearchParams]) + }, [activeScenarioId, cancelScenario, setSearchParams]) // ── animation loop ───────────────────────────────────────────────────── @@ -355,7 +364,11 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React }, [currentTurn, activeScenarioId, syncUrl]) // ── derived values ───────────────────────────────────────────────────── - const activeConfig = SCENARIOS.find((s) => s.id === activeScenarioId) + const planetScenarios = useMemo( + () => SCENARIOS.filter((s) => (s.planet ?? 'earth') === activePlanet), + [activePlanet], + ) + const activeConfig = SCENARIO_BY_ID.get(activeScenarioId) const worldAge = activeConfig?.worldAge ?? 0 const activeScenarioName = activeConfig?.name ?? activeScenarioId @@ -550,7 +563,8 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React @@ -621,7 +635,6 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React - diff --git a/guide/engine/src/components/climate-sim/MapOverlayPanel.tsx b/guide/engine/src/components/climate-sim/MapOverlayPanel.tsx index f0a0f88a..43805444 100644 --- a/guide/engine/src/components/climate-sim/MapOverlayPanel.tsx +++ b/guide/engine/src/components/climate-sim/MapOverlayPanel.tsx @@ -9,9 +9,10 @@ interface MapOverlayPanelProps { defaultExpanded?: boolean className?: string style?: React.CSSProperties + headerAction?: ReactNode } -export function MapOverlayPanel({ title, children, defaultExpanded = false, className, style }: MapOverlayPanelProps): ReactElement { +export function MapOverlayPanel({ title, children, defaultExpanded = false, className, style, headerAction }: MapOverlayPanelProps): ReactElement { const [expanded, setExpanded] = useState(defaultExpanded) return ( @@ -23,6 +24,11 @@ export function MapOverlayPanel({ title, children, defaultExpanded = false, clas onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setExpanded(v => !v) } }} > {title} + {headerAction && ( + e.stopPropagation()}> + {headerAction} + + )} {expanded && ( @@ -63,6 +69,13 @@ const PanelTitle = styled.span` letter-spacing: 0.08em; text-transform: uppercase; color: ${({ theme }) => theme.colors.text.muted}; + flex: 1; +` + +const HeaderActionSlot = styled.div` + display: flex; + align-items: center; + margin-right: 0.25rem; ` const PanelChevron = styled.span<{ $open: boolean }>` diff --git a/guide/engine/src/components/climate-sim/ScenarioTabs.tsx b/guide/engine/src/components/climate-sim/ScenarioTabs.tsx index b5b5ade6..93dde6e9 100644 --- a/guide/engine/src/components/climate-sim/ScenarioTabs.tsx +++ b/guide/engine/src/components/climate-sim/ScenarioTabs.tsx @@ -12,6 +12,7 @@ interface ScenarioGroup { label: string color: string scenarioIds: string[] + planet: string } const SCENARIO_GROUPS: ScenarioGroup[] = [ @@ -19,20 +20,37 @@ const SCENARIO_GROUPS: ScenarioGroup[] = [ id: 'lifeless', label: 'Lifeless Worlds', color: '#78716c', + planet: 'earth', scenarioIds: ['hadean_earth', 'primordial_earth'], }, { id: 'eco', label: 'Eco Disaster', color: '#ef4444', + planet: 'earth', scenarioIds: ['dead_world', 'ecological_collapse', 'trophic_cascade', 'methane_runaway'], }, { id: 'et', label: 'ET Disaster', color: '#7c3aed', + planet: 'earth', scenarioIds: ['flood_basalt', 'supervolcano'], }, + { + id: 'venus_surface', + label: 'Venus Surface', + color: '#f97316', + planet: 'venus', + scenarioIds: ['venus_modern', 'venus_primordial'], + }, + { + id: 'mars_surface', + label: 'Mars Surface', + color: '#c2410c', + planet: 'mars', + scenarioIds: ['mars_modern', 'mars_ancient'], + }, ] function groupForId(scenarioId: string): ScenarioGroup | undefined { @@ -45,11 +63,12 @@ function groupForId(scenarioId: string): ScenarioGroup | undefined { interface ScenarioTabsProps { scenarios: ScenarioConfig[] + activePlanet: string activeId: string onSelect: (id: string) => void } -export function ScenarioTabs({ scenarios, activeId, onSelect }: ScenarioTabsProps): ReactElement { +export function ScenarioTabs({ scenarios, activePlanet, activeId, onSelect }: ScenarioTabsProps): ReactElement { const [openGroup, setOpenGroup] = useState(null) const navRef = useRef(null) @@ -77,9 +96,11 @@ export function ScenarioTabs({ scenarios, activeId, onSelect }: ScenarioTabsProp const activeGroup = groupForId(activeId) const activeScenario = scenarios.find((s) => s.id === activeId) + const planetGroups = SCENARIO_GROUPS.filter((g) => g.planet === activePlanet) + return (