From 9850020c5316ea22bc77057c21a50099f4c4ad9e Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 21 Apr 2026 08:36:42 -0400 Subject: [PATCH] feat(web): win probability sparkline + critical moment navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/app.ts | 913 ++++++++++++++++++++++++++++++++++++++- web/src/replay-viewer.ts | 109 ++++- web/src/types.ts | 8 + 3 files changed, 993 insertions(+), 37 deletions(-) diff --git a/web/src/app.ts b/web/src/app.ts index c0f945a..1f5e015 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -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 = { + '/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) => void { + return (params: Record) => { + 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): void {
Load a replay file to view
+
@@ -379,6 +471,76 @@ function renderReplayPage(params: Record): 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 = ` + + + + `; + + // Load featured playlists + loadFeaturedPlaylists(); +} + +async function loadFeaturedPlaylists(): Promise { + 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 = '

No playlists available yet.

'; + return; + } + + const featured = data.playlists.slice(0, 4); + container.innerHTML = featured.map((p: any) => ` + +

${escapeHtml(p.title)}

+

${p.match_count} matches

+
+ `).join(''); + } catch { + container.innerHTML = '

Failed to load playlists.

'; + } +} + +// Compete hub page - participant hub with sandbox, register, docs +function renderCompeteHubPage(): void { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+

Compete

+

Build your bot and climb the ranks

+ +
+

Getting Started

+

AI Code Battle is a competitive programming platform where you write HTTP bots that control units on a grid world.

+
+ + + +
+

How Competition Works

+
+
+ 1 +

Build a Bot

+

Write an HTTP server that receives game state and returns move commands

+
+
+ 2 +

Register

+

Submit your bot's endpoint URL and API key to start competing

+
+
+ 3 +

Climb the Ranks

+

Your bot plays matches automatically and earns rating through Glicko-2

+
+
+
+
+ + + `; +} + +// Season detail page - standalone page for viewing a specific season +function renderSeasonDetailPage(params: Record): void { + const seasonId = params.id; + if (!seasonId) { + router.navigate('/seasons'); + return; + } + + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+ + +
Loading season...
+
+ + + `; + + loadSeasonDetail(seasonId); +} + +async function loadSeasonDetail(seasonId: string): Promise { + 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 = ` +
+
+

${escapeHtml(season.name)}

+

${escapeHtml(season.theme)}

+
+
+ ${season.status} +
Started: ${new Date(season.starts_at).toLocaleDateString()}
+ ${season.ends_at ? `
Ended: ${new Date(season.ends_at).toLocaleDateString()}
` : ''} +
+
+ + ${season.champion_name ? ` +
+
๐Ÿ‘‘
+
Champion
+
${escapeHtml(season.champion_name)}
+
+ ` : ''} + + ${season.final_snapshot && season.final_snapshot.length > 0 ? ` +

Final Leaderboard

+ + + + + + + + + + + + ${season.final_snapshot.map((entry: any) => ` + + + + + + + + `).join('')} + +
RankBotRatingWinsLosses
#${entry.rank}${escapeHtml(entry.bot_name)}${Math.round(entry.rating)}${entry.wins}${entry.losses}
+ ` : ''} + +
+

Rules Version: ${season.rules_version}

+
    +
  • Standard 60ร—60 toroidal grid
  • +
  • 500 turn limit
  • +
  • Glicko-2 rating system
  • +
  • Best-of-1 matches
  • +
+
+ `; + } catch (err) { + console.error('Failed to load season:', err); + content.innerHTML = ` +
+

Failed to load season: ${seasonId}

+

The season may not exist yet.

+ Back to Seasons +
+ `; + } +} + +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'); diff --git a/web/src/replay-viewer.ts b/web/src/replay-viewer.ts index 8540cda..6f11235 100644 --- a/web/src/replay-viewer.ts +++ b/web/src/replay-viewer.ts @@ -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, }); } } diff --git a/web/src/types.ts b/web/src/types.ts index a9c7b82..41a3dfc 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -72,6 +72,12 @@ export interface ReplayTurn { debug?: Record; } +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