// Evolution dashboard - shows live evolution pipeline status import { fetchEvolutionData, type EvolutionLiveData, type IslandStat, type LineageNode, type MetaSnapshot, type GenerationEntry, type CycleInfo, type ActivityEntry, type Totals, type Candidate } from '../api-types'; const ISLAND_COLORS: Record = { alpha: '#ef4444', // red - core-rushing beta: '#f59e0b', // amber - energy-focused gamma: '#22c55e', // green - defensive delta: '#a78bfa', // violet - experimental }; const ISLAND_LABELS: Record = { alpha: 'Alpha (Rush)', beta: 'Beta (Economy)', gamma: 'Gamma (Defense)', delta: 'Delta (Experimental)', }; let pollingInterval: number | null = null; export async function renderEvolutionPage(): Promise { const app = document.getElementById('app'); if (!app) return; app.innerHTML = `

Evolution Dashboard

Loading evolution data...
`; const content = document.getElementById('evolution-content'); if (!content) return; // Clear any existing poll if (pollingInterval !== null) { clearInterval(pollingInterval); } // Initial load await loadEvolutionData(content); // Start polling for live updates (every 10 seconds) pollingInterval = window.setInterval(() => { loadEvolutionData(content); }, 10000); } async function loadEvolutionData(content: HTMLElement): Promise { try { const data = await fetchEvolutionData(); renderDashboard(content, data); } catch { content.innerHTML = `

Evolution data not available yet.

The evolution pipeline needs to run at least one cycle before data appears here. Run acb-evolver live-export to generate the data file.

`; } } // Stop polling when navigating away export function cleanupEvolutionPage(): void { if (pollingInterval !== null) { clearInterval(pollingInterval); pollingInterval = null; } } function renderDashboard(container: HTMLElement, data: EvolutionLiveData): void { container.innerHTML = `

Last updated: ${formatTimestamp(data.updated_at)}  ·  ${data.total_programs || 0} programs  ·  ${data.promoted_count || 0} promoted

Live Status

Island Overview

Statistics

Recent Activity

Meta Tracker Best fitness per island over generations

Lineage Tree Program ancestry (top 80 by fitness)

Generation Log

