diff --git a/web/src/components/playlist-carousel.ts b/web/src/components/playlist-carousel.ts index c9ad263..5a7bae7 100644 --- a/web/src/components/playlist-carousel.ts +++ b/web/src/components/playlist-carousel.ts @@ -370,8 +370,8 @@ export class PlaylistCarousel { private async fetchReplay(matchId: string): Promise { const urls = [ - `${R2_BASE}/replays/${matchId}.json`, - `${B2_FALLBACK}/replays/${matchId}.json`, + `${R2_BASE}/replays/${matchId}.json.gz`, + `${B2_FALLBACK}/replays/${matchId}.json.gz`, ]; for (const url of urls) { try { @@ -379,7 +379,7 @@ export class PlaylistCarousel { if (resp.ok) return await resp.json(); } catch { /* try next */ } } - const resp = await fetch(`/replays/${matchId}.json`); + const resp = await fetch(`/replays/${matchId}.json.gz`); if (!resp.ok) throw new Error(`Failed to fetch replay ${matchId}`); return resp.json(); } @@ -431,7 +431,7 @@ export class PlaylistCarousel { btn.addEventListener('click', () => { const id = (btn as HTMLElement).dataset.matchId!; this.destroy(); - window.location.hash = `/watch/replay?url=/replays/${id}.json`; + window.location.hash = `/watch/replay?url=/replays/${id}.json.gz`; }); } } diff --git a/web/src/lib/virtual-list.ts b/web/src/lib/virtual-list.ts index b0e16ec..771e1bc 100644 --- a/web/src/lib/virtual-list.ts +++ b/web/src/lib/virtual-list.ts @@ -1,6 +1,8 @@ // §16.15 Virtual list — renders only visible rows for large datasets. -// Keeps the DOM small even with 1000+ entries. Each row has a fixed height +// Keeps the DOM small even with 1000+ entries. Each row has a base height // and only rows in the viewport (plus a buffer) are materialised. +// Expanded rows are tracked with their actual measured height so spacer +// calculations remain correct. export interface VirtualListOptions { items: T[]; @@ -21,6 +23,7 @@ interface VirtualListState { visibleStart: number; visibleEnd: number; expandedIndex: number | null; + expandedHeight: number; showMoreCount: number; } @@ -44,6 +47,7 @@ export class VirtualList { visibleStart: 0, visibleEnd: Math.min(initial, opts.items.length), expandedIndex: null, + expandedHeight: 0, showMoreCount: initial, }; } @@ -119,13 +123,50 @@ export class VirtualList { }); } - private computeWindow(): void { + /** Compute the height of a row at the given index, accounting for expansion. */ + private rowHeightAt(idx: number): number { + if (this.state.expandedIndex === idx && this.state.expandedHeight > 0) { + return this.opts.rowHeight + this.state.expandedHeight; + } + return this.opts.rowHeight; + } + + /** Compute the total height of rows from index 0 up to (but not including) `end`. */ + private totalHeight(end: number): number { const { rowHeight } = this.opts; + if (this.state.expandedIndex === null || this.state.expandedHeight === 0) { + // Fast path: no expanded row, all same height + return end * rowHeight; + } + let h = 0; + for (let i = 0; i < end; i++) { + h += this.rowHeightAt(i); + } + return h; + } + + /** Find the row index that contains the given scroll offset. */ + private indexAtOffset(offset: number): number { + const { rowHeight, items } = this.opts; + const maxIdx = Math.min(this.state.showMoreCount, items.length); + if (this.state.expandedIndex === null || this.state.expandedHeight === 0) { + // Fast path: uniform row heights + return Math.floor(offset / rowHeight); + } + let acc = 0; + for (let i = 0; i < maxIdx; i++) { + acc += this.rowHeightAt(i); + if (acc > offset) return i; + } + return maxIdx; + } + + private computeWindow(): void { const buffer = this.opts.buffer ?? 5; const maxIdx = this.state.showMoreCount; - const rawStart = Math.floor(this.state.scrollTop / rowHeight) - buffer; - const rawEnd = Math.ceil((this.state.scrollTop + this.state.viewportHeight) / rowHeight) + buffer; + const rawStart = this.indexAtOffset(this.state.scrollTop) - buffer; + const rawEnd = this.indexAtOffset(this.state.scrollTop + this.state.viewportHeight) + buffer; this.state.visibleStart = Math.max(0, rawStart); this.state.visibleEnd = Math.min(maxIdx, rawEnd); @@ -133,7 +174,7 @@ export class VirtualList { private render(): void { if (!this.rowContainer || !this.spacerAbove || !this.spacerBelow) return; - const { items, rowHeight, renderRow, renderExpanded } = this.opts; + const { items, renderRow, renderExpanded } = this.opts; const { visibleStart, visibleEnd, expandedIndex, showMoreCount } = this.state; const frags: string[] = []; @@ -150,15 +191,16 @@ export class VirtualList { } this.rowContainer.innerHTML = frags.join(''); - this.spacerAbove.style.height = `${visibleStart * rowHeight}px`; - const belowStart = visibleEnd; - const belowCount = showMoreCount - belowStart; - this.spacerBelow.style.height = `${Math.max(0, belowCount) * rowHeight}px`; - // Set total scrollable height hint - if (this.scrollEl) { - this.scrollEl.style.setProperty('--vl-total-height', `${showMoreCount * rowHeight}px`); + // Spacer above = total height of all rows before visibleStart + this.spacerAbove.style.height = `${this.totalHeight(visibleStart)}px`; + + // Spacer below = total height of all rows after visibleEnd up to showMoreCount + let belowHeight = 0; + for (let i = visibleEnd; i < showMoreCount; i++) { + belowHeight += this.rowHeightAt(i); } + this.spacerBelow.style.height = `${Math.max(0, belowHeight)}px`; // Wire expand toggles this.rowContainer.querySelectorAll('.virtual-list-row').forEach(row => { @@ -168,10 +210,38 @@ export class VirtualList { this.toggleExpand(idx); }); }); + + // Measure expanded row height after DOM update + if (expandedIndex !== null) { + const expandedRow = this.rowContainer.querySelector(`[data-idx="${expandedIndex}"]`); + const expandedEl = expandedRow?.querySelector('.virtual-list-expanded'); + if (expandedEl) { + const measured = expandedEl.offsetHeight; + if (measured !== this.state.expandedHeight) { + this.state.expandedHeight = measured; + // Recalculate spacers with the new height + this.spacerAbove.style.height = `${this.totalHeight(visibleStart)}px`; + let belowHeight2 = 0; + for (let i = visibleEnd; i < showMoreCount; i++) { + belowHeight2 += this.rowHeightAt(i); + } + this.spacerBelow.style.height = `${Math.max(0, belowHeight2)}px`; + } + } + } } private toggleExpand(idx: number): void { - this.state.expandedIndex = this.state.expandedIndex === idx ? null : idx; + const prevExpanded = this.state.expandedIndex; + if (prevExpanded === idx) { + // Collapse current + this.state.expandedIndex = null; + this.state.expandedHeight = 0; + } else { + // Expand new (height measured after render) + this.state.expandedIndex = idx; + this.state.expandedHeight = 0; + } this.render(); } diff --git a/web/src/pages/feedback.ts b/web/src/pages/feedback.ts index b9ee901..6d36d9f 100644 --- a/web/src/pages/feedback.ts +++ b/web/src/pages/feedback.ts @@ -401,8 +401,7 @@ function initFeedback(): void { // ─── Utilities ──────────────────────────────────────────────────────────────── function replayUrlForMatch(m: MatchSummary): string { - // Replays are stored in R2 at /replays/{match_id}.json - return `/replays/${m.id}.json`; + return `/replays/${m.id}.json.gz`; } function formatDate(s: string | null): string { diff --git a/web/src/pages/home.ts b/web/src/pages/home.ts index 990e795..ab6f125 100644 --- a/web/src/pages/home.ts +++ b/web/src/pages/home.ts @@ -147,7 +147,7 @@ export async function renderHomePage(): Promise { ? `${featuredReplay!.participants.map((p) => `${esc(p.name)}`).join(' vs ')}${featuredReplay!.winner_id ? ` — Winner: ${esc(featuredReplay!.participants.find((p) => p.bot_id === featuredReplay!.winner_id)?.name || 'Unknown')}` : ''}` : 'Demo Replay — Watch a sample battle'; const replayLink = hasLiveReplay - ? `#/watch/replay?url=/replays/${featuredReplay!.id}.json` + ? `#/watch/replay?url=/replays/${featuredReplay!.id}.json.gz` : '#/watch/replays'; // Build lazy-loaded content for below-the-fold sections diff --git a/web/src/pages/leaderboard.ts b/web/src/pages/leaderboard.ts index 1c5588a..7fa08f4 100644 --- a/web/src/pages/leaderboard.ts +++ b/web/src/pages/leaderboard.ts @@ -239,19 +239,20 @@ function renderMobileCard(entry: LeaderboardEntry): string { } function initMobileCardToggles(container: HTMLElement): void { - container.querySelectorAll('.leaderboard-mobile-card').forEach(card => { - const toggle = card.querySelector('.mobile-card-toggle'); + // Use event delegation so toggles work even when cards are inside lazy sections + container.addEventListener('click', (e) => { + const toggle = (e.target as HTMLElement).closest('.mobile-card-toggle'); if (!toggle) return; - toggle.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)); - const arrow = card.querySelector('.mobile-card-arrow'); - if (arrow) arrow.textContent = expanded ? '▾' : '▸'; - toggle.setAttribute('aria-expanded', String(expanded)); - }); + if ((e.target as HTMLElement).closest('a')) return; + const card = toggle.closest('.leaderboard-mobile-card'); + if (!card) return; + const details = card.querySelector('.leaderboard-mobile-details'); + if (!details) return; + const expanded = details.classList.toggle('expanded'); + card.setAttribute('aria-expanded', String(expanded)); + const arrow = card.querySelector('.mobile-card-arrow'); + if (arrow) arrow.textContent = expanded ? '▾' : '▸'; + toggle.setAttribute('aria-expanded', String(expanded)); }); } @@ -278,8 +279,6 @@ function addMobileShowMore( container.appendChild(temp.firstChild); } - initMobileCardToggles(container); - const newCount = end; if (newCount >= allEntries.length) { btn.remove(); diff --git a/web/src/pages/replay.ts b/web/src/pages/replay.ts index 0ef226d..2068852 100644 --- a/web/src/pages/replay.ts +++ b/web/src/pages/replay.ts @@ -61,6 +61,7 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
Load a replay file to view
+ @@ -1230,6 +1231,30 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { playBtn.textContent = playing ? 'Pause' : 'Play'; mobilePlayBtn.textContent = playing ? '⏸' : '▶'; }; + // Follow camera indicator (§16.12) + const followIndicator = document.getElementById('follow-indicator') as HTMLDivElement; + viewer.onFollowChange = (player: number | null) => { + const replay = viewer.getReplay() as Replay | null; + if (player !== null && replay && player < replay.players.length) { + const name = replay.players[player].name; + followIndicator.innerHTML = `Following: ${name} [×]`; + followIndicator.style.display = 'block'; + // Auto-pair fog of war with followed player + fogSelect.value = String(player); + viewer.setFogOfWar(player); + } else { + followIndicator.style.display = 'none'; + followIndicator.innerHTML = ''; + // Clear auto-paired fog of war + fogSelect.value = ''; + viewer.setFogOfWar(null); + } + }; + followIndicator.addEventListener('click', () => { + // Click the × or the whole bar to exit follow + viewer.setFollowPlayer(null); + }); + viewer.onCommentaryChange = (entry: { turn: number; text: string; type: string } | null) => { if (!entry || !commentaryEnabled) { commentaryText.textContent = ''; diff --git a/web/src/styles/components.css b/web/src/styles/components.css index 0b457a7..7a06a42 100644 --- a/web/src/styles/components.css +++ b/web/src/styles/components.css @@ -940,6 +940,10 @@ code { .mobile-card-arrow { transition: none; } + + .leaderboard-mobile-details { + transition: none; + } } /* ─── Mobile responsive for progressive disclosure ──────────────────────────── */ diff --git a/web/src/styles/mobile.css b/web/src/styles/mobile.css index de19431..e8fea04 100644 --- a/web/src/styles/mobile.css +++ b/web/src/styles/mobile.css @@ -141,14 +141,18 @@ } .leaderboard-mobile-details { - display: none; - padding-top: var(--space-sm); - margin-top: var(--space-sm); - border-top: 1px solid var(--border); + max-height: 0; + overflow: hidden; + transition: max-height 250ms ease-out, padding 250ms ease-out; + padding: 0; + border-top: none; } .leaderboard-mobile-details.expanded { - display: block; + max-height: 300px; + padding-top: var(--space-sm); + margin-top: var(--space-sm); + border-top: 1px solid var(--border); } .leaderboard-mobile-stat {