feat: add open-mapping collaborative route planning module
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 <noreply@anthropic.com>
This commit is contained in:
parent
068ff7d3be
commit
4f1a6d1314
|
|
@ -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/)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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<MapLayer, 'id'>) => void;
|
||||||
|
onLayerRemove?: (layerId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayerPanel({ layers, onLayerToggle }: LayerPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="open-mapping-layer-panel">
|
||||||
|
<h3>Layers</h3>
|
||||||
|
<ul>
|
||||||
|
{layers.map((layer) => (
|
||||||
|
<li key={layer.id}>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={layer.visible}
|
||||||
|
onChange={(e) => onLayerToggle?.(layer.id, e.target.checked)}
|
||||||
|
/>
|
||||||
|
{layer.name}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LayerPanel;
|
||||||
|
|
@ -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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="open-mapping-canvas"
|
||||||
|
style={{ width: '100%', height: '100%', position: 'relative' }}
|
||||||
|
>
|
||||||
|
{!isLoaded && <div className="open-mapping-loading">Loading map...</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MapCanvas;
|
||||||
|
|
@ -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<Record<RoutingProfile, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROFILE_COLORS: Record<RoutingProfile, string> = {
|
||||||
|
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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { MapCanvas } from './MapCanvas';
|
||||||
|
export { RouteLayer } from './RouteLayer';
|
||||||
|
export { WaypointMarker } from './WaypointMarker';
|
||||||
|
export { LayerPanel } from './LayerPanel';
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { useMapInstance } from './useMapInstance';
|
||||||
|
export { useRouting } from './useRouting';
|
||||||
|
export { useCollaboration } from './useCollaboration';
|
||||||
|
export { useLayers } from './useLayers';
|
||||||
|
|
@ -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<CollaborationSession | null>(null);
|
||||||
|
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||||
|
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;
|
||||||
|
|
@ -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<LayerPreset, Omit<MapLayer, 'id'>> = {
|
||||||
|
'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<MapLayer[]>(initialLayers);
|
||||||
|
|
||||||
|
const updateAndNotify = useCallback((newLayers: MapLayer[]) => {
|
||||||
|
setLayers(newLayers);
|
||||||
|
onLayerChange?.(newLayers);
|
||||||
|
}, [onLayerChange]);
|
||||||
|
|
||||||
|
const addLayer = useCallback((layer: Omit<MapLayer, 'id'>) => {
|
||||||
|
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<MapLayer>) => 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;
|
||||||
|
|
@ -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<unknown>(null);
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
const [viewport, setViewportState] = useState<MapViewport>(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;
|
||||||
|
|
@ -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<Route[]>([]);
|
||||||
|
const [isCalculating, setIsCalculating] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const service = new RoutingService(config);
|
||||||
|
|
||||||
|
const calculateRoute = useCallback(async (waypoints: Waypoint[], options?: Partial<RoutingOptions>) => {
|
||||||
|
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;
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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<OptimizationResult> {
|
||||||
|
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<OptimizationResult> {
|
||||||
|
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;
|
||||||
|
|
@ -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<RoutingOptions>): Promise<Route> {
|
||||||
|
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<Route[]> {
|
||||||
|
const mainRoute = await this.calculateRoute(waypoints, { alternatives: count });
|
||||||
|
return mainRoute.alternatives ? [mainRoute, ...mainRoute.alternatives] : [mainRoute];
|
||||||
|
}
|
||||||
|
|
||||||
|
async optimizeWaypointOrder(waypoints: Waypoint[]): Promise<Waypoint[]> {
|
||||||
|
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<GeoJSON.FeatureCollection> {
|
||||||
|
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<RoutingOptions>): Promise<Route> {
|
||||||
|
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<RoutingOptions>): Promise<Route> {
|
||||||
|
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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { RoutingService } from './RoutingService';
|
||||||
|
export { TileService, DEFAULT_TILE_SOURCES } from './TileService';
|
||||||
|
export type { TileSource } from './TileService';
|
||||||
|
export { OptimizationService } from './OptimizationService';
|
||||||
|
|
@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue