"""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) )