2025-11-03 15:28:57 -08:00

704 lines
29 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 &gt;1%</option>
<option value="a2">{idShortA} wins by &gt;2%</option>
<option value="b1">{idShortB} wins by &gt;1%</option>
<option value="b2">{idShortB} wins by &gt;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>