histograms with ranges
This commit is contained in:
@@ -8,14 +8,32 @@
|
|||||||
export let hoveredIdx: number | null = null;
|
export let hoveredIdx: number | null = null;
|
||||||
|
|
||||||
type Tab = 'elevation' | 'speed' | 'hr' | 'cadence';
|
type Tab = 'elevation' | 'speed' | 'hr' | 'cadence';
|
||||||
|
type XMode = 'time' | 'distance';
|
||||||
|
type ChartType = 'line' | 'histogram';
|
||||||
|
|
||||||
let activeTab: Tab = 'elevation';
|
let activeTab: Tab = 'elevation';
|
||||||
|
let xMode: XMode = 'time';
|
||||||
|
let chartType: ChartType = 'line';
|
||||||
let chartEl: HTMLDivElement;
|
let chartEl: HTMLDivElement;
|
||||||
let chart: SVGElement | null = null;
|
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
|
// Pre-build data array once
|
||||||
$: data = timeseries.t.map((t, i) => ({
|
$: data = timeseries.t.map((t, i) => ({
|
||||||
t,
|
t,
|
||||||
|
dist_km: dist_km ? dist_km[i] : null,
|
||||||
elevation: timeseries.elevation_m[i],
|
elevation: timeseries.elevation_m[i],
|
||||||
speed: timeseries.speed_kmh[i],
|
speed: timeseries.speed_kmh[i],
|
||||||
hr: timeseries.hr_bpm[i],
|
hr: timeseries.hr_bpm[i],
|
||||||
@@ -26,6 +44,7 @@
|
|||||||
$: hasCadence = timeseries.cadence_rpm.some(v => v != null);
|
$: hasCadence = timeseries.cadence_rpm.some(v => v != null);
|
||||||
$: hasElevation = timeseries.elevation_m.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> = {
|
const tabLabels: Record<Tab, string> = {
|
||||||
elevation: 'Elevation',
|
elevation: 'Elevation',
|
||||||
@@ -34,12 +53,54 @@
|
|||||||
cadence: 'Cadence',
|
cadence: 'Cadence',
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
const tabMeta: Record<Tab, { color: string; yLabel: string; yKey: string }> = {
|
||||||
renderChart();
|
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) {
|
$: if (chartEl) {
|
||||||
activeTab; // reactive dependency — re-render when tab changes
|
activeTab; xMode; chartType; histData; histThresholds;
|
||||||
renderChart();
|
renderChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,94 +110,90 @@
|
|||||||
|
|
||||||
const w = chartEl.clientWidth || 800;
|
const w = chartEl.clientWidth || 800;
|
||||||
const h = 220;
|
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[] = [];
|
const marks: any[] = [];
|
||||||
let yLabel = '';
|
|
||||||
let yKey = '';
|
|
||||||
let color = '#00c8ff';
|
|
||||||
|
|
||||||
if (activeTab === 'elevation' && hasElevation) {
|
if (activeTab === 'cadence') {
|
||||||
yKey = 'elevation'; yLabel = 'Elevation (m)'; color = '#00c8ff';
|
marks.push(Plot.lineY(data, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotone-x' }));
|
||||||
|
} else {
|
||||||
marks.push(
|
marks.push(
|
||||||
Plot.areaY(data, { x: 't', y: yKey, fill: color, fillOpacity: 0.15, curve: 'monotone-x' }),
|
Plot.areaY(data, { x, 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' }),
|
Plot.lineY(data, { x, 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' }),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!marks.length) return;
|
|
||||||
|
|
||||||
// Hover crosshair
|
|
||||||
marks.push(
|
marks.push(
|
||||||
Plot.ruleX(data, Plot.pointerX({
|
Plot.ruleX(data, Plot.pointerX({ x, stroke: 'rgba(255,255,255,0.3)', strokeWidth: 1, strokeDasharray: '4,4' })),
|
||||||
x: 't',
|
Plot.dot(data, Plot.pointerX({ x, y: yKey, r: 4, fill: color, stroke: 'white', strokeWidth: 1.5 })),
|
||||||
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.text(data, Plot.pointerX({
|
Plot.text(data, Plot.pointerX({
|
||||||
x: 't', y: yKey,
|
x, y: yKey,
|
||||||
text: (d: any) => d[yKey] != null ? `${Math.round(d[yKey])}` : '',
|
text: (d: any) => d[yKey] != null ? `${Math.round(d[yKey])}` : '',
|
||||||
dy: -12, fill: 'white', fontSize: 11, fontWeight: '600',
|
dy: -12, fill: 'white', fontSize: 11, fontWeight: '600',
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
chart = Plot.plot({
|
const xTickFormat = xMode === 'distance'
|
||||||
width: w,
|
? (v: number) => `${v.toFixed(1)} km`
|
||||||
height: h,
|
: (t: number) => {
|
||||||
marginLeft: 48,
|
|
||||||
marginBottom: 32,
|
|
||||||
style: { background: 'transparent', color: '#a1a1aa', fontSize: '11px' },
|
|
||||||
x: {
|
|
||||||
label: null,
|
|
||||||
tickFormat: (t: number) => {
|
|
||||||
const h = Math.floor(t / 3600);
|
const h = Math.floor(t / 3600);
|
||||||
const m = Math.floor((t % 3600) / 60);
|
const m = Math.floor((t % 3600) / 60);
|
||||||
return h > 0 ? `${h}h${m.toString().padStart(2,'0')}` : `${m}m`;
|
return h > 0 ? `${h}h${m.toString().padStart(2, '0')}` : `${m}m`;
|
||||||
},
|
};
|
||||||
grid: false,
|
|
||||||
ticks: 6,
|
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 },
|
y: { label: yLabel, grid: true, tickCount: 4 },
|
||||||
marks,
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- Tab bar -->
|
<!-- Metric tabs + chart controls -->
|
||||||
<div class="flex gap-1 mb-3">
|
<div class="flex items-center gap-1 mb-3 flex-wrap">
|
||||||
{#each Object.entries(tabLabels) as [tab, label]}
|
{#each Object.entries(tabLabels) as [tab, label]}
|
||||||
{@const enabled =
|
{@const enabled =
|
||||||
tab === 'elevation' ? hasElevation :
|
tab === 'elevation' ? hasElevation :
|
||||||
@@ -157,6 +214,132 @@
|
|||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/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>
|
||||||
|
|
||||||
<div bind:this={chartEl} class="w-full overflow-hidden" />
|
<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