From d9ddae57ba10bf8cd118bbf3ad240dc0e5baa544 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Sun, 29 Mar 2026 11:26:58 +0200 Subject: [PATCH] use percentile instead of linear scale from max --- CLAUDE.md | 57 +++++++++++++++++++++++++++- site/src/components/StatsView.svelte | 17 ++++++--- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 88eca60..6d038a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,61 @@ vite: { }, ``` +## StatsView heatmap — colour intensity scaling + +Two approaches have been tried. The **active one is percentile-based** (preferred for now). + +### Option A — Linear / max-relative (simpler, currently inactive) + +```ts +$: maxDailyKm = Math.max(...[...byDate.values()].map(v => v / 1000), 1); +// inside cellColors loop: +const km = total / 1000; +const intensity = Math.min(0.12 + (km / maxDailyKm) * 0.88, 1.0); +``` + +- Busiest day = full brightness; all others scale linearly against it. +- Intuitive: you can visually read "this day was ~50% of my biggest day". +- Downside: one outlier (e.g. a 250 km day) compresses everything else into + near-darkness. Cross-sport comparison is unfair (10 km run vs 10 km cycling + look very different even when filtered to a single sport). +- Legend shows actual max km: `More (X km max)`. + +### Option B — Percentile rank (active) + +```ts +$: sortedDaily = [...byDate.values()].sort((a, b) => a - b); + +function pctRank(value: number, sorted: number[]): number { + if (!sorted.length) return 0; + let lo = 0, hi = sorted.length; + while (lo < hi) { const mid = (lo + hi) >> 1; if (sorted[mid] <= value) lo = mid + 1; else hi = mid; } + return lo / sorted.length; +} + +// inside cellColors loop: +const intensity = 0.12 + pctRank(total, sortedDaily) * 0.88; +``` + +- Each day is ranked against all other active days; the laziest active day = + intensity 0.12, the busiest = 1.0. The colour scale spreads evenly regardless + of km gaps. +- GitHub-contribution-graph style: easy to see "busy vs quiet" relative to + your own habits. +- Downside: absolute effort is not visible. A 5 km walk and a 200 km ride can + look the same if they're both 95th-percentile days for their respective sports. +- Legend says `More (percentile · max X km)` to hint at both dimensions. + +### Shared infrastructure + +- Blended colours: in "All" sport view, each cell's RGB is a weighted average + of sport colours by distance that day. +- `applyIntensity(hex, t)`: lerps from zinc-800 (#27272a = 39,39,42) to the + target colour, so dim cells fade into the background rather than going black. +- `$: cellColors = Map` — precomputed reactively so Svelte + detects the dependency change when the sport filter or scale method changes + (plain function calls with static args don't trigger Svelte re-renders). + ## Activity sidecar edits — design spec Users edit activities via **sidecar markdown files** that live alongside BAS JSON in the data dir. @@ -266,7 +321,7 @@ to `site/public/images/activities/{id}/` so they're served from the static site. - `bincio render` Python CLI is a stub — site is built via `npm run build` directly - Activity IDs in existing test data still use `+0000` format (pre-fix); re-run extract to get `Z` format - Some activities appear with both untitled and titled IDs (near-dedup timing race) -- Stats page heatmap month labels use absolute positioning and may misalign +- Stats page heatmap month labels are embedded in the week-column flex grid (fixed March 2026); `getWeeks` uses `localISO()` not `toISOString()` to avoid UTC/local date mismatch - Federation (remote data sources) not yet implemented in site - Friends pages (`/friends/{handle}/`) not yet implemented - `bincio render` should automate: symlink data → `astro build` diff --git a/site/src/components/StatsView.svelte b/site/src/components/StatsView.svelte index 82ce424..a5ddf0d 100644 --- a/site/src/components/StatsView.svelte +++ b/site/src/components/StatsView.svelte @@ -76,8 +76,16 @@ ]) ); - // Auto-scale: max daily km across all dates in filtered data - $: maxDailyKm = Math.max(...[...byDate.values()].map(v => v / 1000), 1); + // Sorted daily distances for percentile-based intensity scaling + $: sortedDaily = [...byDate.values()].sort((a, b) => a - b); + $: maxDailyKm = (sortedDaily[sortedDaily.length - 1] ?? 0) / 1000 || 1; + + function pctRank(value: number, sorted: number[]): number { + if (!sorted.length) return 0; + let lo = 0, hi = sorted.length; + while (lo < hi) { const mid = (lo + hi) >> 1; if (sorted[mid] <= value) lo = mid + 1; else hi = mid; } + return lo / sorted.length; + } // ── Totals ──────────────────────────────────────────────────────────────── $: totalsByYear = (() => { @@ -117,8 +125,7 @@ for (const [date, sportMap] of byDateBySport) { const total = byDate.get(date) ?? 0; if (total === 0) { m.set(date, '#27272a'); continue; } - const km = total / 1000; - const intensity = Math.min(0.12 + (km / maxDailyKm) * 0.88, 1.0); + const intensity = 0.12 + pctRank(total, sortedDaily) * 0.88; let tr = 0, tg = 0, tb = 0; for (const [sp, dist] of sportMap) { const w = dist / total; @@ -280,7 +287,7 @@ {#each legendSwatches as c}
{/each} - More ({Math.round(maxDailyKm)} km max) + More (percentile · max {Math.round(maxDailyKm)} km)
{#if sportsInData.length > 1}