Add interactive map support for bike and walk routes
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user