From 4f1a6d131496cefa4bab2abc1c940813da8476a1 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 4 Dec 2025 06:39:26 -0800 Subject: [PATCH] feat: add open-mapping collaborative route planning module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a comprehensive mapping and routing layer for the canvas that provides advanced route planning capabilities beyond Google Maps. Built on open-source foundations: - OpenStreetMap for base map data - OSRM/Valhalla for routing engines - MapLibre GL JS for map rendering - VROOM for route optimization - Y.js for real-time collaboration Features planned: - Multi-path routing with alternatives comparison - Real-time collaborative waypoint editing - Layer management (basemaps, overlays, custom GeoJSON) - Calendar/scheduling integration - Budget tracking per waypoint/route - Offline tile caching via PWA Includes: - TypeScript types for routes, waypoints, layers - React hooks for map instance, routing, collaboration - Service abstractions for multiple routing providers - Docker Compose config for backend deployment - Setup script for OSRM data preparation Backlog task: task-024 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- OPEN_MAPPING_PROJECT.md | 139 +++++++++ open-mapping.docker-compose.yml | 94 ++++++ open-mapping.setup.sh | 32 ++ src/open-mapping/components/LayerPanel.tsx | 38 +++ src/open-mapping/components/MapCanvas.tsx | 47 +++ src/open-mapping/components/RouteLayer.tsx | 28 ++ .../components/WaypointMarker.tsx | 22 ++ src/open-mapping/components/index.ts | 4 + src/open-mapping/hooks/index.ts | 4 + src/open-mapping/hooks/useCollaboration.ts | 56 ++++ src/open-mapping/hooks/useLayers.ts | 54 ++++ src/open-mapping/hooks/useMapInstance.ts | 50 +++ src/open-mapping/hooks/useRouting.ts | 64 ++++ src/open-mapping/index.ts | 35 +++ .../services/OptimizationService.ts | 87 ++++++ src/open-mapping/services/RoutingService.ts | 93 ++++++ src/open-mapping/services/TileService.ts | 67 ++++ src/open-mapping/services/index.ts | 4 + src/open-mapping/types/index.ts | 288 ++++++++++++++++++ src/open-mapping/utils/index.ts | 54 ++++ 20 files changed, 1260 insertions(+) create mode 100644 OPEN_MAPPING_PROJECT.md create mode 100644 open-mapping.docker-compose.yml create mode 100644 open-mapping.setup.sh create mode 100644 src/open-mapping/components/LayerPanel.tsx create mode 100644 src/open-mapping/components/MapCanvas.tsx create mode 100644 src/open-mapping/components/RouteLayer.tsx create mode 100644 src/open-mapping/components/WaypointMarker.tsx create mode 100644 src/open-mapping/components/index.ts create mode 100644 src/open-mapping/hooks/index.ts create mode 100644 src/open-mapping/hooks/useCollaboration.ts create mode 100644 src/open-mapping/hooks/useLayers.ts create mode 100644 src/open-mapping/hooks/useMapInstance.ts create mode 100644 src/open-mapping/hooks/useRouting.ts create mode 100644 src/open-mapping/index.ts create mode 100644 src/open-mapping/services/OptimizationService.ts create mode 100644 src/open-mapping/services/RoutingService.ts create mode 100644 src/open-mapping/services/TileService.ts create mode 100644 src/open-mapping/services/index.ts create mode 100644 src/open-mapping/types/index.ts create mode 100644 src/open-mapping/utils/index.ts diff --git a/OPEN_MAPPING_PROJECT.md b/OPEN_MAPPING_PROJECT.md new file mode 100644 index 0000000..ea23ad5 --- /dev/null +++ b/OPEN_MAPPING_PROJECT.md @@ -0,0 +1,139 @@ +# Open Mapping Project + +## Overview + +**Open Mapping** is a collaborative route planning module for canvas-website that provides advanced mapping functionality beyond traditional tools like Google Maps. Built on open-source foundations (OpenStreetMap, OSRM, Valhalla, MapLibre), it integrates seamlessly with the tldraw canvas environment. + +## Vision + +Create a "living map" that exists as a layer within the collaborative canvas, enabling teams to: +- Plan multi-destination trips with optimized routing +- Compare alternative routes visually +- Share and collaborate on itineraries in real-time +- Track budgets and schedules alongside geographic planning +- Work offline with cached map data + +## Core Features + +### 1. Map Canvas Integration +- MapLibre GL JS as the rendering engine +- Seamless embedding within tldraw canvas +- Pan/zoom synchronized with canvas viewport + +### 2. Multi-Path Routing +- Support for multiple routing profiles (car, bike, foot, transit) +- Side-by-side route comparison +- Alternative route suggestions +- Turn-by-turn directions with elevation profiles + +### 3. Collaborative Editing +- Real-time waypoint sharing via Y.js/CRDT +- Cursor presence on map +- Concurrent route editing without conflicts +- Share links for view-only or edit access + +### 4. Layer Management +- Multiple basemap options (OSM, satellite, terrain) +- Custom overlay layers (GeoJSON import) +- Route-specific layers (cycling, hiking trails) + +### 5. Calendar Integration +- Attach time windows to waypoints +- Visualize itinerary timeline +- Sync with external calendars (iCal export) + +### 6. Budget Tracking +- Cost estimates per route (fuel, tolls) +- Per-waypoint expense tracking +- Trip budget aggregation + +### 7. Offline Capability +- Tile caching for offline use +- Route pre-computation and storage +- PWA support + +## Technology Stack + +| Component | Technology | License | +|-----------|------------|---------| +| Map Renderer | MapLibre GL JS | BSD-3 | +| Base Maps | OpenStreetMap | ODbL | +| Routing Engine | OSRM / Valhalla | BSD-2 / MIT | +| Optimization | VROOM | BSD | +| Collaboration | Y.js | MIT | + +## Implementation Phases + +### Phase 1: Foundation (MVP) +- [ ] MapLibre GL JS integration with tldraw +- [ ] Basic waypoint placement and rendering +- [ ] Single-route calculation via OSRM +- [ ] Route polyline display + +### Phase 2: Multi-Route & Comparison +- [ ] Alternative routes visualization +- [ ] Route comparison panel +- [ ] Elevation profile display +- [ ] Drag-to-reroute functionality + +### Phase 3: Collaboration +- [ ] Y.js integration for real-time sync +- [ ] Cursor presence on map +- [ ] Share link generation + +### Phase 4: Layers & Customization +- [ ] Layer panel UI +- [ ] Multiple basemap options +- [ ] Overlay layer support + +### Phase 5: Calendar & Budget +- [ ] Time window attachment +- [ ] Budget tracking per waypoint +- [ ] iCal export + +### Phase 6: Optimization & Offline +- [ ] VROOM integration for TSP/VRP +- [ ] Tile caching via Service Worker +- [ ] PWA manifest + +## File Structure + +``` +src/open-mapping/ +├── index.ts # Public exports +├── types/index.ts # TypeScript definitions +├── components/ +│ ├── MapCanvas.tsx # Main map component +│ ├── RouteLayer.tsx # Route rendering +│ ├── WaypointMarker.tsx # Interactive markers +│ └── LayerPanel.tsx # Layer management UI +├── hooks/ +│ ├── useMapInstance.ts # MapLibre instance +│ ├── useRouting.ts # Route calculation +│ ├── useCollaboration.ts # Y.js sync +│ └── useLayers.ts # Layer state +├── services/ +│ ├── RoutingService.ts # Multi-provider routing +│ ├── TileService.ts # Tile management +│ └── OptimizationService.ts # VROOM integration +└── utils/index.ts # Helper functions +``` + +## Docker Deployment + +Backend services deploy to `/opt/apps/open-mapping/` on Netcup RS 8000: + +- **OSRM** - Primary routing engine +- **Valhalla** - Extended routing with transit/isochrones +- **TileServer GL** - Vector tiles +- **VROOM** - Route optimization + +See `open-mapping.docker-compose.yml` for full configuration. + +## References + +- [OSRM Documentation](https://project-osrm.org/docs/v5.24.0/api/) +- [Valhalla API](https://valhalla.github.io/valhalla/api/) +- [MapLibre GL JS](https://maplibre.org/maplibre-gl-js-docs/api/) +- [VROOM Project](http://vroom-project.org/) +- [Y.js Documentation](https://docs.yjs.dev/) diff --git a/open-mapping.docker-compose.yml b/open-mapping.docker-compose.yml new file mode 100644 index 0000000..afa7597 --- /dev/null +++ b/open-mapping.docker-compose.yml @@ -0,0 +1,94 @@ +# Open Mapping Backend Services +# Deploy to: /opt/apps/open-mapping/ on Netcup RS 8000 + +version: '3.8' + +services: + # OSRM - Open Source Routing Machine + osrm: + image: osrm/osrm-backend:v5.27.1 + container_name: open-mapping-osrm + restart: unless-stopped + volumes: + - ./data/osrm:/data:ro + command: osrm-routed --algorithm mld /data/germany-latest.osrm --max-table-size 10000 + ports: + - "5000:5000" + networks: + - traefik-public + - open-mapping-internal + labels: + - "traefik.enable=true" + - "traefik.http.routers.osrm.rule=Host(`routing.jeffemmett.com`) && PathPrefix(`/osrm`)" + - "traefik.http.routers.osrm.middlewares=osrm-stripprefix" + - "traefik.http.middlewares.osrm-stripprefix.stripprefix.prefixes=/osrm" + - "traefik.http.services.osrm.loadbalancer.server.port=5000" + + # Valhalla - Extended Routing + valhalla: + image: ghcr.io/gis-ops/docker-valhalla/valhalla:latest + container_name: open-mapping-valhalla + restart: unless-stopped + volumes: + - ./data/valhalla:/custom_files + environment: + - tile_urls=https://download.geofabrik.de/europe/germany-latest.osm.pbf + - use_tiles_ignore_pbf=True + - build_elevation=True + - build_admins=True + - build_time_zones=True + ports: + - "8002:8002" + networks: + - traefik-public + - open-mapping-internal + labels: + - "traefik.enable=true" + - "traefik.http.routers.valhalla.rule=Host(`routing.jeffemmett.com`) && PathPrefix(`/valhalla`)" + - "traefik.http.services.valhalla.loadbalancer.server.port=8002" + deploy: + resources: + limits: + memory: 8G + + # TileServer GL - Vector Tiles + tileserver: + image: maptiler/tileserver-gl:v4.6.5 + container_name: open-mapping-tiles + restart: unless-stopped + volumes: + - ./data/tiles:/data:ro + ports: + - "8080:8080" + networks: + - traefik-public + labels: + - "traefik.enable=true" + - "traefik.http.routers.tiles.rule=Host(`tiles.jeffemmett.com`)" + - "traefik.http.services.tiles.loadbalancer.server.port=8080" + + # VROOM - Route Optimization + vroom: + image: vroomvrp/vroom-docker:v1.14.0 + container_name: open-mapping-vroom + restart: unless-stopped + environment: + - VROOM_ROUTER=osrm + - OSRM_URL=http://osrm:5000 + ports: + - "3000:3000" + networks: + - traefik-public + - open-mapping-internal + labels: + - "traefik.enable=true" + - "traefik.http.routers.vroom.rule=Host(`routing.jeffemmett.com`) && PathPrefix(`/optimize`)" + - "traefik.http.services.vroom.loadbalancer.server.port=3000" + depends_on: + - osrm + +networks: + traefik-public: + external: true + open-mapping-internal: + driver: bridge diff --git a/open-mapping.setup.sh b/open-mapping.setup.sh new file mode 100644 index 0000000..fb282dc --- /dev/null +++ b/open-mapping.setup.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Open Mapping Backend Setup Script +# Run on Netcup RS 8000 to prepare routing data + +set -e + +REGION=${1:-germany} +DATA_DIR="/opt/apps/open-mapping/data" + +echo "=== Open Mapping Setup ===" +echo "Region: $REGION" + +mkdir -p "$DATA_DIR/osrm" "$DATA_DIR/valhalla" "$DATA_DIR/tiles" +cd "$DATA_DIR" + +# Download OSM data +case $REGION in + germany) OSM_URL="https://download.geofabrik.de/europe/germany-latest.osm.pbf"; OSM_FILE="germany-latest.osm.pbf" ;; + europe) OSM_URL="https://download.geofabrik.de/europe-latest.osm.pbf"; OSM_FILE="europe-latest.osm.pbf" ;; + *) echo "Unknown region: $REGION"; exit 1 ;; +esac + +[ ! -f "osrm/$OSM_FILE" ] && wget -O "osrm/$OSM_FILE" "$OSM_URL" + +# Process OSRM data +cd osrm +[ ! -f "${OSM_FILE%.osm.pbf}.osrm" ] && docker run -t -v "${PWD}:/data" osrm/osrm-backend:v5.27.1 osrm-extract -p /opt/car.lua /data/$OSM_FILE +[ ! -f "${OSM_FILE%.osm.pbf}.osrm.partition" ] && docker run -t -v "${PWD}:/data" osrm/osrm-backend:v5.27.1 osrm-partition /data/${OSM_FILE%.osm.pbf}.osrm +[ ! -f "${OSM_FILE%.osm.pbf}.osrm.mldgr" ] && docker run -t -v "${PWD}:/data" osrm/osrm-backend:v5.27.1 osrm-customize /data/${OSM_FILE%.osm.pbf}.osrm + +echo "=== Setup Complete ===" +echo "Next: docker compose up -d" diff --git a/src/open-mapping/components/LayerPanel.tsx b/src/open-mapping/components/LayerPanel.tsx new file mode 100644 index 0000000..ac10c88 --- /dev/null +++ b/src/open-mapping/components/LayerPanel.tsx @@ -0,0 +1,38 @@ +/** + * LayerPanel - UI for managing map layers + */ + +import type { MapLayer } from '../types'; + +interface LayerPanelProps { + layers: MapLayer[]; + onLayerToggle?: (layerId: string, visible: boolean) => void; + onLayerOpacity?: (layerId: string, opacity: number) => void; + onLayerReorder?: (layerIds: string[]) => void; + onLayerAdd?: (layer: Omit) => void; + onLayerRemove?: (layerId: string) => void; +} + +export function LayerPanel({ layers, onLayerToggle }: LayerPanelProps) { + return ( +
+

