diff --git a/site/src/components/ActivityCharts.svelte b/site/src/components/ActivityCharts.svelte index 2802a97..fd681ff 100644 --- a/site/src/components/ActivityCharts.svelte +++ b/site/src/components/ActivityCharts.svelte @@ -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 = { elevation: 'Elevation', - speed: 'Speed', - hr: 'Heart Rate', - cadence: 'Cadence', + speed: 'Speed', + hr: 'Heart Rate', + cadence: 'Cadence', }; - onMount(() => { - renderChart(); + const tabMeta: Record = { + 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); } - -
+ +
{#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} + {/each} +
+ {/if} + +
+ {#each (['line', 'histogram'] as ChartType[]) as type} + + {/each} +
+
+ + +{#if chartType === 'histogram'} +
+ + +
+ {Math.round(trimMin)} +
+ +
+ +
+ + { const v = +e.currentTarget.value; trimMin = v < trimMax - step ? v : trimMax - step; }} + class="range-thumb" + /> + + { const v = +e.currentTarget.value; trimMax = v > trimMin + step ? v : trimMin + step; }} + class="range-thumb" + /> +
+ {Math.round(trimMax)} +
+ + +
+ Bins + + {bins} +
+ +
+{/if} + +