feat(web): progressive disclosure — lazy-load, expand details, windowed lists per §16.15
Virtual list tracks expanded row heights for correct spacer calculations. Leaderboard mobile cards use event delegation so toggles work inside lazy sections. Mobile card details animate with max-height instead of display toggle. Reduced-motion rules cover all expand/collapse patterns. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c618f0b7a1
commit
04927a76b0
8 changed files with 140 additions and 39 deletions
|
|
@ -370,8 +370,8 @@ export class PlaylistCarousel {
|
|||
|
||||
private async fetchReplay(matchId: string): Promise<Replay> {
|
||||
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`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T> {
|
||||
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<T> {
|
|||
visibleStart: 0,
|
||||
visibleEnd: Math.min(initial, opts.items.length),
|
||||
expandedIndex: null,
|
||||
expandedHeight: 0,
|
||||
showMoreCount: initial,
|
||||
};
|
||||
}
|
||||
|
|
@ -119,13 +123,50 @@ export class VirtualList<T> {
|
|||
});
|
||||
}
|
||||
|
||||
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<T> {
|
|||
|
||||
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<T> {
|
|||
}
|
||||
|
||||
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<HTMLElement>('.virtual-list-row').forEach(row => {
|
||||
|
|
@ -168,10 +210,38 @@ export class VirtualList<T> {
|
|||
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<HTMLElement>('.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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ export async function renderHomePage(): Promise<void> {
|
|||
? `${featuredReplay!.participants.map((p) => `<strong>${esc(p.name)}</strong>`).join(' vs ')}${featuredReplay!.winner_id ? ` — Winner: <strong>${esc(featuredReplay!.participants.find((p) => p.bot_id === featuredReplay!.winner_id)?.name || 'Unknown')}</strong>` : ''}`
|
||||
: '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
|
||||
|
|
|
|||
|
|
@ -239,19 +239,20 @@ function renderMobileCard(entry: LeaderboardEntry): string {
|
|||
}
|
||||
|
||||
function initMobileCardToggles(container: HTMLElement): void {
|
||||
container.querySelectorAll<HTMLElement>('.leaderboard-mobile-card').forEach(card => {
|
||||
const toggle = card.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.mobile-card-toggle');
|
||||
if (!toggle) return;
|
||||
toggle.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement).closest('a')) return;
|
||||
const details = card.querySelector<HTMLElement>('.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<HTMLElement>('.leaderboard-mobile-card');
|
||||
if (!card) return;
|
||||
const details = card.querySelector<HTMLElement>('.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();
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
|
|||
<canvas id="replay-canvas" style="touch-action:none"></canvas>
|
||||
<div id="no-replay" class="no-replay-message">Load a replay file to view</div>
|
||||
<button id="theater-btn" class="theater-btn" aria-label="Toggle theater mode" title="Theater mode (F)" style="position:absolute;top:8px;right:8px">⛶</button>
|
||||
<div id="follow-indicator" class="follow-indicator" style="display:none;position:absolute;top:8px;left:8px;background:rgba(0,0,0,0.75);color:#e5e7eb;font:12px monospace;padding:4px 10px;border-radius:4px;pointer-events:auto;z-index:10;cursor:pointer" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile compact controls bar — CSS hides on tablet+ -->
|
||||
|
|
@ -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} <span style="color:#94a3b8;margin-left:6px;cursor:pointer" title="Exit follow mode (Esc)">[×]</span>`;
|
||||
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 = '';
|
||||
|
|
|
|||
|
|
@ -940,6 +940,10 @@ code {
|
|||
.mobile-card-arrow {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaderboard-mobile-details {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Mobile responsive for progressive disclosure ──────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue