From da29ee71967d93e10fa5a349e25cb9a188aa83d0 Mon Sep 17 00:00:00 2001 From: George Powell Date: Mon, 28 Sep 2026 20:21:48 -0400 Subject: [PATCH] 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. --- .../components/charts/CalendarHeatmap.svelte | 81 ++++++++++++---- src/lib/utils/calendar-scale.ts | 96 ++++++++++++++++++ src/lib/utils/stats-format.ts | 2 +- src/routes/global/+page.svelte | 97 +++++++++++++++++-- 4 files changed, 249 insertions(+), 27 deletions(-) create mode 100644 src/lib/utils/calendar-scale.ts diff --git a/src/lib/components/charts/CalendarHeatmap.svelte b/src/lib/components/charts/CalendarHeatmap.svelte index 2067d75..8e706a4 100644 --- a/src/lib/components/charts/CalendarHeatmap.svelte +++ b/src/lib/components/charts/CalendarHeatmap.svelte @@ -1,22 +1,39 @@
-
-
- SunWedSat +
+
+ MonThuSun
-
+
{#each weeks as week, wi (wi)} -
+
{#each week as cell (cell.date)} {@const count = values.get(cell.date) ?? 0} + {@const alpha = scale.alphaFor(count) || 0.06} + {@const label = annotations[cell.date]}
+ class="cell-label flex items-center justify-center font-semibold tabular-nums" + data-pale={label && alpha < 0.4 ? 'true' : undefined} + style={`width:${cellSize}px;height:${cellSize}px;border-radius:${radius}px;font-size:${fontSize}px;line-height:1;${ + cell.inRange ? `background:rgba(${color},${alpha})` : 'background:transparent' + }`} + title={cell.inRange + ? `${cell.date}: ${count.toLocaleString('en-US')} finishes` + : cell.date} + >{label ?? ''}
{/each}
{/each}
-
\ No newline at end of file +
+ + \ No newline at end of file diff --git a/src/lib/utils/calendar-scale.ts b/src/lib/utils/calendar-scale.ts new file mode 100644 index 0000000..748d8dc --- /dev/null +++ b/src/lib/utils/calendar-scale.ts @@ -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]; + } + }; +} \ No newline at end of file diff --git a/src/lib/utils/stats-format.ts b/src/lib/utils/stats-format.ts index 87ef115..93ba6b9 100644 --- a/src/lib/utils/stats-format.ts +++ b/src/lib/utils/stats-format.ts @@ -1,7 +1,7 @@ /** Small formatting helpers shared by the stats dashboard components. */ 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 { diff --git a/src/routes/global/+page.svelte b/src/routes/global/+page.svelte index 4836d6f..555e960 100644 --- a/src/routes/global/+page.svelte +++ b/src/routes/global/+page.svelte @@ -5,11 +5,50 @@ import { formatDateLabel, numOrDash } from '$lib/utils/stats-format'; 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 = {}; + for (const day of [bestDay, medianDay, worstDay]) { + if (day) out[day.date] = compactCount(day.count); + } + return out; + }); Community Stats | Bibdle - +
@@ -20,7 +59,7 @@
-

All-time finishes

+

All-time plays

{numOrDash(data.completions)}

@@ -36,21 +75,61 @@

{numOrDash(data.monthlyPlayers)}

-

Yesterday's finishes

+

Latest day's plays

{numOrDash(data.yesterdayCompletions)}

-

Avg guesses yesterday

+

Avg guesses that day

{numOrDash(data.yesterdayAvgGuesses)}

- -

Daily finishes (last 30 days)

- + +

Daily plays (last {calendar.length} days)

+
+
+ +

+ Numbers mark the best, median, and least popular days. +

+
+ +
+
+
+
{calendar.length}-day plays
+
{numOrDash(total)}
+
+
+
Daily average
+
+ {avgPerDay.toLocaleString('en-US', { maximumFractionDigits: 1 })} +
+
+ {#if bestDay} +
+
Best day ({calendar.length}d)
+
+ {numOrDash(bestDay.count)} + on {formatDateLabel(bestDay.date)} +
+
+ {/if} + {#if data.bestDayAllTime} +
+
Best day · all-time
+
+ {numOrDash(data.bestDayAllTime.count)} + on {formatDateLabel(data.bestDayAllTime.date)} +
+
+ {/if} +
+
+

- Updated once a day from completed games. Play today's verse → + Updated once a day from completed games.
Play today's verse →

-
\ No newline at end of file +