704 lines
29 KiB
Svelte
704 lines
29 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import MapCard from '$lib/components/MapCard.svelte';
|
||
import PlayerCompareForm from '$lib/components/PlayerCompareForm.svelte';
|
||
import HasToolAccess from '$lib/components/HasToolAccess.svelte';
|
||
import {
|
||
type MapMeta,
|
||
type StarInfo,
|
||
type BeatLeaderScore,
|
||
type BeatLeaderScoresResponse,
|
||
loadMetaForHashes,
|
||
loadStarsForHashes,
|
||
normalizeDifficultyName,
|
||
parseTimeset,
|
||
getCutoffEpochFromMonths,
|
||
normalizeAccuracy,
|
||
buildLatestByKey,
|
||
fetchAllRecentScoresForDiff,
|
||
fetchAllRecentScoresAllDiffs,
|
||
mean,
|
||
median,
|
||
percentile,
|
||
DIFFICULTIES,
|
||
MODES,
|
||
ONE_YEAR_SECONDS,
|
||
TOOL_REQUIREMENTS,
|
||
type BeatLeaderPlayerProfile
|
||
} from '$lib/utils/plebsaber-utils';
|
||
|
||
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;
|
||
};
|
||
|
||
export let data: { player: BeatLeaderPlayerProfile | null; adminRank: number | null; adminPlayer: BeatLeaderPlayerProfile | null };
|
||
|
||
const requirement = TOOL_REQUIREMENTS['player-headtohead'];
|
||
|
||
$: playerProfile = data?.player ?? null;
|
||
$: adminRank = data?.adminRank ?? null;
|
||
$: adminPlayer = data?.adminPlayer ?? null;
|
||
|
||
let playerA = '';
|
||
let playerB = '';
|
||
const months = 24;
|
||
|
||
// 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;
|
||
let filterWinMargin: string = 'all'; // 'all', 'a1', 'a2', 'b1', 'b2'
|
||
$: pageSizeNum = Number(pageSize) || 24;
|
||
$: filtered = (() => {
|
||
if (filterWinMargin === 'all') return items;
|
||
if (filterWinMargin === 'a1') {
|
||
return items.filter(i => {
|
||
if (i.accA == null || i.accB == null) return false;
|
||
return i.accA > i.accB && (i.accA - i.accB) > 1;
|
||
});
|
||
}
|
||
if (filterWinMargin === 'a2') {
|
||
return items.filter(i => {
|
||
if (i.accA == null || i.accB == null) return false;
|
||
return i.accA > i.accB && (i.accA - i.accB) > 2;
|
||
});
|
||
}
|
||
if (filterWinMargin === 'b1') {
|
||
return items.filter(i => {
|
||
if (i.accA == null || i.accB == null) return false;
|
||
return i.accB > i.accA && (i.accB - i.accA) > 1;
|
||
});
|
||
}
|
||
if (filterWinMargin === 'b2') {
|
||
return items.filter(i => {
|
||
if (i.accA == null || i.accB == null) return false;
|
||
return i.accB > i.accA && (i.accB - i.accA) > 2;
|
||
});
|
||
}
|
||
return items;
|
||
})();
|
||
$: sorted = [...filtered].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);
|
||
|
||
// Metadata caches
|
||
let metaByHash: Record<string, MapMeta> = {};
|
||
let starsByKey: Record<string, StarInfo> = {};
|
||
let loadingMeta = false;
|
||
let loadingStars = false;
|
||
|
||
// Lazy load metadata when pageItems changes
|
||
$: if (pageItems.length > 0) {
|
||
loadPageMetadata(pageItems);
|
||
}
|
||
|
||
async function loadPageMetadata(list: H2HItem[]): Promise<void> {
|
||
const hashes = list.map(i => i.hash);
|
||
|
||
// Load BeatSaver metadata
|
||
loadingMeta = true;
|
||
metaByHash = await loadMetaForHashes(hashes, metaByHash);
|
||
loadingMeta = false;
|
||
|
||
// Load star ratings
|
||
loadingStars = true;
|
||
starsByKey = await loadStarsForHashes(hashes, starsByKey, normalizeDifficultyName);
|
||
loadingStars = false;
|
||
}
|
||
|
||
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 rawHash = (aScore.leaderboard?.song?.hash ?? bScore.leaderboard?.song?.hash ?? hashLower);
|
||
const hash = String(rawHash).toLowerCase();
|
||
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() {
|
||
// 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, normalizeDifficultyName, parseTimeset),
|
||
fetchAllRecentScoresAllDiffs(b, cutoff, normalizeDifficultyName, parseTimeset)
|
||
]);
|
||
const aLatest = buildLatestByKey(aScores, cutoff - ONE_YEAR_SECONDS, normalizeDifficultyName, parseTimeset);
|
||
const bLatest = buildLatestByKey(bScores, cutoff - ONE_YEAR_SECONDS, normalizeDifficultyName, parseTimeset);
|
||
const combined = toH2HItems(aLatest, bLatest);
|
||
combined.sort((x, y) => y.timeset - x.timeset);
|
||
items = combined;
|
||
page = 1;
|
||
// Metadata will be loaded lazily via reactive statement when pageItems changes
|
||
} catch (err) {
|
||
errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
onMount(() => {
|
||
const sp = new URLSearchParams(location.search);
|
||
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;
|
||
const filter = sp.get('filter');
|
||
if (filter && ['all', 'a1', 'a2', 'b1', 'b2'].includes(filter)) filterWinMargin = filter;
|
||
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);
|
||
// playerA and playerB are now handled by PlayerCompareForm component
|
||
sp.delete('diff');
|
||
sp.delete('mode');
|
||
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');
|
||
if (filterWinMargin !== 'all') sp.set('filter', filterWinMargin); else sp.delete('filter');
|
||
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 =====
|
||
$: 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">Player Head-to-Head</h1>
|
||
<p class="mt-2 text-muted max-w-2xl">
|
||
Compare two players on maps they've both played.
|
||
</p>
|
||
|
||
<HasToolAccess player={playerProfile} requirement={requirement} {adminRank} adminPlayer={adminPlayer}>
|
||
<PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={items.length > 0} oncompare={onCompare} currentPlayer={playerProfile} />
|
||
|
||
{#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 & Cumulative Wins -->
|
||
<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 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>
|
||
<div class="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>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if items.length > 0}
|
||
<div class="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||
<div class="text-sm text-muted">
|
||
{filtered.length} / {items.length} songs
|
||
</div>
|
||
<div class="flex items-center gap-4 text-sm text-muted flex-wrap justify-end">
|
||
<label class="flex items-center gap-3">
|
||
<span class="filter-label {filterWinMargin !== 'all' ? 'active' : ''}">Options:</span>
|
||
<select class="neon-select {filterWinMargin !== 'all' ? 'active' : ''}" bind:value={filterWinMargin}>
|
||
<option value="all">All Songs</option>
|
||
<option value="a1">{idShortA} wins by >1%</option>
|
||
<option value="a2">{idShortA} wins by >2%</option>
|
||
<option value="b1">{idShortB} wins by >1%</option>
|
||
<option value="b2">{idShortB} wins by >2%</option>
|
||
</select>
|
||
</label>
|
||
<label class="flex items-center gap-2">Dir
|
||
<select class="neon-select" 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="neon-select" 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}
|
||
{#if loadingStars}
|
||
<div class="mt-2 text-xs text-muted">Loading star ratings…</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">
|
||
<MapCard
|
||
hash={item.hash}
|
||
coverURL={metaByHash[item.hash]?.coverURL}
|
||
songName={metaByHash[item.hash]?.songName}
|
||
mapper={metaByHash[item.hash]?.mapper}
|
||
stars={starsByKey[`${item.hash}|${item.diffName}|${item.modeName}`]?.stars}
|
||
timeset={item.timeset}
|
||
diffName={item.diffName}
|
||
modeName={item.modeName}
|
||
leaderboardId={item.leaderboardId}
|
||
beatsaverKey={metaByHash[item.hash]?.key}
|
||
>
|
||
<div slot="content">
|
||
<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 {item.accA != null && item.accB != null && item.accA > item.accB ? 'winner-label' : ''}">{idShortA}</div>
|
||
<div class="value {item.accA != null && item.accB != null && item.accA > item.accB ? 'winner-value' : ''}">{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 {item.accA != null && item.accB != null && item.accB > item.accA ? 'winner-label' : ''}">{idShortB}</div>
|
||
<div class="value {item.accA != null && item.accB != null && item.accB > item.accA ? 'winner-value' : ''}">{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 margin-text {(item.accA - item.accB) > 1 ? 'margin-bold' : ''} {(item.accA - item.accB) > 2 ? 'margin-bright' : ''}">by {(item.accA - item.accB).toFixed(2)}%</span>
|
||
{:else}
|
||
<span class="chip chip-win-b">Winner: {idShortB}</span>
|
||
<span class="ml-2 text-muted margin-text {(item.accB - item.accA) > 1 ? 'margin-bold' : ''} {(item.accB - item.accA) > 2 ? 'margin-bright' : ''}">by {(item.accB - item.accA).toFixed(2)}%</span>
|
||
{/if}
|
||
{:else}
|
||
<span class="chip">Incomplete</span>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</MapCard>
|
||
</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}
|
||
</HasToolAccess>
|
||
</section>
|
||
|
||
<style>
|
||
.text-danger { color: #dc2626; }
|
||
|
||
/* Cyberpunk neon aesthetics */
|
||
.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.5); box-shadow: 0 0 12px rgba(255, 0, 170, 0.15) inset; }
|
||
.player-card:hover {
|
||
transform: translateY(-2px);
|
||
}
|
||
.player-card .label { color: #9ca3af; font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; transition: all 0.2s ease; }
|
||
.player-card .label.winner-label { color: #ff8800; font-weight: 700; text-shadow: 0 0 8px rgba(255, 136, 0, 0.5); }
|
||
.player-card .value { font-size: 26px; line-height: 1; font-weight: 800; letter-spacing: 0.02em; margin-top: 4px; white-space: nowrap; color: #9ca3af; transition: all 0.2s ease; }
|
||
.player-card .value.winner-value { color: #ffffff; }
|
||
.player-card .sub { margin-top: 6px; font-size: 12px; color: #9ca3af; }
|
||
.player-card.playerA.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);
|
||
}
|
||
.player-card.playerB.winner {
|
||
box-shadow: 0 0 0 1px rgba(255, 0, 170, 0.3) inset, 0 0 18px rgba(255, 0, 170, 0.25);
|
||
border-color: rgba(255, 0, 170, 0.7);
|
||
}
|
||
.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-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));
|
||
}
|
||
|
||
/* Margin text styling */
|
||
.margin-text.margin-bold { font-weight: 700; }
|
||
.margin-text.margin-bright { color: #ffffff; }
|
||
|
||
/* 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; }
|
||
|
||
/* Filter label styling */
|
||
.filter-label {
|
||
font-size: 1em;
|
||
letter-spacing: 0.05em;
|
||
font-weight: 700;
|
||
color: rgba(255, 0, 170, 0.95);
|
||
text-shadow: 0 0 8px rgba(255, 0, 170, 0.3);
|
||
transition: all 0.3s ease;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
font-family: var(--font-display);
|
||
line-height: 1;
|
||
}
|
||
.filter-label.active {
|
||
color: rgba(255, 0, 170, 1);
|
||
text-shadow: 0 0 12px rgba(255, 0, 170, 0.5);
|
||
}
|
||
|
||
/* Neon select dropdown */
|
||
.neon-select {
|
||
border-radius: 0.375rem;
|
||
border: 1px solid rgba(34, 211, 238, 0.3);
|
||
background: linear-gradient(180deg, rgba(15,23,42,0.9), rgba(11,15,23,0.95));
|
||
padding: 0.25rem 0.5rem;
|
||
font-size: 0.875rem;
|
||
color: rgba(148, 163, 184, 1);
|
||
transition: all 0.2s ease;
|
||
box-shadow: 0 0 8px rgba(34, 211, 238, 0.15);
|
||
cursor: pointer;
|
||
}
|
||
.neon-select.active {
|
||
border-color: rgba(34, 211, 238, 0.6);
|
||
color: rgba(255, 255, 255, 1);
|
||
box-shadow: 0 0 18px rgba(34, 211, 238, 0.35);
|
||
}
|
||
.neon-select:hover {
|
||
border-color: rgba(34, 211, 238, 0.5);
|
||
color: rgba(255, 255, 255, 0.9);
|
||
box-shadow: 0 0 16px rgba(34, 211, 238, 0.25);
|
||
}
|
||
.neon-select:focus {
|
||
outline: none;
|
||
border-color: rgba(34, 211, 238, 0.7);
|
||
box-shadow: 0 0 20px rgba(34, 211, 238, 0.35), 0 0 0 2px rgba(34, 211, 238, 0.1);
|
||
}
|
||
.neon-select option {
|
||
background: #0f172a;
|
||
color: #fff;
|
||
}
|
||
</style>
|
||
|
||
|