1076 lines
39 KiB
Svelte
1076 lines
39 KiB
Svelte
<script lang="ts">
|
||
import Field from '$ui/components/primitives/Field.svelte';
|
||
import Input from '$ui/components/primitives/Input.svelte';
|
||
import Select from '$ui/components/primitives/Select.svelte';
|
||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||
let { data: initial }: { data: import('./$types').PageData } = $props();
|
||
import { onMount, untrack } from 'svelte';
|
||
import { goto } from '$app/navigation';
|
||
import { page, navigating } from '$app/stores';
|
||
const changingFilters = $derived($navigating?.to?.route.id === '/leaderboard');
|
||
import {
|
||
leaderboardMetrics,
|
||
playerStatMetrics,
|
||
type LeaderboardMetric,
|
||
type PlayerStatMetric,
|
||
type LeaderboardEntry,
|
||
type LeaderboardMap,
|
||
type LeaderboardResponse
|
||
} from '$lib/leaderboard.js';
|
||
import { createLeaderboardUpdates, leaderboardPageSize } from '$lib/leaderboard-updates.js';
|
||
import { playerContextHref } from '$lib/leaderboard-navigation.js';
|
||
import {
|
||
efficiencyMetrics,
|
||
isEfficiencyMetric,
|
||
efficiencyRequirement,
|
||
efficiencySample,
|
||
formatEfficiencyValue
|
||
} from '$lib/leaderboard-efficiency.js';
|
||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||
import Button from '$ui/components/primitives/Button.svelte';
|
||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||
import Freshness from '$ui/components/Freshness.svelte';
|
||
import RecordSource from '$ui/components/RecordSource.svelte';
|
||
|
||
const number = new Intl.NumberFormat('en-US');
|
||
const pageSize = leaderboardPageSize;
|
||
const games = [
|
||
{ key: 't5', label: 'Black Ops I' },
|
||
{ key: 't6', label: 'Black Ops II' }
|
||
];
|
||
const defaultColumns: PlayerStatMetric[] = ['kills', 'roundsSurvived', 'gamesPlayed'];
|
||
const squadSizes = [
|
||
{ key: '1', label: 'Solo' },
|
||
{ key: '2', label: 'Duo' },
|
||
{ key: '3', label: 'Trio' },
|
||
{ key: '4', label: 'Four players' }
|
||
];
|
||
const descriptions: Record<PlayerStatMetric, string> = {
|
||
highestRound: 'The highest recorded round, including players who joined mid-game.',
|
||
kills: 'Cumulative kills across recorded games.',
|
||
roundsSurvived: 'Round progression during observed participation, added across games.',
|
||
headshots: 'Cumulative headshots across recorded games.',
|
||
revives: 'Cumulative teammates revived across recorded games.',
|
||
downs: 'Cumulative downs across recorded games, ranked from most to fewest.',
|
||
gamesPlayed:
|
||
'Finished games with recorded player stats, excluding players who left in the same round they joined.',
|
||
gamesCompleted: 'Games where the player appears in the final game-end report.',
|
||
bestKills: 'The most kills reported for a player in a single game.',
|
||
bestScore:
|
||
'The highest reported points balance. Spending points reduces this balance; it is not total points earned.'
|
||
};
|
||
const description = (key: LeaderboardMetric) =>
|
||
isEfficiencyMetric(key)
|
||
? efficiencyMetrics.find((item) => item.key === key)!.description
|
||
: descriptions[key];
|
||
const formatValue = (entry: LeaderboardEntry, key: LeaderboardMetric) =>
|
||
isEfficiencyMetric(key)
|
||
? formatEfficiencyValue(entry[key], key)
|
||
: number.format(entry[key]);
|
||
|
||
let data = $state<LeaderboardResponse | null>(untrack(() => initial.leaderboard));
|
||
let maps = $state<LeaderboardMap[]>(untrack(() => initial.leaderboard.maps));
|
||
let loading = $state(false);
|
||
let error = $state('');
|
||
let search = $state(
|
||
untrack(() => ($page.url.searchParams.get('q') ?? '').trim().slice(0, 100))
|
||
);
|
||
let checkedAt = $state<Date | null>(untrack(() => (initial.leaderboard ? new Date() : null)));
|
||
let pendingUpdate = $state<LeaderboardResponse | null>(null);
|
||
let filtersOpen = $state(false);
|
||
let showAllStats = $state(false);
|
||
let tableRegion = $state<HTMLDivElement>();
|
||
let controller: AbortController | undefined;
|
||
let requestId = 0;
|
||
let loadedUrl = '';
|
||
let pendingUrl: URL | undefined;
|
||
let navigationId = 0;
|
||
|
||
const game = $derived(
|
||
games.some((item) => item.key === $page.url.searchParams.get('game'))
|
||
? $page.url.searchParams.get('game')!
|
||
: ''
|
||
);
|
||
const map = $derived($page.url.searchParams.get('map') ?? '');
|
||
const players = $derived(
|
||
squadSizes.some((size) => size.key === $page.url.searchParams.get('players'))
|
||
? $page.url.searchParams.get('players')!
|
||
: ''
|
||
);
|
||
const playersLabel = $derived(
|
||
squadSizes.find((size) => size.key === players)?.label ?? 'All squad sizes'
|
||
);
|
||
const query = $derived(($page.url.searchParams.get('q') ?? '').trim().slice(0, 100));
|
||
const metric = $derived(
|
||
leaderboardMetrics.find((item) => item.key === $page.url.searchParams.get('metric'))?.key ??
|
||
'highestRound'
|
||
);
|
||
const offset = $derived(
|
||
Math.max(
|
||
0,
|
||
Math.min(1_000_000, Math.floor(Number($page.url.searchParams.get('offset')) || 0))
|
||
)
|
||
);
|
||
const filteredMaps = $derived(maps.filter((item) => !game || item.game === game));
|
||
const mapName = $derived(maps.find((item) => item.key === map)?.name);
|
||
const scope = $derived(
|
||
mapName ?? (game ? games.find((item) => item.key === game)!.label : 'All games & maps')
|
||
);
|
||
const metricLabel = $derived(leaderboardMetrics.find((item) => item.key === metric)!.label);
|
||
const columns = $derived([
|
||
metric,
|
||
...(showAllStats ? playerStatMetrics.map((item) => item.key) : defaultColumns).filter(
|
||
(key) => key !== metric
|
||
)
|
||
]);
|
||
const filterCount = $derived([game, map, players, query].filter(Boolean).length);
|
||
const apiUrl = $derived.by(() => {
|
||
const params = new URLSearchParams({
|
||
metric,
|
||
limit: String(pageSize),
|
||
offset: String(offset)
|
||
});
|
||
if (game) params.set('game', game);
|
||
params.set('map', map);
|
||
if (players) params.set('players', players);
|
||
if (query) params.set('q', query);
|
||
return `/api/leaderboard?${params}`;
|
||
});
|
||
|
||
const updates = untrack(() => createLeaderboardUpdates(apiUrl, initial.leaderboard));
|
||
|
||
$effect(() => {
|
||
search = query;
|
||
});
|
||
$effect(() => {
|
||
const report = initial.leaderboard;
|
||
untrack(() => {
|
||
++requestId;
|
||
controller?.abort();
|
||
data = report;
|
||
maps = report.maps;
|
||
loadedUrl = apiUrl;
|
||
loading = false;
|
||
error = '';
|
||
checkedAt = new Date();
|
||
pendingUpdate = null;
|
||
updates.reset(apiUrl, report);
|
||
});
|
||
});
|
||
|
||
onMount(() => {
|
||
const refreshVisible = () => {
|
||
if (!document.hidden && !loading && !changingFilters && !pendingUrl)
|
||
void refresh(apiUrl);
|
||
};
|
||
const interval = setInterval(refreshVisible, 15_000);
|
||
document.addEventListener('visibilitychange', refreshVisible);
|
||
return () => {
|
||
++requestId;
|
||
controller?.abort();
|
||
clearInterval(interval);
|
||
document.removeEventListener('visibilitychange', refreshVisible);
|
||
};
|
||
});
|
||
|
||
async function refresh(url: string) {
|
||
const id = ++requestId;
|
||
controller?.abort();
|
||
const activeController = new AbortController();
|
||
controller = activeController;
|
||
if (url !== loadedUrl) checkedAt = null;
|
||
loading = true;
|
||
error = '';
|
||
const timeout = setTimeout(() => activeController.abort(), 20_000);
|
||
try {
|
||
const response = await fetch(url, {
|
||
signal: activeController.signal,
|
||
cache: 'no-store'
|
||
});
|
||
if (!response.ok) throw new Error(`Leaderboard request failed (${response.status}).`);
|
||
const result: LeaderboardResponse = await response.json();
|
||
if (id !== requestId || url !== apiUrl || changingFilters || pendingUrl) return;
|
||
const inspecting = !!tableRegion?.contains(document.activeElement);
|
||
const next = updates.receive(url, result, inspecting);
|
||
if (!next) return;
|
||
data = next.current;
|
||
maps = next.current.maps;
|
||
pendingUpdate = next.pending;
|
||
loadedUrl = url;
|
||
checkedAt = new Date();
|
||
} catch (cause) {
|
||
if (id !== requestId) return;
|
||
error = activeController.signal.aborted
|
||
? 'The leaderboard request timed out.'
|
||
: cause instanceof Error
|
||
? cause.message
|
||
: 'Unable to load the leaderboard.';
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
if (id === requestId) loading = false;
|
||
}
|
||
}
|
||
|
||
function applyUpdate() {
|
||
data = updates.apply();
|
||
maps = data.maps;
|
||
pendingUpdate = null;
|
||
}
|
||
|
||
function updateFilters(changes: Record<string, string | null>) {
|
||
++requestId;
|
||
controller?.abort();
|
||
loading = false;
|
||
// Keep rapid changes together while the preceding navigation settles.
|
||
const url = new URL(pendingUrl ?? $page.url);
|
||
url.searchParams.delete('offset');
|
||
for (const [key, value] of Object.entries(changes)) {
|
||
if (value || (key === 'map' && value !== null)) url.searchParams.set(key, value);
|
||
else url.searchParams.delete(key);
|
||
}
|
||
pendingUrl = url;
|
||
const id = ++navigationId;
|
||
void goto(`${url.pathname}${url.search}`, { keepFocus: true, noScroll: true })
|
||
.catch(() => {
|
||
error = 'Unable to update the leaderboard filters. Please try again.';
|
||
})
|
||
.finally(() => {
|
||
if (id === navigationId) pendingUrl = undefined;
|
||
});
|
||
}
|
||
|
||
function clearFilters() {
|
||
search = '';
|
||
updateFilters({ game: '', map: null, players: '', q: '' });
|
||
}
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>Leaderboard · Electric Cherry</title>
|
||
<meta
|
||
name="description"
|
||
content="Player records and lifetime Zombies stats across your Electric Cherry fleet."
|
||
/>
|
||
</svelte:head>
|
||
|
||
<section class="page">
|
||
<SectionHeader title="Leaderboard" subtitle="Records by map and starting squad.">
|
||
{#snippet actions()}<div class="mobile-filter-trigger">
|
||
<Button size="sm" onclick={() => (filtersOpen = true)}
|
||
>Filters{filterCount ? ` · ${filterCount}` : ''}</Button
|
||
>
|
||
</div>{/snippet}
|
||
</SectionHeader>
|
||
|
||
{#snippet filterFields(suffix: string)}
|
||
<div class="filters">
|
||
<Field label="Game" forId={`leaderboard-game${suffix}`}>
|
||
<Select
|
||
id={`leaderboard-game${suffix}`}
|
||
value={game}
|
||
onchange={(event) =>
|
||
updateFilters({ game: event.currentTarget.value, map: null })}
|
||
>
|
||
<option value="">All games</option>{#each games as item (item.key)}<option
|
||
value={item.key}>{item.label}</option
|
||
>{/each}
|
||
</Select>
|
||
</Field>
|
||
<Field label="Map" forId={`leaderboard-map${suffix}`}>
|
||
<Select
|
||
id={`leaderboard-map${suffix}`}
|
||
value={map}
|
||
onchange={(event) => updateFilters({ map: event.currentTarget.value })}
|
||
>
|
||
<option value="">All maps</option>
|
||
{#if map && !filteredMaps.some((item) => item.key === map)}<option value={map}
|
||
>{map}</option
|
||
>{/if}
|
||
{#each filteredMaps as item (item.key)}<option value={item.key}
|
||
>{item.name}{!game
|
||
? ` · ${item.game === 't5' ? 'BO1' : 'BO2'}`
|
||
: ''}</option
|
||
>{/each}
|
||
</Select>
|
||
</Field>
|
||
<Field label="Players" forId={`leaderboard-players${suffix}`}>
|
||
<Select
|
||
id={`leaderboard-players${suffix}`}
|
||
value={players}
|
||
onchange={(event) => updateFilters({ players: event.currentTarget.value })}
|
||
aria-describedby={`players-help${suffix}`}
|
||
>
|
||
<option value="">All squad sizes</option>{#each squadSizes as size}<option
|
||
value={size.key}>{size.label}</option
|
||
>{/each}
|
||
</Select><span id={`players-help${suffix}`} class="sr-only"
|
||
>Players recorded at the start of each match. Matches with unknown starting
|
||
counts appear only under All squad sizes.</span
|
||
>
|
||
</Field>
|
||
<Field label="Rank by" forId={`leaderboard-metric${suffix}`}>
|
||
<Select
|
||
id={`leaderboard-metric${suffix}`}
|
||
value={metric}
|
||
onchange={(event) => updateFilters({ metric: event.currentTarget.value })}
|
||
>
|
||
{#each leaderboardMetrics as item (item.key)}<option value={item.key}
|
||
>{item.label}</option
|
||
>{/each}
|
||
</Select>
|
||
</Field>
|
||
<form
|
||
class="search"
|
||
onsubmit={(event) => {
|
||
event.preventDefault();
|
||
updateFilters({ q: search.trim() });
|
||
if (suffix) filtersOpen = false;
|
||
}}
|
||
role="search"
|
||
>
|
||
<Field label="Find a player" forId={`leaderboard-search${suffix}`}>
|
||
<Input
|
||
id={`leaderboard-search${suffix}`}
|
||
type="search"
|
||
bind:value={search}
|
||
maxlength={100}
|
||
placeholder="Name or GUID"
|
||
aria-describedby={`search-help${suffix}`}
|
||
/></Field
|
||
><span id={`search-help${suffix}`} class="sr-only"
|
||
>Press Enter to search. Player ranks are preserved.</span
|
||
>
|
||
</form>
|
||
</div>
|
||
{/snippet}
|
||
<div class="desktop-filters">{@render filterFields('')}</div>
|
||
<Dialog
|
||
bind:open={filtersOpen}
|
||
title="Leaderboard filters"
|
||
description="Choose a map, starting squad size, and ranking."
|
||
drawer
|
||
>
|
||
{@render filterFields('-mobile')}
|
||
{#snippet actions()}<Button variant="ghost" onclick={clearFilters}>Reset filters</Button
|
||
><Button variant="primary" onclick={() => (filtersOpen = false)}>Show results</Button
|
||
>{/snippet}
|
||
</Dialog>
|
||
|
||
<div class="scope-row">
|
||
<div>
|
||
<span class="scope">{scope}</span><span class="all-time">{playersLabel}</span><span
|
||
class="all-time">All time</span
|
||
>
|
||
</div>
|
||
{#if game || players || query}<button class="text-button" onclick={clearFilters}
|
||
>Reset filters</button
|
||
>{/if}
|
||
</div>
|
||
|
||
{#if error}
|
||
<div class="error" role="alert">
|
||
<span>{error}{data ? ' Showing the last successful update.' : ''}</span><Button
|
||
size="sm"
|
||
onclick={() => void refresh(apiUrl)}>Try again</Button
|
||
>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if data}
|
||
<section class="overview" aria-label={`All-time stats for ${scope}`}>
|
||
<div class="record">
|
||
<span>Highest round</span><strong>{number.format(data.summary.highestRound)}</strong
|
||
><small>{game || map ? 'Selection record' : 'Fleet record'}</small>
|
||
</div>
|
||
<div>
|
||
<span>Total kills</span><strong>{number.format(data.summary.kills)}</strong><small
|
||
>Across all players</small
|
||
>
|
||
</div>
|
||
<div>
|
||
<span>Total rounds</span><strong
|
||
>{number.format(data.summary.roundsSurvived)}</strong
|
||
><small>Player rounds</small>
|
||
</div>
|
||
<div>
|
||
<span>Games tracked</span><strong>{number.format(data.summary.matches)}</strong
|
||
><small>Completed games</small>
|
||
</div>
|
||
<div>
|
||
<span>Players</span><strong>{number.format(data.summary.players)}</strong><small
|
||
>Unique players</small
|
||
>
|
||
</div>
|
||
</section>
|
||
|
||
<section
|
||
class="rankings"
|
||
class:show-all-stats={showAllStats}
|
||
aria-labelledby="rankings-title"
|
||
aria-busy={loading || changingFilters}
|
||
>
|
||
<div class="rankings-header">
|
||
<div>
|
||
<h2 id="rankings-title">{metricLabel}</h2>
|
||
{#if isEfficiencyMetric(metric)}<p>
|
||
{efficiencyRequirement(metric)} within the selected game and map.
|
||
</p>{/if}
|
||
<p class="metric-description">
|
||
{description(metric)} Equal values share a rank.
|
||
</p>
|
||
</div>
|
||
<div class="ranking-controls">
|
||
<label class="all-stats"
|
||
><input type="checkbox" bind:checked={showAllStats} /> Show all stats</label
|
||
>{#if !error}<Freshness
|
||
updatedAt={checkedAt?.getTime() ?? null}
|
||
intervalLabel="Updates every 15 seconds"
|
||
/>{/if}
|
||
</div>
|
||
</div>
|
||
{#if pendingUpdate}<div class="pending-update" role="status">
|
||
<button class="text-button" onclick={applyUpdate} disabled={changingFilters}
|
||
>Updated rankings available — apply update</button
|
||
>
|
||
</div>{/if}
|
||
{#if data.entries.length}
|
||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the table.) -->
|
||
<div
|
||
class="table-scroll"
|
||
bind:this={tableRegion}
|
||
role="region"
|
||
aria-label="Player rankings"
|
||
tabindex="0"
|
||
>
|
||
<table>
|
||
<caption class="sr-only"
|
||
>{scope}: players ranked by {metricLabel.toLowerCase()}, highest first</caption
|
||
>
|
||
<thead
|
||
><tr>
|
||
<th scope="col" class="rank">Rank</th>
|
||
<th scope="col" class="player">Player</th>
|
||
{#each columns as key (key)}
|
||
<th
|
||
scope="col"
|
||
class="stat"
|
||
class:selected={key === metric}
|
||
aria-sort={key === metric ? 'descending' : 'none'}
|
||
>
|
||
<button
|
||
onclick={() => updateFilters({ metric: key })}
|
||
title={description(key)}
|
||
>{leaderboardMetrics.find((item) => item.key === key)!
|
||
.label}{#if key === metric}<span aria-hidden="true">
|
||
↓</span
|
||
>{/if}</button
|
||
>
|
||
</th>
|
||
{/each}
|
||
</tr></thead
|
||
>
|
||
<tbody>
|
||
{#each data.entries as entry (entry.guid)}
|
||
<tr>
|
||
<td class="rank"
|
||
><span class:podium={entry.rank <= 3}
|
||
>{number.format(entry.rank)}</span
|
||
></td
|
||
>
|
||
<th scope="row" class="player">
|
||
<a
|
||
class="player-name"
|
||
href={playerContextHref(
|
||
entry.guid,
|
||
`${$page.url.pathname}${$page.url.search}`
|
||
)}>{entry.name}</a
|
||
>
|
||
</th>
|
||
{#each columns as key (key)}
|
||
<td class="stat" class:selected={key === metric}>
|
||
{formatValue(entry, key)}
|
||
{#if isEfficiencyMetric(key)}
|
||
<small class="sample"
|
||
>{efficiencySample(entry, key)} · {number.format(
|
||
entry.gamesPlayed
|
||
)} games</small
|
||
>
|
||
{/if}
|
||
{#if key === metric && (key === 'highestRound' || key === 'bestKills' || key === 'bestScore')}
|
||
<RecordSource
|
||
evidence={entry.records[key]}
|
||
metric={metricLabel}
|
||
returnTo={`${$page.url.pathname}${$page.url.search}`}
|
||
/>
|
||
{/if}
|
||
</td>
|
||
{/each}
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="pagination">
|
||
<span
|
||
>{number.format(data.offset + 1)}–{number.format(
|
||
data.offset + data.entries.length
|
||
)} of {number.format(data.total)}
|
||
{isEfficiencyMetric(metric) ? 'qualifying ' : ''}{data.total === 1
|
||
? 'player'
|
||
: 'players'}{query ? ` matching “${query}”` : ''}</span
|
||
>
|
||
<div>
|
||
<Button
|
||
size="sm"
|
||
disabled={data.offset === 0 || loading || changingFilters}
|
||
onclick={() =>
|
||
updateFilters({ offset: String(Math.max(0, offset - pageSize)) })}
|
||
>Previous</Button
|
||
><Button
|
||
size="sm"
|
||
disabled={data.offset + data.entries.length >= data.total ||
|
||
loading ||
|
||
changingFilters}
|
||
onclick={() => updateFilters({ offset: String(offset + pageSize) })}
|
||
>Next</Button
|
||
>
|
||
</div>
|
||
</div>
|
||
{:else if isEfficiencyMetric(metric)}
|
||
<EmptyState
|
||
spacious
|
||
title="No qualifying players found"
|
||
description={`${efficiencyRequirement(metric)} in this game/map selection. Try another scope or player search.`}
|
||
>
|
||
{#snippet actions()}<Button
|
||
size="sm"
|
||
onclick={() => updateFilters({ metric: 'highestRound' })}
|
||
>Rank by highest round</Button
|
||
>{/snippet}
|
||
</EmptyState>
|
||
{:else if query || game || map || players || offset}
|
||
<EmptyState
|
||
spacious
|
||
title="No players found"
|
||
description="Try a different game, map, starting squad size, or player name."
|
||
>
|
||
{#snippet actions()}<Button size="sm" onclick={clearFilters}
|
||
>Reset filters</Button
|
||
>{/snippet}
|
||
</EmptyState>
|
||
{:else}
|
||
<EmptyState
|
||
spacious
|
||
title="Your first records start here"
|
||
description="Player stats appear after a game ends, once the player has been observed at round 2 or later."
|
||
/>
|
||
{/if}
|
||
</section>
|
||
{:else if loading}
|
||
<div class="loading" role="status">Loading player records…</div>
|
||
{/if}
|
||
|
||
{#if data}<details class="mobile-totals">
|
||
<summary>Selection totals</summary>
|
||
<dl>
|
||
<div>
|
||
<dt>Highest round</dt>
|
||
<dd>{number.format(data.summary.highestRound)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Total kills</dt>
|
||
<dd>{number.format(data.summary.kills)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Total rounds</dt>
|
||
<dd>{number.format(data.summary.roundsSurvived)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Games tracked</dt>
|
||
<dd>{number.format(data.summary.matches)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Players</dt>
|
||
<dd>{number.format(data.summary.players)}</dd>
|
||
</div>
|
||
</dl>
|
||
</details>{/if}
|
||
<details class="tracking-notes">
|
||
<summary>How these records work</summary>
|
||
<div>
|
||
<p>
|
||
Each GUID has one leaderboard entry across Black Ops I and Black Ops II, so name
|
||
changes keep a player's records together. The latest reported name across both games
|
||
appears here and on the player's profile, regardless of filters.
|
||
</p>
|
||
<p>
|
||
Only games with a received end report contribute to records. Games in progress,
|
||
interrupted games, and games missing their end report stay out of totals and
|
||
rankings. Players who left a completed game keep their recorded stats. Game, map,
|
||
and starting squad-size filters choose which matches contribute; player search
|
||
preserves ranks. Player totals count each GUID once. The top stats cover all
|
||
recorded players in the selected game and map; player search and ranking
|
||
qualification do not change those totals.
|
||
</p>
|
||
<p>
|
||
Highest round includes mid-game joins. Total rounds counts round progression during
|
||
observed participation, excluding earlier rounds. Joining at round 8 records a
|
||
highest round of 8; reaching round 9 adds one to total rounds. The fleet total adds
|
||
all players’ rounds.
|
||
</p>
|
||
<p>
|
||
Kills, headshots, revives, and downs accumulate across recorded games. Highest
|
||
reported score is the largest observed points balance, not the total points a player
|
||
has earned. Equal values share a rank.
|
||
</p>
|
||
<p>
|
||
Records cover telemetry saved since tracking began; older unrecorded games cannot be
|
||
included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <RelativeTime
|
||
value={data.summary.firstTrackedAt}
|
||
/> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <RelativeTime
|
||
value={data.summary.updatedAt}
|
||
/>.{/if}
|
||
</p>
|
||
</div>
|
||
</details>
|
||
</section>
|
||
|
||
<style>
|
||
.overview {
|
||
display: grid;
|
||
grid-template-columns: 1.2fr repeat(4, minmax(0, 1fr));
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
background: var(--surface);
|
||
overflow: hidden;
|
||
}
|
||
.overview > div {
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
gap: var(--space-1);
|
||
padding: var(--space-4);
|
||
border-right: 1px solid var(--border-subtle);
|
||
min-width: 0;
|
||
}
|
||
.overview > div:last-child {
|
||
border-right: 0;
|
||
}
|
||
.overview span,
|
||
.overview small {
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.overview strong {
|
||
font-size: var(--text-xl);
|
||
line-height: 1.15;
|
||
font-weight: var(--weight-semibold);
|
||
font-variant-numeric: tabular-nums;
|
||
letter-spacing: -0.035em;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.overview .record {
|
||
background: var(--surface-raised);
|
||
}
|
||
.record strong {
|
||
color: var(--text-primary);
|
||
font-size: var(--text-2xl);
|
||
}
|
||
summary {
|
||
cursor: pointer;
|
||
}
|
||
.pending-update {
|
||
padding: 0 var(--space-5) var(--space-3);
|
||
}
|
||
.text-button:focus-visible,
|
||
summary:focus-visible {
|
||
outline: 2px solid var(--accent-primary);
|
||
outline-offset: 3px;
|
||
}
|
||
.page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-5);
|
||
}
|
||
.filters {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: var(--space-4);
|
||
}
|
||
.search {
|
||
grid-column: 1 / -1;
|
||
}
|
||
.search {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-2);
|
||
min-width: 0;
|
||
}
|
||
label {
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-xs);
|
||
}
|
||
input,
|
||
button {
|
||
font: inherit;
|
||
}
|
||
input {
|
||
min-height: 42px;
|
||
width: 100%;
|
||
padding: 9px 12px;
|
||
background: var(--surface-sunken);
|
||
border: 1px solid var(--border-control);
|
||
border-radius: var(--radius-md);
|
||
color: var(--text-primary);
|
||
font-size: var(--text-sm);
|
||
}
|
||
input::placeholder {
|
||
color: var(--text-tertiary);
|
||
}
|
||
.scope-row,
|
||
.scope-row > div {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-3);
|
||
flex-wrap: wrap;
|
||
}
|
||
.scope-row {
|
||
justify-content: space-between;
|
||
}
|
||
.scope {
|
||
font-weight: var(--weight-semibold);
|
||
}
|
||
.all-time {
|
||
font-size: var(--text-xs);
|
||
color: var(--text-tertiary);
|
||
padding-left: var(--space-3);
|
||
border-left: 1px solid var(--border-strong);
|
||
}
|
||
.text-button {
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--accent-primary-hover);
|
||
cursor: pointer;
|
||
padding: 4px 0;
|
||
font-size: var(--text-xs);
|
||
}
|
||
.rankings {
|
||
min-width: 0;
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
background: var(--surface);
|
||
overflow: hidden;
|
||
}
|
||
.rankings-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: var(--space-4);
|
||
padding: var(--space-5);
|
||
}
|
||
h2 {
|
||
margin: 0 0 var(--space-1);
|
||
font-size: var(--text-lg);
|
||
letter-spacing: var(--tracking-tight);
|
||
}
|
||
.rankings-header p {
|
||
margin: 0;
|
||
color: var(--text-tertiary);
|
||
font-size: var(--text-xs);
|
||
max-width: 70ch;
|
||
}
|
||
.table-scroll {
|
||
position: relative;
|
||
max-width: 100%;
|
||
overflow-x: auto;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: separate;
|
||
border-spacing: 0;
|
||
font-size: var(--text-sm);
|
||
}
|
||
th,
|
||
td {
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
thead th {
|
||
background: var(--surface-sunken);
|
||
border-top: 1px solid var(--border-subtle);
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--weight-medium);
|
||
color: var(--text-secondary);
|
||
}
|
||
.rank {
|
||
width: 65px;
|
||
text-align: center;
|
||
color: var(--text-tertiary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.rank span {
|
||
display: inline-grid;
|
||
min-width: 30px;
|
||
height: 30px;
|
||
place-items: center;
|
||
}
|
||
.rank .podium {
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text-primary);
|
||
background: var(--surface-raised);
|
||
font-weight: var(--weight-semibold);
|
||
}
|
||
.player {
|
||
position: sticky;
|
||
left: 0;
|
||
z-index: 1;
|
||
min-width: 180px;
|
||
max-width: 320px;
|
||
text-align: left;
|
||
}
|
||
tbody .player {
|
||
background: var(--surface);
|
||
font-weight: var(--weight-medium);
|
||
}
|
||
.player-name {
|
||
display: block;
|
||
color: var(--text-primary);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.stat {
|
||
text-align: right;
|
||
white-space: nowrap;
|
||
}
|
||
td.stat {
|
||
font-family: var(--font-numeric);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.stat.selected {
|
||
color: var(--text-primary);
|
||
}
|
||
th.stat.selected {
|
||
color: var(--accent-primary-hover);
|
||
}
|
||
.sample {
|
||
display: block;
|
||
max-width: 22rem;
|
||
margin-top: var(--space-2);
|
||
white-space: normal;
|
||
font-family: var(--font-body);
|
||
font-size: var(--text-xs);
|
||
font-weight: normal;
|
||
color: var(--text-secondary);
|
||
}
|
||
td.selected {
|
||
font-weight: var(--weight-semibold);
|
||
}
|
||
th button {
|
||
display: inline-flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
background: transparent;
|
||
border: 0;
|
||
padding: 3px 0;
|
||
cursor: pointer;
|
||
color: inherit;
|
||
white-space: nowrap;
|
||
}
|
||
th button:hover {
|
||
color: var(--accent-primary-hover);
|
||
}
|
||
tbody tr:hover > * {
|
||
background: var(--surface-raised);
|
||
}
|
||
tbody tr:last-child > * {
|
||
border-bottom: 0;
|
||
}
|
||
.pagination {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: var(--space-3);
|
||
border-top: 1px solid var(--border-subtle);
|
||
padding: var(--space-4) var(--space-5);
|
||
color: var(--text-tertiary);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.pagination > div {
|
||
display: flex;
|
||
gap: var(--space-2);
|
||
}
|
||
.error {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: var(--space-3);
|
||
padding: var(--space-4);
|
||
border: 1px solid color-mix(in srgb, var(--state-warning) 45%, transparent);
|
||
border-radius: var(--radius-md);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.loading {
|
||
padding: var(--space-12) var(--space-5);
|
||
background: var(--surface);
|
||
border-radius: var(--radius-lg);
|
||
text-align: center;
|
||
color: var(--text-secondary);
|
||
}
|
||
.tracking-notes {
|
||
color: var(--text-tertiary);
|
||
font-size: var(--text-xs);
|
||
line-height: 1.7;
|
||
}
|
||
.tracking-notes summary {
|
||
display: list-item;
|
||
width: fit-content;
|
||
cursor: pointer;
|
||
color: var(--text-secondary);
|
||
}
|
||
.tracking-notes > div {
|
||
max-width: 100ch;
|
||
}
|
||
.tracking-notes p {
|
||
margin: var(--space-3) 0 0;
|
||
}
|
||
.sr-only {
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 1px;
|
||
padding: 0;
|
||
margin: -1px;
|
||
overflow: hidden;
|
||
clip-path: inset(50%);
|
||
white-space: nowrap;
|
||
border: 0;
|
||
}
|
||
.ranking-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
gap: var(--space-2);
|
||
}
|
||
.all-stats {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-1);
|
||
white-space: nowrap;
|
||
font-size: var(--text-xs);
|
||
}
|
||
.all-stats input {
|
||
width: 16px;
|
||
height: 16px;
|
||
min-height: 0;
|
||
accent-color: var(--accent-primary);
|
||
}
|
||
.mobile-filter-trigger,
|
||
.mobile-totals {
|
||
display: none;
|
||
}
|
||
.mobile-totals {
|
||
font-size: var(--text-xs);
|
||
color: var(--text-secondary);
|
||
}
|
||
.mobile-totals dl {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--space-3);
|
||
}
|
||
.mobile-totals dd {
|
||
margin: var(--space-1) 0 0;
|
||
color: var(--text-primary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
@media (max-width: 1000px) {
|
||
.filters {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
@media (max-width: 800px) {
|
||
.overview {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
.overview > div {
|
||
padding: 20px;
|
||
}
|
||
.overview .record {
|
||
grid-column: 1 / -1;
|
||
border-right: 0;
|
||
}
|
||
.overview > div:nth-child(-n + 3) {
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
.overview > div:nth-child(3) {
|
||
border-right: 0;
|
||
}
|
||
}
|
||
@media (max-width: 640px) {
|
||
.page {
|
||
gap: var(--space-4);
|
||
}
|
||
.filters {
|
||
gap: var(--space-3);
|
||
}
|
||
.search {
|
||
grid-column: 1 / -1;
|
||
}
|
||
.desktop-filters,
|
||
.overview {
|
||
display: none;
|
||
}
|
||
.mobile-filter-trigger,
|
||
.mobile-totals {
|
||
display: block;
|
||
}
|
||
.scope-row {
|
||
font-size: var(--text-sm);
|
||
}
|
||
.scope-row > div {
|
||
gap: var(--space-1) var(--space-2);
|
||
}
|
||
.all-time {
|
||
padding-left: var(--space-2);
|
||
}
|
||
.filters {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
.ranking-controls {
|
||
justify-content: flex-start;
|
||
}
|
||
.metric-description {
|
||
display: none;
|
||
}
|
||
.rankings:not(.show-all-stats) .stat:not(.selected) {
|
||
display: none;
|
||
}
|
||
.rank {
|
||
width: 36px;
|
||
}
|
||
.rank span {
|
||
min-width: 20px;
|
||
}
|
||
.player {
|
||
position: static;
|
||
min-width: 90px;
|
||
max-width: 140px;
|
||
}
|
||
.selected :global(.record-source) {
|
||
max-width: 150px;
|
||
}
|
||
.rankings-header {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
padding: var(--space-4);
|
||
}
|
||
th,
|
||
td {
|
||
padding: var(--space-3);
|
||
}
|
||
.pagination {
|
||
padding: var(--space-4);
|
||
}
|
||
}
|
||
</style>
|