`; renderIslandGrid(document.getElementById('island-grid')!, data.islands); renderLiveStatus(document.getElementById('live-status')!, data.cycle); renderStatistics(document.getElementById('statistics')!, data.totals); renderActivityFeed(document.getElementById('activity-feed')!, data.recent_activity || []); // Below-the-fold sections: render immediately if already visible (polling update), // otherwise defer with IntersectionObserver. const belowFoldSections = container.querySelectorAll('.evo-below-fold'); const rendered = new Set(); function renderSection(name: string): void { if (rendered.has(name)) return; rendered.add(name); switch (name) { case 'meta': renderMetaChart(document.getElementById('meta-chart')!, data.meta_snapshots ?? []); break; case 'lineage': renderLineageTree(document.getElementById('lineage-tree')!, data.lineage ?? []); break; case 'genlog': renderGenerationLog(document.getElementById('generation-log')!, data.generation_log ?? []); break; } } if (belowFoldSections.length > 0) { const observer = new IntersectionObserver((entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue; const section = (entry.target as HTMLElement).dataset.evoSection; if (section) { renderSection(section); observer.unobserve(entry.target); } } }, { rootMargin: '200px' }); belowFoldSections.forEach(el => { const name = el.dataset.evoSection; if (!name) return; // If already in viewport (e.g. polling update on small screen), render immediately const rect = el.getBoundingClientRect(); if (rect.top < window.innerHeight + 200) { renderSection(name); } else { observer.observe(el); } }); } } // ── Island Status ────────────────────────────────────────────────────────────── function renderIslandGrid(container: HTMLElement, islands: Record): void { const islandOrder = ['alpha', 'beta', 'gamma', 'delta']; const cards = islandOrder.map(island => { const stat = islands[island]; if (!stat) return ''; const color = ISLAND_COLORS[island] ?? '#94a3b8'; const label = ISLAND_LABELS[island] ?? island; return `
${escapeHtml(label)}
Population ${stat.population}
Best Rating ${stat.best_rating}
Best Bot ${escapeHtml(stat.best_bot || '—')}
`; }); container.innerHTML = cards.join(''); } // ── Live Status ───────────────────────────────────────────────────────────────── function renderLiveStatus(container: HTMLElement, cycle: CycleInfo | undefined): void { if (!cycle) { container.innerHTML = '

No active cycle. Evolution is idle.

'; return; } const phaseColors: Record = { idle: '#94a3b8', generating: '#f59e0b', validating: '#3b82f6', evaluating: '#8b5cf6', promoting: '#22c55e', }; const phaseLabel = cycle.phase.charAt(0).toUpperCase() + cycle.phase.slice(1); container.innerHTML = `
Generation #${cycle.generation}
Phase ${phaseLabel}
Started ${formatTimestamp(cycle.started_at)}
${cycle.candidate ? renderCandidateInfo(cycle.candidate) : ''}
`; } function renderCandidateInfo(candidate: Candidate): string { let statusHTML = ''; if (candidate.validation) { const v = candidate.validation; statusHTML += `
Syntax ${v.syntax?.passed ? '✓' : '⋯'}
Schema ${v.schema?.passed ? '✓' : '⋯'}
Smoke ${v.smoke?.passed ? '✓' : '⋯'}
`; } if (candidate.evaluation && candidate.evaluation.matches_total > 0) { const played = candidate.evaluation.matches_played; const total = candidate.evaluation.matches_total; const pct = Math.round((played / total) * 100); statusHTML += `
Evaluating: ${played}/${total} matches
`; } return `
${escapeHtml(candidate.id)} ${escapeHtml(candidate.island)}
Parents: ${candidate.parents.map(p => `${escapeHtml(p.id)} (${p.rating})`).join('')}
${statusHTML}
`; } // ── Statistics ───────────────────────────────────────────────────────────────── function renderStatistics(container: HTMLElement, totals: Totals): void { container.innerHTML = `
Total Generations
${totals.generations_total}
Candidates Today
${totals.candidates_today}
Promoted Today
${totals.promoted_today}
Promotion Rate (7d)
${(totals.promotion_rate_7d * 100).toFixed(1)}%
Highest Evolved Rating
${totals.highest_evolved_rating}
Evolved in Top 10
${totals.evolved_in_top_10}
Mutations / Hour
${totals.mutations_per_hour ?? 0}
`; } // ── Activity Feed ─────────────────────────────────────────────────────────────── function renderActivityFeed(container: HTMLElement, activities: ActivityEntry[]): void { if (!activities || activities.length === 0) { container.innerHTML = '

No recent activity.

'; return; } const rows = activities.map(a => { const resultClass = a.result === 'promoted' ? 'result-promoted' : 'result-rejected'; const resultIcon = a.result === 'promoted' ? '🟢' : '🔴'; const color = ISLAND_COLORS[a.island] || '#94a3b8'; return `
${formatTimeAgo(a.time)} ${resultIcon} ${escapeHtml(a.result)} ${escapeHtml(a.candidate)} ${escapeHtml(a.island)} ${escapeHtml(a.reason)}
`; }).join(''); container.innerHTML = `
${rows}
`; } function formatTimeAgo(iso: string): string { try { const then = new Date(iso).getTime(); const now = Date.now(); const seconds = Math.floor((now - then) / 1000); if (seconds < 60) return `${seconds}s ago`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; return `${Math.floor(seconds / 86400)}d ago`; } catch { return iso; } } // ── Meta Tracker Chart ───────────────────────────────────────────────────────── function renderMetaChart(container: HTMLElement, snapshots: MetaSnapshot[]): void { if (!snapshots || snapshots.length === 0) { container.innerHTML = '

No generation data yet.

'; return; } const islands = ['alpha', 'beta', 'gamma', 'delta']; const W = 700, H = 220; const padL = 44, padR = 16, padT = 16, padB = 36; const chartW = W - padL - padR; const chartH = H - padT - padB; const gens = snapshots.map(s => s.generation); const minGen = gens[0]; const maxGen = gens[gens.length - 1]; const genRange = Math.max(maxGen - minGen, 1); // Find max count across all islands/snapshots for Y scale let maxCount = 1; for (const snap of snapshots) { for (const island of islands) { const v = snap.island_counts[island] ?? 0; if (v > maxCount) maxCount = v; } } const xOf = (gen: number) => padL + ((gen - minGen) / genRange) * chartW; const yOf = (v: number) => padT + chartH - (v / maxCount) * chartH; const lineEls: string[] = []; const dotEls: string[] = []; const legendEls: string[] = []; for (const island of islands) { const color = ISLAND_COLORS[island] ?? '#94a3b8'; const points = snapshots.map(s => ({ x: xOf(s.generation), y: yOf(s.island_counts[island] ?? 0), })); if (points.length < 2) { // single point — draw a dot if (points.length === 1) { dotEls.push(``); } } else { const d = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '); lineEls.push(``); for (const p of points) { dotEls.push(``); } } } // Legend islands.forEach((island, i) => { const color = ISLAND_COLORS[island] ?? '#94a3b8'; const lx = padL + i * 120; const ly = H - 6; legendEls.push(` ${escapeHtml(ISLAND_LABELS[island] ?? island)} `); }); // Y axis ticks const yTicks: string[] = []; const tickCount = 4; for (let i = 0; i <= tickCount; i++) { const v = Math.round((maxCount / tickCount) * i); const y = yOf(v); yTicks.push(` ${v} `); } // X axis ticks (up to 6) const xTicks: string[] = []; const xTickCount = Math.min(6, snapshots.length); const step = Math.max(1, Math.floor(snapshots.length / xTickCount)); for (let i = 0; i < snapshots.length; i += step) { const snap = snapshots[i]; const x = xOf(snap.generation); xTicks.push(` G${snap.generation} `); } container.innerHTML = ` ${yTicks.join('')} ${xTicks.join('')} ${lineEls.join('')} ${dotEls.join('')} ${legendEls.join('')} `; } // ── Lineage Tree ─────────────────────────────────────────────────────────────── function renderLineageTree(container: HTMLElement, nodes: LineageNode[]): void { if (!nodes || nodes.length === 0) { container.innerHTML = '

No lineage data yet.

'; return; } // Keep top 80 by fitness to keep the tree readable const sorted = [...nodes].sort((a, b) => b.fitness - a.fitness).slice(0, 80); const nodeById = new Map(sorted.map(n => [n.id as unknown as number, n])); // Group by generation for Y layout const genSet = new Set(sorted.map(n => n.generation)); const gens = Array.from(genSet).sort((a, b) => a - b); const genIndex = new Map(gens.map((g, i) => [g, i])); const maxGenIdx = gens.length - 1; const NODE_R = 6; const H_GAP = 38; // horizontal spacing between nodes on same generation const V_GAP = 54; // vertical spacing between generation rows const PAD_X = 20; const PAD_Y = 20; // Count nodes per generation for X layout const nodesPerGen = new Map(); for (const n of sorted) { if (!nodesPerGen.has(n.generation)) nodesPerGen.set(n.generation, []); nodesPerGen.get(n.generation)!.push(n); } // Assign x positions — spread per generation const nodePos = new Map(); for (const [gen, genNodes] of nodesPerGen) { const gIdx = genIndex.get(gen) ?? 0; const y = PAD_Y + gIdx * V_GAP; genNodes.forEach((n, i) => { const x = PAD_X + i * H_GAP; nodePos.set(n.id as unknown as number, { x, y }); }); } // SVG dimensions const svgW = Math.max(...Array.from(nodePos.values()).map(p => p.x)) + PAD_X + NODE_R + 60; const svgH = PAD_Y + maxGenIdx * V_GAP + PAD_Y + 20; const edges: string[] = []; const nodeEls: string[] = []; // Draw edges for (const n of sorted) { const pos = nodePos.get(n.id as unknown as number); if (!pos) continue; for (const pid of (n.parent_ids ?? [])) { if (!nodeById.has(pid as unknown as number)) continue; const ppos = nodePos.get(pid as unknown as number); if (!ppos) continue; edges.push(``); } } // Draw nodes for (const n of sorted) { const pos = nodePos.get(n.id as unknown as number); if (!pos) continue; const color = ISLAND_COLORS[n.island] ?? '#94a3b8'; const strokeW = n.promoted ? 2.5 : 1; const strokeColor = n.promoted ? '#ffffff' : color; const r = n.promoted ? NODE_R + 2 : NODE_R; const title = `#${n.id} ${n.island} gen${n.generation} ${n.language} fit=${(n.fitness * 100).toFixed(1)}%${n.promoted ? ' PROMOTED' : ''}`; nodeEls.push(` ${escapeHtml(title)} `); } // Generation labels on the left const genLabels = gens.map(gen => { const gIdx = genIndex.get(gen) ?? 0; const y = PAD_Y + gIdx * V_GAP; return `G${gen}`; }); // Legend const legendIslands = ['alpha', 'beta', 'gamma', 'delta']; const legendY = svgH - 4; const legendEls = legendIslands.map((island, i) => { const color = ISLAND_COLORS[island] ?? '#94a3b8'; const lx = PAD_X + i * 110; return ` ${island} `; }); const legendPromo = ` promoted `; const fullSvgH = svgH + 20; container.innerHTML = ` ${edges.join('')} ${nodeEls.join('')} ${genLabels.join('')} ${legendEls.join('')} ${legendPromo} `; } // ── Generation Log Table ─────────────────────────────────────────────────────── const GEN_LOG_BATCH = 30; function renderGenerationLog(container: HTMLElement, log: GenerationEntry[]): void { if (!log || log.length === 0) { container.innerHTML = '

