ui(climate-sim): 💄 Improve biome reference display, climate simulation layout, map overlay panel, scenario tabs, and terrain legend styling for enhanced clarity and interactivity

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-29 10:07:31 -07:00
parent 1e8ab650fa
commit 7df293ebfb
5 changed files with 173 additions and 15 deletions

View file

@ -131,7 +131,7 @@ interface BiomeReferenceProps {
presentBiomes?: ReadonlySet<string>
}
export function BiomeReference({ layerMask, presentBiomes }: BiomeReferenceProps): ReactElement | null {
export function BiomeReferenceContent({ layerMask, presentBiomes }: BiomeReferenceProps): ReactElement | null {
const [hoveredBiome, setHoveredBiome] = useState<string | null>(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 (
<MapOverlayPanel title="Biome Reference">
<>
<Intro>
Classification tree from <code>classifyBiome()</code>. Each biome shows
the decision path and example input values that produce it.
@ -189,6 +189,15 @@ export function BiomeReference({ layerMask, presentBiomes }: BiomeReferenceProps
<FooterNote>T = temperature [0,1] · M = moisture [0,1] · E = elevation [0,1] · C = canopy [0,1]</FooterNote>
<FooterNote>Decision priority: Special Mangrove Wetland Elevation Temperature Polar</FooterNote>
</Footer>
</>
)
}
export function BiomeReference({ layerMask, presentBiomes }: BiomeReferenceProps): ReactElement | null {
if ((layerMask & ((1 << 5) | (1 << 0))) === 0) return null
return (
<MapOverlayPanel title="Biome Reference">
<BiomeReferenceContent layerMask={layerMask} presentBiomes={presentBiomes} />
</MapOverlayPanel>
)
}

View file

@ -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
</CategoryBar>
<ScenarioTabs
scenarios={SCENARIOS}
scenarios={planetScenarios}
activePlanet={activePlanet}
activeId={activeScenarioId}
onSelect={handleSelectScenario}
/>
@ -621,7 +635,6 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React
<OverlayTopLeft>
<TerrainLegend layerMask={layerMask} presentBiomes={presentBiomes} />
<BiomeReference layerMask={layerMask} presentBiomes={presentBiomes} />
</OverlayTopLeft>
<OverlayBottomLeft>

View file

@ -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) } }}
>
<PanelTitle>{title}</PanelTitle>
{headerAction && (
<HeaderActionSlot onClick={(e) => e.stopPropagation()}>
{headerAction}
</HeaderActionSlot>
)}
<PanelChevron $open={expanded} aria-hidden="true">{'\u25B8'}</PanelChevron>
</PanelHeader>
{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 }>`

View file

@ -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<string | null>(null)
const navRef = useRef<HTMLDivElement>(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 (
<Nav ref={navRef} role="navigation" aria-label="Climate scenario groups">
{SCENARIO_GROUPS.map((group) => {
{planetGroups.map((group) => {
const groupScenarios = scenarios.filter((s) => group.scenarioIds.includes(s.id))
const isActiveGroup = activeGroup?.id === group.id
const isOpen = openGroup === group.id

View file

@ -1,6 +1,9 @@
import { useState } from 'react'
import { createPortal } from 'react-dom'
import type { ReactElement } from 'react'
import styled from 'styled-components'
import { MapOverlayPanel, PanelSectionLabel } from './MapOverlayPanel'
import { BiomeReferenceContent } from './BiomeReference'
// Indices match TERRAIN_ORDER in runner.ts and terrainColor() in hexGLShaders.ts
// Only the first 31 (non-magic) entries are shown in the legend
@ -88,6 +91,7 @@ interface TerrainLegendProps {
}
export function TerrainLegend({ layerMask, presentBiomes }: TerrainLegendProps): ReactElement | null {
const [refOpen, setRefOpen] = useState(false)
const showTerrain = has(layerMask, 5) || has(layerMask, 0)
const showTemp = has(layerMask, 1)
const showMoisture = has(layerMask, 2)
@ -107,7 +111,19 @@ export function TerrainLegend({ layerMask, presentBiomes }: TerrainLegendProps):
if (!hasContent) return null
return (
<MapOverlayPanel title="Legend">
<>
<MapOverlayPanel
title="Legend"
headerAction={showTerrain ? (
<InfoButton
onClick={() => setRefOpen(true)}
title="Biome classification reference"
aria-label="Open biome reference"
>
</InfoButton>
) : undefined}
>
<LegendSections>
{/* ── Terrain biomes ── */}
{showTerrain && (
@ -261,6 +277,21 @@ export function TerrainLegend({ layerMask, presentBiomes }: TerrainLegendProps):
)}
</LegendSections>
</MapOverlayPanel>
{refOpen && createPortal(
<ModalBackdrop onClick={() => setRefOpen(false)}>
<ModalPanel onClick={(e) => e.stopPropagation()}>
<ModalHeader>
<ModalTitle>Biome Reference</ModalTitle>
<ModalClose onClick={() => setRefOpen(false)} aria-label="Close">×</ModalClose>
</ModalHeader>
<ModalBody>
<BiomeReferenceContent layerMask={layerMask} presentBiomes={presentBiomes} />
</ModalBody>
</ModalPanel>
</ModalBackdrop>,
document.body
)}
</>
)
}
@ -379,3 +410,74 @@ const TierLabel = styled.span`
font-size: 0.5625rem;
color: ${({ theme }) => theme.colors.text.secondary};
`
const InfoButton = styled.button`
background: none;
border: none;
padding: 0 3px;
cursor: pointer;
font-size: 0.625rem;
color: ${({ theme }) => theme.colors.text.muted};
opacity: 0.6;
line-height: 1;
&:hover { opacity: 1; color: ${({ theme }) => theme.colors.text.secondary}; }
`
const ModalBackdrop = styled.div`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
`
const ModalPanel = styled.div`
background: rgba(13, 10, 22, 0.98);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
backdrop-filter: blur(16px);
width: min(520px, 90vw);
max-height: 80vh;
display: flex;
flex-direction: column;
`
const ModalHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.875rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
`
const ModalTitle = styled.span`
font-family: monospace;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: ${({ theme }) => theme.colors.text.muted};
`
const ModalClose = styled.button`
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
line-height: 1;
color: ${({ theme }) => theme.colors.text.muted};
opacity: 0.6;
padding: 0 2px;
&:hover { opacity: 1; }
`
const ModalBody = styled.div`
padding: 0.75rem 0.875rem;
overflow-y: auto;
flex: 1;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
`