Initial Commit
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import L from 'leaflet'
|
||||
import { sportColor } from '../../utils/format'
|
||||
|
||||
// Fix Leaflet default icon issue with bundlers
|
||||
delete L.Icon.Default.prototype._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
})
|
||||
|
||||
function decodePolyline(encoded) {
|
||||
// Simple polyline decoder
|
||||
const coords = []
|
||||
let index = 0, lat = 0, lng = 0
|
||||
|
||||
while (index < encoded.length) {
|
||||
let b, shift = 0, result = 0
|
||||
do {
|
||||
b = encoded.charCodeAt(index++) - 63
|
||||
result |= (b & 0x1f) << shift
|
||||
shift += 5
|
||||
} while (b >= 0x20)
|
||||
lat += (result & 1) ? ~(result >> 1) : result >> 1
|
||||
|
||||
shift = 0; result = 0
|
||||
do {
|
||||
b = encoded.charCodeAt(index++) - 63
|
||||
result |= (b & 0x1f) << shift
|
||||
shift += 5
|
||||
} while (b >= 0x20)
|
||||
lng += (result & 1) ? ~(result >> 1) : result >> 1
|
||||
|
||||
coords.push([lat / 1e5, lng / 1e5])
|
||||
}
|
||||
return coords
|
||||
}
|
||||
|
||||
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType }) {
|
||||
const mapRef = useRef(null)
|
||||
const mapInstanceRef = useRef(null)
|
||||
const markerRef = useRef(null)
|
||||
const trackRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return
|
||||
|
||||
mapInstanceRef.current = L.map(mapRef.current, {
|
||||
zoomControl: true,
|
||||
attributionControl: true,
|
||||
})
|
||||
|
||||
// Use CartoDB dark tiles (no API key needed)
|
||||
L.tileLayer(
|
||||
'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
{
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
}
|
||||
).addTo(mapInstanceRef.current)
|
||||
|
||||
return () => {
|
||||
mapInstanceRef.current?.remove()
|
||||
mapInstanceRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Draw route when polyline changes
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !polyline) return
|
||||
|
||||
if (trackRef.current) {
|
||||
trackRef.current.remove()
|
||||
}
|
||||
|
||||
const coords = decodePolyline(polyline)
|
||||
if (!coords.length) return
|
||||
|
||||
trackRef.current = L.polyline(coords, {
|
||||
color: sportColor(sportType),
|
||||
weight: 3,
|
||||
opacity: 0.9,
|
||||
}).addTo(mapInstanceRef.current)
|
||||
|
||||
mapInstanceRef.current.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
|
||||
|
||||
// Start/end markers
|
||||
if (coords.length > 0) {
|
||||
const startIcon = L.divIcon({
|
||||
html: '<div style="width:12px;height:12px;background:#22c55e;border:2px solid white;border-radius:50%"></div>',
|
||||
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
|
||||
})
|
||||
const endIcon = L.divIcon({
|
||||
html: '<div style="width:12px;height:12px;background:#ef4444;border:2px solid white;border-radius:50%"></div>',
|
||||
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
|
||||
})
|
||||
L.marker(coords[0], { icon: startIcon }).addTo(mapInstanceRef.current)
|
||||
L.marker(coords[coords.length - 1], { icon: endIcon }).addTo(mapInstanceRef.current)
|
||||
}
|
||||
}, [polyline, sportType])
|
||||
|
||||
// Move position marker when timeline is hovered
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return
|
||||
|
||||
const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
|
||||
if (!point?.latitude || !point?.longitude) return
|
||||
|
||||
if (markerRef.current) {
|
||||
markerRef.current.setLatLng([point.latitude, point.longitude])
|
||||
} else {
|
||||
const icon = L.divIcon({
|
||||
html: '<div style="width:14px;height:14px;background:#fff;border:3px solid #3b82f6;border-radius:50%;box-shadow:0 0 6px rgba(59,130,246,0.8)"></div>',
|
||||
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
|
||||
})
|
||||
markerRef.current = L.marker([point.latitude, point.longitude], { icon })
|
||||
.addTo(mapInstanceRef.current)
|
||||
}
|
||||
}, [hoveredDistance, dataPoints])
|
||||
|
||||
return <div ref={mapRef} style={{ height: '100%', width: '100%', background: '#1a1a2e' }} />
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
const ZONE_CONFIG = [
|
||||
{ key: 'z1', label: 'Z1 Recovery', color: '#60a5fa' },
|
||||
{ key: 'z2', label: 'Z2 Base', color: '#34d399' },
|
||||
{ key: 'z3', label: 'Z3 Tempo', color: '#fbbf24' },
|
||||
{ key: 'z4', label: 'Z4 Threshold', color: '#f97316' },
|
||||
{ key: 'z5', label: 'Z5 Max', color: '#f43f5e' },
|
||||
]
|
||||
|
||||
export default function HRZoneBar({ zones }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Stacked bar */}
|
||||
<div className="flex h-4 rounded-full overflow-hidden gap-0.5">
|
||||
{ZONE_CONFIG.map(({ key, color }) => {
|
||||
const pct = zones[key] || 0
|
||||
if (pct < 0.5) return null
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||
className="h-full"
|
||||
title={`${key.toUpperCase()}: ${pct}%`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{ZONE_CONFIG.map(({ key, label, color }) => {
|
||||
const pct = zones[key] || 0
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
|
||||
<span className="text-xs text-gray-400">{label}</span>
|
||||
<span className="text-xs font-medium text-white">{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { formatDuration, formatDistance, formatPace, formatHeartRate } from '../../utils/format'
|
||||
|
||||
export default function LapTable({ laps, sportType }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 border-b border-gray-800">
|
||||
<th className="text-left pb-2 font-medium">Lap</th>
|
||||
<th className="text-right pb-2 font-medium">Distance</th>
|
||||
<th className="text-right pb-2 font-medium">Time</th>
|
||||
<th className="text-right pb-2 font-medium">Pace</th>
|
||||
<th className="text-right pb-2 font-medium">Avg HR</th>
|
||||
<th className="text-right pb-2 font-medium">Cadence</th>
|
||||
<th className="text-right pb-2 font-medium">Power</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{laps.map((lap) => (
|
||||
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
|
||||
<td className="py-2 text-gray-400">{lap.lap_number}</td>
|
||||
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
|
||||
<td className="py-2 text-right text-gray-200">{formatDuration(lap.duration_s)}</td>
|
||||
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-400">
|
||||
{lap.avg_cadence ? `${Math.round(lap.avg_cadence)} rpm` : '--'}
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-400">
|
||||
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import {
|
||||
ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, ReferenceLine,
|
||||
} from 'recharts'
|
||||
import { formatDuration, formatPace } 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
|
||||
}
|
||||
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 = `${Math.round(entry.value)} rpm`
|
||||
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))
|
||||
|
||||
// Build per-metric Y-axis domains
|
||||
const domains = useMemo(() => {
|
||||
const result = {}
|
||||
for (const m of activeMetricConfigs) {
|
||||
const vals = chartData.map(p => p[m.key]).filter(v => v != null)
|
||||
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])
|
||||
|
||||
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 data = chartData.filter(p => p[metric.key] != null)
|
||||
if (!data.length) 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 }}>
|
||||
<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={36}
|
||||
tickFormatter={v => {
|
||||
if (metric.key === 'speed_ms') return `${(v * 3.6).toFixed(0)}`
|
||||
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>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Shared distance axis label */}
|
||||
<p className="text-xs text-gray-600 text-center">Distance (km)</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user