use percentile instead of linear scale from max
This commit is contained in:
@@ -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<string, string>` — 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
|
## Activity sidecar edits — design spec
|
||||||
|
|
||||||
Users edit activities via **sidecar markdown files** that live alongside BAS JSON in the data dir.
|
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
|
- `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
|
- 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)
|
- 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
|
- Federation (remote data sources) not yet implemented in site
|
||||||
- Friends pages (`/friends/{handle}/`) not yet implemented
|
- Friends pages (`/friends/{handle}/`) not yet implemented
|
||||||
- `bincio render` should automate: symlink data → `astro build`
|
- `bincio render` should automate: symlink data → `astro build`
|
||||||
|
|||||||
@@ -76,8 +76,16 @@
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-scale: max daily km across all dates in filtered data
|
// Sorted daily distances for percentile-based intensity scaling
|
||||||
$: maxDailyKm = Math.max(...[...byDate.values()].map(v => v / 1000), 1);
|
$: 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 ────────────────────────────────────────────────────────────────
|
// ── Totals ────────────────────────────────────────────────────────────────
|
||||||
$: totalsByYear = (() => {
|
$: totalsByYear = (() => {
|
||||||
@@ -117,8 +125,7 @@
|
|||||||
for (const [date, sportMap] of byDateBySport) {
|
for (const [date, sportMap] of byDateBySport) {
|
||||||
const total = byDate.get(date) ?? 0;
|
const total = byDate.get(date) ?? 0;
|
||||||
if (total === 0) { m.set(date, '#27272a'); continue; }
|
if (total === 0) { m.set(date, '#27272a'); continue; }
|
||||||
const km = total / 1000;
|
const intensity = 0.12 + pctRank(total, sortedDaily) * 0.88;
|
||||||
const intensity = Math.min(0.12 + (km / maxDailyKm) * 0.88, 1.0);
|
|
||||||
let tr = 0, tg = 0, tb = 0;
|
let tr = 0, tg = 0, tb = 0;
|
||||||
for (const [sp, dist] of sportMap) {
|
for (const [sp, dist] of sportMap) {
|
||||||
const w = dist / total;
|
const w = dist / total;
|
||||||
@@ -280,7 +287,7 @@
|
|||||||
{#each legendSwatches as c}
|
{#each legendSwatches as c}
|
||||||
<div class="w-[10px] h-[10px] rounded-[2px]" style="background:{c}" />
|
<div class="w-[10px] h-[10px] rounded-[2px]" style="background:{c}" />
|
||||||
{/each}
|
{/each}
|
||||||
<span class="text-xs text-zinc-500 ml-1">More ({Math.round(maxDailyKm)} km max)</span>
|
<span class="text-xs text-zinc-500 ml-1">More (percentile · max {Math.round(maxDailyKm)} km)</span>
|
||||||
</div>
|
</div>
|
||||||
{#if sportsInData.length > 1}
|
{#if sportsInData.length > 1}
|
||||||
<div class="flex items-center gap-2 ml-2">
|
<div class="flex items-center gap-2 ml-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user