221 lines
6.5 KiB
Svelte
221 lines
6.5 KiB
Svelte
<script lang="ts">
|
|
import type { EventAttendance } from '$lib/statistics/aggregate';
|
|
import { formatMonth } from '$lib/statistics/aggregate';
|
|
|
|
interface Props {
|
|
data: EventAttendance[];
|
|
}
|
|
|
|
let { data }: Props = $props();
|
|
|
|
/*
|
|
* One series — unique players per event — so there is no legend: the heading
|
|
* above the chart already says what is plotted, and a one-swatch legend would
|
|
* just restate it. Columns rather than a line because the events are discrete
|
|
* monthly rankings; a line would imply attendance existed between them.
|
|
*
|
|
* Marks are painted with the theme's own accent token, which is a different
|
|
* (validated) step in light and dark, so the chart is not a flipped light chart.
|
|
* Axes and labels wear text/line tokens, never the data colour.
|
|
*/
|
|
|
|
// Rendered 1:1 against the measured container width: a viewBox that scales would
|
|
// shrink the tick labels on a phone and blur the hairlines.
|
|
let width = $state(880);
|
|
const height = 260;
|
|
const pad = { top: 14, right: 10, bottom: 28, left: 40 };
|
|
|
|
let host: HTMLDivElement | undefined = $state();
|
|
$effect(() => {
|
|
if (!host || typeof ResizeObserver === 'undefined') return;
|
|
const observer = new ResizeObserver((entries) => {
|
|
const measured = entries[0]?.contentRect.width ?? 0;
|
|
if (measured > 0) width = Math.max(320, measured);
|
|
});
|
|
observer.observe(host);
|
|
return () => observer.disconnect();
|
|
});
|
|
|
|
const plot = $derived({
|
|
x: pad.left,
|
|
y: pad.top,
|
|
w: Math.max(10, width - pad.left - pad.right),
|
|
h: Math.max(10, height - pad.top - pad.bottom)
|
|
});
|
|
|
|
/** Clean axis ceiling — 0 / 20 / 40 rather than 0 / 17 / 34. */
|
|
function niceTicks(max: number, wanted = 4): number[] {
|
|
if (max <= 0) return [0, 1];
|
|
const raw = max / wanted;
|
|
const magnitude = 10 ** Math.floor(Math.log10(raw));
|
|
const step = [1, 2, 2.5, 5, 10].map((m) => m * magnitude).find((s) => s >= raw) ?? magnitude * 10;
|
|
const ticks: number[] = [];
|
|
for (let value = 0; value <= max + step / 2; value += step) ticks.push(Math.round(value));
|
|
return ticks;
|
|
}
|
|
|
|
const peak = $derived(Math.max(0, ...data.map((entry) => entry.players)));
|
|
const ticks = $derived(niceTicks(peak));
|
|
const scaleMax = $derived(ticks[ticks.length - 1] || 1);
|
|
|
|
const slot = $derived(data.length > 0 ? plot.w / data.length : plot.w);
|
|
/** Capped at 24px, and 2px of the gap is the surface showing between neighbours. */
|
|
const barWidth = $derived(Math.max(2, Math.min(24, slot - 2)));
|
|
|
|
interface Bar {
|
|
entry: EventAttendance;
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
slotX: number;
|
|
label: string;
|
|
}
|
|
|
|
const bars = $derived<Bar[]>(
|
|
data.map((entry, index) => {
|
|
const barHeight = scaleMax === 0 ? 0 : (entry.players / scaleMax) * plot.h;
|
|
const slotX = plot.x + index * slot;
|
|
return {
|
|
entry,
|
|
x: slotX + (slot - barWidth) / 2,
|
|
y: plot.y + plot.h - barHeight,
|
|
w: barWidth,
|
|
h: barHeight,
|
|
slotX,
|
|
label: formatMonth(entry.date) || entry.name
|
|
};
|
|
})
|
|
);
|
|
|
|
/** Square at the baseline, 4px rounded at the data end. */
|
|
function columnPath(bar: Bar): string {
|
|
const r = Math.min(4, bar.w / 2, bar.h);
|
|
const base = bar.y + bar.h;
|
|
if (r <= 0.5) return `M${bar.x} ${bar.y}h${bar.w}v${bar.h}h${-bar.w}Z`;
|
|
return [
|
|
`M${bar.x} ${base}`,
|
|
`V${bar.y + r}`,
|
|
`a${r} ${r} 0 0 1 ${r} ${-r}`,
|
|
`h${bar.w - 2 * r}`,
|
|
`a${r} ${r} 0 0 1 ${r} ${r}`,
|
|
`V${base}`,
|
|
'Z'
|
|
].join(' ');
|
|
}
|
|
|
|
/** Label the ends plus a sparse interior: a label on every column is unreadable. */
|
|
const labelEvery = $derived(Math.max(1, Math.ceil(data.length / Math.max(1, Math.floor(width / 90)))));
|
|
function showLabel(index: number): boolean {
|
|
if (data.length <= 1) return true;
|
|
if (index === 0 || index === data.length - 1) return true;
|
|
return index % labelEvery === 0 && index > 0 && index < data.length - 1;
|
|
}
|
|
|
|
let hovered = $state<number | null>(null);
|
|
const active = $derived(hovered === null ? null : bars[hovered]);
|
|
</script>
|
|
|
|
<div bind:this={host} class="relative w-full">
|
|
{#if data.length === 0}
|
|
<p class="py-10 text-center text-sm text-subtle">No event in this scope.</p>
|
|
{:else}
|
|
<svg
|
|
{width}
|
|
{height}
|
|
role="img"
|
|
aria-label="Unique players per event, oldest first. The same numbers are in the table below."
|
|
class="block"
|
|
>
|
|
<!-- Gridlines: hairline, solid, one step off the surface. -->
|
|
{#each ticks as tick (tick)}
|
|
{@const y = plot.y + plot.h - (scaleMax === 0 ? 0 : (tick / scaleMax) * plot.h)}
|
|
<line
|
|
x1={plot.x}
|
|
x2={plot.x + plot.w}
|
|
y1={y}
|
|
y2={y}
|
|
stroke="var(--app-line)"
|
|
stroke-width="1"
|
|
shape-rendering="crispEdges"
|
|
/>
|
|
<text
|
|
x={plot.x - 8}
|
|
{y}
|
|
dy="0.32em"
|
|
text-anchor="end"
|
|
font-size="11"
|
|
fill="var(--app-subtle)"
|
|
>
|
|
{tick}
|
|
</text>
|
|
{/each}
|
|
|
|
{#each bars as bar, index (bar.entry.eventId)}
|
|
<path
|
|
d={columnPath(bar)}
|
|
fill="var(--app-accent)"
|
|
opacity={hovered === null || hovered === index ? 1 : 0.45}
|
|
/>
|
|
{/each}
|
|
|
|
{#each bars as bar, index (bar.entry.eventId)}
|
|
{#if showLabel(index)}
|
|
<text
|
|
x={bar.x + bar.w / 2}
|
|
y={plot.y + plot.h + 18}
|
|
text-anchor="middle"
|
|
font-size="11"
|
|
fill="var(--app-subtle)"
|
|
>
|
|
{bar.label}
|
|
</text>
|
|
{/if}
|
|
{/each}
|
|
|
|
<!-- Hit targets are the full slot height, so thin columns stay hoverable. -->
|
|
{#each bars as bar, index (bar.entry.eventId)}
|
|
<rect
|
|
x={bar.slotX}
|
|
y={plot.y}
|
|
width={Math.max(slot, 6)}
|
|
height={plot.h}
|
|
fill="transparent"
|
|
onmouseenter={() => (hovered = index)}
|
|
onmouseleave={() => (hovered = null)}
|
|
role="presentation"
|
|
/>
|
|
{/each}
|
|
</svg>
|
|
|
|
{#if active}
|
|
<div
|
|
class="pointer-events-none absolute z-10 w-44 rounded-lg border border-line bg-overlay p-2 text-xs shadow-card"
|
|
style="left: {Math.min(
|
|
Math.max(active.x + active.w / 2 - 88, 0),
|
|
Math.max(width - 176, 0)
|
|
)}px; top: {Math.max(active.y - 92, 0)}px"
|
|
>
|
|
<p class="font-semibold text-ink">{active.entry.name}</p>
|
|
{#if formatMonth(active.entry.date)}
|
|
<p class="text-subtle">{formatMonth(active.entry.date)}</p>
|
|
{/if}
|
|
<dl class="mt-1 space-y-0.5 text-muted">
|
|
<div class="flex justify-between gap-2">
|
|
<dt>Players</dt>
|
|
<dd class="font-semibold text-ink">{active.entry.players}</dd>
|
|
</div>
|
|
<div class="flex justify-between gap-2">
|
|
<dt>Entries</dt>
|
|
<dd>{active.entry.entries}</dd>
|
|
</div>
|
|
<div class="flex justify-between gap-2">
|
|
<dt>Brackets</dt>
|
|
<dd>{active.entry.brackets}</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|