diff --git a/guide/engine/src/components/climate-sim/ClimateSimDisplay.tsx b/guide/engine/src/components/climate-sim/ClimateSimDisplay.tsx index 8360dff3..6bc1d591 100644 --- a/guide/engine/src/components/climate-sim/ClimateSimDisplay.tsx +++ b/guide/engine/src/components/climate-sim/ClimateSimDisplay.tsx @@ -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(initialView) + const [simCategory, setSimCategory] = useState(initialCategory ?? 'environment') const [mapHeightPx, setMapHeightPx] = useState(() => window.innerHeight * 0.42) const rafRef = useRef(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 ( + + {SIM_CATEGORIES.map(({ id, label, tip }) => ( + handleCategoryChange(id)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleCategoryChange(id) } }} + > + {label} + + ))} + + @@ -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; + } +` diff --git a/guide/engine/src/components/climate-sim/LayerPanel.tsx b/guide/engine/src/components/climate-sim/LayerPanel.tsx index 447caaec..6a794447 100644 --- a/guide/engine/src/components/climate-sim/LayerPanel.tsx +++ b/guide/engine/src/components/climate-sim/LayerPanel.tsx @@ -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 = { + 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 {!collapsed && ( <> - {renderLayerGroup('climate')} - - Ecology - {renderLayerGroup('ecology')} + {visibleGroups.includes('climate') && renderLayerGroup('climate')} + {visibleGroups.includes('climate') && visibleGroups.includes('ecology') && } + {visibleGroups.includes('ecology') && !category && Ecology} + {visibleGroups.includes('ecology') && renderLayerGroup('ecology')} )} {!collapsed && onViewCenterChange && ( diff --git a/guide/engine/src/components/climate-sim/StatsDashboard.tsx b/guide/engine/src/components/climate-sim/StatsDashboard.tsx index 456eed86..2419508b 100644 --- a/guide/engine/src/components/climate-sim/StatsDashboard.tsx +++ b/guide/engine/src/components/climate-sim/StatsDashboard.tsx @@ -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 ( - - - - - - {hasLey && } - - ) -} - -// ── time series panel (4 sparkline rows) ─────────────────────────────────── - -function TimeSeriesPanel({ stats, currentTurn, onScrub }: StatsDashboardProps): ReactElement { const currentStats = stats[currentTurn] const baseStats = stats[0] return ( - - {METRICS.map((def) => { - const val = currentStats ? def.getValue(currentStats) : 0 - const baseVal = baseStats ? def.getValue(baseStats) : 0 - const delta = val - baseVal + + + + {/* Primary metrics — full-width sparklines */} + + {PRIMARY_METRICS.map((def) => { + const val = currentStats ? def.getValue(currentStats) : 0 + const baseVal = baseStats ? def.getValue(baseStats) : 0 + const delta = val - baseVal + return ( + + {def.label} + + {def.formatValue(val)} + 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)} + + + ) + })} + - return ( - - {def.label} - - {def.formatValue(val)} - 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)} - - - ) - })} - + {category === 'life' ? ( + /* Life mode: Land vs Water ecology */ + + + Land + {COMPACT_LEFT.map((def) => { + const val = currentStats ? def.getValue(currentStats) : 0 + return ( + + {def.label} + + {def.formatValue(val)} + + ) + })} + + + Water + {COMPACT_RIGHT.map((def) => { + const val = currentStats ? def.getValue(currentStats) : 0 + return ( + + {def.label} + + {def.formatValue(val)} + + ) + })} + + + ) : ( + /* Environment mode: atmosphere + sea level metrics */ + + + {ATMOSPHERE_METRICS.map((def) => { + const val = currentStats ? def.getValue(currentStats) : 0 + return ( + + {def.label} + + {def.formatValue(val)} + + ) + })} + + + {ENV_RIGHT_METRICS.map((def) => { + const val = currentStats ? def.getValue(currentStats) : 0 + return ( + + {def.label} + + {def.formatValue(val)} + + ) + })} + + + )} + + + + + {hasLey && } + ) } @@ -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(null) const containerRef = useRef(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) => { if (stats.length === 0) return @@ -245,7 +384,7 @@ function SparklineCanvas({ stats, currentTurn, def, onScrub }: SparklineCanvasPr }, [onScrub, stats.length]) return ( - + ) @@ -257,7 +396,6 @@ function TerrainDistChart({ stats, currentTurn, onScrub }: StatsDashboardProps): const canvasRef = useRef(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(null) const containerRef = useRef(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;