Improve metrics clarity, exploration, and refresh reliability
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import type { MapMetrics, ServerMetrics } from '$lib/metrics.js';
|
||||
import { compareMetricValues, metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
|
||||
let { maps, servers }: { maps: MapMetrics[]; servers: ServerMetrics[] } = $props();
|
||||
const mapColumns = [
|
||||
{ key: 'map', label: 'Map' }, { key: 'playerHours', label: 'Player-hours' },
|
||||
{ key: 'active', label: 'With players' }, { key: 'full', label: 'All lobbies full' },
|
||||
{ key: 'capacityObserved', label: 'Known capacity' }
|
||||
] as const;
|
||||
const serverColumns = [
|
||||
{ key: 'server', label: 'Server' }, { key: 'utilization', label: 'Time with players' },
|
||||
{ key: 'idle', label: 'Idle' }, { key: 'observed', label: 'Known server time' }, { key: 'unknown', label: 'Unknown' }
|
||||
] as const;
|
||||
type MapSort = typeof mapColumns[number]['key'];
|
||||
type ServerSort = typeof serverColumns[number]['key'];
|
||||
type Direction = 'asc' | 'desc';
|
||||
// Shallow navigation changes page.state, not page.url. Store the working
|
||||
// search string there so filters react immediately and survive Back/Forward.
|
||||
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
|
||||
const params = $derived(new URLSearchParams(contextSearch));
|
||||
const mapQuery = $derived((params.get('mapQ') ?? '').slice(0, 100));
|
||||
const serverQuery = $derived((params.get('serverQ') ?? '').slice(0, 100));
|
||||
const mapSort = $derived(mapColumns.find(column => column.key === params.get('mapSort'))?.key ?? 'playerHours');
|
||||
const serverSort = $derived(serverColumns.find(column => column.key === params.get('serverSort'))?.key ?? 'utilization');
|
||||
const mapDirection = $derived<Direction>(params.get('mapDirection') === 'asc' ? 'asc' : 'desc');
|
||||
const serverDirection = $derived<Direction>(params.get('serverDirection') === 'asc' ? 'asc' : 'desc');
|
||||
const maxPlayerHours = $derived(Math.max(0, ...maps.map(map => map.playerHours)));
|
||||
const mapRows = $derived(maps.filter(map => `${map.game} ${gameName(map.game)} ${map.map}`.toLowerCase().includes(mapQuery.trim().toLowerCase())).sort((a, b) => {
|
||||
const result = mapSort === 'map' ? a.map.localeCompare(b.map) * (mapDirection === 'asc' ? 1 : -1)
|
||||
: compareMetricValues(mapValue(a, mapSort), mapValue(b, mapSort), mapDirection);
|
||||
return result || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
|
||||
}));
|
||||
const serverRows = $derived(servers.filter(server => `${server.server} ${serverTitle(server.server)}`.toLowerCase().includes(serverQuery.trim().toLowerCase())).sort((a, b) => {
|
||||
const result = serverSort === 'server' ? serverTitle(a.server).localeCompare(serverTitle(b.server)) * (serverDirection === 'asc' ? 1 : -1)
|
||||
: compareMetricValues(serverValue(a, serverSort), serverValue(b, serverSort), serverDirection);
|
||||
return result || b.observed - a.observed || a.server.localeCompare(b.server);
|
||||
}));
|
||||
function gameName(game: string) { return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); }
|
||||
function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null {
|
||||
return key === 'full' && !map.capacityObserved || key === 'playerHours' && !map.observed ? null : map[key];
|
||||
}
|
||||
function serverValue(server: ServerMetrics, key: Exclude<ServerSort, 'server'>): number | null {
|
||||
if (key === 'utilization' || key === 'idle') return server.observed > 0 ? server[key === 'utilization' ? 'active' : 'idle'] / server.observed : null;
|
||||
return server[key];
|
||||
}
|
||||
function updateContext(changes: Record<string, string>) {
|
||||
const url = new URL(page.url);
|
||||
url.search = contextSearch;
|
||||
for (const [key, value] of Object.entries(changes)) { if (value) url.searchParams.set(key, value); else url.searchParams.delete(key); }
|
||||
replaceState(url, { ...page.state, metricsTableSearch: url.search });
|
||||
}
|
||||
function sortMap(key: MapSort) { updateContext({ mapSort: key, mapDirection: key === mapSort ? mapDirection === 'asc' ? 'desc' : 'asc' : key === 'map' ? 'asc' : 'desc' }); }
|
||||
function sortServer(key: ServerSort) { updateContext({ serverSort: key, serverDirection: key === serverSort ? serverDirection === 'asc' ? 'desc' : 'asc' : key === 'server' ? 'asc' : 'desc' }); }
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-labelledby="map-metrics-title">
|
||||
<div class="heading"><div><h2 id="map-metrics-title">Map play time & capacity</h2><p>Compare play time and how often every tracked lobby was full.</p></div>
|
||||
<div class="search"><Field label="Find a map" hint="Search this table only."><Input type="search" value={mapQuery} placeholder="Map or game…" oninput={event => updateContext({ mapQ: (event.currentTarget as HTMLInputElement).value })} /></Field></div>
|
||||
</div>
|
||||
<p class="definition">“All lobbies full” uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand.</p>
|
||||
{#if mapRows.length}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Map play time and capacity table" tabindex="0"><table>
|
||||
<thead><tr>{#each mapColumns as column}<th scope="col" aria-sort={mapSort === column.key ? mapDirection === 'asc' ? 'ascending' : 'descending' : undefined}><button type="button" onclick={() => sortMap(column.key)}>{column.label}<span aria-hidden="true">{mapSort === column.key ? mapDirection === 'asc' ? '↑' : '↓' : '↕'}</span></button></th>{/each}</tr></thead>
|
||||
<tbody>{#each mapRows as map (JSON.stringify([map.game, map.map]))}<tr>
|
||||
<th scope="row">{map.map}<small>{gameName(map.game)} · {map.game.toUpperCase()}</small></th>
|
||||
<td class="player-hours"><span>{map.observed ? metricNumber(map.playerHours) : '—'}</span><div class="comparison" aria-hidden="true"><i style:width={`${maxPlayerHours ? map.playerHours / maxPlayerHours * 100 : 0}%`}></i></div></td>
|
||||
<td>{metricDuration(map.active)}<small>server time</small></td>
|
||||
<td>{#if map.capacityObserved > 0}{metricDuration(map.full)}<small>{metricPercent(map.full, map.capacityObserved)} during {metricDuration(map.capacityObserved)} known</small>{:else}<span class="unavailable">Unknown</span><small>No known capacity time</small>{/if}</td>
|
||||
<td>{metricDuration(map.capacityObserved)}<small>map time</small></td>
|
||||
</tr>{/each}</tbody>
|
||||
</table></div>
|
||||
{:else}<EmptyState title={maps.length ? 'No maps match this search' : 'No map observations'} description={maps.length ? 'Try a recorded map name or game.' : 'Map rows appear when server observations include a map.'}>{#snippet actions()}{#if mapQuery}<Button size="sm" onclick={() => updateContext({ mapQ: '' })}>Clear map search</Button>{/if}{/snippet}</EmptyState>{/if}
|
||||
<div class="footer"><span>{mapRows.length} of {maps.length} maps</span><span>Missing player limits leave capacity unknown, even when occupancy is available.</span></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="server-metrics-title">
|
||||
<div class="heading"><div><h2 id="server-metrics-title">Server utilization</h2><p>Time with players as a share of each server’s known time.</p></div>
|
||||
<div class="search"><Field label="Find a server" hint="Search this table only."><Input type="search" value={serverQuery} placeholder="Server name or map…" oninput={event => updateContext({ serverQ: (event.currentTarget as HTMLInputElement).value })} /></Field></div>
|
||||
</div>
|
||||
{#if serverRows.length}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Server utilization table" tabindex="0"><table>
|
||||
<thead><tr>{#each serverColumns as column}<th scope="col" aria-sort={serverSort === column.key ? serverDirection === 'asc' ? 'ascending' : 'descending' : undefined}><button type="button" onclick={() => sortServer(column.key)}>{column.label}<span aria-hidden="true">{serverSort === column.key ? serverDirection === 'asc' ? '↑' : '↓' : '↕'}</span></button></th>{/each}</tr></thead>
|
||||
<tbody>{#each serverRows as server (server.server)}<tr>
|
||||
<th scope="row"><ServerIdentity name={server.server} /><small class="server-id">{server.server}</small></th>
|
||||
<td>{metricPercent(server.active, server.observed)}<small>{metricDuration(server.active)} of {metricDuration(server.observed)} known</small></td>
|
||||
<td>{metricPercent(server.idle, server.observed)}<small>{metricDuration(server.idle)} idle</small></td>
|
||||
<td>{metricDuration(server.observed)}</td><td>{metricDuration(server.unknown)}</td>
|
||||
</tr>{/each}</tbody>
|
||||
</table></div>
|
||||
{:else}<EmptyState title="No servers match this search" description="Try a friendly map name or technical server ID.">{#snippet actions()}<Button size="sm" onclick={() => updateContext({ serverQ: '' })}>Clear server search</Button>{/snippet}</EmptyState>{/if}
|
||||
<div class="footer"><span>{serverRows.length} of {servers.length} historical server names</span><span>A dash means no known time. Names may include more than one registration.</span></div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); min-width: 0; overflow: hidden; }
|
||||
.heading { padding: var(--space-5); display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; }
|
||||
h2 { font-size: var(--text-lg); margin: 0; }
|
||||
.heading p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.search { flex: 0 1 230px; min-width: 180px; }
|
||||
.definition { margin: 0; padding: 0 var(--space-5) var(--space-4); color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.6; max-width: 110ch; }
|
||||
.table-wrap { width: 100%; overflow-x: auto; }
|
||||
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -3px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
|
||||
th, td { padding: 14px var(--space-4); border-bottom: 1px solid var(--border-subtle); text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
th:first-child { text-align: left; min-width: 185px; max-width: 280px; white-space: normal; overflow-wrap: anywhere; }
|
||||
thead th { color: var(--text-secondary); background: var(--surface-sunken); font-size: var(--text-xs); }
|
||||
thead button { display: inline-flex; justify-content: flex-end; align-items: center; gap: 6px; color: inherit; background: transparent; font: inherit; padding: 5px 0; border: 0; cursor: pointer; text-align: inherit; white-space: nowrap; }
|
||||
thead button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 4px; }
|
||||
thead button span { color: var(--text-tertiary); }
|
||||
thead th[aria-sort] { color: var(--text-primary); }
|
||||
tbody th { font-weight: var(--weight-medium); }
|
||||
tbody tr:hover { background: color-mix(in oklab, var(--surface-raised) 35%, transparent); }
|
||||
small { display: block; margin-top: 4px; font-size: 11px; font-weight: normal; line-height: 1.5; color: var(--text-tertiary); }
|
||||
.server-id { font-family: var(--font-mono); font-size: 10px; }
|
||||
.unavailable { color: var(--text-secondary); }
|
||||
.player-hours { min-width: 120px; }
|
||||
.comparison { height: 3px; width: 90px; margin: 8px 0 0 auto; background: var(--surface-raised); border-radius: 3px; }
|
||||
.comparison i { display: block; height: 100%; background: var(--chart-active); border-radius: inherit; opacity: .7; }
|
||||
.footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2) var(--space-4); padding: var(--space-4) var(--space-5); font-size: 11px; color: var(--text-tertiary); line-height: 1.6; }
|
||||
@media (max-width: 640px) {
|
||||
.heading { padding: var(--space-4); }
|
||||
.search { flex: 1 1 100%; }
|
||||
.definition { padding-inline: var(--space-4); }
|
||||
th, td { padding: 12px; font-size: var(--text-xs); }
|
||||
th:first-child { position: sticky; left: 0; z-index: 1; min-width: 130px; max-width: 150px; background: var(--surface); box-shadow: 1px 0 var(--border-strong); }
|
||||
thead th:first-child { background: var(--surface-sunken); }
|
||||
.footer { padding: var(--space-3) var(--space-4); }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user