/** * ExcursionMap — interactive map for a Take a Walk / Take a Ride album * (Bekky, 2026-09-03). Renders the route polyline + a plant pin (photo * thumbnail) for each entry that has a location. Interactive (pan/zoom), * auto-fits to the route bounds. */ import React, { useMemo } from 'react'; import { StyleSheet, View, Text, Image, TouchableOpacity, Linking } from 'react-native'; import MapView, { Polyline, Marker, type Region } from 'react-native-maps'; import type { FieldAlbum } from '../types/garden'; import { formatExcursionDistance, formatExcursionDuration } from '../services/excursion/excursionService'; type Props = { album: FieldAlbum; height?: number; /** Non-interactive thumbnail (album cover). Hides stats + Google Maps button, * disables pan/zoom, and skips plant pins so the route reads clearly. */ thumbnail?: boolean; }; export function ExcursionMap({ album, height = 360, thumbnail = false }: Props) { const route = album.route ?? []; const hasRoute = route.length >= 2; // Google Maps deep link that opens the exact route in the Google Maps app // with turn-by-turn navigation (Bekky, 2026-09-03). Uses origin + destination // + up to 9 waypoints (Google's limit) sampled from the recorded route. const mapsUrl = useMemo(() => { if (!hasRoute) return null; const pts = route.map((p) => `${p.latitude},${p.longitude}`); const origin = pts[0]; const destination = pts[pts.length - 1]; // Sample waypoints (skip first + last, cap at 9). const middle = pts.slice(1, -1); const step = Math.max(1, Math.ceil(middle.length / 9)); const waypoints = middle.filter((_, i) => i % step === 0).slice(0, 9); const wp = waypoints.length ? `&waypoints=${waypoints.join('|')}` : ''; return `https://www.google.com/maps/dir/?api=1&origin=${origin}&destination=${destination}${wp}&travelmode=walking`; }, [route, hasRoute]); const openInGoogleMaps = () => { if (!mapsUrl) return; Linking.openURL(mapsUrl).catch(() => {}); }; const region: Region | undefined = useMemo(() => { if (!hasRoute) return undefined; let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; for (const p of route) { if (p.latitude < minLat) minLat = p.latitude; if (p.latitude > maxLat) maxLat = p.latitude; if (p.longitude < minLng) minLng = p.longitude; if (p.longitude > maxLng) maxLng = p.longitude; } const latDelta = Math.max(0.01, (maxLat - minLat) * 1.4); const lngDelta = Math.max(0.01, (maxLng - minLng) * 1.4); return { latitude: (minLat + maxLat) / 2, longitude: (minLng + maxLng) / 2, latitudeDelta: latDelta, longitudeDelta: lngDelta, }; }, [route, hasRoute]); // Plant pins: entries with a location (precise or approximate). const pins = useMemo(() => { return album.entries .map((entry) => { const loc = entry.location; const lat = loc?.latitude ?? loc?.approximateLatitude; const lng = loc?.longitude ?? loc?.approximateLongitude; if (typeof lat !== 'number' || typeof lng !== 'number') return null; return { id: entry.id, name: entry.commonName, latitude: lat, longitude: lng, cover: entry.coverPhotoUri || entry.photos?.[0]?.uri, }; }) .filter((p): p is NonNullable => p !== null); }, [album.entries]); if (!hasRoute && pins.length === 0) { return ( No route or plant locations recorded for this trip. ); } return ( {hasRoute ? ( ({ latitude: p.latitude, longitude: p.longitude }))} strokeColor="#2E6B3F" strokeWidth={thumbnail ? 3 : 4} /> ) : null} {!thumbnail && pins.map((pin) => ( {pin.cover ? ( ) : ( 🌿 )} ))} {!thumbnail ? ( <> {formatExcursionDistance(album.distanceMeters ?? 0)} · {formatExcursionDuration(album.durationSec ?? 0)} {mapsUrl ? ( Open in Google Maps ) : null} ) : null} ); } const styles = StyleSheet.create({ wrap: { width: '100%', borderRadius: 14, overflow: 'hidden', borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#E8F5D3', }, empty: { width: '100%', borderRadius: 14, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF6', alignItems: 'center', justifyContent: 'center', padding: 16, }, emptyText: { color: '#5A6B52', fontSize: 13, textAlign: 'center', }, stats: { position: 'absolute', bottom: 10, left: 10, backgroundColor: 'rgba(255,253,247,0.92)', borderRadius: 999, paddingHorizontal: 12, paddingVertical: 5, }, statText: { color: '#2E6B3F', fontSize: 12, fontWeight: '600', }, gmapsButton: { position: 'absolute', bottom: 10, right: 10, backgroundColor: '#2E6B3F', borderRadius: 999, paddingHorizontal: 14, paddingVertical: 8, shadowColor: '#000', shadowOpacity: 0.2, shadowRadius: 6, shadowOffset: { width: 0, height: 2 }, elevation: 4, }, gmapsButtonText: { color: '#FFFFFF', fontSize: 12, fontWeight: '600', }, pin: { width: 40, height: 40, borderRadius: 20, borderWidth: 2, borderColor: '#2E6B3F', overflow: 'hidden', backgroundColor: '#FFFDF6', }, pinImage: { width: '100%', height: '100%', }, pinFallback: { alignItems: 'center', justifyContent: 'center', }, pinEmoji: { fontSize: 20, }, });