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 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue