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