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:
Claude Code 2026-03-26 01:06:56 -07:00
parent 157feac4fd
commit 278d02373a
3 changed files with 419 additions and 100 deletions

View file

@ -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;
}
`

View file

@ -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 && (

View file

@ -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;