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", "expo-status-bar": "~3.0.9",
"react": "19.1.0", "react": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-maps": "^1.20.0",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.5" "react-native-svg": "^15.15.5"
+6 -18
View File
@@ -1,9 +1,10 @@
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; 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 { formatDuration, formatDistance } from '@timetoleave/core';
import type { BikeRoute, Station } from '@timetoleave/core'; import type { BikeRoute, Station } from '@timetoleave/core';
import type { AppColors } from '../hooks/useColors'; import type { AppColors } from '../hooks/useColors';
import { RouteMap } from './RouteMap';
/** Props for the bike route information section shown on event detail. */ /** Props for the bike route information section shown on event detail. */
interface Props { 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 * event's origin → destination leg. Shows contextual empty states when no
* origin is set or no route is available. * origin is set or no route is available.
*/ */
@@ -44,12 +45,9 @@ export function BikeSection({ bikeRoute, loading, origin, colors }: Props) {
</View> </View>
<Text style={[styles.value, { color: colors.accent }]}>{formatDistance(bikeRoute.distance)}</Text> <Text style={[styles.value, { color: colors.accent }]}>{formatDistance(bikeRoute.distance)}</Text>
</View> </View>
<View style={[styles.mapPlaceholder, { backgroundColor: colors.background, borderColor: colors.border }]}> {bikeRoute.geometry && (
<View style={styles.mapTextRow}> <RouteMap geometry={bikeRoute.geometry} colors={colors} mode="bike" />
<FontAwesomeIcon icon={faMap} size={13} color={colors.subtext} /> )}
<Text style={[styles.mapText, { color: colors.subtext }]}>Map (post-MVP)</Text>
</View>
</View>
</View> </View>
) : ( ) : (
<Text style={[styles.empty, { color: colors.subtext }]}> <Text style={[styles.empty, { color: colors.subtext }]}>
@@ -71,14 +69,4 @@ const styles = StyleSheet.create({
labelRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, labelRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
label: { fontSize: 15, fontWeight: '500' }, label: { fontSize: 15, fontWeight: '500' },
value: { fontSize: 15, fontWeight: '600' }, 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 { formatDuration, formatDistance, rankJourneys } from '@timetoleave/core';
import type { Journey, Station, WalkRoute } from '@timetoleave/core'; import type { Journey, Station, WalkRoute } from '@timetoleave/core';
import type { AppColors } from '../hooks/useColors'; import type { AppColors } from '../hooks/useColors';
import { RouteMap } from './RouteMap';
/** Props for the train journey list and optional final walk leg. */ /** Props for the train journey list and optional final walk leg. */
interface Props { interface Props {
@@ -132,6 +133,9 @@ export function JourneyList({
<Text style={[styles.walkLabel, { color: colors.text }]}>Distance</Text> <Text style={[styles.walkLabel, { color: colors.text }]}>Distance</Text>
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDistance(walkRoute.distance)}</Text> <Text style={[styles.walkValue, { color: colors.accent }]}>{formatDistance(walkRoute.distance)}</Text>
</View> </View>
{walkRoute.geometry && (
<RouteMap geometry={walkRoute.geometry} colors={colors} mode="walk" />
)}
</View> </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({ mockGetBikeRoute.mockResolvedValue({
distance: 1500, distance: 1500,
duration: 300, duration: 300,
geometry: "_p~iF~ps|U_ulLnnqC_mqNvxq`@",
steps: [ steps: [
{ name: "Start", distance: 100, duration: 10, instruction: "Go straight" }, { name: "Start", distance: 100, duration: 10, instruction: "Go straight" },
{ name: "Turn left", distance: 200, duration: 20, instruction: "Turn left at the corner" }, { 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("distance");
expect(data).toHaveProperty("duration"); expect(data).toHaveProperty("duration");
expect(data).toHaveProperty("steps"); expect(data).toHaveProperty("steps");
expect(data).toHaveProperty("geometry");
}); });
it("should handle client error", async () => { it("should handle client error", async () => {
@@ -29,6 +29,7 @@ describe("api/walk-route/route", () => {
mockGetWalkRoute.mockResolvedValue({ mockGetWalkRoute.mockResolvedValue({
distance: 800, distance: 800,
duration: 600, duration: 600,
geometry: "_p~iF~ps|U_ulLnnqC_mqNvxq`@",
steps: [ steps: [
{ name: "Start", distance: 50, duration: 40, instruction: "Head north" }, { name: "Start", distance: 50, duration: 40, instruction: "Head north" },
{ name: "Main St", distance: 150, duration: 120, instruction: "Turn right" }, { 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("distance");
expect(data).toHaveProperty("duration"); expect(data).toHaveProperty("duration");
expect(data).toHaveProperty("steps"); expect(data).toHaveProperty("steps");
expect(data).toHaveProperty("geometry");
}); });
it("should handle client error", async () => { it("should handle client error", async () => {
+3 -1
View File
@@ -17,6 +17,7 @@ interface OsrmRoute {
distance: number; distance: number;
duration: number; duration: number;
legs: Array<{ steps: OsrmStep[] }>; legs: Array<{ steps: OsrmStep[] }>;
geometry: string;
} }
interface OsrmResponse { interface OsrmResponse {
@@ -66,7 +67,7 @@ export class BikeRoutingClient {
const res = await this.client.get<OsrmResponse>( const res = await this.client.get<OsrmResponse>(
path, path,
{ overview: "false", steps: "true" }, { overview: "full", steps: "true", geometries: "polyline" },
{ {
cacheKey, cacheKey,
ttl: this.defaultTtlMs, ttl: this.defaultTtlMs,
@@ -87,6 +88,7 @@ export class BikeRoutingClient {
distance: route.distance, distance: route.distance,
duration: route.duration, duration: route.duration,
steps, steps,
geometry: route.geometry,
}; };
} }
+3 -1
View File
@@ -19,6 +19,7 @@ interface OsrmRoute {
distance: number; distance: number;
duration: number; duration: number;
legs: Array<{ steps: OsrmStep[] }>; legs: Array<{ steps: OsrmStep[] }>;
geometry: string;
} }
interface OsrmResponse { interface OsrmResponse {
@@ -68,7 +69,7 @@ export class WalkRoutingClient {
const res = await this.client.get<OsrmResponse>( const res = await this.client.get<OsrmResponse>(
path, path,
{ overview: "false", steps: "true" }, { overview: "full", steps: "true", geometries: "polyline" },
{ {
cacheKey, cacheKey,
ttl: this.defaultTtlMs, ttl: this.defaultTtlMs,
@@ -90,6 +91,7 @@ export class WalkRoutingClient {
distance: route.distance, distance: route.distance,
duration: Math.max(route.duration, walkingDuration), duration: Math.max(route.duration, walkingDuration),
steps, steps,
geometry: route.geometry,
}; };
} }
+36 -1
View File
@@ -11,6 +11,11 @@
"apps/*", "apps/*",
"packages/*" "packages/*"
], ],
"dependencies": {
"expo": "~54.0.34",
"react": "19.1.0",
"react-native": "0.81.5"
},
"devDependencies": { "devDependencies": {
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^17.0.5" "lint-staged": "^17.0.5"
@@ -35,6 +40,7 @@
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"react": "19.1.0", "react": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-maps": "^1.20.0",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.5" "react-native-svg": "^15.15.5"
@@ -5386,6 +5392,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/graceful-fs": { "node_modules/@types/graceful-fs": {
"version": "4.1.9", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -15764,6 +15776,28 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-maps": {
"version": "1.27.2",
"resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.27.2.tgz",
"integrity": "sha512-VKr+xZ2RZGHHJlY6KhlafvGSmK0dq/tUu5uhfJ7K9rwN5pUdubdugzMKGDU/16lXmQSg7xbClKhRctj3Pm5F5g==",
"license": "MIT",
"dependencies": {
"@types/geojson": "^7946.0.13"
},
"engines": {
"node": ">= 20.19.4"
},
"peerDependencies": {
"react": ">= 18.3.1",
"react-native": ">= 0.76.0",
"react-native-web": ">= 0.11"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/react-native-safe-area-context": { "node_modules/react-native-safe-area-context": {
"version": "5.6.2", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
@@ -18913,7 +18947,8 @@
"@timetoleave/core": "*" "@timetoleave/core": "*"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5" "typescript": "^5",
"vitest": "^4.1.5"
} }
}, },
"packages/core": { "packages/core": {
+8 -1
View File
@@ -15,7 +15,9 @@
"test": "npm run test -w apps/web && npm run test -w apps/mobile && npm run test -w packages/core && npm run test -w packages/api-client", "test": "npm run test -w apps/web && npm run test -w apps/mobile && npm run test -w packages/core && npm run test -w packages/api-client",
"dev:mobile": "npm run start -w apps/mobile", "dev:mobile": "npm run start -w apps/mobile",
"typecheck:mobile": "npm run typecheck -w apps/mobile", "typecheck:mobile": "npm run typecheck -w apps/mobile",
"prepare": "husky" "prepare": "husky",
"android": "expo run:android",
"ios": "expo run:ios"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx}": [ "*.{ts,tsx}": [
@@ -31,5 +33,10 @@
"devDependencies": { "devDependencies": {
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^17.0.5" "lint-staged": "^17.0.5"
},
"dependencies": {
"expo": "~54.0.34",
"react": "19.1.0",
"react-native": "0.81.5"
} }
} }
+4
View File
@@ -54,6 +54,8 @@ export interface BikeRoute {
distance: number; distance: number;
duration: number; duration: number;
steps: BikeStep[]; steps: BikeStep[];
/** Encoded polyline geometry (Google polyline format). */
geometry?: string;
} }
/** Walking route from OSRM with turn-by-turn steps. */ /** Walking route from OSRM with turn-by-turn steps. */
@@ -61,6 +63,8 @@ export interface WalkRoute {
distance: number; distance: number;
duration: number; duration: number;
steps: WalkStep[]; steps: WalkStep[];
/** Encoded polyline geometry (Google polyline format). */
geometry?: string;
} }
/** Walking step from OSRM route data. */ /** Walking step from OSRM route data. */