histograms with ranges
This commit is contained in:
@@ -8,38 +8,99 @@
|
||||
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],
|
||||
cadence: timeseries.cadence_rpm[i],
|
||||
}));
|
||||
|
||||
$: hasHR = timeseries.hr_bpm.some(v => v != null);
|
||||
$: hasCadence = timeseries.cadence_rpm.some(v => v != null);
|
||||
$: hasHR = timeseries.hr_bpm.some(v => v != null);
|
||||
$: hasCadence = timeseries.cadence_rpm.some(v => v != null);
|
||||
$: hasElevation = timeseries.elevation_m.some(v => v != null);
|
||||
$: hasSpeed = timeseries.speed_kmh.some(v => v != null);
|
||||
$: hasSpeed = timeseries.speed_kmh.some(v => v != null);
|
||||
$: hasDistance = dist_km !== null;
|
||||
|
||||
const tabLabels: Record<Tab, string> = {
|
||||
elevation: 'Elevation',
|
||||
speed: 'Speed',
|
||||
hr: 'Heart Rate',
|
||||
cadence: 'Cadence',
|
||||
speed: 'Speed',
|
||||
hr: 'Heart Rate',
|
||||
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,99 +110,95 @@
|
||||
|
||||
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;
|
||||
}
|
||||
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' }),
|
||||
],
|
||||
});
|
||||
|
||||
chartEl.appendChild(chart);
|
||||
}
|
||||
</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 :
|
||||
tab === 'speed' ? hasSpeed :
|
||||
tab === 'hr' ? hasHR :
|
||||
tab === 'speed' ? hasSpeed :
|
||||
tab === 'hr' ? hasHR :
|
||||
hasCadence}
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-md text-sm transition-colors"
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user