Files
LaDOSE/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/AttendanceChart.svelte
T
2026-08-06 09:58:06 +02:00

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>