From 1c49d3a769cc41ca3c136df4dca25bc3f5be7e77 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Sun, 29 Mar 2026 22:31:03 +0200 Subject: [PATCH] zone alignment --- site/src/components/ActivityCharts.svelte | 98 ++++++++++++++++++----- 1 file changed, 79 insertions(+), 19 deletions(-) diff --git a/site/src/components/ActivityCharts.svelte b/site/src/components/ActivityCharts.svelte index d25cd12..f79ceea 100644 --- a/site/src/components/ActivityCharts.svelte +++ b/site/src/components/ActivityCharts.svelte @@ -69,7 +69,7 @@ }; // ── Histogram controls ─────────────────────────────────────────────────── - let bins = 40; + let bins = 15; // Metric values for current tab (non-null) $: yKey = tabMeta[activeTab].yKey; @@ -104,11 +104,21 @@ (_, i) => trimMin + (i + 1) * (trimMax - trimMin) / bins, ); + // ── Zone alignment ─────────────────────────────────────────────────────── + let alignZones = false; + $: canAlignZones = chartType === 'histogram' && !!( + activeTab === 'hr' ? athlete?.hr_zones?.length : + activeTab === 'power' ? athlete?.power_zones?.length : + false + ); + // Reset when switching away from a zone-capable metric or leaving histogram + $: if (!canAlignZones) alignZones = false; + // ── Rendering ──────────────────────────────────────────────────────────── onMount(() => { renderChart(); }); $: if (chartEl) { - activeTab; xMode; chartType; histData; histThresholds; + activeTab; xMode; chartType; histData; histThresholds; alignZones; renderChart(); } @@ -184,7 +194,50 @@ function renderHistogram(w: number, h: number, yKey: string, yLabel: string, color: string) { const yTickFormat = (v: number) => v >= 60 ? `${Math.round(v / 60)}m` : `${v}s`; + const rawZones = activeTab === 'hr' ? athlete?.hr_zones : activeTab === 'power' ? athlete?.power_zones : null; + const zoneColors = activeTab === 'hr' ? HR_ZONE_COLORS : PWR_ZONE_COLORS; + // ── Zone-aligned: one colored bar per zone ────────────────────────────── + if (alignZones && rawZones?.length) { + // Cap the top zone's hi at the actual data max so sentinel values like + // 999 bpm or 9999 W don't stretch the x-axis into empty space. + const dataMax = Math.max(...data.map((d: any) => d[yKey]).filter((v: any) => v != null)); + const clampedZones = rawZones.map((z, i) => + i === rawZones.length - 1 ? [z[0], Math.min(z[1], dataMax * 1.05)] : z + ); + + const zoneBars = clampedZones.map((z, i) => ({ + lo: z[0], hi: z[1], + // Count directly from full data — trim sliders don't apply in zone mode + count: data.filter((d: any) => { const v = d[yKey]; return v != null && v >= rawZones[i][0] && v < rawZones[i][1]; }).length, + color: zoneColors[i] ?? zoneColors[zoneColors.length - 1], + label: `Z${i + 1}`, + })); + + return Plot.plot({ + width: w, height: h, marginLeft: 48, marginBottom: 32, + style: { background: 'transparent', color: '#a1a1aa', fontSize: '11px' }, + x: { label: yLabel, grid: false, domain: [clampedZones[0][0], clampedZones[clampedZones.length - 1][1]] }, + y: { label: 'Time', grid: true, tickCount: 4, tickFormat: yTickFormat }, + marks: [ + Plot.rect(zoneBars, { + x1: 'lo', x2: 'hi', y1: 0, y2: 'count', + fill: 'color', fillOpacity: 0.75, + }), + Plot.text(zoneBars, { + x: (d: any) => (d.lo + d.hi) / 2, + y: 'count', + text: 'label', + fill: 'color', + fontSize: 10, fontWeight: '600', + dy: -8, + }), + Plot.ruleY([0], { stroke: '#52525b' }), + ], + }); + } + + // ── Normal histogram with optional zone overlays ───────────────────────── const marks: any[] = [ Plot.rectY(histData, Plot.binX( { y: 'count' }, @@ -193,17 +246,12 @@ Plot.ruleY([0], { stroke: '#52525b' }), ]; - const rawZones = activeTab === 'hr' ? athlete?.hr_zones : activeTab === 'power' ? athlete?.power_zones : null; - const zoneColors = activeTab === 'hr' ? HR_ZONE_COLORS : PWR_ZONE_COLORS; - if (rawZones?.length) { - // Boundary vertical lines (interior boundaries only, skip first lo and last hi) const boundaries = rawZones.slice(0, -1).map((z, i) => ({ - x: z[1], // the upper bound of each zone = lower bound of the next + x: z[1], color: zoneColors[i + 1] ?? zoneColors[zoneColors.length - 1], })).filter(b => b.x > trimMin && b.x < trimMax); - // Zone midpoints for labels const labels = rawZones.map((z, i) => ({ mid: (Math.max(z[0], trimMin) + Math.min(z[1], trimMax)) / 2, label: `Z${i + 1}`, @@ -215,18 +263,11 @@ Plot.ruleX(boundaries, { x: 'x', stroke: (d: any) => d.color, - strokeWidth: 1, - strokeOpacity: 0.5, - strokeDasharray: '4,3', + strokeWidth: 1, strokeOpacity: 0.5, strokeDasharray: '4,3', }), Plot.text(labels, { - x: 'mid', - text: 'label', - fill: (d: any) => d.color, - fontSize: 9, - fontWeight: '600', - frameAnchor: 'top', - dy: 6, + x: 'mid', text: 'label', fill: (d: any) => d.color, + fontSize: 9, fontWeight: '600', frameAnchor: 'top', dy: 6, }), ); } @@ -304,6 +345,24 @@ {#if chartType === 'histogram'}
+ + {#if canAlignZones} +
+ Bins: + {#each ([false, true] as boolean[]) as zoneMode} + + {/each} +
+ {/if} + + {#if !alignZones}
{Math.round(trimMin)} @@ -339,12 +398,13 @@
Bins {bins}
+ {/if}
{/if}