zone alignment

This commit is contained in:
Davide Scaini
2026-03-29 22:31:03 +02:00
parent 9bd065f354
commit 1c49d3a769
+79 -19
View File
@@ -69,7 +69,7 @@
}; };
// ── Histogram controls ─────────────────────────────────────────────────── // ── Histogram controls ───────────────────────────────────────────────────
let bins = 40; let bins = 15;
// Metric values for current tab (non-null) // Metric values for current tab (non-null)
$: yKey = tabMeta[activeTab].yKey; $: yKey = tabMeta[activeTab].yKey;
@@ -104,11 +104,21 @@
(_, i) => trimMin + (i + 1) * (trimMax - trimMin) / bins, (_, 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 ──────────────────────────────────────────────────────────── // ── Rendering ────────────────────────────────────────────────────────────
onMount(() => { renderChart(); }); onMount(() => { renderChart(); });
$: if (chartEl) { $: if (chartEl) {
activeTab; xMode; chartType; histData; histThresholds; activeTab; xMode; chartType; histData; histThresholds; alignZones;
renderChart(); renderChart();
} }
@@ -184,7 +194,50 @@
function renderHistogram(w: number, h: number, yKey: string, yLabel: string, color: string) { 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 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[] = [ const marks: any[] = [
Plot.rectY(histData, Plot.binX( Plot.rectY(histData, Plot.binX(
{ y: 'count' }, { y: 'count' },
@@ -193,17 +246,12 @@
Plot.ruleY([0], { stroke: '#52525b' }), 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) { if (rawZones?.length) {
// Boundary vertical lines (interior boundaries only, skip first lo and last hi)
const boundaries = rawZones.slice(0, -1).map((z, i) => ({ 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], color: zoneColors[i + 1] ?? zoneColors[zoneColors.length - 1],
})).filter(b => b.x > trimMin && b.x < trimMax); })).filter(b => b.x > trimMin && b.x < trimMax);
// Zone midpoints for labels
const labels = rawZones.map((z, i) => ({ const labels = rawZones.map((z, i) => ({
mid: (Math.max(z[0], trimMin) + Math.min(z[1], trimMax)) / 2, mid: (Math.max(z[0], trimMin) + Math.min(z[1], trimMax)) / 2,
label: `Z${i + 1}`, label: `Z${i + 1}`,
@@ -215,18 +263,11 @@
Plot.ruleX(boundaries, { Plot.ruleX(boundaries, {
x: 'x', x: 'x',
stroke: (d: any) => d.color, stroke: (d: any) => d.color,
strokeWidth: 1, strokeWidth: 1, strokeOpacity: 0.5, strokeDasharray: '4,3',
strokeOpacity: 0.5,
strokeDasharray: '4,3',
}), }),
Plot.text(labels, { Plot.text(labels, {
x: 'mid', x: 'mid', text: 'label', fill: (d: any) => d.color,
text: 'label', fontSize: 9, fontWeight: '600', frameAnchor: 'top', dy: 6,
fill: (d: any) => d.color,
fontSize: 9,
fontWeight: '600',
frameAnchor: 'top',
dy: 6,
}), }),
); );
} }
@@ -304,6 +345,24 @@
{#if chartType === 'histogram'} {#if chartType === 'histogram'}
<div class="mt-3 flex flex-col gap-2 text-xs text-zinc-400"> <div class="mt-3 flex flex-col gap-2 text-xs text-zinc-400">
<!-- Bins mode toggle — only shown when zones are available -->
{#if canAlignZones}
<div class="flex items-center gap-1">
<span class="text-zinc-500 mr-1">Bins:</span>
{#each ([false, true] as boolean[]) as zoneMode}
<button
class="px-2 py-1 rounded transition-colors"
class:bg-zinc-800={alignZones === zoneMode}
class:text-white={alignZones === zoneMode}
class:text-zinc-500={alignZones !== zoneMode}
class:hover:text-zinc-300={alignZones !== zoneMode}
on:click={() => alignZones = zoneMode}
>{zoneMode ? 'Zones' : 'Custom'}</button>
{/each}
</div>
{/if}
{#if !alignZones}
<!-- Dual range slider --> <!-- Dual range slider -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="w-8 text-right shrink-0">{Math.round(trimMin)}</span> <span class="w-8 text-right shrink-0">{Math.round(trimMin)}</span>
@@ -339,12 +398,13 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="w-8 text-right shrink-0 text-zinc-500">Bins</span> <span class="w-8 text-right shrink-0 text-zinc-500">Bins</span>
<input <input
type="range" min="5" max="100" step="1" type="range" min="5" max="20" step="1"
bind:value={bins} bind:value={bins}
class="flex-1 h-1 accent-zinc-400 cursor-pointer" class="flex-1 h-1 accent-zinc-400 cursor-pointer"
/> />
<span class="w-8 shrink-0">{bins}</span> <span class="w-8 shrink-0">{bins}</span>
</div> </div>
{/if}
</div> </div>
{/if} {/if}