histograms with ranges

This commit is contained in:
Davide Scaini
2026-03-29 21:53:00 +02:00
parent 631eaf2db0
commit 018958f0e9
+250 -67
View File
@@ -8,14 +8,32 @@
export let hoveredIdx: number | null = null;
type Tab = 'elevation' | 'speed' | 'hr' | 'cadence';
type XMode = 'time' | 'distance';
type ChartType = 'line' | 'histogram';
let activeTab: Tab = 'elevation';
let xMode: XMode = 'time';
let chartType: ChartType = 'line';
let chartEl: HTMLDivElement;
let chart: SVGElement | null = null;
// Cumulative distance in km, integrated from speed_kmh
$: dist_km = (() => {
if (!timeseries.speed_kmh.some(v => v != null)) return null;
const d: (number | null)[] = [0];
for (let i = 1; i < timeseries.t.length; i++) {
const v = timeseries.speed_kmh[i];
const dt = timeseries.t[i] - timeseries.t[i - 1];
const prev = d[i - 1];
d.push(v != null && prev != null ? prev + v * dt / 3600 : prev);
}
return d;
})();
// Pre-build data array once
$: data = timeseries.t.map((t, i) => ({
t,
dist_km: dist_km ? dist_km[i] : null,
elevation: timeseries.elevation_m[i],
speed: timeseries.speed_kmh[i],
hr: timeseries.hr_bpm[i],
@@ -26,6 +44,7 @@
$: hasCadence = timeseries.cadence_rpm.some(v => v != null);
$: hasElevation = timeseries.elevation_m.some(v => v != null);
$: hasSpeed = timeseries.speed_kmh.some(v => v != null);
$: hasDistance = dist_km !== null;
const tabLabels: Record<Tab, string> = {
elevation: 'Elevation',
@@ -34,12 +53,54 @@
cadence: 'Cadence',
};
onMount(() => {
renderChart();
const tabMeta: Record<Tab, { color: string; yLabel: string; yKey: string }> = {
elevation: { color: '#00c8ff', yLabel: 'Elevation (m)', yKey: 'elevation' },
speed: { color: '#ff6b35', yLabel: 'Speed (km/h)', yKey: 'speed' },
hr: { color: '#f87171', yLabel: 'Heart Rate (bpm)', yKey: 'hr' },
cadence: { color: '#a78bfa', yLabel: 'Cadence (rpm)', yKey: 'cadence' },
};
// ── Histogram controls ───────────────────────────────────────────────────
let bins = 40;
// Metric values for current tab (non-null)
$: yKey = tabMeta[activeTab].yKey;
$: metricValues = data
.map(d => (d as any)[yKey] as number | null)
.filter((v): v is number => v != null);
$: dataMin = metricValues.length ? Math.min(...metricValues) : 0;
$: dataMax = metricValues.length ? Math.max(...metricValues) : 100;
// Range handles — reset whenever the metric or chart type changes
let trimMin = 0;
let trimMax = 100;
$: if (dataMin !== undefined) resetTrim(dataMin, dataMax);
function resetTrim(lo: number, hi: number) { trimMin = lo; trimMax = hi; }
$: step = (dataMax - dataMin) / 200 || 1;
// Percentage positions for the active-range highlight bar
$: span = dataMax - dataMin || 1;
$: leftPct = ((trimMin - dataMin) / span) * 100;
$: rightPct = ((dataMax - trimMax) / span) * 100;
// Pre-filtered data + explicit evenly-spaced thresholds anchored to [trimMin, trimMax].
// d3's count-based thresholds snap to "nice" values and produce the wrong bin count
// when the range is narrow — explicit thresholds give exactly `bins` bins always.
$: histData = data.filter(d => {
const v = (d as any)[yKey];
return v != null && v >= trimMin && v <= trimMax;
});
$: histThresholds = Array.from(
{ length: bins - 1 },
(_, i) => trimMin + (i + 1) * (trimMax - trimMin) / bins,
);
// ── Rendering ────────────────────────────────────────────────────────────
onMount(() => { renderChart(); });
$: if (chartEl) {
activeTab; // reactive dependency — re-render when tab changes
activeTab; xMode; chartType; histData; histThresholds;
renderChart();
}
@@ -49,94 +110,90 @@
const w = chartEl.clientWidth || 800;
const h = 220;
const { color, yLabel, yKey } = tabMeta[activeTab];
const tabEnabled =
activeTab === 'elevation' ? hasElevation :
activeTab === 'speed' ? hasSpeed :
activeTab === 'hr' ? hasHR :
hasCadence;
if (!tabEnabled) return;
chart = chartType === 'histogram'
? renderHistogram(w, h, yKey, yLabel, color)
: renderLine(w, h, yKey, yLabel, color);
if (chartType === 'line') {
chart.addEventListener('input', () => {
const pt = (chart as any)?.value;
hoveredIdx = pt ? timeseries.t.findIndex(t => t === pt.t) : null;
});
}
chartEl.appendChild(chart);
}
function renderLine(w: number, h: number, yKey: string, yLabel: string, color: string) {
const x = xMode === 'distance' ? 'dist_km' : 't';
const marks: any[] = [];
let yLabel = '';
let yKey = '';
let color = '#00c8ff';
if (activeTab === 'elevation' && hasElevation) {
yKey = 'elevation'; yLabel = 'Elevation (m)'; color = '#00c8ff';
if (activeTab === 'cadence') {
marks.push(Plot.lineY(data, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotone-x' }));
} else {
marks.push(
Plot.areaY(data, { x: 't', y: yKey, fill: color, fillOpacity: 0.15, curve: 'monotone-x' }),
Plot.lineY(data, { x: 't', y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotone-x' }),
);
} else if (activeTab === 'speed' && hasSpeed) {
yKey = 'speed'; yLabel = 'Speed (km/h)'; color = '#ff6b35';
marks.push(
Plot.areaY(data, { x: 't', y: yKey, fill: color, fillOpacity: 0.15, curve: 'monotone-x' }),
Plot.lineY(data, { x: 't', y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotone-x' }),
);
} else if (activeTab === 'hr' && hasHR) {
yKey = 'hr'; yLabel = 'Heart Rate (bpm)'; color = '#f87171';
marks.push(
Plot.areaY(data, { x: 't', y: yKey, fill: color, fillOpacity: 0.15, curve: 'monotone-x' }),
Plot.lineY(data, { x: 't', y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotone-x' }),
);
} else if (activeTab === 'cadence' && hasCadence) {
yKey = 'cadence'; yLabel = 'Cadence (rpm)'; color = '#a78bfa';
marks.push(
Plot.lineY(data, { x: 't', y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotone-x' }),
Plot.areaY(data, { x, y: yKey, fill: color, fillOpacity: 0.15, curve: 'monotone-x' }),
Plot.lineY(data, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotone-x' }),
);
}
if (!marks.length) return;
// Hover crosshair
marks.push(
Plot.ruleX(data, Plot.pointerX({
x: 't',
stroke: 'rgba(255,255,255,0.3)',
strokeWidth: 1,
strokeDasharray: '4,4',
})),
Plot.dot(data, Plot.pointerX({
x: 't', y: yKey,
r: 4, fill: color, stroke: 'white', strokeWidth: 1.5,
})),
Plot.ruleX(data, Plot.pointerX({ x, stroke: 'rgba(255,255,255,0.3)', strokeWidth: 1, strokeDasharray: '4,4' })),
Plot.dot(data, Plot.pointerX({ x, y: yKey, r: 4, fill: color, stroke: 'white', strokeWidth: 1.5 })),
Plot.text(data, Plot.pointerX({
x: 't', y: yKey,
x, y: yKey,
text: (d: any) => d[yKey] != null ? `${Math.round(d[yKey])}` : '',
dy: -12, fill: 'white', fontSize: 11, fontWeight: '600',
})),
);
chart = Plot.plot({
width: w,
height: h,
marginLeft: 48,
marginBottom: 32,
style: { background: 'transparent', color: '#a1a1aa', fontSize: '11px' },
x: {
label: null,
tickFormat: (t: number) => {
const xTickFormat = xMode === 'distance'
? (v: number) => `${v.toFixed(1)} km`
: (t: number) => {
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
return h > 0 ? `${h}h${m.toString().padStart(2,'0')}` : `${m}m`;
},
grid: false,
ticks: 6,
},
return h > 0 ? `${h}h${m.toString().padStart(2, '0')}` : `${m}m`;
};
return Plot.plot({
width: w, height: h, marginLeft: 48, marginBottom: 32,
style: { background: 'transparent', color: '#a1a1aa', fontSize: '11px' },
x: { label: null, tickFormat: xTickFormat, grid: false, ticks: 6 },
y: { label: yLabel, grid: true, tickCount: 4 },
marks,
});
// Attach pointer listener to emit hover index
chart.addEventListener('input', () => {
const pt = (chart as any)?.value;
if (pt) {
hoveredIdx = timeseries.t.findIndex(t => t === pt.t);
} else {
hoveredIdx = null;
}
});
chartEl.appendChild(chart);
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`;
return Plot.plot({
width: w, height: h, marginLeft: 48, marginBottom: 32,
style: { background: 'transparent', color: '#a1a1aa', fontSize: '11px' },
x: { label: yLabel, grid: false, ticks: 6, domain: [trimMin, trimMax] },
y: { label: 'Time', grid: true, tickCount: 4, tickFormat: yTickFormat },
marks: [
Plot.rectY(histData, Plot.binX(
{ y: 'count' },
{ x: yKey, fill: color, fillOpacity: 0.7, thresholds: histThresholds },
)),
Plot.ruleY([0], { stroke: '#52525b' }),
],
});
}
</script>
<!-- Tab bar -->
<div class="flex gap-1 mb-3">
<!-- Metric tabs + chart controls -->
<div class="flex items-center gap-1 mb-3 flex-wrap">
{#each Object.entries(tabLabels) as [tab, label]}
{@const enabled =
tab === 'elevation' ? hasElevation :
@@ -157,6 +214,132 @@
{label}
</button>
{/each}
<div class="flex-1" />
<div class="flex items-center gap-3 text-xs text-zinc-500">
{#if hasDistance && chartType === 'line'}
<div class="flex items-center gap-1">
<span class="mr-0.5">X:</span>
{#each (['time', 'distance'] as XMode[]) as mode}
<button
class="px-2 py-1 rounded transition-colors"
class:bg-zinc-800={xMode === mode}
class:text-white={xMode === mode}
class:hover:text-zinc-300={xMode !== mode}
on:click={() => xMode = mode}
>{mode === 'time' ? 'Time' : 'Dist'}</button>
{/each}
</div>
{/if}
<div class="flex items-center gap-1">
{#each (['line', 'histogram'] as ChartType[]) as type}
<button
class="px-2 py-1 rounded transition-colors"
class:bg-zinc-800={chartType === type}
class:text-white={chartType === type}
class:hover:text-zinc-300={chartType !== type}
on:click={() => chartType = type}
title={type === 'line' ? 'Time series' : 'Distribution'}
>{type === 'line' ? '↗ Line' : '▭ Hist'}</button>
{/each}
</div>
</div>
</div>
<div bind:this={chartEl} class="w-full overflow-hidden" />
<!-- Histogram controls (range + bins) -->
{#if chartType === 'histogram'}
<div class="mt-3 flex flex-col gap-2 text-xs text-zinc-400">
<!-- Dual range slider -->
<div class="flex items-center gap-3">
<span class="w-8 text-right shrink-0">{Math.round(trimMin)}</span>
<div class="relative flex-1" style="height:20px">
<!-- Background track -->
<div class="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-1 bg-zinc-700 rounded-full pointer-events-none" />
<!-- Active range fill -->
<div
class="absolute top-1/2 -translate-y-1/2 h-1 bg-zinc-400 rounded-full pointer-events-none"
style="left:{leftPct}%; right:{rightPct}%"
/>
<!-- Min handle -->
<input
type="range"
min={dataMin} max={dataMax} {step}
value={trimMin}
on:input={(e) => { const v = +e.currentTarget.value; trimMin = v < trimMax - step ? v : trimMax - step; }}
class="range-thumb"
/>
<!-- Max handle -->
<input
type="range"
min={dataMin} max={dataMax} {step}
value={trimMax}
on:input={(e) => { const v = +e.currentTarget.value; trimMax = v > trimMin + step ? v : trimMin + step; }}
class="range-thumb"
/>
</div>
<span class="w-8 shrink-0">{Math.round(trimMax)}</span>
</div>
<!-- Bins slider -->
<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"
bind:value={bins}
class="flex-1 h-1 accent-zinc-400 cursor-pointer"
/>
<span class="w-8 shrink-0">{bins}</span>
</div>
</div>
{/if}
<style>
.range-thumb {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
appearance: none;
-webkit-appearance: none;
background: transparent;
pointer-events: none;
}
.range-thumb::-webkit-slider-runnable-track {
background: transparent;
height: 4px;
}
.range-thumb::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #e4e4e7;
border: 2px solid #52525b;
cursor: pointer;
pointer-events: all;
margin-top: -5px; /* center on 4px track */
}
.range-thumb::-moz-range-track {
background: transparent;
height: 4px;
}
.range-thumb::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: #e4e4e7;
border: 2px solid #52525b;
cursor: pointer;
pointer-events: all;
}
</style>