| 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", | |
| ` | |
| <div class="panel-title">GENERATION</div> | |
| <div class="stat-value neon-text-cyan" id="gen-count" aria-live="polite">0</div> | |
| <div class="stat-label" id="gen-time">0:00</div> | |
| `, | |
| ); | |
| this._buildPanel( | |
| "panel-stats", | |
| ` | |
| <div class="panel-title"><span class="live-indicator" aria-hidden="true"></span>POPULATION</div> | |
| <div id="pop-total" class="stat-value" style="margin-bottom:6px">0</div> | |
| <div id="pop-breakdown" role="list" aria-label="Population by type"></div> | |
| <hr class="neon-divider"> | |
| <div class="stat-row"> | |
| <span class="stat-label">Births</span> | |
| <span class="count" id="stat-births">0</span> | |
| </div> | |
| <div class="stat-row"> | |
| <span class="stat-label">Deaths</span> | |
| <span class="count" id="stat-deaths">0</span> | |
| </div> | |
| <div class="stat-row"> | |
| <span class="stat-label">Peak</span> | |
| <span class="count" id="stat-peak">0</span> | |
| </div> | |
| <div class="stat-row"> | |
| <span class="stat-label">Diversity</span> | |
| <span class="count" id="stat-diversity">0%</span> | |
| </div> | |
| `, | |
| ); | |
| this._buildPanel( | |
| "panel-performance", | |
| ` | |
| <div class="panel-title">SYSTEM</div> | |
| <div class="stat-row"> | |
| <span class="stat-label">FPS</span> | |
| <span class="count neon-text-green" id="perf-fps">60</span> | |
| </div> | |
| <div class="stat-row"> | |
| <span class="stat-label">Entities</span> | |
| <span class="count" id="perf-entities">0</span> | |
| </div> | |
| <div class="stat-row"> | |
| <span class="stat-label">Particles</span> | |
| <span class="count" id="perf-particles">0</span> | |
| </div> | |
| `, | |
| ); | |
| this._buildPanel( | |
| "panel-events", | |
| ` | |
| <div class="panel-title">EVENT LOG</div> | |
| <div id="event-list" role="log" aria-label="Simulation events" aria-live="polite"></div> | |
| `, | |
| ); | |
| this._buildPanel( | |
| "panel-charts", | |
| ` | |
| <canvas id="chart-canvas" class="chart-canvas" aria-label="Population and fitness charts" role="img"></canvas> | |
| `, | |
| ); | |
| 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 = ` | |
| <span> | |
| <span class="dot dot-${type}" aria-hidden="true"></span> | |
| <span class="stat-label">${info.name}</span> | |
| </span> | |
| <span class="count type-${type}" id="pop-${type}">0</span> | |
| `; | |
| 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 = `<span class="timestamp">${timeStr}</span>${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, "<") | |
| .replace(/>/g, ">"); | |
| } | |
| } | |