Add unique and new player metrics (Refs #142)
CI / checks (push) Successful in 4m53s

This commit is contained in:
2026-10-10 18:47:56 -04:00
parent 7fcf1cded2
commit e3e740cea3
16 changed files with 1212 additions and 3 deletions
+15 -1
View File
@@ -92,7 +92,18 @@ export interface RunningMetrics {
capturedHours: number;
}
export interface MetricPoint extends Utilization {
export interface PlayerCounts {
/** Distinct GUIDs, across games; null means this interval predates retained history. */
uniquePlayers: number | null;
newPlayers: number | null;
}
export interface PlayerMetrics extends PlayerCounts {
/** Global earliest retained match, independent of the selected game/map. */
historyStartAt: number | null;
}
export interface MetricPoint extends Utilization, PlayerCounts {
/** UTC bucket boundary, including when the selected interval starts later. */
at: number;
startAt: number;
@@ -100,6 +111,8 @@ export interface MetricPoint extends Utilization {
partial: boolean;
/** Captured scope wall time, excluding collection gaps. */
capturedHours: number;
/** Retained player history begins inside this period. */
playerHistoryPartial: boolean;
}
export interface PreviousMetrics {
@@ -164,6 +177,7 @@ export interface MetricsData {
/** Distinct recorded names sampled in this scope/window, including zero elapsed time. */
sampledServerCount: number;
matches: MatchMetrics;
players: PlayerMetrics;
autoscaling: AutoscalingMetrics;
maps: MapMetrics[];
points: MetricPoint[];
+6
View File
@@ -7,6 +7,7 @@ import { migrateFileBlobs } from './file-blobs.js';
import { installPerformanceIndexes } from './performance-indexes.js';
import {
installMetricsMatchRevisions,
installMetricsPlayerRevisions,
installMetricsPolicyRevisions,
installMetricsProvisioningRevisions,
installReportRevisions
@@ -85,6 +86,11 @@ export const migrations: readonly Migration[] = [
version: 15,
name: 'metrics provisioning report revisions',
run: installMetricsProvisioningRevisions
},
{
version: 16,
name: 'metrics player history and report revisions',
run: installMetricsPlayerRevisions
}
];
+87
View File
@@ -0,0 +1,87 @@
import { db } from '../db/index.js';
import type { MetricPoint, MetricsScope, PlayerMetrics } from '../../metrics.js';
/** Populate the shared periods from retained participation, independently of occupancy samples. */
export function readPlayerMetrics(
scope: MetricsScope,
since: number,
now: number,
points: MetricPoint[]
): PlayerMetrics {
const handle = db();
const nowIso = new Date(now).toISOString();
const sinceIso = new Date(since).toISOString();
const { first } = handle
.prepare('SELECT MIN(started_at) AS first FROM leaderboard_matches WHERE started_at <= ?')
.get(nowIso) as { first: string | null };
const historyStartAt = first === null ? null : Date.parse(first);
const periods = points.map((point) => {
const available =
historyStartAt !== null &&
(historyStartAt < point.endAt || (historyStartAt === now && point.endAt === now));
point.uniquePlayers = available ? 0 : null;
point.newPlayers = available ? 0 : null;
point.playerHistoryPartial = available && historyStartAt! > point.startAt;
return { unique: new Set<string>(), fresh: new Set<string>() };
});
if (historyStartAt === null) return { historyStartAt, uniquePlayers: null, newPlayers: null };
const conditions = [
'm.last_received_at >= ?',
'm.started_at <= ?',
'p.last_seen_at >= ?',
'p.first_seen_at <= ?'
];
const params = [sinceIso, nowIso, sinceIso, nowIso];
if (scope.game !== null) {
conditions.push('m.game = ?');
params.push(scope.game);
}
if (scope.map !== null) {
conditions.push('m.map_name = ?');
params.push(scope.map);
}
// The match clock and participation PK bound this scan to recent matches.
// The GUID/first-seen index looks up the durable cross-game identity without
// reclassifying a returning player after their older matches expire.
const rows = handle
.prepare(
`SELECT p.guid, p.first_seen_at AS start, p.last_seen_at AS end,
(SELECT MIN(first_seen_at) FROM leaderboard_players WHERE guid=p.guid) AS firstSeen
FROM leaderboard_matches m JOIN leaderboard_match_players p ON p.match_id=m.id
WHERE ${conditions.join(' AND ')}`
)
.iterate(...params) as Iterable<{
guid: string;
start: string;
end: string;
firstSeen: string | null;
}>;
const unique = new Set<string>();
const fresh = new Set<string>();
for (const row of rows) {
const start = Date.parse(row.start);
const end = Date.parse(row.end);
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) continue;
unique.add(row.guid);
// Scope the original sighting, never the first later visit to this game/map.
const isNew =
row.firstSeen !== null && Date.parse(row.firstSeen) === start && start >= since;
if (isNew) fresh.add(row.guid);
for (let index = 0; index < points.length; index++) {
const point = points[index];
// Internal boundaries belong to the next period. Include a sighting
// exactly at the report's inclusive right edge in its final period.
const startsBeforeEnd = start < point.endAt || (start === now && point.endAt === now);
if (point.uniquePlayers === null || end < point.startAt || !startsBeforeEnd) continue;
periods[index].unique.add(row.guid);
if (isNew && start >= point.startAt) periods[index].fresh.add(row.guid);
}
}
for (let index = 0; index < points.length; index++) {
if (points[index].uniquePlayers === null) continue;
points[index].uniquePlayers = periods[index].unique.size;
points[index].newPlayers = periods[index].fresh.size;
}
return { historyStartAt, uniquePlayers: unique.size, newPlayers: fresh.size };
}
+6
View File
@@ -1,5 +1,6 @@
import { fleetSettings } from '../settings.js';
import { db } from '../db/index.js';
import { readPlayerMetrics } from './metrics-players.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js';
import { zombieMaps } from '../../map-aliases.js';
import { readAutoscalingMetrics } from './metrics-autoscaling.js';
@@ -314,9 +315,13 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
endAt: end,
partial: start !== at || end !== at + bucketMs,
capturedHours: 0,
uniquePlayers: null,
newPlayers: null,
playerHistoryPartial: false,
...emptyUtilization()
});
}
const players = readPlayerMetrics(scope, since, now, points);
const cells: HeatmapCell[] = Array.from({ length: 7 * 24 }, (_, index) => ({
weekday: Math.floor(index / 24),
@@ -517,6 +522,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
summary,
running,
matches: matchMetrics(totalMatches),
players,
autoscaling: autoscaling.summary,
sampledServerCount: sampledServers.size,
maps,
+21
View File
@@ -1,5 +1,26 @@
import type Database from 'better-sqlite3';
/** Player trends also change when a participation interval extends into another period. */
export function installMetricsPlayerRevisions(handle: Database.Database) {
handle.exec(`CREATE INDEX IF NOT EXISTS idx_leaderboard_players_guid_first_seen
ON leaderboard_players(guid,first_seen_at);
DROP TRIGGER IF EXISTS report_metrics_leaderboard_match_players_UPDATE;
CREATE TRIGGER report_metrics_leaderboard_match_players_UPDATE
AFTER UPDATE ON leaderboard_match_players
WHEN OLD.match_id IS NOT NEW.match_id OR OLD.guid IS NOT NEW.guid
OR OLD.first_seen_at IS NOT NEW.first_seen_at OR OLD.last_seen_at IS NOT NEW.last_seen_at
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
const condition =
action === 'UPDATE'
? 'WHEN OLD.guid IS NOT NEW.guid OR OLD.first_seen_at IS NOT NEW.first_seen_at'
: '';
handle.exec(`CREATE TRIGGER IF NOT EXISTS report_metrics_leaderboard_players_${action}
AFTER ${action} ON leaderboard_players ${condition}
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
}
}
/** Provisioning outcomes remain report inputs even after the policy is removed. */
export function installMetricsProvisioningRevisions(handle: Database.Database) {
for (const table of ['server_deployments', 'provisioning_sources'])
@@ -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>
+10
View File
@@ -32,6 +32,11 @@ const autoscaling = (value: unknown) =>
['readinessMeanSeconds', 'readinessMaxSeconds'].every(
(key) => value[key] === null || nonnegative(value[key])
);
const players = (value: unknown) =>
record(value) &&
['uniquePlayers', 'newPlayers'].every(
(key) => value[key] === null || (nonnegative(value[key]) && Number.isInteger(value[key]))
);
const policy = (value: unknown) =>
value === null ||
(record(value) &&
@@ -51,6 +56,9 @@ function validReport(value: unknown): value is MetricsData {
!totals(value.summary) ||
!running(value.running) ||
!matches(value.matches) ||
!record(value.players) ||
!players(value.players) ||
!timestamp(value.players.historyStartAt) ||
!autoscaling(value.autoscaling)
)
return false;
@@ -95,6 +103,8 @@ function validReport(value: unknown): value is MetricsData {
numeric(item.startAt) &&
numeric(item.endAt) &&
nonnegative(item.capturedHours) &&
players(item) &&
typeof item.playerHistoryPartial === 'boolean' &&
typeof item.partial === 'boolean'
)
)
+26
View File
@@ -1,5 +1,31 @@
const numberFormat = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
/** Separate SVG paths at unavailable periods, including isolated recorded points. */
export function metricPlayerPaths(values: readonly (number | null)[], ceiling: number) {
const segments: { x: number; y: number }[][] = [];
let segment: { x: number; y: number }[] = [];
for (let index = 0; index < values.length; index++) {
const value = values[index];
if (value === null) {
segment = [];
continue;
}
if (!segment.length) segments.push(segment);
segment.push({
x: ((index + 0.5) / values.length) * 100,
y: 100 - (value / ceiling) * 100
});
}
const line = (points: typeof segment) =>
points.map(({ x, y }, index) => `${index ? 'L' : 'M'}${x},${y}`).join(' ');
return {
line: segments.map(line).join(' '),
area: segments
.map((points) => `${line(points)} L${points.at(-1)!.x},100 L${points[0].x},100 Z`)
.join(' ')
};
}
/** Label the actual bucket start in the chosen timezone; never imply local calendar-day buckets. */
export function metricPeriodLabels(
points: readonly { startAt: number }[],