154 lines
6.0 KiB
React
154 lines
6.0 KiB
React
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 (
|
|
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
|
|
<p className="text-gray-400 mb-1">{(label / 1000).toFixed(2)} km</p>
|
|
{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 (
|
|
<div key={entry.dataKey} className="flex items-center gap-2">
|
|
<span style={{ color: entry.color }}>●</span>
|
|
<span className="text-gray-300">{metric.label}:</span>
|
|
<span className="text-white font-medium">{display}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="flex items-center justify-center h-48 text-gray-600 text-sm">
|
|
No timeline data available
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{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 (
|
|
<div key={metric.key}>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span style={{ color: metric.color }} className="text-xs font-medium">{metric.label}</span>
|
|
{metric.unit && <span className="text-xs text-gray-600">({metric.unit})</span>}
|
|
</div>
|
|
<ResponsiveContainer width="100%" height={100}>
|
|
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }} syncId="activity-metrics">
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
|
<XAxis
|
|
dataKey="distance_m"
|
|
type="number"
|
|
domain={['dataMin', 'dataMax']}
|
|
tickFormatter={v => `${(v / 1000).toFixed(1)}`}
|
|
tick={{ fontSize: 10, fill: '#6b7280' }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
hide={idx < activeMetricConfigs.length - 1}
|
|
/>
|
|
<YAxis
|
|
domain={domain}
|
|
tick={{ fontSize: 10, fill: '#6b7280' }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
width={40}
|
|
tickFormatter={v => {
|
|
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)
|
|
}}
|
|
/>
|
|
<Tooltip
|
|
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} />}
|
|
isAnimationActive={false}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey={metric.key}
|
|
stroke={metric.color}
|
|
strokeWidth={1.5}
|
|
dot={false}
|
|
isAnimationActive={false}
|
|
connectNulls={false}
|
|
/>
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)
|
|
})}
|
|
<p className="text-xs text-gray-600 text-center">Distance (km)</p>
|
|
</div>
|
|
)
|
|
}
|