Layers

+
    + {layers.map((layer) => ( +
  • + +
  • + ))} +
+
+ ); +} + +export default LayerPanel; diff --git a/src/open-mapping/components/MapCanvas.tsx b/src/open-mapping/components/MapCanvas.tsx new file mode 100644 index 0000000..13288f7 --- /dev/null +++ b/src/open-mapping/components/MapCanvas.tsx @@ -0,0 +1,47 @@ +/** + * MapCanvas - Main map component integrating with tldraw canvas + */ + +import { useEffect, useRef, useState } from 'react'; +import type { MapViewport, MapLayer, Coordinate } from '../types'; + +interface MapCanvasProps { + viewport: MapViewport; + layers: MapLayer[]; + onViewportChange?: (viewport: MapViewport) => void; + onMapClick?: (coordinate: Coordinate) => void; + onMapLoad?: () => void; + style?: string; + interactive?: boolean; +} + +export function MapCanvas({ + viewport, + layers, + onViewportChange, + onMapClick, + onMapLoad, + style = 'https://demotiles.maplibre.org/style.json', + interactive = true, +}: MapCanvasProps) { + const containerRef = useRef(null); + const [isLoaded, setIsLoaded] = useState(false); + + useEffect(() => { + // TODO: Initialize MapLibre GL JS instance (Phase 1) + console.log('MapCanvas: Initializing with viewport', viewport); + return () => { /* Cleanup */ }; + }, []); + + return ( +
+ {!isLoaded &&
Loading map...
} +
+ ); +} + +export default MapCanvas; diff --git a/src/open-mapping/components/RouteLayer.tsx b/src/open-mapping/components/RouteLayer.tsx new file mode 100644 index 0000000..8d8e158 --- /dev/null +++ b/src/open-mapping/components/RouteLayer.tsx @@ -0,0 +1,28 @@ +/** + * RouteLayer - Renders route polylines on the map + */ + +import type { Route, RoutingProfile } from '../types'; + +interface RouteLayerProps { + routes: Route[]; + selectedRouteId?: string; + showAlternatives?: boolean; + showElevation?: boolean; + onRouteSelect?: (routeId: string) => void; + onRouteEdit?: (routeId: string, geometry: GeoJSON.LineString) => void; + profileColors?: Partial>; +} + +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', +}; + +export function RouteLayer({ routes, selectedRouteId, profileColors = {} }: RouteLayerProps) { + // TODO: Implement route rendering (Phase 2) + return null; +} + +export default RouteLayer; diff --git a/src/open-mapping/components/WaypointMarker.tsx b/src/open-mapping/components/WaypointMarker.tsx new file mode 100644 index 0000000..694e4b1 --- /dev/null +++ b/src/open-mapping/components/WaypointMarker.tsx @@ -0,0 +1,22 @@ +/** + * WaypointMarker - Interactive waypoint markers + */ + +import type { Waypoint } from '../types'; + +interface WaypointMarkerProps { + waypoint: Waypoint; + index?: number; + isSelected?: boolean; + isDraggable?: 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 default WaypointMarker; diff --git a/src/open-mapping/components/index.ts b/src/open-mapping/components/index.ts new file mode 100644 index 0000000..2e72c05 --- /dev/null +++ b/src/open-mapping/components/index.ts @@ -0,0 +1,4 @@ +export { MapCanvas } from './MapCanvas'; +export { RouteLayer } from './RouteLayer'; +export { WaypointMarker } from './WaypointMarker'; +export { LayerPanel } from './LayerPanel'; diff --git a/src/open-mapping/hooks/index.ts b/src/open-mapping/hooks/index.ts new file mode 100644 index 0000000..e09b9c8 --- /dev/null +++ b/src/open-mapping/hooks/index.ts @@ -0,0 +1,4 @@ +export { useMapInstance } from './useMapInstance'; +export { useRouting } from './useRouting'; +export { useCollaboration } from './useCollaboration'; +export { useLayers } from './useLayers'; diff --git a/src/open-mapping/hooks/useCollaboration.ts b/src/open-mapping/hooks/useCollaboration.ts new file mode 100644 index 0000000..cf3ae84 --- /dev/null +++ b/src/open-mapping/hooks/useCollaboration.ts @@ -0,0 +1,56 @@ +/** + * useCollaboration - Hook for real-time collaborative map editing via Y.js + */ + +import { useState, useEffect, useCallback } from 'react'; +import type { CollaborationSession, Participant, Route, Waypoint, MapLayer, Coordinate } from '../types'; + +interface UseCollaborationOptions { + sessionId?: string; + userId: string; + userName: string; + userColor?: string; + serverUrl?: string; + onParticipantJoin?: (participant: Participant) => void; + onParticipantLeave?: (participantId: string) => void; +} + +export function useCollaboration({ + sessionId, userId, userName, userColor = '#3B82F6', serverUrl, +}: UseCollaborationOptions) { + const [session, setSession] = useState(null); + const [participants, setParticipants] = useState([]); + const [isConnected, setIsConnected] = useState(false); + + useEffect(() => { + if (!sessionId) return; + // TODO: Initialize Y.js (Phase 3) + setIsConnected(true); + return () => { setIsConnected(false); }; + }, [sessionId, serverUrl]); + + const createSession = useCallback(async (name: string) => { + const newSessionId = `session-${Date.now()}`; + return newSessionId; + }, []); + + const joinSession = useCallback(async (sessionIdToJoin: string) => { + console.log('Joining session', sessionIdToJoin); + }, []); + + const leaveSession = useCallback(() => { + 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) => {}, []); + + return { + session, participants, isConnected, createSession, joinSession, leaveSession, + updateCursor, broadcastRouteChange, broadcastWaypointChange, broadcastLayerChange, + }; +} + +export default useCollaboration; diff --git a/src/open-mapping/hooks/useLayers.ts b/src/open-mapping/hooks/useLayers.ts new file mode 100644 index 0000000..cde1ca8 --- /dev/null +++ b/src/open-mapping/hooks/useLayers.ts @@ -0,0 +1,54 @@ +/** + * useLayers - Hook for managing map layers + */ + +import { useState, useCallback } from 'react'; +import type { MapLayer } from '../types'; + +interface UseLayersOptions { + initialLayers?: MapLayer[]; + onLayerChange?: (layers: MapLayer[]) => void; +} + +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' } }, +}; + +let layerIdCounter = 0; + +export function useLayers({ initialLayers = [], onLayerChange }: UseLayersOptions = {}) { + const [layers, setLayers] = useState(initialLayers); + + const updateAndNotify = useCallback((newLayers: MapLayer[]) => { + setLayers(newLayers); + onLayerChange?.(newLayers); + }, [onLayerChange]); + + const addLayer = useCallback((layer: Omit) => { + const id = `layer-${++layerIdCounter}-${Date.now()}`; + updateAndNotify([...layers, { ...layer, id }]); + 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 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); + 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 }; +} + +export default useLayers; diff --git a/src/open-mapping/hooks/useMapInstance.ts b/src/open-mapping/hooks/useMapInstance.ts new file mode 100644 index 0000000..105e3ab --- /dev/null +++ b/src/open-mapping/hooks/useMapInstance.ts @@ -0,0 +1,50 @@ +/** + * useMapInstance - Hook for managing MapLibre GL JS instance + */ + +import { useEffect, useRef, useState, useCallback } from 'react'; +import type { MapViewport, Coordinate, TileServiceConfig } from '../types'; + +interface UseMapInstanceOptions { + container: HTMLElement | null; + config: TileServiceConfig; + initialViewport?: MapViewport; + onViewportChange?: (viewport: MapViewport) => void; + onClick?: (coordinate: Coordinate) => void; +} + +const DEFAULT_VIEWPORT: MapViewport = { + center: { lat: 0, lng: 0 }, zoom: 2, bearing: 0, pitch: 0, +}; + +export function useMapInstance({ + container, config, initialViewport = DEFAULT_VIEWPORT, onViewportChange, +}: UseMapInstanceOptions) { + const mapRef = useRef(null); + const [isLoaded, setIsLoaded] = useState(false); + const [viewport, setViewportState] = useState(initialViewport); + + useEffect(() => { + if (!container) return; + // TODO: Initialize MapLibre GL JS (Phase 1) + setIsLoaded(true); + return () => { mapRef.current = null; setIsLoaded(false); }; + }, [container]); + + const setViewport = useCallback((newViewport: MapViewport) => { + setViewportState(newViewport); + onViewportChange?.(newViewport); + }, [onViewportChange]); + + const flyTo = useCallback((coordinate: Coordinate, zoom?: number) => { + console.log('flyTo', coordinate, zoom); + }, []); + + const fitBounds = useCallback((bounds: [[number, number], [number, number]]) => { + console.log('fitBounds', bounds); + }, []); + + return { isLoaded, viewport, setViewport, flyTo, fitBounds, getMap: () => mapRef.current }; +} + +export default useMapInstance; diff --git a/src/open-mapping/hooks/useRouting.ts b/src/open-mapping/hooks/useRouting.ts new file mode 100644 index 0000000..7850acb --- /dev/null +++ b/src/open-mapping/hooks/useRouting.ts @@ -0,0 +1,64 @@ +/** + * useRouting - Hook for route calculation and management + */ + +import { useState, useCallback } from 'react'; +import type { Waypoint, Route, RoutingOptions, RoutingServiceConfig, Coordinate } from '../types'; +import { RoutingService } from '../services/RoutingService'; + +interface UseRoutingOptions { + config: RoutingServiceConfig; + onRouteCalculated?: (route: Route) => void; + onError?: (error: Error) => void; +} + +export function useRouting({ config, onRouteCalculated, onError }: UseRoutingOptions) { + 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); + try { + const route = await service.calculateRoute(waypoints, options); + setRoutes((prev) => [...prev, route]); + onRouteCalculated?.(route); + return route; + } catch (err) { + const error = err instanceof Error ? err : new Error('Route calculation failed'); + setError(error); onError?.(error); return null; + } finally { setIsCalculating(false); } + }, [service, onRouteCalculated, onError]); + + const calculateAlternatives = useCallback(async (waypoints: Waypoint[], count = 3) => { + 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); } + }, [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[]) => { + setIsCalculating(true); + try { return await service.calculateIsochrone(center, minutes); } + catch { return { type: 'FeatureCollection' as const, features: [] }; } + finally { setIsCalculating(false); } + }, [service]); + + return { routes, isCalculating, error, calculateRoute, calculateAlternatives, optimizeOrder, calculateIsochrone, clearRoutes: () => setRoutes([]) }; +} + +export default useRouting; diff --git a/src/open-mapping/index.ts b/src/open-mapping/index.ts new file mode 100644 index 0000000..36ffb74 --- /dev/null +++ b/src/open-mapping/index.ts @@ -0,0 +1,35 @@ +/** + * Open Mapping - Collaborative Route Planning for Canvas + * + * A tldraw canvas integration providing advanced mapping and routing capabilities + * beyond traditional mapping tools like Google Maps. + * + * Features: + * - OpenStreetMap base layers with MapLibre GL JS + * - Multi-path routing via OSRM/Valhalla + * - Real-time collaborative route planning + * - Layer management (custom overlays, POIs, routes) + * - Calendar/scheduling integration + * - Budget and cost tracking + * - Offline capability via PWA + */ + +// Components +export { MapCanvas } from './components/MapCanvas'; +export { RouteLayer } from './components/RouteLayer'; +export { WaypointMarker } from './components/WaypointMarker'; +export { LayerPanel } from './components/LayerPanel'; + +// Hooks +export { useMapInstance } from './hooks/useMapInstance'; +export { useRouting } from './hooks/useRouting'; +export { useCollaboration } from './hooks/useCollaboration'; +export { useLayers } from './hooks/useLayers'; + +// Services +export { RoutingService } from './services/RoutingService'; +export { TileService } from './services/TileService'; +export { OptimizationService } from './services/OptimizationService'; + +// Types +export type * from './types'; diff --git a/src/open-mapping/services/OptimizationService.ts b/src/open-mapping/services/OptimizationService.ts new file mode 100644 index 0000000..bd77554 --- /dev/null +++ b/src/open-mapping/services/OptimizationService.ts @@ -0,0 +1,87 @@ +/** + * OptimizationService - Route optimization using VROOM + */ + +import type { Waypoint, Coordinate, OptimizationServiceConfig } from '../types'; + +export interface OptimizationResult { + orderedWaypoints: Waypoint[]; + totalDistance: number; + totalDuration: number; + estimatedCost: { fuel: number; time: number; total: number; currency: string }; +} + +export interface CostParameters { + fuelPricePerLiter: number; + fuelConsumptionPer100km: number; + valueOfTimePerHour: number; + currency: string; +} + +const DEFAULT_COST_PARAMS: CostParameters = { fuelPricePerLiter: 1.5, fuelConsumptionPer100km: 8, valueOfTimePerHour: 20, currency: 'EUR' }; + +export class OptimizationService { + private config: OptimizationServiceConfig; + private costParams: CostParameters; + + constructor(config: OptimizationServiceConfig, costParams = DEFAULT_COST_PARAMS) { + this.config = config; + this.costParams = costParams; + } + + async optimizeRoute(waypoints: Waypoint[]): Promise { + if (waypoints.length <= 2) return { orderedWaypoints: waypoints, totalDistance: 0, totalDuration: 0, estimatedCost: { fuel: 0, time: 0, total: 0, currency: this.costParams.currency } }; + + if (this.config.provider === 'vroom') { + return this.optimizeWithVROOM(waypoints); + } + return this.nearestNeighbor(waypoints); + } + + estimateCosts(distance: number, duration: number) { + const km = distance / 1000, hours = duration / 3600; + const fuel = (km / 100) * this.costParams.fuelConsumptionPer100km * this.costParams.fuelPricePerLiter; + const time = hours * this.costParams.valueOfTimePerHour; + return { fuel: Math.round(fuel * 100) / 100, time: Math.round(time * 100) / 100, total: Math.round((fuel + time) * 100) / 100, currency: this.costParams.currency }; + } + + private async optimizeWithVROOM(waypoints: Waypoint[]): Promise { + const jobs = waypoints.map((wp, i) => ({ id: i, location: [wp.coordinate.lng, wp.coordinate.lat] })); + const vehicles = [{ id: 0, start: [waypoints[0].coordinate.lng, waypoints[0].coordinate.lat] }]; + try { + const res = await fetch(this.config.baseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jobs, vehicles }) }); + const data = await res.json(); + if (data.code !== 0) throw new Error(data.error); + const indices = data.routes[0].steps.filter((s: any) => s.type === 'job').map((s: any) => s.job); + return { orderedWaypoints: indices.map((i: number) => waypoints[i]), totalDistance: data.summary.distance, totalDuration: data.summary.duration, estimatedCost: this.estimateCosts(data.summary.distance, data.summary.duration) }; + } catch { return this.nearestNeighbor(waypoints); } + } + + private nearestNeighbor(waypoints: Waypoint[]): OptimizationResult { + const remaining = [...waypoints], ordered: Waypoint[] = []; + let current = remaining.shift()!; + ordered.push(current); + while (remaining.length) { + let nearest = 0, minDist = Infinity; + for (let i = 0; i < remaining.length; i++) { + const d = this.haversine(current.coordinate, remaining[i].coordinate); + if (d < minDist) { minDist = d; nearest = i; } + } + current = remaining.splice(nearest, 1)[0]; + ordered.push(current); + } + let dist = 0; + for (let i = 0; i < ordered.length - 1; i++) dist += this.haversine(ordered[i].coordinate, ordered[i + 1].coordinate); + const dur = (dist / 50000) * 3600; + return { orderedWaypoints: ordered, totalDistance: dist, totalDuration: dur, estimatedCost: this.estimateCosts(dist, dur) }; + } + + private haversine(a: Coordinate, b: Coordinate): number { + const R = 6371000, lat1 = (a.lat * Math.PI) / 180, lat2 = (b.lat * Math.PI) / 180; + const dLat = ((b.lat - a.lat) * Math.PI) / 180, dLng = ((b.lng - a.lng) * Math.PI) / 180; + const x = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x)); + } +} + +export default OptimizationService; diff --git a/src/open-mapping/services/RoutingService.ts b/src/open-mapping/services/RoutingService.ts new file mode 100644 index 0000000..5dcff66 --- /dev/null +++ b/src/open-mapping/services/RoutingService.ts @@ -0,0 +1,93 @@ +/** + * RoutingService - Multi-provider routing abstraction + * Supports: OSRM, Valhalla, GraphHopper, OpenRouteService + */ + +import type { Waypoint, Route, RoutingOptions, RoutingServiceConfig, Coordinate, RoutingProfile } from '../types'; + +export class RoutingService { + private config: RoutingServiceConfig; + + constructor(config: RoutingServiceConfig) { + this.config = config; + } + + async calculateRoute(waypoints: Waypoint[], options?: Partial): Promise { + const profile = options?.profile ?? 'car'; + const coordinates = waypoints.map((w) => w.coordinate); + + switch (this.config.provider) { + case 'osrm': return this.calculateOSRMRoute(coordinates, profile, options); + case 'valhalla': return this.calculateValhallaRoute(coordinates, profile, options); + default: throw new Error(`Unsupported provider: ${this.config.provider}`); + } + } + + async calculateAlternatives(waypoints: Waypoint[], count = 3): Promise { + const mainRoute = await this.calculateRoute(waypoints, { alternatives: count }); + return mainRoute.alternatives ? [mainRoute, ...mainRoute.alternatives] : [mainRoute]; + } + + async optimizeWaypointOrder(waypoints: Waypoint[]): Promise { + if (waypoints.length <= 2) return waypoints; + const coords = waypoints.map((w) => `${w.coordinate.lng},${w.coordinate.lat}`).join(';'); + const url = `${this.config.baseUrl}/trip/v1/driving/${coords}?roundtrip=false&source=first&destination=last`; + try { + const res = await fetch(url); + const data = await res.json(); + if (data.code !== 'Ok') return waypoints; + return data.waypoints.map((wp: { waypoint_index: number }) => waypoints[wp.waypoint_index]); + } catch { return waypoints; } + } + + async calculateIsochrone(center: Coordinate, minutes: number[]): Promise { + if (this.config.provider !== 'valhalla') return { type: 'FeatureCollection', features: [] }; + const body = { locations: [{ lat: center.lat, lon: center.lng }], costing: 'auto', contours: minutes.map((m) => ({ time: m })), polygons: true }; + const res = await fetch(`${this.config.baseUrl}/isochrone`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + return res.json(); + } + + private async calculateOSRMRoute(coords: Coordinate[], profile: RoutingProfile, options?: Partial): Promise { + const coordStr = coords.map((c) => `${c.lng},${c.lat}`).join(';'); + const osrmProfile = profile === 'bicycle' ? 'cycling' : profile === 'foot' ? 'walking' : 'driving'; + const url = new URL(`${this.config.baseUrl}/route/v1/${osrmProfile}/${coordStr}`); + url.searchParams.set('overview', 'full'); + url.searchParams.set('geometries', 'geojson'); + url.searchParams.set('steps', 'true'); + if (options?.alternatives) url.searchParams.set('alternatives', 'true'); + const res = await fetch(url.toString()); + const data = await res.json(); + if (data.code !== 'Ok') throw new Error(`OSRM error: ${data.message || data.code}`); + return this.parseOSRMResponse(data, profile); + } + + private async calculateValhallaRoute(coords: Coordinate[], profile: RoutingProfile, options?: Partial): Promise { + const costing = profile === 'bicycle' ? 'bicycle' : profile === 'foot' ? 'pedestrian' : 'auto'; + const body = { locations: coords.map((c) => ({ lat: c.lat, lon: c.lng })), costing, alternates: options?.alternatives ?? 0 }; + const res = await fetch(`${this.config.baseUrl}/route`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const data = await res.json(); + if (data.error) throw new Error(`Valhalla error: ${data.error}`); + return this.parseValhallaResponse(data, profile); + } + + private parseOSRMResponse(data: any, profile: RoutingProfile): Route { + const r = data.routes[0]; + return { + id: `route-${Date.now()}`, waypoints: [], geometry: r.geometry, profile, + summary: { distance: r.distance, duration: r.duration }, + legs: r.legs.map((leg: any, i: number) => ({ startWaypoint: `wp-${i}`, endWaypoint: `wp-${i + 1}`, distance: leg.distance, duration: leg.duration, geometry: { type: 'LineString', coordinates: [] } })), + alternatives: data.routes.slice(1).map((alt: any) => this.parseOSRMResponse({ routes: [alt] }, profile)), + }; + } + + private parseValhallaResponse(data: any, profile: RoutingProfile): Route { + const trip = data.trip; + return { + id: `route-${Date.now()}`, waypoints: [], geometry: { type: 'LineString', coordinates: [] }, profile, + summary: { distance: trip.summary.length * 1000, duration: trip.summary.time }, + legs: trip.legs.map((leg: any, i: number) => ({ startWaypoint: `wp-${i}`, endWaypoint: `wp-${i + 1}`, distance: leg.summary.length * 1000, duration: leg.summary.time, geometry: { type: 'LineString', coordinates: [] } })), + }; + } +} + +export default RoutingService; diff --git a/src/open-mapping/services/TileService.ts b/src/open-mapping/services/TileService.ts new file mode 100644 index 0000000..ce571c8 --- /dev/null +++ b/src/open-mapping/services/TileService.ts @@ -0,0 +1,67 @@ +/** + * TileService - Map tile management and caching + */ + +import type { TileServiceConfig, BoundingBox } from '../types'; + +export interface TileSource { + id: string; name: string; type: 'raster' | 'vector'; url: string; attribution: string; maxZoom?: number; +} + +export const DEFAULT_TILE_SOURCES: TileSource[] = [ + { id: 'osm-standard', name: 'OpenStreetMap', type: 'raster', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors', maxZoom: 19 }, + { id: 'osm-humanitarian', name: 'Humanitarian', type: 'raster', url: 'https://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', attribution: '© OSM, HOT', maxZoom: 19 }, + { id: 'carto-light', name: 'Carto Light', type: 'raster', url: 'https://cartodb-basemaps-a.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png', attribution: '© OSM, CARTO', maxZoom: 19 }, + { id: 'carto-dark', name: 'Carto Dark', type: 'raster', url: 'https://cartodb-basemaps-a.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', attribution: '© OSM, CARTO', maxZoom: 19 }, +]; + +export class TileService { + private config: TileServiceConfig; + private cache: Cache | null = null; + private cacheName = 'open-mapping-tiles-v1'; + + constructor(config: TileServiceConfig) { + this.config = config; + this.initCache(); + } + + private async initCache() { + if ('caches' in window) { try { this.cache = await caches.open(this.cacheName); } catch {} } + } + + getSources(): TileSource[] { return DEFAULT_TILE_SOURCES; } + getSource(id: string): TileSource | undefined { return DEFAULT_TILE_SOURCES.find((s) => s.id === id); } + getTileUrl(source: TileSource, z: number, x: number, y: number): string { + return source.url.replace('{z}', String(z)).replace('{x}', String(x)).replace('{y}', String(y)); + } + + async cacheTilesForArea(sourceId: string, bounds: BoundingBox, minZoom: number, maxZoom: number, onProgress?: (p: number) => void) { + const source = this.getSource(sourceId); + if (!source || !this.cache) throw new Error('Cannot cache'); + const tiles = this.getTilesInBounds(bounds, minZoom, maxZoom); + let done = 0; + for (const { z, x, y } of tiles) { + try { const res = await fetch(this.getTileUrl(source, z, x, y)); if (res.ok) await this.cache.put(this.getTileUrl(source, z, x, y), res); } catch {} + onProgress?.(++done / tiles.length); + } + } + + async clearCache() { if ('caches' in window) { await caches.delete(this.cacheName); this.cache = await caches.open(this.cacheName); } } + + private getTilesInBounds(bounds: BoundingBox, minZoom: number, maxZoom: number) { + const tiles: Array<{ z: number; x: number; y: number }> = []; + for (let z = minZoom; z <= maxZoom; z++) { + const min = this.latLngToTile(bounds.south, bounds.west, z); + const max = this.latLngToTile(bounds.north, bounds.east, z); + for (let x = min.x; x <= max.x; x++) for (let y = max.y; y <= min.y; y++) tiles.push({ z, x, y }); + } + return tiles; + } + + private latLngToTile(lat: number, lng: number, z: number) { + const n = Math.pow(2, z); + return { x: Math.floor(((lng + 180) / 360) * n), y: Math.floor(((1 - Math.log(Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)) / Math.PI) / 2) * n) }; + } +} + +export default TileService; diff --git a/src/open-mapping/services/index.ts b/src/open-mapping/services/index.ts new file mode 100644 index 0000000..8050ffd --- /dev/null +++ b/src/open-mapping/services/index.ts @@ -0,0 +1,4 @@ +export { RoutingService } from './RoutingService'; +export { TileService, DEFAULT_TILE_SOURCES } from './TileService'; +export type { TileSource } from './TileService'; +export { OptimizationService } from './OptimizationService'; diff --git a/src/open-mapping/types/index.ts b/src/open-mapping/types/index.ts new file mode 100644 index 0000000..705df58 --- /dev/null +++ b/src/open-mapping/types/index.ts @@ -0,0 +1,288 @@ +/** + * Open Mapping Type Definitions + */ + +// Core Geographic Types +export interface Coordinate { + lat: number; + lng: number; + alt?: number; +} + +export interface BoundingBox { + north: number; + south: number; + east: number; + west: number; +} + +// Waypoint & Route Types +export interface Waypoint { + id: string; + coordinate: Coordinate; + name?: string; + description?: string; + icon?: string; + color?: string; + arrivalTime?: Date; + departureTime?: Date; + stayDuration?: number; + budget?: WaypointBudget; + metadata?: Record; +} + +export interface WaypointBudget { + estimated: number; + actual?: number; + currency: string; + category?: 'lodging' | 'food' | 'transport' | 'activity' | 'other'; + notes?: string; +} + +export interface Route { + id: string; + waypoints: Waypoint[]; + geometry: GeoJSON.LineString; + profile: RoutingProfile; + summary: RouteSummary; + legs: RouteLeg[]; + alternatives?: Route[]; + metadata?: RouteMetadata; +} + +export interface RouteSummary { + distance: number; + duration: number; + ascent?: number; + descent?: number; + cost?: RouteCost; +} + +export interface RouteCost { + fuel?: number; + tolls?: number; + total: number; + currency: string; +} + +export interface RouteLeg { + startWaypoint: string; + endWaypoint: string; + distance: number; + duration: number; + geometry: GeoJSON.LineString; + steps?: RouteStep[]; +} + +export interface RouteStep { + instruction: string; + distance: number; + duration: number; + geometry: GeoJSON.LineString; + maneuver: RouteManeuver; +} + +export interface RouteManeuver { + type: ManeuverType; + modifier?: string; + bearingBefore: number; + bearingAfter: number; + location: Coordinate; +} + +export type ManeuverType = + | 'turn' | 'new name' | 'depart' | 'arrive' + | 'merge' | 'on ramp' | 'off ramp' | 'fork' + | 'end of road' | 'continue' | 'roundabout' | 'rotary' + | 'roundabout turn' | 'notification' | 'exit roundabout'; + +export interface RouteMetadata { + createdAt: Date; + updatedAt: Date; + createdBy: string; + name?: string; + description?: string; + tags?: string[]; + isPublic?: boolean; + shareLink?: string; +} + +// Routing Profiles & Options +export type RoutingProfile = + | 'car' | 'truck' | 'motorcycle' + | 'bicycle' | 'mountain_bike' | 'road_bike' + | 'foot' | 'hiking' + | 'wheelchair' + | 'transit'; + +export interface RoutingOptions { + profile: RoutingProfile; + avoidTolls?: boolean; + avoidHighways?: boolean; + avoidFerries?: boolean; + preferScenic?: boolean; + alternatives?: number; + departureTime?: Date; + arrivalTime?: Date; + optimize?: OptimizationType; + constraints?: RoutingConstraints; +} + +export type OptimizationType = 'fastest' | 'shortest' | 'balanced' | 'eco'; + +export interface RoutingConstraints { + maxDistance?: number; + maxDuration?: number; + maxCost?: number; + vehicleHeight?: number; + vehicleWeight?: number; + vehicleWidth?: number; +} + +// Layer Management +export interface MapLayer { + id: string; + name: string; + type: LayerType; + visible: boolean; + opacity: number; + zIndex: number; + source: LayerSource; + style?: LayerStyle; + metadata?: Record; +} + +export type LayerType = + | 'basemap' | 'satellite' | 'terrain' + | 'route' | 'waypoint' | 'poi' + | 'heatmap' | 'cluster' + | 'geojson' | 'custom'; + +export interface LayerSource { + type: 'vector' | 'raster' | 'geojson' | 'image'; + url?: string; + data?: GeoJSON.FeatureCollection; + tiles?: string[]; + attribution?: string; +} + +export interface LayerStyle { + color?: string; + fillColor?: string; + strokeWidth?: number; + opacity?: number; + icon?: string; + iconSize?: number; +} + +// Collaboration Types +export interface CollaborationSession { + id: string; + name: string; + participants: Participant[]; + routes: Route[]; + layers: MapLayer[]; + viewport: MapViewport; + createdAt: Date; + updatedAt: Date; +} + +export interface Participant { + id: string; + name: string; + color: string; + cursor?: Coordinate; + isActive: boolean; + lastSeen: Date; + permissions: ParticipantPermissions; +} + +export interface ParticipantPermissions { + canEdit: boolean; + canAddWaypoints: boolean; + canDeleteWaypoints: boolean; + canChangeRoute: boolean; + canInvite: boolean; +} + +export interface MapViewport { + center: Coordinate; + zoom: number; + bearing: number; + pitch: number; +} + +// Calendar & Scheduling +export interface TripItinerary { + id: string; + name: string; + startDate: Date; + endDate: Date; + routes: Route[]; + events: ItineraryEvent[]; + budget: TripBudget; + participants: string[]; +} + +export interface ItineraryEvent { + id: string; + waypointId?: string; + title: string; + description?: string; + startTime: Date; + endTime: Date; + type: EventType; + confirmed: boolean; + cost?: number; + bookingRef?: string; + url?: string; +} + +export type EventType = + | 'travel' | 'lodging' | 'activity' + | 'meal' | 'meeting' | 'rest' | 'other'; + +export interface TripBudget { + total: number; + spent: number; + currency: string; + categories: BudgetCategory[]; +} + +export interface BudgetCategory { + name: string; + allocated: number; + spent: number; + items: BudgetItem[]; +} + +export interface BudgetItem { + description: string; + amount: number; + date?: Date; + waypointId?: string; + eventId?: string; + receipt?: string; +} + +// Service Configurations +export interface RoutingServiceConfig { + provider: 'osrm' | 'valhalla' | 'graphhopper' | 'openrouteservice'; + baseUrl: string; + apiKey?: string; + timeout?: number; +} + +export interface TileServiceConfig { + provider: 'maplibre' | 'leaflet'; + styleUrl?: string; + tileUrl?: string; + maxZoom?: number; + attribution?: string; +} + +export interface OptimizationServiceConfig { + provider: 'vroom' | 'graphhopper'; + baseUrl: string; + apiKey?: string; +} diff --git a/src/open-mapping/utils/index.ts b/src/open-mapping/utils/index.ts new file mode 100644 index 0000000..4bf5514 --- /dev/null +++ b/src/open-mapping/utils/index.ts @@ -0,0 +1,54 @@ +/** + * Open Mapping Utilities + */ + +import type { Coordinate, BoundingBox } from '../types'; + +export function haversineDistance(a: Coordinate, b: Coordinate): number { + const R = 6371000; + const lat1 = (a.lat * Math.PI) / 180; + const lat2 = (b.lat * Math.PI) / 180; + const dLat = ((b.lat - a.lat) * Math.PI) / 180; + const dLng = ((b.lng - a.lng) * Math.PI) / 180; + const x = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x)); +} + +export function getBounds(coords: Coordinate[]): BoundingBox { + if (!coords.length) return { north: 0, south: 0, east: 0, west: 0 }; + let north = -90, south = 90, east = -180, west = 180; + for (const c of coords) { + if (c.lat > north) north = c.lat; + if (c.lat < south) south = c.lat; + if (c.lng > east) east = c.lng; + if (c.lng < west) west = c.lng; + } + return { north, south, east, west }; +} + +export function formatDistance(meters: number): string { + if (meters < 1000) return `${Math.round(meters)} m`; + return `${(meters / 1000).toFixed(1)} km`; +} + +export function formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + if (hours === 0) return `${mins} min`; + return `${hours}h ${mins}m`; +} + +export function decodePolyline(encoded: string): [number, number][] { + const coords: [number, number][] = []; + let index = 0, lat = 0, lng = 0; + while (index < encoded.length) { + let shift = 0, result = 0, byte: number; + do { byte = encoded.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20); + lat += result & 1 ? ~(result >> 1) : result >> 1; + shift = 0; result = 0; + do { byte = encoded.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20); + lng += result & 1 ? ~(result >> 1) : result >> 1; + coords.push([lng / 1e5, lat / 1e5]); + } + return coords; +}