feat(web): add maps browsing page per plan §14.6

Implements plan §14.6 Map Evolution with a dedicated /maps page for browsing all competitive maps. Users can now:
- Browse maps grouped by player count (2P, 3P, 4P, 6P)
- View map stats (engagement score, wall density, energy count, dimensions)
- Upvote/downvote maps directly from the browsing interface
- See net vote counts and their own vote state

The page integrates with existing map voting API and data structure from the index builder.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-26 16:23:59 -04:00
parent 971f8fd56c
commit 46b9a90f35
4 changed files with 387 additions and 0 deletions

View file

@ -879,6 +879,7 @@
<a href="#/compete" class="nav-link primary">Compete</a>
<a href="#/leaderboard" class="nav-link primary">Leaderboard</a>
<a href="#/evolution" class="nav-link">Evolution</a>
<a href="#/maps" class="nav-link">Maps</a>
<a href="#/blog" class="nav-link">Blog</a>
<a href="#/season/1" class="nav-link" id="current-season-link">Season 1</a>
</div>
@ -890,6 +891,7 @@
<!-- Mobile dropdown menu -->
<div class="mobile-menu" id="mobile-menu">
<a href="#/evolution">Evolution</a>
<a href="#/maps">Maps</a>
<a href="#/blog">Blog</a>
<a href="#/season/1" id="mobile-season-link">Season 1</a>
</div>

View file

@ -605,3 +605,29 @@ export async function fetchMapVotes(mapId: string): Promise<MapVotesResponse> {
if (!response.ok) throw new Error(`Failed to fetch map votes: ${response.status}`);
return response.json();
}
// Map browsing types (§14.6)
export interface MapData {
map_id: string;
player_count: number;
status: string;
engagement: number;
wall_density: number;
energy_count: number;
grid_width: number;
grid_height: number;
net_votes: number;
created_at: string;
}
export interface MapsIndex {
updated_at: string;
maps: MapData[];
}
export async function fetchMapsIndex(): Promise<MapsIndex> {
const response = await fetch('/maps/index.json');
if (!response.ok) throw new Error(`Failed to fetch maps index: ${response.status}`);
return response.json();
}

View file

