diff --git a/mobile/app/activity/[id].tsx b/mobile/app/activity/[id].tsx index 10b0a8b..3d73e70 100644 --- a/mobile/app/activity/[id].tsx +++ b/mobile/app/activity/[id].tsx @@ -11,7 +11,11 @@ const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.j type Timeseries = { t: number[]; - elevation_m: (number | null)[]; + elevation_m: (number | null)[]; + speed_kmh?: (number | null)[] | null; + hr_bpm?: (number | null)[] | null; + cadence_rpm?: (number | null)[] | null; + power_w?: (number | null)[] | null; lat?: (number | null)[] | null; lon?: (number | null)[] | null; }; @@ -104,8 +108,8 @@ export default function ActivityScreen() { {power && } - {/* Elevation chart */} - + {/* Metric charts */} + {/* Meta */} @@ -188,12 +192,20 @@ function RouteMap({ geojson, loading }: { geojson: object | null; loading: boole ); } -// ── Elevation chart ─────────────────────────────────────────────────────────── +// ── Metric charts ───────────────────────────────────────────────────────────── -function ElevationChart({ timeseries, loading }: { timeseries: Timeseries | null; loading: boolean }) { - const W = 340; - const H = 100; - const PAD = 4; +type TabKey = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power'; + +const TAB_META: Record = { + elevation: { label: 'Elevation', unit: 'm', color: '#00c8ff', decimals: 0 }, + speed: { label: 'Speed', unit: 'km/h', color: '#ff6b35', decimals: 1 }, + hr: { label: 'HR', unit: 'bpm', color: '#f87171', decimals: 0 }, + cadence: { label: 'Cadence', unit: 'rpm', color: '#a78bfa', decimals: 0 }, + power: { label: 'Power', unit: 'W', color: '#facc15', decimals: 0 }, +}; + +function MetricCharts({ timeseries, loading }: { timeseries: Timeseries | null; loading: boolean }) { + const [activeTab, setActiveTab] = useState('elevation'); if (loading) { return ( @@ -204,41 +216,94 @@ function ElevationChart({ timeseries, loading }: { timeseries: Timeseries | null } if (!timeseries) return null; - const raw = timeseries.elevation_m; - if (!raw || raw.length < 2) return null; + const seriesMap: Record = { + elevation: timeseries.elevation_m, + speed: timeseries.speed_kmh, + hr: timeseries.hr_bpm, + cadence: timeseries.cadence_rpm, + power: timeseries.power_w, + }; - // Downsample to ≤300 points - const step = Math.max(1, Math.floor(raw.length / 300)); - const times = timeseries.t.filter((_, i) => i % step === 0); - const eles = raw.filter((_, i) => i % step === 0).map(v => v ?? 0); + const available = (Object.keys(TAB_META) as TabKey[]).filter( + k => seriesMap[k]?.some(v => v != null) + ); - const minE = Math.min(...eles); - const maxE = Math.max(...eles); - const range = maxE - minE || 1; - const maxT = times[times.length - 1] || 1; + if (!available.length) return null; - const x = (t: number) => PAD + (t / maxT) * (W - PAD * 2); - const y = (e: number) => PAD + (1 - (e - minE) / range) * (H - PAD * 2); - - const pts = times.map((t, i) => `${x(t).toFixed(1)},${y(eles[i]).toFixed(1)}`); - const linePath = `M ${pts.join(' L ')}`; - const areaPath = `M ${x(times[0])},${H} L ${pts.join(' L ')} L ${x(maxT)},${H} Z`; + const tab = available.includes(activeTab) ? activeTab : available[0]; + const { color, unit, decimals } = TAB_META[tab]; + const raw = seriesMap[tab]!; return ( - {Math.round(maxE)} m + {/* Tab row */} + + {available.map(k => ( + setActiveTab(k)} + > + + {TAB_META[k].label} + + + ))} + + {/* Chart */} + + + ); +} + +function MetricChart({ + times, values, color, unit, decimals, +}: { + times: number[]; + values: (number | null)[]; + color: string; + unit: string; + decimals: number; +}) { + const W = 340; + const H = 100; + const PAD = 4; + + // Downsample to ≤300 points + const step = Math.max(1, Math.floor(values.length / 300)); + const ts = times.filter((_, i) => i % step === 0); + const vs = values.filter((_, i) => i % step === 0).map(v => v ?? 0); + + const minV = Math.min(...vs); + const maxV = Math.max(...vs); + const range = maxV - minV || 1; + const maxT = ts[ts.length - 1] || 1; + + const x = (t: number) => PAD + (t / maxT) * (W - PAD * 2); + const y = (v: number) => PAD + (1 - (v - minV) / range) * (H - PAD * 2); + + const pts = ts.map((t, i) => `${x(t).toFixed(1)},${y(vs[i]).toFixed(1)}`); + const linePath = `M ${pts.join(' L ')}`; + const areaPath = `M ${x(ts[0])},${H} L ${pts.join(' L ')} L ${x(maxT)},${H} Z`; + const gradId = `grad-${color.replace('#', '')}`; + + const fmt = (v: number) => decimals === 0 ? String(Math.round(v)) : v.toFixed(decimals); + + return ( + <> + {fmt(maxV)} {unit} - - - + + + - - + + - {Math.round(minE)} m - + {fmt(minV)} {unit} + ); } @@ -311,9 +376,12 @@ const styles = StyleSheet.create({ fullscreenMap: { flex: 1, backgroundColor: '#09090b' }, closeButton: { position: 'absolute', top: 56, right: 16, backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' }, closeText: { color: '#fff', fontSize: 16 }, - chartContainer: { marginHorizontal: 16, marginBottom: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', padding: 12, alignItems: 'flex-start' }, + chartContainer: { marginHorizontal: 16, marginBottom: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', overflow: 'hidden' }, chartPlaceholder: { height: 120, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', marginHorizontal: 16, marginBottom: 16 }, - chartLabel: { color: '#3f3f46', fontSize: 10, marginBottom: 2 }, + chartTabs: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#27272a' }, + chartTab: { flex: 1, paddingVertical: 8, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' }, + chartTabText: { color: '#52525b', fontSize: 11, fontWeight: '600' }, + chartLabel: { color: '#3f3f46', fontSize: 10, marginBottom: 2, marginHorizontal: 12, marginTop: 10 }, grid: { flexDirection: 'row', flexWrap: 'wrap', paddingHorizontal: 12, gap: 8, marginBottom: 16 }, statCell: { backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', padding: 14, width: '47%' }, statValueRow: { flexDirection: 'row', alignItems: 'baseline', gap: 4, marginBottom: 4 },