ui(climate-sim): 💄 Refactor and enhance climate simulation visualization components with updated layer controls in LayerPanel, improved stats visualization in StatsDashboard, and optimized display logic in ClimateSimDisplay
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
157feac4fd
commit
278d02373a
3 changed files with 419 additions and 100 deletions
|
|
@ -11,7 +11,8 @@ import {
|
|||
} from '@magic-civ/engine-ts'
|
||||
import { useSimulationWorker } from '@/hooks/useSimulationWorker'
|
||||
import { HexGLRenderer } from './HexGLRenderer'
|
||||
import { LayerPanel } from './LayerPanel'
|
||||
import { LayerPanel, ENVIRONMENT_DEFAULT_MASK, LIFE_DEFAULT_MASK } from './LayerPanel'
|
||||
import type { SimCategory } from './LayerPanel'
|
||||
import { ScenarioTabs } from './ScenarioTabs'
|
||||
import { TerrainLegend } from './TerrainLegend'
|
||||
import { EventLog } from './EventLog'
|
||||
|
|
@ -19,6 +20,11 @@ import { PlayerBar } from './PlayerBar'
|
|||
import { StatsDashboard } from './StatsDashboard'
|
||||
import { ScenarioDescription } from './ScenarioDescription'
|
||||
|
||||
const SIM_CATEGORIES: { id: SimCategory; label: string; tip: string }[] = [
|
||||
{ id: 'environment', label: 'Environment', tip: 'Climate lenses: temperature, moisture, pressure, wind, hydrology, biome' },
|
||||
{ id: 'life', label: 'Life', tip: 'Ecology lenses: canopy, undergrowth, fungi, quality, fish, wildlife' },
|
||||
]
|
||||
|
||||
const VALID_IDS = new Set(SCENARIOS.map((s) => s.id))
|
||||
const VALID_VIEWS = new Set(['equator', 'north', 'south'] as const)
|
||||
type ViewCenter = 'equator' | 'north' | 'south'
|
||||
|
|
@ -38,14 +44,31 @@ function frameAsSnapshot(frame: FramePayload, stats?: { avg_moisture: number; to
|
|||
dominant_ley_school: (stats?.dominant_ley_school ?? '') as GridSnapshot['stats']['dominant_ley_school'],
|
||||
ley_school_strengths: { death: 0, life: 0, nature: 0, aether: 0, chaos: 0 },
|
||||
ley_land_coverage: { death: 0, life: 0, nature: 0, aether: 0, chaos: 0 },
|
||||
ocean_pct: 0,
|
||||
ocean_dead_pct: frame.ocean_dead_fraction,
|
||||
sea_level: 0,
|
||||
avg_albedo: 0,
|
||||
avg_solar: 0,
|
||||
avg_land_flora: 0,
|
||||
avg_land_fauna: 0,
|
||||
avg_marine_flora: 1.0,
|
||||
avg_marine_fauna: 0,
|
||||
avg_land_quality: 1,
|
||||
avg_water_quality: 1,
|
||||
avg_aerosol: 0,
|
||||
avg_evapotranspiration: 0,
|
||||
terrain_counts: {},
|
||||
},
|
||||
events: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function ClimateSimDisplay(): ReactElement {
|
||||
interface ClimateSimProps {
|
||||
initialCategory?: SimCategory
|
||||
onCategoryChange?: (cat: SimCategory) => void
|
||||
}
|
||||
|
||||
export function ClimateSimDisplay({ initialCategory, onCategoryChange }: ClimateSimProps = {}): ReactElement {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
// ── URL state ──────────────────────────────────────────────────────────
|
||||
|
|
@ -79,6 +102,7 @@ export function ClimateSimDisplay(): ReactElement {
|
|||
const [simSpeed, setSimSpeed] = useState(1)
|
||||
const [viewCenter, setViewCenter] = useState<ViewCenter>(initialView)
|
||||
|
||||
const [simCategory, setSimCategory] = useState<SimCategory>(initialCategory ?? 'environment')
|
||||
const [mapHeightPx, setMapHeightPx] = useState<number | null>(() => window.innerHeight * 0.42)
|
||||
|
||||
const rafRef = useRef<number>(0)
|
||||
|
|
@ -178,6 +202,12 @@ export function ClimateSimDisplay(): ReactElement {
|
|||
syncUrl(activeScenarioId, turnRef.current, pausedRef.current, view)
|
||||
}, [activeScenarioId, syncUrl])
|
||||
|
||||
const handleCategoryChange = useCallback((cat: SimCategory) => {
|
||||
setSimCategory(cat)
|
||||
onCategoryChange?.(cat)
|
||||
setLayerMask(cat === 'life' ? LIFE_DEFAULT_MASK : ENVIRONMENT_DEFAULT_MASK)
|
||||
}, [])
|
||||
|
||||
const handleDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const h = mapAreaRef.current?.getBoundingClientRect().height ?? 0
|
||||
|
|
@ -273,6 +303,23 @@ export function ClimateSimDisplay(): ReactElement {
|
|||
// ── render ─────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<SimWrapper>
|
||||
<CategoryBar role="tablist" aria-label="Simulation category">
|
||||
{SIM_CATEGORIES.map(({ id, label, tip }) => (
|
||||
<CategoryTab
|
||||
key={id}
|
||||
role="tab"
|
||||
aria-selected={simCategory === id}
|
||||
$active={simCategory === id}
|
||||
tabIndex={simCategory === id ? 0 : -1}
|
||||
title={tip}
|
||||
onClick={() => handleCategoryChange(id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleCategoryChange(id) } }}
|
||||
>
|
||||
{label}
|
||||
</CategoryTab>
|
||||
))}
|
||||
</CategoryBar>
|
||||
|
||||
<ScenarioTabs
|
||||
scenarios={SCENARIOS}
|
||||
activeId={activeScenarioId}
|
||||
|
|
@ -314,6 +361,7 @@ export function ClimateSimDisplay(): ReactElement {
|
|||
onChange={setLayerMask}
|
||||
viewCenter={viewCenter}
|
||||
onViewCenterChange={handleViewCenterChange}
|
||||
category={simCategory}
|
||||
/>
|
||||
</OverlayTopRight>
|
||||
|
||||
|
|
@ -344,6 +392,7 @@ export function ClimateSimDisplay(): ReactElement {
|
|||
stats={stats}
|
||||
currentTurn={currentTurn}
|
||||
onScrub={handleScrub}
|
||||
category={simCategory}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -484,3 +533,46 @@ const SmallSpinner = styled(Spinner)`
|
|||
width: 20px;
|
||||
height: 20px;
|
||||
`
|
||||
|
||||
const CategoryBar = styled.div`
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: rgba(8, 6, 14, 0.98);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
`
|
||||
|
||||
const CategoryTab = styled.button<{ $active: boolean }>`
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: ${({ $active }) => ($active ? 'rgba(255, 255, 255, 0.06)' : 'transparent')};
|
||||
color: ${({ $active, theme }) => ($active ? '#fff' : theme.colors.text.muted)};
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.625rem 1rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: ${({ $active, theme }) => ($active ? theme.colors.primary.main : 'transparent')};
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${({ theme }) => theme.colors.primary.main};
|
||||
outline-offset: -2px;
|
||||
}
|
||||
`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { useState } from 'react'
|
|||
import type { ReactElement } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export type SimCategory = 'environment' | 'life'
|
||||
|
||||
const LAYERS = [
|
||||
{ bit: 0, label: 'Terrain', icon: '🗺', group: 'climate' },
|
||||
{ bit: 1, label: 'Temperature', icon: '🌡', group: 'climate' },
|
||||
|
|
@ -17,6 +19,17 @@ const LAYERS = [
|
|||
{ bit: 14, label: 'Wildlife', icon: '🦌', group: 'ecology' },
|
||||
] as const
|
||||
|
||||
/** Default layer mask for the Environment category: terrain only. */
|
||||
export const ENVIRONMENT_DEFAULT_MASK = (1 << 0)
|
||||
|
||||
/** Default layer mask for the Life category: canopy + quality + wildlife. */
|
||||
export const LIFE_DEFAULT_MASK = (1 << 9) | (1 << 12) | (1 << 14)
|
||||
|
||||
const CATEGORY_GROUPS: Record<SimCategory, string[]> = {
|
||||
environment: ['climate'],
|
||||
life: ['ecology'],
|
||||
}
|
||||
|
||||
type ViewCenter = 'equator' | 'north' | 'south'
|
||||
|
||||
const VIEW_CENTERS: { id: ViewCenter; label: string; icon: string; tip: string }[] = [
|
||||
|
|
@ -30,15 +43,19 @@ interface LayerPanelProps {
|
|||
onChange: (mask: number) => void
|
||||
viewCenter?: ViewCenter
|
||||
onViewCenterChange?: (center: ViewCenter) => void
|
||||
/** When set, only show layers belonging to this category. */
|
||||
category?: SimCategory
|
||||
}
|
||||
|
||||
export function LayerPanel({ layerMask, onChange, viewCenter = 'equator', onViewCenterChange }: LayerPanelProps): ReactElement {
|
||||
export function LayerPanel({ layerMask, onChange, viewCenter = 'equator', onViewCenterChange, category }: LayerPanelProps): ReactElement {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
const toggle = (bit: number): void => {
|
||||
onChange(layerMask ^ (1 << bit))
|
||||
}
|
||||
|
||||
const visibleGroups = category ? CATEGORY_GROUPS[category] : ['climate', 'ecology']
|
||||
|
||||
const renderLayerGroup = (group: string) =>
|
||||
LAYERS.filter(l => l.group === group).map(({ bit, label, icon }) => {
|
||||
const active = (layerMask & (1 << bit)) !== 0
|
||||
|
|
@ -65,10 +82,10 @@ export function LayerPanel({ layerMask, onChange, viewCenter = 'equator', onView
|
|||
</PanelHeader>
|
||||
{!collapsed && (
|
||||
<>
|
||||
{renderLayerGroup('climate')}
|
||||
<PanelDivider />
|
||||
<GroupTitle>Ecology</GroupTitle>
|
||||
{renderLayerGroup('ecology')}
|
||||
{visibleGroups.includes('climate') && renderLayerGroup('climate')}
|
||||
{visibleGroups.includes('climate') && visibleGroups.includes('ecology') && <PanelDivider />}
|
||||
{visibleGroups.includes('ecology') && !category && <GroupTitle>Ecology</GroupTitle>}
|
||||
{visibleGroups.includes('ecology') && renderLayerGroup('ecology')}
|
||||
</>
|
||||
)}
|
||||
{!collapsed && onViewCenterChange && (
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import type { TurnStats, LeySchool } from '@magic-civ/engine-ts'
|
|||
|
||||
// ── constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const SPARKLINE_H = 24
|
||||
const TERRAIN_CHART_W = 260
|
||||
const LEY_STRIP_H = 36
|
||||
const PRIMARY_H = 28
|
||||
const COMPACT_H = 18
|
||||
const TERRAIN_CHART_W = 200
|
||||
|
||||
const SCHOOLS: readonly LeySchool[] = ['death', 'life', 'nature', 'aether', 'chaos'] as const
|
||||
|
||||
|
|
@ -44,33 +44,127 @@ const TEMP_PHASE_BANDS: PhaseBand[] = [
|
|||
interface MetricDef {
|
||||
key: string
|
||||
label: string
|
||||
tooltip: string
|
||||
color: string
|
||||
getValue: (s: TurnStats) => number
|
||||
formatValue: (v: number) => string
|
||||
formatDelta: (d: number) => string
|
||||
phaseBands?: PhaseBand[]
|
||||
gradientColors?: string[]
|
||||
}
|
||||
|
||||
const METRICS: MetricDef[] = [
|
||||
const PRIMARY_METRICS: MetricDef[] = [
|
||||
{
|
||||
key: 'temp', label: 'Temp', color: '#E85D3A',
|
||||
key: 'temp', label: 'Temp', tooltip: 'Average land temperature (0=frozen, 1=scorching). Driven by solar input, albedo, and orbital cycles.', color: '#E85D3A',
|
||||
getValue: (s) => s.avg_temp,
|
||||
formatValue: (v) => v.toFixed(2),
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(2),
|
||||
phaseBands: TEMP_PHASE_BANDS,
|
||||
},
|
||||
{
|
||||
key: 'moisture', label: 'Moisture', color: '#26A69A',
|
||||
key: 'moisture', label: 'Moisture', tooltip: 'Average land moisture (0=bone dry, 1=saturated). Driven by ocean evaporation, wind transport, and precipitation.', color: '#26A69A',
|
||||
getValue: (s) => s.avg_moisture,
|
||||
formatValue: (v) => v.toFixed(2),
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(2),
|
||||
},
|
||||
]
|
||||
|
||||
// Left column: LAND metrics
|
||||
const COMPACT_LEFT: MetricDef[] = [
|
||||
{
|
||||
key: 'ocean_dead', label: 'Ocean☠', color: '#EF5350',
|
||||
getValue: (s) => s.ocean_dead_pct,
|
||||
formatValue: (v) => (v * 100).toFixed(1) + '%',
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + (d * 100).toFixed(1) + '%',
|
||||
key: 'land_flora', label: 'Flora', tooltip: 'Average canopy cover across land tiles (0=barren, 1=full canopy). Forests and jungles are high; deserts and tundra near zero.', color: '#66BB6A',
|
||||
getValue: (s) => s.avg_land_flora,
|
||||
formatValue: (v) => v.toFixed(3),
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(3),
|
||||
},
|
||||
{
|
||||
key: 'land_fauna', label: 'Fauna', tooltip: 'Average habitat suitability across land tiles (0=inhospitable, 1=thriving). Combines flora density, moisture, and temperature.', color: '#8D6E63',
|
||||
getValue: (s) => s.avg_land_fauna,
|
||||
formatValue: (v) => v.toFixed(3),
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(3),
|
||||
},
|
||||
{
|
||||
key: 'land_quality', label: 'Quality', tooltip: 'Average tile quality across land (1-5). Rises when climate matches ideal biome, falls when it shifts away. Higher = better yields.', color: '#FFD54F',
|
||||
getValue: (s) => s.avg_land_quality,
|
||||
formatValue: (v) => v.toFixed(2),
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(2),
|
||||
},
|
||||
{
|
||||
key: 'evapotrans', label: 'ET', tooltip: 'Average evapotranspiration across land tiles. Moisture recycled by vegetation per turn. Forests contribute most; deserts are negative.', color: '#80DEEA',
|
||||
getValue: (s) => s.avg_evapotranspiration,
|
||||
formatValue: (v) => v.toFixed(4),
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(4),
|
||||
},
|
||||
]
|
||||
|
||||
// Right column: WATER + atmosphere metrics
|
||||
const COMPACT_RIGHT: MetricDef[] = [
|
||||
{
|
||||
key: 'marine_flora', label: 'Flora', tooltip: 'Average reef health across coastal tiles (1=healthy coral, 0=dead). Bleaching from high temps (>0.75) destroys reefs. Dead reefs reduce evaporation.', color: '#29B6F6',
|
||||
getValue: (s) => s.avg_marine_flora,
|
||||
formatValue: (v) => v.toFixed(3),
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(3),
|
||||
},
|
||||
{
|
||||
key: 'marine_fauna', label: 'Fauna', tooltip: 'Average fish stock across coastal tiles (0=depleted, 1=abundant). Depends on reef health and water temperature.', color: '#26C6DA',
|
||||
getValue: (s) => s.avg_marine_fauna,
|
||||
formatValue: (v) => v.toFixed(3),
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(3),
|
||||
},
|
||||
{
|
||||
key: 'water_quality', label: 'Quality', tooltip: 'Average tile quality across water tiles (1-5). Reflects ocean and coastal ecosystem health.', color: '#42A5F5',
|
||||
getValue: (s) => s.avg_water_quality,
|
||||
formatValue: (v) => v.toFixed(2),
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(2),
|
||||
},
|
||||
{
|
||||
key: 'sea_level', label: 'Sea Lvl', tooltip: 'Elevation threshold for water. Rises with warming (thermal expansion + ice melt), falls with cooling. Land below this floods.', color: '#5C6BC0',
|
||||
getValue: (s) => s.sea_level,
|
||||
formatValue: (v) => v.toFixed(3),
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(3),
|
||||
},
|
||||
]
|
||||
|
||||
// Environment mode right column
|
||||
const ENV_RIGHT_METRICS: MetricDef[] = [
|
||||
{
|
||||
key: 'sea_level', label: 'Sea Lvl', tooltip: 'Elevation threshold for water. Rises with warming (thermal expansion + ice melt), falls with cooling. Land below this floods.', color: '#5C6BC0',
|
||||
getValue: (s) => s.sea_level,
|
||||
formatValue: (v) => v.toFixed(3),
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(3),
|
||||
},
|
||||
{
|
||||
key: 'evapotrans', label: 'ET', tooltip: 'Average evapotranspiration across land tiles. Moisture recycled by vegetation per turn.', color: '#80DEEA',
|
||||
getValue: (s) => s.avg_evapotranspiration,
|
||||
formatValue: (v) => v.toFixed(4),
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(4),
|
||||
},
|
||||
{
|
||||
key: 'land_quality', label: 'Land Qlty', tooltip: 'Average tile quality across land (1-5). Rises when climate matches ideal biome.', color: '#FFD54F',
|
||||
getValue: (s) => s.avg_land_quality,
|
||||
formatValue: (v) => v.toFixed(2),
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(2),
|
||||
},
|
||||
]
|
||||
|
||||
// Environment mode left column: atmosphere
|
||||
const ATMOSPHERE_METRICS: MetricDef[] = [
|
||||
{
|
||||
key: 'solar', label: 'Solar', tooltip: 'Average absorbed solar energy after albedo reflection. The net heat input driving temperature.', color: '#FFA726',
|
||||
getValue: (s) => s.avg_solar,
|
||||
formatValue: (v) => v.toFixed(3),
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(3),
|
||||
},
|
||||
{
|
||||
key: 'albedo', label: 'Albedo', tooltip: 'Global average surface reflectivity (0=absorbs all, 1=reflects all). Ice/snow raise it, forests/water lower it.', color: '#BDBDBD',
|
||||
getValue: (s) => s.avg_albedo,
|
||||
formatValue: (v) => v.toFixed(3),
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(3),
|
||||
},
|
||||
{
|
||||
key: 'aerosol', label: 'Aerosol', tooltip: 'Sulfate aerosol opacity from volcanic/impact events. Blocks sunlight, causing cooling and drying. Decays over ~20 turns.', color: '#AB47BC',
|
||||
getValue: (s) => s.avg_aerosol,
|
||||
formatValue: (v) => v.toFixed(4),
|
||||
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(4),
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -97,54 +191,111 @@ const TERRAIN_GROUPS: TerrainGroup[] = [
|
|||
|
||||
// ── props ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type SimCategory = 'environment' | 'life'
|
||||
|
||||
interface StatsDashboardProps {
|
||||
stats: TurnStats[]
|
||||
currentTurn: number
|
||||
onScrub: (turn: number) => void
|
||||
category?: SimCategory
|
||||
}
|
||||
|
||||
// ── main component ─────────────────────────────────────────────────────────
|
||||
|
||||
export function StatsDashboard({ stats, currentTurn, onScrub }: StatsDashboardProps): ReactElement {
|
||||
export function StatsDashboard({ stats, currentTurn, onScrub, category = 'environment' }: StatsDashboardProps): ReactElement {
|
||||
const hasLey = stats.some((s) => s.total_ley_strength > 0)
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<TopGrid>
|
||||
<TimeSeriesPanel stats={stats} currentTurn={currentTurn} onScrub={onScrub} />
|
||||
<TerrainDistChart stats={stats} currentTurn={currentTurn} onScrub={onScrub} />
|
||||
</TopGrid>
|
||||
{hasLey && <LeyStrip stats={stats} currentTurn={currentTurn} onScrub={onScrub} />}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
// ── time series panel (4 sparkline rows) ───────────────────────────────────
|
||||
|
||||
function TimeSeriesPanel({ stats, currentTurn, onScrub }: StatsDashboardProps): ReactElement {
|
||||
const currentStats = stats[currentTurn]
|
||||
const baseStats = stats[0]
|
||||
|
||||
return (
|
||||
<SparklineStack>
|
||||
{METRICS.map((def) => {
|
||||
const val = currentStats ? def.getValue(currentStats) : 0
|
||||
const baseVal = baseStats ? def.getValue(baseStats) : 0
|
||||
const delta = val - baseVal
|
||||
<Wrapper>
|
||||
<TopGrid>
|
||||
<LeftColumn>
|
||||
{/* Primary metrics — full-width sparklines */}
|
||||
<PrimaryStack>
|
||||
{PRIMARY_METRICS.map((def) => {
|
||||
const val = currentStats ? def.getValue(currentStats) : 0
|
||||
const baseVal = baseStats ? def.getValue(baseStats) : 0
|
||||
const delta = val - baseVal
|
||||
return (
|
||||
<PrimaryRow key={def.key}>
|
||||
<PLabel title={def.tooltip}>{def.label}</PLabel>
|
||||
<SparklineCanvas stats={stats} currentTurn={currentTurn} def={def} onScrub={onScrub} height={PRIMARY_H} />
|
||||
<PValue style={{ color: def.color }}>{def.formatValue(val)}</PValue>
|
||||
<PDelta $positive={delta > 0.0005} $negative={delta < -0.0005}>
|
||||
{Math.abs(delta) < 0.0005 ? '\u2014' : (delta > 0 ? '\u25B2' : '\u25BC')}{' '}
|
||||
{Math.abs(delta) < 0.0005 ? '0' : def.formatDelta(delta)}
|
||||
</PDelta>
|
||||
</PrimaryRow>
|
||||
)
|
||||
})}
|
||||
</PrimaryStack>
|
||||
|
||||
return (
|
||||
<SparklineRow key={def.key}>
|
||||
<MetricLabel>{def.label}</MetricLabel>
|
||||
<SparklineCanvas stats={stats} currentTurn={currentTurn} def={def} onScrub={onScrub} />
|
||||
<MetricValue style={{ color: def.color }}>{def.formatValue(val)}</MetricValue>
|
||||
<DeltaValue $positive={delta > 0.0005} $negative={delta < -0.0005}>
|
||||
{Math.abs(delta) < 0.0005 ? '\u2014' : (delta > 0 ? '\u25B2' : '\u25BC')}{' '}
|
||||
{Math.abs(delta) < 0.0005 ? '0' : def.formatDelta(delta)}
|
||||
</DeltaValue>
|
||||
</SparklineRow>
|
||||
)
|
||||
})}
|
||||
</SparklineStack>
|
||||
{category === 'life' ? (
|
||||
/* Life mode: Land vs Water ecology */
|
||||
<CompactColumns>
|
||||
<CompactCol>
|
||||
<ColHeader>Land</ColHeader>
|
||||
{COMPACT_LEFT.map((def) => {
|
||||
const val = currentStats ? def.getValue(currentStats) : 0
|
||||
return (
|
||||
<CompactCell key={def.key}>
|
||||
<CLabel title={def.tooltip}>{def.label}</CLabel>
|
||||
<SparklineCanvas stats={stats} currentTurn={currentTurn} def={def} onScrub={onScrub} height={COMPACT_H} />
|
||||
<CValue style={{ color: def.color }}>{def.formatValue(val)}</CValue>
|
||||
</CompactCell>
|
||||
)
|
||||
})}
|
||||
</CompactCol>
|
||||
<CompactCol>
|
||||
<ColHeader>Water</ColHeader>
|
||||
{COMPACT_RIGHT.map((def) => {
|
||||
const val = currentStats ? def.getValue(currentStats) : 0
|
||||
return (
|
||||
<CompactCell key={def.key}>
|
||||
<CLabel title={def.tooltip}>{def.label}</CLabel>
|
||||
<SparklineCanvas stats={stats} currentTurn={currentTurn} def={def} onScrub={onScrub} height={COMPACT_H} />
|
||||
<CValue style={{ color: def.color }}>{def.formatValue(val)}</CValue>
|
||||
</CompactCell>
|
||||
)
|
||||
})}
|
||||
</CompactCol>
|
||||
</CompactColumns>
|
||||
) : (
|
||||
/* Environment mode: atmosphere + sea level metrics */
|
||||
<CompactColumns>
|
||||
<CompactCol>
|
||||
{ATMOSPHERE_METRICS.map((def) => {
|
||||
const val = currentStats ? def.getValue(currentStats) : 0
|
||||
return (
|
||||
<CompactCell key={def.key}>
|
||||
<CLabel title={def.tooltip}>{def.label}</CLabel>
|
||||
<SparklineCanvas stats={stats} currentTurn={currentTurn} def={def} onScrub={onScrub} height={COMPACT_H} />
|
||||
<CValue style={{ color: def.color }}>{def.formatValue(val)}</CValue>
|
||||
</CompactCell>
|
||||
)
|
||||
})}
|
||||
</CompactCol>
|
||||
<CompactCol>
|
||||
{ENV_RIGHT_METRICS.map((def) => {
|
||||
const val = currentStats ? def.getValue(currentStats) : 0
|
||||
return (
|
||||
<CompactCell key={def.key}>
|
||||
<CLabel title={def.tooltip}>{def.label}</CLabel>
|
||||
<SparklineCanvas stats={stats} currentTurn={currentTurn} def={def} onScrub={onScrub} height={COMPACT_H} />
|
||||
<CValue style={{ color: def.color }}>{def.formatValue(val)}</CValue>
|
||||
</CompactCell>
|
||||
)
|
||||
})}
|
||||
</CompactCol>
|
||||
</CompactColumns>
|
||||
)}
|
||||
</LeftColumn>
|
||||
|
||||
<TerrainDistChart stats={stats} currentTurn={currentTurn} onScrub={onScrub} />
|
||||
</TopGrid>
|
||||
{hasLey && <LeyStrip stats={stats} currentTurn={currentTurn} onScrub={onScrub} />}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -155,9 +306,10 @@ interface SparklineCanvasProps {
|
|||
currentTurn: number
|
||||
def: MetricDef
|
||||
onScrub: (turn: number) => void
|
||||
height: number
|
||||
}
|
||||
|
||||
function SparklineCanvas({ stats, currentTurn, def, onScrub }: SparklineCanvasProps): ReactElement {
|
||||
function SparklineCanvas({ stats, currentTurn, def, onScrub, height }: SparklineCanvasProps): ReactElement {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const dpr = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio, 2) : 1
|
||||
|
|
@ -170,7 +322,6 @@ function SparklineCanvas({ stats, currentTurn, def, onScrub }: SparklineCanvasPr
|
|||
if (!ctx) return
|
||||
|
||||
const width = container.clientWidth
|
||||
const height = SPARKLINE_H
|
||||
const w = width * dpr
|
||||
const h = height * dpr
|
||||
canvas.width = w
|
||||
|
|
@ -207,34 +358,22 @@ function SparklineCanvas({ stats, currentTurn, def, onScrub }: SparklineCanvasPr
|
|||
ctx.lineWidth = 1.5
|
||||
ctx.stroke()
|
||||
|
||||
// Filled area
|
||||
if (def.gradientColors) {
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, height)
|
||||
gradient.addColorStop(0, def.gradientColors[1])
|
||||
gradient.addColorStop(1, def.gradientColors[0])
|
||||
ctx.lineTo(width, height)
|
||||
ctx.lineTo(0, height)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// Playhead
|
||||
const px = (currentTurn / (values.length - 1)) * width
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(px, 0)
|
||||
ctx.lineTo(px, height)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.5)'
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.4)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke()
|
||||
|
||||
// Current value dot
|
||||
const cy = height - ((values[currentTurn] - minVal) / range) * (height - 2) - 1
|
||||
ctx.beginPath()
|
||||
ctx.arc(px, cy, 2.5, 0, Math.PI * 2)
|
||||
ctx.arc(px, cy, 2, 0, Math.PI * 2)
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.fill()
|
||||
}, [stats, currentTurn, def, dpr])
|
||||
}, [stats, currentTurn, def, dpr, height])
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (stats.length === 0) return
|
||||
|
|
@ -245,7 +384,7 @@ function SparklineCanvas({ stats, currentTurn, def, onScrub }: SparklineCanvasPr
|
|||
}, [onScrub, stats.length])
|
||||
|
||||
return (
|
||||
<CanvasWrap ref={containerRef}>
|
||||
<CanvasWrap ref={containerRef} $h={height}>
|
||||
<canvas ref={canvasRef} onClick={handleClick} style={{ cursor: 'crosshair', display: 'block' }} />
|
||||
</CanvasWrap>
|
||||
)
|
||||
|
|
@ -257,7 +396,6 @@ function TerrainDistChart({ stats, currentTurn, onScrub }: StatsDashboardProps):
|
|||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const dpr = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio, 2) : 1
|
||||
|
||||
// Precompute grouped proportions for all turns
|
||||
const groupedData = stats.map((s) => {
|
||||
const counts = TERRAIN_GROUPS.map((g) =>
|
||||
g.ids.reduce((sum, id) => sum + (s.terrain_counts[id] ?? 0), 0),
|
||||
|
|
@ -266,8 +404,13 @@ function TerrainDistChart({ stats, currentTurn, onScrub }: StatsDashboardProps):
|
|||
return counts.map((c) => c / total)
|
||||
})
|
||||
|
||||
const chartH = METRICS.length * (SPARKLINE_H + 3) - 3 // match sparkline stack height
|
||||
const legendH = 18
|
||||
// Match total height of primary + compact sections
|
||||
// Height matches the tallest possible content (life mode with headers)
|
||||
const maxCompactRows = Math.max(COMPACT_LEFT.length, COMPACT_RIGHT.length) + 1
|
||||
const chartH = PRIMARY_METRICS.length * (PRIMARY_H + 3)
|
||||
+ 6
|
||||
+ maxCompactRows * (COMPACT_H + 3)
|
||||
const legendH = 16
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
|
|
@ -288,7 +431,6 @@ function TerrainDistChart({ stats, currentTurn, onScrub }: StatsDashboardProps):
|
|||
|
||||
const colW = width / stats.length
|
||||
|
||||
// Draw stacked columns
|
||||
for (let t = 0; t < groupedData.length; t++) {
|
||||
const proportions = groupedData[t]
|
||||
const x = t * colW
|
||||
|
|
@ -304,12 +446,11 @@ function TerrainDistChart({ stats, currentTurn, onScrub }: StatsDashboardProps):
|
|||
}
|
||||
}
|
||||
|
||||
// Playhead
|
||||
const px = (currentTurn / (stats.length - 1)) * width
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(px, 0)
|
||||
ctx.lineTo(px, height)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.5)'
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.4)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke()
|
||||
}, [stats, currentTurn, groupedData, chartH, dpr])
|
||||
|
|
@ -382,6 +523,8 @@ function LeyStrip({ stats, currentTurn, onScrub }: StatsDashboardProps): ReactEl
|
|||
|
||||
// ── ley coverage stacked area ──────────────────────────────────────────────
|
||||
|
||||
const LEY_STRIP_H = 36
|
||||
|
||||
function LeyCoverageChart({ stats, currentTurn, onScrub }: StatsDashboardProps): ReactElement {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -405,7 +548,6 @@ function LeyCoverageChart({ stats, currentTurn, onScrub }: StatsDashboardProps):
|
|||
ctx.clearRect(0, 0, w, h)
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
// Find max total coverage for scaling
|
||||
let maxTotal = 0
|
||||
for (const s of stats) {
|
||||
const total = SCHOOLS.reduce((sum, sc) => sum + s.ley_land_coverage[sc], 0)
|
||||
|
|
@ -430,12 +572,11 @@ function LeyCoverageChart({ stats, currentTurn, onScrub }: StatsDashboardProps):
|
|||
}
|
||||
}
|
||||
|
||||
// Playhead
|
||||
const px = (currentTurn / (stats.length - 1)) * width
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(px, 0)
|
||||
ctx.lineTo(px, height)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.5)'
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.4)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke()
|
||||
}, [stats, currentTurn, dpr])
|
||||
|
|
@ -472,48 +613,50 @@ const TopGrid = styled.div`
|
|||
gap: 0.5rem;
|
||||
`
|
||||
|
||||
// ── sparkline stack ───────────────────────────────
|
||||
const LeftColumn = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
`
|
||||
|
||||
const SparklineStack = styled.div`
|
||||
// ── primary sparklines ───────────────────────────────
|
||||
|
||||
const PrimaryStack = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
`
|
||||
|
||||
const SparklineRow = styled.div`
|
||||
const PrimaryRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
height: ${SPARKLINE_H}px;
|
||||
height: ${PRIMARY_H}px;
|
||||
`
|
||||
|
||||
const MetricLabel = styled.span`
|
||||
const PLabel = styled.span`
|
||||
font-family: monospace;
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
width: 48px;
|
||||
width: 52px;
|
||||
flex-shrink: 0;
|
||||
cursor: help;
|
||||
`
|
||||
|
||||
const CanvasWrap = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: ${SPARKLINE_H}px;
|
||||
`
|
||||
|
||||
const MetricValue = styled.span`
|
||||
const PValue = styled.span`
|
||||
font-family: monospace;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
min-width: 40px;
|
||||
min-width: 42px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const DeltaValue = styled.span<{ $positive: boolean; $negative: boolean }>`
|
||||
const PDelta = styled.span<{ $positive: boolean; $negative: boolean }>`
|
||||
font-family: monospace;
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 500;
|
||||
|
|
@ -524,7 +667,74 @@ const DeltaValue = styled.span<{ $positive: boolean; $negative: boolean }>`
|
|||
$positive ? '#4CAF50' : $negative ? '#EF5350' : 'rgba(255,255,255,0.25)'};
|
||||
`
|
||||
|
||||
// ── terrain distribution ──────────────────────────
|
||||
// ── compact metrics columns ──────────────────────────
|
||||
|
||||
const CompactColumns = styled.div`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
`
|
||||
|
||||
const CompactCol = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
`
|
||||
|
||||
const ColHeader = styled.span`
|
||||
font-family: monospace;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
height: ${COMPACT_H}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
|
||||
const CompactCell = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: ${COMPACT_H}px;
|
||||
`
|
||||
|
||||
const CLabel = styled.span`
|
||||
font-family: monospace;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
width: 44px;
|
||||
flex-shrink: 0;
|
||||
cursor: help;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`
|
||||
|
||||
const CValue = styled.span`
|
||||
font-family: monospace;
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
// ── shared canvas wrapper ────────────────────────────
|
||||
|
||||
const CanvasWrap = styled.div<{ $h: number }>`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: ${({ $h }) => $h}px;
|
||||
`
|
||||
|
||||
// ── terrain distribution ──────────────────────────────
|
||||
|
||||
const TerrainColumn = styled.div`
|
||||
display: flex;
|
||||
|
|
@ -534,11 +744,11 @@ const TerrainColumn = styled.div`
|
|||
|
||||
const TerrainHeader = styled.span`
|
||||
font-family: monospace;
|
||||
font-size: 0.5625rem;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
`
|
||||
|
||||
const TerrainLegendRow = styled.div`
|
||||
|
|
@ -562,11 +772,11 @@ const LegendDot = styled.div`
|
|||
|
||||
const LegendAbbr = styled.span`
|
||||
font-family: monospace;
|
||||
font-size: 0.5rem;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-size: 0.4375rem;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
`
|
||||
|
||||
// ── ley strip ─────────────────────────────────────
|
||||
// ── ley strip ─────────────────────────────────────────
|
||||
|
||||
const LeyStripWrap = styled.div`
|
||||
display: flex;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue