From a6c124c14c30176ed2642ddae56de366b07e1b59 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 28 Dec 2025 23:24:42 +0100 Subject: [PATCH] feat: Add navigation routes feature with indoor/outdoor routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /api/routing endpoint for route calculation - Support OSRM for outdoor walking/driving routes - Support c3nav API for indoor routes at CCC events - Add RouteOverlay component for map route visualization - Add NavigationPanel for participant/waypoint navigation UI - Integrate route state management into Zustand store - Display route line on outdoor map with distance/time estimates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/app/api/routing/route.ts | 366 +++++++++++++++++++++++++ src/components/map/DualMapView.tsx | 50 +++- src/components/map/MapView.tsx | 45 ++- src/components/map/NavigationPanel.tsx | 166 +++++++++++ src/components/map/RouteOverlay.tsx | 296 ++++++++++++++++++++ src/stores/room.ts | 176 ++++++++++++ 6 files changed, 1095 insertions(+), 4 deletions(-) create mode 100644 src/app/api/routing/route.ts create mode 100644 src/components/map/NavigationPanel.tsx create mode 100644 src/components/map/RouteOverlay.tsx diff --git a/src/app/api/routing/route.ts b/src/app/api/routing/route.ts new file mode 100644 index 0000000..901a9b6 --- /dev/null +++ b/src/app/api/routing/route.ts @@ -0,0 +1,366 @@ +import { NextRequest, NextResponse } from 'next/server'; +import type { RouteSegment, C3NavRouteRequest } from '@/types'; + +// OSRM public server for outdoor routing +const OSRM_API = 'https://router.project-osrm.org'; + +// c3nav routing API +const C3NAV_EVENTS = ['38c3', '37c3', 'eh22', 'eh2025', 'camp2023']; + +interface RouteRequest { + origin: { + latitude: number; + longitude: number; + indoor?: { level: number; x: number; y: number }; + }; + destination: { + latitude: number; + longitude: number; + indoor?: { level: number; x: number; y: number }; + }; + mode?: 'walking' | 'driving'; + eventId?: string; // for c3nav indoor routing + options?: { + avoidStairs?: boolean; + wheelchair?: boolean; + }; +} + +interface RouteResponse { + success: boolean; + route?: { + segments: RouteSegment[]; + totalDistance: number; + estimatedTime: number; + summary: string; + }; + error?: string; +} + +// Fetch outdoor route from OSRM +async function getOutdoorRoute( + origin: { latitude: number; longitude: number }, + destination: { latitude: number; longitude: number }, + mode: 'walking' | 'driving' = 'walking' +): Promise { + const profile = mode === 'walking' ? 'foot' : 'driving'; + + // OSRM uses [lng, lat] format + const coords = `${origin.longitude},${origin.latitude};${destination.longitude},${destination.latitude}`; + const url = `${OSRM_API}/route/v1/${profile}/${coords}?overview=full&geometries=geojson&steps=true`; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(url, { + headers: { 'User-Agent': 'rMaps.online/1.0' }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + console.error('OSRM error:', response.status); + return null; + } + + const data = await response.json(); + + if (data.code !== 'Ok' || !data.routes?.[0]) { + console.error('OSRM no route:', data.code); + return null; + } + + const route = data.routes[0]; + const geometry = route.geometry; + + // Build instructions from steps + const instructions = route.legs?.[0]?.steps + ?.map((step: { maneuver?: { instruction?: string } }) => step.maneuver?.instruction) + .filter(Boolean) + .join(' -> '); + + return { + type: 'outdoor', + coordinates: geometry.coordinates, // Already in [lng, lat] format + distance: route.distance, + duration: route.duration, + instructions: instructions || 'Follow the route', + }; + } catch (error) { + console.error('OSRM fetch error:', error); + return null; + } +} + +// Fetch indoor route from c3nav +async function getIndoorRoute( + origin: { level: number; x: number; y: number }, + destination: { level: number; x: number; y: number }, + eventId: string, + options?: { avoidStairs?: boolean; wheelchair?: boolean } +): Promise { + if (!C3NAV_EVENTS.includes(eventId)) { + console.error('Invalid c3nav event:', eventId); + return null; + } + + const apiUrl = `https://${eventId}.c3nav.de/api/v2/routing/route/`; + + const request: C3NavRouteRequest = { + origin: { + coordinates: [origin.x, origin.y, origin.level], + }, + destination: { + coordinates: [destination.x, destination.y, destination.level], + }, + options: { + mode: 'fastest', + avoid_stairs: options?.avoidStairs, + wheelchair: options?.wheelchair, + }, + }; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': 'anonymous', + 'User-Agent': 'rMaps.online/1.0', + }, + body: JSON.stringify(request), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + console.error('c3nav routing error:', response.status); + return null; + } + + const data = await response.json(); + + if (data.status !== 'ok' || !data.path) { + console.error('c3nav no route:', data.status); + return null; + } + + // Group path points by level into segments + const segments: RouteSegment[] = []; + let currentLevel: number | null = null; + let currentCoords: Array<[number, number]> = []; + + for (const point of data.path) { + const level = point.level ?? point.coordinates[2]; + + if (currentLevel !== null && level !== currentLevel) { + // Level change - save current segment and start new one + if (currentCoords.length > 0) { + segments.push({ + type: 'indoor', + coordinates: currentCoords, + distance: 0, // Will be calculated + duration: 0, + level: currentLevel, + }); + } + // Add transition segment + segments.push({ + type: 'transition', + coordinates: [currentCoords[currentCoords.length - 1], [point.coordinates[0], point.coordinates[1]]], + distance: 0, + duration: 10, // Estimate 10s for level change + instructions: `Go to level ${level}`, + level, + }); + currentCoords = []; + } + + currentLevel = level; + currentCoords.push([point.coordinates[0], point.coordinates[1]]); + } + + // Add final segment + if (currentCoords.length > 0 && currentLevel !== null) { + segments.push({ + type: 'indoor', + coordinates: currentCoords, + distance: 0, + duration: 0, + level: currentLevel, + }); + } + + // Calculate distances for each segment + for (const segment of segments) { + if (segment.coordinates.length > 1) { + let dist = 0; + for (let i = 1; i < segment.coordinates.length; i++) { + const [x1, y1] = segment.coordinates[i - 1]; + const [x2, y2] = segment.coordinates[i]; + dist += Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); + } + segment.distance = dist; + // Estimate walking speed: ~1.4 m/s + segment.duration = dist / 1.4; + } + } + + return segments; + } catch (error) { + console.error('c3nav fetch error:', error); + return null; + } +} + +export async function POST(request: NextRequest) { + try { + const body: RouteRequest = await request.json(); + const { origin, destination, mode = 'walking', eventId = '38c3', options } = body; + + if (!origin || !destination) { + return NextResponse.json( + { success: false, error: 'Origin and destination required' }, + { status: 400 } + ); + } + + const segments: RouteSegment[] = []; + let totalDistance = 0; + let estimatedTime = 0; + let summary = ''; + + // Determine routing mode based on indoor positions + const isOriginIndoor = !!origin.indoor; + const isDestIndoor = !!destination.indoor; + + if (isOriginIndoor && isDestIndoor) { + // Both indoor - use c3nav + const indoorSegments = await getIndoorRoute( + origin.indoor!, + destination.indoor!, + eventId, + options + ); + + if (indoorSegments) { + segments.push(...indoorSegments); + summary = 'Indoor route'; + } else { + return NextResponse.json( + { success: false, error: 'Could not calculate indoor route' }, + { status: 400 } + ); + } + } else if (!isOriginIndoor && !isDestIndoor) { + // Both outdoor - use OSRM + const outdoorSegment = await getOutdoorRoute(origin, destination, mode); + + if (outdoorSegment) { + segments.push(outdoorSegment); + summary = 'Outdoor route'; + } else { + return NextResponse.json( + { success: false, error: 'Could not calculate outdoor route' }, + { status: 400 } + ); + } + } else { + // Mixed indoor/outdoor - need both + if (isOriginIndoor && !isDestIndoor) { + // Indoor to outdoor: indoor segment + outdoor segment + // For now, use outdoor coordinates as exit point + const outdoorSegment = await getOutdoorRoute( + { latitude: origin.latitude, longitude: origin.longitude }, + destination, + mode + ); + + if (outdoorSegment) { + segments.push({ + type: 'transition', + coordinates: [[origin.longitude, origin.latitude]], + distance: 0, + duration: 30, // Estimate 30s to exit building + instructions: 'Exit the building', + }); + segments.push(outdoorSegment); + summary = 'Exit building, then outdoor route'; + } + } else { + // Outdoor to indoor: outdoor segment + indoor segment + const outdoorSegment = await getOutdoorRoute( + origin, + { latitude: destination.latitude, longitude: destination.longitude }, + mode + ); + + if (outdoorSegment) { + segments.push(outdoorSegment); + segments.push({ + type: 'transition', + coordinates: [[destination.longitude, destination.latitude]], + distance: 0, + duration: 30, // Estimate 30s to enter building + instructions: 'Enter the building', + }); + summary = 'Outdoor route, then enter building'; + } + } + + if (segments.length === 0) { + return NextResponse.json( + { success: false, error: 'Could not calculate route' }, + { status: 400 } + ); + } + } + + // Calculate totals + for (const seg of segments) { + totalDistance += seg.distance; + estimatedTime += seg.duration; + } + + const response: RouteResponse = { + success: true, + route: { + segments, + totalDistance, + estimatedTime, + summary, + }, + }; + + return NextResponse.json(response, { + status: 200, + headers: { + 'Cache-Control': 'no-store', + 'Access-Control-Allow-Origin': '*', + }, + }); + } catch (error) { + console.error('Routing API error:', error); + return NextResponse.json( + { success: false, error: 'Internal server error' }, + { status: 500 } + ); + } +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); +} diff --git a/src/components/map/DualMapView.tsx b/src/components/map/DualMapView.tsx index d30ee67..5e22021 100644 --- a/src/components/map/DualMapView.tsx +++ b/src/components/map/DualMapView.tsx @@ -4,6 +4,8 @@ import { useState, useEffect, useCallback } from 'react'; import dynamic from 'next/dynamic'; import type { Participant, MapViewport, Waypoint } from '@/types'; import { isInC3NavArea } from '@/lib/c3nav'; +import { useRoomStore } from '@/stores/room'; +import NavigationPanel from './NavigationPanel'; // Dynamic imports to avoid SSR issues const MapView = dynamic(() => import('./MapView'), { @@ -59,6 +61,11 @@ export default function DualMapView({ }: DualMapViewProps) { const [mode, setMode] = useState(initialMode); const [activeView, setActiveView] = useState<'outdoor' | 'indoor'>('outdoor'); + const [selectedParticipant, setSelectedParticipant] = useState(null); + const [selectedWaypoint, setSelectedWaypoint] = useState(null); + + // Get route state from store + const { activeRoute, clearRoute } = useRoomStore(); // Auto-detect indoor/outdoor based on location useEffect(() => { @@ -86,6 +93,26 @@ export default function DualMapView({ setActiveView('indoor'); }, []); + // Handle participant click - show navigation panel + const handleParticipantClick = useCallback((participant: Participant) => { + setSelectedParticipant(participant); + setSelectedWaypoint(null); + onParticipantClick?.(participant); + }, [onParticipantClick]); + + // Handle waypoint click - show navigation panel + const handleWaypointClick = useCallback((waypoint: Waypoint) => { + setSelectedWaypoint(waypoint); + setSelectedParticipant(null); + onWaypointClick?.(waypoint); + }, [onWaypointClick]); + + // Close navigation panel + const closeNavigationPanel = useCallback(() => { + setSelectedParticipant(null); + setSelectedWaypoint(null); + }, []); + return (
{/* Map view */} @@ -94,20 +121,37 @@ export default function DualMapView({ participants={participants} waypoints={waypoints} currentUserId={currentUserId} - onParticipantClick={onParticipantClick} - onWaypointClick={onWaypointClick} + onParticipantClick={handleParticipantClick} + onWaypointClick={handleWaypointClick} + routeSegments={activeRoute?.segments} + routeLoading={activeRoute?.isLoading} + routeError={activeRoute?.error} + routeSummary={activeRoute?.summary} + routeDistance={activeRoute?.totalDistance} + routeTime={activeRoute?.estimatedTime} + routeDestination={activeRoute?.to.name} + onClearRoute={clearRoute} /> ) : ( )} + {/* Navigation panel for selected participant/waypoint */} + {(selectedParticipant || selectedWaypoint) && ( + + )} + {/* Indoor Map button - switch to indoor view */} {activeView === 'outdoor' && ( +
+ + {/* Actions */} +
+ {!isSelf && hasLocation && ( + <> + {isNavigatingToThis ? ( + + ) : ( + + )} + + )} + + {!hasLocation && !isSelf && ( +
+ + + + Location not available +
+ )} + + {isSelf && ( +
+ + + + + This is your location +
+ )} +
+ + {/* Location info if available */} + {hasLocation && ( +
+ {isParticipant && selectedParticipant.location?.indoor ? ( + Indoor: Level {selectedParticipant.location.indoor.level} + ) : ( + Outdoor location + )} +
+ )} + + + ); +} diff --git a/src/components/map/RouteOverlay.tsx b/src/components/map/RouteOverlay.tsx new file mode 100644 index 0000000..0206286 --- /dev/null +++ b/src/components/map/RouteOverlay.tsx @@ -0,0 +1,296 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import type { Map as MaplibreMap } from 'maplibre-gl'; +import type { RouteSegment } from '@/types'; + +interface RouteOverlayProps { + map: MaplibreMap | null; + segments: RouteSegment[]; + isLoading?: boolean; + error?: string; + summary?: string; + totalDistance?: number; + estimatedTime?: number; + destinationName?: string; + onClose?: () => void; +} + +// Format distance in meters to a human-readable string +function formatDistance(meters: number): string { + if (meters < 1000) { + return `${Math.round(meters)}m`; + } + return `${(meters / 1000).toFixed(1)}km`; +} + +// Format time in seconds to a human-readable string +function formatTime(seconds: number): string { + if (seconds < 60) { + return `${Math.round(seconds)}s`; + } + const mins = Math.floor(seconds / 60); + if (mins < 60) { + return `${mins} min`; + } + const hours = Math.floor(mins / 60); + const remainingMins = mins % 60; + return `${hours}h ${remainingMins}m`; +} + +// Route line colors by segment type +const SEGMENT_COLORS = { + outdoor: '#3b82f6', // blue + indoor: '#8b5cf6', // purple + transition: '#f59e0b', // amber +}; + +export default function RouteOverlay({ + map, + segments, + isLoading, + error, + summary, + totalDistance = 0, + estimatedTime = 0, + destinationName, + onClose, +}: RouteOverlayProps) { + const sourceAddedRef = useRef(false); + + // Add/update route layer on the map + useEffect(() => { + if (!map || segments.length === 0) { + // Clean up existing route if no segments + if (map && sourceAddedRef.current) { + try { + if (map.getLayer('route-line')) map.removeLayer('route-line'); + if (map.getLayer('route-line-outline')) map.removeLayer('route-line-outline'); + if (map.getSource('route')) map.removeSource('route'); + sourceAddedRef.current = false; + } catch (e) { + // Ignore cleanup errors + } + } + return; + } + + // Build GeoJSON features for all outdoor segments + const outdoorSegments = segments.filter((s) => s.type === 'outdoor'); + const features = outdoorSegments.map((segment, index) => ({ + type: 'Feature' as const, + properties: { + segmentType: segment.type, + index, + }, + geometry: { + type: 'LineString' as const, + coordinates: segment.coordinates, + }, + })); + + const geojson = { + type: 'FeatureCollection' as const, + features, + }; + + // Wait for map to be loaded + const addRoute = () => { + try { + // Remove existing layers/source if they exist + if (map.getLayer('route-line')) map.removeLayer('route-line'); + if (map.getLayer('route-line-outline')) map.removeLayer('route-line-outline'); + if (map.getSource('route')) map.removeSource('route'); + + // Add source + map.addSource('route', { + type: 'geojson', + data: geojson, + }); + + // Add outline layer (for better visibility) + map.addLayer({ + id: 'route-line-outline', + type: 'line', + source: 'route', + layout: { + 'line-join': 'round', + 'line-cap': 'round', + }, + paint: { + 'line-color': '#1e3a5f', + 'line-width': 8, + 'line-opacity': 0.6, + }, + }); + + // Add main route line + map.addLayer({ + id: 'route-line', + type: 'line', + source: 'route', + layout: { + 'line-join': 'round', + 'line-cap': 'round', + }, + paint: { + 'line-color': SEGMENT_COLORS.outdoor, + 'line-width': 5, + 'line-opacity': 0.9, + }, + }); + + sourceAddedRef.current = true; + + // Fit map to route bounds + if (features.length > 0 && features[0].geometry.coordinates.length > 0) { + const allCoords = features.flatMap((f) => f.geometry.coordinates); + const bounds = allCoords.reduce( + (acc, coord) => ({ + minLng: Math.min(acc.minLng, coord[0]), + maxLng: Math.max(acc.maxLng, coord[0]), + minLat: Math.min(acc.minLat, coord[1]), + maxLat: Math.max(acc.maxLat, coord[1]), + }), + { minLng: Infinity, maxLng: -Infinity, minLat: Infinity, maxLat: -Infinity } + ); + + map.fitBounds( + [ + [bounds.minLng, bounds.minLat], + [bounds.maxLng, bounds.maxLat], + ], + { padding: 80, maxZoom: 16 } + ); + } + } catch (e) { + console.error('Error adding route to map:', e); + } + }; + + if (map.isStyleLoaded()) { + addRoute(); + } else { + map.once('load', addRoute); + } + + // Cleanup on unmount + return () => { + if (map && sourceAddedRef.current) { + try { + if (map.getLayer('route-line')) map.removeLayer('route-line'); + if (map.getLayer('route-line-outline')) map.removeLayer('route-line-outline'); + if (map.getSource('route')) map.removeSource('route'); + sourceAddedRef.current = false; + } catch (e) { + // Ignore cleanup errors (map might be destroyed) + } + } + }; + }, [map, segments]); + + // Don't render panel if no route data and not loading + if (!isLoading && !error && segments.length === 0 && !destinationName) { + return null; + } + + return ( +
+
+ {/* Header */} +
+
+ + + + Directions +
+ {onClose && ( + + )} +
+ + {/* Content */} +
+ {isLoading ? ( +
+
+ Calculating route... +
+ ) : error ? ( +
+ + + +
+

{error}

+
+
+ ) : ( + <> + {/* Destination */} + {destinationName && ( +
+ To +

{destinationName}

+
+ )} + + {/* Stats */} +
+
+ Distance +

{formatDistance(totalDistance)}

+
+
+ Walking time +

{formatTime(estimatedTime)}

+
+
+ + {/* Summary */} + {summary && ( +

{summary}

+ )} + + {/* Segment breakdown for mixed routes */} + {segments.some((s) => s.type === 'transition') && ( +
+ Route steps +
+ {segments.map((segment, index) => ( +
+
+ + {segment.type === 'outdoor' && 'Walk outside'} + {segment.type === 'indoor' && `Level ${segment.level}`} + {segment.type === 'transition' && (segment.instructions || 'Change level')} + + {segment.distance > 0 && ( + + {formatDistance(segment.distance)} + + )} +
+ ))} +
+
+ )} + + )} +
+
+
+ ); +} diff --git a/src/stores/room.ts b/src/stores/room.ts index 0fb3cb0..15aa62d 100644 --- a/src/stores/room.ts +++ b/src/stores/room.ts @@ -8,8 +8,31 @@ import type { Waypoint, RoomSettings, PrecisionLevel, + Route, + RouteSegment, } from '@/types'; +// Route state for navigation +interface ActiveRoute { + id: string; + from: { + type: 'participant' | 'waypoint' | 'current'; + id?: string; + name: string; + }; + to: { + type: 'participant' | 'waypoint'; + id: string; + name: string; + }; + segments: RouteSegment[]; + totalDistance: number; + estimatedTime: number; + summary: string; + isLoading: boolean; + error?: string; +} + // Color palette for participants const COLORS = [ '#10b981', // emerald @@ -30,6 +53,7 @@ interface RoomState { currentParticipantId: string | null; isConnected: boolean; error: string | null; + activeRoute: ActiveRoute | null; // Actions joinRoom: (slug: string, name: string, emoji: string) => void; @@ -40,6 +64,10 @@ interface RoomState { addWaypoint: (waypoint: Omit) => void; removeWaypoint: (waypointId: string) => void; + // Route actions + navigateTo: (target: { type: 'participant' | 'waypoint'; id: string }) => Promise; + clearRoute: () => void; + // Internal _syncFromDocument: (doc: unknown) => void; } @@ -50,6 +78,7 @@ export const useRoomStore = create((set, get) => ({ currentParticipantId: null, isConnected: false, error: null, + activeRoute: null, joinRoom: (slug: string, name: string, emoji: string) => { const participantId = nanoid(); @@ -163,6 +192,153 @@ export const useRoomStore = create((set, get) => ({ set({ room: { ...room } }); }, + navigateTo: async (target: { type: 'participant' | 'waypoint'; id: string }) => { + const { participants, room, currentParticipantId } = get(); + + // Get current user's location + const currentUser = participants.find((p) => p.id === currentParticipantId); + if (!currentUser?.location) { + set({ + activeRoute: { + id: nanoid(), + from: { type: 'current', name: 'You' }, + to: { type: target.type, id: target.id, name: 'Target' }, + segments: [], + totalDistance: 0, + estimatedTime: 0, + summary: '', + isLoading: false, + error: 'Enable location sharing to get directions', + }, + }); + return; + } + + // Get destination + let destLocation: { latitude: number; longitude: number; indoor?: { level: number; x: number; y: number } } | null = null; + let destName = ''; + + if (target.type === 'participant') { + const participant = participants.find((p) => p.id === target.id); + if (participant?.location) { + destLocation = { + latitude: participant.location.latitude, + longitude: participant.location.longitude, + indoor: participant.location.indoor, + }; + destName = participant.name; + } + } else if (target.type === 'waypoint') { + const waypoint = room?.waypoints.find((w) => w.id === target.id); + if (waypoint) { + destLocation = { + latitude: waypoint.location.latitude, + longitude: waypoint.location.longitude, + indoor: waypoint.location.indoor, + }; + destName = waypoint.name; + } + } + + if (!destLocation) { + set({ + activeRoute: { + id: nanoid(), + from: { type: 'current', name: currentUser.name }, + to: { type: target.type, id: target.id, name: destName || 'Unknown' }, + segments: [], + totalDistance: 0, + estimatedTime: 0, + summary: '', + isLoading: false, + error: 'Destination location not available', + }, + }); + return; + } + + // Set loading state + set({ + activeRoute: { + id: nanoid(), + from: { type: 'current', name: currentUser.name }, + to: { type: target.type, id: target.id, name: destName }, + segments: [], + totalDistance: 0, + estimatedTime: 0, + summary: '', + isLoading: true, + }, + }); + + try { + const response = await fetch('/api/routing', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + origin: { + latitude: currentUser.location.latitude, + longitude: currentUser.location.longitude, + indoor: currentUser.location.indoor, + }, + destination: destLocation, + mode: 'walking', + eventId: room?.settings.eventId || '38c3', + }), + }); + + const data = await response.json(); + + if (data.success && data.route) { + set({ + activeRoute: { + id: nanoid(), + from: { type: 'current', name: currentUser.name }, + to: { type: target.type, id: target.id, name: destName }, + segments: data.route.segments, + totalDistance: data.route.totalDistance, + estimatedTime: data.route.estimatedTime, + summary: data.route.summary, + isLoading: false, + }, + }); + } else { + set({ + activeRoute: { + id: nanoid(), + from: { type: 'current', name: currentUser.name }, + to: { type: target.type, id: target.id, name: destName }, + segments: [], + totalDistance: 0, + estimatedTime: 0, + summary: '', + isLoading: false, + error: data.error || 'Could not calculate route', + }, + }); + } + } catch (error) { + console.error('Navigation error:', error); + set({ + activeRoute: { + id: nanoid(), + from: { type: 'current', name: currentUser.name }, + to: { type: target.type, id: target.id, name: destName }, + segments: [], + totalDistance: 0, + estimatedTime: 0, + summary: '', + isLoading: false, + error: 'Failed to calculate route', + }, + }); + } + }, + + clearRoute: () => { + set({ activeRoute: null }); + }, + _syncFromDocument: (doc: unknown) => { // TODO: Implement Automerge document sync console.log('Sync from document:', doc);