mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
feat(stats): quantile-scaled, annotated calendar heatmap
Replace the linear count/max alpha with quantile buckets (GitHub-style) so a single outlier day no longer flattens the rest of the graph, and render best/median/worst day labels inside their cells. - Add buildCalendarScale utility with empty-bucket handling. - Make CalendarHeatmap size, gap, and annotations configurable; align weeks to Monday. - Expand the community calendar to 90 days, add play totals/averages and all-time best day, and format large numbers with locale separators.
This commit is contained in:
1 parent
a562ee3cc9
commit
da29ee7196
4 files changed
+249
-27
No files matched your search
@@ -1,22 +1,39 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { buildCalendarScale } from '$lib/utils/calendar-scale';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: { date: string; count: number }[];
|
data: { date: string; count: number }[];
|
||||||
color?: string;
|
color?: string;
|
||||||
|
/** Pixel size of each day square. */
|
||||||
|
cellSize?: number;
|
||||||
|
/** Gap between squares in pixels. */
|
||||||
|
gap?: number;
|
||||||
|
/** Optional date → short label rendered inside the square. */
|
||||||
|
annotations?: Record<string, string>;
|
||||||
}
|
}
|
||||||
let { data, color = '52,211,153' }: Props = $props();
|
let {
|
||||||
|
data,
|
||||||
|
color = '52,211,153',
|
||||||
|
cellSize = 12,
|
||||||
|
gap = 3,
|
||||||
|
annotations = {}
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const values = $derived(new Map(data.map((d) => [d.date, d.count])));
|
const values = $derived(new Map(data.map((d) => [d.date, d.count])));
|
||||||
const max = $derived(Math.max(1, ...data.map((d) => d.count)));
|
const scale = $derived(buildCalendarScale(data.map((d) => d.count)));
|
||||||
|
const fontSize = $derived(Math.max(8, Math.round(cellSize * 0.42)));
|
||||||
|
const radius = $derived(Math.max(2, Math.round(cellSize * 0.16)));
|
||||||
|
|
||||||
// Build Sunday-aligned weeks ending on the last date.
|
// Build Monday-aligned weeks ending on the last date (weekend at the bottom).
|
||||||
const weeks = $derived.by(() => {
|
const weeks = $derived.by(() => {
|
||||||
if (data.length === 0) return [] as { date: string; inRange: boolean }[][];
|
if (data.length === 0) return [] as { date: string; inRange: boolean }[][];
|
||||||
|
const mondayOffset = (date: Date) => (date.getUTCDay() + 6) % 7;
|
||||||
const first = new Date(data[0].date + 'T00:00:00Z');
|
const first = new Date(data[0].date + 'T00:00:00Z');
|
||||||
const start = new Date(first);
|
const start = new Date(first);
|
||||||
start.setUTCDate(start.getUTCDate() - start.getUTCDay());
|
start.setUTCDate(start.getUTCDate() - mondayOffset(start));
|
||||||
const last = new Date(data[data.length - 1].date + 'T00:00:00Z');
|
const last = new Date(data[data.length - 1].date + 'T00:00:00Z');
|
||||||
const end = new Date(last);
|
const end = new Date(last);
|
||||||
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()));
|
end.setUTCDate(end.getUTCDate() + (6 - mondayOffset(end)));
|
||||||
const range = new Set(data.map((d) => d.date));
|
const range = new Set(data.map((d) => d.date));
|
||||||
const out: { date: string; inRange: boolean }[][] = [];
|
const out: { date: string; inRange: boolean }[][] = [];
|
||||||
let cursor = new Date(start);
|
let cursor = new Date(start);
|
||||||
@@ -34,25 +51,55 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<div class="flex gap-1">
|
<div class="flex" style={`gap:${Math.max(4, gap)}px`}>
|
||||||
<div class="flex flex-col justify-between py-0.5 text-[9px] text-gray-500">
|
<div
|
||||||
<span>Sun</span><span>Wed</span><span>Sat</span>
|
class="flex flex-col justify-between py-0.5 text-gray-500"
|
||||||
|
style={`font-size:${fontSize}px`}
|
||||||
|
>
|
||||||
|
<span>Mon</span><span>Thu</span><span>Sun</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-[3px]">
|
<div class="flex" style={`gap:${gap}px`}>
|
||||||
{#each weeks as week, wi (wi)}
|
{#each weeks as week, wi (wi)}
|
||||||
<div class="flex flex-col gap-[3px]">
|
<div class="flex flex-col" style={`gap:${gap}px`}>
|
||||||
{#each week as cell (cell.date)}
|
{#each week as cell (cell.date)}
|
||||||
{@const count = values.get(cell.date) ?? 0}
|
{@const count = values.get(cell.date) ?? 0}
|
||||||
|
{@const alpha = scale.alphaFor(count) || 0.06}
|
||||||
|
{@const label = annotations[cell.date]}
|
||||||
<div
|
<div
|
||||||
class="h-3 w-3 rounded-[2px]"
|
class="cell-label flex items-center justify-center font-semibold tabular-nums"
|
||||||
style={cell.inRange
|
data-pale={label && alpha < 0.4 ? 'true' : undefined}
|
||||||
? `background:rgba(${color},${(0.12 + 0.88 * (count / max)).toFixed(3)})`
|
style={`width:${cellSize}px;height:${cellSize}px;border-radius:${radius}px;font-size:${fontSize}px;line-height:1;${
|
||||||
: 'background:transparent'}
|
cell.inRange ? `background:rgba(${color},${alpha})` : 'background:transparent'
|
||||||
title={cell.inRange ? `${cell.date}: ${count} finishes` : cell.date}
|
}`}
|
||||||
></div>
|
title={cell.inRange
|
||||||
|
? `${cell.date}: ${count.toLocaleString('en-US')} finishes`
|
||||||
|
: cell.date}
|
||||||
|
>{label ?? ''}</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
* Annotated numbers need to read on both light and dark cell backgrounds.
|
||||||
|
* Intensity is the dominant factor: the bright/high buckets are light enough
|
||||||
|
* for dark ink in either theme, while the lowest bucket is a dark green on a
|
||||||
|
* dark page and needs light ink there.
|
||||||
|
*/
|
||||||
|
.cell-label {
|
||||||
|
color: rgb(17 24 39 / 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.dark) .cell-label[data-pale='true'] {
|
||||||
|
color: rgb(249 250 251 / 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:global(html:not(.light)) .cell-label[data-pale='true'] {
|
||||||
|
color: rgb(249 250 251 / 0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Quantile-bucketed color scale for calendar/heatmap charts.
|
||||||
|
*
|
||||||
|
* A naive `count / max` alpha collapses most cells into the same pale shade when
|
||||||
|
* a single day is an outlier. Bucketing by quantiles of the non-zero values
|
||||||
|
* (like GitHub's contribution graph) keeps typical variation visible and never
|
||||||
|
* changes when a new all-time-high day appears.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CalendarBucket {
|
||||||
|
/** Lowest count in this bucket. */
|
||||||
|
min: number;
|
||||||
|
/** Highest count in this bucket. */
|
||||||
|
max: number;
|
||||||
|
/** Alpha (0..1) applied to the base color. */
|
||||||
|
alpha: number;
|
||||||
|
/** How many non-zero days fall in this bucket. */
|
||||||
|
days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarScale {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
/** Non-empty intensity buckets, low → high. Zero is rendered separately. */
|
||||||
|
buckets: CalendarBucket[];
|
||||||
|
/** Alpha for a raw count (0 for a zero/empty day). */
|
||||||
|
alphaFor: (count: number) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linear-interpolated quantile over an already-sorted ascending array. */
|
||||||
|
export function quantile(sorted: number[], q: number): number {
|
||||||
|
if (sorted.length === 0) return 0;
|
||||||
|
if (sorted.length === 1) return sorted[0];
|
||||||
|
const pos = (sorted.length - 1) * q;
|
||||||
|
const base = Math.floor(pos);
|
||||||
|
const rest = pos - base;
|
||||||
|
const next = sorted[base + 1];
|
||||||
|
return next === undefined ? sorted[base] : sorted[base] + rest * (next - sorted[base]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a quantile scale from a list of daily counts.
|
||||||
|
*
|
||||||
|
* The non-zero counts are split into `steps` intensity buckets at evenly spaced
|
||||||
|
* quantiles; duplicate-heavy data is tolerated because empty buckets are dropped.
|
||||||
|
*/
|
||||||
|
export function buildCalendarScale(counts: number[], steps = 4): CalendarScale {
|
||||||
|
const nonzero = counts.filter((c) => c > 0).sort((a, b) => a - b);
|
||||||
|
const min = counts.length ? Math.min(...counts) : 0;
|
||||||
|
const max = counts.length ? Math.max(...counts) : 0;
|
||||||
|
|
||||||
|
if (nonzero.length === 0) {
|
||||||
|
return { min, max, buckets: [], alphaFor: () => 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const alphas =
|
||||||
|
steps <= 1
|
||||||
|
? [0.9]
|
||||||
|
: Array.from({ length: steps }, (_, i) => 0.28 + (0.72 * i) / (steps - 1));
|
||||||
|
|
||||||
|
const thresholds: number[] = [];
|
||||||
|
for (let i = 1; i < steps; i++) thresholds.push(quantile(nonzero, i / steps));
|
||||||
|
|
||||||
|
const bucketIndex = (count: number): number => {
|
||||||
|
if (count <= 0) return -1;
|
||||||
|
let index = 0;
|
||||||
|
for (const threshold of thresholds) {
|
||||||
|
if (count > threshold) index++;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buckets: CalendarBucket[] = alphas.map((alpha) => ({
|
||||||
|
alpha,
|
||||||
|
min: Number.POSITIVE_INFINITY,
|
||||||
|
max: Number.NEGATIVE_INFINITY,
|
||||||
|
days: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const count of nonzero) {
|
||||||
|
const bucket = buckets[bucketIndex(count)];
|
||||||
|
bucket.min = Math.min(bucket.min, count);
|
||||||
|
bucket.max = Math.max(bucket.max, count);
|
||||||
|
bucket.days++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
buckets: buckets.filter((bucket) => bucket.days > 0),
|
||||||
|
alphaFor: (count: number) => {
|
||||||
|
const index = bucketIndex(count);
|
||||||
|
return index < 0 ? 0 : alphas[index];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/** Small formatting helpers shared by the stats dashboard components. */
|
/** Small formatting helpers shared by the stats dashboard components. */
|
||||||
|
|
||||||
export function numOrDash(n: number | null | undefined): string {
|
export function numOrDash(n: number | null | undefined): string {
|
||||||
return n === null || n === undefined ? '—' : String(n);
|
return n === null || n === undefined ? '—' : n.toLocaleString('en-US');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pctOrDash(n: number | null | undefined): string {
|
export function pctOrDash(n: number | null | undefined): string {
|
||||||
|
|||||||
@@ -5,11 +5,50 @@
|
|||||||
import { formatDateLabel, numOrDash } from '$lib/utils/stats-format';
|
import { formatDateLabel, numOrDash } from '$lib/utils/stats-format';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
const calendar = $derived(data.calendar);
|
||||||
|
const active = $derived(calendar.filter((d) => d.count > 0));
|
||||||
|
|
||||||
|
const total = $derived(calendar.reduce((sum, d) => sum + d.count, 0));
|
||||||
|
const avgPerDay = $derived(calendar.length ? total / calendar.length : 0);
|
||||||
|
|
||||||
|
const bestDay = $derived(
|
||||||
|
active.reduce<{ date: string; count: number } | null>(
|
||||||
|
(best, d) => (best === null || d.count > best.count ? d : best),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const worstDay = $derived(
|
||||||
|
active.reduce<{ date: string; count: number } | null>(
|
||||||
|
(worst, d) => (worst === null || d.count < worst.count ? d : worst),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const medianDay = $derived.by(() => {
|
||||||
|
if (active.length === 0) return null;
|
||||||
|
const sorted = [...active].sort((a, b) => a.count - b.count);
|
||||||
|
return sorted[Math.floor((sorted.length - 1) / 2)];
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Compact enough to sit inside a day square. */
|
||||||
|
function compactCount(n: number): string {
|
||||||
|
if (n >= 10000) return `${Math.round(n / 1000)}k`;
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
const annotations = $derived.by(() => {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const day of [bestDay, medianDay, worstDay]) {
|
||||||
|
if (day) out[day.date] = compactCount(day.count);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Community Stats | Bibdle</title>
|
<title>Community Stats | Bibdle</title>
|
||||||
<meta name="description" content="Bibdle community statistics, as of yesterday." />
|
<meta name="description" content="Bibdle community statistics, as of the latest completed day." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="mx-auto max-w-3xl px-4 py-8">
|
<div class="mx-auto max-w-3xl px-4 py-8">
|
||||||
@@ -20,7 +59,7 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-3">
|
<div class="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||||
<Container class="p-4 w-full items-start text-left">
|
<Container class="p-4 w-full items-start text-left">
|
||||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">All-time finishes</p>
|
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">All-time plays</p>
|
||||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.completions)}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.completions)}</p>
|
||||||
</Container>
|
</Container>
|
||||||
<Container class="p-4 w-full items-start text-left">
|
<Container class="p-4 w-full items-start text-left">
|
||||||
@@ -36,21 +75,61 @@
|
|||||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.monthlyPlayers)}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.monthlyPlayers)}</p>
|
||||||
</Container>
|
</Container>
|
||||||
<Container class="p-4 w-full items-start text-left">
|
<Container class="p-4 w-full items-start text-left">
|
||||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Yesterday's finishes</p>
|
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Latest day's plays</p>
|
||||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.yesterdayCompletions)}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.yesterdayCompletions)}</p>
|
||||||
</Container>
|
</Container>
|
||||||
<Container class="p-4 w-full items-start text-left">
|
<Container class="p-4 w-full items-start text-left">
|
||||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Avg guesses yesterday</p>
|
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Avg guesses that day</p>
|
||||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.yesterdayAvgGuesses)}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.yesterdayAvgGuesses)}</p>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Container class="mt-3 p-4 w-full items-stretch text-left gap-2">
|
<Container class="mt-3 p-4 w-full items-stretch text-left gap-3">
|
||||||
<p class="font-semibold text-gray-900 dark:text-gray-100">Daily finishes (last 30 days)</p>
|
<p class="font-semibold text-gray-900 dark:text-gray-100">Daily plays (last {calendar.length} days)</p>
|
||||||
<CalendarHeatmap data={data.calendar} />
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<CalendarHeatmap data={calendar} cellSize={22} annotations={annotations} />
|
||||||
|
<p class="text-[10px] text-gray-500 dark:text-gray-400">
|
||||||
|
Numbers mark the best, median, and least popular days.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-4">
|
||||||
|
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt class="text-[11px] uppercase tracking-wider text-gray-500 dark:text-gray-400">{calendar.length}-day plays</dt>
|
||||||
|
<dd class="font-semibold tabular-nums text-gray-900 dark:text-gray-100">{numOrDash(total)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-[11px] uppercase tracking-wider text-gray-500 dark:text-gray-400">Daily average</dt>
|
||||||
|
<dd class="font-semibold tabular-nums text-gray-900 dark:text-gray-100">
|
||||||
|
{avgPerDay.toLocaleString('en-US', { maximumFractionDigits: 1 })}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{#if bestDay}
|
||||||
|
<div>
|
||||||
|
<dt class="text-[11px] uppercase tracking-wider text-gray-500 dark:text-gray-400">Best day ({calendar.length}d)</dt>
|
||||||
|
<dd class="font-semibold tabular-nums text-gray-900 dark:text-gray-100">
|
||||||
|
{numOrDash(bestDay.count)}
|
||||||
|
<span class="font-normal text-gray-500 dark:text-gray-400">on {formatDateLabel(bestDay.date)}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if data.bestDayAllTime}
|
||||||
|
<div>
|
||||||
|
<dt class="text-[11px] uppercase tracking-wider text-gray-500 dark:text-gray-400">Best day · all-time</dt>
|
||||||
|
<dd class="font-semibold tabular-nums text-gray-900 dark:text-gray-100">
|
||||||
|
{numOrDash(data.bestDayAllTime.count)}
|
||||||
|
<span class="font-normal text-gray-500 dark:text-gray-400">on {formatDateLabel(data.bestDayAllTime.date)}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<p class="mt-4 text-center text-xs text-gray-500 dark:text-gray-500">
|
<p class="mt-4 text-center text-xs text-gray-500 dark:text-gray-500">
|
||||||
Updated once a day from completed games. <a class="underline hover:text-gray-300" href="/">Play today's verse →</a>
|
Updated once a day from completed games.<br /> <a class="underline hover:text-gray-300" href="/">Play today's verse →</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
Reference in new issue
Block a user