import { ENTITY_TYPES } from "./genetics.js"; import { EVENT_TYPES } from "./world.js"; import { COLORS, formatNumber } from "./utils.js"; const MAX_EVENTS = 30; export class UI { constructor(container) { this.container = container; this.events = []; this._announcement = null; this._announcementTimeout = null; this._elements = {}; this._chartCanvas = null; this._chartCtx = null; this._built = false; } init() { if (this._built) return; this._built = true; this._buildPanel( "panel-generation", `
GENERATION
0
0:00
`, ); this._buildPanel( "panel-stats", `
POPULATION
0

Births 0
Deaths 0
Peak 0
Diversity 0%
`, ); this._buildPanel( "panel-performance", `
SYSTEM
FPS 60
Entities 0
Particles 0
`, ); this._buildPanel( "panel-events", `
EVENT LOG
`, ); this._buildPanel( "panel-charts", ` `, ); this._buildPopRows(); const ann = document.createElement("div"); ann.className = "announcement"; ann.id = "announcement"; ann.setAttribute("role", "alert"); ann.setAttribute("aria-live", "assertive"); this.container.appendChild(ann); this._announcement = ann; this._chartCanvas = document.getElementById("chart-canvas"); if (this._chartCanvas) { this._resizeChartCanvas(); this._chartCtx = this._chartCanvas.getContext("2d"); } const srSummary = document.createElement("div"); srSummary.className = "sr-only"; srSummary.id = "sr-summary"; srSummary.setAttribute("aria-live", "polite"); srSummary.setAttribute("aria-atomic", "true"); this.container.appendChild(srSummary); this._srSummary = srSummary; this._srTimer = 0; window.addEventListener("resize", () => this._resizeChartCanvas()); } _buildPanel(id, html) { const panel = document.createElement("div"); panel.id = id; panel.className = "hud-panel glass-panel"; panel.innerHTML = html; panel.setAttribute("tabindex", "0"); this.container.appendChild(panel); return panel; } _buildPopRows() { const container = document.getElementById("pop-breakdown"); if (!container) return; for (const [type, info] of Object.entries(ENTITY_TYPES)) { const row = document.createElement("div"); row.className = "stat-row"; row.setAttribute("role", "listitem"); row.innerHTML = ` ${info.name} 0 `; container.appendChild(row); } } _resizeChartCanvas() { const canvas = this._chartCanvas; if (!canvas) return; const panel = canvas.parentElement; const rect = panel.getBoundingClientRect(); canvas.width = rect.width - 32; canvas.height = rect.height - 32; this._chartCtx = canvas.getContext("2d"); } update(stats, world, entityManager, dt) { const c = stats.current; this._setText("gen-count", stats.generationCount.toString()); this._setText("gen-time", this._formatTime(stats.totalTime)); this._setText("pop-total", c.totalPop.toString()); for (const type of Object.keys(ENTITY_TYPES)) { this._setText(`pop-${type}`, (c.population[type] || 0).toString()); } this._setText("stat-births", formatNumber(stats.totalBirths)); this._setText("stat-deaths", formatNumber(stats.totalDeaths)); this._setText("stat-peak", stats.peakPopulation.toString()); this._setText("stat-diversity", (c.diversity * 100).toFixed(0) + "%"); this._setText("perf-fps", Math.round(c.fps).toString()); this._setText("perf-entities", c.entityCount.toString()); this._setText("perf-particles", c.particleCount.toString()); const fpsEl = document.getElementById("perf-fps"); if (fpsEl) { if (c.fps >= 50) fpsEl.className = "count neon-text-green"; else if (c.fps >= 30) fpsEl.className = "count neon-text-amber"; else fpsEl.className = "count neon-text-red"; } if (this._chartCtx && this._chartCanvas) { this._chartCtx.clearRect( 0, 0, this._chartCanvas.width, this._chartCanvas.height, ); stats.renderCharts( this._chartCtx, 4, 0, this._chartCanvas.width - 8, this._chartCanvas.height, ); } this._srTimer += dt; if (this._srTimer >= 10) { this._srTimer = 0; this._updateScreenReaderSummary(stats); } } _setText(id, text) { const el = document.getElementById(id); if (el && el.textContent !== text) { el.textContent = text; } } _formatTime(seconds) { const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m}:${s.toString().padStart(2, "0")}`; } addEvent(message, type = "world") { const time = new Date(); const timeStr = time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", }); this.events.push({ message, type, time: timeStr }); if (this.events.length > MAX_EVENTS) this.events.shift(); const list = document.getElementById("event-list"); if (!list) return; const entry = document.createElement("div"); entry.className = `event-entry event-${type}`; entry.innerHTML = `${timeStr}${this._escapeHtml(message)}`; list.appendChild(entry); while (list.children.length > MAX_EVENTS) { list.removeChild(list.firstChild); } const panel = document.getElementById("panel-events"); if (panel) panel.scrollTop = panel.scrollHeight; } announce(message) { if (!this._announcement) return; this._announcement.textContent = message; this._announcement.classList.add("visible"); if (this._announcementTimeout) clearTimeout(this._announcementTimeout); this._announcementTimeout = setTimeout(() => { this._announcement.classList.remove("visible"); }, 4000); } announceWorldEvent(event) { const info = EVENT_TYPES[event.type]; if (info) { this.announce(`${info.icon} ${info.name} detected!`); this.addEvent( `${info.name} at (${Math.round(event.x)}, ${Math.round(event.y)})`, "world", ); } } _updateScreenReaderSummary(stats) { const c = stats.current; const parts = [ `Generation ${stats.generationCount}.`, `Total population: ${c.totalPop}.`, ]; for (const [type, info] of Object.entries(ENTITY_TYPES)) { parts.push(`${info.name}: ${c.population[type] || 0}.`); } parts.push(`Average fitness: ${c.avgFitness.toFixed(1)}.`); parts.push(`Diversity: ${(c.diversity * 100).toFixed(0)} percent.`); if (this._srSummary) { this._srSummary.textContent = parts.join(" "); } } _escapeHtml(str) { return str .replace(/&/g, "&") .replace(//g, ">"); } }