mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 23:48:52 -07:00
[www] stats
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
where,
|
||||
} from './api'
|
||||
import { ModerationPage } from './Moderation'
|
||||
import { StatsPage } from './Stats'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Hosts } from './api'
|
||||
@@ -836,6 +837,12 @@ export function App() {
|
||||
// on any of them falls through to the SPA shell; the page then gates itself on
|
||||
// the token's role, and every endpoint behind it re-checks.
|
||||
<ModerationPage account={account} path={path} search={search} navigate={navigate} />
|
||||
) : path === '/stats' ? (
|
||||
// Unlinked on purpose — nothing in the nav or footer points here; it's for whoever
|
||||
// is handed the URL. Public all the same, and a client-side route like the rest:
|
||||
// its data comes from `/api/stats/online`, which the existing `/api/*` allowlist
|
||||
// entry already sends to the Worker.
|
||||
<StatsPage search={search} navigate={navigate} />
|
||||
) : roomId !== null ? (
|
||||
<RoomPage account={account} roomId={roomId} navigate={navigate} />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { call } from './api'
|
||||
|
||||
import type { KeyboardEvent, PointerEvent } from 'react'
|
||||
|
||||
/**
|
||||
* The public stats page, at `/stats`: how many players are online, and how that has
|
||||
* moved. One series — the `online` samples the `match` presence cron writes to `stat` —
|
||||
* read from `www`'s own `/api/stats/online` (see www.app.ts), which folds them into
|
||||
* buckets so the quarter view doesn't ship 26,000 points.
|
||||
*
|
||||
* Its own file for the reason Moderation.tsx is: a whole surface rather than one more
|
||||
* form. The chart is hand-drawn SVG — one line doesn't justify a charting dependency in a
|
||||
* bundle whose homepage is a poster.
|
||||
*/
|
||||
|
||||
type Range = '24h' | '7d' | '30d' | '90d'
|
||||
|
||||
// Shortest first: "what's it like right now" is the question most visitors arrive with.
|
||||
const RANGES: Array<{ id: Range; label: string; title: string }> = [
|
||||
{ id: '24h', label: '24 hours', title: 'the last 24 hours' },
|
||||
{ id: '7d', label: '7 days', title: 'the last 7 days' },
|
||||
{ id: '30d', label: '30 days', title: 'the last 30 days' },
|
||||
{ id: '90d', label: '90 days', title: 'the last 90 days' },
|
||||
]
|
||||
|
||||
interface Point {
|
||||
/** Start of the bucket, epoch seconds. */
|
||||
t: number
|
||||
/** The bucket's peak. */
|
||||
players: number
|
||||
}
|
||||
|
||||
interface OnlineStats {
|
||||
range: Range
|
||||
bucketSeconds: number
|
||||
from: number
|
||||
to: number
|
||||
online: number
|
||||
peak: number
|
||||
average: number
|
||||
points: Point[]
|
||||
}
|
||||
|
||||
/** How often the page re-asks. The cron samples every five minutes; the live count moves faster. */
|
||||
const REFRESH_MS = 60_000
|
||||
|
||||
const DAY = 86_400
|
||||
|
||||
function rangeFromSearch(search: string): Range {
|
||||
const asked = new URLSearchParams(search).get('range')
|
||||
return RANGES.find((r) => r.id === asked)?.id ?? '24h'
|
||||
}
|
||||
|
||||
export function StatsPage({
|
||||
search,
|
||||
navigate,
|
||||
}: {
|
||||
search: string
|
||||
navigate: (to: string) => void
|
||||
}) {
|
||||
// The range lives in the URL so a view is linkable ("look at last month") and the back
|
||||
// button walks back through the ranges, the way the moderation panel's filters do.
|
||||
const range = rangeFromSearch(search)
|
||||
const [stats, setStats] = useState<OnlineStats | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const load = () =>
|
||||
call<OnlineStats>(`/api/stats/online?range=${range}`)
|
||||
.then((next) => {
|
||||
if (cancelled) return
|
||||
setStats(next)
|
||||
setError('')
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!cancelled) setError(e.message)
|
||||
})
|
||||
void load()
|
||||
// Only while someone is looking: a backgrounded tab polling a public endpoint all
|
||||
// night is traffic nobody reads.
|
||||
const timer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') void load()
|
||||
}, REFRESH_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [range])
|
||||
|
||||
// The previous range's numbers stay on screen (dimmed) until the new ones land, so
|
||||
// flipping ranges doesn't collapse the page to a spinner and back.
|
||||
const stale = stats !== null && stats.range !== range
|
||||
// Named from the data on screen, not the range just clicked: while the new one loads,
|
||||
// the old chart must not be captioned as the new window.
|
||||
const shown = RANGES.find((r) => r.id === stats?.range)?.title ?? ''
|
||||
|
||||
return (
|
||||
<main className="shell home stats">
|
||||
<header className="stats-head">
|
||||
<h1>Players online</h1>
|
||||
<p className="muted">How many people are in the game, now and lately.</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-ranges" role="group" aria-label="Time range">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
className={r.id === range ? 'active' : ''}
|
||||
aria-pressed={r.id === range}
|
||||
onClick={() => navigate(r.id === '24h' ? '/stats' : `/stats?range=${r.id}`)}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && stats === null ? (
|
||||
<p className="error">Couldn’t load the stats: {error}</p>
|
||||
) : stats === null ? (
|
||||
<p className="muted">Loading…</p>
|
||||
) : (
|
||||
<div className={stale ? 'stats-body stale' : 'stats-body'}>
|
||||
<dl className="stat-tiles">
|
||||
<div className="stat-tile">
|
||||
<dt>
|
||||
<span className="stat-live-dot" aria-hidden="true" />
|
||||
Online now
|
||||
</dt>
|
||||
<dd className="hero">{formatCount(stats.online)}</dd>
|
||||
</div>
|
||||
<div className="stat-tile">
|
||||
<dt>Peak</dt>
|
||||
<dd>{formatCount(stats.peak)}</dd>
|
||||
</div>
|
||||
<div className="stat-tile">
|
||||
<dt>Average</dt>
|
||||
<dd>{formatAverage(stats.average)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section className="card stats-card">
|
||||
<h2>{chartTitle(stats.bucketSeconds)}</h2>
|
||||
<p className="muted">Over {shown}, in your local time.</p>
|
||||
{stats.points.length === 0 ? (
|
||||
<p className="stats-empty muted">Nothing has been recorded in this window yet.</p>
|
||||
) : (
|
||||
<>
|
||||
<OnlineChart stats={stats} />
|
||||
<details className="stats-table">
|
||||
<summary>View as a table</summary>
|
||||
<div className="stats-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">When</th>
|
||||
<th scope="col">Players</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Newest first: the row someone opens a table for is the latest. */}
|
||||
{[...stats.points].reverse().map((p) => (
|
||||
<tr key={p.t}>
|
||||
<td>{bucketLabel(p.t, stats.bucketSeconds)}</td>
|
||||
<td>{formatCount(p.players)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
{error && <p className="error">Couldn’t refresh: {error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Formatting ------------------------------------------------------------
|
||||
|
||||
const countFormat = new Intl.NumberFormat()
|
||||
const formatCount = (n: number) => countFormat.format(n)
|
||||
|
||||
/** One decimal while the numbers are small enough for it to mean something. */
|
||||
const formatAverage = (n: number) =>
|
||||
new Intl.NumberFormat(undefined, { maximumFractionDigits: n < 100 ? 1 : 0 }).format(n)
|
||||
|
||||
/** Says what a point IS: a raw sample on the day view, a bucket's peak on the rest. */
|
||||
function chartTitle(bucketSeconds: number): string {
|
||||
if (bucketSeconds <= 300) return 'Players online, every 5 minutes'
|
||||
if (bucketSeconds >= DAY) return 'Peak players online, per day'
|
||||
const hours = bucketSeconds / 3600
|
||||
return hours === 1 ? 'Peak players online, per hour' : `Peak players online, per ${hours} hours`
|
||||
}
|
||||
|
||||
const timeFormat = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
|
||||
const dayTimeFormat = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
// A day bucket is a UTC day (the server folds on epoch seconds), so it is NAMED in UTC —
|
||||
// formatting its start in local time would call it "Sep 13, 8 PM" for half the world.
|
||||
const utcDayFormat = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
function bucketLabel(t: number, bucketSeconds: number): string {
|
||||
if (bucketSeconds >= DAY) return `${utcDayFormat.format(t * 1000)} (UTC)`
|
||||
const start = dayTimeFormat.format(t * 1000)
|
||||
if (bucketSeconds <= 300) return start
|
||||
return `${start} – ${timeFormat.format((t + bucketSeconds) * 1000)}`
|
||||
}
|
||||
|
||||
// ---- Axes ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Y ticks on whole, round numbers. Whole matters more than round here: this is a count of
|
||||
* people, and a young server's axis topping out at "2.5" reads as a bug.
|
||||
*/
|
||||
function yTicks(max: number): number[] {
|
||||
const rough = Math.max(max, 1) / 4
|
||||
const magnitude = 10 ** Math.floor(Math.log10(rough))
|
||||
const step = Math.max(1, [1, 2, 5, 10].map((m) => m * magnitude).find((s) => s >= rough) ?? 1)
|
||||
const top = Math.max(step, Math.ceil(max / step) * step)
|
||||
const ticks: number[] = []
|
||||
for (let v = 0; v <= top; v += step) ticks.push(v)
|
||||
return ticks
|
||||
}
|
||||
|
||||
const HOUR_STEPS = [1, 2, 3, 4, 6, 12]
|
||||
const DAY_STEPS = [1, 2, 3, 7, 14, 28]
|
||||
const hourTick = new Intl.DateTimeFormat(undefined, { hour: 'numeric' })
|
||||
const dayTick = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' })
|
||||
|
||||
/**
|
||||
* X ticks on LOCAL clock boundaries (midnights, or hours divisible by the step), as many
|
||||
* as fit: the step is the smallest whose labels get `minGap` pixels each, so the same
|
||||
* code serves a phone and a desktop without labels colliding.
|
||||
*/
|
||||
function xTicks(from: number, to: number, width: number, minGap: number) {
|
||||
const fits = (stepSeconds: number) => ((to - from) / stepSeconds) * minGap <= width
|
||||
const hours = HOUR_STEPS.find((h) => fits(h * 3600))
|
||||
const ticks: Array<{ t: number; label: string }> = []
|
||||
if (to - from <= 2 * DAY && hours !== undefined) {
|
||||
const cursor = new Date(from * 1000)
|
||||
cursor.setMinutes(0, 0, 0)
|
||||
for (; cursor.getTime() / 1000 <= to; cursor.setHours(cursor.getHours() + 1)) {
|
||||
const t = cursor.getTime() / 1000
|
||||
if (t >= from && cursor.getHours() % hours === 0) {
|
||||
ticks.push({ t, label: hourTick.format(cursor) })
|
||||
}
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
const days = DAY_STEPS.find((d) => fits(d * DAY)) ?? DAY_STEPS[DAY_STEPS.length - 1]!
|
||||
const midnights: Date[] = []
|
||||
const cursor = new Date(from * 1000)
|
||||
cursor.setHours(0, 0, 0, 0)
|
||||
// setDate rather than adding 86,400s: a DST day isn't 24 hours long.
|
||||
for (; cursor.getTime() / 1000 <= to; cursor.setDate(cursor.getDate() + 1)) {
|
||||
if (cursor.getTime() / 1000 >= from) midnights.push(new Date(cursor))
|
||||
}
|
||||
// Counted back from the newest so the most recent midnight is always labelled.
|
||||
midnights.forEach((d, i) => {
|
||||
if ((midnights.length - 1 - i) % days === 0) {
|
||||
ticks.push({ t: d.getTime() / 1000, label: dayTick.format(d) })
|
||||
}
|
||||
})
|
||||
return ticks
|
||||
}
|
||||
|
||||
// ---- Chart -----------------------------------------------------------------
|
||||
|
||||
const HEIGHT = 300
|
||||
const MARGIN = { top: 16, right: 16, bottom: 28, left: 40 }
|
||||
|
||||
/**
|
||||
* The line. X spans the WINDOW (`from`–`to`), not the data, so a server with three days
|
||||
* of history shows three days at the right of the 90-day view instead of stretching them
|
||||
* across it. The line BREAKS where buckets are missing — the cron didn't run, which is
|
||||
* an outage, and a straight line drawn across it would invent players for the gap.
|
||||
*/
|
||||
function OnlineChart({ stats }: { stats: OnlineStats }) {
|
||||
const { points, from, to, bucketSeconds } = stats
|
||||
const frame = useRef<HTMLDivElement>(null)
|
||||
// Measured rather than scaled through a viewBox: scaling would shrink the axis type on
|
||||
// a phone along with the plot.
|
||||
const [width, setWidth] = useState(0)
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = frame.current
|
||||
if (!el) return
|
||||
setWidth(el.clientWidth)
|
||||
const observer = new ResizeObserver(() => setWidth(el.clientWidth))
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// A hovered index means nothing against a different series.
|
||||
useEffect(() => setHover(null), [stats])
|
||||
|
||||
const plot = useMemo(() => {
|
||||
const innerW = Math.max(width - MARGIN.left - MARGIN.right, 1)
|
||||
const innerH = HEIGHT - MARGIN.top - MARGIN.bottom
|
||||
const ticks = yTicks(points.reduce((max, p) => Math.max(max, p.players), 0))
|
||||
const top = ticks[ticks.length - 1] ?? 1
|
||||
const x = (t: number) => MARGIN.left + ((t - from) / (to - from)) * innerW
|
||||
const y = (v: number) => MARGIN.top + innerH - (v / top) * innerH
|
||||
|
||||
// Runs of consecutive buckets. 2.5× tolerates one late sample without breaking the
|
||||
// line, and still breaks it for a real hole.
|
||||
const runs: Point[][] = []
|
||||
for (const p of points) {
|
||||
const run = runs[runs.length - 1]
|
||||
const last = run?.[run.length - 1]
|
||||
if (run && last && p.t - last.t <= bucketSeconds * 2.5) run.push(p)
|
||||
else runs.push([p])
|
||||
}
|
||||
const baseline = y(0)
|
||||
const paths = runs.map((run) => {
|
||||
const line = run.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.t)},${y(p.players)}`).join('')
|
||||
const first = run[0]!
|
||||
const last = run[run.length - 1]!
|
||||
return {
|
||||
line,
|
||||
area: `${line}L${x(last.t)},${baseline}L${x(first.t)},${baseline}Z`,
|
||||
// A one-bucket run has no length to stroke; it gets a dot instead.
|
||||
lone: run.length === 1 ? first : null,
|
||||
}
|
||||
})
|
||||
return { x, y, ticks, paths, baseline, innerW }
|
||||
}, [points, from, to, bucketSeconds, width])
|
||||
|
||||
const nearest = (clientX: number): number | null => {
|
||||
const el = frame.current
|
||||
if (!el || points.length === 0) return null
|
||||
const px = clientX - el.getBoundingClientRect().left
|
||||
let best = 0
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (Math.abs(plot.x(points[i]!.t) - px) < Math.abs(plot.x(points[best]!.t) - px)) best = i
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => setHover(nearest(e.clientX))
|
||||
|
||||
// The same readout from the keyboard: arrows walk the points, Home/End jump, Escape
|
||||
// puts it away.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const last = points.length - 1
|
||||
const moves: Record<string, number | null> = {
|
||||
ArrowLeft: Math.max((hover ?? points.length) - 1, 0),
|
||||
ArrowRight: Math.min((hover ?? -1) + 1, last),
|
||||
Home: 0,
|
||||
End: last,
|
||||
Escape: null,
|
||||
}
|
||||
if (!(e.key in moves)) return
|
||||
e.preventDefault()
|
||||
setHover(moves[e.key] ?? null)
|
||||
}
|
||||
|
||||
const hovered = hover === null ? undefined : points[hover]
|
||||
const latest = points[points.length - 1]!
|
||||
const tipX = hovered ? plot.x(hovered.t) : 0
|
||||
// Past the midpoint the readout sits to the LEFT of the crosshair, so it never runs off
|
||||
// the card.
|
||||
const tipLeft = tipX > width / 2
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={frame}
|
||||
className="stats-chart"
|
||||
tabIndex={0}
|
||||
role="group"
|
||||
aria-label={`Line chart of players online. Latest: ${formatCount(latest.players)}. Use the arrow keys to read each point; the same numbers are in the table below.`}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => setHover(null)}
|
||||
>
|
||||
{width > 0 && (
|
||||
<svg
|
||||
width={width}
|
||||
height={HEIGHT}
|
||||
aria-hidden="true"
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerDown={onPointerMove}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
>
|
||||
{plot.ticks.map((v) => (
|
||||
<g key={v}>
|
||||
<line
|
||||
className="grid"
|
||||
x1={MARGIN.left}
|
||||
x2={width - MARGIN.right}
|
||||
y1={plot.y(v)}
|
||||
y2={plot.y(v)}
|
||||
/>
|
||||
<text className="tick" x={MARGIN.left - 8} y={plot.y(v)} dy="0.32em" textAnchor="end">
|
||||
{formatCount(v)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{xTicks(from, to, plot.innerW, 72).map((tick) => (
|
||||
<text
|
||||
key={tick.t}
|
||||
className="tick"
|
||||
x={plot.x(tick.t)}
|
||||
y={HEIGHT - 8}
|
||||
// A label centred on a tick at the very edge would hang outside the plot.
|
||||
textAnchor={plot.x(tick.t) > width - MARGIN.right - 24 ? 'end' : 'middle'}
|
||||
>
|
||||
{tick.label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{plot.paths.map((p, i) => (
|
||||
<g key={i}>
|
||||
<path className="area" d={p.area} />
|
||||
<path className="series" d={p.line} />
|
||||
{p.lone && (
|
||||
<circle
|
||||
className="series-dot"
|
||||
cx={plot.x(p.lone.t)}
|
||||
cy={plot.y(p.lone.players)}
|
||||
r="2.5"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
|
||||
{hovered && (
|
||||
<line className="crosshair" x1={tipX} x2={tipX} y1={MARGIN.top} y2={plot.baseline} />
|
||||
)}
|
||||
{/* The end-dot marks "latest"; while reading another point it moves there. */}
|
||||
<circle
|
||||
className="marker"
|
||||
cx={plot.x((hovered ?? latest).t)}
|
||||
cy={plot.y((hovered ?? latest).players)}
|
||||
r="5"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{hovered && (
|
||||
<div
|
||||
className="stats-tip"
|
||||
role="status"
|
||||
style={tipLeft ? { right: width - tipX + 12 } : { left: tipX + 12 }}
|
||||
>
|
||||
<strong>
|
||||
{formatCount(hovered.players)} {hovered.players === 1 ? 'player' : 'players'}
|
||||
</strong>
|
||||
<span>{bucketLabel(hovered.t, bucketSeconds)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1097,6 +1097,253 @@ button[type='submit']:disabled {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
/* ---- Stats ---------------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* `/stats`: the head-count and its history. The line wears `--live`, the same green as the
|
||||
* homepage's "online" dot, because it IS that signal drawn over time — and not the
|
||||
* orange, which this page keeps for things you can press. Everything that isn't data
|
||||
* (grid, ticks, the table) stays in `--line`/`--muted` so the line is the only loud thing.
|
||||
*/
|
||||
.shell.stats {
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.stats-head h1 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stats-head p {
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
|
||||
/* One control, four positions — the auth card's `.tabs`, but hugging its labels instead
|
||||
of filling the row: a full-width bar over a chart reads as a heading. */
|
||||
.stats-ranges {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 18px;
|
||||
padding: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stats-ranges button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
padding: 7px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-family: var(--body);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stats-ranges button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stats-ranges button.active {
|
||||
background: var(--surface-hi);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* The last range's numbers, held while the next one loads. */
|
||||
.stats-body {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.stats-body.stale {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.stat-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.stat-tile {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.stat-tile dt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.stat-tile dd {
|
||||
margin: 2px 0 0;
|
||||
font-weight: 600;
|
||||
font-size: 1.7rem;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* The one number the page leads with. */
|
||||
.stat-tile dd.hero {
|
||||
font-size: 3rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat-live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--live);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--live) 22%, transparent);
|
||||
}
|
||||
|
||||
.stats-card h2 {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stats-card > .muted {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.stats-empty {
|
||||
padding: 60px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-chart {
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
/* A finger dragging along the line reads it; it shouldn't also scroll the page sideways. */
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.stats-chart svg {
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.stats-chart .grid {
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.stats-chart .tick {
|
||||
fill: var(--muted);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stats-chart .series {
|
||||
fill: none;
|
||||
stroke: var(--live);
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.stats-chart .series-dot {
|
||||
fill: var(--live);
|
||||
}
|
||||
|
||||
.stats-chart .area {
|
||||
fill: var(--live);
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.stats-chart .crosshair {
|
||||
stroke: var(--muted);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* Ringed in the surface colour so it stays legible where it sits on the line. */
|
||||
.stats-chart .marker {
|
||||
fill: var(--live);
|
||||
stroke: var(--surface);
|
||||
stroke-width: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stats-tip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface-hi);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 20px rgb(0 0 0 / 0.25);
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stats-tip strong {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stats-table {
|
||||
margin-top: 14px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.stats-table summary {
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.stats-table summary:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stats-table-scroll {
|
||||
max-height: 320px;
|
||||
margin-top: 10px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stats-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.stats-table th,
|
||||
.stats-table td {
|
||||
padding: 7px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.stats-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--surface-hi);
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.stats-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stats-table th:last-child,
|
||||
.stats-table td:last-child {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- Responsive --------------------------------------------------------- */
|
||||
|
||||
@media (max-width: 860px) {
|
||||
@@ -1132,6 +1379,29 @@ button[type='submit']:disabled {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Now leads on its own row; the two range figures share the one under it. */
|
||||
.stat-tiles {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-tile:first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.stats-ranges {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.stats-ranges button {
|
||||
flex: 1;
|
||||
padding: 7px 4px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.vtabs {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user