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:
Jeff Emmett 2025-12-04 06:39:26 -08:00
parent 068ff7d3be
commit 4f1a6d1314
20 changed files with 1260 additions and 0 deletions

139
OPEN_MAPPING_PROJECT.md Normal file
View File

@ -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/)

View File

@ -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

32
open-mapping.setup.sh Normal file
View File

@ -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"

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,4 @@
export { MapCanvas } from './MapCanvas';
export { RouteLayer } from './RouteLayer';
export { WaypointMarker } from './WaypointMarker';
export { LayerPanel } from './LayerPanel';

View File

@ -0,0 +1,4 @@
export { useMapInstance } from './useMapInstance';
export { useRouting } from './useRouting';
export { useCollaboration } from './useCollaboration';
export { useLayers } from './useLayers';

View File

@ -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;

View File

@ -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: '&copy; 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: '&copy; 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;

View File

@ -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;

View File

@ -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;

35
src/open-mapping/index.ts Normal file
View File

@ -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';

View File

@ -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;

View File

@ -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;

View File

@ -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: '&copy; OpenStreetMap contributors', maxZoom: 19 },
{ id: 'osm-humanitarian', name: 'Humanitarian', type: 'raster', url: 'https://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', attribution: '&copy; 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: '&copy; 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: '&copy; 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;

View File

@ -0,0 +1,4 @@
export { RoutingService } from './RoutingService';
export { TileService, DEFAULT_TILE_SOURCES } from './TileService';
export type { TileSource } from './TileService';
export { OptimizationService } from './OptimizationService';

View File

@ -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;
}

View File

@ -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;
}