feat(guide): ✨ Add simulation data handling and display logic to LairsPage and PopulationDashboardPage components
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
a8a7fb6f63
commit
b741f37612
2 changed files with 21 additions and 616 deletions
|
|
@ -1,11 +1,17 @@
|
|||
import { useState, useMemo, type ReactElement } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FadeIn, PageTitle, PageHeading, PageSubtitle, Section, SectionHeading, Prose, FilterChip } from '@magic-civ/guide-engine'
|
||||
import { allWildCreatures } from '@/data/game'
|
||||
import { TierRangePicker } from '@/components/TierRangePicker'
|
||||
import { FilterBar, FilterGroup, FilterLabel, ChipRow } from '@/components/FilterBar'
|
||||
import creatureDisplay from '@resources/wilds/creature_display.json'
|
||||
import {
|
||||
CountLabel, CardGrid, Card, CardHeader, TierRange, CardTitles, CardName, HabitatBadge,
|
||||
Stats, Stat, StatLabel, StatValue, TagRow, TagPill,
|
||||
LairSection, LairTitle, LairMeta, LairMetaItem, LairMetaLabel,
|
||||
MaturityPill, RatePill, NoLairBadge, NoteText, ExpandButton,
|
||||
MaturityGrid, MaturityCard, MaturityIndex, MaturityInfo, MaturityName, MaturityDesc,
|
||||
EmptyState, RelatedRow, RelatedLink,
|
||||
} from './lairs/styled'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -60,9 +66,11 @@ const MATURITY_ORDER = ['nascent', 'established', 'matured', 'ancient'] as const
|
|||
|
||||
// ─── Data ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const creatures: WildCreature[] = allWildCreatures as unknown as WildCreature[]
|
||||
// allWildCreatures is typed as the raw JSON shape; the WildCreature interface
|
||||
// is a strict overlay of the same fields — no cast needed, shape is compatible.
|
||||
const creatures = allWildCreatures as WildCreature[]
|
||||
|
||||
// ─── Components ─────────────────────────────────────────────────────────────
|
||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function StatBlock({ hp, atk, def, mov }: { hp: number; atk: number; def: number; mov: number }): ReactElement {
|
||||
return (
|
||||
|
|
@ -302,283 +310,3 @@ export default function LairsPage(): ReactElement {
|
|||
</FadeIn>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Styled components ──────────────────────────────────────────────────────
|
||||
|
||||
const CountLabel = styled.div`
|
||||
font-size: 0.8125rem;
|
||||
color: ${({ theme }) => theme.colors.text.muted};
|
||||
margin-top: 0.25rem;
|
||||
`
|
||||
|
||||
const CardGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
`
|
||||
|
||||
const Card = styled.div`
|
||||
background: ${({ theme }) => theme.colors.surface};
|
||||
border: 1px solid ${({ theme }) => theme.colors.border.default};
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
`
|
||||
|
||||
const CardHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
`
|
||||
|
||||
const TierRange = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 3.5rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
color: ${({ theme }) => theme.colors.text.primary};
|
||||
background: ${({ theme }) => theme.colors.border.default};
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const CardTitles = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
`
|
||||
|
||||
const CardName = styled.div`
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: ${({ theme }) => theme.colors.text.primary};
|
||||
`
|
||||
|
||||
const HabitatBadge = styled.span<{ $color: string }>`
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.4375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
background: ${({ $color }) => $color}22;
|
||||
border: 1px solid ${({ $color }) => $color}55;
|
||||
color: ${({ $color }) => $color};
|
||||
width: fit-content;
|
||||
`
|
||||
|
||||
const Stats = styled.div`
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
`
|
||||
|
||||
const Stat = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
`
|
||||
|
||||
const StatLabel = styled.span`
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: ${({ theme }) => theme.colors.text.muted};
|
||||
`
|
||||
|
||||
const StatValue = styled.span`
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
color: ${({ theme }) => theme.colors.text.primary};
|
||||
`
|
||||
|
||||
const TagRow = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
`
|
||||
|
||||
const TagPill = styled.span`
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.4375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
background: ${({ theme }) => theme.colors.border.default};
|
||||
color: ${({ theme }) => theme.colors.text.muted};
|
||||
`
|
||||
|
||||
const LairSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: ${({ theme }) => theme.colors.background?.secondary ?? '#1a1a22'};
|
||||
border: 1px solid ${({ theme }) => theme.colors.border.default};
|
||||
border-radius: 6px;
|
||||
`
|
||||
|
||||
const LairTitle = styled.div`
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
color: ${({ theme }) => theme.colors.text.primary};
|
||||
text-transform: capitalize;
|
||||
`
|
||||
|
||||
const LairMeta = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
`
|
||||
|
||||
const LairMetaItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.75rem;
|
||||
color: ${({ theme }) => theme.colors.text.primary};
|
||||
`
|
||||
|
||||
const LairMetaLabel = styled.span`
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: ${({ theme }) => theme.colors.text.muted};
|
||||
`
|
||||
|
||||
const MaturityPill = styled.span`
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
background: ${({ theme }) => theme.colors.border.default};
|
||||
color: ${({ theme }) => theme.colors.text.primary};
|
||||
`
|
||||
|
||||
const RatePill = styled.span<{ $color: string }>`
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
background: ${({ $color }) => $color}22;
|
||||
border: 1px solid ${({ $color }) => $color}55;
|
||||
color: ${({ $color }) => $color};
|
||||
`
|
||||
|
||||
const NoLairBadge = styled.div`
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
color: ${({ theme }) => theme.colors.text.muted};
|
||||
`
|
||||
|
||||
const NoteText = styled.div`
|
||||
font-size: 0.75rem;
|
||||
color: ${({ theme }) => theme.colors.text.muted};
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
`
|
||||
|
||||
const ExpandButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${({ theme }) => theme.colors.text.secondary ?? theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
padding: 0 0.25rem;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
color: ${({ theme }) => theme.colors.text.primary};
|
||||
}
|
||||
`
|
||||
|
||||
const MaturityGrid = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
margin-top: 1rem;
|
||||
`
|
||||
|
||||
const MaturityCard = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.625rem 1rem;
|
||||
background: ${({ theme }) => theme.colors.surface};
|
||||
border: 1px solid ${({ theme }) => theme.colors.border.default};
|
||||
border-radius: 6px;
|
||||
`
|
||||
|
||||
const MaturityIndex = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
font-size: 0.8125rem;
|
||||
color: ${({ theme }) => theme.colors.text.primary};
|
||||
background: ${({ theme }) => theme.colors.border.default};
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const MaturityInfo = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
`
|
||||
|
||||
const MaturityName = styled.span`
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: ${({ theme }) => theme.colors.text.primary};
|
||||
text-transform: capitalize;
|
||||
`
|
||||
|
||||
const MaturityDesc = styled.span`
|
||||
font-size: 0.8125rem;
|
||||
color: ${({ theme }) => theme.colors.text.muted};
|
||||
`
|
||||
|
||||
const EmptyState = styled.div`
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
`
|
||||
|
||||
const RelatedRow = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
`
|
||||
|
||||
const RelatedLink = styled(Link)`
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: ${({ theme }) => theme.colors.surface};
|
||||
border: 1px solid ${({ theme }) => theme.colors.border.default};
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: ${({ theme }) => theme.colors.text.primary};
|
||||
text-decoration: none;
|
||||
transition: border-color 120ms;
|
||||
&:hover { border-color: ${({ theme }) => theme.colors.border.hover}; }
|
||||
`
|
||||
|
|
|
|||
|
|
@ -1,303 +1,15 @@
|
|||
import { useState, useMemo, useRef, useEffect, type ReactElement } from 'react'
|
||||
import { useState, useMemo, type ReactElement } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { FadeIn, PageTitle, PageHeading, PageSubtitle, Section, SectionHeading, Prose, FilterRow, FilterChip } from '@magic-civ/guide-engine'
|
||||
import { ALL_BIOMES } from '@magic-civ/guide-engine'
|
||||
import type { QualityTierDef } from '@magic-civ/guide-engine'
|
||||
import traitDefinitions from '@resources/ecology/traits/trait_definitions.json'
|
||||
import { useSimulation, MAX_TURNS, SEED, W, H } from './population-dashboard/simulation'
|
||||
import { LineChart, type ChartSeries } from './population-dashboard/LineChart'
|
||||
|
||||
const TIER_COLOR_MAP: Record<number, string> = Object.fromEntries(
|
||||
traitDefinitions.tier_system.tiers.map((t: QualityTierDef) => [t.tier, t.color])
|
||||
)
|
||||
import type { GridState, TileState } from '@magic-civ/engine-ts'
|
||||
import { classifyBiome, DEFAULT_DT } from '@magic-civ/engine-ts'
|
||||
import { WasmEcologyPhysics, WasmGrid } from '@magic-civ/physics-rs'
|
||||
|
||||
const W = 30
|
||||
const H = 20
|
||||
const MAX_TURNS = 120
|
||||
const SEED = 42
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulation: run ecology on a deterministic grid, record per-biome stats per turn
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TurnRecord {
|
||||
turn: number
|
||||
biomeCounts: Record<string, number>
|
||||
biomeAvgQuality: Record<string, number>
|
||||
biomeAvgCanopy: Record<string, number>
|
||||
biomeAvgUndergrowth: Record<string, number>
|
||||
totalLandTiles: number
|
||||
globalHealth: number
|
||||
}
|
||||
|
||||
function tileHash(seed: number, col: number, row: number): number {
|
||||
let h = seed * 374761393 + col * 668265263 + row * 2147483647
|
||||
h = (h ^ (h >>> 13)) * 1274126177
|
||||
h = h ^ (h >>> 16)
|
||||
return (h >>> 0) / 4294967296
|
||||
}
|
||||
|
||||
function isWaterTile(tile: TileState): boolean {
|
||||
return tile.substrate_id === 'deep_water' || tile.substrate_id === 'shallow_water' || tile.substrate_id === 'lake_bed'
|
||||
}
|
||||
|
||||
function makeSimGrid(seed: number): GridState {
|
||||
const tiles: TileState[] = new Array(W * H)
|
||||
for (let row = 0; row < H; row++) {
|
||||
for (let col = 0; col < W; col++) {
|
||||
const h = tileHash(seed, col, row)
|
||||
const h2 = tileHash(seed + 1, col, row)
|
||||
const h3 = tileHash(seed + 2, col, row)
|
||||
const latFactor = 1.0 - Math.abs(row - H / 2) / (H / 2)
|
||||
const temperature = Math.min(1.0, Math.max(0.0, latFactor * 0.85 + h * 0.15))
|
||||
const moisture = Math.min(1.0, Math.max(0.0, h2 * 0.75 + 0.12))
|
||||
const edgeDist = Math.min(col, row, W - 1 - col, H - 1 - row)
|
||||
const elevation = edgeDist <= 1 ? 0.1 : 0.3 + h3 * 0.5
|
||||
const isWater = edgeDist <= 1
|
||||
const substrate_id = isWater
|
||||
? (edgeDist === 0 ? 'deep_water' : 'shallow_water')
|
||||
: (elevation > 0.65 ? 'highland' : elevation > 0.55 ? 'midland' : 'lowland')
|
||||
|
||||
const tile: TileState = {
|
||||
col, row, temperature, moisture, elevation,
|
||||
biome_id: isWater ? 'ocean' : 'grassland',
|
||||
wind_direction: Math.floor(h * 6),
|
||||
wind_speed: 0.5,
|
||||
pressure: 1013.0, pressure_anomaly: 0.0,
|
||||
humidity: 0.0, relative_humidity: 0.5, dew_point: 0.4, cape: 0.0,
|
||||
sulfate_aerosol: 0.0,
|
||||
quality: 2,
|
||||
quality_progress: 0,
|
||||
river_edges: [],
|
||||
river_flow: {},
|
||||
flow_accumulation: 0.0,
|
||||
original_biome_id: isWater ? 'ocean' : 'grassland',
|
||||
ley_line_count: 0, ley_school: 'none',
|
||||
reef_health: isWater ? 1.0 : 0.0,
|
||||
magic_heat_delta: 0.0, magic_moisture_delta: 0.0,
|
||||
is_natural_wonder: false,
|
||||
wonder_anchor_strength: 0.0, wonder_anchor_school: 'none',
|
||||
wonder_anchor_schools: [], wonder_tier: 0,
|
||||
substrate_id, water_body_id: isWater ? 0 : -1,
|
||||
depth_from_coast: isWater ? edgeDist : -1,
|
||||
canopy_cover: 0.0, undergrowth: 0.0, fungi_network: 0.0,
|
||||
drought_counter: 0, succession_progress: 0,
|
||||
regrowth_stage: -1, regrowth_turns: 0,
|
||||
habitat_suitability: 0.0, habitat_low_turns: 0, landmark_name: '',
|
||||
water_body_type: isWater ? 'ocean' : '', is_river_mouth: false, has_cave: false, is_coastal: false,
|
||||
surface_water: 0.0,
|
||||
river_source_type: '',
|
||||
fish_stock: 0.0,
|
||||
aerosol_mitigation: 0.0,
|
||||
resource_id: '',
|
||||
}
|
||||
|
||||
if (!isWater) {
|
||||
tile.biome_id = classifyBiome(tile)
|
||||
}
|
||||
|
||||
tiles[row * W + col] = tile
|
||||
}
|
||||
}
|
||||
return {
|
||||
tiles, width: W, height: H,
|
||||
global_avg_temp: 0.5, ocean_dead_fraction: 0.0,
|
||||
ecosystem_health: 0.5, sea_level: 0.2,
|
||||
total_ocean_water: 0.0, ocean_basin_area: 0,
|
||||
o2_fraction: 0.21, co2_ppm: 280.0, ch4_ppb: 700.0,
|
||||
global_temp_bias: 0.0, ecological_collapse: false,
|
||||
o2_collapse_turn_count: 0, photosynthesisMultiplier: 1.0,
|
||||
global_fish_stock: 1.0, ocean_toxic: false, ocean_toxicity: 0.0,
|
||||
ocean_o2_contribution: 1.0, ocean_o2_suspended_turns: 0,
|
||||
ocean_anoxic: false, dead_ocean: false, canfield_ocean: false,
|
||||
trophic_cascade_active: false, trophic_cascade_phase: 0,
|
||||
trophic_cascade_turns_remaining: 0, fish_collapse_check_timer: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function recordTurn(grid: GridState, turn: number): TurnRecord {
|
||||
const biomeCounts: Record<string, number> = {}
|
||||
const biomeQualitySum: Record<string, number> = {}
|
||||
const biomeCanopySum: Record<string, number> = {}
|
||||
const biomeUgSum: Record<string, number> = {}
|
||||
let totalLand = 0
|
||||
|
||||
for (const tile of grid.tiles) {
|
||||
if (isWaterTile(tile)) continue
|
||||
totalLand++
|
||||
const b = tile.biome_id
|
||||
biomeCounts[b] = (biomeCounts[b] ?? 0) + 1
|
||||
biomeQualitySum[b] = (biomeQualitySum[b] ?? 0) + tile.quality
|
||||
biomeCanopySum[b] = (biomeCanopySum[b] ?? 0) + tile.canopy_cover
|
||||
biomeUgSum[b] = (biomeUgSum[b] ?? 0) + tile.undergrowth
|
||||
}
|
||||
|
||||
const biomeAvgQuality: Record<string, number> = {}
|
||||
const biomeAvgCanopy: Record<string, number> = {}
|
||||
const biomeAvgUndergrowth: Record<string, number> = {}
|
||||
for (const b of Object.keys(biomeCounts)) {
|
||||
const n = biomeCounts[b]
|
||||
biomeAvgQuality[b] = biomeQualitySum[b] / n
|
||||
biomeAvgCanopy[b] = biomeCanopySum[b] / n
|
||||
biomeAvgUndergrowth[b] = biomeUgSum[b] / n
|
||||
}
|
||||
|
||||
return {
|
||||
turn, biomeCounts, biomeAvgQuality, biomeAvgCanopy, biomeAvgUndergrowth,
|
||||
totalLandTiles: totalLand,
|
||||
globalHealth: grid.ecosystem_health,
|
||||
}
|
||||
}
|
||||
|
||||
function useSimulation(): TurnRecord[] {
|
||||
return useMemo(() => {
|
||||
let grid = makeSimGrid(SEED)
|
||||
const eco = new WasmEcologyPhysics()
|
||||
const records: TurnRecord[] = [recordTurn(grid, 0)]
|
||||
|
||||
const wg = WasmGrid.fromJSON(grid)
|
||||
for (let t = 1; t <= MAX_TURNS; t++) {
|
||||
eco.processStep(wg, DEFAULT_DT)
|
||||
if (t % 5 === 0 || t <= 10 || t === MAX_TURNS) {
|
||||
grid = wg.toJSON() as GridState
|
||||
records.push(recordTurn(grid, t))
|
||||
}
|
||||
}
|
||||
wg.free()
|
||||
eco.free()
|
||||
|
||||
return records
|
||||
}, [])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canvas line chart renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ChartSeries {
|
||||
label: string
|
||||
color: string
|
||||
data: Array<{ x: number; y: number }>
|
||||
}
|
||||
|
||||
function LineChart({
|
||||
series,
|
||||
title,
|
||||
xLabel,
|
||||
yLabel,
|
||||
width = 700,
|
||||
height = 300,
|
||||
}: {
|
||||
series: ChartSeries[]
|
||||
title: string
|
||||
xLabel: string
|
||||
yLabel: string
|
||||
width?: number
|
||||
height?: number
|
||||
}): ReactElement {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const pad = { top: 30, right: 20, bottom: 35, left: 55 }
|
||||
const cw = width - pad.left - pad.right
|
||||
const ch = height - pad.top - pad.bottom
|
||||
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
// Find ranges
|
||||
let xMin = Infinity, xMax = -Infinity, yMax = -Infinity
|
||||
const yMin = 0
|
||||
for (const s of series) {
|
||||
for (const d of s.data) {
|
||||
if (d.x < xMin) xMin = d.x
|
||||
if (d.x > xMax) xMax = d.x
|
||||
if (d.y > yMax) yMax = d.y
|
||||
}
|
||||
}
|
||||
if (xMax === xMin) xMax = xMin + 1
|
||||
if (yMax === yMin) yMax = yMin + 1
|
||||
yMax *= 1.1
|
||||
|
||||
const toX = (v: number) => pad.left + ((v - xMin) / (xMax - xMin)) * cw
|
||||
const toY = (v: number) => pad.top + ch - ((v - yMin) / (yMax - yMin)) * ch
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = 'rgba(200,200,200,0.15)'
|
||||
ctx.lineWidth = 0.5
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = pad.top + (ch * i) / 4
|
||||
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + cw, y); ctx.stroke()
|
||||
}
|
||||
|
||||
// Axes
|
||||
ctx.strokeStyle = 'rgba(200,200,200,0.4)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(pad.left, pad.top)
|
||||
ctx.lineTo(pad.left, pad.top + ch)
|
||||
ctx.lineTo(pad.left + cw, pad.top + ch)
|
||||
ctx.stroke()
|
||||
|
||||
// Data lines
|
||||
for (const s of series) {
|
||||
if (s.data.length < 2) continue
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(toX(s.data[0].x), toY(s.data[0].y))
|
||||
for (let i = 1; i < s.data.length; i++) {
|
||||
ctx.lineTo(toX(s.data[i].x), toY(s.data[i].y))
|
||||
}
|
||||
ctx.strokeStyle = s.color
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Labels
|
||||
ctx.fillStyle = 'rgba(240,232,208,0.8)'
|
||||
ctx.font = '11px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(title, width / 2, 16)
|
||||
|
||||
ctx.font = '9px monospace'
|
||||
ctx.fillText(xLabel, pad.left + cw / 2, height - 4)
|
||||
ctx.textAlign = 'right'
|
||||
ctx.fillText(String(Math.round(yMax)), pad.left - 5, pad.top + 4)
|
||||
ctx.fillText(String(Math.round(yMin)), pad.left - 5, pad.top + ch + 4)
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(10, pad.top + ch / 2)
|
||||
ctx.rotate(-Math.PI / 2)
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(yLabel, 0, 0)
|
||||
ctx.restore()
|
||||
|
||||
// X-axis ticks
|
||||
ctx.textAlign = 'center'
|
||||
const xStep = Math.ceil((xMax - xMin) / 6)
|
||||
for (let x = xMin; x <= xMax; x += xStep) {
|
||||
ctx.fillText(String(Math.round(x)), toX(x), pad.top + ch + 14)
|
||||
}
|
||||
}, [series, title, xLabel, yLabel, width, height])
|
||||
|
||||
return (
|
||||
<ChartContainer>
|
||||
<canvas ref={canvasRef} width={width} height={height} />
|
||||
<ChartLegend>
|
||||
{series.map(s => (
|
||||
<ChartLegendItem key={s.label}>
|
||||
<ChartLegendLine style={{ background: s.color }} />
|
||||
{s.label}
|
||||
</ChartLegendItem>
|
||||
))}
|
||||
</ChartLegend>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PopulationDashboardPage(): ReactElement {
|
||||
const records = useSimulation()
|
||||
|
|
@ -355,6 +67,8 @@ export default function PopulationDashboardPage(): ReactElement {
|
|||
count: { title: 'Tile Count per Biome', yLabel: 'Tiles' },
|
||||
}
|
||||
|
||||
const lastRecord = records[records.length - 1]
|
||||
|
||||
return (
|
||||
<FadeIn duration="fast">
|
||||
<PageTitle>
|
||||
|
|
@ -424,8 +138,7 @@ export default function PopulationDashboardPage(): ReactElement {
|
|||
</thead>
|
||||
<tbody>
|
||||
{presentBiomes.map(biome => {
|
||||
const last = records[records.length - 1]
|
||||
const count = last.biomeCounts[biome.id] ?? 0
|
||||
const count = lastRecord.biomeCounts[biome.id] ?? 0
|
||||
if (count === 0) return null
|
||||
return (
|
||||
<tr key={biome.id}>
|
||||
|
|
@ -436,9 +149,9 @@ export default function PopulationDashboardPage(): ReactElement {
|
|||
</BiomeName>
|
||||
</td>
|
||||
<td>{count}</td>
|
||||
<td>{(last.biomeAvgQuality[biome.id] ?? 0).toFixed(1)}</td>
|
||||
<td>{(last.biomeAvgCanopy[biome.id] ?? 0).toFixed(3)}</td>
|
||||
<td>{(last.biomeAvgUndergrowth[biome.id] ?? 0).toFixed(3)}</td>
|
||||
<td>{(lastRecord.biomeAvgQuality[biome.id] ?? 0).toFixed(1)}</td>
|
||||
<td>{(lastRecord.biomeAvgCanopy[biome.id] ?? 0).toFixed(3)}</td>
|
||||
<td>{(lastRecord.biomeAvgUndergrowth[biome.id] ?? 0).toFixed(3)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
|
@ -453,42 +166,6 @@ export default function PopulationDashboardPage(): ReactElement {
|
|||
// Styled components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ChartContainer = styled.div`
|
||||
margin-top: 1rem;
|
||||
border: 1px solid ${({ theme }) => theme.colors.border.default};
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #0d0b14;
|
||||
padding: 0.5rem;
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
`
|
||||
|
||||
const ChartLegend = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1rem;
|
||||
padding: 0.5rem;
|
||||
`
|
||||
|
||||
const ChartLegendItem = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
color: rgba(240,232,208,0.7);
|
||||
`
|
||||
|
||||
const ChartLegendLine = styled.span`
|
||||
width: 1rem;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
`
|
||||
|
||||
const SummaryTable = styled.table`
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue