add page to compare player scores
This commit is contained in:
parent
a35ad405d7
commit
c4c5b6b506
@ -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>
|
||||||
|
|||||||
749
src/routes/tools/beatleader-headtohead/+page.svelte
Normal file
749
src/routes/tools/beatleader-headtohead/+page.svelte
Normal 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>
|
||||||
|
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user