From f726bac67afbcd08ab4fd9bfc1606e0c89576d4b Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 4 Dec 2025 06:51:35 -0800 Subject: [PATCH] Merge main into feature/open-mapping, resolve conflicts --- src/open-mapping/components/LayerPanel.tsx | 21 +- src/open-mapping/components/MapCanvas.tsx | 39 +++- src/open-mapping/components/RouteLayer.tsx | 37 +++- .../components/WaypointMarker.tsx | 30 ++- src/open-mapping/hooks/useCollaboration.ts | 108 +++++++++-- src/open-mapping/hooks/useLayers.ts | 179 ++++++++++++++++-- src/open-mapping/hooks/useMapInstance.ts | 67 ++++++- src/open-mapping/hooks/useRouting.ts | 135 ++++++++++--- src/open-mapping/types/index.ts | 41 +++- 9 files changed, 561 insertions(+), 96 deletions(-) diff --git a/src/open-mapping/components/LayerPanel.tsx b/src/open-mapping/components/LayerPanel.tsx index ac10c88..afd56c7 100644 --- a/src/open-mapping/components/LayerPanel.tsx +++ b/src/open-mapping/components/LayerPanel.tsx @@ -1,5 +1,12 @@ /** * LayerPanel - UI for managing map layers + * + * Features: + * - Toggle layer visibility + * - Adjust layer opacity + * - Reorder layers (z-index) + * - Add custom layers (GeoJSON, tiles) + * - Import/export layer configurations */ import type { MapLayer } from '../types'; @@ -11,9 +18,21 @@ interface LayerPanelProps { onLayerReorder?: (layerIds: string[]) => void; onLayerAdd?: (layer: Omit) => void; onLayerRemove?: (layerId: string) => void; + onLayerEdit?: (layerId: string, updates: Partial) => void; } -export function LayerPanel({ layers, onLayerToggle }: LayerPanelProps) { +export function LayerPanel({ + layers, + onLayerToggle, + onLayerOpacity, + onLayerReorder, + onLayerAdd, + onLayerRemove, + onLayerEdit, +}: LayerPanelProps) { + // TODO: Implement layer panel UI + // This will be implemented in Phase 2 + return (

Layers

diff --git a/src/open-mapping/components/MapCanvas.tsx b/src/open-mapping/components/MapCanvas.tsx index 13288f7..a772387 100644 --- a/src/open-mapping/components/MapCanvas.tsx +++ b/src/open-mapping/components/MapCanvas.tsx @@ -1,5 +1,8 @@ /** - * MapCanvas - Main map component integrating with tldraw canvas + * MapCanvas - Main map component that integrates with tldraw canvas + * + * Renders a MapLibre GL JS map as a layer within the tldraw canvas, + * enabling collaborative route planning with full canvas editing capabilities. */ import { useEffect, useRef, useState } from 'react'; @@ -11,7 +14,7 @@ interface MapCanvasProps { onViewportChange?: (viewport: MapViewport) => void; onMapClick?: (coordinate: Coordinate) => void; onMapLoad?: () => void; - style?: string; + style?: string; // MapLibre style URL interactive?: boolean; } @@ -28,18 +31,42 @@ export function MapCanvas({ const [isLoaded, setIsLoaded] = useState(false); useEffect(() => { - // TODO: Initialize MapLibre GL JS instance (Phase 1) + // TODO: Initialize MapLibre GL JS instance + // This will be implemented in Phase 1 console.log('MapCanvas: Initializing with viewport', viewport); - return () => { /* Cleanup */ }; + + return () => { + // Cleanup map instance + }; }, []); + useEffect(() => { + // TODO: Update layers when they change + console.log('MapCanvas: Updating layers', layers); + }, [layers]); + + useEffect(() => { + // TODO: Sync viewport changes + if (isLoaded) { + console.log('MapCanvas: Viewport changed', viewport); + } + }, [viewport, isLoaded]); + return (
- {!isLoaded &&
Loading map...
} + {!isLoaded && ( +
+ Loading map... +
+ )}
); } diff --git a/src/open-mapping/components/RouteLayer.tsx b/src/open-mapping/components/RouteLayer.tsx index 8d8e158..dd65bf5 100644 --- a/src/open-mapping/components/RouteLayer.tsx +++ b/src/open-mapping/components/RouteLayer.tsx @@ -1,5 +1,11 @@ /** * RouteLayer - Renders route polylines on the map + * + * Displays computed routes with support for: + * - Multiple alternative routes + * - Turn-by-turn visualization + * - Elevation profile overlay + * - Interactive route editing */ import type { Route, RoutingProfile } from '../types'; @@ -15,14 +21,33 @@ interface RouteLayerProps { } const DEFAULT_PROFILE_COLORS: Record = { - car: '#3B82F6', truck: '#6366F1', motorcycle: '#8B5CF6', - bicycle: '#10B981', mountain_bike: '#059669', road_bike: '#14B8A6', - foot: '#F59E0B', hiking: '#D97706', wheelchair: '#EC4899', transit: '#6B7280', + car: '#3B82F6', // blue + truck: '#6366F1', // indigo + motorcycle: '#8B5CF6', // violet + bicycle: '#10B981', // emerald + mountain_bike: '#059669', // green + road_bike: '#14B8A6', // teal + foot: '#F59E0B', // amber + hiking: '#D97706', // orange + wheelchair: '#EC4899', // pink + transit: '#6B7280', // gray }; -export function RouteLayer({ routes, selectedRouteId, profileColors = {} }: RouteLayerProps) { - // TODO: Implement route rendering (Phase 2) - return null; +export function RouteLayer({ + routes, + selectedRouteId, + showAlternatives = true, + showElevation = false, + onRouteSelect, + onRouteEdit, + profileColors = {}, +}: RouteLayerProps) { + const colors = { ...DEFAULT_PROFILE_COLORS, ...profileColors }; + + // TODO: Implement route rendering with MapLibre GL JS + // This will be implemented in Phase 2 + + return null; // Routes are rendered directly on the map canvas } export default RouteLayer; diff --git a/src/open-mapping/components/WaypointMarker.tsx b/src/open-mapping/components/WaypointMarker.tsx index 694e4b1..f65680f 100644 --- a/src/open-mapping/components/WaypointMarker.tsx +++ b/src/open-mapping/components/WaypointMarker.tsx @@ -1,5 +1,11 @@ /** - * WaypointMarker - Interactive waypoint markers + * WaypointMarker - Interactive waypoint markers on the map + * + * Features: + * - Drag-and-drop repositioning + * - Custom icons and colors + * - Info popups with waypoint details + * - Time/budget annotations */ import type { Waypoint } from '../types'; @@ -9,14 +15,30 @@ interface WaypointMarkerProps { index?: number; isSelected?: boolean; isDraggable?: boolean; + showLabel?: boolean; + showTime?: boolean; + showBudget?: boolean; onSelect?: (waypointId: string) => void; onDragEnd?: (waypointId: string, newCoordinate: { lat: number; lng: number }) => void; onDelete?: (waypointId: string) => void; } -export function WaypointMarker({ waypoint, isSelected = false }: WaypointMarkerProps) { - // TODO: Implement marker rendering (Phase 1) - return null; +export function WaypointMarker({ + waypoint, + index, + isSelected = false, + isDraggable = true, + showLabel = true, + showTime = false, + showBudget = false, + onSelect, + onDragEnd, + onDelete, +}: WaypointMarkerProps) { + // TODO: Implement marker rendering with MapLibre GL JS + // This will be implemented in Phase 1 + + return null; // Markers are rendered directly on the map } export default WaypointMarker; diff --git a/src/open-mapping/hooks/useCollaboration.ts b/src/open-mapping/hooks/useCollaboration.ts index cf3ae84..bb08edb 100644 --- a/src/open-mapping/hooks/useCollaboration.ts +++ b/src/open-mapping/hooks/useCollaboration.ts @@ -1,9 +1,22 @@ /** - * useCollaboration - Hook for real-time collaborative map editing via Y.js + * useCollaboration - Hook for real-time collaborative map editing + * + * Uses Y.js for CRDT-based synchronization, enabling: + * - Real-time waypoint/route sharing + * - Cursor presence awareness + * - Conflict-free concurrent edits + * - Offline-first with sync on reconnect */ import { useState, useEffect, useCallback } from 'react'; -import type { CollaborationSession, Participant, Route, Waypoint, MapLayer, Coordinate } from '../types'; +import type { + CollaborationSession, + Participant, + Route, + Waypoint, + MapLayer, + Coordinate, +} from '../types'; interface UseCollaborationOptions { sessionId?: string; @@ -13,43 +26,106 @@ interface UseCollaborationOptions { serverUrl?: string; onParticipantJoin?: (participant: Participant) => void; onParticipantLeave?: (participantId: string) => void; + onRouteUpdate?: (routes: Route[]) => void; + onWaypointUpdate?: (waypoints: Waypoint[]) => void; +} + +interface UseCollaborationReturn { + session: CollaborationSession | null; + participants: Participant[]; + isConnected: boolean; + createSession: (name: string) => Promise; + joinSession: (sessionId: string) => Promise; + leaveSession: () => void; + updateCursor: (coordinate: Coordinate) => void; + broadcastRouteChange: (route: Route) => void; + broadcastWaypointChange: (waypoint: Waypoint) => void; + broadcastLayerChange: (layer: MapLayer) => void; } export function useCollaboration({ - sessionId, userId, userName, userColor = '#3B82F6', serverUrl, -}: UseCollaborationOptions) { + sessionId, + userId, + userName, + userColor = '#3B82F6', + serverUrl, + onParticipantJoin, + onParticipantLeave, + onRouteUpdate, + onWaypointUpdate, +}: UseCollaborationOptions): UseCollaborationReturn { const [session, setSession] = useState(null); const [participants, setParticipants] = useState([]); const [isConnected, setIsConnected] = useState(false); + // TODO: Initialize Y.js document and WebSocket provider useEffect(() => { if (!sessionId) return; - // TODO: Initialize Y.js (Phase 3) + + console.log('useCollaboration: Would connect to session', sessionId); + // const ydoc = new Y.Doc(); + // const provider = new WebsocketProvider(serverUrl, sessionId, ydoc); + setIsConnected(true); - return () => { setIsConnected(false); }; + + return () => { + // provider.destroy(); + // ydoc.destroy(); + setIsConnected(false); + }; }, [sessionId, serverUrl]); - const createSession = useCallback(async (name: string) => { + const createSession = useCallback(async (name: string): Promise => { + // TODO: Create new Y.js document and return session ID const newSessionId = `session-${Date.now()}`; + console.log('useCollaboration: Creating session', name, newSessionId); return newSessionId; }, []); - const joinSession = useCallback(async (sessionIdToJoin: string) => { - console.log('Joining session', sessionIdToJoin); + const joinSession = useCallback(async (sessionIdToJoin: string): Promise => { + // TODO: Join existing Y.js session + console.log('useCollaboration: Joining session', sessionIdToJoin); }, []); const leaveSession = useCallback(() => { - setSession(null); setParticipants([]); setIsConnected(false); + // TODO: Disconnect from session + console.log('useCollaboration: Leaving session'); + setSession(null); + setParticipants([]); + setIsConnected(false); }, []); - const updateCursor = useCallback((coordinate: Coordinate) => {}, []); - const broadcastRouteChange = useCallback((route: Route) => {}, []); - const broadcastWaypointChange = useCallback((waypoint: Waypoint) => {}, []); - const broadcastLayerChange = useCallback((layer: MapLayer) => {}, []); + const updateCursor = useCallback((coordinate: Coordinate) => { + // TODO: Broadcast cursor position via Y.js awareness + // awareness.setLocalStateField('cursor', coordinate); + }, []); + + const broadcastRouteChange = useCallback((route: Route) => { + // TODO: Update Y.js shared route array + console.log('useCollaboration: Broadcasting route change', route.id); + }, []); + + const broadcastWaypointChange = useCallback((waypoint: Waypoint) => { + // TODO: Update Y.js shared waypoint array + console.log('useCollaboration: Broadcasting waypoint change', waypoint.id); + }, []); + + const broadcastLayerChange = useCallback((layer: MapLayer) => { + // TODO: Update Y.js shared layer array + console.log('useCollaboration: Broadcasting layer change', layer.id); + }, []); return { - session, participants, isConnected, createSession, joinSession, leaveSession, - updateCursor, broadcastRouteChange, broadcastWaypointChange, broadcastLayerChange, + session, + participants, + isConnected, + createSession, + joinSession, + leaveSession, + updateCursor, + broadcastRouteChange, + broadcastWaypointChange, + broadcastLayerChange, }; } diff --git a/src/open-mapping/hooks/useLayers.ts b/src/open-mapping/hooks/useLayers.ts index cde1ca8..21c3e64 100644 --- a/src/open-mapping/hooks/useLayers.ts +++ b/src/open-mapping/hooks/useLayers.ts @@ -1,29 +1,124 @@ /** * useLayers - Hook for managing map layers + * + * Provides: + * - Layer CRUD operations + * - Visibility and opacity controls + * - Layer ordering (z-index) + * - Preset layer templates */ import { useState, useCallback } from 'react'; -import type { MapLayer } from '../types'; +import type { MapLayer, LayerType, LayerSource, LayerStyle } from '../types'; interface UseLayersOptions { initialLayers?: MapLayer[]; onLayerChange?: (layers: MapLayer[]) => void; } -export type LayerPreset = 'osm-standard' | 'osm-humanitarian' | 'satellite' | 'terrain' | 'cycling' | 'hiking'; +interface UseLayersReturn { + layers: MapLayer[]; + addLayer: (layer: Omit) => string; + removeLayer: (layerId: string) => void; + updateLayer: (layerId: string, updates: Partial) => void; + toggleVisibility: (layerId: string) => void; + setOpacity: (layerId: string, opacity: number) => void; + reorderLayers: (layerIds: string[]) => void; + getLayer: (layerId: string) => MapLayer | undefined; + addPresetLayer: (preset: LayerPreset) => string; +} + +export type LayerPreset = + | 'osm-standard' + | 'osm-humanitarian' + | 'satellite' + | 'terrain' + | 'cycling' + | 'hiking'; const PRESET_LAYERS: Record> = { - 'osm-standard': { name: 'OpenStreetMap', type: 'basemap', visible: true, opacity: 1, zIndex: 0, source: { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], attribution: '© OpenStreetMap' } }, - 'osm-humanitarian': { name: 'Humanitarian', type: 'basemap', visible: false, opacity: 1, zIndex: 0, source: { type: 'raster', tiles: ['https://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png'], attribution: '© OSM, HOT' } }, - 'satellite': { name: 'Satellite', type: 'satellite', visible: false, opacity: 1, zIndex: 0, source: { type: 'raster', tiles: [] } }, - 'terrain': { name: 'Terrain', type: 'terrain', visible: false, opacity: 0.5, zIndex: 1, source: { type: 'raster', tiles: ['https://stamen-tiles.a.ssl.fastly.net/terrain/{z}/{x}/{y}.png'], attribution: 'Stamen' } }, - 'cycling': { name: 'Cycling Routes', type: 'route', visible: false, opacity: 0.8, zIndex: 2, source: { type: 'raster', tiles: ['https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png'], attribution: 'Waymarked Trails' } }, - 'hiking': { name: 'Hiking Trails', type: 'route', visible: false, opacity: 0.8, zIndex: 2, source: { type: 'raster', tiles: ['https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png'], attribution: 'Waymarked Trails' } }, + 'osm-standard': { + name: 'OpenStreetMap', + type: 'basemap', + visible: true, + opacity: 1, + zIndex: 0, + source: { + type: 'raster', + tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], + attribution: '© OpenStreetMap contributors', + }, + }, + 'osm-humanitarian': { + name: 'Humanitarian', + type: 'basemap', + visible: false, + opacity: 1, + zIndex: 0, + source: { + type: 'raster', + tiles: ['https://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png'], + attribution: '© OpenStreetMap contributors, Tiles: HOT', + }, + }, + 'satellite': { + name: 'Satellite', + type: 'satellite', + visible: false, + opacity: 1, + zIndex: 0, + source: { + type: 'raster', + // Note: Would need proper satellite tile source (e.g., Mapbox, ESRI) + tiles: [], + attribution: '', + }, + }, + 'terrain': { + name: 'Terrain', + type: 'terrain', + visible: false, + opacity: 0.5, + zIndex: 1, + source: { + type: 'raster', + tiles: ['https://stamen-tiles.a.ssl.fastly.net/terrain/{z}/{x}/{y}.png'], + attribution: 'Map tiles by Stamen Design', + }, + }, + 'cycling': { + name: 'Cycling Routes', + type: 'route', + visible: false, + opacity: 0.8, + zIndex: 2, + source: { + type: 'raster', + tiles: ['https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png'], + attribution: 'Waymarked Trails', + }, + }, + 'hiking': { + name: 'Hiking Trails', + type: 'route', + visible: false, + opacity: 0.8, + zIndex: 2, + source: { + type: 'raster', + tiles: ['https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png'], + attribution: 'Waymarked Trails', + }, + }, }; let layerIdCounter = 0; +const generateLayerId = () => `layer-${++layerIdCounter}-${Date.now()}`; -export function useLayers({ initialLayers = [], onLayerChange }: UseLayersOptions = {}) { +export function useLayers({ + initialLayers = [], + onLayerChange, +}: UseLayersOptions = {}): UseLayersReturn { const [layers, setLayers] = useState(initialLayers); const updateAndNotify = useCallback((newLayers: MapLayer[]) => { @@ -31,24 +126,68 @@ export function useLayers({ initialLayers = [], onLayerChange }: UseLayersOption onLayerChange?.(newLayers); }, [onLayerChange]); - const addLayer = useCallback((layer: Omit) => { - const id = `layer-${++layerIdCounter}-${Date.now()}`; - updateAndNotify([...layers, { ...layer, id }]); + const addLayer = useCallback((layer: Omit): string => { + const id = generateLayerId(); + const newLayer: MapLayer = { ...layer, id }; + updateAndNotify([...layers, newLayer]); return id; }, [layers, updateAndNotify]); - const removeLayer = useCallback((layerId: string) => updateAndNotify(layers.filter((l) => l.id !== layerId)), [layers, updateAndNotify]); - const updateLayer = useCallback((layerId: string, updates: Partial) => updateAndNotify(layers.map((l) => l.id === layerId ? { ...l, ...updates } : l)), [layers, updateAndNotify]); - const toggleVisibility = useCallback((layerId: string) => updateAndNotify(layers.map((l) => l.id === layerId ? { ...l, visible: !l.visible } : l)), [layers, updateAndNotify]); - const setOpacity = useCallback((layerId: string, opacity: number) => updateAndNotify(layers.map((l) => l.id === layerId ? { ...l, opacity: Math.max(0, Math.min(1, opacity)) } : l)), [layers, updateAndNotify]); + const removeLayer = useCallback((layerId: string) => { + updateAndNotify(layers.filter((l) => l.id !== layerId)); + }, [layers, updateAndNotify]); + + const updateLayer = useCallback((layerId: string, updates: Partial) => { + updateAndNotify( + layers.map((l) => (l.id === layerId ? { ...l, ...updates } : l)) + ); + }, [layers, updateAndNotify]); + + const toggleVisibility = useCallback((layerId: string) => { + updateAndNotify( + layers.map((l) => (l.id === layerId ? { ...l, visible: !l.visible } : l)) + ); + }, [layers, updateAndNotify]); + + const setOpacity = useCallback((layerId: string, opacity: number) => { + updateAndNotify( + layers.map((l) => (l.id === layerId ? { ...l, opacity: Math.max(0, Math.min(1, opacity)) } : l)) + ); + }, [layers, updateAndNotify]); + const reorderLayers = useCallback((layerIds: string[]) => { - const reordered = layerIds.map((id, i) => { const l = layers.find((x) => x.id === id); return l ? { ...l, zIndex: i } : null; }).filter((l): l is MapLayer => !!l); + const reordered = layerIds + .map((id, index) => { + const layer = layers.find((l) => l.id === id); + return layer ? { ...layer, zIndex: index } : null; + }) + .filter((l): l is MapLayer => l !== null); updateAndNotify(reordered); }, [layers, updateAndNotify]); - const getLayer = useCallback((layerId: string) => layers.find((l) => l.id === layerId), [layers]); - const addPresetLayer = useCallback((preset: LayerPreset) => addLayer(PRESET_LAYERS[preset]), [addLayer]); - return { layers, addLayer, removeLayer, updateLayer, toggleVisibility, setOpacity, reorderLayers, getLayer, addPresetLayer }; + const getLayer = useCallback((layerId: string): MapLayer | undefined => { + return layers.find((l) => l.id === layerId); + }, [layers]); + + const addPresetLayer = useCallback((preset: LayerPreset): string => { + const presetConfig = PRESET_LAYERS[preset]; + if (!presetConfig) { + throw new Error(`Unknown layer preset: ${preset}`); + } + return addLayer(presetConfig); + }, [addLayer]); + + return { + layers, + addLayer, + removeLayer, + updateLayer, + toggleVisibility, + setOpacity, + reorderLayers, + getLayer, + addPresetLayer, + }; } export default useLayers; diff --git a/src/open-mapping/hooks/useMapInstance.ts b/src/open-mapping/hooks/useMapInstance.ts index 105e3ab..69fb3d6 100644 --- a/src/open-mapping/hooks/useMapInstance.ts +++ b/src/open-mapping/hooks/useMapInstance.ts @@ -1,5 +1,11 @@ /** * useMapInstance - Hook for managing MapLibre GL JS instance + * + * Provides: + * - Map initialization and cleanup + * - Viewport state management + * - Event handlers (click, move, zoom) + * - Ref to underlying map instance for advanced usage */ import { useEffect, useRef, useState, useCallback } from 'react'; @@ -13,38 +19,83 @@ interface UseMapInstanceOptions { onClick?: (coordinate: Coordinate) => void; } +interface UseMapInstanceReturn { + isLoaded: boolean; + viewport: MapViewport; + setViewport: (viewport: MapViewport) => void; + flyTo: (coordinate: Coordinate, zoom?: number) => void; + fitBounds: (bounds: [[number, number], [number, number]]) => void; + getMap: () => unknown; // MapLibre map instance +} + const DEFAULT_VIEWPORT: MapViewport = { - center: { lat: 0, lng: 0 }, zoom: 2, bearing: 0, pitch: 0, + center: { lat: 0, lng: 0 }, + zoom: 2, + bearing: 0, + pitch: 0, }; export function useMapInstance({ - container, config, initialViewport = DEFAULT_VIEWPORT, onViewportChange, -}: UseMapInstanceOptions) { + container, + config, + initialViewport = DEFAULT_VIEWPORT, + onViewportChange, + onClick, +}: UseMapInstanceOptions): UseMapInstanceReturn { const mapRef = useRef(null); const [isLoaded, setIsLoaded] = useState(false); const [viewport, setViewportState] = useState(initialViewport); + // Initialize map useEffect(() => { if (!container) return; - // TODO: Initialize MapLibre GL JS (Phase 1) + + // TODO: Initialize MapLibre GL JS + // const map = new maplibregl.Map({ + // container, + // style: config.styleUrl, + // center: [initialViewport.center.lng, initialViewport.center.lat], + // zoom: initialViewport.zoom, + // bearing: initialViewport.bearing, + // pitch: initialViewport.pitch, + // }); + + console.log('useMapInstance: Would initialize map with config', config); setIsLoaded(true); - return () => { mapRef.current = null; setIsLoaded(false); }; + + return () => { + // map.remove(); + mapRef.current = null; + setIsLoaded(false); + }; }, [container]); const setViewport = useCallback((newViewport: MapViewport) => { setViewportState(newViewport); onViewportChange?.(newViewport); + // TODO: Update map instance }, [onViewportChange]); const flyTo = useCallback((coordinate: Coordinate, zoom?: number) => { - console.log('flyTo', coordinate, zoom); + // TODO: Implement flyTo animation + console.log('useMapInstance: flyTo', coordinate, zoom); }, []); const fitBounds = useCallback((bounds: [[number, number], [number, number]]) => { - console.log('fitBounds', bounds); + // TODO: Implement fitBounds + console.log('useMapInstance: fitBounds', bounds); }, []); - return { isLoaded, viewport, setViewport, flyTo, fitBounds, getMap: () => mapRef.current }; + const getMap = useCallback(() => mapRef.current, []); + + return { + isLoaded, + viewport, + setViewport, + flyTo, + fitBounds, + getMap, + }; } export default useMapInstance; diff --git a/src/open-mapping/hooks/useRouting.ts b/src/open-mapping/hooks/useRouting.ts index 7850acb..2dc6ea0 100644 --- a/src/open-mapping/hooks/useRouting.ts +++ b/src/open-mapping/hooks/useRouting.ts @@ -1,9 +1,21 @@ /** * useRouting - Hook for route calculation and management + * + * Provides: + * - Route calculation between waypoints + * - Multi-route comparison + * - Route optimization (reorder waypoints) + * - Isochrone calculation */ import { useState, useCallback } from 'react'; -import type { Waypoint, Route, RoutingOptions, RoutingServiceConfig, Coordinate } from '../types'; +import type { + Waypoint, + Route, + RoutingOptions, + RoutingServiceConfig, + Coordinate, +} from '../types'; import { RoutingService } from '../services/RoutingService'; interface UseRoutingOptions { @@ -12,15 +24,40 @@ interface UseRoutingOptions { onError?: (error: Error) => void; } -export function useRouting({ config, onRouteCalculated, onError }: UseRoutingOptions) { +interface UseRoutingReturn { + routes: Route[]; + isCalculating: boolean; + error: Error | null; + calculateRoute: (waypoints: Waypoint[], options?: Partial) => Promise; + calculateAlternatives: (waypoints: Waypoint[], count?: number) => Promise; + optimizeOrder: (waypoints: Waypoint[]) => Promise; + calculateIsochrone: (center: Coordinate, minutes: number[]) => Promise; + clearRoutes: () => void; +} + +export function useRouting({ + config, + onRouteCalculated, + onError, +}: UseRoutingOptions): UseRoutingReturn { const [routes, setRoutes] = useState([]); const [isCalculating, setIsCalculating] = useState(false); const [error, setError] = useState(null); + const service = new RoutingService(config); - const calculateRoute = useCallback(async (waypoints: Waypoint[], options?: Partial) => { - if (waypoints.length < 2) { setError(new Error('At least 2 waypoints required')); return null; } - setIsCalculating(true); setError(null); + const calculateRoute = useCallback(async ( + waypoints: Waypoint[], + options?: Partial + ): Promise => { + if (waypoints.length < 2) { + setError(new Error('At least 2 waypoints required')); + return null; + } + + setIsCalculating(true); + setError(null); + try { const route = await service.calculateRoute(waypoints, options); setRoutes((prev) => [...prev, route]); @@ -28,37 +65,85 @@ export function useRouting({ config, onRouteCalculated, onError }: UseRoutingOpt return route; } catch (err) { const error = err instanceof Error ? err : new Error('Route calculation failed'); - setError(error); onError?.(error); return null; - } finally { setIsCalculating(false); } + setError(error); + onError?.(error); + return null; + } finally { + setIsCalculating(false); + } }, [service, onRouteCalculated, onError]); - const calculateAlternatives = useCallback(async (waypoints: Waypoint[], count = 3) => { - setIsCalculating(true); setError(null); + const calculateAlternatives = useCallback(async ( + waypoints: Waypoint[], + count = 3 + ): Promise => { + setIsCalculating(true); + setError(null); + try { const alternatives = await service.calculateAlternatives(waypoints, count); setRoutes(alternatives); return alternatives; } catch (err) { - const error = err instanceof Error ? err : new Error('Failed'); - setError(error); onError?.(error); return []; - } finally { setIsCalculating(false); } + const error = err instanceof Error ? err : new Error('Alternative routes calculation failed'); + setError(error); + onError?.(error); + return []; + } finally { + setIsCalculating(false); + } }, [service, onError]); - const optimizeOrder = useCallback(async (waypoints: Waypoint[]) => { - setIsCalculating(true); setError(null); - try { return await service.optimizeWaypointOrder(waypoints); } - catch (err) { const error = err instanceof Error ? err : new Error('Failed'); setError(error); return waypoints; } - finally { setIsCalculating(false); } - }, [service]); - - const calculateIsochrone = useCallback(async (center: Coordinate, minutes: number[]) => { + const optimizeOrder = useCallback(async (waypoints: Waypoint[]): Promise => { setIsCalculating(true); - try { return await service.calculateIsochrone(center, minutes); } - catch { return { type: 'FeatureCollection' as const, features: [] }; } - finally { setIsCalculating(false); } - }, [service]); + setError(null); - return { routes, isCalculating, error, calculateRoute, calculateAlternatives, optimizeOrder, calculateIsochrone, clearRoutes: () => setRoutes([]) }; + try { + return await service.optimizeWaypointOrder(waypoints); + } catch (err) { + const error = err instanceof Error ? err : new Error('Waypoint optimization failed'); + setError(error); + onError?.(error); + return waypoints; + } finally { + setIsCalculating(false); + } + }, [service, onError]); + + const calculateIsochrone = useCallback(async ( + center: Coordinate, + minutes: number[] + ): Promise => { + setIsCalculating(true); + setError(null); + + try { + return await service.calculateIsochrone(center, minutes); + } catch (err) { + const error = err instanceof Error ? err : new Error('Isochrone calculation failed'); + setError(error); + onError?.(error); + return { type: 'FeatureCollection', features: [] }; + } finally { + setIsCalculating(false); + } + }, [service, onError]); + + const clearRoutes = useCallback(() => { + setRoutes([]); + setError(null); + }, []); + + return { + routes, + isCalculating, + error, + calculateRoute, + calculateAlternatives, + optimizeOrder, + calculateIsochrone, + clearRoutes, + }; } export default useRouting; diff --git a/src/open-mapping/types/index.ts b/src/open-mapping/types/index.ts index 705df58..43e6f3f 100644 --- a/src/open-mapping/types/index.ts +++ b/src/open-mapping/types/index.ts @@ -2,11 +2,14 @@ * Open Mapping Type Definitions */ +// ============================================================================ // Core Geographic Types +// ============================================================================ + export interface Coordinate { lat: number; lng: number; - alt?: number; + alt?: number; // elevation in meters } export interface BoundingBox { @@ -16,7 +19,10 @@ export interface BoundingBox { west: number; } +// ============================================================================ // Waypoint & Route Types +// ============================================================================ + export interface Waypoint { id: string; coordinate: Coordinate; @@ -26,7 +32,7 @@ export interface Waypoint { color?: string; arrivalTime?: Date; departureTime?: Date; - stayDuration?: number; + stayDuration?: number; // minutes budget?: WaypointBudget; metadata?: Record; } @@ -51,10 +57,10 @@ export interface Route { } export interface RouteSummary { - distance: number; - duration: number; - ascent?: number; - descent?: number; + distance: number; // meters + duration: number; // seconds + ascent?: number; // meters + descent?: number; // meters cost?: RouteCost; } @@ -66,7 +72,7 @@ export interface RouteCost { } export interface RouteLeg { - startWaypoint: string; + startWaypoint: string; // waypoint id endWaypoint: string; distance: number; duration: number; @@ -107,7 +113,10 @@ export interface RouteMetadata { shareLink?: string; } +// ============================================================================ // Routing Profiles & Options +// ============================================================================ + export type RoutingProfile = | 'car' | 'truck' | 'motorcycle' | 'bicycle' | 'mountain_bike' | 'road_bike' @@ -121,7 +130,7 @@ export interface RoutingOptions { avoidHighways?: boolean; avoidFerries?: boolean; preferScenic?: boolean; - alternatives?: number; + alternatives?: number; // number of alternative routes to compute departureTime?: Date; arrivalTime?: Date; optimize?: OptimizationType; @@ -139,7 +148,10 @@ export interface RoutingConstraints { vehicleWidth?: number; } +// ============================================================================ // Layer Management +// ============================================================================ + export interface MapLayer { id: string; name: string; @@ -175,7 +187,10 @@ export interface LayerStyle { iconSize?: number; } +// ============================================================================ // Collaboration Types +// ============================================================================ + export interface CollaborationSession { id: string; name: string; @@ -212,7 +227,10 @@ export interface MapViewport { pitch: number; } -// Calendar & Scheduling +// ============================================================================ +// Calendar & Scheduling Integration +// ============================================================================ + export interface TripItinerary { id: string; name: string; @@ -262,10 +280,13 @@ export interface BudgetItem { date?: Date; waypointId?: string; eventId?: string; - receipt?: string; + receipt?: string; // URL or file path } +// ============================================================================ // Service Configurations +// ============================================================================ + export interface RoutingServiceConfig { provider: 'osrm' | 'valhalla' | 'graphhopper' | 'openrouteservice'; baseUrl: string;