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:
parent
2d960a53f2
commit
a6c124c14c
|
|
@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ import { useState, useEffect, useCallback } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import type { Participant, MapViewport, Waypoint } from '@/types';
|
import type { Participant, MapViewport, Waypoint } from '@/types';
|
||||||
import { isInC3NavArea } from '@/lib/c3nav';
|
import { isInC3NavArea } from '@/lib/c3nav';
|
||||||
|
import { useRoomStore } from '@/stores/room';
|
||||||
|
import NavigationPanel from './NavigationPanel';
|
||||||
|
|
||||||
// Dynamic imports to avoid SSR issues
|
// Dynamic imports to avoid SSR issues
|
||||||
const MapView = dynamic(() => import('./MapView'), {
|
const MapView = dynamic(() => import('./MapView'), {
|
||||||
|
|
@ -59,6 +61,11 @@ export default function DualMapView({
|
||||||
}: DualMapViewProps) {
|
}: DualMapViewProps) {
|
||||||
const [mode, setMode] = useState<MapMode>(initialMode);
|
const [mode, setMode] = useState<MapMode>(initialMode);
|
||||||
const [activeView, setActiveView] = useState<'outdoor' | 'indoor'>('outdoor');
|
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
|
// Auto-detect indoor/outdoor based on location
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -86,6 +93,26 @@ export default function DualMapView({
|
||||||
setActiveView('indoor');
|
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 (
|
return (
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
{/* Map view */}
|
{/* Map view */}
|
||||||
|
|
@ -94,20 +121,37 @@ export default function DualMapView({
|
||||||
participants={participants}
|
participants={participants}
|
||||||
waypoints={waypoints}
|
waypoints={waypoints}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
onParticipantClick={onParticipantClick}
|
onParticipantClick={handleParticipantClick}
|
||||||
onWaypointClick={onWaypointClick}
|
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
|
<IndoorMapView
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
participants={participants}
|
participants={participants}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
onParticipantClick={onParticipantClick}
|
onParticipantClick={handleParticipantClick}
|
||||||
onSwitchToOutdoor={goOutdoor}
|
onSwitchToOutdoor={goOutdoor}
|
||||||
onPositionSet={onIndoorPositionSet}
|
onPositionSet={onIndoorPositionSet}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Navigation panel for selected participant/waypoint */}
|
||||||
|
{(selectedParticipant || selectedWaypoint) && (
|
||||||
|
<NavigationPanel
|
||||||
|
selectedParticipant={selectedParticipant}
|
||||||
|
selectedWaypoint={selectedWaypoint}
|
||||||
|
onClose={closeNavigationPanel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Indoor Map button - switch to indoor view */}
|
{/* Indoor Map button - switch to indoor view */}
|
||||||
{activeView === 'outdoor' && (
|
{activeView === 'outdoor' && (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
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 FriendMarker from './FriendMarker';
|
||||||
|
import RouteOverlay from './RouteOverlay';
|
||||||
|
|
||||||
interface MapViewProps {
|
interface MapViewProps {
|
||||||
participants: Participant[];
|
participants: Participant[];
|
||||||
|
|
@ -16,6 +17,22 @@ interface MapViewProps {
|
||||||
onMapClick?: (lngLat: { lng: number; lat: number }) => void;
|
onMapClick?: (lngLat: { lng: number; lat: number }) => void;
|
||||||
/** Auto-center on current user's location when first available */
|
/** Auto-center on current user's location when first available */
|
||||||
autoCenterOnUser?: boolean;
|
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
|
// Default to Hamburg CCH area for CCC events
|
||||||
|
|
@ -47,6 +64,14 @@ export default function MapView({
|
||||||
onWaypointClick,
|
onWaypointClick,
|
||||||
onMapClick,
|
onMapClick,
|
||||||
autoCenterOnUser = true,
|
autoCenterOnUser = true,
|
||||||
|
routeSegments = [],
|
||||||
|
routeLoading = false,
|
||||||
|
routeError,
|
||||||
|
routeSummary,
|
||||||
|
routeDistance = 0,
|
||||||
|
routeTime = 0,
|
||||||
|
routeDestination,
|
||||||
|
onClearRoute,
|
||||||
}: MapViewProps) {
|
}: MapViewProps) {
|
||||||
const mapContainer = useRef<HTMLDivElement>(null);
|
const mapContainer = useRef<HTMLDivElement>(null);
|
||||||
const map = useRef<maplibregl.Map | null>(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 (
|
return (
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<div ref={mapContainer} className="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 */}
|
{/* Fit all button */}
|
||||||
{participants.some((p) => p.location) && (
|
{participants.some((p) => p.location) && (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,31 @@ import type {
|
||||||
Waypoint,
|
Waypoint,
|
||||||
RoomSettings,
|
RoomSettings,
|
||||||
PrecisionLevel,
|
PrecisionLevel,
|
||||||
|
Route,
|
||||||
|
RouteSegment,
|
||||||
} from '@/types';
|
} 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
|
// Color palette for participants
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
'#10b981', // emerald
|
'#10b981', // emerald
|
||||||
|
|
@ -30,6 +53,7 @@ interface RoomState {
|
||||||
currentParticipantId: string | null;
|
currentParticipantId: string | null;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
activeRoute: ActiveRoute | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
joinRoom: (slug: string, name: string, emoji: string) => void;
|
joinRoom: (slug: string, name: string, emoji: string) => void;
|
||||||
|
|
@ -40,6 +64,10 @@ interface RoomState {
|
||||||
addWaypoint: (waypoint: Omit<Waypoint, 'id' | 'createdAt' | 'createdBy'>) => void;
|
addWaypoint: (waypoint: Omit<Waypoint, 'id' | 'createdAt' | 'createdBy'>) => void;
|
||||||
removeWaypoint: (waypointId: string) => void;
|
removeWaypoint: (waypointId: string) => void;
|
||||||
|
|
||||||
|
// Route actions
|
||||||
|
navigateTo: (target: { type: 'participant' | 'waypoint'; id: string }) => Promise<void>;
|
||||||
|
clearRoute: () => void;
|
||||||
|
|
||||||
// Internal
|
// Internal
|
||||||
_syncFromDocument: (doc: unknown) => void;
|
_syncFromDocument: (doc: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +78,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
currentParticipantId: null,
|
currentParticipantId: null,
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
activeRoute: null,
|
||||||
|
|
||||||
joinRoom: (slug: string, name: string, emoji: string) => {
|
joinRoom: (slug: string, name: string, emoji: string) => {
|
||||||
const participantId = nanoid();
|
const participantId = nanoid();
|
||||||
|
|
@ -163,6 +192,153 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
set({ room: { ...room } });
|
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) => {
|
_syncFromDocument: (doc: unknown) => {
|
||||||
// TODO: Implement Automerge document sync
|
// TODO: Implement Automerge document sync
|
||||||
console.log('Sync from document:', doc);
|
console.log('Sync from document:', doc);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue