Felladrin's picture
Add the simulator files
17fd0c2 verified
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
}