import { useMemo } from 'react' import { ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts' import { formatPace, formatCadence } from '../../utils/format' function downsample(points, maxPoints = 500) { if (points.length <= maxPoints) return points const step = Math.ceil(points.length / maxPoints) return points.filter((_, i) => i % step === 0) } function buildChartData(dataPoints, activeMetrics) { return dataPoints .filter(p => p.timestamp) .map(p => { const row = { distance_m: p.distance_m ?? 0 } for (const key of activeMetrics) { row[key] = (p[key] != null && p[key] !== 0) ? p[key] : null } return row }) } const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => { if (!active || !payload?.length) return null if (onHover) onHover(label) return (

{(label / 1000).toFixed(2)} km

{payload.map(entry => { const metric = metrics.find(m => m.key === entry.dataKey) if (!metric || entry.value == null) return null let display = entry.value.toFixed(1) if (entry.dataKey === 'speed_ms') display = formatPace(entry.value, sportType) else if (entry.dataKey === 'heart_rate') display = `${Math.round(entry.value)} bpm` else if (entry.dataKey === 'cadence') display = formatCadence(entry.value, sportType) else if (entry.dataKey === 'power') display = `${Math.round(entry.value)} W` else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C` else if (entry.dataKey === 'altitude_m') display = `${entry.value.toFixed(0)} m` return (
{metric.label}: {display}
) })}
) } export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onHoverDistance, sportType }) { const chartData = useMemo(() => downsample(buildChartData(dataPoints, activeMetrics)), [dataPoints, activeMetrics] ) const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key)) const domains = useMemo(() => { const result = {} for (const m of activeMetricConfigs) { let vals = chartData.map(p => p[m.key]).filter(v => v != null) if (!vals.length) continue // Clamp GPS speed outliers (spikes cause absurd pace labels like 0:01/km) if (m.key === 'speed_ms') { const speedCap = sportType === 'cycling' ? 25 : 12 vals = vals.filter(v => v > 0 && v <= speedCap) if (!vals.length) continue } const min = Math.min(...vals) const max = Math.max(...vals) const pad = (max - min) * 0.1 || 1 result[m.key] = [min - pad, max + pad] } return result }, [chartData, activeMetricConfigs, sportType]) if (!chartData.length) { return (
No timeline data available
) } return (
{activeMetricConfigs.map((metric, idx) => { const domain = domains[metric.key] || ['auto', 'auto'] const hasData = chartData.some(p => p[metric.key] != null) if (!hasData) return null return (
{metric.label} {metric.unit && ({metric.unit})}
`${(v / 1000).toFixed(1)}`} tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} hide={idx < activeMetricConfigs.length - 1} /> { if (metric.key === 'speed_ms') { if (v <= 0 || v > 25) return '' if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}` const spm = 1000 / v return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}` } if (metric.key === 'cadence') return Math.round(v * (sportType === 'running' ? 2 : 1)) return Math.round(v) }} /> } isAnimationActive={false} />
) })}

Distance (km)

) }