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 ───────────────────────────────────────────────────
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'}
<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 -->
<div class="flex items-center gap-3">
<span class="w-8 text-right shrink-0">{Math.round(trimMin)}</span>
@@ -339,12 +398,13 @@
<div class="flex items-center gap-3">
<span class="w-8 text-right shrink-0 text-zinc-500">Bins</span>
<input
type="range" min="5" max="100" step="1"
type="range" min="5" max="20" step="1"
bind:value={bins}
class="flex-1 h-1 accent-zinc-400 cursor-pointer"
/>
<span class="w-8 shrink-0">{bins}</span>
</div>
{/if}
</div>
{/if}