Add interactive map support for bike and walk routes

This commit is contained in:
2026-05-19 15:38:45 +02:00
parent 7da5acbba3
commit 229e0dd557
12 changed files with 200 additions and 22 deletions
+1
View File
@@ -28,6 +28,7 @@
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-maps": "^1.20.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.5"
+6 -18
View File
@@ -1,9 +1,10 @@
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import { faClock, faRuler, faMap } from '@fortawesome/free-solid-svg-icons';
import { faClock, faRuler } from '@fortawesome/free-solid-svg-icons';
import { formatDuration, formatDistance } from '@timetoleave/core';
import type { BikeRoute, Station } from '@timetoleave/core';
import type { AppColors } from '../hooks/useColors';
import { RouteMap } from './RouteMap';
/** Props for the bike route information section shown on event detail. */
interface Props {
@@ -14,7 +15,7 @@ interface Props {
}
/**
* Displays cycling route duration, distance, and a map placeholder for the
* Displays cycling route duration, distance, and an interactive map for the
* event's origin → destination leg. Shows contextual empty states when no
* origin is set or no route is available.
*/
@@ -44,12 +45,9 @@ export function BikeSection({ bikeRoute, loading, origin, colors }: Props) {
</View>
<Text style={[styles.value, { color: colors.accent }]}>{formatDistance(bikeRoute.distance)}</Text>
</View>
<View style={[styles.mapPlaceholder, { backgroundColor: colors.background, borderColor: colors.border }]}>
<View style={styles.mapTextRow}>
<FontAwesomeIcon icon={faMap} size={13} color={colors.subtext} />
<Text style={[styles.mapText, { color: colors.subtext }]}>Map (post-MVP)</Text>
</View>
</View>
{bikeRoute.geometry && (
<RouteMap geometry={bikeRoute.geometry} colors={colors} mode="bike" />
)}
</View>
) : (
<Text style={[styles.empty, { color: colors.subtext }]}>
@@ -71,14 +69,4 @@ const styles = StyleSheet.create({
labelRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
label: { fontSize: 15, fontWeight: '500' },
value: { fontSize: 15, fontWeight: '600' },
mapTextRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
mapPlaceholder: {
marginTop: 10,
height: 100,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
},
mapText: { fontSize: 14 },
});
@@ -3,6 +3,7 @@ import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'rea
import { formatDuration, formatDistance, rankJourneys } from '@timetoleave/core';
import type { Journey, Station, WalkRoute } from '@timetoleave/core';
import type { AppColors } from '../hooks/useColors';
import { RouteMap } from './RouteMap';
/** Props for the train journey list and optional final walk leg. */
interface Props {
@@ -132,6 +133,9 @@ export function JourneyList({
<Text style={[styles.walkLabel, { color: colors.text }]}>Distance</Text>
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDistance(walkRoute.distance)}</Text>
</View>
{walkRoute.geometry && (
<RouteMap geometry={walkRoute.geometry} colors={colors} mode="walk" />
)}
</View>
)}
+85
View File
@@ -0,0 +1,85 @@
import { useMemo } from 'react';
import { StyleSheet, View } from 'react-native';
import MapView, { Marker, Polyline } from 'react-native-maps';
import { decodePolyline } from '../utils/polyline';
import type { AppColors } from '../hooks/useColors';
interface Props {
geometry: string;
colors: AppColors;
mode: 'bike' | 'walk';
}
function getRegionFromCoordinates(
coords: Array<{ latitude: number; longitude: number }>,
) {
let minLat = Infinity;
let maxLat = -Infinity;
let minLng = Infinity;
let maxLng = -Infinity;
for (const c of coords) {
minLat = Math.min(minLat, c.latitude);
maxLat = Math.max(maxLat, c.latitude);
minLng = Math.min(minLng, c.longitude);
maxLng = Math.max(maxLng, c.longitude);
}
const latDelta = (maxLat - minLat) * 1.3; // 30 % padding
const lngDelta = (maxLng - minLng) * 1.3;
return {
latitude: (minLat + maxLat) / 2,
longitude: (minLng + maxLng) / 2,
latitudeDelta: Math.max(latDelta, 0.005),
longitudeDelta: Math.max(lngDelta, 0.005),
};
}
/**
* Renders an interactive map with the decoded route polyline and start/end
* markers. Automatically centres and zooms to fit the entire route.
*/
export function RouteMap({ geometry, colors, mode }: Props) {
const coords = useMemo(() => decodePolyline(geometry), [geometry]);
const region = useMemo(() => {
if (coords.length < 2) return null;
return getRegionFromCoordinates(coords);
}, [coords]);
if (!region || coords.length < 2) {
return null;
}
const strokeColor = mode === 'bike' ? colors.accent : colors.success;
const startCoord = coords[0];
const endCoord = coords[coords.length - 1];
return (
<View style={[styles.container, { borderColor: colors.border }]}>
<MapView style={styles.map} initialRegion={region} scrollEnabled={false} zoomEnabled={false} rotateEnabled={false} pitchEnabled={false}>
<Polyline
coordinates={coords}
strokeColor={strokeColor}
strokeWidth={4}
/>
<Marker coordinate={startCoord} pinColor={colors.accent} />
<Marker coordinate={endCoord} pinColor={colors.success} />
</MapView>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginTop: 10,
height: 220,
borderRadius: 8,
overflow: 'hidden',
borderWidth: 1,
},
map: {
...StyleSheet.absoluteFillObject,
},
});
+46
View File
@@ -0,0 +1,46 @@
/**
* Decode a Google polyline string into an array of { latitude, longitude } points.
*
* Based on the polyline algorithm:
* https://developers.google.com/maps/documentation/utilities/polylinealgorithm
*/
export function decodePolyline(encoded: string): Array<{ latitude: number; longitude: number }> {
const points: Array<{ latitude: number; longitude: number }> = [];
let index = 0;
let lat = 0;
let lng = 0;
while (index < encoded.length) {
let b;
let shift = 0;
let result = 0;
// Decode latitude
do {
b = encoded.charCodeAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
} while (b >= 0x20);
const dlat = (result & 1) !== 0 ? ~(result >> 1) : result >> 1;
lat += dlat;
shift = 0;
result = 0;
// Decode longitude
do {
b = encoded.charCodeAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
} while (b >= 0x20);
const dlng = (result & 1) !== 0 ? ~(result >> 1) : result >> 1;
lng += dlng;
points.push({
latitude: lat / 1e5,
longitude: lng / 1e5,
});
}
return points;
}
@@ -29,6 +29,7 @@ describe("api/bike-route/route", () => {
mockGetBikeRoute.mockResolvedValue({
distance: 1500,
duration: 300,
geometry: "_p~iF~ps|U_ulLnnqC_mqNvxq`@",
steps: [
{ name: "Start", distance: 100, duration: 10, instruction: "Go straight" },
{ name: "Turn left", distance: 200, duration: 20, instruction: "Turn left at the corner" },
@@ -45,6 +46,7 @@ describe("api/bike-route/route", () => {
expect(data).toHaveProperty("distance");
expect(data).toHaveProperty("duration");
expect(data).toHaveProperty("steps");
expect(data).toHaveProperty("geometry");
});
it("should handle client error", async () => {
@@ -29,6 +29,7 @@ describe("api/walk-route/route", () => {
mockGetWalkRoute.mockResolvedValue({
distance: 800,
duration: 600,
geometry: "_p~iF~ps|U_ulLnnqC_mqNvxq`@",
steps: [
{ name: "Start", distance: 50, duration: 40, instruction: "Head north" },
{ name: "Main St", distance: 150, duration: 120, instruction: "Turn right" },
@@ -45,6 +46,7 @@ describe("api/walk-route/route", () => {
expect(data).toHaveProperty("distance");
expect(data).toHaveProperty("duration");
expect(data).toHaveProperty("steps");
expect(data).toHaveProperty("geometry");
});
it("should handle client error", async () => {
+3 -1
View File
@@ -17,6 +17,7 @@ interface OsrmRoute {
distance: number;
duration: number;
legs: Array<{ steps: OsrmStep[] }>;
geometry: string;
}
interface OsrmResponse {
@@ -66,7 +67,7 @@ export class BikeRoutingClient {
const res = await this.client.get<OsrmResponse>(
path,
{ overview: "false", steps: "true" },
{ overview: "full", steps: "true", geometries: "polyline" },
{
cacheKey,
ttl: this.defaultTtlMs,
@@ -87,6 +88,7 @@ export class BikeRoutingClient {
distance: route.distance,
duration: route.duration,
steps,
geometry: route.geometry,
};
}
+3 -1
View File
@@ -19,6 +19,7 @@ interface OsrmRoute {
distance: number;
duration: number;
legs: Array<{ steps: OsrmStep[] }>;
geometry: string;
}
interface OsrmResponse {
@@ -68,7 +69,7 @@ export class WalkRoutingClient {
const res = await this.client.get<OsrmResponse>(
path,
{ overview: "false", steps: "true" },
{ overview: "full", steps: "true", geometries: "polyline" },
{
cacheKey,
ttl: this.defaultTtlMs,
@@ -90,6 +91,7 @@ export class WalkRoutingClient {
distance: route.distance,
duration: Math.max(route.duration, walkingDuration),
steps,
geometry: route.geometry,
};
}