feat: tabbed metric charts — elevation, speed, HR, cadence, power

This commit is contained in:
Davide Scaini
2026-04-24 22:03:51 +02:00
parent 3ce365e439
commit a1e56e5308
+102 -34
View File
@@ -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 && <StatCell label="Avg power" value={String(power)} unit="W" />}
</View>
{/* Elevation chart */}
<ElevationChart timeseries={timeseries} loading={loadingChart} />
{/* Metric charts */}
<MetricCharts timeseries={timeseries} loading={loadingChart} />
{/* Meta */}
<View style={styles.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<TabKey, { label: string; unit: string; color: string; decimals: number }> = {
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<TabKey>('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<TabKey, (number | null)[] | null | undefined> = {
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 (
<View style={styles.chartContainer}>
<Text style={styles.chartLabel}>{Math.round(maxE)} m</Text>
{/* Tab row */}
<View style={styles.chartTabs}>
{available.map(k => (
<Pressable
key={k}
style={[styles.chartTab, tab === k && { borderBottomColor: TAB_META[k].color, borderBottomWidth: 2 }]}
onPress={() => setActiveTab(k)}
>
<Text style={[styles.chartTabText, tab === k && { color: TAB_META[k].color }]}>
{TAB_META[k].label}
</Text>
</Pressable>
))}
</View>
{/* Chart */}
<MetricChart key={tab} times={timeseries.t} values={raw} color={color} unit={unit} decimals={decimals} />
</View>
);
}
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 (
<>
<Text style={[styles.chartLabel, { color }]}>{fmt(maxV)} {unit}</Text>
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
<Defs>
<LinearGradient id="grad" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#60a5fa" stopOpacity="0.35" />
<Stop offset="1" stopColor="#60a5fa" stopOpacity="0.02" />
<LinearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor={color} stopOpacity="0.35" />
<Stop offset="1" stopColor={color} stopOpacity="0.02" />
</LinearGradient>
</Defs>
<Path d={areaPath} fill="url(#grad)" />
<Path d={linePath} fill="none" stroke="#60a5fa" strokeWidth="1.5" strokeLinejoin="round" />
<Path d={areaPath} fill={`url(#${gradId})`} />
<Path d={linePath} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
</Svg>
<Text style={styles.chartLabel}>{Math.round(minE)} m</Text>
</View>
<Text style={[styles.chartLabel, { color: '#3f3f46', marginBottom: 10 }]}>{fmt(minV)} {unit}</Text>
</>
);
}
@@ -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 },