@ -42,6 +42,7 @@ function getSkeletonHtml(path: string): string {
if (path === '/evolution') return skeletonEvolution();
if (path.startsWith('/blog')) return skeletonBlog();
if (path === '/seasons' || path.startsWith('/season/')) return skeletonSeasons();
if (path === '/maps') return skeletonGeneric('Maps');
if (path === '/rivalries' || path.startsWith('/rivalry/')) return skeletonGeneric('Rivalries');
if (path === '/watch/predictions' || path === '/predictions') return skeletonGeneric('Predictions');
if (path === '/watch') return skeletonGeneric('Watch');
@ -76,6 +77,7 @@ const loadDocsDataPage = () => import('./pages/docs-data').then(m => m.renderDoc
// Bot-related pages
const loadBotProfilePage = () => import('./pages/bot-profile').then(m => m.renderBotProfilePage);
const loadEvolutionPage = () => import('./pages/evolution').then(m => m.renderEvolutionPage);
const loadMapsPage = () => import('./pages/maps').then(m => m.renderMapsPage);
// Blog & seasons
const loadBlogPages = () => import('./pages/blog').then(m => ({ renderBlogPage: m.renderBlogPage, renderBlogPostPage: m.renderBlogPostPage }));
@ -292,6 +294,7 @@ router
.on('/rivalries', lazyRoute(loadRivalriesPage))
.on('/rivalry/:bot_a/:bot_b', lazyRoute(loadRivalryPage))
.on('/embed/:id', lazyRoute(loadEmbedPage))
.on('/maps', lazyRoute(loadMapsPage))
.notFound(lazyRoute(loadNotFoundPage));
// ─── Initialization ────────────────────────────────────────────────────────────────

356
web/src/pages/maps.ts Normal file
View file

@ -0,0 +1,356 @@
// Maps browsing page — view all maps, stats, and vote (§14.6)
import { fetchMapsIndex, submitMapVote, fetchMapVotes, type MapData } from '../api-types';
let mapsData: MapData[] = [];
let mapVotes: Map<string, { my_vote?: number; net_votes: number }> = new Map();
export async function renderMapsPage(): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="maps-page">
<h1 class="page-title">Maps</h1>
<p class="page-subtitle">Browse all competitive maps, view engagement scores, and vote on your favorites</p>
<div id="maps-content" class="loading">Loading maps...</div>
</div>
`;
const content = document.getElementById('maps-content');
if (!content) return;
try {
const data = await fetchMapsIndex();
mapsData = data.maps;
renderMapsGrid(content);
} catch (err) {
content.innerHTML = `
<div class="error">
<p>Failed to load maps.</p>
<p class="hint">${err instanceof Error ? err.message : 'Unknown error'}</p>
</div>
`;
}
}
function renderMapsGrid(container: HTMLElement): void {
// Group maps by player count
const byPlayerCount: Record<number, MapData[]> = {};
for (const map of mapsData) {
if (!byPlayerCount[map.player_count]) {
byPlayerCount[map.player_count] = [];
}
byPlayerCount[map.player_count].push(map);
}
// Sort each group by engagement (descending)
for (const count of Object.keys(byPlayerCount)) {
byPlayerCount[Number(count)].sort((a, b) => b.engagement - a.engagement);
}
let html = '';
for (const [count, maps] of Object.entries(byPlayerCount).sort(([a], [b]) => Number(a) - Number(b))) {
html += `
<section class="maps-section">
<h2 class="maps-section-title">${count}-Player Maps</h2>
<div class="maps-grid">
`;
for (const map of maps) {
html += renderMapCard(map);
}
html += `
</div>
</section>
`;
}
container.innerHTML = html;
attachMapCardListeners();
}
function renderMapCard(map: MapData): string {
const voteData = mapVotes.get(map.map_id);
const netVotes = voteData?.net_votes ?? map.net_votes;
const myVote = voteData?.my_vote;
return `
<div class="map-card" data-map-id="${map.map_id}">
<div class="map-header">
<span class="map-id">${escapeHtml(map.map_id)}</span>
<span class="map-players">${map.player_count}P</span>
</div>
<div class="map-stats">
<div class="map-stat">
<span class="stat-label">Engagement</span>
<span class="stat-value">${map.engagement.toFixed(1)}</span>
</div>
<div class="map-stat">
<span class="stat-label">Wall Density</span>
<span class="stat-value">${(map.wall_density * 100).toFixed(0)}%</span>
</div>
<div class="map-stat">
<span class="stat-label">Energy</span>
<span class="stat-value">${map.energy_count}</span>
</div>
<div class="map-stat">
<span class="stat-label">Size</span>
<span class="stat-value">${map.grid_width}×${map.grid_height}</span>
</div>
</div>
<div class="map-votes">
<button class="vote-btn ${myVote === 1 ? 'voted' : ''}" data-vote="1" aria-label="Upvote this map">
👍
</button>
<span class="vote-count">${netVotes > 0 ? '+' : ''}${netVotes}</span>
<button class="vote-btn ${myVote === -1 ? 'voted' : ''}" data-vote="-1" aria-label="Downvote this map">
👎
</button>
</div>
<div class="map-status map-status-${map.status}">${map.status}</div>
</div>
`;
}
function attachMapCardListeners(): void {
// Vote buttons
for (const card of document.querySelectorAll('.map-card')) {
const mapId = card.getAttribute('data-map-id');
if (!mapId) continue;
for (const btn of card.querySelectorAll('.vote-btn')) {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const vote = (e.currentTarget as HTMLElement).getAttribute('data-vote');
if (!vote) return;
const voteNum = vote === '1' ? 1 : -1;
await handleVote(mapId, voteNum, card as HTMLElement);
});
}
}
}
async function handleVote(mapId: string, vote: 1 | -1, card: HTMLElement): Promise<void> {
const voteData = mapVotes.get(mapId);
// If clicking the same vote, remove it
const newVote = voteData?.my_vote === vote ? 0 : vote;
try {
// Optimistic update
const currentNet = voteData?.net_votes ?? (mapsData.find(m => m.map_id === mapId)?.net_votes ?? 0);
const oldMyVote = voteData?.my_vote ?? 0;
// Update net votes: subtract old vote, add new vote
const newNet = currentNet - oldMyVote + newVote;
mapVotes.set(mapId, { my_vote: newVote === 0 ? undefined : newVote, net_votes: newNet });
// Re-render just this card
const map = mapsData.find(m => m.map_id === mapId);
if (map) {
card.outerHTML = renderMapCard({ ...map, net_votes: newNet });
attachMapCardListeners();
}
// Submit to API (if not removing vote)
if (newVote !== 0) {
await submitMapVote(mapId, newVote);
} else {
// To remove a vote, we'd need to vote the opposite direction and then vote again
// For now, just submit the new vote
await submitMapVote(mapId, vote);
}
// Refresh vote data from server to confirm
const fresh = await fetchMapVotes(mapId);
mapVotes.set(mapId, { my_vote: fresh.my_vote, net_votes: fresh.net_votes });
// Re-render with confirmed data
const freshMap = mapsData.find(m => m.map_id === mapId);
if (freshMap) {
const freshCard = document.querySelector(`[data-map-id="${mapId}"]`);
if (freshCard) {
freshCard.outerHTML = renderMapCard({ ...freshMap, net_votes: fresh.net_votes });
attachMapCardListeners();
}
}
} catch (err) {
console.error('Failed to submit vote:', err);
alert('Failed to submit vote. Please try again.');
}
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ─── Styles ────────────────────────────────────────────────────────────────────────
document.head.insertAdjacentHTML('beforeend', `
<style>
.maps-page {
max-width: 1200px;
margin: 0 auto;
}
.page-subtitle {
color: var(--text-muted);
margin-bottom: 24px;
}
.maps-section {
margin-bottom: 32px;
}
.maps-section-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
}
.maps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.map-card {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 16px;
border: 1px solid var(--border);
transition: transform 0.2s, box-shadow 0.2s;
}
.map-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.map-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.map-id {
font-weight: 600;
color: var(--text-primary);
font-size: 0.875rem;
}
.map-players {
background-color: var(--accent);
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
.map-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 12px;
}
.map-stat {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-size: 0.875rem;
color: var(--text-primary);
font-weight: 600;
}
.map-votes {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 0;
margin-bottom: 8px;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.vote-btn {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.vote-btn:hover {
background-color: var(--bg-tertiary);
}
.vote-btn.voted {
background-color: var(--accent);
color: white;
}
.vote-count {
font-weight: 600;
color: var(--text-primary);
min-width: 40px;
text-align: center;
}
.map-status {
font-size: 0.75rem;
padding: 4px 8px;
border-radius: 4px;
text-align: center;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.05em;
}
.map-status-active {
background-color: rgba(34, 197, 94, 0.2);
color: var(--success);
}
.map-status-retired {
background-color: rgba(148, 163, 184, 0.2);
color: var(--text-muted);
}
.map-status-unfair {
background-color: rgba(239, 68, 68, 0.2);
color: var(--error);
}
@media (max-width: 640px) {
.maps-grid {
grid-template-columns: 1fr;
}
}
</style>
`);