diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 71a6df6..10fa9f0 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -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"
diff --git a/apps/mobile/src/components/BikeSection.tsx b/apps/mobile/src/components/BikeSection.tsx
index 7ca8fc8..15d4586 100644
--- a/apps/mobile/src/components/BikeSection.tsx
+++ b/apps/mobile/src/components/BikeSection.tsx
@@ -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) {
{formatDistance(bikeRoute.distance)}
-
-
-
- Map (post-MVP)
-
-
+ {bikeRoute.geometry && (
+
+ )}
) : (
@@ -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 },
});
diff --git a/apps/mobile/src/components/JourneyList.tsx b/apps/mobile/src/components/JourneyList.tsx
index e107d77..5309ee8 100644
--- a/apps/mobile/src/components/JourneyList.tsx
+++ b/apps/mobile/src/components/JourneyList.tsx
@@ -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({
Distance
{formatDistance(walkRoute.distance)}
+ {walkRoute.geometry && (
+
+ )}
)}
diff --git a/apps/mobile/src/components/RouteMap.tsx b/apps/mobile/src/components/RouteMap.tsx
new file mode 100644
index 0000000..595365b
--- /dev/null
+++ b/apps/mobile/src/components/RouteMap.tsx
@@ -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 (
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ marginTop: 10,
+ height: 220,
+ borderRadius: 8,
+ overflow: 'hidden',
+ borderWidth: 1,
+ },
+ map: {
+ ...StyleSheet.absoluteFillObject,
+ },
+});
diff --git a/apps/mobile/src/utils/polyline.ts b/apps/mobile/src/utils/polyline.ts
new file mode 100644
index 0000000..fc47090
--- /dev/null
+++ b/apps/mobile/src/utils/polyline.ts
@@ -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;
+}
diff --git a/apps/web/src/app/api/__tests__/bike-route.test.ts b/apps/web/src/app/api/__tests__/bike-route.test.ts
index 6eb5c35..4bafb8c 100644
--- a/apps/web/src/app/api/__tests__/bike-route.test.ts
+++ b/apps/web/src/app/api/__tests__/bike-route.test.ts
@@ -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 () => {
diff --git a/apps/web/src/app/api/__tests__/walk-route.test.ts b/apps/web/src/app/api/__tests__/walk-route.test.ts
index 6148ab0..1216699 100644
--- a/apps/web/src/app/api/__tests__/walk-route.test.ts
+++ b/apps/web/src/app/api/__tests__/walk-route.test.ts
@@ -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 () => {
diff --git a/apps/web/src/lib/bike-routing-client.ts b/apps/web/src/lib/bike-routing-client.ts
index af2e318..80f3f5a 100644
--- a/apps/web/src/lib/bike-routing-client.ts
+++ b/apps/web/src/lib/bike-routing-client.ts
@@ -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(
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,
};
}
diff --git a/apps/web/src/lib/walk-routing-client.ts b/apps/web/src/lib/walk-routing-client.ts
index 22c2b2a..1776b30 100644
--- a/apps/web/src/lib/walk-routing-client.ts
+++ b/apps/web/src/lib/walk-routing-client.ts
@@ -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(
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,
};
}
diff --git a/package-lock.json b/package-lock.json
index b090112..6f5d63a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,11 @@
"apps/*",
"packages/*"
],
+ "dependencies": {
+ "expo": "~54.0.34",
+ "react": "19.1.0",
+ "react-native": "0.81.5"
+ },
"devDependencies": {
"husky": "^9.1.7",
"lint-staged": "^17.0.5"
@@ -35,6 +40,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"
@@ -5386,6 +5392,12 @@
"dev": true,
"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": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -15764,6 +15776,28 @@
"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": {
"version": "5.6.2",
"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": "*"
},
"devDependencies": {
- "typescript": "^5"
+ "typescript": "^5",
+ "vitest": "^4.1.5"
}
},
"packages/core": {
diff --git a/package.json b/package.json
index 4c2b5c2..33d140d 100644
--- a/package.json
+++ b/package.json
@@ -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",
"dev:mobile": "npm run start -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": {
"*.{ts,tsx}": [
@@ -31,5 +33,10 @@
"devDependencies": {
"husky": "^9.1.7",
"lint-staged": "^17.0.5"
+ },
+ "dependencies": {
+ "expo": "~54.0.34",
+ "react": "19.1.0",
+ "react-native": "0.81.5"
}
}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 6d73cc9..6557cac 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -54,6 +54,8 @@ export interface BikeRoute {
distance: number;
duration: number;
steps: BikeStep[];
+ /** Encoded polyline geometry (Google polyline format). */
+ geometry?: string;
}
/** Walking route from OSRM with turn-by-turn steps. */
@@ -61,6 +63,8 @@ export interface WalkRoute {
distance: number;
duration: number;
steps: WalkStep[];
+ /** Encoded polyline geometry (Google polyline format). */
+ geometry?: string;
}
/** Walking step from OSRM route data. */