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:
parent
1e8ab650fa
commit
7df293ebfb
5 changed files with 173 additions and 15 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }>`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue