feat: tabbed metric charts — elevation, speed, HR, cadence, power
This commit is contained in:
+102
-34
@@ -11,7 +11,11 @@ const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.j
|
|||||||
|
|
||||||
type Timeseries = {
|
type Timeseries = {
|
||||||
t: number[];
|
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;
|
lat?: (number | null)[] | null;
|
||||||
lon?: (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" />}
|
{power && <StatCell label="Avg power" value={String(power)} unit="W" />}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Elevation chart */}
|
{/* Metric charts */}
|
||||||
<ElevationChart timeseries={timeseries} loading={loadingChart} />
|
<MetricCharts timeseries={timeseries} loading={loadingChart} />
|
||||||
|
|
||||||
{/* Meta */}
|
{/* Meta */}
|
||||||
<View style={styles.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 }) {
|
type TabKey = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power';
|
||||||
const W = 340;
|
|
||||||
const H = 100;
|
const TAB_META: Record<TabKey, { label: string; unit: string; color: string; decimals: number }> = {
|
||||||
const PAD = 4;
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -204,41 +216,94 @@ function ElevationChart({ timeseries, loading }: { timeseries: Timeseries | null
|
|||||||
}
|
}
|
||||||
if (!timeseries) return null;
|
if (!timeseries) return null;
|
||||||
|
|
||||||
const raw = timeseries.elevation_m;
|
const seriesMap: Record<TabKey, (number | null)[] | null | undefined> = {
|
||||||
if (!raw || raw.length < 2) return null;
|
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 available = (Object.keys(TAB_META) as TabKey[]).filter(
|
||||||
const step = Math.max(1, Math.floor(raw.length / 300));
|
k => seriesMap[k]?.some(v => v != null)
|
||||||
const times = timeseries.t.filter((_, i) => i % step === 0);
|
);
|
||||||
const eles = raw.filter((_, i) => i % step === 0).map(v => v ?? 0);
|
|
||||||
|
|
||||||
const minE = Math.min(...eles);
|
if (!available.length) return null;
|
||||||
const maxE = Math.max(...eles);
|
|
||||||
const range = maxE - minE || 1;
|
|
||||||
const maxT = times[times.length - 1] || 1;
|
|
||||||
|
|
||||||
const x = (t: number) => PAD + (t / maxT) * (W - PAD * 2);
|
const tab = available.includes(activeTab) ? activeTab : available[0];
|
||||||
const y = (e: number) => PAD + (1 - (e - minE) / range) * (H - PAD * 2);
|
const { color, unit, decimals } = TAB_META[tab];
|
||||||
|
const raw = seriesMap[tab]!;
|
||||||
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`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.chartContainer}>
|
<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}`}>
|
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
|
||||||
<Defs>
|
<Defs>
|
||||||
<LinearGradient id="grad" x1="0" y1="0" x2="0" y2="1">
|
<LinearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||||
<Stop offset="0" stopColor="#60a5fa" stopOpacity="0.35" />
|
<Stop offset="0" stopColor={color} stopOpacity="0.35" />
|
||||||
<Stop offset="1" stopColor="#60a5fa" stopOpacity="0.02" />
|
<Stop offset="1" stopColor={color} stopOpacity="0.02" />
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</Defs>
|
</Defs>
|
||||||
<Path d={areaPath} fill="url(#grad)" />
|
<Path d={areaPath} fill={`url(#${gradId})`} />
|
||||||
<Path d={linePath} fill="none" stroke="#60a5fa" strokeWidth="1.5" strokeLinejoin="round" />
|
<Path d={linePath} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
<Text style={styles.chartLabel}>{Math.round(minE)} m</Text>
|
<Text style={[styles.chartLabel, { color: '#3f3f46', marginBottom: 10 }]}>{fmt(minV)} {unit}</Text>
|
||||||
</View>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,9 +376,12 @@ const styles = StyleSheet.create({
|
|||||||
fullscreenMap: { flex: 1, backgroundColor: '#09090b' },
|
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' },
|
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 },
|
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 },
|
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 },
|
grid: { flexDirection: 'row', flexWrap: 'wrap', paddingHorizontal: 12, gap: 8, marginBottom: 16 },
|
||||||
statCell: { backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', padding: 14, width: '47%' },
|
statCell: { backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', padding: 14, width: '47%' },
|
||||||
statValueRow: { flexDirection: 'row', alignItems: 'baseline', gap: 4, marginBottom: 4 },
|
statValueRow: { flexDirection: 'row', alignItems: 'baseline', gap: 4, marginBottom: 4 },
|
||||||
|
|||||||
Reference in New Issue
Block a user