import { RingBuffer, COLORS, EMA } from "./utils.js"; import { ENTITY_TYPES, measureDiversity } from "./genetics.js"; const HISTORY_LENGTH = 200; export class StatsTracker { constructor() { this.generationCount = 0; this.totalBirths = 0; this.totalDeaths = 0; this.totalTime = 0; this.peakPopulation = 0; this.popHistory = {}; this.totalPopHistory = new RingBuffer(HISTORY_LENGTH); for (const type of Object.keys(ENTITY_TYPES)) { this.popHistory[type] = new RingBuffer(HISTORY_LENGTH); } this.avgFitnessHistory = new RingBuffer(HISTORY_LENGTH); this.maxFitnessHistory = new RingBuffer(HISTORY_LENGTH); this.diversityHistory = new RingBuffer(HISTORY_LENGTH); this.fpsEMA = new EMA(0.1); this.entityCountEMA = new EMA(0.2); this.current = { population: {}, totalPop: 0, avgFitness: 0, maxFitness: 0, diversity: 0, fps: 60, entityCount: 0, particleCount: 0, }; this.heatmapCols = 40; this.heatmapRows = 30; this.heatmap = new Float32Array(this.heatmapCols * this.heatmapRows); this._recordTimer = 0; this._recordInterval = 0.5; } record(entities, world, dt, fps, particleCount) { this.totalTime += dt; this._recordTimer += dt; this.current.fps = this.fpsEMA.update(fps); this.current.entityCount = entities.length; this.current.particleCount = particleCount; if (this._recordTimer < this._recordInterval) return; this._recordTimer = 0; const popCounts = {}; for (const type of Object.keys(ENTITY_TYPES)) { popCounts[type] = 0; } let totalFitness = 0; let maxFitness = 0; const genomes = []; this.heatmap.fill(0); const cellW = world.width / this.heatmapCols; const cellH = world.height / this.heatmapRows; for (const e of entities) { popCounts[e.type] = (popCounts[e.type] || 0) + 1; totalFitness += e.fitness; if (e.fitness > maxFitness) maxFitness = e.fitness; genomes.push(e.genome); const col = Math.floor(e.x / cellW); const row = Math.floor(e.y / cellH); if ( col >= 0 && col < this.heatmapCols && row >= 0 && row < this.heatmapRows ) { this.heatmap[row * this.heatmapCols + col] += 1; } } const totalPop = entities.length; this.current.population = popCounts; this.current.totalPop = totalPop; this.current.avgFitness = totalPop > 0 ? totalFitness / totalPop : 0; this.current.maxFitness = maxFitness; this.current.diversity = measureDiversity(genomes); if (totalPop > this.peakPopulation) this.peakPopulation = totalPop; this.totalPopHistory.push(totalPop); for (const type of Object.keys(ENTITY_TYPES)) { this.popHistory[type].push(popCounts[type] || 0); } this.avgFitnessHistory.push(this.current.avgFitness); this.maxFitnessHistory.push(maxFitness); this.diversityHistory.push(this.current.diversity); } onBirth() { this.totalBirths++; } onDeath() { this.totalDeaths++; } onGeneration() { this.generationCount++; } renderSparkline(ctx, buffer, x, y, w, h, color) { const data = buffer.toArray(); if (data.length < 2) return; let max = -Infinity, min = Infinity; for (const v of data) { if (v > max) max = v; if (v < min) min = v; } const range = max - min || 1; ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.lineJoin = "round"; for (let i = 0; i < data.length; i++) { const px = x + (i / (data.length - 1)) * w; const py = y + h - ((data[i] - min) / range) * h; if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); } ctx.stroke(); ctx.lineTo(x + w, y + h); ctx.lineTo(x, y + h); ctx.closePath(); const grad = ctx.createLinearGradient(x, y, x, y + h); const rgbMatch = color.match(/(\d+),\s*(\d+),\s*(\d+)/); if (rgbMatch) { grad.addColorStop( 0, `rgba(${rgbMatch[1]},${rgbMatch[2]},${rgbMatch[3]},0.15)`, ); grad.addColorStop( 1, `rgba(${rgbMatch[1]},${rgbMatch[2]},${rgbMatch[3]},0.0)`, ); } ctx.fillStyle = grad; ctx.fill(); } renderCharts(ctx, x, y, w, h) { const chartH = h / 3 - 8; const pad = 4; ctx.fillStyle = "rgba(100, 200, 255, 0.4)"; ctx.font = "9px Courier New"; ctx.fillText("POPULATION", x + 2, y + 8); this.renderSparkline( ctx, this.totalPopHistory, x, y + 12, w, chartH, "rgb(0, 240, 255)", ); const types = Object.keys(ENTITY_TYPES); for (const type of types) { const c = COLORS[type]; if (c) { this.renderSparkline( ctx, this.popHistory[type], x, y + 12, w, chartH, `rgb(${c.r},${c.g},${c.b})`, ); } } const fy = y + chartH + 20; ctx.fillStyle = "rgba(100, 200, 255, 0.4)"; ctx.fillText("FITNESS", x + 2, fy + 8); this.renderSparkline( ctx, this.avgFitnessHistory, x, fy + 12, w, chartH, "rgb(0, 255, 136)", ); this.renderSparkline( ctx, this.maxFitnessHistory, x, fy + 12, w, chartH, "rgb(255, 170, 0)", ); const dy = fy + chartH + 20; ctx.fillStyle = "rgba(100, 200, 255, 0.4)"; ctx.fillText("DIVERSITY", x + 2, dy + 8); this.renderSparkline( ctx, this.diversityHistory, x, dy + 12, w, chartH, "rgb(204, 102, 255)", ); } renderHeatmap(ctx, worldW, worldH) { const cellW = worldW / this.heatmapCols; const cellH = worldH / this.heatmapRows; let maxDensity = 0; for (let i = 0; i < this.heatmap.length; i++) { if (this.heatmap[i] > maxDensity) maxDensity = this.heatmap[i]; } if (maxDensity === 0) return; for (let row = 0; row < this.heatmapRows; row++) { for (let col = 0; col < this.heatmapCols; col++) { const density = this.heatmap[row * this.heatmapCols + col]; if (density === 0) continue; const intensity = density / maxDensity; const alpha = intensity * 0.06; ctx.fillStyle = `rgba(0, 240, 255, ${alpha})`; ctx.fillRect(col * cellW, row * cellH, cellW + 1, cellH + 1); } } } toJSON() { return { generationCount: this.generationCount, totalBirths: this.totalBirths, totalDeaths: this.totalDeaths, totalTime: this.totalTime, peakPopulation: this.peakPopulation, }; } static fromJSON(data) { const s = new StatsTracker(); s.generationCount = data.generationCount || 0; s.totalBirths = data.totalBirths || 0; s.totalDeaths = data.totalDeaths || 0; s.totalTime = data.totalTime || 0; s.peakPopulation = data.peakPopulation || 0; return s; } }