feat: Phase 4 — MapLibre route map + SVG elevation chart on activity screen

- Add /api/activity/{id}/geojson and /api/activity/{id}/timeseries endpoints
  (bearer-token-gated, falls back from _merged to raw activities dir)
- Rewrite activity detail screen with MapLibreGL v11 API (Map, Camera,
  GeoJSONSource, Layer) and react-native-svg area chart with gradient fill
- On-demand fetch for remote activities that have no local geojson/timeseries
- Add react-native-svg dependency; requires dev build (npx expo run:android)
This commit is contained in:
Davide Scaini
2026-04-24 15:40:10 +02:00
parent 02726034c7
commit 97c7fae9be
5 changed files with 507 additions and 164 deletions
+232 -99
View File
@@ -1,11 +1,64 @@
import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useActivity } from '@/db/queries';
import { useEffect, useState } from 'react';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
import { useActivity, useSetting } from '@/db/queries';
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
// ── Types ────────────────────────────────────────────────────────────────────
type Timeseries = {
t: number[];
elevation_m: (number | null)[];
lat?: (number | null)[] | null;
lon?: (number | null)[] | null;
};
// ── Screen ───────────────────────────────────────────────────────────────────
export default function ActivityScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const row = useActivity(id);
const instanceUrl = useSetting('instance_url')?.replace(/\/$/, '') ?? '';
const token = useSetting('api_token') ?? '';
const [geojson, setGeojson] = useState<object | null>(null);
const [timeseries, setTimeseries] = useState<Timeseries | null>(null);
const [loadingMap, setLoadingMap] = useState(false);
const [loadingChart, setLoadingChart] = useState(false);
useEffect(() => {
if (!row) return;
if (row.geojson) {
setGeojson(JSON.parse(row.geojson));
} else if (row.origin === 'remote' && instanceUrl && token) {
setLoadingMap(true);
fetch(`${instanceUrl}/api/activity/${row.id}/geojson`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setGeojson(data); })
.catch(() => {})
.finally(() => setLoadingMap(false));
}
if (row.timeseries_json) {
setTimeseries(JSON.parse(row.timeseries_json));
} else if (row.origin === 'remote' && instanceUrl && token) {
setLoadingChart(true);
fetch(`${instanceUrl}/api/activity/${row.id}/timeseries`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setTimeseries(data); })
.catch(() => {})
.finally(() => setLoadingChart(false));
}
}, [row?.id]);
if (!row) {
return (
@@ -16,29 +69,14 @@ export default function ActivityScreen() {
}
const detail = JSON.parse(row.detail_json);
const km = detail.distance_m != null
? (detail.distance_m / 1000).toFixed(2)
: null;
const elev = detail.elevation_gain_m != null
? Math.round(detail.elevation_gain_m)
: null;
const elevLoss = detail.elevation_loss_m != null
? Math.round(Math.abs(detail.elevation_loss_m))
: null;
const movingTime = detail.moving_time_s != null
? formatDuration(detail.moving_time_s)
: null;
const speed = detail.avg_speed_kmh != null
? detail.avg_speed_kmh.toFixed(1)
: null;
const hr = detail.avg_hr_bpm != null
? Math.round(detail.avg_hr_bpm)
: null;
const power = detail.avg_power_w != null
? Math.round(detail.avg_power_w)
: null;
const date = new Date(detail.started_at).toLocaleDateString(undefined, {
const km = detail.distance_m != null ? (detail.distance_m / 1000).toFixed(2) : null;
const elev = detail.elevation_gain_m != null ? Math.round(detail.elevation_gain_m) : null;
const elevLoss = detail.elevation_loss_m != null ? Math.round(Math.abs(detail.elevation_loss_m)) : null;
const movingTime = detail.moving_time_s != null ? formatDuration(detail.moving_time_s) : null;
const speed = detail.avg_speed_kmh != null ? detail.avg_speed_kmh.toFixed(1) : null;
const hr = detail.avg_hr_bpm != null ? Math.round(detail.avg_hr_bpm) : null;
const power = detail.avg_power_w != null ? Math.round(detail.avg_power_w) : null;
const date = new Date(detail.started_at).toLocaleDateString(undefined, {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
});
@@ -52,39 +90,161 @@ export default function ActivityScreen() {
<Text style={styles.title}>{detail.title}</Text>
<Text style={styles.date}>{date}</Text>
{/* Map placeholder — Phase 1 */}
<View style={styles.mapPlaceholder}>
<Text style={styles.mapPlaceholderText}>Map · Phase 1</Text>
</View>
{/* Map */}
<RouteMap geojson={geojson} loading={loadingMap} />
{/* Stats grid */}
<View style={styles.grid}>
{km && <StatCell label="Distance" value={km} unit="km" />}
{movingTime && <StatCell label="Moving time" value={movingTime} unit="" />}
{elev != null && <StatCell label="Elevation gain" value={String(elev)} unit="m" />}
{elevLoss != null && <StatCell label="Elevation loss" value={String(elevLoss)} unit="m" />}
{speed && <StatCell label="Avg speed" value={speed} unit="km/h" />}
{hr && <StatCell label="Avg HR" value={String(hr)} unit="bpm" />}
{power && <StatCell label="Avg power" value={String(power)} unit="W" />}
{km && <StatCell label="Distance" value={km} unit="km" />}
{movingTime && <StatCell label="Moving time" value={movingTime} unit="" />}
{elev != null && <StatCell label="Elev gain" value={String(elev)} unit="m" />}
{elevLoss != null && <StatCell label="Elev loss" value={String(elevLoss)} unit="m" />}
{speed && <StatCell label="Avg speed" value={speed} unit="km/h"/>}
{hr && <StatCell label="Avg HR" value={String(hr)} unit="bpm" />}
{power && <StatCell label="Avg power" value={String(power)} unit="W" />}
</View>
{/* Elevation chart placeholder — Phase 1 */}
{row.timeseries_json && (
<View style={styles.chartPlaceholder}>
<Text style={styles.mapPlaceholderText}>Elevation chart · Phase 1</Text>
</View>
)}
{/* Elevation chart */}
<ElevationChart timeseries={timeseries} loading={loadingChart} />
{/* Meta */}
<View style={styles.meta}>
<MetaRow label="Source" value={detail.source ?? '—'} />
<MetaRow label="Device" value={detail.device ?? '—'} />
<MetaRow label="Origin" value={row.origin} />
<MetaRow label="Synced" value={row.synced_at ? new Date(row.synced_at * 1000).toLocaleDateString() : 'No'} />
<MetaRow label="Source" value={detail.source ?? '—'} />
<MetaRow label="Device" value={detail.device ?? '—'} />
<MetaRow label="Origin" value={row.origin} />
<MetaRow label="Synced" value={row.synced_at ? new Date(row.synced_at * 1000).toLocaleDateString() : 'No'} />
</View>
</ScrollView>
);
}
// ── Map ───────────────────────────────────────────────────────────────────────
function RouteMap({ geojson, loading }: { geojson: object | null; loading: boolean }) {
if (loading) {
return (
<View style={styles.mapPlaceholder}>
<ActivityIndicator color="#60a5fa" />
</View>
);
}
if (!geojson) return null;
const bounds = geoJsonBounds(geojson);
return (
<View style={styles.mapContainer}>
<Map
style={styles.map}
mapStyle={MAP_STYLE}
dragPan={false}
touchZoom={false}
touchPitch={false}
touchRotate={false}
>
{bounds && (
<Camera
initialViewState={{
bounds,
padding: { top: 24, bottom: 24, left: 24, right: 24 },
}}
/>
)}
<GeoJSONSource id="route" data={geojson as GeoJSON.FeatureCollection}>
<Layer
type="line"
id="route-line"
paint={{ 'line-color': '#60a5fa', 'line-width': 3 }}
layout={{ 'line-cap': 'round', 'line-join': 'round' }}
/>
</GeoJSONSource>
</Map>
</View>
);
}
// ── Elevation chart ───────────────────────────────────────────────────────────
function ElevationChart({ timeseries, loading }: { timeseries: Timeseries | null; loading: boolean }) {
const W = 340;
const H = 100;
const PAD = 4;
if (loading) {
return (
<View style={styles.chartPlaceholder}>
<ActivityIndicator color="#60a5fa" />
</View>
);
}
if (!timeseries) return null;
const raw = timeseries.elevation_m;
if (!raw || raw.length < 2) return null;
// 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 minE = Math.min(...eles);
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 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`;
return (
<View style={styles.chartContainer}>
<Text style={styles.chartLabel}>{Math.round(maxE)} m</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>
</Defs>
<Path d={areaPath} fill="url(#grad)" />
<Path d={linePath} fill="none" stroke="#60a5fa" strokeWidth="1.5" strokeLinejoin="round" />
</Svg>
<Text style={styles.chartLabel}>{Math.round(minE)} m</Text>
</View>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// Returns [west, south, east, north] per LngLatBounds spec
function geoJsonBounds(gj: object): [number, number, number, number] | null {
const coords: [number, number][] = [];
function collect(obj: unknown) {
if (!obj || typeof obj !== 'object') return;
const o = obj as Record<string, unknown>;
if (o.type === 'Feature') { collect(o.geometry); return; }
if (o.type === 'FeatureCollection') { (o.features as unknown[]).forEach(collect); return; }
if (o.type === 'LineString') { coords.push(...(o.coordinates as [number, number][])); return; }
if (o.type === 'MultiLineString') { (o.coordinates as [number, number][][]).forEach(c => coords.push(...c)); return; }
}
collect(gj);
if (!coords.length) return null;
const lons = coords.map(c => c[0]);
const lats = coords.map(c => c[1]);
return [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
function StatCell({ label, value, unit }: { label: string; value: string; unit: string }) {
return (
<View style={styles.statCell}>
@@ -106,59 +266,32 @@ function MetaRow({ label, value }: { label: string; value: string }) {
);
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
// ── Styles ────────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#09090b' },
content: { paddingBottom: 40 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#09090b' },
notFound: { color: '#71717a', fontSize: 16 },
backButton: { paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12 },
backText: { color: '#60a5fa', fontSize: 15 },
sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 },
title: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', paddingHorizontal: 16, marginBottom: 4 },
date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 },
mapPlaceholder: {
height: 200, backgroundColor: '#18181b',
alignItems: 'center', justifyContent: 'center',
borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a',
marginBottom: 16,
},
chartPlaceholder: {
height: 120, backgroundColor: '#18181b',
alignItems: 'center', justifyContent: 'center',
borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a',
marginBottom: 16,
},
mapPlaceholderText: { color: '#3f3f46', fontSize: 13 },
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 },
statValue: { color: '#f4f4f5', fontSize: 24, fontWeight: '700' },
statUnit: { color: '#71717a', fontSize: 13 },
statLabel: { color: '#71717a', fontSize: 12 },
meta: {
marginHorizontal: 16, backgroundColor: '#18181b',
borderRadius: 10, borderWidth: 1, borderColor: '#27272a',
},
metaRow: {
flexDirection: 'row', justifyContent: 'space-between',
paddingHorizontal: 14, paddingVertical: 10,
borderBottomWidth: 1, borderBottomColor: '#27272a',
},
metaLabel: { color: '#71717a', fontSize: 13 },
metaValue: { color: '#a1a1aa', fontSize: 13 },
container: { flex: 1, backgroundColor: '#09090b' },
content: { paddingBottom: 40 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#09090b' },
notFound: { color: '#71717a', fontSize: 16 },
backButton: { paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12 },
backText: { color: '#60a5fa', fontSize: 15 },
sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 },
title: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', paddingHorizontal: 16, marginBottom: 4 },
date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 },
mapContainer: { height: 220, marginBottom: 16, borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a' },
map: { flex: 1 },
mapPlaceholder: { height: 220, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a', marginBottom: 16 },
chartContainer: { marginHorizontal: 16, marginBottom: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', padding: 12, alignItems: 'flex-start' },
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 },
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 },
statValue: { color: '#f4f4f5', fontSize: 24, fontWeight: '700' },
statUnit: { color: '#71717a', fontSize: 13 },
statLabel: { color: '#71717a', fontSize: 12 },
meta: { marginHorizontal: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a' },
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 14, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#27272a' },
metaLabel: { color: '#71717a', fontSize: 13 },
metaValue: { color: '#a1a1aa', fontSize: 13 },
});