feat(climate-sim): Add seed input validation props and climate simulation stats dashboard charts

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-29 06:07:10 -07:00
parent 20aefb9c36
commit dab2755634
2 changed files with 9 additions and 203 deletions

View file

@ -1,198 +0,0 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import type { ReactElement, KeyboardEvent, ClipboardEvent } from 'react'
import styled from 'styled-components'
import type { EasterEggSeed } from '@magic-civ/engine-ts'
const DEBOUNCE_MS = 700
// ── props ────────────────────────────────────────────────────────────────────
interface SeedInputProps {
value: number
onChange: (seed: number) => void
easterEggs?: Record<string, EasterEggSeed>
}
// ── helpers ──────────────────────────────────────────────────────────────────
function seedToDigits(seed: number): string[] {
return seed.toString().padStart(6, '0').slice(0, 6).split('')
}
function digitsToSeed(digits: string[]): number {
return parseInt(digits.join(''), 10) || 1
}
function digitFromKey(e: KeyboardEvent<HTMLInputElement>): string | null {
if (e.key.length === 1 && e.key >= '0' && e.key <= '9') return e.key
const m = e.code.match(/^Numpad(\d)$/)
return m ? m[1] : null
}
// ── component ────────────────────────────────────────────────────────────────
export function SeedInput({ value, onChange, easterEggs }: SeedInputProps): ReactElement {
const refs = useRef<Array<HTMLInputElement | null>>([null, null, null, null, null, null])
const [localDigits, setLocalDigits] = useState<string[]>(() => seedToDigits(value))
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const isEditing = useRef(false)
// Sync from parent only when not actively editing
useEffect(() => {
if (!isEditing.current) {
setLocalDigits(seedToDigits(value))
}
}, [value])
useEffect(() => () => {
if (debounceTimer.current) clearTimeout(debounceTimer.current)
}, [])
const localSeed = digitsToSeed(localDigits)
const easterEgg = easterEggs?.[String(localSeed)]
const commit = useCallback((digits: string[]): void => {
isEditing.current = true
setLocalDigits(digits)
if (debounceTimer.current) clearTimeout(debounceTimer.current)
debounceTimer.current = setTimeout(() => {
isEditing.current = false
onChange(digitsToSeed(digits))
}, DEBOUNCE_MS)
}, [onChange])
const moveTo = useCallback((i: number): void => {
const el = refs.current[i]
if (!el) return
el.focus()
el.setSelectionRange(0, 1)
}, [])
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>, i: number): void => {
const digit = digitFromKey(e)
if (digit !== null) {
e.preventDefault()
const next = [...localDigits]
next[i] = digit
commit(next)
} else if (e.key === 'Backspace') {
e.preventDefault()
const next = [...localDigits]
next[i] = '0'
commit(next)
} else if (e.key === 'Delete') {
e.preventDefault()
const next = [...localDigits]
next[i] = '0'
commit(next)
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
if (i > 0) moveTo(i - 1)
} else if (e.key === 'ArrowRight') {
e.preventDefault()
if (i < 5) moveTo(i + 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const next = [...localDigits]
const d = parseInt(next[i], 10)
next[i] = String(d === 9 ? 0 : d + 1)
commit(next)
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const next = [...localDigits]
const d = parseInt(next[i], 10)
next[i] = String(d === 0 ? 9 : d - 1)
commit(next)
} else if (e.key !== 'Tab') {
e.preventDefault()
}
}, [localDigits, commit, moveTo])
const handlePaste = useCallback((e: ClipboardEvent<HTMLInputElement>): void => {
e.preventDefault()
const raw = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6)
if (!raw) return
const digits = raw.padStart(6, '0').split('')
commit(digits)
moveTo(Math.min(raw.length, 5))
}, [commit, moveTo])
const handleRandom = useCallback((): void => {
if (debounceTimer.current) clearTimeout(debounceTimer.current)
isEditing.current = false
const seed = Math.floor(Math.random() * 999998) + 1
onChange(seed)
}, [onChange])
return (
<Wrap title={easterEgg?.flavor}>
<Cells>
{localDigits.map((digit, i) => (
<Cell
key={i}
ref={(el) => { refs.current[i] = el }}
type="text"
inputMode="numeric"
maxLength={1}
value={digit}
$active={!!easterEgg}
aria-label={`Seed digit ${i + 1}`}
onChange={() => { /* controlled via keydown */ }}
onKeyDown={(e) => handleKeyDown(e, i)}
onFocus={(e) => e.target.select()}
onPaste={handlePaste}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
))}
</Cells>
<RndmBtn onClick={handleRandom} title="Random seed" aria-label="Random seed">
</RndmBtn>
</Wrap>
)
}
// ── styled components ────────────────────────────────────────────────────────
const Wrap = styled.div`
display: flex;
align-items: center;
gap: 2px;
`
const Cells = styled.div`
display: flex;
gap: 2px;
`
const Cell = styled.input<{ $active: boolean }>`
width: 22px;
height: 28px;
background: ${({ $active }) => $active ? 'rgba(201, 168, 76, 0.07)' : 'rgba(255, 255, 255, 0.05)'};
border: 1px solid ${({ $active }) => $active ? 'rgba(201, 168, 76, 0.35)' : 'rgba(255, 255, 255, 0.12)'};
border-radius: 2px;
color: ${({ $active }) => $active ? '#c9a84c' : 'rgba(255, 255, 255, 0.8)'};
font-family: monospace;
font-size: 0.7rem;
text-align: center;
padding: 0;
caret-color: transparent;
transition: border-color 0.15s, color 0.15s, background 0.15s;
&:focus {
outline: none;
border-color: ${({ $active }) => $active ? 'rgba(201, 168, 76, 0.8)' : 'rgba(255, 255, 255, 0.5)'};
background: ${({ $active }) => $active ? 'rgba(201, 168, 76, 0.14)' : 'rgba(255, 255, 255, 0.1)'};
}
`
const RndmBtn = styled.button`
background: none;
border: none;
color: rgba(255, 255, 255, 0.25);
cursor: pointer;
font-size: 0.7rem;
padding: 0 1px;
line-height: 1;
flex-shrink: 0;
&:hover { color: rgba(255, 255, 255, 0.65); }
`

