feat: Add navigation routes feature with indoor/outdoor routing

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-28 23:24:42 +01:00
parent 2d960a53f2
commit a6c124c14c
6 changed files with 1095 additions and 4 deletions

View File

@ -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<RouteSegment | null> {
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<RouteSegment[] | null> {
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',
},
});
}

View File

@ -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<MapMode>(initialMode);
const [activeView, setActiveView] = useState<'outdoor' | 'indoor'>('outdoor');
const [selectedParticipant, setSelectedParticipant] = useState<Participant | null>(null);
const [selectedWaypoint, setSelectedWaypoint] = useState<Waypoint | null>(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 (
<div className="relative w-full h-full">
{/* 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}
/>
) : (
<IndoorMapView
eventId={eventId}
participants={participants}
currentUserId={currentUserId}
onParticipantClick={onParticipantClick}
onParticipantClick={handleParticipantClick}
onSwitchToOutdoor={goOutdoor}
onPositionSet={onIndoorPositionSet}
/>
)}
{/* Navigation panel for selected participant/waypoint */}
{(selectedParticipant || selectedWaypoint) && (
<NavigationPanel
selectedParticipant={selectedParticipant}
selectedWaypoint={selectedWaypoint}
onClose={closeNavigationPanel}
/>
)}
{/* Indoor Map button - switch to indoor view */}
{activeView === 'outdoor' && (
<button

View File

@ -3,8 +3,9 @@
import { useEffect, useRef, useState } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import type { Participant, MapViewport, Waypoint } from '@/types';
import type { Participant, MapViewport, Waypoint, RouteSegment } from '@/types';
import FriendMarker from './FriendMarker';
import RouteOverlay from './RouteOverlay';
interface MapViewProps {
participants: Participant[];
@ -16,6 +17,22 @@ interface MapViewProps {
onMapClick?: (lngLat: { lng: number; lat: number }) => void;
/** Auto-center on current user's location when first available */
autoCenterOnUser?: boolean;
/** Active route segments to display */
routeSegments?: RouteSegment[];
/** Route loading state */
routeLoading?: boolean;
/** Route error message */
routeError?: string;
/** Route summary text */
routeSummary?: string;
/** Total route distance in meters */
routeDistance?: number;
/** Estimated route time in seconds */
routeTime?: number;
/** Destination name for route */
routeDestination?: string;
/** Callback when route is cleared */
onClearRoute?: () => void;
}
// Default to Hamburg CCH area for CCC events
@ -47,6 +64,14 @@ export default function MapView({
onWaypointClick,
onMapClick,
autoCenterOnUser = true,
routeSegments = [],
routeLoading = false,
routeError,
routeSummary,
routeDistance = 0,
routeTime = 0,
routeDestination,
onClearRoute,
}: MapViewProps) {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(null);
@ -327,10 +352,28 @@ export default function MapView({
}
};
// Check if there are route segments or route is loading/errored
const hasActiveRoute = routeSegments.length > 0 || routeLoading || routeError;
return (
<div className="relative w-full h-full">
<div ref={mapContainer} className="w-full h-full" />
{/* Route overlay */}
{hasActiveRoute && (
<RouteOverlay
map={map.current}
segments={routeSegments}
isLoading={routeLoading}
error={routeError}
summary={routeSummary}
totalDistance={routeDistance}
estimatedTime={routeTime}
destinationName={routeDestination}
onClose={onClearRoute}
/>
)}
{/* Fit all button */}
{participants.some((p) => p.location) && (
<button

View File

@ -0,0 +1,166 @@
'use client';
import { useRoomStore } from '@/stores/room';
import type { Participant, Waypoint } from '@/types';
interface NavigationPanelProps {
selectedParticipant?: Participant | null;
selectedWaypoint?: Waypoint | null;
onClose: () => void;
}
export default function NavigationPanel({
selectedParticipant,
selectedWaypoint,
onClose,
}: NavigationPanelProps) {
const { navigateTo, activeRoute, clearRoute, currentParticipantId } = useRoomStore();
const target = selectedParticipant || selectedWaypoint;
if (!target) return null;
const isParticipant = !!selectedParticipant;
const isSelf = isParticipant && selectedParticipant.id === currentParticipantId;
const hasLocation = isParticipant
? !!selectedParticipant.location
: !!selectedWaypoint?.location;
const handleNavigate = () => {
if (isParticipant && selectedParticipant) {
navigateTo({ type: 'participant', id: selectedParticipant.id });
} else if (selectedWaypoint) {
navigateTo({ type: 'waypoint', id: selectedWaypoint.id });
}
};
const isNavigatingToThis =
activeRoute &&
!activeRoute.isLoading &&
!activeRoute.error &&
activeRoute.to.id === (isParticipant ? selectedParticipant?.id : selectedWaypoint?.id);
return (
<div className="absolute top-16 left-4 right-4 sm:left-auto sm:right-4 sm:w-72 z-40">
<div className="bg-rmaps-dark/95 backdrop-blur-sm rounded-lg shadow-xl border border-white/10 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<div className="flex items-center gap-3">
{isParticipant ? (
<>
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-lg"
style={{ backgroundColor: selectedParticipant.color }}
>
{selectedParticipant.emoji}
</div>
<div>
<p className="font-medium text-white">
{selectedParticipant.name}
{isSelf && <span className="text-rmaps-primary ml-1">(you)</span>}
</p>
<p className="text-xs text-white/60">
{selectedParticipant.status === 'online' && 'Sharing location'}
{selectedParticipant.status === 'away' && 'Away'}
{selectedParticipant.status === 'ghost' && 'Hidden'}
{selectedParticipant.status === 'offline' && 'Offline'}
</p>
</div>
</>
) : (
<>
<div className="w-10 h-10 rounded-full bg-rmaps-secondary flex items-center justify-center text-lg">
{selectedWaypoint?.emoji || '📍'}
</div>
<div>
<p className="font-medium text-white">{selectedWaypoint?.name}</p>
<p className="text-xs text-white/60 capitalize">{selectedWaypoint?.type}</p>
</div>
</>
)}
</div>
<button
onClick={onClose}
className="text-white/60 hover:text-white transition-colors p-1"
aria-label="Close"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Actions */}
<div className="p-3 space-y-2">
{!isSelf && hasLocation && (
<>
{isNavigatingToThis ? (
<button
onClick={() => {
clearRoute();
onClose();
}}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Stop navigating
</button>
) : (
<button
onClick={handleNavigate}
disabled={activeRoute?.isLoading}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-rmaps-primary text-white rounded-lg hover:bg-rmaps-primary/80 transition-colors disabled:opacity-50"
>
{activeRoute?.isLoading ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Calculating...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
Navigate here
</>
)}
</button>
)}
</>
)}
{!hasLocation && !isSelf && (
<div className="flex items-center gap-2 px-4 py-2.5 bg-white/5 rounded-lg text-white/60 text-sm">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3" />
</svg>
Location not available
</div>
)}
{isSelf && (
<div className="flex items-center gap-2 px-4 py-2.5 bg-rmaps-primary/10 rounded-lg text-rmaps-primary text-sm">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
This is your location
</div>
)}
</div>
{/* Location info if available */}
{hasLocation && (
<div className="px-4 pb-3 text-xs text-white/50">
{isParticipant && selectedParticipant.location?.indoor ? (
<span>Indoor: Level {selectedParticipant.location.indoor.level}</span>
) : (
<span>Outdoor location</span>
)}
</div>
)}
</div>
</div>
);
}

View File

@ -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 (
<div className="absolute bottom-20 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 z-40">
<div className="bg-rmaps-dark/95 backdrop-blur-sm rounded-lg shadow-xl border border-white/10 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-rmaps-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<span className="font-medium text-white">Directions</span>
</div>
{onClose && (
<button
onClick={onClose}
className="text-white/60 hover:text-white transition-colors p-1"
aria-label="Close directions"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Content */}
<div className="p-4">
{isLoading ? (
<div className="flex items-center gap-3">
<div className="w-5 h-5 border-2 border-rmaps-primary border-t-transparent rounded-full animate-spin" />
<span className="text-white/80">Calculating route...</span>
</div>
) : error ? (
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p className="text-red-400 text-sm">{error}</p>
</div>
</div>
) : (
<>
{/* Destination */}
{destinationName && (
<div className="mb-3">
<span className="text-white/60 text-xs">To</span>
<p className="text-white font-medium">{destinationName}</p>
</div>
)}
{/* Stats */}
<div className="flex items-center gap-6 mb-3">
<div>
<span className="text-white/60 text-xs">Distance</span>
<p className="text-white font-medium text-lg">{formatDistance(totalDistance)}</p>
</div>
<div>
<span className="text-white/60 text-xs">Walking time</span>
<p className="text-white font-medium text-lg">{formatTime(estimatedTime)}</p>
</div>
</div>
{/* Summary */}
{summary && (
<p className="text-white/70 text-sm">{summary}</p>
)}
{/* Segment breakdown for mixed routes */}
{segments.some((s) => s.type === 'transition') && (
<div className="mt-3 pt-3 border-t border-white/10">
<span className="text-white/60 text-xs">Route steps</span>
<div className="mt-2 space-y-2">
{segments.map((segment, index) => (
<div key={index} className="flex items-center gap-2 text-sm">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: SEGMENT_COLORS[segment.type] }}
/>
<span className="text-white/80">
{segment.type === 'outdoor' && 'Walk outside'}
{segment.type === 'indoor' && `Level ${segment.level}`}
{segment.type === 'transition' && (segment.instructions || 'Change level')}
</span>
{segment.distance > 0 && (
<span className="text-white/50 ml-auto">
{formatDistance(segment.distance)}
</span>
)}
</div>
))}
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}

View File

@ -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<Waypoint, 'id' | 'createdAt' | 'createdBy'>) => void;
removeWaypoint: (waypointId: string) => void;
// Route actions
navigateTo: (target: { type: 'participant' | 'waypoint'; id: string }) => Promise<void>;
clearRoute: () => void;
// Internal
_syncFromDocument: (doc: unknown) => void;
}
@ -50,6 +78,7 @@ export const useRoomStore = create<RoomState>((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<RoomState>((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);