villsim/frontend/renderer/stats_renderer.py

884 lines
35 KiB
Python

"""Real-time statistics and charts renderer for the Village Simulation.
Includes tabs for Economy, Religion, Factions, and Diplomacy.
Uses matplotlib for beautiful data visualization.
"""
import io
from dataclasses import dataclass, field
from collections import deque
from typing import TYPE_CHECKING, Optional
import pygame
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
from matplotlib.figure import Figure
import numpy as np
if TYPE_CHECKING:
from frontend.client import SimulationState
class ChartColors:
"""Color palette for charts - dark cyberpunk theme."""
BG = '#0f1117'
PANEL = '#1a1d26'
GRID = '#2a2f3d'
TEXT = '#e0e4eb'
TEXT_DIM = '#6b7280'
# Neon accents
CYAN = '#00d4ff'
MAGENTA = '#ff0099'
LIME = '#39ff14'
ORANGE = '#ff6600'
PURPLE = '#9d4edd'
YELLOW = '#ffcc00'
TEAL = '#00ffa3'
PINK = '#ff1493'
SERIES = [CYAN, MAGENTA, LIME, ORANGE, PURPLE, YELLOW, TEAL, PINK]
# Faction colors (matching backend)
FACTIONS = {
"northlands": "#64a0dc",
"riverfolk": "#46a0b4",
"forestkin": "#5aa050",
"mountaineer": "#96785a",
"plainsmen": "#c8b464",
"neutral": "#787878",
}
# Religion colors
RELIGIONS = {
"solaris": "#ffc850",
"aquarius": "#50aaf0",
"terranus": "#a07846",
"ignis": "#f06432",
"naturis": "#64c864",
"atheist": "#8c8c8c",
}
class UIColors:
"""Color palette for pygame UI."""
BG = (15, 17, 23)
PANEL_BG = (26, 29, 38)
PANEL_BORDER = (55, 65, 85)
TEXT_PRIMARY = (224, 228, 235)
TEXT_SECONDARY = (107, 114, 128)
TEXT_HIGHLIGHT = (0, 212, 255)
TAB_ACTIVE = (0, 212, 255)
TAB_INACTIVE = (45, 50, 65)
TAB_HOVER = (65, 75, 95)
@dataclass
class HistoryData:
"""Stores historical simulation data for charting."""
max_history: int = 300
# Time series
turns: deque = field(default_factory=lambda: deque(maxlen=300))
population: deque = field(default_factory=lambda: deque(maxlen=300))
deaths_cumulative: deque = field(default_factory=lambda: deque(maxlen=300))
# Wealth data
total_money: deque = field(default_factory=lambda: deque(maxlen=300))
avg_wealth: deque = field(default_factory=lambda: deque(maxlen=300))
gini_coefficient: deque = field(default_factory=lambda: deque(maxlen=300))
# Price history
prices: dict = field(default_factory=dict)
# Trade statistics
trade_volume: deque = field(default_factory=lambda: deque(maxlen=300))
# Faction data
factions: dict = field(default_factory=dict)
# Religion data
religions: dict = field(default_factory=dict)
avg_faith: deque = field(default_factory=lambda: deque(maxlen=300))
# Diplomacy data
active_wars: deque = field(default_factory=lambda: deque(maxlen=300))
peace_treaties: deque = field(default_factory=lambda: deque(maxlen=300))
def clear(self) -> None:
"""Clear all history."""
self.turns.clear()
self.population.clear()
self.deaths_cumulative.clear()
self.total_money.clear()
self.avg_wealth.clear()
self.gini_coefficient.clear()
self.prices.clear()
self.trade_volume.clear()
self.factions.clear()
self.religions.clear()
self.avg_faith.clear()
self.active_wars.clear()
self.peace_treaties.clear()
def update(self, state: "SimulationState") -> None:
"""Update history with new state."""
turn = state.turn
if self.turns and self.turns[-1] == turn:
return
self.turns.append(turn)
# Population
living = len(state.get_living_agents())
self.population.append(living)
self.deaths_cumulative.append(state.statistics.get("total_agents_died", 0))
# Wealth
stats = state.statistics
self.total_money.append(stats.get("total_money_in_circulation", 0))
self.avg_wealth.append(stats.get("avg_money", 0))
self.gini_coefficient.append(stats.get("gini_coefficient", 0))
# Prices
for resource, data in state.market_prices.items():
if resource not in self.prices:
self.prices[resource] = deque(maxlen=self.max_history)
price = data.get("lowest_price") or data.get("avg_sale_price")
self.prices[resource].append(price)
# Trade volume
self.trade_volume.append(len(state.market_orders))
# Factions
faction_stats = state.get_faction_stats()
for faction, count in faction_stats.items():
if faction not in self.factions:
self.factions[faction] = deque(maxlen=self.max_history)
self.factions[faction].append(count)
for faction in self.factions:
if faction not in faction_stats:
self.factions[faction].append(0)
# Religions
religion_stats = state.get_religion_stats()
for religion, count in religion_stats.items():
if religion not in self.religions:
self.religions[religion] = deque(maxlen=self.max_history)
self.religions[religion].append(count)
for religion in self.religions:
if religion not in religion_stats:
self.religions[religion].append(0)
# Faith
self.avg_faith.append(state.get_avg_faith())
# Diplomacy
self.active_wars.append(len(state.active_wars))
self.peace_treaties.append(len(state.peace_treaties))
class ChartRenderer:
"""Renders matplotlib charts to pygame surfaces."""
def __init__(self, width: int, height: int):
self.width = width
self.height = height
self.dpi = 100
# Configure matplotlib
plt.style.use('dark_background')
plt.rcParams.update({
'figure.facecolor': ChartColors.BG,
'axes.facecolor': ChartColors.PANEL,
'axes.edgecolor': ChartColors.GRID,
'axes.labelcolor': ChartColors.TEXT,
'text.color': ChartColors.TEXT,
'xtick.color': ChartColors.TEXT_DIM,
'ytick.color': ChartColors.TEXT_DIM,
'grid.color': ChartColors.GRID,
'grid.alpha': 0.3,
'legend.facecolor': ChartColors.PANEL,
'legend.edgecolor': ChartColors.GRID,
'font.size': 9,
'axes.titlesize': 12,
'axes.titleweight': 'bold',
})
def _fig_to_surface(self, fig: Figure) -> pygame.Surface:
"""Convert matplotlib figure to pygame surface."""
buf = io.BytesIO()
fig.savefig(
buf, format='png', dpi=self.dpi,
facecolor=ChartColors.BG, edgecolor='none',
bbox_inches='tight', pad_inches=0.1
)
buf.seek(0)
surface = pygame.image.load(buf, 'png')
buf.close()
plt.close(fig)
return surface
def render_price_history(self, history: HistoryData, w: int, h: int) -> pygame.Surface:
"""Render price history chart."""
fig, ax = plt.subplots(figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi)
turns = list(history.turns) if history.turns else [0]
has_data = False
for i, (resource, prices) in enumerate(history.prices.items()):
if prices and any(p is not None for p in prices):
color = ChartColors.SERIES[i % len(ChartColors.SERIES)]
valid = [p if p is not None else 0 for p in prices]
min_len = min(len(turns), len(valid))
ax.plot(
list(turns)[-min_len:], valid[-min_len:],
color=color, linewidth=1.5, label=resource.title(), alpha=0.9
)
has_data = True
ax.set_title('Market Prices Over Time', color=ChartColors.CYAN)
ax.set_xlabel('Turn')
ax.set_ylabel('Price (coins)')
ax.grid(True, alpha=0.2)
if has_data:
ax.legend(loc='upper left', fontsize=8, framealpha=0.8, ncol=2)
ax.set_ylim(bottom=0)
ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True))
fig.tight_layout()
return self._fig_to_surface(fig)
def render_population(self, history: HistoryData, w: int, h: int) -> pygame.Surface:
"""Render population chart."""
fig, ax = plt.subplots(figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi)
turns = list(history.turns) if history.turns else [0]
pop = list(history.population) if history.population else [0]
deaths = list(history.deaths_cumulative) if history.deaths_cumulative else [0]
min_len = min(len(turns), len(pop))
ax.fill_between(turns[-min_len:], pop[-min_len:], alpha=0.3, color=ChartColors.CYAN)
ax.plot(turns[-min_len:], pop[-min_len:], color=ChartColors.CYAN, linewidth=2, label='Living')
if deaths:
ax.plot(
turns[-min_len:], deaths[-min_len:],
color=ChartColors.MAGENTA, linewidth=1.5,
linestyle='--', label='Deaths', alpha=0.8
)
ax.set_title('Population Over Time', color=ChartColors.LIME)
ax.set_xlabel('Turn')
ax.set_ylabel('Count')
ax.grid(True, alpha=0.2)
ax.legend(loc='upper right', fontsize=8)
ax.set_ylim(bottom=0)
ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True))
fig.tight_layout()
return self._fig_to_surface(fig)
def render_wealth_distribution(self, state: "SimulationState", w: int, h: int) -> pygame.Surface:
"""Render wealth distribution bar chart."""
fig, ax = plt.subplots(figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi)
agents = state.get_living_agents()
if not agents:
ax.text(0.5, 0.5, 'No living agents', ha='center', va='center',
color=ChartColors.TEXT_DIM, fontsize=12)
ax.set_title('Wealth Distribution', color=ChartColors.ORANGE)
fig.tight_layout()
return self._fig_to_surface(fig)
# Sort by wealth and show top 20
sorted_agents = sorted(agents, key=lambda a: a.get("money", 0), reverse=True)[:20]
names = [a.get("name", "?")[:8] for a in sorted_agents]
wealth = [a.get("money", 0) for a in sorted_agents]
colors = []
for i, agent in enumerate(sorted_agents):
diplomacy = agent.get("diplomacy", {})
faction = diplomacy.get("faction", "neutral")
colors.append(ChartColors.FACTIONS.get(faction, "#787878"))
bars = ax.barh(range(len(sorted_agents)), wealth, color=colors, alpha=0.85)
ax.set_yticks(range(len(sorted_agents)))
ax.set_yticklabels(names, fontsize=7)
ax.invert_yaxis()
for bar, val in zip(bars, wealth):
ax.text(
bar.get_width() + 1, bar.get_y() + bar.get_height()/2,
f'{val}', va='center', fontsize=7, color=ChartColors.TEXT_DIM
)
ax.set_title('Wealth Distribution (Top 20)', color=ChartColors.ORANGE)
ax.set_xlabel('Coins')
ax.grid(True, alpha=0.2, axis='x')
fig.tight_layout()
return self._fig_to_surface(fig)
def render_wealth_over_time(self, history: HistoryData, w: int, h: int) -> pygame.Surface:
"""Render wealth metrics over time."""
fig, (ax1, ax2) = plt.subplots(
2, 1, figsize=(w/self.dpi, h/self.dpi),
dpi=self.dpi, height_ratios=[2, 1]
)
turns = list(history.turns) if history.turns else [0]
total = list(history.total_money) if history.total_money else [0]
avg = list(history.avg_wealth) if history.avg_wealth else [0]
gini = list(history.gini_coefficient) if history.gini_coefficient else [0]
min_len = min(len(turns), len(total), len(avg))
ax1.plot(turns[-min_len:], total[-min_len:], color=ChartColors.CYAN, linewidth=2, label='Total')
ax1.fill_between(turns[-min_len:], total[-min_len:], alpha=0.2, color=ChartColors.CYAN)
ax1_twin = ax1.twinx()
ax1_twin.plot(turns[-min_len:], avg[-min_len:], color=ChartColors.LIME, linewidth=1.5, linestyle='--', label='Avg')
ax1_twin.set_ylabel('Avg', color=ChartColors.LIME)
ax1_twin.tick_params(axis='y', labelcolor=ChartColors.LIME)
ax1.set_title('Money Circulation', color=ChartColors.YELLOW)
ax1.set_ylabel('Total', color=ChartColors.CYAN)
ax1.tick_params(axis='y', labelcolor=ChartColors.CYAN)
ax1.grid(True, alpha=0.2)
ax1.set_ylim(bottom=0)
min_gini = min(len(turns), len(gini))
ax2.fill_between(turns[-min_gini:], gini[-min_gini:], alpha=0.4, color=ChartColors.MAGENTA)
ax2.plot(turns[-min_gini:], gini[-min_gini:], color=ChartColors.MAGENTA, linewidth=1.5)
ax2.set_xlabel('Turn')
ax2.set_ylabel('Gini')
ax2.set_title('Inequality', color=ChartColors.MAGENTA, fontsize=9)
ax2.set_ylim(0, 1)
ax2.axhline(y=0.4, color=ChartColors.YELLOW, linestyle=':', alpha=0.5)
ax2.grid(True, alpha=0.2)
fig.tight_layout()
return self._fig_to_surface(fig)
def render_factions(self, state: "SimulationState", history: HistoryData, w: int, h: int) -> pygame.Surface:
"""Render faction distribution and history."""
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi)
# Current pie chart
faction_stats = state.get_faction_stats()
if faction_stats:
labels = list(faction_stats.keys())
sizes = list(faction_stats.values())
colors = [ChartColors.FACTIONS.get(f, "#787878") for f in labels]
wedges, texts, autotexts = ax1.pie(
sizes, labels=[l.title() for l in labels], colors=colors,
autopct='%1.0f%%', startangle=90, pctdistance=0.75,
textprops={'fontsize': 8, 'color': ChartColors.TEXT}
)
for autotext in autotexts:
autotext.set_color(ChartColors.BG)
autotext.set_fontweight('bold')
ax1.set_title('Faction Distribution', color=ChartColors.ORANGE, fontsize=10)
else:
ax1.text(0.5, 0.5, 'No data', ha='center', va='center', color=ChartColors.TEXT_DIM)
# History stacked area
turns = list(history.turns) if history.turns else [0]
if history.factions and turns:
faction_list = list(history.factions.keys())
data = []
for f in faction_list:
f_data = list(history.factions[f])
while len(f_data) < len(turns):
f_data.insert(0, 0)
data.append(f_data[-len(turns):])
colors = [ChartColors.FACTIONS.get(f, "#787878") for f in faction_list]
ax2.stackplot(turns, *data, labels=[f.title() for f in faction_list], colors=colors, alpha=0.8)
ax2.legend(loc='upper left', fontsize=7, framealpha=0.8)
ax2.set_xlabel('Turn')
ax2.set_ylabel('Count')
ax2.set_title('Faction History', color=ChartColors.ORANGE, fontsize=10)
ax2.grid(True, alpha=0.2)
fig.tight_layout()
return self._fig_to_surface(fig)
def render_religions(self, state: "SimulationState", history: HistoryData, w: int, h: int) -> pygame.Surface:
"""Render religion distribution and faith levels."""
fig, axes = plt.subplots(2, 2, figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi)
ax1, ax2, ax3, ax4 = axes.flat
# Current pie chart
religion_stats = state.get_religion_stats()
if religion_stats:
labels = list(religion_stats.keys())
sizes = list(religion_stats.values())
colors = [ChartColors.RELIGIONS.get(r, "#8c8c8c") for r in labels]
wedges, texts, autotexts = ax1.pie(
sizes, labels=[l.title() for l in labels], colors=colors,
autopct='%1.0f%%', startangle=90, pctdistance=0.75,
textprops={'fontsize': 7, 'color': ChartColors.TEXT}
)
for autotext in autotexts:
autotext.set_color(ChartColors.BG)
autotext.set_fontweight('bold')
autotext.set_fontsize(6)
ax1.set_title('Religion Distribution', color=ChartColors.PURPLE, fontsize=9)
# Religion history
turns = list(history.turns) if history.turns else [0]
if history.religions and turns:
religion_list = list(history.religions.keys())
data = []
for r in religion_list:
r_data = list(history.religions[r])
while len(r_data) < len(turns):
r_data.insert(0, 0)
data.append(r_data[-len(turns):])
colors = [ChartColors.RELIGIONS.get(r, "#8c8c8c") for r in religion_list]
ax2.stackplot(turns, *data, labels=[r.title() for r in religion_list], colors=colors, alpha=0.8)
ax2.legend(loc='upper left', fontsize=6, framealpha=0.8)
ax2.set_xlabel('Turn')
ax2.set_title('Religion History', color=ChartColors.PURPLE, fontsize=9)
ax2.grid(True, alpha=0.2)
# Faith distribution histogram
agents = state.get_living_agents()
if agents:
faiths = [a.get("faith", 50) for a in agents]
ax3.hist(faiths, bins=20, color=ChartColors.PURPLE, alpha=0.7, edgecolor=ChartColors.PANEL)
avg_faith = np.mean(faiths)
ax3.axvline(x=avg_faith, color=ChartColors.YELLOW, linestyle='--', label=f'Avg: {avg_faith:.0f}')
ax3.legend(fontsize=7)
ax3.set_title('Faith Distribution', color=ChartColors.PURPLE, fontsize=9)
ax3.set_xlabel('Faith Level')
ax3.set_xlim(0, 100)
ax3.grid(True, alpha=0.2)
# Average faith over time
if history.avg_faith and turns:
min_len = min(len(turns), len(history.avg_faith))
faith_data = list(history.avg_faith)[-min_len:]
ax4.fill_between(list(turns)[-min_len:], faith_data, alpha=0.3, color=ChartColors.PURPLE)
ax4.plot(list(turns)[-min_len:], faith_data, color=ChartColors.PURPLE, linewidth=2)
ax4.set_title('Avg Faith Over Time', color=ChartColors.PURPLE, fontsize=9)
ax4.set_xlabel('Turn')
ax4.set_ylim(0, 100)
ax4.grid(True, alpha=0.2)
fig.tight_layout()
return self._fig_to_surface(fig)
def render_diplomacy(self, state: "SimulationState", history: HistoryData, w: int, h: int) -> pygame.Surface:
"""Render diplomacy stats - wars and peace treaties."""
fig, axes = plt.subplots(2, 2, figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi)
ax1, ax2, ax3, ax4 = axes.flat
turns = list(history.turns) if history.turns else [0]
# Wars over time
if history.active_wars:
min_len = min(len(turns), len(history.active_wars))
wars_data = list(history.active_wars)[-min_len:]
ax1.fill_between(list(turns)[-min_len:], wars_data, alpha=0.4, color=ChartColors.MAGENTA)
ax1.plot(list(turns)[-min_len:], wars_data, color=ChartColors.MAGENTA, linewidth=2)
ax1.set_title('Active Wars', color=ChartColors.MAGENTA, fontsize=10)
ax1.set_xlabel('Turn')
ax1.set_ylim(bottom=0)
ax1.yaxis.set_major_locator(mticker.MaxNLocator(integer=True))
ax1.grid(True, alpha=0.2)
# Peace treaties over time
if history.peace_treaties:
min_len = min(len(turns), len(history.peace_treaties))
peace_data = list(history.peace_treaties)[-min_len:]
ax2.fill_between(list(turns)[-min_len:], peace_data, alpha=0.4, color=ChartColors.LIME)
ax2.plot(list(turns)[-min_len:], peace_data, color=ChartColors.LIME, linewidth=2)
ax2.set_title('Peace Treaties', color=ChartColors.LIME, fontsize=10)
ax2.set_xlabel('Turn')
ax2.set_ylim(bottom=0)
ax2.yaxis.set_major_locator(mticker.MaxNLocator(integer=True))
ax2.grid(True, alpha=0.2)
# Current wars list
active_wars = state.active_wars
if active_wars:
war_text = '\n'.join([
f"{w.get('faction1', '?')[:8]} vs {w.get('faction2', '?')[:8]}"
for w in active_wars[:6]
])
ax3.text(0.5, 0.5, war_text, ha='center', va='center',
fontsize=9, color=ChartColors.MAGENTA,
transform=ax3.transAxes, family='monospace')
else:
ax3.text(0.5, 0.5, '☮ No Active Wars', ha='center', va='center',
fontsize=12, color=ChartColors.LIME, transform=ax3.transAxes)
ax3.set_title('Current Conflicts', color=ChartColors.MAGENTA, fontsize=10)
ax3.set_xticks([])
ax3.set_yticks([])
# Faction relations heatmap (simplified)
relations = state.faction_relations
if relations:
factions = list(relations.keys())[:5] # Top 5 factions
n = len(factions)
matrix = np.zeros((n, n))
for i, f1 in enumerate(factions):
for j, f2 in enumerate(factions):
if f1 in relations and f2 in relations.get(f1, {}):
rel_data = relations[f1].get(f2, {})
if isinstance(rel_data, dict):
matrix[i, j] = rel_data.get("value", 50)
else:
matrix[i, j] = 50
elif i == j:
matrix[i, j] = 100
else:
matrix[i, j] = 50
im = ax4.imshow(matrix, cmap='RdYlGn', vmin=0, vmax=100)
ax4.set_xticks(range(n))
ax4.set_yticks(range(n))
ax4.set_xticklabels([f[:5] for f in factions], fontsize=7, rotation=45)
ax4.set_yticklabels([f[:5] for f in factions], fontsize=7)
plt.colorbar(im, ax=ax4, shrink=0.8)
ax4.set_title('Relations', color=ChartColors.CYAN, fontsize=10)
fig.tight_layout()
return self._fig_to_surface(fig)
def render_agent_stats(self, state: "SimulationState", w: int, h: int) -> pygame.Surface:
"""Render agent stats distribution."""
fig, axes = plt.subplots(2, 2, figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi)
agents = state.get_living_agents()
if not agents:
for ax in axes.flat:
ax.text(0.5, 0.5, 'No agents', ha='center', va='center', color=ChartColors.TEXT_DIM)
fig.suptitle('Agent Statistics', color=ChartColors.CYAN)
fig.tight_layout()
return self._fig_to_surface(fig)
energies = [a.get("stats", {}).get("energy", 0) for a in agents]
hungers = [a.get("stats", {}).get("hunger", 0) for a in agents]
thirsts = [a.get("stats", {}).get("thirst", 0) for a in agents]
heats = [a.get("stats", {}).get("heat", 0) for a in agents]
stats_data = [
(energies, 100, 'Energy', ChartColors.LIME),
(hungers, 100, 'Hunger', ChartColors.ORANGE),
(thirsts, 100, 'Thirst', ChartColors.CYAN),
(heats, 100, 'Heat', ChartColors.MAGENTA),
]
for ax, (values, max_val, name, color) in zip(axes.flat, stats_data):
bins = np.linspace(0, max_val, 11)
ax.hist(values, bins=bins, color=color, alpha=0.7, edgecolor=ChartColors.PANEL)
mean_val = np.mean(values)
ax.axvline(x=mean_val, color=ChartColors.TEXT, linestyle='--', linewidth=1.5, label=f'Avg: {mean_val:.0f}')
critical = max_val * 0.25
ax.axvline(x=critical, color=ChartColors.MAGENTA, linestyle=':', linewidth=1, alpha=0.7)
ax.set_title(name, color=color, fontsize=9)
ax.set_xlim(0, max_val)
ax.legend(fontsize=7, loc='upper right')
ax.grid(True, alpha=0.2)
fig.suptitle('Agent Statistics', color=ChartColors.CYAN, fontsize=11)
fig.tight_layout()
return self._fig_to_surface(fig)
def render_market_activity(self, state: "SimulationState", history: HistoryData, w: int, h: int) -> pygame.Surface:
"""Render market activity charts."""
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(w/self.dpi, h/self.dpi), dpi=self.dpi)
# Market supply
prices = state.market_prices
resources = []
quantities = []
colors = []
for i, (resource, data) in enumerate(prices.items()):
qty = data.get("total_available", 0)
if qty > 0:
resources.append(resource.title()[:7])
quantities.append(qty)
colors.append(ChartColors.SERIES[i % len(ChartColors.SERIES)])
if resources:
bars = ax1.bar(resources, quantities, color=colors, alpha=0.85)
ax1.set_ylabel('Available')
for bar, val in zip(bars, quantities):
ax1.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.3,
str(val), ha='center', fontsize=7, color=ChartColors.TEXT)
else:
ax1.text(0.5, 0.5, 'No orders', ha='center', va='center', color=ChartColors.TEXT_DIM)
ax1.set_title('Market Supply', color=ChartColors.TEAL, fontsize=10)
ax1.tick_params(axis='x', rotation=45, labelsize=7)
ax1.grid(True, alpha=0.2, axis='y')
# Trade volume over time
turns = list(history.turns) if history.turns else [0]
if history.trade_volume:
min_len = min(len(turns), len(history.trade_volume))
volume_data = list(history.trade_volume)[-min_len:]
ax2.fill_between(list(turns)[-min_len:], volume_data, alpha=0.3, color=ChartColors.TEAL)
ax2.plot(list(turns)[-min_len:], volume_data, color=ChartColors.TEAL, linewidth=2)
ax2.set_title('Trade Volume', color=ChartColors.TEAL, fontsize=10)
ax2.set_xlabel('Turn')
ax2.grid(True, alpha=0.2)
fig.tight_layout()
return self._fig_to_surface(fig)
class StatsRenderer:
"""Main statistics panel with tabbed interface."""
TABS = [
("Population", "population"),
("Prices", "price_history"),
("Wealth", "wealth"),
("Factions", "factions"),
("Religion", "religions"),
("Diplomacy", "diplomacy"),
("Market", "market"),
("Agent Stats", "agent_stats"),
]
def __init__(self, screen: pygame.Surface):
self.screen = screen
self.visible = False
self.font = pygame.font.Font(None, 22)
self.small_font = pygame.font.Font(None, 16)
self.title_font = pygame.font.Font(None, 28)
self.current_tab = 0
self.tab_hovered = -1
self.history = HistoryData()
self.chart_renderer: Optional[ChartRenderer] = None
self._chart_cache: dict[str, pygame.Surface] = {}
self._cache_turn: int = -1
self._calculate_layout()
def _calculate_layout(self) -> None:
"""Calculate panel layout."""
screen_w, screen_h = self.screen.get_size()
margin = 40
self.panel_rect = pygame.Rect(
margin, margin,
screen_w - margin * 2,
screen_h - margin * 2
)
self.tab_height = 36
self.tab_rect = pygame.Rect(
self.panel_rect.x,
self.panel_rect.y,
self.panel_rect.width,
self.tab_height
)
self.chart_rect = pygame.Rect(
self.panel_rect.x + 15,
self.panel_rect.y + self.tab_height + 15,
self.panel_rect.width - 30,
self.panel_rect.height - self.tab_height - 45
)
self.chart_renderer = ChartRenderer(self.chart_rect.width, self.chart_rect.height)
self.tab_width = self.panel_rect.width // len(self.TABS)
def toggle(self) -> None:
"""Toggle visibility."""
self.visible = not self.visible
if self.visible:
self._invalidate_cache()
def update_history(self, state: "SimulationState") -> None:
"""Update history data."""
if state:
self.history.update(state)
def clear_history(self) -> None:
"""Clear history."""
self.history.clear()
self._invalidate_cache()
def _invalidate_cache(self) -> None:
"""Invalidate chart cache."""
self._chart_cache.clear()
self._cache_turn = -1
def handle_event(self, event: pygame.event.Event) -> bool:
"""Handle events."""
if not self.visible:
return False
if event.type == pygame.MOUSEMOTION:
self._handle_mouse_motion(event.pos)
return True
elif event.type == pygame.MOUSEBUTTONDOWN:
if self._handle_click(event.pos):
return True
return True
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
self.toggle()
return True
elif event.key == pygame.K_LEFT:
self.current_tab = (self.current_tab - 1) % len(self.TABS)
self._invalidate_cache()
return True
elif event.key == pygame.K_RIGHT:
self.current_tab = (self.current_tab + 1) % len(self.TABS)
self._invalidate_cache()
return True
return False
def _handle_mouse_motion(self, pos: tuple[int, int]) -> None:
"""Handle mouse motion."""
self.tab_hovered = -1
if self.tab_rect.collidepoint(pos):
rel_x = pos[0] - self.tab_rect.x
tab_idx = rel_x // self.tab_width
if 0 <= tab_idx < len(self.TABS):
self.tab_hovered = tab_idx
def _handle_click(self, pos: tuple[int, int]) -> bool:
"""Handle click."""
if self.tab_rect.collidepoint(pos):
rel_x = pos[0] - self.tab_rect.x
tab_idx = rel_x // self.tab_width
if 0 <= tab_idx < len(self.TABS) and tab_idx != self.current_tab:
self.current_tab = tab_idx
self._invalidate_cache()
return True
return False
def _render_chart(self, state: "SimulationState") -> pygame.Surface:
"""Render current tab chart."""
tab_name, tab_key = self.TABS[self.current_tab]
current_turn = state.turn if state else 0
if tab_key in self._chart_cache and self._cache_turn == current_turn:
return self._chart_cache[tab_key]
w, h = self.chart_rect.width, self.chart_rect.height
if tab_key == "population":
surface = self.chart_renderer.render_population(self.history, w, h)
elif tab_key == "price_history":
surface = self.chart_renderer.render_price_history(self.history, w, h)
elif tab_key == "wealth":
half_h = h // 2
dist = self.chart_renderer.render_wealth_distribution(state, w, half_h)
time_chart = self.chart_renderer.render_wealth_over_time(self.history, w, half_h)
surface = pygame.Surface((w, h))
surface.fill(UIColors.BG)
surface.blit(dist, (0, 0))
surface.blit(time_chart, (0, half_h))
elif tab_key == "factions":
surface = self.chart_renderer.render_factions(state, self.history, w, h)
elif tab_key == "religions":
surface = self.chart_renderer.render_religions(state, self.history, w, h)
elif tab_key == "diplomacy":
surface = self.chart_renderer.render_diplomacy(state, self.history, w, h)
elif tab_key == "market":
surface = self.chart_renderer.render_market_activity(state, self.history, w, h)
elif tab_key == "agent_stats":
surface = self.chart_renderer.render_agent_stats(state, w, h)
else:
surface = pygame.Surface((w, h))
surface.fill(UIColors.BG)
self._chart_cache[tab_key] = surface
self._cache_turn = current_turn
return surface
def draw(self, state: "SimulationState") -> None:
"""Draw statistics panel."""
if not self.visible:
return
# Dim background
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 230))
self.screen.blit(overlay, (0, 0))
# Panel background
pygame.draw.rect(self.screen, UIColors.PANEL_BG, self.panel_rect, border_radius=10)
pygame.draw.rect(self.screen, UIColors.PANEL_BORDER, self.panel_rect, 2, border_radius=10)
# Tabs
self._draw_tabs()
# Chart
if state:
chart = self._render_chart(state)
self.screen.blit(chart, self.chart_rect.topleft)
# Close hint
hint = self.small_font.render("Press G or ESC to close | ← → to switch tabs", True, UIColors.TEXT_SECONDARY)
hint_rect = hint.get_rect(centerx=self.panel_rect.centerx, y=self.panel_rect.bottom - 22)
self.screen.blit(hint, hint_rect)
def _draw_tabs(self) -> None:
"""Draw tab bar."""
for i, (tab_name, _) in enumerate(self.TABS):
tab_x = self.tab_rect.x + i * self.tab_width
tab_rect = pygame.Rect(tab_x, self.tab_rect.y, self.tab_width, self.tab_height)
if i == self.current_tab:
color = UIColors.TAB_ACTIVE
elif i == self.tab_hovered:
color = UIColors.TAB_HOVER
else:
color = UIColors.TAB_INACTIVE
tab_surface = pygame.Surface((self.tab_width, self.tab_height), pygame.SRCALPHA)
pygame.draw.rect(
tab_surface, color, (0, 0, self.tab_width, self.tab_height),
border_top_left_radius=6, border_top_right_radius=6
)
if i == self.current_tab:
tab_surface.set_alpha(255)
else:
tab_surface.set_alpha(180)
self.screen.blit(tab_surface, (tab_x, self.tab_rect.y))
text_color = UIColors.BG if i == self.current_tab else UIColors.TEXT_PRIMARY
text = self.small_font.render(tab_name, True, text_color)
text_rect = text.get_rect(center=tab_rect.center)
self.screen.blit(text, text_rect)
if i != self.current_tab and i < len(self.TABS) - 1:
pygame.draw.line(
self.screen, UIColors.PANEL_BORDER,
(tab_x + self.tab_width - 1, self.tab_rect.y + 6),
(tab_x + self.tab_width - 1, self.tab_rect.y + self.tab_height - 6)
)