No generation history yet.

'; return; } // Show first batch immediately, paginate the rest const initial = log.slice(0, GEN_LOG_BATCH); const remaining = log.slice(GEN_LOG_BATCH); container.innerHTML = ` ${initial.map(renderGenRow).join('')}
Gen Island Programs Promoted Best Fitness Avg Fitness Timestamp
`; if (remaining.length > 0) { let offset = 0; const total = remaining.length; const btn = document.createElement('button'); btn.className = 'btn secondary show-more-btn'; btn.type = 'button'; function updateBtn(): void { const left = total - offset; if (left <= 0) { btn.remove(); return; } const next = Math.min(GEN_LOG_BATCH, left); btn.textContent = `Show ${next} more generations (${left} remaining)`; btn.setAttribute('aria-label', `Show ${next} more generations, ${left} remaining`); } btn.addEventListener('click', () => { const batch = remaining.slice(offset, offset + GEN_LOG_BATCH); const tbody = document.getElementById('gen-log-tbody'); if (!tbody) return; tbody.insertAdjacentHTML('beforeend', batch.map(renderGenRow).join('')); offset += batch.length; updateBtn(); }); updateBtn(); container.appendChild(btn); } } function renderGenRow(e: GenerationEntry): string { const color = ISLAND_COLORS[e.island] ?? '#94a3b8'; const bestPct = (e.best_fitness * 100).toFixed(1); const avgPct = (e.avg_fitness * 100).toFixed(1); const barWidth = Math.round(e.best_fitness * 100); return ` ${e.generation} ${escapeHtml(e.island)} ${e.count} ${e.promoted}
${bestPct}%
${avgPct}% ${formatTimestamp(e.evaluated_at)} `; } // ── Helpers ──────────────────────────────────────────────────────────────────── function formatTimestamp(iso: string): string { try { return new Date(iso).toLocaleString(); } catch { return iso; } } function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); }