feat: section 5 — MapLibre map with live track and camera follow
Replace SVG TrackView with a real MapLibre map: - OpenFreeMap liberty tiles (no API key) - Camera follows user in course mode while recording - GeoJSONSource + LineLayer renders track polyline updated live - UserLocation dot shows current GPS position - Sensors button overlaid with semi-transparent background
This commit is contained in:
@@ -121,11 +121,9 @@ Items below are what remains before v1 is shippable.
|
|||||||
|
|
||||||
- [x] `src/services/batteryOptimization.ts` — Android-only, one-time prompt (dismissed flag in AsyncStorage); uses `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` intent to open the system dialog for bincio-rec directly; falls back to `IGNORE_BATTERY_OPTIMIZATION_SETTINGS` (general page) on OEMs that block the direct intent; called from `App.tsx` on mount alongside notification permission request
|
- [x] `src/services/batteryOptimization.ts` — Android-only, one-time prompt (dismissed flag in AsyncStorage); uses `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` intent to open the system dialog for bincio-rec directly; falls back to `IGNORE_BATTERY_OPTIMIZATION_SETTINGS` (general page) on OEMs that block the direct intent; called from `App.tsx` on mount alongside notification permission request
|
||||||
|
|
||||||
### 5 — Map (optional upgrade)
|
### 5 — Map ✅
|
||||||
|
|
||||||
The track view from section 1 already shows the GPX polyline scaled to fit the screen.
|
- [x] **MapLibre basemap** — `Map` with OpenFreeMap liberty style (`tiles.openfreemap.org`); no API key required; logo and attribution hidden
|
||||||
MapLibre can be added later for a real basemap (streets/terrain), but is not required for v1.
|
- [x] **Camera follow** — `Camera` with `trackUserLocation="course"` while recording; switches off when idle/paused
|
||||||
|
- [x] **Live track line** — `GeoJSONSource` fed a memoized `LineString` from `trackPoints`; `Layer` with `type="line"` and blue stroke rendered on top of the basemap
|
||||||
- [ ] **MapLibre basemap** — replace `TrackView` SVG with `<MapLibreGL.MapView>` + a tile source (e.g. OpenFreeMap); call `MapLibreGL.setAccessToken(null)` for raster-free usage
|
- [x] **User location dot** — `UserLocation` component shows current position on map
|
||||||
- [ ] **Camera follow** — add `<MapLibreGL.Camera>` that follows `trackPoints[last]` during recording
|
|
||||||
- [ ] **Line layer** — replace SVG polyline with `<MapLibreGL.ShapeSource>` + `<MapLibreGL.LineLayer>`
|
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Alert, LayoutChangeEvent } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
|
||||||
import Svg, { Polyline } from 'react-native-svg';
|
import { Map, Camera, GeoJSONSource, Layer, UserLocation } from '@maplibre/maplibre-react-native';
|
||||||
|
import type { LineLayerStyle } from '@maplibre/maplibre-react-native';
|
||||||
import { useRecordingStore } from '../store/recording';
|
import { useRecordingStore } from '../store/recording';
|
||||||
import { startGpsRecording, stopGpsRecording, requestLocationPermissions } from '../services/gps';
|
import { startGpsRecording, stopGpsRecording, requestLocationPermissions } from '../services/gps';
|
||||||
import { RootStackParamList, TrackPoint } from '../types';
|
import { RootStackParamList, TrackPoint } from '../types';
|
||||||
|
|
||||||
|
const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
|
||||||
|
|
||||||
|
const trackLineStyle: LineLayerStyle = {
|
||||||
|
lineColor: '#3b82f6',
|
||||||
|
lineWidth: 3,
|
||||||
|
lineJoin: 'round',
|
||||||
|
lineCap: 'round',
|
||||||
|
};
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
export function RecordingScreen() {
|
export function RecordingScreen() {
|
||||||
const nav = useNavigation<Nav>();
|
const nav = useNavigation<Nav>();
|
||||||
const { status, ble, keepAwake, trackPoints, start, pause, resume, stop, setKeepAwake, getStats } = useRecordingStore();
|
const { status, ble, keepAwake, trackPoints, start, pause, resume, stop, setKeepAwake, getStats } = useRecordingStore();
|
||||||
const [stats, setStats] = useState(getStats());
|
const [stats, setStats] = useState(getStats());
|
||||||
const [mapSize, setMapSize] = useState({ width: 0, height: 0 });
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -30,6 +39,15 @@ export function RecordingScreen() {
|
|||||||
}
|
}
|
||||||
}, [keepAwake, status]);
|
}, [keepAwake, status]);
|
||||||
|
|
||||||
|
const trackGeoJSON = useMemo<GeoJSON.Feature<GeoJSON.LineString>>(() => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: trackPoints.map((p) => [p.lon, p.lat]),
|
||||||
|
},
|
||||||
|
properties: {},
|
||||||
|
}), [trackPoints]);
|
||||||
|
|
||||||
async function handleStart() {
|
async function handleStart() {
|
||||||
const granted = await requestLocationPermissions();
|
const granted = await requestLocationPermissions();
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
@@ -76,16 +94,20 @@ export function RecordingScreen() {
|
|||||||
<StatBox label="Cadence" value={ble.cadence ? `${ble.cadence} rpm` : '—'} />
|
<StatBox label="Cadence" value={ble.cadence ? `${ble.cadence} rpm` : '—'} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
<View style={styles.mapArea}>
|
||||||
style={styles.mapArea}
|
<Map mapStyle={MAP_STYLE} style={StyleSheet.absoluteFill} logo={false} attribution={false}>
|
||||||
onLayout={(e: LayoutChangeEvent) => {
|
<Camera
|
||||||
const { width, height } = e.nativeEvent.layout;
|
trackUserLocation={status === 'recording' ? 'course' : undefined}
|
||||||
setMapSize({ width, height });
|
initialViewState={{ zoom: 14 }}
|
||||||
}}
|
/>
|
||||||
>
|
<UserLocation />
|
||||||
{mapSize.width > 0 && (
|
{trackPoints.length >= 2 && (
|
||||||
<TrackView trackPoints={trackPoints} width={mapSize.width} height={mapSize.height} />
|
<GeoJSONSource id="track" data={trackGeoJSON}>
|
||||||
)}
|
<Layer id="track-line" type="line" style={trackLineStyle} />
|
||||||
|
</GeoJSONSource>
|
||||||
|
)}
|
||||||
|
</Map>
|
||||||
|
|
||||||
<TouchableOpacity style={styles.sensorBtn} onPress={() => nav.navigate('SensorPairing')}>
|
<TouchableOpacity style={styles.sensorBtn} onPress={() => nav.navigate('SensorPairing')}>
|
||||||
<Text style={styles.sensorBtnText}>⚡ Sensors</Text>
|
<Text style={styles.sensorBtnText}>⚡ Sensors</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -129,45 +151,6 @@ export function RecordingScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PADDING = 16;
|
|
||||||
|
|
||||||
function TrackView({ trackPoints, width, height }: { trackPoints: TrackPoint[]; width: number; height: number }) {
|
|
||||||
if (trackPoints.length < 2) {
|
|
||||||
return (
|
|
||||||
<View style={StyleSheet.absoluteFill}>
|
|
||||||
<Text style={styles.mapHint}>
|
|
||||||
{trackPoints.length === 0 ? 'Start recording to see your track' : '…'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lats = trackPoints.map((p) => p.lat);
|
|
||||||
const lons = trackPoints.map((p) => p.lon);
|
|
||||||
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
|
|
||||||
const minLon = Math.min(...lons), maxLon = Math.max(...lons);
|
|
||||||
const latRange = maxLat - minLat || 0.0001;
|
|
||||||
const lonRange = maxLon - minLon || 0.0001;
|
|
||||||
|
|
||||||
const w = width - PADDING * 2;
|
|
||||||
const h = height - PADDING * 2;
|
|
||||||
|
|
||||||
// preserve aspect ratio
|
|
||||||
const scale = Math.min(w / lonRange, h / latRange);
|
|
||||||
const offX = (w - lonRange * scale) / 2 + PADDING;
|
|
||||||
const offY = (h - latRange * scale) / 2 + PADDING;
|
|
||||||
|
|
||||||
const points = trackPoints
|
|
||||||
.map((p) => `${offX + (p.lon - minLon) * scale},${offY + (maxLat - p.lat) * scale}`)
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Svg width={width} height={height} style={StyleSheet.absoluteFill}>
|
|
||||||
<Polyline points={points} fill="none" stroke="#3b82f6" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatBox({ label, value }: { label: string; value: string }) {
|
function StatBox({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.statBox}>
|
<View style={styles.statBox}>
|
||||||
@@ -183,9 +166,8 @@ const styles = StyleSheet.create({
|
|||||||
statBox: { width: '25%', padding: 8, alignItems: 'center' },
|
statBox: { width: '25%', padding: 8, alignItems: 'center' },
|
||||||
statLabel: { color: '#888', fontSize: 11, textTransform: 'uppercase' },
|
statLabel: { color: '#888', fontSize: 11, textTransform: 'uppercase' },
|
||||||
statValue: { color: '#fff', fontSize: 18, fontWeight: '600', marginTop: 2 },
|
statValue: { color: '#fff', fontSize: 18, fontWeight: '600', marginTop: 2 },
|
||||||
mapArea: { flex: 1, backgroundColor: '#0d0d1a', overflow: 'hidden' },
|
mapArea: { flex: 1, overflow: 'hidden' },
|
||||||
mapHint: { color: '#333', fontSize: 13, textAlign: 'center', marginTop: 40 },
|
sensorBtn: { position: 'absolute', top: 10, right: 10, backgroundColor: 'rgba(17,17,17,0.85)', borderRadius: 8, paddingVertical: 6, paddingHorizontal: 12 },
|
||||||
sensorBtn: { position: 'absolute', top: 10, right: 10, backgroundColor: '#1e1e2e', borderRadius: 8, paddingVertical: 6, paddingHorizontal: 12 },
|
|
||||||
sensorBtnText: { color: '#3b82f6', fontSize: 13, fontWeight: '600' },
|
sensorBtnText: { color: '#3b82f6', fontSize: 13, fontWeight: '600' },
|
||||||
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, padding: 20 },
|
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, padding: 20 },
|
||||||
awakeBtn: { backgroundColor: '#1e1e1e', borderRadius: 20, paddingVertical: 8, paddingHorizontal: 14 },
|
awakeBtn: { backgroundColor: '#1e1e1e', borderRadius: 20, paddingVertical: 8, paddingHorizontal: 14 },
|
||||||
|
|||||||
Reference in New Issue
Block a user