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)
)
}