View file

@ -1,9 +1,9 @@
import { useEffect, useRef, useCallback } from 'react'
import { useEffect, useRef, useCallback, useState } from 'react'
import type { ReactElement, ReactNode } from 'react'
import styled, { keyframes } from 'styled-components'
import type { TurnStats, LeySchool, OceanState, EasterEggSeed } from '@magic-civ/engine-ts'
import { computeBiosphereHealth, getBreathabilityState, getOceanState } from '@magic-civ/engine-ts'
import { SeedInput } from './SeedInput'
import { SeedInput } from '../ui/SeedInput'
// ── constants ──────────────────────────────────────────────────────────────
@ -235,17 +235,21 @@ export function StatsDashboard({
extendTurns, isSimulating, onExtend,
children,
}: StatsDashboardProps): ReactElement {
const [liveSeed, setLiveSeed] = useState<number | undefined>(seed)
useEffect(() => { setLiveSeed(seed) }, [seed])
const hasLey = stats.some((s) => s.total_ley_strength > 0)
const currentStats = stats[currentTurn]
const baseStats = stats[0]
const resolvedTotal = totalTurns ?? stats.length
const activeSeed = liveSeed ?? seed
return (
<Wrapper>
{/* Scrubber + event timeline — aligned with sparklines */}
<ScrubberRow>
<ScrubberMeta>
{seed != null && onSeedChange && (
<SeedInput value={seed} onChange={onSeedChange} easterEggs={easterEggs} />
<SeedInput value={seed} onChange={onSeedChange} onLiveChange={setLiveSeed} easterEggs={easterEggs} />
)}
{onExtend && extendTurns && (
<ExtendBtn onClick={onExtend} disabled={isSimulating} title={`Extend by ${extendTurns} turns`}>
@ -265,8 +269,8 @@ export function StatsDashboard({
/>
</ScrubberFlex>
</ScrubberRow>
{seed != null && easterEggs?.[String(seed)] && (() => {
const egg = easterEggs[String(seed)]
{activeSeed != null && easterEggs?.[String(activeSeed)] && (() => {
const egg = easterEggs[String(activeSeed)]
return (
<EggRevealRow>
<EggName> {egg.name}</EggName>