feat(web): win probability sparkline + critical moment navigation

Add §13.2 win probability graph and critical moment navigation to the
replay viewer.

- types.ts: add ReplayCriticalMoment interface; extend Replay with
  win_prob ([][]float per turn) and critical_moments fields
- replay-viewer.ts: export CriticalMomentMarker; draw dashed vertical
  lines with delta labels for critical moments on the sparkline canvas;
  add setCriticalMoments / getCriticalMomentMarkers; update
  createWinProbSparkline to accept click-to-scrub callback and replace
  existing canvas on reload; refresh sparkline on every render()
- app.ts: add win-probability section below main canvas with prev/next
  critical moment buttons, description label, and player legend;
  initWinProb converts win_prob array to WinProbPoint[] and wires up
  setCriticalMoments; graceful hide when win_prob absent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-21 08:36:42 -04:00
parent 72addb2089
commit 9850020c53
3 changed files with 993 additions and 37 deletions

View file

@ -20,46 +20,123 @@ import { renderPredictionsPage } from './pages/predictions';
import { ReplayViewer } from './replay-viewer';
import type { Replay } from './types';
// Route definitions
// Backwards compatibility redirects
const redirectMap: Record<string, string> = {
'/matches': '/watch/replays',
'/playlists': '/watch/replays',
'/replay': '/watch/replay',
'/predictions': '/watch/predictions',
'/series': '/watch/series',
'/sandbox': '/compete/sandbox',
'/register': '/compete/register',
'/bots': '/leaderboard',
'/docs': '/compete/docs',
'/docs/api': '/compete/docs',
'/clip-maker': '/watch/replays',
'/rivalries': '/watch/replays',
'/feedback': '/compete/docs',
};
// Helper to redirect to new route
function redirect(to: string): (params: Record<string, string>) => void {
return (params: Record<string, string>) => {
const fullPath = Object.entries(params).reduce(
(path, [key, value]) => path.replace(`:${key}`, encodeURIComponent(value)),
to
);
router.navigate(fullPath);
};
}
// Route definitions with new Watch/Compete hub structure
router
// Main routes
.on('/', renderHomePage)
.on('/watch', renderWatchHubPage)
.on('/watch/replays', renderMatchesPage)
.on('/watch/replay/:id', renderReplayPage)
.on('/watch/series/:id', renderSeriesPage)
.on('/watch/predictions', renderPredictionsPage)
.on('/watch/series', renderSeriesPage)
.on('/compete', renderCompeteHubPage)
.on('/compete/sandbox', renderSandboxPage)
.on('/compete/register', renderRegisterPage)
.on('/compete/bot/:id', renderBotProfilePage)
.on('/compete/docs', renderDocsPage)
.on('/leaderboard', renderLeaderboardPage)
.on('/matches', renderMatchesPage)
.on('/bots', renderBotsPage)
.on('/bot/:id', renderBotProfilePage)
.on('/register', renderRegisterPage)
.on('/evolution', renderEvolutionPage)
.on('/sandbox', renderSandboxPage)
.on('/clip-maker', renderClipMakerPage)
.on('/rivalries', renderRivalriesPage)
.on('/feedback', renderFeedbackPage)
.on('/playlists', renderPlaylistsPage)
.on('/blog', renderBlogPage)
.on('/blog/:slug', renderBlogPostPage)
.on('/replay', renderReplayPage)
.on('/docs', renderDocsPage)
.on('/docs/api', renderDocsPage)
.on('/season/:id', renderSeasonDetailPage)
.on('/seasons', renderSeasonsPage)
.on('/series', renderSeriesPage)
.on('/predictions', renderPredictionsPage)
.on('/bot/:id', renderBotProfilePage)
// Backwards compatibility redirects
.on('/matches', redirect('/watch/replays'))
.on('/playlists', redirect('/watch/replays'))
.on('/replay', redirect('/watch/replay'))
.on('/predictions', redirect('/watch/predictions'))
.on('/series', redirect('/watch/series'))
.on('/sandbox', redirect('/compete/sandbox'))
.on('/register', redirect('/compete/register'))
.on('/bots', redirect('/leaderboard'))
.on('/docs', redirect('/compete/docs'))
.on('/docs/api', redirect('/compete/docs'))
.on('/clip-maker', redirect('/watch/replays'))
.on('/rivalries', redirect('/watch/replays'))
.on('/feedback', redirect('/compete/docs'))
.notFound(renderNotFoundPage);
// Update active nav link on route change
function updateActiveNavLink(): void {
const currentPath = router.getCurrentPath();
// Clear all active states
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.remove('active');
});
// Set active state for matching links
document.querySelectorAll('.nav-link').forEach(link => {
const href = link.getAttribute('href');
if (href) {
const linkPath = href.slice(2); // Remove '#/'
if (currentPath === linkPath || (linkPath !== '' && currentPath.startsWith(linkPath))) {
// Check for exact match or prefix match for hub pages
if (currentPath === linkPath ||
(linkPath !== '' && currentPath.startsWith(linkPath)) ||
(linkPath === '/watch' && currentPath.startsWith('/watch')) ||
(linkPath === '/compete' && currentPath.startsWith('/compete'))) {
link.classList.add('active');
} else {
link.classList.remove('active');
}
}
});
}
// Mobile menu toggle
function initMobileMenu(): void {
const toggle = document.getElementById('mobile-menu-toggle');
const menu = document.getElementById('mobile-menu');
if (!toggle || !menu) return;
toggle.addEventListener('click', () => {
menu.classList.toggle('open');
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (!menu.contains(e.target as Node) && !toggle.contains(e.target as Node)) {
menu.classList.remove('open');
}
});
// Close menu on route change
const originalNavigate = router.navigate.bind(router);
router.navigate = (path: string) => {
originalNavigate(path);
menu.classList.remove('open');
};
}
// Override router navigation to update nav links
const originalNavigate = router.navigate.bind(router);
router.navigate = (path: string) => {
@ -82,6 +159,21 @@ function renderReplayPage(params: Record<string, string>): void {
<canvas id="replay-canvas"></canvas>
<div id="no-replay" class="no-replay-message">Load a replay file to view</div>
</div>
<div id="win-prob-section" class="win-prob-section" style="display:none">
<div class="win-prob-header">
<span class="win-prob-title">Win Probability</span>
<div class="critical-moment-nav">
<button id="prev-critical-btn" class="btn" title="Previous critical moment" disabled>&#9664; Prev</button>
<span id="critical-moment-info" class="critical-moment-info">&#8212;</span>
<button id="next-critical-btn" class="btn" title="Next critical moment" disabled>Next &#9654;</button>
</div>
</div>
<div id="win-prob-container" class="win-prob-container"></div>
<div class="win-prob-legend">
<span id="wp-p0-label" class="wp-legend-p0">&#8212; Player 0</span>
<span id="wp-p1-label" class="wp-legend-p1">-- Player 1</span>
</div>
</div>
</div>
<div class="replay-sidebar">
@ -379,6 +471,76 @@ function renderReplayPage(params: Record<string, string>): void {
margin-right: 4px;
}
.win-prob-section {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 12px;
margin-top: 10px;
}
.win-prob-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 8px;
}
.win-prob-title {
color: var(--text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.critical-moment-nav {
display: flex;
align-items: center;
gap: 8px;
}
.critical-moment-nav .btn {
padding: 4px 10px;
font-size: 0.75rem;
}
.critical-moment-nav .btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.critical-moment-info {
color: var(--text-muted);
font-size: 0.8rem;
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.win-prob-container {
width: 100%;
overflow: hidden;
border-radius: 4px;
}
.win-prob-legend {
display: flex;
gap: 16px;
margin-top: 6px;
font-size: 0.75rem;
}
.wp-legend-p0 {
color: #3b82f6;
}
.wp-legend-p1 {
color: #ef4444;
}
@media (max-width: 900px) {
.replay-layout {
flex-direction: column;
@ -417,8 +579,16 @@ function initReplayViewer(initialUrl?: string): void {
const infoWinner = document.getElementById('info-winner') as HTMLElement;
const infoTurns = document.getElementById('info-turns') as HTMLElement;
const infoReason = document.getElementById('info-reason') as HTMLElement;
const winProbSection = document.getElementById('win-prob-section') as HTMLDivElement;
const winProbContainer = document.getElementById('win-prob-container') as HTMLDivElement;
const prevCriticalBtn = document.getElementById('prev-critical-btn') as HTMLButtonElement;
const nextCriticalBtn = document.getElementById('next-critical-btn') as HTMLButtonElement;
const criticalMomentInfo = document.getElementById('critical-moment-info') as HTMLSpanElement;
const wpP0Label = document.getElementById('wp-p0-label') as HTMLSpanElement;
const wpP1Label = document.getElementById('wp-p1-label') as HTMLSpanElement;
let viewer = new ReplayViewer(canvas, { cellSize: 10 });
let criticalMoments: Array<{turn: number; delta: number; description: string}> = [];
function enableControls(): void {
playBtn.disabled = false;
@ -480,8 +650,82 @@ function initReplayViewer(initialUrl?: string): void {
turnSlider.max = String(viewer.getTotalTurns() - 1);
updateUI();
updateEventLog();
initWinProb(replay);
}
function initWinProb(replay: Replay): void {
if (!replay.win_prob || replay.win_prob.length === 0) {
winProbSection.style.display = 'none';
return;
}
const points = replay.win_prob.map((pair, t) => ({
turn: t,
p0WinProb: pair[0] ?? 0.5,
p1WinProb: pair[1] ?? 0.5,
drawProb: Math.max(0, 1 - (pair[0] ?? 0.5) - (pair[1] ?? 0.5)),
}));
criticalMoments = replay.critical_moments ?? [];
viewer.setWinProbabilityData(points);
viewer.setCriticalMoments(criticalMoments);
winProbSection.style.display = 'block';
if (replay.players.length >= 1) wpP0Label.textContent = `${replay.players[0].name}`;
if (replay.players.length >= 2) wpP1Label.textContent = `-- ${replay.players[1].name}`;
winProbContainer.innerHTML = '';
viewer.createWinProbSparkline(winProbContainer, 800, 70, (turn) => {
viewer.setTurn(turn);
updateUI();
updateEventLog();
});
updateCriticalMomentNav();
}
function updateCriticalMomentNav(): void {
const hasMoments = criticalMoments.length > 0;
prevCriticalBtn.disabled = !hasMoments;
nextCriticalBtn.disabled = !hasMoments;
if (hasMoments) {
const currentTurn = viewer.getTurn();
const atMoment = criticalMoments.find(m => m.turn === currentTurn);
if (atMoment) {
criticalMomentInfo.textContent = atMoment.description;
} else {
criticalMomentInfo.textContent = `${criticalMoments.length} critical moment${criticalMoments.length !== 1 ? 's' : ''}`;
}
} else {
criticalMomentInfo.textContent = '—';
}
}
prevCriticalBtn.addEventListener('click', () => {
const currentTurn = viewer.getTurn();
const prev = [...criticalMoments].reverse().find(m => m.turn < currentTurn);
if (prev) {
viewer.setTurn(prev.turn);
updateUI();
updateEventLog();
criticalMomentInfo.textContent = prev.description;
}
});
nextCriticalBtn.addEventListener('click', () => {
const currentTurn = viewer.getTurn();
const next = criticalMoments.find(m => m.turn > currentTurn);
if (next) {
viewer.setTurn(next.turn);
updateUI();
updateEventLog();
criticalMomentInfo.textContent = next.description;
}
});
fileInput.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
@ -533,8 +777,11 @@ function initReplayViewer(initialUrl?: string): void {
const size = parseInt(cellSizeSelect.value, 10);
const replay = viewer.getReplay();
if (replay) {
const prevTurn = viewer.getTurn();
viewer = new ReplayViewer(canvas, { cellSize: size });
loadReplay(replay);
viewer.setTurn(prevTurn);
updateUI();
}
});
@ -564,7 +811,11 @@ function initReplayViewer(initialUrl?: string): void {
updateAccessibility();
}
viewer.onTurnChange = () => { updateUI(); updateEventLog(); };
viewer.onTurnChange = () => {
updateUI();
updateEventLog();
if (criticalMoments.length > 0) updateCriticalMomentNav();
};
viewer.onPlayStateChange = (playing) => { playBtn.textContent = playing ? 'Pause' : 'Play'; };
document.addEventListener('keydown', (e) => {
@ -608,6 +859,630 @@ function initReplayViewer(initialUrl?: string): void {
}
}
// Watch hub page - spectator hub with replays, playlists, predictions
function renderWatchHubPage(): void {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="watch-hub-page">
<h1 class="page-title">Watch</h1>
<p class="page-subtitle">Spectate matches, browse replays, and make predictions</p>
<div class="watch-grid">
<a href="#/watch/replays" class="watch-card">
<div class="card-icon">📺</div>
<h2>Match Replays</h2>
<p>Browse all completed matches and watch replays</p>
</a>
<a href="#/watch/predictions" class="watch-card">
<div class="card-icon">🎯</div>
<h2>Predictions</h2>
<p>Predict match winners and climb the predictor leaderboard</p>
</a>
<a href="#/leaderboard" class="watch-card">
<div class="card-icon">🏆</div>
<h2>Leaderboard</h2>
<p>See current rankings and top bots</p>
</a>
</div>
<div class="featured-section">
<h2>Featured Playlists</h2>
<div id="featured-playlists" class="playlists-preview">
<div class="loading">Loading playlists...</div>
</div>
</div>
</div>
<style>
.watch-hub-page {
max-width: 1200px;
margin: 0 auto;
}
.page-subtitle {
color: var(--text-muted);
margin-bottom: 32px;
}
.watch-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.watch-card {
background-color: var(--bg-secondary);
border-radius: 12px;
padding: 32px 24px;
text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
display: block;
}
.watch-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.card-icon {
font-size: 3rem;
margin-bottom: 16px;
}
.watch-card h2 {
color: var(--text-primary);
margin-bottom: 8px;
font-size: 1.25rem;
}
.watch-card p {
color: var(--text-muted);
font-size: 0.875rem;
}
.featured-section {
margin-top: 40px;
}
.featured-section h2 {
color: var(--text-primary);
margin-bottom: 16px;
}
.playlists-preview {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.playlist-preview-card {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 16px;
text-decoration: none;
transition: transform 0.2s;
}
.playlist-preview-card:hover {
transform: translateY(-2px);
}
.playlist-preview-card h3 {
color: var(--text-primary);
font-size: 1rem;
margin-bottom: 4px;
}
.playlist-preview-card p {
color: var(--text-muted);
font-size: 0.75rem;
}
.loading {
color: var(--text-muted);
text-align: center;
padding: 40px;
grid-column: 1 / -1;
}
</style>
`;
// Load featured playlists
loadFeaturedPlaylists();
}
async function loadFeaturedPlaylists(): Promise<void> {
const container = document.getElementById('featured-playlists');
if (!container) return;
try {
const response = fetch('/data/playlists/index.json');
const data = await (await response).json();
if (data.playlists.length === 0) {
container.innerHTML = '<p style="color: var(--text-muted);">No playlists available yet.</p>';
return;
}
const featured = data.playlists.slice(0, 4);
container.innerHTML = featured.map((p: any) => `
<a href="#/watch/replays" class="playlist-preview-card">
<h3>${escapeHtml(p.title)}</h3>
<p>${p.match_count} matches</p>
</a>
`).join('');
} catch {
container.innerHTML = '<p style="color: var(--text-muted);">Failed to load playlists.</p>';
}
}
// Compete hub page - participant hub with sandbox, register, docs
function renderCompeteHubPage(): void {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="compete-hub-page">
<h1 class="page-title">Compete</h1>
<p class="page-subtitle">Build your bot and climb the ranks</p>
<div class="getting-started">
<h2>Getting Started</h2>
<p>AI Code Battle is a competitive programming platform where you write HTTP bots that control units on a grid world.</p>
</div>
<div class="compete-grid">
<a href="#/compete/sandbox" class="compete-card primary">
<div class="card-icon">🧪</div>
<h2>Test in Sandbox</h2>
<p>Write code and run matches in-browser with no server needed</p>
</a>
<a href="#/compete/register" class="compete-card primary">
<div class="card-icon">🤖</div>
<h2>Register Your Bot</h2>
<p>Sign up your HTTP bot and start competing</p>
</a>
<a href="#/compete/docs" class="compete-card">
<div class="card-icon">📖</div>
<h2>Documentation</h2>
<p>Read the protocol spec and starter kit guides</p>
</a>
<a href="https://github.com/aicodebattle/acb" class="compete-card" target="_blank" rel="noopener">
<div class="card-icon">💻</div>
<h2>Starter Kits</h2>
<p>Example bots in Python, Go, Rust, TypeScript, and more</p>
</a>
<a href="#/leaderboard" class="compete-card">
<div class="card-icon">🏆</div>
<h2>Leaderboard</h2>
<p>See current standings and top performers</p>
</a>
<a href="#/evolution" class="compete-card">
<div class="card-icon">🧬</div>
<h2>Evolution</h2>
<p>Watch bots evolve through genetic algorithms</p>
</a>
</div>
<div class="how-it-works">
<h2>How Competition Works</h2>
<div class="steps">
<div class="step">
<span class="step-number">1</span>
<h3>Build a Bot</h3>
<p>Write an HTTP server that receives game state and returns move commands</p>
</div>
<div class="step">
<span class="step-number">2</span>
<h3>Register</h3>
<p>Submit your bot's endpoint URL and API key to start competing</p>
</div>
<div class="step">
<span class="step-number">3</span>
<h3>Climb the Ranks</h3>
<p>Your bot plays matches automatically and earns rating through Glicko-2</p>
</div>
</div>
</div>
</div>
<style>
.compete-hub-page {
max-width: 1200px;
margin: 0 auto;
}
.page-subtitle {
color: var(--text-muted);
margin-bottom: 32px;
}
.getting-started {
background-color: var(--bg-secondary);
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
}
.getting-started h2 {
color: var(--text-primary);
margin-bottom: 12px;
}
.getting-started p {
color: var(--text-muted);
}
.compete-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.compete-card {
background-color: var(--bg-secondary);
border-radius: 12px;
padding: 32px 24px;
text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
display: block;
border: 2px solid transparent;
}
.compete-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.compete-card.primary {
border-color: var(--accent);
background-color: rgba(59, 130, 246, 0.1);
}
.card-icon {
font-size: 3rem;
margin-bottom: 16px;
}
.compete-card h2 {
color: var(--text-primary);
margin-bottom: 8px;
font-size: 1.25rem;
}
.compete-card p {
color: var(--text-muted);
font-size: 0.875rem;
}
.how-it-works {
background-color: var(--bg-secondary);
border-radius: 12px;
padding: 32px;
}
.how-it-works h2 {
color: var(--text-primary);
margin-bottom: 24px;
}
.steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
}
.step {
display: flex;
flex-direction: column;
gap: 12px;
}
.step-number {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background-color: var(--accent);
color: white;
border-radius: 50%;
font-weight: 700;
font-size: 1.25rem;
}
.step h3 {
color: var(--text-primary);
}
.step p {
color: var(--text-muted);
font-size: 0.875rem;
}
</style>
`;
}
// Season detail page - standalone page for viewing a specific season
function renderSeasonDetailPage(params: Record<string, string>): void {
const seasonId = params.id;
if (!seasonId) {
router.navigate('/seasons');
return;
}
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="season-detail-page">
<div class="breadcrumb">
<a href="#/seasons">Seasons</a> / <span id="season-breadcrumb">Loading...</span>
</div>
<div id="season-content" class="loading">Loading season...</div>
</div>
<style>
.season-detail-page {
max-width: 1000px;
margin: 0 auto;
}
.breadcrumb {
color: var(--text-muted);
font-size: 0.875rem;
margin-bottom: 20px;
}
.breadcrumb a {
color: var(--accent);
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.loading {
color: var(--text-muted);
text-align: center;
padding: 40px;
}
.season-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.season-info h1 {
font-size: 2rem;
color: var(--text-primary);
margin-bottom: 8px;
}
.season-theme {
color: var(--text-muted);
font-size: 1rem;
}
.season-dates {
text-align: right;
color: var(--text-muted);
font-size: 0.875rem;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
margin-bottom: 8px;
}
.status-badge.active { background-color: #22c55e; color: white; }
.status-badge.completed { background-color: #3b82f6; color: white; }
.status-badge.upcoming { background-color: #6b7280; color: white; }
.champion-banner {
background: linear-gradient(135deg, rgba(255, 215, 0, 0.1) 0%, rgba(255, 215, 0, 0.05) 100%);
border: 1px solid rgba(255, 215, 0, 0.3);
border-radius: 12px;
padding: 24px;
text-align: center;
margin-bottom: 32px;
}
.champion-crown {
font-size: 3rem;
margin-bottom: 8px;
}
.champion-label {
color: var(--text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.champion-name {
font-size: 1.5rem;
color: gold;
font-weight: 700;
}
.section-title {
font-size: 1.25rem;
color: var(--text-primary);
margin-bottom: 16px;
}
.leaderboard-table {
width: 100%;
border-collapse: collapse;
background-color: var(--bg-secondary);
border-radius: 8px;
overflow: hidden;
margin-bottom: 32px;
}
.leaderboard-table th,
.leaderboard-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--bg-tertiary);
}
.leaderboard-table th {
background-color: var(--bg-tertiary);
color: var(--text-muted);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.leaderboard-table .rank {
font-weight: 700;
color: var(--text-muted);
}
.leaderboard-table tr.rank-1 .rank { color: #fbbf24; }
.leaderboard-table tr.rank-2 .rank { color: #94a3b8; }
.leaderboard-table tr.rank-3 .rank { color: #cd7f32; }
.season-rules {
background-color: var(--bg-tertiary);
border-radius: 8px;
padding: 20px;
}
.season-rules h4 {
color: var(--text-primary);
margin-bottom: 12px;
}
.season-rules ul {
margin-left: 20px;
color: var(--text-muted);
}
.season-rules li {
margin-bottom: 6px;
}
</style>
`;
loadSeasonDetail(seasonId);
}
async function loadSeasonDetail(seasonId: string): Promise<void> {
const breadcrumb = document.getElementById('season-breadcrumb');
const content = document.getElementById('season-content');
if (!content) return;
try {
const response = await fetch(`/data/seasons/${seasonId}.json`);
if (!response.ok) throw new Error('Season not found');
const season = await response.json();
if (breadcrumb) {
breadcrumb.textContent = season.name;
}
content.innerHTML = `
<div class="season-header">
<div class="season-info">
<h1>${escapeHtml(season.name)}</h1>
<p class="season-theme">${escapeHtml(season.theme)}</p>
</div>
<div class="season-dates">
<span class="status-badge ${season.status}">${season.status}</span>
<div>Started: ${new Date(season.starts_at).toLocaleDateString()}</div>
${season.ends_at ? `<div>Ended: ${new Date(season.ends_at).toLocaleDateString()}</div>` : ''}
</div>
</div>
${season.champion_name ? `
<div class="champion-banner">
<div class="champion-crown">👑</div>
<div class="champion-label">Champion</div>
<div class="champion-name">${escapeHtml(season.champion_name)}</div>
</div>
` : ''}
${season.final_snapshot && season.final_snapshot.length > 0 ? `
<h2 class="section-title">Final Leaderboard</h2>
<table class="leaderboard-table">
<thead>
<tr>
<th>Rank</th>
<th>Bot</th>
<th>Rating</th>
<th>Wins</th>
<th>Losses</th>
</tr>
</thead>
<tbody>
${season.final_snapshot.map((entry: any) => `
<tr class="rank-${entry.rank}">
<td class="rank">#${entry.rank}</td>
<td>${escapeHtml(entry.bot_name)}</td>
<td>${Math.round(entry.rating)}</td>
<td>${entry.wins}</td>
<td>${entry.losses}</td>
</tr>
`).join('')}
</tbody>
</table>
` : ''}
<div class="season-rules">
<h4>Rules Version: ${season.rules_version}</h4>
<ul>
<li>Standard 60×60 toroidal grid</li>
<li>500 turn limit</li>
<li>Glicko-2 rating system</li>
<li>Best-of-1 matches</li>
</ul>
</div>
`;
} catch (err) {
console.error('Failed to load season:', err);
content.innerHTML = `
<div class="error">
<p>Failed to load season: ${seasonId}</p>
<p class="hint">The season may not exist yet.</p>
<a href="#/seasons" class="btn primary">Back to Seasons</a>
</div>
`;
}
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Docs/Getting Started page
function renderDocsPage(): void {
const app = document.getElementById('app');

View file

@ -8,6 +8,12 @@ export interface WinProbPoint {
drawProb?: number;
}
export interface CriticalMomentMarker {
turn: number;
delta: number;
description: string;
}
// Render win probability sparkline to canvas
export function renderWinProbSparkline(
ctx: CanvasRenderingContext2D,
@ -18,9 +24,10 @@ export function renderWinProbSparkline(
height: number;
color0?: string;
color1?: string;
criticalMoments?: CriticalMomentMarker[];
},
): void {
const { width, height, color0 = '#3b82f6', color1 = '#ef4444' } = options;
const { width, height, color0 = '#3b82f6', color1 = '#ef4444', criticalMoments = [] } = options;
const padding = { top: 8, bottom: 8, left: 4, right: 4 };
const chartW = width - padding.left - padding.right;
const chartH = height - padding.top - padding.bottom;
@ -51,6 +58,40 @@ export function renderWinProbSparkline(
ctx.stroke();
ctx.setLineDash([]);
// Critical moment markers — dashed vertical lines with delta labels
for (const moment of criticalMoments) {
const mx = x(moment.turn);
const markerColor = moment.delta > 0 ? color0 : color1;
ctx.strokeStyle = markerColor + 'aa';
ctx.lineWidth = 1.5;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(mx, padding.top);
ctx.lineTo(mx, height - padding.bottom);
ctx.stroke();
ctx.setLineDash([]);
// Small diamond at midpoint
const my = height / 2;
const s = 3;
ctx.fillStyle = markerColor;
ctx.beginPath();
ctx.moveTo(mx, my - s);
ctx.lineTo(mx + s, my);
ctx.lineTo(mx, my + s);
ctx.lineTo(mx - s, my);
ctx.closePath();
ctx.fill();
// Delta label near top
const label = `${moment.delta > 0 ? '+' : ''}${(moment.delta * 100).toFixed(0)}%`;
ctx.fillStyle = markerColor;
ctx.font = '9px monospace';
ctx.textAlign = 'center';
ctx.fillText(label, Math.max(18, Math.min(width - 18, mx)), padding.top + 7);
}
// P0 area fill
ctx.beginPath();
ctx.moveTo(padding.left, y(0.5));
@ -606,6 +647,11 @@ export class ReplayViewer {
const events = turnData.events ?? [];
this.announceToScreenReader(this.generateTurnDescription(events));
}
// Keep sparkline current-turn marker in sync
if (this.winProbCanvas && this.winProbData) {
this.renderWinProbSparkline();
}
}
// Standard view with grid
@ -1193,40 +1239,66 @@ export class ReplayViewer {
private winProbData: WinProbPoint[] | null = null;
private winProbCanvas: HTMLCanvasElement | null = null;
private winProbCriticalMoments: CriticalMomentMarker[] = [];
// Set win probability data for sparkline rendering
setWinProbabilityData(points: WinProbPoint[]): void {
this.winProbData = points;
if (this.winProbCanvas) {
this.renderWinProbSparkline();
}
if (this.winProbCanvas) this.renderWinProbSparkline();
}
// Get the win probability data
getWinProbabilityData(): WinProbPoint[] | null {
return this.winProbData;
}
// Create and attach a win probability sparkline canvas
createWinProbSparkline(container: HTMLElement, width?: number, height = 60): HTMLCanvasElement {
this.winProbCanvas = document.createElement('canvas');
this.winProbCanvas.width = width ?? container.clientWidth;
this.winProbCanvas.height = height;
this.winProbCanvas.className = 'win-prob-sparkline-canvas';
this.winProbCanvas.style.cssText = 'width:100%;height:' + height + 'px;border-radius:6px;';
container.appendChild(this.winProbCanvas);
setCriticalMoments(moments: CriticalMomentMarker[]): void {
this.winProbCriticalMoments = moments;
if (this.winProbCanvas) this.renderWinProbSparkline();
}
if (this.winProbData) {
this.renderWinProbSparkline();
getCriticalMomentMarkers(): CriticalMomentMarker[] {
return this.winProbCriticalMoments;
}
// Create and attach a win probability sparkline canvas below the main viewer.
// Pass onTurnClick to enable click-to-scrub: clicking anywhere on the sparkline
// calls onTurnClick with the nearest turn number.
createWinProbSparkline(
container: HTMLElement,
width?: number,
height = 70,
onTurnClick?: (turn: number) => void,
): HTMLCanvasElement {
// Replace any existing canvas
if (this.winProbCanvas && this.winProbCanvas.parentElement === container) {
container.removeChild(this.winProbCanvas);
}
this.winProbCanvas = document.createElement('canvas');
this.winProbCanvas.width = width ?? Math.max(container.clientWidth, 400);
this.winProbCanvas.height = height;
this.winProbCanvas.className = 'win-prob-sparkline-canvas';
this.winProbCanvas.style.cssText = `width:100%;height:${height}px;border-radius:6px;cursor:pointer;`;
container.appendChild(this.winProbCanvas);
if (onTurnClick) {
this.winProbCanvas.addEventListener('click', (e) => {
if (!this.winProbData || this.winProbData.length < 2 || !this.winProbCanvas) return;
const rect = this.winProbCanvas.getBoundingClientRect();
const x = (e.clientX - rect.left) * (this.winProbCanvas.width / rect.width);
const padding = 4;
const chartW = this.winProbCanvas.width - padding * 2;
const maxTurn = this.winProbData[this.winProbData.length - 1].turn;
const turn = Math.round(Math.max(0, Math.min(maxTurn, (x - padding) / chartW * maxTurn)));
onTurnClick(turn);
});
}
if (this.winProbData) this.renderWinProbSparkline();
return this.winProbCanvas;
}
// Render the sparkline
private renderWinProbSparkline(): void {
if (!this.winProbCanvas || !this.winProbData || this.winProbData.length < 2) return;
const ctx = this.winProbCanvas.getContext('2d');
if (!ctx) return;
@ -1235,6 +1307,7 @@ export class ReplayViewer {
height: this.winProbCanvas.height,
color0: this.accessibility.highContrast ? '#0000ff' : '#3b82f6',
color1: this.accessibility.highContrast ? '#ff0000' : '#ef4444',
criticalMoments: this.winProbCriticalMoments,
});
}
}

View file

@ -72,6 +72,12 @@ export interface ReplayTurn {
debug?: Record<number, DebugInfo>;
}
export interface ReplayCriticalMoment {
turn: number;
delta: number; // change in p0 win probability (positive = p0 improved)
description: string;
}
export interface Replay {
format_version?: string; // semver, e.g. "1.0" — absent in pre-v1 replays
match_id: string;
@ -82,6 +88,8 @@ export interface Replay {
players: ReplayPlayer[];
map: ReplayMap;
turns: ReplayTurn[];
win_prob?: number[][]; // [[p0, p1], ...] one entry per turn
critical_moments?: ReplayCriticalMoment[];
}
// Event detail types