diff --git a/web/app.html b/web/app.html index e1614aa..e34e038 100644 --- a/web/app.html +++ b/web/app.html @@ -206,7 +206,7 @@ } /* Responsive Navigation */ - @media (max-width: 768px) { + @media (max-width: 639px) { .nav-container { height: 50px; } diff --git a/web/src/pages/home.ts b/web/src/pages/home.ts index 0ff443b..e7d7af5 100644 --- a/web/src/pages/home.ts +++ b/web/src/pages/home.ts @@ -511,11 +511,13 @@ export async function renderHomePage(): Promise { padding: 16px 0; } -/* Responsive */ -@media (max-width: 768px) { +/* Responsive — phone (<640px) */ +@media (max-width: 639px) { .home-grid { grid-template-columns: 1fr; } .home-hero h1 { font-size: 1.75rem; } .home-tagline { font-size: 1rem; } + .home-hero { padding: 20px 16px; } + .home-ctas { flex-wrap: wrap; } .home-season { flex-direction: column; gap: 10px; @@ -535,6 +537,7 @@ export async function renderHomePage(): Promise { gap: 8px; align-items: flex-start; } + .home-pl-card { width: 140px; } } `; } diff --git a/web/src/pages/leaderboard.ts b/web/src/pages/leaderboard.ts index 2c24730..159be88 100644 --- a/web/src/pages/leaderboard.ts +++ b/web/src/pages/leaderboard.ts @@ -18,7 +18,7 @@ export async function renderLeaderboardPage(): Promise { try { const data = await fetchLeaderboard(); - renderLeaderboardTable(content, data.entries, data.updated_at); + renderLeaderboard(content, data.entries, data.updated_at); } catch (error) { content.innerHTML = ` @@ -29,7 +29,7 @@ export async function renderLeaderboardPage(): Promise { } } -function renderLeaderboardTable( +function renderLeaderboard( container: HTMLElement, entries: LeaderboardEntry[], updatedAt: string @@ -47,22 +47,29 @@ function renderLeaderboardTable( container.innerHTML = ` Last updated: ${formatTimestamp(updatedAt)} - - - - Rank - Bot - Rating - W/L - Win Rate - Status - - - - ${entries.map(entry => renderLeaderboardRow(entry)).join('')} - - + + + + + Rank + Bot + Rating + W/L + Win Rate + Status + + + + ${entries.map(entry => renderLeaderboardRow(entry)).join('')} + + + + + ${entries.map(entry => renderMobileCard(entry)).join('')} + `; + + initMobileCardToggles(container); } function renderLeaderboardRow(entry: LeaderboardEntry): string { @@ -87,6 +94,54 @@ function renderLeaderboardRow(entry: LeaderboardEntry): string { `; } +function renderMobileCard(entry: LeaderboardEntry): string { + const rankClass = entry.rank <= 3 ? `rank-${entry.rank}` : ''; + const statusClass = entry.health_status === 'healthy' ? 'status-healthy' : + entry.health_status === 'unhealthy' ? 'status-unhealthy' : 'status-unknown'; + const winRate = entry.win_rate.toFixed(1); + + return ` + + ${entry.rank} + + ${escapeHtml(entry.name)} + ${entry.rating} ±${entry.rating_deviation} + + — + + + W / L + ${entry.matches_won} / ${entry.matches_played} + + + Win Rate + ${winRate}% + + + Status + ${entry.health_status} + + Full Stats → + + + `; +} + +function initMobileCardToggles(container: HTMLElement): void { + container.querySelectorAll('.leaderboard-mobile-card').forEach(card => { + card.addEventListener('click', (e) => { + if ((e.target as HTMLElement).closest('a')) return; + const details = card.querySelector('.leaderboard-mobile-details'); + if (!details) return; + const expanded = details.classList.toggle('expanded'); + card.setAttribute('aria-expanded', String(expanded)); + }); + }); +} + function formatTimestamp(iso: string): string { try { return new Date(iso).toLocaleString(); diff --git a/web/src/pages/replay.ts b/web/src/pages/replay.ts index 0095629..0a05a59 100644 --- a/web/src/pages/replay.ts +++ b/web/src/pages/replay.ts @@ -33,9 +33,29 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string): - + Load a replay file to view + + + + + ▌▌ + ◀ + ▶ + ▶▶ + T: 0/0 + 100ms + + + + + + + Load a replay + + Win Probability @@ -162,6 +182,10 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string): + + 👁 + = []; let commentaryEnabled = true; let debugPanelExpanded = false; + // Mobile speed cycling + const SPEED_STEPS = [1000, 500, 200, 100, 50, 20]; + let mobileSpeedIdx = 3; // default 100ms + + // View mode cycling + const VIEW_MODES: Array<'standard' | 'dots' | 'voronoi' | 'influence'> = ['standard', 'dots', 'voronoi', 'influence']; + const VIEW_MODE_ICONS: Record = { standard: '\u{1F5FA}', dots: '··', voronoi: '⬡', influence: '◎' }; + + // Pinch-to-zoom pointer state + const activePointers = new Map(); + let pinchStartDist = 0; + let pinchStartCellSize = 10; + function enableControls(): void { playBtn.disabled = false; prevBtn.disabled = false; @@ -346,6 +394,63 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { noReplayDiv.style.display = 'none'; } + function enableMobileControls(): void { + mobilePlayBtn.disabled = false; + mobilePrevBtn.disabled = false; + mobileNextBtn.disabled = false; + mobileResetBtn.disabled = false; + mobileTurnSlider.disabled = false; + } + + function updateMobileUI(): void { + const turn = viewer.getTurn(); + const total = viewer.getTotalTurns(); + mobileTurnInfo.textContent = `T: ${turn}/${total - 1}`; + mobileTurnSlider.value = String(turn); + mobilePlayBtn.textContent = viewer.getIsPlaying() ? '⏸' : '▶'; + } + + function buildMobileTimeline(replay: Replay): void { + const eventTurns: number[] = []; + replay.turns.forEach((t: any, i: number) => { + if (t.events && t.events.length > 0) eventTurns.push(i); + }); + + if (eventTurns.length === 0) { + mobileTimeline.innerHTML = 'No events'; + return; + } + + const currentTurn = viewer.getTurn(); + mobileTimeline.innerHTML = eventTurns.map(turn => { + const active = turn === currentTurn ? ' active' : ''; + return `${turn}`; + }).join(''); + + mobileTimeline.querySelectorAll('.mobile-event-dot').forEach(dot => { + dot.addEventListener('click', () => { + const t = parseInt(dot.dataset.turn!, 10); + viewer.setTurn(t); + updateUI(); + updateEventLog(); + updateMobileUI(); + updateMobileTimeline(); + }); + }); + } + + function updateMobileTimeline(): void { + const currentTurn = viewer.getTurn(); + mobileTimeline.querySelectorAll('.mobile-event-dot').forEach(dot => { + const t = parseInt(dot.dataset.turn!, 10); + dot.classList.toggle('active', t === currentTurn); + }); + const activeDot = mobileTimeline.querySelector('.mobile-event-dot.active'); + if (activeDot) { + activeDot.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); + } + } + function updateUI(): void { turnDisplay.textContent = String(viewer.getTurn()); totalTurnsSpan.textContent = String(viewer.getTotalTurns()); @@ -393,10 +498,14 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { function loadReplay(replay: Replay): void { viewer.loadReplay(replay); enableControls(); + enableMobileControls(); updateMatchInfo(replay); turnSlider.max = String(viewer.getTotalTurns() - 1); + mobileTurnSlider.max = String(viewer.getTotalTurns() - 1); updateUI(); updateEventLog(); + updateMobileUI(); + buildMobileTimeline(replay); initWinProb(replay); loadCommentary(replay.match_id); initDebugPanel(replay); @@ -684,11 +793,16 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { updateUI(); updateEventLog(); if (criticalMoments.length > 0) updateCriticalMomentNav(); + updateMobileUI(); + updateMobileTimeline(); }; viewer.onDebugChange = (debug: Record | null) => { updateDebugDisplay(debug); }; - viewer.onPlayStateChange = (playing: boolean) => { playBtn.textContent = playing ? 'Pause' : 'Play'; }; + viewer.onPlayStateChange = (playing: boolean) => { + playBtn.textContent = playing ? 'Pause' : 'Play'; + mobilePlayBtn.textContent = playing ? '⏸' : '▶'; + }; viewer.onCommentaryChange = (entry: { turn: number; text: string; type: string } | null) => { if (!entry || !commentaryEnabled) { commentaryText.textContent = ''; @@ -698,6 +812,118 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { commentaryText.className = `commentary-text type-${entry.type}`; }; + // ── Mobile controls ───────────────────────────────────────────────────────── + mobilePlayBtn.addEventListener('click', () => viewer.togglePlay()); + mobilePrevBtn.addEventListener('click', () => { + viewer.setTurn(viewer.getTurn() - 1); + updateUI(); updateEventLog(); updateMobileUI(); updateMobileTimeline(); + }); + mobileNextBtn.addEventListener('click', () => { + viewer.setTurn(viewer.getTurn() + 1); + updateUI(); updateEventLog(); updateMobileUI(); updateMobileTimeline(); + }); + mobileResetBtn.addEventListener('click', () => { + viewer.pause(); viewer.setTurn(0); + updateUI(); updateEventLog(); updateMobileUI(); updateMobileTimeline(); + }); + mobileTurnSlider.addEventListener('input', () => { + viewer.setTurn(parseInt(mobileTurnSlider.value, 10)); + updateUI(); updateEventLog(); updateMobileUI(); updateMobileTimeline(); + }); + mobileSpeedBtn.addEventListener('click', () => { + mobileSpeedIdx = (mobileSpeedIdx + 1) % SPEED_STEPS.length; + const speed = SPEED_STEPS[mobileSpeedIdx]; + viewer.setSpeed(speed); + speedDisplay.textContent = String(speed); + speedSlider.value = String(speed); + mobileSpeedBtn.textContent = `${speed}ms`; + }); + + // Floating view mode toggle + mobileViewModeBtn.addEventListener('click', () => { + const current = viewer.getViewMode(); + const idx = VIEW_MODES.indexOf(current as any); + const next = VIEW_MODES[(idx + 1) % VIEW_MODES.length]; + viewer.setViewMode(next); + mobileViewModeBtn.textContent = VIEW_MODE_ICONS[next] ?? '👁'; + }); + + // ── Canvas touch gestures ──────────────────────────────────────────────────── + // Tap = play/pause; horizontal swipe = prev/next turn; two-finger pinch = zoom + + let tapStartX = 0; + let tapStartY = 0; + let tapStartTime = 0; + + canvas.addEventListener('pointerdown', (e: PointerEvent) => { + activePointers.set(e.pointerId, e); + canvas.setPointerCapture(e.pointerId); + + if (activePointers.size === 1) { + tapStartX = e.clientX; + tapStartY = e.clientY; + tapStartTime = Date.now(); + } else if (activePointers.size === 2) { + const pts = [...activePointers.values()]; + const dx = pts[0].clientX - pts[1].clientX; + const dy = pts[0].clientY - pts[1].clientY; + pinchStartDist = Math.sqrt(dx * dx + dy * dy); + pinchStartCellSize = viewer.getCellSize(); + } + }); + + canvas.addEventListener('pointermove', (e: PointerEvent) => { + activePointers.set(e.pointerId, e); + + if (activePointers.size === 2) { + const pts = [...activePointers.values()]; + const dx = pts[0].clientX - pts[1].clientX; + const dy = pts[0].clientY - pts[1].clientY; + const dist = Math.sqrt(dx * dx + dy * dy); + if (pinchStartDist > 0) { + const newSize = Math.round(pinchStartCellSize * (dist / pinchStartDist)); + viewer.setCellSize(newSize); + } + } + }); + + canvas.addEventListener('pointerup', (e: PointerEvent) => { + const wasOne = activePointers.size === 1; + const endX = e.clientX; + const endY = e.clientY; + activePointers.delete(e.pointerId); + + if (wasOne) { + const dx = endX - tapStartX; + const dy = endY - tapStartY; + const elapsed = Date.now() - tapStartTime; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (elapsed < 300 && dist < 12) { + // Tap: play/pause + if (viewer.getReplay()) viewer.togglePlay(); + } else if (elapsed < 500 && Math.abs(dx) > 40 && Math.abs(dy) < 50) { + // Horizontal swipe: scrub turn + if (!viewer.getReplay()) return; + if (dx < 0) { + viewer.setTurn(viewer.getTurn() + 1); + } else { + viewer.setTurn(viewer.getTurn() - 1); + } + updateUI(); updateEventLog(); updateMobileUI(); updateMobileTimeline(); + } + } + + if (activePointers.size < 2) { + pinchStartDist = 0; + } + }); + + canvas.addEventListener('pointercancel', (e: PointerEvent) => { + activePointers.delete(e.pointerId); + if (activePointers.size < 2) pinchStartDist = 0; + }); + // Commentary toggle commentaryToggle.addEventListener('click', () => { commentaryEnabled = !commentaryEnabled; diff --git a/web/src/replay-viewer.ts b/web/src/replay-viewer.ts index 9313cac..56e431b 100644 --- a/web/src/replay-viewer.ts +++ b/web/src/replay-viewer.ts @@ -576,6 +576,18 @@ export class ReplayViewer { return this.viewMode; } + setCellSize(size: number): void { + this.cellSize = Math.max(4, Math.min(20, Math.round(size))); + if (this.replay) { + this.resizeCanvas(); + this.render(); + } + } + + getCellSize(): number { + return this.cellSize; + } + setShowDebug(show: boolean): void { this.showDebug = show; this.render(); diff --git a/web/src/styles/mobile.css b/web/src/styles/mobile.css index e06b820..de19431 100644 --- a/web/src/styles/mobile.css +++ b/web/src/styles/mobile.css @@ -167,6 +167,33 @@ font-weight: 500; } + /* Win probability sparkline — full width on mobile */ + .win-prob-section { + margin-top: 8px; + } + + .win-prob-header { + flex-direction: column; + align-items: flex-start; + gap: 6px; + } + + .critical-moment-nav { + font-size: 0.75rem; + } + + /* Commentary — scrollable on mobile if text is long */ + .commentary-bar { + flex-wrap: wrap; + gap: 6px; + } + + .commentary-content { + max-height: 60px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + } + /* Replay viewer mobile */ .replay-page .page-title { font-size: 1.25rem; @@ -188,8 +215,13 @@ } .canvas-wrapper { - padding: var(--space-xs); + padding: 0 !important; border-radius: var(--radius-md); + /* Square viewport so canvas fills the full phone width */ + width: 100%; + aspect-ratio: 1; + max-height: none !important; + overflow: hidden; } /* Mobile replay controls - compact bar below canvas */
Last updated: ${formatTimestamp(updatedAt)}