This commit is contained in:
@@ -0,0 +1,614 @@
|
||||
<script lang="ts">
|
||||
import type { MetricPoint, MetricsDays, PlayerMetrics } from '$lib/metrics.js';
|
||||
import { createTimeFormatter } from '$lib/time-zone.js';
|
||||
import {
|
||||
metricLobbyAxis,
|
||||
metricNumber,
|
||||
metricPeriodLabels,
|
||||
metricPlayerPaths
|
||||
} from '$ui/metrics.js';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
|
||||
let {
|
||||
points,
|
||||
players,
|
||||
days,
|
||||
since,
|
||||
timeZone
|
||||
}: {
|
||||
points: MetricPoint[];
|
||||
players: PlayerMetrics;
|
||||
days: MetricsDays;
|
||||
since: number;
|
||||
timeZone: string;
|
||||
} = $props();
|
||||
let selectedAt = $state<number | null>(null);
|
||||
let announcement = $state('');
|
||||
const format = $derived(createTimeFormatter(timeZone));
|
||||
const selectedIndex = $derived.by(() => {
|
||||
const existing = points.findIndex((point) => point.at === selectedAt);
|
||||
if (existing >= 0) return existing;
|
||||
for (let index = points.length - 1; index >= 0; index--)
|
||||
if (!partial(points[index]) && points[index].uniquePlayers !== null) return index;
|
||||
return Math.max(0, points.length - 1);
|
||||
});
|
||||
const selected = $derived(points[selectedIndex]);
|
||||
const axis = $derived(
|
||||
metricLobbyAxis(
|
||||
Math.max(
|
||||
0,
|
||||
...points.flatMap((point) => [point.uniquePlayers ?? 0, point.newPlayers ?? 0])
|
||||
)
|
||||
)
|
||||
);
|
||||
const uniquePath = $derived(
|
||||
metricPlayerPaths(
|
||||
points.map((point) => point.uniquePlayers),
|
||||
axis.ceiling
|
||||
)
|
||||
);
|
||||
const newPath = $derived(
|
||||
metricPlayerPaths(
|
||||
points.map((point) => point.newPlayers),
|
||||
axis.ceiling
|
||||
)
|
||||
);
|
||||
const periodLabels = $derived(metricPeriodLabels(points, days, timeZone));
|
||||
const periodOptions = $derived(
|
||||
points.map((point) => ({
|
||||
value: String(point.at),
|
||||
label: `${format.period(point.startAt)}${partial(point) ? ' · partial' : ''}${point.uniquePlayers === null ? ' · no retained history' : ''}`
|
||||
}))
|
||||
);
|
||||
const limitedHistory = $derived(
|
||||
players.historyStartAt !== null && players.historyStartAt > since
|
||||
);
|
||||
|
||||
function partial(point: MetricPoint) {
|
||||
return point.partial || point.playerHistoryPartial;
|
||||
}
|
||||
function count(value: number | null) {
|
||||
return value === null ? '—' : metricNumber(value);
|
||||
}
|
||||
function summary(point: MetricPoint) {
|
||||
return point.uniquePlayers === null
|
||||
? 'No retained player history'
|
||||
: `${count(point.uniquePlayers)} unique players · ${count(point.newPlayers)} new`;
|
||||
}
|
||||
function selectPeriod(at: number) {
|
||||
selectedAt = at;
|
||||
const point = points.find((item) => item.at === at);
|
||||
if (point)
|
||||
announcement = `${format.period(point.startAt)} to ${format.period(point.endAt)}${partial(point) ? ', partial period' : ''}. ${summary(point)}`;
|
||||
}
|
||||
function move(direction: number) {
|
||||
const point = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))];
|
||||
if (point) selectPeriod(point.at);
|
||||
}
|
||||
function chartKey(event: KeyboardEvent, index: number) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? points.length - 1
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1))
|
||||
);
|
||||
selectPeriod(points[next].at);
|
||||
(event.currentTarget as HTMLElement).parentElement
|
||||
?.querySelectorAll<HTMLButtonElement>('button')
|
||||
[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-labelledby="metrics-players-title">
|
||||
<div class="heading">
|
||||
<div>
|
||||
<h2 id="metrics-players-title">Players over time</h2>
|
||||
<p>
|
||||
Distinct players seen in each {days === 1 ? 'hour' : '24-hour period'}, and players
|
||||
seen for the first time. A returning player counts once per period.
|
||||
</p>
|
||||
</div>
|
||||
<div class="totals">
|
||||
<dl aria-label="Players across the selected range">
|
||||
<div>
|
||||
<dt>Unique players</dt>
|
||||
<dd>{count(players.uniquePlayers)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>New players</dt>
|
||||
<dd>{count(players.newPlayers)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p>
|
||||
Last {days === 1 ? '24 hours' : `${days} days`}{limitedHistory
|
||||
? ' · Partial history'
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span><i class="unique-key"></i>Unique players</span>
|
||||
<span><i class="new-key"></i>New players</span>
|
||||
<span><i class="partial-key"></i>Partial period</span>
|
||||
</div>
|
||||
{#if players.historyStartAt === null}
|
||||
<p class="history-note">
|
||||
No retained player history yet. Counts will appear after match participation is
|
||||
recorded.
|
||||
</p>
|
||||
{:else if limitedHistory}
|
||||
<p class="history-note">
|
||||
Retained player history starts {format.period(players.historyStartAt)}. Earlier periods
|
||||
are gaps; range totals cover available history only.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="axis-title">Players</div>
|
||||
<div class="chart-grid">
|
||||
<div class="y-axis" aria-hidden="true">
|
||||
{#each axis.ticks as value}<span style:top={`${(1 - value / axis.ceiling) * 100}%`}
|
||||
>{metricNumber(value)}</span
|
||||
>{/each}
|
||||
</div>
|
||||
<div
|
||||
class="plot"
|
||||
role="group"
|
||||
aria-label="Players by period. Select a point or use the period selector below."
|
||||
>
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
||||
{#each axis.ticks as value}<line
|
||||
x1="0"
|
||||
x2="100"
|
||||
y1={100 - (value / axis.ceiling) * 100}
|
||||
y2={100 - (value / axis.ceiling) * 100}
|
||||
class="grid-line"
|
||||
/>{/each}
|
||||
<path d={uniquePath.area} class="area" />
|
||||
<path d={uniquePath.line} class="unique-line" />
|
||||
<path d={newPath.line} class="new-line" />
|
||||
</svg>
|
||||
{#each points as point, index (point.at)}
|
||||
<button
|
||||
type="button"
|
||||
class="period"
|
||||
class:selected={point.at === selected?.at}
|
||||
class:partial={partial(point)}
|
||||
tabindex={index === selectedIndex ? 0 : -1}
|
||||
aria-pressed={point.at === selected?.at}
|
||||
aria-label={`${format.period(point.startAt)}${partial(point) ? ', partial period' : ''}: ${summary(point)}`}
|
||||
title={`${format.period(point.startAt)} – ${format.period(point.endAt)}: ${summary(point)}`}
|
||||
onclick={() => selectPeriod(point.at)}
|
||||
onkeydown={(event) => chartKey(event, index)}
|
||||
>
|
||||
{#if point.uniquePlayers !== null}<span
|
||||
class="dot unique-dot"
|
||||
style:top={`${100 - (point.uniquePlayers / axis.ceiling) * 100}%`}
|
||||
></span>{/if}
|
||||
{#if point.newPlayers !== null}<span
|
||||
class="dot new-dot"
|
||||
style:top={`${100 - (point.newPlayers / axis.ceiling) * 100}%`}
|
||||
></span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="x-axis" class:with-dates={days === 30} aria-hidden="true">
|
||||
{#each periodLabels as item}<span
|
||||
style:left={`${item.position}%`}
|
||||
title={format.period(points[item.index].startAt)}
|
||||
>{item.label}{#if item.date}<small>{item.date}</small>{/if}</span
|
||||
>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<p class="period-note">
|
||||
{days === 1 ? 'Hours' : 'Weekdays'} show when each period starts in {timeZone.replaceAll(
|
||||
'_',
|
||||
' '
|
||||
)}.{days === 1 ? '' : ' Daily periods are 24-hour windows.'} Partial periods contain less history.
|
||||
</p>
|
||||
{#if selected}
|
||||
<div class="selection-summary" aria-label="Selected player period summary">
|
||||
<time
|
||||
>{format.period(selected.startAt)}{partial(selected)
|
||||
? ' · Partial period'
|
||||
: ''}</time
|
||||
><span>{summary(selected)}</span>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Inspect a period</summary>
|
||||
<div class="period-controls">
|
||||
<div class="period-select">
|
||||
<Field label={`Inspect a player period · ${timeZone.replaceAll('_', ' ')}`}
|
||||
><Select
|
||||
value={String(selected.at)}
|
||||
options={periodOptions}
|
||||
onchange={(event) =>
|
||||
selectPeriod(
|
||||
Number((event.currentTarget as HTMLSelectElement).value)
|
||||
)}
|
||||
/></Field
|
||||
>
|
||||
</div>
|
||||
<div class="step-buttons">
|
||||
<Button size="sm" disabled={selectedIndex === 0} onclick={() => move(-1)}
|
||||
>← Previous</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={selectedIndex === points.length - 1}
|
||||
onclick={() => move(1)}>Next →</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p class="readout" aria-label="Selected player period details">
|
||||
{format.period(selected.startAt)} – {format.period(selected.endAt)}{partial(
|
||||
selected
|
||||
)
|
||||
? ' · Partial period'
|
||||
: ''}<br />{summary(selected)}{selected.playerHistoryPartial
|
||||
? ' · Retained history begins during this period.'
|
||||
: ''}
|
||||
</p>
|
||||
</details>
|
||||
{/if}
|
||||
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
|
||||
<details>
|
||||
<summary>View exact time data</summary>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Player time data" tabindex="0">
|
||||
<table>
|
||||
<caption
|
||||
>Players by period in {timeZone.replaceAll('_', ' ')}. A dash means no retained
|
||||
history.</caption
|
||||
>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Period</th><th scope="col">Unique players</th><th
|
||||
scope="col">New players</th
|
||||
></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each points as point (point.at)}<tr
|
||||
><th scope="row"
|
||||
>{format.period(point.startAt)}<small
|
||||
>to {format.period(point.endAt)}{partial(point)
|
||||
? ' · Partial'
|
||||
: ''}{point.uniquePlayers === null
|
||||
? ' · No retained history'
|
||||
: point.playerHistoryPartial
|
||||
? ' · History begins'
|
||||
: ''}</small
|
||||
></th
|
||||
><td>{count(point.uniquePlayers)}</td><td>{count(point.newPlayers)}</td
|
||||
></tr
|
||||
>{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
<p class="method-note">
|
||||
Range totals count each player once, so period counts do not add up to the unique total.
|
||||
“New” means first recorded across both games; players already playing when recording began
|
||||
also count as new. Missing or expired matches cannot be reconstructed.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
min-width: 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
.heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.heading > div:first-child {
|
||||
flex: 1 1 280px;
|
||||
min-width: 0;
|
||||
}
|
||||
.heading p {
|
||||
margin: var(--space-2) 0 var(--space-4);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.totals dl {
|
||||
display: flex;
|
||||
gap: var(--space-5);
|
||||
margin: 0;
|
||||
}
|
||||
dt {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
dd {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--weight-semibold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.totals p {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.legend span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.legend i {
|
||||
display: block;
|
||||
width: 16px;
|
||||
}
|
||||
.unique-key {
|
||||
border-top: 2px solid var(--chart-active);
|
||||
}
|
||||
.new-key {
|
||||
border-top: 2px dashed var(--text-secondary);
|
||||
}
|
||||
.legend .partial-key {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 1px solid var(--text-secondary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.axis-title {
|
||||
margin-top: var(--space-5);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
column-gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.y-axis {
|
||||
position: relative;
|
||||
height: 210px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.y-axis span {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.plot {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 210px;
|
||||
}
|
||||
svg {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
svg path,
|
||||
svg line {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.grid-line {
|
||||
stroke: var(--border-subtle);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.area {
|
||||
fill: var(--chart-active);
|
||||
opacity: 0.12;
|
||||
}
|
||||
.unique-line,
|
||||
.new-line {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.unique-line {
|
||||
stroke: var(--chart-active);
|
||||
}
|
||||
.new-line {
|
||||
stroke: var(--text-secondary);
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
.period {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.period.selected {
|
||||
background: color-mix(in srgb, var(--text-secondary) 6%, transparent);
|
||||
}
|
||||
.period:hover {
|
||||
background: color-mix(in srgb, var(--chart-active) 10%, transparent);
|
||||
}
|
||||
.period:focus-visible {
|
||||
outline: 2px solid var(--accent-primary-hover);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.dot {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.unique-dot {
|
||||
background: var(--chart-active);
|
||||
border-color: var(--chart-active);
|
||||
}
|
||||
.new-dot {
|
||||
background: var(--text-secondary);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
.partial .dot {
|
||||
background: var(--surface);
|
||||
}
|
||||
.x-axis {
|
||||
grid-column: 2;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
height: 1.6em;
|
||||
margin-top: 10px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.x-axis.with-dates {
|
||||
height: 3em;
|
||||
}
|
||||
.x-axis > span {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: normal;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.period-note,
|
||||
.method-note,
|
||||
.history-note {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-body);
|
||||
margin: var(--space-3) 0 0;
|
||||
}
|
||||
.history-note {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.selection-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
margin-top: var(--space-4);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
details {
|
||||
margin-top: var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.period-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.period-select {
|
||||
flex: 1 1 250px;
|
||||
max-width: 450px;
|
||||
}
|
||||
.step-buttons {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
.readout {
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.table-wrap {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.table-wrap:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
caption {
|
||||
text-align: left;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
th:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
thead th {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
tbody th {
|
||||
font-weight: var(--weight-regular);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.panel {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.legend {
|
||||
gap: 10px;
|
||||
}
|
||||
.chart-grid {
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
column-gap: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user