Files
electric-cherry/src/lib/ui/components/metrics/MetricsTables.svelte
T

502 lines
20 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 { page } from '$app/state';
import { replaceState } from '$app/navigation';
import type { MapMetrics, ServerMetrics } from '$lib/metrics.js';
import {
metricPage,
compareMetricValues,
metricDuration,
metricNumber,
metricPercent
} from '$ui/metrics.js';
import { serverTitle } from '$ui/server-labels.js';
import { contextHref } from '$lib/navigation-context.js';
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,
view = 'maps'
}: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
const mapColumns = [
{ key: 'map', label: 'Map' },
{ key: 'playerHours', label: 'Player-hours' },
{ key: 'active', label: 'With players' },
{ key: 'full', label: 'All lobbies full' },
{ key: 'details', label: 'Details' }
] as const;
const serverColumns = [
{ key: 'server', label: 'Instance' },
{ key: 'utilization', label: 'Time with players' },
{ key: 'observed', label: 'Observed time' },
{ key: 'unknown', label: 'Unknown time' },
{ key: 'details', label: 'Details' }
] 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 !== 'details' && column.key === params.get('mapSort')
)?.key ?? 'playerHours'
);
const serverSort = $derived(
serverColumns.find(
(column) => column.key !== 'details' && 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);
})
);
const mapPage = $derived(metricPage(mapRows, params.get('mapPage')));
const serverPage = $derived(metricPage(serverRows, params.get('serverPage')));
const currentPage = $derived(view === 'maps' ? mapPage : serverPage);
const pageKey = $derived(view === 'maps' ? 'mapPage' : 'serverPage');
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 {
if (key === 'details') return null;
if (key === 'full') return map.capacityObserved ? map.full / map.capacityObserved : null;
return (key === 'playerHours' || key === 'active') && !map.observed ? null : map[key];
}
function serverValue(server: ServerMetrics, key: Exclude<ServerSort, 'server'>): number | null {
if (key === 'details') return null;
if (key === 'utilization')
return server.observed > 0 ? server.active / 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);
}
if ('mapQ' in changes || 'mapSort' in changes) url.searchParams.delete('mapPage');
if ('serverQ' in changes || 'serverSort' in changes) url.searchParams.delete('serverPage');
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-label={view === 'maps' ? 'Map metrics' : 'Instance metrics'}>
<div class="heading">
<h2>{view === 'maps' ? 'Map play time and capacity' : 'Instance utilization'}</h2>
<div class="search">
<Field label={view === 'maps' ? 'Find a map' : 'Find an instance'}
><Input
type="search"
value={view === 'maps' ? mapQuery : serverQuery}
placeholder={view === 'maps' ? 'Map or game…' : 'Instance name or map…'}
oninput={(event) =>
updateContext({
[view === 'maps' ? 'mapQ' : 'serverQ']: (
event.currentTarget as HTMLInputElement
).value
})}
/></Field
>
</div>
</div>
{#if currentPage.total}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div
class="table-wrap"
role="region"
aria-label={view === 'maps' ? 'Map metrics table' : 'Instance metrics table'}
tabindex="0"
>
<table>
<thead
><tr
>{#each view === 'maps' ? mapColumns : serverColumns as column}
<th
scope="col"
aria-sort={column.key === 'details'
? undefined
: (view === 'maps' ? mapSort : serverSort) === column.key
? (view === 'maps' ? mapDirection : serverDirection) === 'asc'
? 'ascending'
: 'descending'
: undefined}
>
{#if column.key === 'details'}Details{:else}<button
type="button"
onclick={() =>
view === 'maps'
? sortMap(column.key as MapSort)
: sortServer(column.key as ServerSort)}
>{column.label}<span aria-hidden="true"
>{(view === 'maps' ? mapSort : serverSort) ===
column.key
? (view === 'maps'
? mapDirection
: serverDirection) === 'asc'
? '↑'
: '↓'
: '↕'}</span
></button
>{/if}
</th>
{/each}</tr
></thead
>
<tbody>
{#if view === 'maps'}
{#each mapPage.rows as map (JSON.stringify([map.game, map.map]))}<tr>
<th scope="row"
>{map.map}<small
>{gameName(map.game)}{map.observed < 1 ||
map.capacityObserved < 1 ||
map.unknown > map.observed / 4
? ' · Limited evidence'
: ''}</small
></th
>
<td class="player-hours"
>{map.observed ? metricNumber(map.playerHours) : '—'}
<div class="comparison" aria-hidden="true">
<i
style:width={`${maxPlayerHours ? (map.playerHours / maxPlayerHours) * 100 : 0}%`}
></i>
</div></td
>
<td>{map.observed ? metricDuration(map.active) : '—'}</td>
<td>{metricPercent(map.full, map.capacityObserved)}</td>
<td
><details>
<summary
><span class="sr-only">{map.map} </span>Details</summary
>
<dl>
<dt>Observed instance time</dt>
<dd>{metricDuration(map.observed)}</dd>
<dt>Unknown status</dt>
<dd>{metricDuration(map.unknown)}</dd>
<dt>All lobbies full</dt>
<dd>
{map.capacityObserved
? `${metricDuration(map.full)} of ${metricDuration(map.capacityObserved)} known map time`
: 'Capacity data unavailable'}
</dd>
</dl>
</details></td
>
</tr>{/each}
{:else}
{#each serverPage.rows as server (server.server)}<tr>
<th scope="row"
>{#if server.currentRegistered}<a
href={contextHref(
`/servers/${encodeURIComponent(server.server)}`,
`/metrics${contextSearch}`
)}
title="Open current instance with this name"
>{serverTitle(server.server)}</a
>{:else}{serverTitle(server.server)}<small>Historical</small
>{/if}</th
>
<td>{metricPercent(server.active, server.observed)}</td><td
>{metricDuration(server.observed)}</td
><td>{metricDuration(server.unknown)}</td>
<td
><details>
<summary
><span class="sr-only"
>{serverTitle(server.server)}
</span>Details</summary
>
<dl>
<dt>Recorded name</dt>
<dd><code>{server.server}</code></dd>
<dt>Time with players</dt>
<dd>{metricDuration(server.active)}</dd>
<dt>Idle</dt>
<dd>
{metricPercent(server.idle, server.observed)} · {metricDuration(
server.idle
)}
</dd>
</dl>
</details></td
>
</tr>{/each}
{/if}
</tbody>
</table>
</div>
{:else}<EmptyState
title={view === 'maps' ? 'No maps match this search' : 'No instances match this search'}
>{#snippet actions()}<Button
size="sm"
onclick={() => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}
>Clear search</Button
>{/snippet}</EmptyState
>{/if}
<div class="footer">
<span
>{currentPage.start}–{currentPage.end} of {currentPage.total}
{view === 'maps' ? 'maps' : 'instances'}</span
>
{#if currentPage.pages > 1}<div class="paging">
<Button
size="sm"
disabled={currentPage.page === 1}
onclick={() => updateContext({ [pageKey]: String(currentPage.page - 1) })}
>Previous</Button
><span>Page {currentPage.page} of {currentPage.pages}</span><Button
size="sm"
disabled={currentPage.page === currentPage.pages}
onclick={() => updateContext({ [pageKey]: String(currentPage.page + 1) })}
>Next</Button
>
</div>{/if}
</div>
{#if view === 'instances'}<p class="identity-note">
Grouped by recorded name; links open the current instance, which may differ from the
historical registration.
</p>{/if}
<details class="table-help">
<summary>About these metrics</summary>
<p>
{view === 'maps'
? 'All lobbies full uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand. A dash means unavailable, not zero.'
: 'Time with players excludes unknown status and collection gaps. Rows group historical observations by name and may span multiple registrations. Links open the current instance with that name.'}
</p>
</details>
</section>
<style>
.panel {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
min-width: 0;
overflow: hidden;
}
.heading {
padding: 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
h2 {
font-size: var(--text-lg);
margin: 0;
}
.search {
flex: 0 1 240px;
}
.table-wrap {
position: relative;
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: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
text-align: right;
font-variant-numeric: tabular-nums;
vertical-align: top;
}
th:first-child {
text-align: left;
min-width: 160px;
}
thead th {
color: var(--text-secondary);
background: var(--surface-sunken);
font-size: var(--text-xs);
white-space: nowrap;
}
thead button {
display: inline-flex;
gap: 6px;
color: inherit;
background: transparent;
font: inherit;
padding: 5px 0;
border: 0;
cursor: pointer;
white-space: nowrap;
}
thead button:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
tbody th {
font-weight: var(--weight-medium);
}
small {
display: block;
margin-top: 4px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.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;
}
.footer,
.paging {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.footer {
padding: 16px 20px;
}
details {
font-size: var(--text-xs);
color: var(--text-secondary);
}
summary {
cursor: pointer;
}
dl {
text-align: left;
min-width: 170px;
max-width: 300px;
}
dt {
margin-top: 10px;
}
dd {
margin: 4px 0;
color: var(--text-primary);
overflow-wrap: anywhere;
}
.identity-note {
margin: 0;
padding: 0 20px 12px;
color: var(--text-secondary);
font-size: var(--text-xs);
}
.table-help {
padding: 0 20px 16px;
}
.table-help p {
max-width: 90ch;
line-height: 1.6;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
@media (max-width: 640px) {
th,
td {
padding: 10px;
}
.search {
flex: 1 1 100%;
}
}
</style>