zone alignment
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user