From 229e0dd557c1a55e2a6776a12b40fad3a91a116e Mon Sep 17 00:00:00 2001 From: fegger Date: Tue, 19 May 2026 15:38:45 +0200 Subject: [PATCH] Add interactive map support for bike and walk routes --- apps/mobile/package.json | 1 + apps/mobile/src/components/BikeSection.tsx | 24 ++---- apps/mobile/src/components/JourneyList.tsx | 4 + apps/mobile/src/components/RouteMap.tsx | 85 +++++++++++++++++++ apps/mobile/src/utils/polyline.ts | 46 ++++++++++ .../src/app/api/__tests__/bike-route.test.ts | 2 + .../src/app/api/__tests__/walk-route.test.ts | 2 + apps/web/src/lib/bike-routing-client.ts | 4 +- apps/web/src/lib/walk-routing-client.ts | 4 +- package-lock.json | 37 +++++++- package.json | 9 +- packages/core/src/types.ts | 4 + 12 files changed, 200 insertions(+), 22 deletions(-) create mode 100644 apps/mobile/src/components/RouteMap.tsx create mode 100644 apps/mobile/src/utils/polyline.ts 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. */