add page to compare player scores

This commit is contained in:
Brian Lee 2025-10-01 13:21:43 -07:00
parent a35ad405d7
commit c4c5b6b506
2 changed files with 751 additions and 1 deletions

View File

@ -5,7 +5,8 @@
<div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each [ {#each [
{ name: 'BeatLeader Compare Players', href: '/tools/beatleader-compare', desc: 'Find songs A played that B has not' }, { name: 'BeatLeader Compare Players', href: '/tools/beatleader-compare', desc: 'Find songs A played that B has not' },
{ name: 'BeatLeader Playlist Gap', href: '/tools/beatleader-playlist-gap', desc: 'Upload a playlist and find songs a player has not played' } { name: 'BeatLeader Playlist Gap', href: '/tools/beatleader-playlist-gap', desc: 'Upload a playlist and find songs a player has not played' },
{ name: 'BeatLeader Head-to-Head', href: '/tools/beatleader-headtohead', desc: 'Compare two players on the same map/difficulty' }
] as tool} ] as tool}
<a href={tool.href} class="card-surface p-5 block"> <a href={tool.href} class="card-surface p-5 block">
<div class="font-semibold">{tool.name}</div> <div class="font-semibold">{tool.name}</div>

View File

@ -0,0 +1,749 @@
<script lang="ts">
import { onMount } from 'svelte';
import SongPlayer from '$lib/components/SongPlayer.svelte';
type BeatLeaderScore = {
timeset?: string | number;
accuracy?: number;
acc?: number;
rank?: number;
leaderboard?: {
id?: string | number | null;
leaderboardId?: string | number | null;
song?: { hash?: string | null };
difficulty?: { value?: number | string | null; modeName?: string | null };
};
};
type BeatLeaderScoresResponse = { data?: BeatLeaderScore[] };
type MapMeta = {
songName?: string;
key?: string;
coverURL?: string;
mapper?: string;
};
type H2HItem = {
hash: string;
diffName: string;
modeName: string;
timeset: number; // most recent between the two
accA: number | null;
accB: number | null;
rankA?: number;
rankB?: number;
leaderboardId?: string;
};
const difficulties = ['Easy', 'Normal', 'Hard', 'Expert', 'ExpertPlus'];
const modes = ['Standard', 'Lawless', 'OneSaber', 'NoArrows', 'Lightshow'];
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
let playerA = '';
let playerB = '';
let months: number | string = 6;
// UI state
let loading = false;
let errorMsg: string | null = null;
// Results state
let items: H2HItem[] = [];
let sortDir: 'asc' | 'desc' = 'desc';
let page = 1;
let pageSize: number | string = 24;
$: pageSizeNum = Number(pageSize) || 24;
$: sorted = [...items].sort((a, b) => (sortDir === 'asc' ? a.timeset - b.timeset : b.timeset - a.timeset));
$: totalPages = Math.max(1, Math.ceil(sorted.length / pageSizeNum));
$: page = Math.min(page, totalPages);
$: pageItems = sorted.slice((page - 1) * pageSizeNum, (page - 1) * pageSizeNum + pageSizeNum);
// Meta cache
let metaByHash: Record<string, MapMeta> = {};
let loadingMeta = false;
function normalizeAccuracy(value: number | undefined): number | null {
if (value === undefined || value === null) return null;
return value <= 1 ? value * 100 : value;
}
function normalizeDifficultyName(value: number | string | null | undefined): string {
if (value === null || value === undefined) return 'ExpertPlus';
if (typeof value === 'string') {
const v = value.toLowerCase();
if (v.includes('expertplus') || v === 'expertplus' || v === 'ex+' || v.includes('ex+')) return 'ExpertPlus';
if (v.includes('expert')) return 'Expert';
if (v.includes('hard')) return 'Hard';
if (v.includes('normal')) return 'Normal';
if (v.includes('easy')) return 'Easy';
return value;
}
switch (value) {
case 1: return 'Easy';
case 3: return 'Normal';
case 5: return 'Hard';
case 7: return 'Expert';
case 9: return 'ExpertPlus';
default: return 'ExpertPlus';
}
}
function parseTimeset(ts: string | number | undefined): number {
if (ts === undefined) return 0;
if (typeof ts === 'number') return ts;
const n = Number(ts);
return Number.isFinite(n) ? n : 0;
}
function getCutoffEpochFromMonths(m: number | string): number {
const monthsNum = Number(m) || 0;
const seconds = Math.max(0, monthsNum) * 30 * 24 * 60 * 60;
return Math.floor(Date.now() / 1000) - seconds;
}
async function fetchBeatSaverMeta(inHash: string): Promise<MapMeta | null> {
try {
const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(inHash)}`);
if (!res.ok) throw new Error(String(res.status));
const data: any = await res.json();
const cover = data?.versions?.[0]?.coverURL ?? `https://cdn.beatsaver.com/${inHash.toLowerCase()}.jpg`;
return {
songName: data?.metadata?.songName ?? data?.name ?? undefined,
key: data?.id ?? undefined,
coverURL: cover,
mapper: data?.uploader?.name ?? undefined
};
} catch {
return { coverURL: `https://cdn.beatsaver.com/${inHash.toLowerCase()}.jpg` };
}
}
async function loadMetaForResults(list: H2HItem[]): Promise<void> {
const needed = Array.from(new Set(list.map((i) => i.hash))).filter((h) => !metaByHash[h]);
if (needed.length === 0) return;
loadingMeta = true;
for (const h of needed) {
const m = await fetchBeatSaverMeta(h);
if (m) metaByHash = { ...metaByHash, [h]: m };
}
loadingMeta = false;
}
async function fetchAllRecentScoresForDiff(playerId: string, cutoffEpoch: number, reqDiff: string, maxPages = 100): Promise<BeatLeaderScore[]> {
const qs = new URLSearchParams({ diff: reqDiff, cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) });
const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch scores for ${playerId}: ${res.status}`);
const data = (await res.json()) as BeatLeaderScoresResponse;
return data.data ?? [];
}
async function fetchAllRecentScoresAllDiffs(playerId: string, cutoffEpoch: number): Promise<BeatLeaderScore[]> {
const arrays = await Promise.all(
difficulties.map((d) => fetchAllRecentScoresForDiff(playerId, cutoffEpoch, d))
);
// Merge and dedupe by leaderboard key (hash|diff|mode) and timeset
const merged = new Map<string, BeatLeaderScore>();
for (const arr of arrays) {
for (const s of arr) {
const rawHash = s.leaderboard?.song?.hash ?? undefined;
const modeName = s.leaderboard?.difficulty?.modeName ?? 'Standard';
if (!rawHash) continue;
const hashLower = String(rawHash).toLowerCase();
const diffName = normalizeDifficultyName(s.leaderboard?.difficulty?.value ?? undefined);
const key = `${hashLower}|${diffName}|${modeName}`;
const prev = merged.get(key);
if (!prev || parseTimeset(prev.timeset) < parseTimeset(s.timeset)) merged.set(key, s);
}
}
return Array.from(merged.values());
}
function buildLatestByKey(scores: BeatLeaderScore[], cutoffEpoch: number): Map<string, BeatLeaderScore> {
const byKey = new Map<string, BeatLeaderScore>();
for (const s of scores) {
const t = parseTimeset(s.timeset);
if (!t || t < cutoffEpoch - ONE_YEAR_SECONDS) continue; // sanity guard
const rawHash = s.leaderboard?.song?.hash ?? undefined;
const modeName = s.leaderboard?.difficulty?.modeName ?? 'Standard';
if (!rawHash) continue;
const hashLower = String(rawHash).toLowerCase();
const diffName = normalizeDifficultyName(s.leaderboard?.difficulty?.value ?? undefined);
const key = `${hashLower}|${diffName}|${modeName}`;
const prev = byKey.get(key);
if (!prev || parseTimeset(prev.timeset) < t) byKey.set(key, s);
}
return byKey;
}
function toH2HItems(aMap: Map<string, BeatLeaderScore>, bMap: Map<string, BeatLeaderScore>): H2HItem[] {
const out: H2HItem[] = [];
for (const [key, aScore] of aMap) {
if (!bMap.has(key)) continue;
const bScore = bMap.get(key)!;
const [hashLower, diffName, modeName] = key.split('|');
const hash = (aScore.leaderboard?.song?.hash ?? bScore.leaderboard?.song?.hash ?? hashLower).toString();
const tA = parseTimeset(aScore.timeset);
const tB = parseTimeset(bScore.timeset);
const accA = normalizeAccuracy((aScore.accuracy ?? aScore.acc) as number | undefined);
const accB = normalizeAccuracy((bScore.accuracy ?? bScore.acc) as number | undefined);
const lbIdRaw = (aScore.leaderboard as any)?.id ?? (aScore.leaderboard as any)?.leaderboardId ?? (bScore.leaderboard as any)?.id ?? (bScore.leaderboard as any)?.leaderboardId;
const leaderboardId = lbIdRaw != null ? String(lbIdRaw) : undefined;
out.push({
hash,
diffName,
modeName,
timeset: Math.max(tA, tB),
accA,
accB,
rankA: aScore.rank,
rankB: bScore.rank,
leaderboardId
});
}
return out;
}
async function onCompare(ev: SubmitEvent) {
ev.preventDefault();
// Push current params into URL on user action
updateUrl(false);
errorMsg = null; items = []; metaByHash = {};
const a = playerA.trim();
const b = playerB.trim();
if (!a || !b) { errorMsg = 'Please enter both Player A and Player B IDs.'; return; }
loading = true;
try {
const cutoff = getCutoffEpochFromMonths(months);
const [aScores, bScores] = await Promise.all([
fetchAllRecentScoresAllDiffs(a, cutoff),
fetchAllRecentScoresAllDiffs(b, cutoff)
]);
const aLatest = buildLatestByKey(aScores, cutoff - ONE_YEAR_SECONDS);
const bLatest = buildLatestByKey(bScores, cutoff - ONE_YEAR_SECONDS);
const combined = toH2HItems(aLatest, bLatest);
combined.sort((x, y) => y.timeset - x.timeset);
items = combined;
page = 1;
loadMetaForResults(combined);
} catch (err) {
errorMsg = err instanceof Error ? err.message : 'Unknown error';
} finally {
loading = false;
}
}
onMount(() => {
const sp = new URLSearchParams(location.search);
playerA = sp.get('a') ?? '';
playerB = sp.get('b') ?? '';
const m = sp.get('m') ?? sp.get('months');
if (m) months = Number(m);
const p = sp.get('page');
if (p) page = Math.max(1, Number(p) || 1);
const dir = sp.get('dir');
if (dir === 'asc' || dir === 'desc') sortDir = dir;
const size = sp.get('size') ?? sp.get('ps');
if (size) pageSize = Number(size) || pageSize;
initialized = true;
});
// Short labels for players
$: playerAId = playerA.trim();
$: playerBId = playerB.trim();
$: idShortA = playerAId ? playerAId.slice(0, 6) : 'A';
$: idShortB = playerBId ? playerBId.slice(0, 6) : 'B';
// URL param sync
let initialized = false;
let urlSyncInProgress = false;
function updateUrl(replace = true) {
if (!initialized || urlSyncInProgress) return;
try {
urlSyncInProgress = true;
const sp = new URLSearchParams(location.search);
if (playerAId) sp.set('a', playerAId); else sp.delete('a');
if (playerBId) sp.set('b', playerBId); else sp.delete('b');
sp.delete('diff');
sp.delete('mode');
const monthsVal = Number(months) || 0;
if (monthsVal) sp.set('months', String(monthsVal)); else sp.delete('months');
if (page > 1) sp.set('page', String(page)); else sp.delete('page');
if (sortDir !== 'desc') sp.set('dir', sortDir); else sp.delete('dir');
if (pageSizeNum !== 24) sp.set('size', String(pageSizeNum)); else sp.delete('size');
const qs = sp.toString();
const url = location.pathname + (qs ? `?${qs}` : '');
if (replace) history.replaceState(null, '', url); else history.pushState(null, '', url);
} finally {
urlSyncInProgress = false;
}
}
// Sync URL on state changes
$: updateUrl(true);
// ===== Derived Stats for Visualizations =====
function mean(values: number[]): number {
if (!values.length) return 0;
return values.reduce((a, b) => a + b, 0) / values.length;
}
function median(values: number[]): number {
if (!values.length) return 0;
const v = [...values].sort((a, b) => a - b);
const mid = Math.floor(v.length / 2);
return v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2;
}
function percentile(values: number[], p: number): number {
if (!values.length) return 0;
const v = [...values].sort((a, b) => a - b);
const idx = Math.min(v.length - 1, Math.max(0, Math.floor((p / 100) * (v.length - 1))));
return v[idx];
}
$: comparable = items.filter((i) => i.accA != null && i.accB != null) as Array<{
hash: string; diffName: string; modeName: string; timeset: number; accA: number; accB: number; rankA?: number; rankB?: number; leaderboardId?: string;
}>;
$: totalComparable = comparable.length;
$: winsA = comparable.filter((i) => i.accA > i.accB).length;
$: winsB = comparable.filter((i) => i.accB > i.accA).length;
$: ties = comparable.filter((i) => i.accA === i.accB).length;
$: accAList = comparable.map((i) => i.accA);
$: accBList = comparable.map((i) => i.accB);
$: avgAccA = mean(accAList);
$: avgAccB = mean(accBList);
$: medAccA = median(accAList);
$: medAccB = median(accBList);
$: deltas = comparable.map((i) => i.accA - i.accB);
$: absMargins = deltas.map((x) => Math.abs(x));
$: avgMargin = mean(absMargins);
$: p95Margin = percentile(absMargins, 95);
$: chronological = [...comparable].sort((a, b) => a.timeset - b.timeset);
$: longestA = (() => {
let cur = 0, best = 0;
for (const i of chronological) {
if (i.accA > i.accB) { cur += 1; best = Math.max(best, cur); } else if (i.accA === i.accB) { cur = 0; } else { cur = 0; }
}
return best;
})();
$: longestB = (() => {
let cur = 0, best = 0;
for (const i of chronological) {
if (i.accB > i.accA) { cur += 1; best = Math.max(best, cur); } else if (i.accA === i.accB) { cur = 0; } else { cur = 0; }
}
return best;
})();
// Win share (bar + donut)
$: shareA = totalComparable ? winsA / totalComparable : 0;
$: shareT = totalComparable ? ties / totalComparable : 0;
$: shareB = totalComparable ? winsB / totalComparable : 0;
// Donut chart calculations
const donutR = 44;
$: donutCircumference = 2 * Math.PI * donutR;
$: donutALen = donutCircumference * shareA;
$: donutTLen = donutCircumference * shareT;
$: donutBLen = donutCircumference * shareB;
// Histogram of signed margins (A - B)
$: maxAbsDelta = Math.max(0, ...deltas.map((x) => Math.abs(x)));
$: histBins = 14;
$: histRangeMin = -maxAbsDelta;
$: histRangeMax = maxAbsDelta;
$: histBinWidth = histBins ? (histRangeMax - histRangeMin) / histBins : 1;
$: histogram = (() => {
const bins = Array.from({ length: histBins }, () => 0);
for (const x of deltas) {
if (!Number.isFinite(x)) continue;
let idx = Math.floor((x - histRangeMin) / histBinWidth);
if (idx < 0) idx = 0; if (idx >= histBins) idx = histBins - 1;
bins[idx] += 1;
}
return bins;
})();
$: histMaxCount = Math.max(1, ...histogram);
// Cumulative wins over time sparkline
$: cumSeries = (() => {
const pts: { t: number; a: number; b: number }[] = [];
let a = 0, b = 0;
for (const i of chronological) {
if (i.accA > i.accB) a += 1; else if (i.accB > i.accA) b += 1;
pts.push({ t: i.timeset, a, b });
}
return pts;
})();
$: tMin = cumSeries.length ? cumSeries[0].t : 0;
$: tMax = cumSeries.length ? cumSeries[cumSeries.length - 1].t : 1;
function mapX(t: number, w: number): number {
if (tMax === tMin) return 0;
return ((t - tMin) / (tMax - tMin)) * w;
}
function mapY(v: number, h: number, vMax?: number): number {
const maxV = vMax ?? Math.max(1, cumSeries.length ? Math.max(cumSeries[cumSeries.length - 1].a, cumSeries[cumSeries.length - 1].b) : 1);
return h - (v / maxV) * h;
}
</script>
<section class="py-8">
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: Head-to-Head</h1>
<p class="mt-2 text-muted">Paginated head-to-head results on the same map and difficulty.</p>
<form class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4 items-end" on:submit|preventDefault={onCompare}>
<div>
<label class="block text-sm text-muted">Player A ID
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={playerA} placeholder="7656119... or BL ID" required />
</label>
</div>
<div>
<label class="block text-sm text-muted">Player B ID
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={playerB} placeholder="7656119... or BL ID" required />
</label>
</div>
<div>
<label class="block text-sm text-muted">Lookback (months)
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" type="number" min="0" max="120" bind:value={months} />
</label>
</div>
<div>
<button class="btn-neon" disabled={loading}>
{#if loading}Loading...{:else}Compare{/if}
</button>
</div>
</form>
{#if errorMsg}
<div class="mt-4 text-danger">{errorMsg}</div>
{/if}
{#if items.length > 0}
<!-- KPI Tiles -->
<div class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="kpi-tile">
<div class="kpi-label">Shared Maps</div>
<div class="kpi-value">{totalComparable}</div>
</div>
<div class="kpi-tile a">
<div class="kpi-label">Wins {idShortA}</div>
<div class="kpi-value">{winsA}</div>
</div>
<div class="kpi-tile b">
<div class="kpi-label">Wins {idShortB}</div>
<div class="kpi-value">{winsB}</div>
</div>
<div class="kpi-tile">
<div class="kpi-label">Ties</div>
<div class="kpi-value">{ties}</div>
</div>
</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="kpi-tile a">
<div class="kpi-sublabel">Avg Acc {idShortA}</div>
<div class="kpi-subvalue">{avgAccA.toFixed(2)}%</div>
</div>
<div class="kpi-tile b">
<div class="kpi-sublabel">Avg Acc {idShortB}</div>
<div class="kpi-subvalue">{avgAccB.toFixed(2)}%</div>
</div>
<div class="kpi-tile a">
<div class="kpi-sublabel">Median {idShortA}</div>
<div class="kpi-subvalue">{medAccA.toFixed(2)}%</div>
</div>
<div class="kpi-tile b">
<div class="kpi-sublabel">Median {idShortB}</div>
<div class="kpi-subvalue">{medAccB.toFixed(2)}%</div>
</div>
</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="kpi-tile">
<div class="kpi-sublabel">Avg Win Margin</div>
<div class="kpi-subvalue">{avgMargin.toFixed(2)}%</div>
</div>
<div class="kpi-tile">
<div class="kpi-sublabel">95th %ile Margin</div>
<div class="kpi-subvalue">{p95Margin.toFixed(2)}%</div>
</div>
<div class="kpi-tile a">
<div class="kpi-sublabel">Longest Streak {idShortA}</div>
<div class="kpi-subvalue">{longestA}</div>
</div>
<div class="kpi-tile b">
<div class="kpi-sublabel">Longest Streak {idShortB}</div>
<div class="kpi-subvalue">{longestB}</div>
</div>
</div>
<!-- Win Share: Bar + Donut -->
<div class="mt-6 grid gap-4 lg:grid-cols-2">
<div class="card-surface p-4">
<div class="text-sm text-muted mb-2">Win Share</div>
<div class="winshare-bar">
<div class="seg a" style={`width: ${(shareA * 100).toFixed(2)}%`}></div>
<div class="seg t" style={`width: ${(shareT * 100).toFixed(2)}%`}></div>
<div class="seg b" style={`width: ${(shareB * 100).toFixed(2)}%`}></div>
</div>
<div class="mt-2 text-xs text-muted flex gap-3">
<span class="badge a"></span> {idShortA} {(shareA * 100).toFixed(1)}%
<span class="badge t"></span> Ties {(shareT * 100).toFixed(1)}%
<span class="badge b"></span> {idShortB} {(shareB * 100).toFixed(1)}%
</div>
</div>
<div class="card-surface p-4 flex items-center justify-center">
<svg viewBox="0 0 120 120" width="160" height="160">
<!-- Donut background -->
<circle cx="60" cy="60" r="44" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="16" />
{#if totalComparable > 0}
<!-- A -->
<circle cx="60" cy="60" r="44" fill="none" stroke="rgba(0,255,204,0.9)" stroke-width="16" stroke-dasharray={`${donutALen} ${donutCircumference - donutALen}`} stroke-dashoffset={0} />
<!-- Ties -->
<circle cx="60" cy="60" r="44" fill="none" stroke="rgba(200,200,200,0.7)" stroke-width="16" stroke-dasharray={`${donutTLen} ${donutCircumference - donutTLen}`} stroke-dashoffset={-donutALen} />
<!-- B -->
<circle cx="60" cy="60" r="44" fill="none" stroke="rgba(255,0,170,0.9)" stroke-width="16" stroke-dasharray={`${donutBLen} ${donutCircumference - donutBLen}`} stroke-dashoffset={-(donutALen + donutTLen)} />
{/if}
<text x="60" y="64" text-anchor="middle" font-size="14" fill="#9ca3af">{totalComparable} maps</text>
</svg>
</div>
</div>
<!-- Win Margin Histogram -->
<div class="mt-6 card-surface p-4">
<div class="text-sm text-muted mb-2">Win Margin Histogram (A B, %)</div>
<div class="hist">
{#each histogram as count, i}
<div
class="bar"
title={`${(histRangeMin + (i + 0.5) * histBinWidth).toFixed(2)}%`}
style={`left:${(i / histogram.length) * 100}%; width:${(1 / histogram.length) * 100}%; height:${((count / histMaxCount) * 100).toFixed(1)}%; background:${(histRangeMin + (i + 0.5) * histBinWidth) >= 0 ? 'rgba(0,255,204,0.8)' : 'rgba(255,0,170,0.8)'};`}
></div>
{/each}
<div class="zero-line"></div>
</div>
<div class="mt-2 text-xs text-muted">Left (magenta) favors {idShortB}, Right (cyan) favors {idShortA}</div>
</div>
<!-- Cumulative Wins Over Time -->
<div class="mt-6 card-surface p-4">
<div class="text-sm text-muted mb-2">Cumulative Wins Over Time</div>
<svg viewBox="0 0 600 140" class="spark">
<!-- Axes bg -->
<rect x="0" y="0" width="600" height="140" fill="transparent" />
{#if cumSeries.length > 1}
{#key cumSeries.length}
<!-- A line -->
<polyline fill="none" stroke="rgba(0,255,204,0.9)" stroke-width="2" points={cumSeries.map(p => `${mapX(p.t, 600)},${mapY(p.a, 120)}`).join(' ')} />
<!-- B line -->
<polyline fill="none" stroke="rgba(255,0,170,0.9)" stroke-width="2" points={cumSeries.map(p => `${mapX(p.t, 600)},${mapY(p.b, 120)}`).join(' ')} />
{/key}
{/if}
</svg>
</div>
{/if}
{#if items.length > 0}
<div class="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-3 text-sm text-muted">
<span>{items.length} songs</span>
<span>·</span>
<label class="flex items-center gap-2">Dir
<select class="rounded-md border border-white/10 bg-transparent px-2 py-1 text-sm" bind:value={sortDir}>
<option value="desc">Desc</option>
<option value="asc">Asc</option>
</select>
</label>
<label class="flex items-center gap-2">Page size
<select class="rounded-md border border-white/10 bg-transparent px-2 py-1 text-sm" bind:value={pageSize}>
<option value={12}>12</option>
<option value={24}>24</option>
<option value={36}>36</option>
<option value={48}>48</option>
</select>
</label>
</div>
</div>
{#if loadingMeta}
<div class="mt-2 text-xs text-muted">Loading covers…</div>
{/if}
<div class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each pageItems as item}
<article class="card-surface overflow-hidden">
<div class="aspect-square bg-black/30">
{#if metaByHash[item.hash]?.coverURL}
<img src={metaByHash[item.hash].coverURL} alt={metaByHash[item.hash]?.songName ?? item.hash} loading="lazy" class="h-full w-full object-cover" />
{:else}
<div class="h-full w-full flex items-center justify-center text-xs text-muted">No cover</div>
{/if}
</div>
<div class="p-3">
<div class="font-semibold truncate" title={metaByHash[item.hash]?.songName ?? item.hash}>{metaByHash[item.hash]?.songName ?? item.hash}</div>
{#if metaByHash[item.hash]?.mapper}
<div class="mt-0.5 text-xs text-muted truncate">{metaByHash[item.hash]?.mapper}</div>
{/if}
<div class="mt-2 flex items-center justify-between text-[11px]">
<span class="rounded bg-white/10 px-2 py-0.5">
{item.modeName} · <span class="rounded px-1 ml-1" style="background-color: var(--neon-diff)">{item.diffName}</span>
</span>
<span class="text-muted">{new Date(item.timeset * 1000).toLocaleDateString()}</span>
</div>
<div class="mt-3 grid grid-cols-2 gap-3 neon-surface">
<div class="player-card playerA {item.accA != null && item.accB != null && item.accA > item.accB ? 'winner' : ''}">
<div class="label">{idShortA}</div>
<div class="value {item.accA != null && item.accB != null && (item.accA < item.accB || item.accA === item.accB) ? 'small' : ''}">{item.accA != null ? item.accA.toFixed(2) + '%' : '—'}</div>
<div class="sub">{item.rankA ? `Rank #${item.rankA}` : ''}</div>
</div>
<div class="player-card playerB {item.accA != null && item.accB != null && item.accB > item.accA ? 'winner' : ''}">
<div class="label">{idShortB}</div>
<div class="value {item.accA != null && item.accB != null && (item.accB < item.accA || item.accA === item.accB) ? 'small' : ''}">{item.accB != null ? item.accB.toFixed(2) + '%' : '—'}</div>
<div class="sub">{item.rankB ? `Rank #${item.rankB}` : ''}</div>
</div>
</div>
<div class="mt-2 text-center text-sm">
{#if item.accA != null && item.accB != null}
{#if item.accA === item.accB}
<span class="chip chip-draw">Tie</span>
{:else if item.accA > item.accB}
<span class="chip chip-win-a">Winner: {idShortA}</span>
<span class="ml-2 text-muted">by {(item.accA - item.accB).toFixed(2)}%</span>
{:else}
<span class="chip chip-win-b">Winner: {idShortB}</span>
<span class="ml-2 text-muted">by {(item.accB - item.accA).toFixed(2)}%</span>
{/if}
{:else}
<span class="chip">Incomplete</span>
{/if}
</div>
<div class="mt-3">
<SongPlayer hash={item.hash} preferBeatLeader={true} />
</div>
<div class="mt-3 flex flex-wrap gap-2">
<a class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20" href={item.leaderboardId
? `https://beatleader.com/leaderboard/global/${item.leaderboardId}`
: `https://beatleader.com/leaderboard/global/${item.hash}?diff=${encodeURIComponent(item.diffName)}&mode=${encodeURIComponent(item.modeName)}`}
target="_blank" rel="noopener" title="Open in BeatLeader">BL</a>
</div>
</div>
</article>
{/each}
</div>
{#if totalPages > 1}
<div class="mt-6 flex items-center justify-center gap-2">
<button class="rounded-md border border-white/10 px-3 py-1 text-sm disabled:opacity-50" on:click={() => (page = Math.max(1, page - 1))} disabled={page === 1}>Prev</button>
<span class="text-sm text-muted">Page {page} / {totalPages}</span>
<button class="rounded-md border border-white/10 px-3 py-1 text-sm disabled:opacity-50" on:click={() => (page = Math.min(totalPages, page + 1))} disabled={page === totalPages}>Next</button>
</div>
{/if}
{/if}
</section>
<style>
.text-danger { color: #dc2626; }
/* Cyberpunk neon aesthetics */
.neon-frame {
background: linear-gradient(135deg, rgba(0,0,0,0.45), rgba(15,15,25,0.65));
border: 1px solid rgba(255,255,255,0.08);
box-shadow: 0 0 0 1px rgba(0, 255, 204, 0.08) inset, 0 8px 24px rgba(0, 0, 0, 0.5);
}
.neon-surface {
padding: 16px;
border-radius: 12px;
background: radial-gradient(1200px 400px at -10% -10%, rgba(0, 255, 204, 0.08), transparent),
radial-gradient(1200px 400px at 110% 110%, rgba(255, 0, 170, 0.08), transparent),
rgba(12,12,18,0.6);
border: 1px solid rgba(255,255,255,0.06);
box-shadow: 0 0 20px rgba(0, 255, 204, 0.08), 0 0 30px rgba(255, 0, 170, 0.06) inset;
}
.player-card {
padding: 16px;
border-radius: 10px;
background: linear-gradient(180deg, rgba(0,0,0,0.4), rgba(10,10,18,0.6));
border: 1px solid rgba(255,255,255,0.06);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.player-card.playerA { border-color: rgba(0, 255, 204, 0.25); box-shadow: 0 0 12px rgba(0, 255, 204, 0.08) inset; }
.player-card.playerB { border-color: rgba(255, 0, 170, 0.25); box-shadow: 0 0 12px rgba(255, 0, 170, 0.08) inset; }
.player-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(0,0,0,0.35);
border-color: rgba(0, 255, 204, 0.25);
}
.player-card .label { color: #9ca3af; font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; }
.player-card .value { font-size: 42px; line-height: 1; font-weight: 800; letter-spacing: 0.02em; margin-top: 4px; }
.player-card .value.small { font-size: 26px; opacity: 0.8; }
.player-card .value, .player-card .value.small { white-space: nowrap; }
.player-card .sub { margin-top: 6px; font-size: 12px; color: #9ca3af; }
.player-card.winner {
box-shadow: 0 0 0 1px rgba(0, 255, 204, 0.2) inset, 0 0 18px rgba(0, 255, 204, 0.14);
border-color: rgba(0, 255, 204, 0.35);
}
.chip {
display: inline-block;
border-radius: 9999px;
border: 1px solid rgba(255,255,255,0.12);
padding: 4px 10px;
font-size: 12px;
background: rgba(255,255,255,0.04);
}
.chip-win {
border-color: rgba(0, 255, 204, 0.5);
background: linear-gradient(90deg, rgba(0, 255, 204, 0.14), rgba(0, 255, 170, 0.08));
}
.chip-win-a {
border-color: rgba(0, 255, 204, 0.5);
background: linear-gradient(90deg, rgba(0, 255, 204, 0.18), rgba(0, 255, 170, 0.10));
}
.chip-win-b {
border-color: rgba(255, 0, 170, 0.5);
background: linear-gradient(90deg, rgba(255, 0, 170, 0.18), rgba(255, 102, 204, 0.10));
}
.chip-draw {
border-color: rgba(255, 255, 0, 0.5);
background: linear-gradient(90deg, rgba(255, 255, 0, 0.14), rgba(255, 215, 0, 0.08));
}
/* KPI tiles */
.kpi-tile {
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
padding: 12px 14px;
background: linear-gradient(180deg, rgba(0,0,0,0.35), rgba(15,15,25,0.6));
}
.kpi-tile.a { box-shadow: 0 0 0 1px rgba(0,255,204,0.14) inset; }
.kpi-tile.b { box-shadow: 0 0 0 1px rgba(255,0,170,0.14) inset; }
.kpi-label { font-size: 12px; color: #9ca3af; letter-spacing: 0.06em; text-transform: uppercase; }
.kpi-value { font-size: 28px; font-weight: 800; margin-top: 2px; }
.kpi-sublabel { font-size: 11px; color: #9ca3af; }
.kpi-subvalue { font-size: 20px; font-weight: 700; margin-top: 2px; }
/* Win share bar */
.winshare-bar {
height: 16px;
border-radius: 9999px;
overflow: hidden;
display: flex;
background: rgba(255,255,255,0.08);
}
.winshare-bar .seg { height: 100%; }
.winshare-bar .seg.a { background: rgba(0,255,204,0.9); }
.winshare-bar .seg.t { background: rgba(200,200,200,0.7); }
.winshare-bar .seg.b { background: rgba(255,0,170,0.9); }
.badge { display: inline-block; width: 10px; height: 10px; border-radius: 9999px; margin-right: 6px; }
.badge.a { background: rgba(0,255,204,0.9); }
.badge.t { background: rgba(200,200,200,0.7); }
.badge.b { background: rgba(255,0,170,0.9); }
/* Histogram */
.hist { position: relative; height: 120px; width: 100%; background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; }
.hist .bar { position: absolute; bottom: 0; }
.hist .zero-line { position: absolute; left: 50%; top: 0; bottom: 0; width: 1px; background: rgba(255,255,255,0.15); }
/* Sparkline */
.spark { width: 100%; height: 140px; }
</style>