| 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; | |
| } | |
| } | |