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 = {
|
||||
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 },
|
||||
|
||||
Reference in New Issue
Block a user