chore: remove open-mapping files (should be on feature branch)
This commit is contained in:
parent
48818816c4
commit
7ef0533a8f
|
|
@ -1,336 +0,0 @@
|
|||
# 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
|
||||
- Map shapes that can be annotated like any canvas object
|
||||
|
||||
### 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 (see where collaborators are looking)
|
||||
- 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)
|
||||
- POI layers with filtering
|
||||
|
||||
### 5. Calendar Integration
|
||||
- Attach time windows to waypoints
|
||||
- Visualize itinerary timeline
|
||||
- Sync with external calendars (iCal export)
|
||||
- Travel time estimation between events
|
||||
|
||||
### 6. Budget Tracking
|
||||
- Cost estimates per route (fuel, tolls)
|
||||
- Per-waypoint expense tracking
|
||||
- Trip budget aggregation
|
||||
- Currency conversion
|
||||
|
||||
### 7. Offline Capability
|
||||
- Tile caching for offline use
|
||||
- Route pre-computation and storage
|
||||
- PWA support for mobile
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Canvas Website │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ tldraw Canvas │ │
|
||||
│ │ ┌─────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Open Mapping Layer │ │ │
|
||||
│ │ │ ┌─────────────┐ ┌─────────────────────────┐ │ │ │
|
||||
│ │ │ │ MapLibre GL │ │ Route Visualization │ │ │ │
|
||||
│ │ │ │ (basemap) │ │ (polylines/markers) │ │ │ │
|
||||
│ │ │ └─────────────┘ └─────────────────────────┘ │ │ │
|
||||
│ │ │ ┌─────────────┐ ┌─────────────────────────┐ │ │ │
|
||||
│ │ │ │ Layers │ │ Collaboration │ │ │ │
|
||||
│ │ │ │ Panel │ │ Cursors/Presence │ │ │ │
|
||||
│ │ │ └─────────────┘ └─────────────────────────┘ │ │ │
|
||||
│ │ └─────────────────────────────────────────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌───────────┐ ┌─────────────────┐
|
||||
│ Routing API │ │ Y.js │ │ Tile Server │
|
||||
│ (OSRM/Valhalla)│ │ (collab) │ │ (MapLibre) │
|
||||
└─────────────────┘ └───────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ VROOM │
|
||||
│ (optimization) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Component | Technology | License | Notes |
|
||||
|-----------|------------|---------|-------|
|
||||
| Map Renderer | MapLibre GL JS | BSD-3 | Open-source Mapbox fork |
|
||||
| Base Maps | OpenStreetMap | ODbL | Free, community-maintained |
|
||||
| Routing Engine | OSRM / Valhalla | BSD-2 / MIT | Self-hosted, fast |
|
||||
| Multi-Route | GraphHopper | Apache 2.0 | Custom profiles |
|
||||
| Optimization | VROOM | BSD | TSP/VRP solver |
|
||||
| Collaboration | Y.js | MIT | CRDT-based sync |
|
||||
| State Management | Jotai | MIT | Already in use |
|
||||
| Tile Caching | Service Worker | - | PWA standard |
|
||||
|
||||
## Routing Provider Comparison
|
||||
|
||||
| Feature | OSRM | Valhalla | GraphHopper | ORS |
|
||||
|---------|------|----------|-------------|-----|
|
||||
| Speed | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
|
||||
| Profiles | 3 | 6+ | 10+ | 8+ |
|
||||
| Alternatives | ✅ | ✅ | ✅ | ✅ |
|
||||
| Isochrones | ❌ | ✅ | ✅ | ✅ |
|
||||
| Transit | ❌ | ✅ | ⚠️ | ❌ |
|
||||
| License | BSD-2 | MIT | Apache | GPL |
|
||||
| Docker Ready | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
**Recommendation**: Start with OSRM for simplicity and speed, add Valhalla for transit/isochrones.
|
||||
|
||||
## 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
|
||||
- [ ] Simple UI for profile selection (car/bike/foot)
|
||||
|
||||
### Phase 2: Multi-Route & Comparison
|
||||
- [ ] Alternative routes visualization
|
||||
- [ ] Route comparison panel (distance, time, cost)
|
||||
- [ ] Profile-based coloring
|
||||
- [ ] Elevation profile display
|
||||
- [ ] Drag-to-reroute functionality
|
||||
|
||||
### Phase 3: Collaboration
|
||||
- [ ] Y.js integration for real-time sync
|
||||
- [ ] Cursor presence on map
|
||||
- [ ] Concurrent waypoint editing
|
||||
- [ ] Share link generation
|
||||
- [ ] Permission management (view/edit)
|
||||
|
||||
### Phase 4: Layers & Customization
|
||||
- [ ] Layer panel UI
|
||||
- [ ] Multiple basemap options
|
||||
- [ ] Overlay layer support (GeoJSON)
|
||||
- [ ] Custom marker icons
|
||||
- [ ] Style customization
|
||||
|
||||
### Phase 5: Calendar & Budget
|
||||
- [ ] Time window attachment to waypoints
|
||||
- [ ] Itinerary timeline view
|
||||
- [ ] Budget tracking per waypoint
|
||||
- [ ] Cost estimation for routes
|
||||
- [ ] iCal export
|
||||
|
||||
### Phase 6: Optimization & Offline
|
||||
- [ ] VROOM integration for TSP/VRP
|
||||
- [ ] Multi-stop optimization
|
||||
- [ ] Tile caching via Service Worker
|
||||
- [ ] Offline route storage
|
||||
- [ ] PWA manifest
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/open-mapping/
|
||||
├── index.ts # Public exports
|
||||
├── types/
|
||||
│ └── index.ts # TypeScript definitions
|
||||
├── components/
|
||||
│ ├── index.ts
|
||||
│ ├── MapCanvas.tsx # Main map component
|
||||
│ ├── RouteLayer.tsx # Route polyline rendering
|
||||
│ ├── WaypointMarker.tsx # Interactive markers
|
||||
│ └── LayerPanel.tsx # Layer management UI
|
||||
├── hooks/
|
||||
│ ├── index.ts
|
||||
│ ├── useMapInstance.ts # MapLibre instance management
|
||||
│ ├── useRouting.ts # Route calculation
|
||||
│ ├── useCollaboration.ts # Y.js sync
|
||||
│ └── useLayers.ts # Layer state
|
||||
├── services/
|
||||
│ ├── index.ts
|
||||
│ ├── RoutingService.ts # Multi-provider routing
|
||||
│ ├── TileService.ts # Tile management/caching
|
||||
│ └── OptimizationService.ts # VROOM integration
|
||||
└── utils/
|
||||
└── index.ts # Helper functions
|
||||
```
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
The open-mapping backend services will be deployed to `/opt/apps/open-mapping/` on Netcup RS 8000.
|
||||
|
||||
### Services
|
||||
|
||||
1. **OSRM** - Primary routing engine
|
||||
- Pre-processed OSM data for region (Europe/Germany)
|
||||
- HTTP API on internal port
|
||||
|
||||
2. **Valhalla** (optional) - Extended routing
|
||||
- Transit integration via GTFS
|
||||
- Isochrone calculations
|
||||
|
||||
3. **Tile Server** - Vector tiles
|
||||
- OpenMapTiles-based
|
||||
- Serves tiles for offline caching
|
||||
|
||||
4. **VROOM** - Route optimization
|
||||
- Solves complex multi-stop problems
|
||||
- REST API
|
||||
|
||||
### Docker Compose Preview
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
osrm:
|
||||
image: osrm/osrm-backend:latest
|
||||
volumes:
|
||||
- ./data/osrm:/data
|
||||
command: osrm-routed --algorithm mld /data/region.osrm
|
||||
networks:
|
||||
- traefik-public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.osrm.rule=Host(`routing.jeffemmett.com`)"
|
||||
|
||||
tileserver:
|
||||
image: maptiler/tileserver-gl:latest
|
||||
volumes:
|
||||
- ./data/tiles:/data
|
||||
networks:
|
||||
- traefik-public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.tiles.rule=Host(`tiles.jeffemmett.com`)"
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
```
|
||||
|
||||
## Data Requirements
|
||||
|
||||
### OSM Data
|
||||
- Download PBF files from Geofabrik
|
||||
- For Europe: ~30GB (full), ~5GB (Germany only)
|
||||
- Pre-process with `osrm-extract`, `osrm-partition`, `osrm-customize`
|
||||
|
||||
### Vector Tiles
|
||||
- Generate from OSM data using OpenMapTiles
|
||||
- Or download pre-built from MapTiler
|
||||
- Storage: ~50GB for detailed regional tiles
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Routing API (`/api/route`)
|
||||
```typescript
|
||||
POST /api/route
|
||||
{
|
||||
waypoints: [{ lat: number, lng: number }],
|
||||
profile: 'car' | 'bike' | 'foot',
|
||||
alternatives: number,
|
||||
}
|
||||
Response: Route[]
|
||||
```
|
||||
|
||||
### Optimization API (`/api/optimize`)
|
||||
```typescript
|
||||
POST /api/optimize
|
||||
{
|
||||
waypoints: Waypoint[],
|
||||
constraints: OptimizationConstraints,
|
||||
}
|
||||
Response: OptimizationResult
|
||||
```
|
||||
|
||||
### Isochrone API (`/api/isochrone`)
|
||||
```typescript
|
||||
POST /api/isochrone
|
||||
{
|
||||
center: { lat: number, lng: number },
|
||||
minutes: number[],
|
||||
profile: string,
|
||||
}
|
||||
Response: GeoJSON.FeatureCollection
|
||||
```
|
||||
|
||||
## Dependencies to Add
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"maplibre-gl": "^4.x",
|
||||
"@maplibre/maplibre-gl-geocoder": "^1.x",
|
||||
"geojson": "^0.5.x"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Related Projects & Inspiration
|
||||
|
||||
- **Mapus** - Real-time collaborative mapping
|
||||
- **uMap** - OpenStreetMap-based map maker
|
||||
- **Organic Maps** - Offline-first navigation
|
||||
- **Komoot** - Outdoor route planning
|
||||
- **Rome2Rio** - Multi-modal journey planner
|
||||
- **Wandrer.earth** - Exploration tracking
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **Route Calculation** < 500ms for typical queries
|
||||
2. **Collaboration Sync** < 100ms latency
|
||||
3. **Offline Coverage** Entire planned region cached
|
||||
4. **Budget Accuracy** ±15% for fuel estimates
|
||||
5. **User Satisfaction** Preferred over Google Maps for trip planning
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should we integrate transit data (GTFS feeds)?
|
||||
2. What regions should we pre-process initially?
|
||||
3. How to handle very long routes (cross-country)?
|
||||
4. Should routes be persisted separately from canvas?
|
||||
5. Integration with existing canvas tools (markdown notes on waypoints)?
|
||||
|
||||
## 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/)
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
# Open Mapping Backend Services
|
||||
# Deploy to: /opt/apps/open-mapping/ on Netcup RS 8000
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. Download OSM data: wget https://download.geofabrik.de/europe/germany-latest.osm.pbf
|
||||
# 2. Pre-process OSRM: docker run -t -v "${PWD}:/data" osrm/osrm-backend osrm-extract -p /opt/car.lua /data/germany-latest.osm.pbf
|
||||
# Then: osrm-partition, osrm-customize
|
||||
# 3. Download vector tiles or generate with OpenMapTiles
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# =========================================================================
|
||||
# OSRM - Open Source Routing Machine
|
||||
# Primary routing engine for fast route calculations
|
||||
# =========================================================================
|
||||
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" # Internal only, accessed via Traefik
|
||||
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"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# =========================================================================
|
||||
# Valhalla - Extended Routing Engine
|
||||
# For isochrones, transit, and advanced features
|
||||
# =========================================================================
|
||||
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
|
||||
- force_rebuild=False
|
||||
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.routers.valhalla.middlewares=valhalla-stripprefix"
|
||||
- "traefik.http.middlewares.valhalla-stripprefix.stripprefix.prefixes=/valhalla"
|
||||
- "traefik.http.services.valhalla.loadbalancer.server.port=8002"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 8G
|
||||
|
||||
# =========================================================================
|
||||
# TileServer GL - Vector Tile Server
|
||||
# Serves map tiles for MapLibre GL JS
|
||||
# =========================================================================
|
||||
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"
|
||||
# CORS headers for cross-origin tile requests
|
||||
- "traefik.http.middlewares.tiles-cors.headers.accesscontrolallowmethods=GET,OPTIONS"
|
||||
- "traefik.http.middlewares.tiles-cors.headers.accesscontrolalloworiginlist=*"
|
||||
- "traefik.http.middlewares.tiles-cors.headers.accesscontrolmaxage=100"
|
||||
- "traefik.http.routers.tiles.middlewares=tiles-cors"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# =========================================================================
|
||||
# VROOM - Vehicle Routing Optimization
|
||||
# Solves TSP and VRP for multi-stop 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.routers.vroom.middlewares=vroom-stripprefix"
|
||||
- "traefik.http.middlewares.vroom-stripprefix.stripprefix.prefixes=/optimize"
|
||||
- "traefik.http.services.vroom.loadbalancer.server.port=3000"
|
||||
depends_on:
|
||||
- osrm
|
||||
|
||||
# =========================================================================
|
||||
# API Gateway (optional)
|
||||
# Unified routing API that abstracts backend services
|
||||
# =========================================================================
|
||||
# api:
|
||||
# build: ./api
|
||||
# container_name: open-mapping-api
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# - OSRM_URL=http://osrm:5000
|
||||
# - VALHALLA_URL=http://valhalla:8002
|
||||
# - VROOM_URL=http://vroom:3000
|
||||
# ports:
|
||||
# - "4000:4000"
|
||||
# networks:
|
||||
# - traefik-public
|
||||
# - open-mapping-internal
|
||||
# labels:
|
||||
# - "traefik.enable=true"
|
||||
# - "traefik.http.routers.mapping-api.rule=Host(`mapping.jeffemmett.com`)"
|
||||
# - "traefik.http.services.mapping-api.loadbalancer.server.port=4000"
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
open-mapping-internal:
|
||||
driver: bridge
|
||||
|
||||
# Persistent storage for processed routing data
|
||||
volumes:
|
||||
osrm-data:
|
||||
valhalla-data:
|
||||
tiles-data:
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Open Mapping Backend Setup Script
|
||||
# Run on Netcup RS 8000 to prepare routing data
|
||||
#
|
||||
# Usage: ./open-mapping.setup.sh [region]
|
||||
# Example: ./open-mapping.setup.sh germany
|
||||
# ./open-mapping.setup.sh europe
|
||||
|
||||
set -e
|
||||
|
||||
REGION=${1:-germany}
|
||||
DATA_DIR="/opt/apps/open-mapping/data"
|
||||
|
||||
echo "=== Open Mapping Setup ==="
|
||||
echo "Region: $REGION"
|
||||
echo "Data directory: $DATA_DIR"
|
||||
echo ""
|
||||
|
||||
# Create directories
|
||||
mkdir -p "$DATA_DIR/osrm"
|
||||
mkdir -p "$DATA_DIR/valhalla"
|
||||
mkdir -p "$DATA_DIR/tiles"
|
||||
|
||||
cd "$DATA_DIR"
|
||||
|
||||
# =========================================================================
|
||||
# Download OSM Data
|
||||
# =========================================================================
|
||||
echo "=== Downloading 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"
|
||||
echo "Supported: germany, europe"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -f "osrm/$OSM_FILE" ]; then
|
||||
echo "Downloading $OSM_URL..."
|
||||
wget -O "osrm/$OSM_FILE" "$OSM_URL"
|
||||
else
|
||||
echo "OSM file already exists, skipping download"
|
||||
fi
|
||||
|
||||
# =========================================================================
|
||||
# Process OSRM Data
|
||||
# =========================================================================
|
||||
echo "=== Processing OSRM routing data ==="
|
||||
echo "This may take several hours for large regions..."
|
||||
|
||||
cd osrm
|
||||
|
||||
# Extract
|
||||
if [ ! -f "${OSM_FILE%.osm.pbf}.osrm" ]; then
|
||||
echo "Running osrm-extract..."
|
||||
docker run -t -v "${PWD}:/data" osrm/osrm-backend:v5.27.1 \
|
||||
osrm-extract -p /opt/car.lua /data/$OSM_FILE
|
||||
else
|
||||
echo "OSRM extract already done, skipping"
|
||||
fi
|
||||
|
||||
# Partition (for MLD algorithm)
|
||||
if [ ! -f "${OSM_FILE%.osm.pbf}.osrm.partition" ]; then
|
||||
echo "Running osrm-partition..."
|
||||
docker run -t -v "${PWD}:/data" osrm/osrm-backend:v5.27.1 \
|
||||
osrm-partition /data/${OSM_FILE%.osm.pbf}.osrm
|
||||
else
|
||||
echo "OSRM partition already done, skipping"
|
||||
fi
|
||||
|
||||
# Customize
|
||||
if [ ! -f "${OSM_FILE%.osm.pbf}.osrm.mldgr" ]; then
|
||||
echo "Running osrm-customize..."
|
||||
docker run -t -v "${PWD}:/data" osrm/osrm-backend:v5.27.1 \
|
||||
osrm-customize /data/${OSM_FILE%.osm.pbf}.osrm
|
||||
else
|
||||
echo "OSRM customize already done, skipping"
|
||||
fi
|
||||
|
||||
cd ..
|
||||
|
||||
# =========================================================================
|
||||
# Download Vector Tiles (optional, can use Valhalla built-in)
|
||||
# =========================================================================
|
||||
echo "=== Setting up vector tiles ==="
|
||||
|
||||
# Option 1: Use OpenMapTiles pre-built (requires license for commercial)
|
||||
# Option 2: Generate from OSM data (time consuming)
|
||||
# Option 3: Use free tile providers with attribution
|
||||
|
||||
# For now, create a config to use external tile providers
|
||||
cat > tiles/config.json << 'EOF'
|
||||
{
|
||||
"options": {
|
||||
"paths": {
|
||||
"fonts": "fonts",
|
||||
"sprites": "sprites",
|
||||
"styles": "styles",
|
||||
"mbtiles": ""
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"osm-bright": {
|
||||
"style": "osm-bright/style.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "Tile server configured to use styles from ./tiles/"
|
||||
echo "Download MBTiles from OpenMapTiles or generate from OSM for offline use"
|
||||
|
||||
# =========================================================================
|
||||
# Verify Setup
|
||||
# =========================================================================
|
||||
echo ""
|
||||
echo "=== Setup Complete ==="
|
||||
echo ""
|
||||
echo "Directory structure:"
|
||||
ls -la "$DATA_DIR"
|
||||
echo ""
|
||||
echo "OSRM files:"
|
||||
ls -la "$DATA_DIR/osrm/"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Copy docker-compose file to /opt/apps/open-mapping/"
|
||||
echo "2. Run: docker compose up -d"
|
||||
echo "3. Test OSRM: curl 'http://localhost:5000/route/v1/driving/13.388860,52.517037;13.397634,52.529407?overview=false'"
|
||||
echo "4. Add to Cloudflare tunnel if needed"
|
||||
|
|
@ -1,325 +0,0 @@
|
|||
/**
|
||||
* OptimizationService - Route and trip optimization
|
||||
*
|
||||
* Uses VROOM or similar for:
|
||||
* - Vehicle Routing Problems (VRP)
|
||||
* - Traveling Salesman Problem (TSP)
|
||||
* - Time window constraints
|
||||
* - Capacity constraints
|
||||
* - Multi-vehicle optimization
|
||||
* - Cost tracking and budgeting
|
||||
*/
|
||||
|
||||
import type {
|
||||
Waypoint,
|
||||
Route,
|
||||
Coordinate,
|
||||
TripItinerary,
|
||||
TripBudget,
|
||||
OptimizationServiceConfig,
|
||||
} from '../types';
|
||||
|
||||
export interface OptimizationJob {
|
||||
id: string;
|
||||
waypoints: Waypoint[];
|
||||
constraints: OptimizationConstraints;
|
||||
result?: OptimizationResult;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface OptimizationConstraints {
|
||||
startLocation?: Coordinate;
|
||||
endLocation?: Coordinate;
|
||||
returnToStart?: boolean;
|
||||
maxDuration?: number; // seconds
|
||||
maxDistance?: number; // meters
|
||||
timeWindows?: TimeWindow[];
|
||||
vehicleCapacity?: number;
|
||||
priorities?: number[]; // waypoint priorities
|
||||
}
|
||||
|
||||
export interface TimeWindow {
|
||||
waypointIndex: number;
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
export interface OptimizationResult {
|
||||
orderedWaypoints: Waypoint[];
|
||||
totalDistance: number;
|
||||
totalDuration: number;
|
||||
estimatedCost: OptimizationCost;
|
||||
unassigned?: number[]; // indices of waypoints that couldn't be visited
|
||||
violations?: string[];
|
||||
}
|
||||
|
||||
export interface OptimizationCost {
|
||||
fuel: number;
|
||||
time: number; // value of time
|
||||
total: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface CostParameters {
|
||||
fuelPricePerLiter: number;
|
||||
fuelConsumptionPer100km: number; // liters
|
||||
valueOfTimePerHour: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
const DEFAULT_COST_PARAMS: CostParameters = {
|
||||
fuelPricePerLiter: 1.5, // EUR
|
||||
fuelConsumptionPer100km: 8, // liters
|
||||
valueOfTimePerHour: 20, // EUR
|
||||
currency: 'EUR',
|
||||
};
|
||||
|
||||
export class OptimizationService {
|
||||
private config: OptimizationServiceConfig;
|
||||
private costParams: CostParameters;
|
||||
|
||||
constructor(
|
||||
config: OptimizationServiceConfig,
|
||||
costParams: CostParameters = DEFAULT_COST_PARAMS
|
||||
) {
|
||||
this.config = config;
|
||||
this.costParams = costParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize waypoint order for minimum travel time/distance
|
||||
*/
|
||||
async optimizeRoute(
|
||||
waypoints: Waypoint[],
|
||||
constraints?: OptimizationConstraints
|
||||
): 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, constraints);
|
||||
}
|
||||
|
||||
// Fallback: simple nearest-neighbor heuristic
|
||||
return this.nearestNeighborOptimization(waypoints, constraints);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize a full trip itinerary with time constraints
|
||||
*/
|
||||
async optimizeItinerary(itinerary: TripItinerary): Promise<TripItinerary> {
|
||||
// Extract all waypoints from all routes
|
||||
const allWaypoints = itinerary.routes.flatMap((r) => r.waypoints);
|
||||
|
||||
// Build time windows from events
|
||||
const timeWindows: TimeWindow[] = itinerary.events
|
||||
.filter((e) => e.waypointId)
|
||||
.map((e) => {
|
||||
const waypointIndex = allWaypoints.findIndex((w) => w.id === e.waypointId);
|
||||
return {
|
||||
waypointIndex,
|
||||
start: e.startTime,
|
||||
end: e.endTime,
|
||||
};
|
||||
})
|
||||
.filter((tw) => tw.waypointIndex >= 0);
|
||||
|
||||
const result = await this.optimizeRoute(allWaypoints, { timeWindows });
|
||||
|
||||
// Rebuild itinerary with optimized order
|
||||
return {
|
||||
...itinerary,
|
||||
// Would need more sophisticated logic to rebuild routes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate trip costs
|
||||
*/
|
||||
estimateCosts(
|
||||
distance: number, // meters
|
||||
duration: number, // seconds
|
||||
additionalCosts?: Partial<TripBudget>
|
||||
): OptimizationCost {
|
||||
const distanceKm = distance / 1000;
|
||||
const durationHours = duration / 3600;
|
||||
|
||||
const fuelLiters = (distanceKm / 100) * this.costParams.fuelConsumptionPer100km;
|
||||
const fuelCost = fuelLiters * this.costParams.fuelPricePerLiter;
|
||||
const timeCost = durationHours * this.costParams.valueOfTimePerHour;
|
||||
|
||||
return {
|
||||
fuel: Math.round(fuelCost * 100) / 100,
|
||||
time: Math.round(timeCost * 100) / 100,
|
||||
total: Math.round((fuelCost + timeCost) * 100) / 100,
|
||||
currency: this.costParams.currency,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cost calculation parameters
|
||||
*/
|
||||
setCostParameters(params: Partial<CostParameters>): void {
|
||||
this.costParams = { ...this.costParams, ...params };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private Methods
|
||||
// =========================================================================
|
||||
|
||||
private async optimizeWithVROOM(
|
||||
waypoints: Waypoint[],
|
||||
constraints?: OptimizationConstraints
|
||||
): Promise<OptimizationResult> {
|
||||
// Build VROOM request
|
||||
const jobs = waypoints.map((wp, index) => ({
|
||||
id: index,
|
||||
location: [wp.coordinate.lng, wp.coordinate.lat],
|
||||
service: wp.stayDuration ? wp.stayDuration * 60 : 0, // seconds
|
||||
priority: constraints?.priorities?.[index] ?? 0,
|
||||
time_windows: this.getTimeWindowForWaypoint(index, constraints?.timeWindows),
|
||||
}));
|
||||
|
||||
const vehicles = [{
|
||||
id: 0,
|
||||
start: constraints?.startLocation
|
||||
? [constraints.startLocation.lng, constraints.startLocation.lat]
|
||||
: [waypoints[0].coordinate.lng, waypoints[0].coordinate.lat],
|
||||
end: constraints?.endLocation
|
||||
? [constraints.endLocation.lng, constraints.endLocation.lat]
|
||||
: constraints?.returnToStart
|
||||
? [waypoints[0].coordinate.lng, waypoints[0].coordinate.lat]
|
||||
: undefined,
|
||||
capacity: constraints?.vehicleCapacity ? [constraints.vehicleCapacity] : undefined,
|
||||
}];
|
||||
|
||||
const body = { jobs, vehicles };
|
||||
|
||||
try {
|
||||
const response = await fetch(this.config.baseUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code !== 0) {
|
||||
throw new Error(`VROOM error: ${data.error}`);
|
||||
}
|
||||
|
||||
return this.parseVROOMResponse(data, waypoints);
|
||||
} catch (error) {
|
||||
console.error('VROOM optimization failed:', error);
|
||||
return this.nearestNeighborOptimization(waypoints, constraints);
|
||||
}
|
||||
}
|
||||
|
||||
private parseVROOMResponse(
|
||||
data: any,
|
||||
originalWaypoints: Waypoint[]
|
||||
): OptimizationResult {
|
||||
const route = data.routes[0];
|
||||
const orderedIndices = route.steps
|
||||
.filter((s: any) => s.type === 'job')
|
||||
.map((s: any) => s.job);
|
||||
|
||||
const orderedWaypoints = orderedIndices.map((i: number) => originalWaypoints[i]);
|
||||
|
||||
return {
|
||||
orderedWaypoints,
|
||||
totalDistance: data.summary.distance,
|
||||
totalDuration: data.summary.duration,
|
||||
estimatedCost: this.estimateCosts(data.summary.distance, data.summary.duration),
|
||||
unassigned: data.unassigned?.map((u: any) => u.id),
|
||||
};
|
||||
}
|
||||
|
||||
private nearestNeighborOptimization(
|
||||
waypoints: Waypoint[],
|
||||
constraints?: OptimizationConstraints
|
||||
): OptimizationResult {
|
||||
const remaining = [...waypoints];
|
||||
const ordered: Waypoint[] = [];
|
||||
|
||||
// Start from first waypoint or specified start
|
||||
let current = remaining.shift()!;
|
||||
ordered.push(current);
|
||||
|
||||
while (remaining.length > 0) {
|
||||
// Find nearest unvisited waypoint
|
||||
let nearestIndex = 0;
|
||||
let nearestDist = Infinity;
|
||||
|
||||
for (let i = 0; i < remaining.length; i++) {
|
||||
const dist = this.haversineDistance(
|
||||
current.coordinate,
|
||||
remaining[i].coordinate
|
||||
);
|
||||
if (dist < nearestDist) {
|
||||
nearestDist = dist;
|
||||
nearestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
current = remaining.splice(nearestIndex, 1)[0];
|
||||
ordered.push(current);
|
||||
}
|
||||
|
||||
// Estimate total distance/duration
|
||||
let totalDistance = 0;
|
||||
for (let i = 0; i < ordered.length - 1; i++) {
|
||||
totalDistance += this.haversineDistance(
|
||||
ordered[i].coordinate,
|
||||
ordered[i + 1].coordinate
|
||||
);
|
||||
}
|
||||
|
||||
// Rough duration estimate: 50 km/h average
|
||||
const totalDuration = (totalDistance / 50000) * 3600;
|
||||
|
||||
return {
|
||||
orderedWaypoints: ordered,
|
||||
totalDistance,
|
||||
totalDuration,
|
||||
estimatedCost: this.estimateCosts(totalDistance, totalDuration),
|
||||
};
|
||||
}
|
||||
|
||||
private getTimeWindowForWaypoint(
|
||||
index: number,
|
||||
timeWindows?: TimeWindow[]
|
||||
): number[][] | undefined {
|
||||
const tw = timeWindows?.find((t) => t.waypointIndex === index);
|
||||
if (!tw) return undefined;
|
||||
|
||||
return [[
|
||||
Math.floor(tw.start.getTime() / 1000),
|
||||
Math.floor(tw.end.getTime() / 1000),
|
||||
]];
|
||||
}
|
||||
|
||||
private haversineDistance(coord1: Coordinate, coord2: Coordinate): number {
|
||||
const R = 6371000; // Earth's radius in meters
|
||||
const lat1 = (coord1.lat * Math.PI) / 180;
|
||||
const lat2 = (coord2.lat * Math.PI) / 180;
|
||||
const deltaLat = ((coord2.lat - coord1.lat) * Math.PI) / 180;
|
||||
const deltaLng = ((coord2.lng - coord1.lng) * Math.PI) / 180;
|
||||
|
||||
const a =
|
||||
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLng / 2) * Math.sin(deltaLng / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return R * c;
|
||||
}
|
||||
}
|
||||
|
||||
export default OptimizationService;
|
||||
|
|
@ -1,517 +0,0 @@
|
|||
/**
|
||||
* RoutingService - Abstraction layer for routing providers
|
||||
*
|
||||
* Supports multiple backends:
|
||||
* - OSRM (Open Source Routing Machine)
|
||||
* - Valhalla
|
||||
* - GraphHopper
|
||||
* - OpenRouteService
|
||||
*
|
||||
* All providers expose a unified API for route calculation.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Waypoint,
|
||||
Route,
|
||||
RoutingOptions,
|
||||
RoutingServiceConfig,
|
||||
RouteSummary,
|
||||
RouteLeg,
|
||||
Coordinate,
|
||||
RoutingProfile,
|
||||
} from '../types';
|
||||
|
||||
export class RoutingService {
|
||||
private config: RoutingServiceConfig;
|
||||
|
||||
constructor(config: RoutingServiceConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a route between waypoints
|
||||
*/
|
||||
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);
|
||||
case 'graphhopper':
|
||||
return this.calculateGraphHopperRoute(coordinates, profile, options);
|
||||
case 'openrouteservice':
|
||||
return this.calculateORSRoute(coordinates, profile, options);
|
||||
default:
|
||||
throw new Error(`Unsupported routing provider: ${this.config.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate multiple alternative routes
|
||||
*/
|
||||
async calculateAlternatives(
|
||||
waypoints: Waypoint[],
|
||||
count: number = 3
|
||||
): Promise<Route[]> {
|
||||
// TODO: Implement alternatives calculation
|
||||
// OSRM supports alternatives=true parameter
|
||||
// Valhalla supports alternates parameter
|
||||
const mainRoute = await this.calculateRoute(waypoints, { alternatives: count });
|
||||
return mainRoute.alternatives
|
||||
? [mainRoute, ...mainRoute.alternatives]
|
||||
: [mainRoute];
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize waypoint ordering (traveling salesman)
|
||||
*/
|
||||
async optimizeWaypointOrder(waypoints: Waypoint[]): Promise<Waypoint[]> {
|
||||
if (waypoints.length <= 2) return waypoints;
|
||||
|
||||
// Use OSRM trip endpoint or VROOM for optimization
|
||||
const coordinates = waypoints.map((w) => `${w.coordinate.lng},${w.coordinate.lat}`).join(';');
|
||||
const url = `${this.config.baseUrl}/trip/v1/driving/${coordinates}?roundtrip=false&source=first&destination=last`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code !== 'Ok' || !data.trips?.[0]?.legs) {
|
||||
return waypoints;
|
||||
}
|
||||
|
||||
// Reorder waypoints based on optimization result
|
||||
const optimizedIndices = data.waypoints.map((wp: { waypoint_index: number }) => wp.waypoint_index);
|
||||
return optimizedIndices.map((index: number) => waypoints[index]);
|
||||
} catch (error) {
|
||||
console.error('Waypoint optimization failed:', error);
|
||||
return waypoints;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate isochrone (reachable area in given time)
|
||||
*/
|
||||
async calculateIsochrone(
|
||||
center: Coordinate,
|
||||
minutes: number[]
|
||||
): Promise<GeoJSON.FeatureCollection> {
|
||||
// Valhalla and ORS support isochrones natively
|
||||
if (this.config.provider === 'valhalla') {
|
||||
return this.calculateValhallaIsochrone(center, minutes);
|
||||
}
|
||||
if (this.config.provider === 'openrouteservice') {
|
||||
return this.calculateORSIsochrone(center, minutes);
|
||||
}
|
||||
|
||||
// For OSRM/GraphHopper, would need to approximate with sampling
|
||||
console.warn('Isochrone not supported for provider:', this.config.provider);
|
||||
return { type: 'FeatureCollection', features: [] };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private Provider-Specific Methods
|
||||
// =========================================================================
|
||||
|
||||
private async calculateOSRMRoute(
|
||||
coordinates: Coordinate[],
|
||||
profile: RoutingProfile,
|
||||
options?: Partial<RoutingOptions>
|
||||
): Promise<Route> {
|
||||
const coordString = coordinates
|
||||
.map((c) => `${c.lng},${c.lat}`)
|
||||
.join(';');
|
||||
|
||||
const osrmProfile = this.mapProfileToOSRM(profile);
|
||||
const url = new URL(`${this.config.baseUrl}/route/v1/${osrmProfile}/${coordString}`);
|
||||
url.searchParams.set('overview', 'full');
|
||||
url.searchParams.set('geometries', 'geojson');
|
||||
url.searchParams.set('steps', 'true');
|
||||
if (options?.alternatives) {
|
||||
url.searchParams.set('alternatives', 'true');
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code !== 'Ok') {
|
||||
throw new Error(`OSRM error: ${data.message || data.code}`);
|
||||
}
|
||||
|
||||
return this.parseOSRMResponse(data, profile);
|
||||
}
|
||||
|
||||
private async calculateValhallaRoute(
|
||||
coordinates: Coordinate[],
|
||||
profile: RoutingProfile,
|
||||
options?: Partial<RoutingOptions>
|
||||
): Promise<Route> {
|
||||
const locations = coordinates.map((c) => ({
|
||||
lat: c.lat,
|
||||
lon: c.lng,
|
||||
}));
|
||||
|
||||
const costing = this.mapProfileToValhalla(profile);
|
||||
const body = {
|
||||
locations,
|
||||
costing,
|
||||
directions_options: {
|
||||
units: 'kilometers',
|
||||
},
|
||||
alternates: options?.alternatives ?? 0,
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.config.baseUrl}/route`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(`Valhalla error: ${data.error}`);
|
||||
}
|
||||
|
||||
return this.parseValhallaResponse(data, profile);
|
||||
}
|
||||
|
||||
private async calculateGraphHopperRoute(
|
||||
coordinates: Coordinate[],
|
||||
profile: RoutingProfile,
|
||||
options?: Partial<RoutingOptions>
|
||||
): Promise<Route> {
|
||||
const url = new URL(`${this.config.baseUrl}/route`);
|
||||
coordinates.forEach((c) => {
|
||||
url.searchParams.append('point', `${c.lat},${c.lng}`);
|
||||
});
|
||||
url.searchParams.set('profile', this.mapProfileToGraphHopper(profile));
|
||||
url.searchParams.set('points_encoded', 'false');
|
||||
url.searchParams.set('instructions', 'true');
|
||||
if (options?.alternatives) {
|
||||
url.searchParams.set('algorithm', 'alternative_route');
|
||||
url.searchParams.set('alternative_route.max_paths', String(options.alternatives));
|
||||
}
|
||||
if (this.config.apiKey) {
|
||||
url.searchParams.set('key', this.config.apiKey);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
const data = await response.json();
|
||||
|
||||
if (data.message) {
|
||||
throw new Error(`GraphHopper error: ${data.message}`);
|
||||
}
|
||||
|
||||
return this.parseGraphHopperResponse(data, profile);
|
||||
}
|
||||
|
||||
private async calculateORSRoute(
|
||||
coordinates: Coordinate[],
|
||||
profile: RoutingProfile,
|
||||
options?: Partial<RoutingOptions>
|
||||
): Promise<Route> {
|
||||
const orsProfile = this.mapProfileToORS(profile);
|
||||
const body = {
|
||||
coordinates: coordinates.map((c) => [c.lng, c.lat]),
|
||||
alternative_routes: options?.alternatives
|
||||
? { target_count: options.alternatives }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (this.config.apiKey) {
|
||||
headers['Authorization'] = this.config.apiKey;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${this.config.baseUrl}/v2/directions/${orsProfile}/geojson`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(`ORS error: ${data.error.message}`);
|
||||
}
|
||||
|
||||
return this.parseORSResponse(data, profile);
|
||||
}
|
||||
|
||||
private async calculateValhallaIsochrone(
|
||||
center: Coordinate,
|
||||
minutes: number[]
|
||||
): Promise<GeoJSON.FeatureCollection> {
|
||||
const body = {
|
||||
locations: [{ lat: center.lat, lon: center.lng }],
|
||||
costing: 'auto',
|
||||
contours: minutes.map((m) => ({ time: m })),
|
||||
polygons: true,
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.config.baseUrl}/isochrone`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async calculateORSIsochrone(
|
||||
center: Coordinate,
|
||||
minutes: number[]
|
||||
): Promise<GeoJSON.FeatureCollection> {
|
||||
const body = {
|
||||
locations: [[center.lng, center.lat]],
|
||||
range: minutes.map((m) => m * 60), // ORS uses seconds
|
||||
range_type: 'time',
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (this.config.apiKey) {
|
||||
headers['Authorization'] = this.config.apiKey;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.config.baseUrl}/v2/isochrones/driving-car`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Profile Mapping
|
||||
// =========================================================================
|
||||
|
||||
private mapProfileToOSRM(profile: RoutingProfile): string {
|
||||
const mapping: Partial<Record<RoutingProfile, string>> = {
|
||||
car: 'driving',
|
||||
bicycle: 'cycling',
|
||||
foot: 'walking',
|
||||
hiking: 'walking',
|
||||
};
|
||||
return mapping[profile] ?? 'driving';
|
||||
}
|
||||
|
||||
private mapProfileToValhalla(profile: RoutingProfile): string {
|
||||
const mapping: Partial<Record<RoutingProfile, string>> = {
|
||||
car: 'auto',
|
||||
truck: 'truck',
|
||||
motorcycle: 'motorcycle',
|
||||
bicycle: 'bicycle',
|
||||
mountain_bike: 'bicycle',
|
||||
road_bike: 'bicycle',
|
||||
foot: 'pedestrian',
|
||||
hiking: 'pedestrian',
|
||||
transit: 'multimodal',
|
||||
};
|
||||
return mapping[profile] ?? 'auto';
|
||||
}
|
||||
|
||||
private mapProfileToGraphHopper(profile: RoutingProfile): string {
|
||||
const mapping: Partial<Record<RoutingProfile, string>> = {
|
||||
car: 'car',
|
||||
truck: 'truck',
|
||||
motorcycle: 'motorcycle',
|
||||
bicycle: 'bike',
|
||||
mountain_bike: 'mtb',
|
||||
road_bike: 'racingbike',
|
||||
foot: 'foot',
|
||||
hiking: 'hike',
|
||||
};
|
||||
return mapping[profile] ?? 'car';
|
||||
}
|
||||
|
||||
private mapProfileToORS(profile: RoutingProfile): string {
|
||||
const mapping: Partial<Record<RoutingProfile, string>> = {
|
||||
car: 'driving-car',
|
||||
truck: 'driving-hgv',
|
||||
bicycle: 'cycling-regular',
|
||||
mountain_bike: 'cycling-mountain',
|
||||
road_bike: 'cycling-road',
|
||||
foot: 'foot-walking',
|
||||
hiking: 'foot-hiking',
|
||||
wheelchair: 'wheelchair',
|
||||
};
|
||||
return mapping[profile] ?? 'driving-car';
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Response Parsing
|
||||
// =========================================================================
|
||||
|
||||
private parseOSRMResponse(data: any, profile: RoutingProfile): Route {
|
||||
const route = data.routes[0];
|
||||
const id = `route-${Date.now()}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
waypoints: [], // Populated by caller
|
||||
geometry: route.geometry,
|
||||
profile,
|
||||
summary: {
|
||||
distance: route.distance,
|
||||
duration: route.duration,
|
||||
},
|
||||
legs: route.legs.map((leg: any, index: number) => ({
|
||||
startWaypoint: `waypoint-${index}`,
|
||||
endWaypoint: `waypoint-${index + 1}`,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
geometry: { type: 'LineString', coordinates: [] }, // Would need to extract from steps
|
||||
steps: leg.steps?.map((step: any) => ({
|
||||
instruction: step.maneuver?.instruction ?? '',
|
||||
distance: step.distance,
|
||||
duration: step.duration,
|
||||
geometry: step.geometry,
|
||||
maneuver: {
|
||||
type: step.maneuver?.type ?? 'continue',
|
||||
modifier: step.maneuver?.modifier,
|
||||
bearingBefore: step.maneuver?.bearing_before ?? 0,
|
||||
bearingAfter: step.maneuver?.bearing_after ?? 0,
|
||||
location: {
|
||||
lat: step.maneuver?.location?.[1] ?? 0,
|
||||
lng: step.maneuver?.location?.[0] ?? 0,
|
||||
},
|
||||
},
|
||||
})),
|
||||
})),
|
||||
alternatives: data.routes.slice(1).map((alt: any) =>
|
||||
this.parseOSRMResponse({ routes: [alt] }, profile)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private parseValhallaResponse(data: any, profile: RoutingProfile): Route {
|
||||
const trip = data.trip;
|
||||
const id = `route-${Date.now()}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
waypoints: [],
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: this.decodeValhallaPolyline(trip.legs[0]?.shape ?? ''),
|
||||
},
|
||||
profile,
|
||||
summary: {
|
||||
distance: trip.summary.length * 1000, // km to m
|
||||
duration: trip.summary.time,
|
||||
},
|
||||
legs: trip.legs.map((leg: any, index: number) => ({
|
||||
startWaypoint: `waypoint-${index}`,
|
||||
endWaypoint: `waypoint-${index + 1}`,
|
||||
distance: leg.summary.length * 1000,
|
||||
duration: leg.summary.time,
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: this.decodeValhallaPolyline(leg.shape ?? ''),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private parseGraphHopperResponse(data: any, profile: RoutingProfile): Route {
|
||||
const path = data.paths[0];
|
||||
const id = `route-${Date.now()}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
waypoints: [],
|
||||
geometry: path.points,
|
||||
profile,
|
||||
summary: {
|
||||
distance: path.distance,
|
||||
duration: path.time / 1000, // ms to s
|
||||
ascent: path.ascend,
|
||||
descent: path.descend,
|
||||
},
|
||||
legs: [], // GraphHopper doesn't split by waypoint in the same way
|
||||
alternatives: data.paths.slice(1).map((alt: any) =>
|
||||
this.parseGraphHopperResponse({ paths: [alt] }, profile)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private parseORSResponse(data: any, profile: RoutingProfile): Route {
|
||||
const feature = data.features[0];
|
||||
const id = `route-${Date.now()}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
waypoints: [],
|
||||
geometry: feature.geometry,
|
||||
profile,
|
||||
summary: {
|
||||
distance: feature.properties.summary.distance,
|
||||
duration: feature.properties.summary.duration,
|
||||
},
|
||||
legs: feature.properties.segments.map((seg: any, index: number) => ({
|
||||
startWaypoint: `waypoint-${index}`,
|
||||
endWaypoint: `waypoint-${index + 1}`,
|
||||
distance: seg.distance,
|
||||
duration: seg.duration,
|
||||
geometry: { type: 'LineString', coordinates: [] },
|
||||
steps: seg.steps,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private decodeValhallaPolyline(encoded: string): [number, number][] {
|
||||
// Valhalla uses Google's polyline encoding
|
||||
const coordinates: [number, number][] = [];
|
||||
let index = 0;
|
||||
let lat = 0;
|
||||
let lng = 0;
|
||||
|
||||
while (index < encoded.length) {
|
||||
let shift = 0;
|
||||
let result = 0;
|
||||
let byte: number;
|
||||
|
||||
do {
|
||||
byte = encoded.charCodeAt(index++) - 63;
|
||||
result |= (byte & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (byte >= 0x20);
|
||||
|
||||
const dlat = result & 1 ? ~(result >> 1) : result >> 1;
|
||||
lat += dlat;
|
||||
|
||||
shift = 0;
|
||||
result = 0;
|
||||
|
||||
do {
|
||||
byte = encoded.charCodeAt(index++) - 63;
|
||||
result |= (byte & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (byte >= 0x20);
|
||||
|
||||
const dlng = result & 1 ? ~(result >> 1) : result >> 1;
|
||||
lng += dlng;
|
||||
|
||||
coordinates.push([lng / 1e6, lat / 1e6]);
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
}
|
||||
}
|
||||
|
||||
export default RoutingService;
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
/**
|
||||
* TileService - Manages map tile sources and caching
|
||||
*
|
||||
* Features:
|
||||
* - Multiple tile providers (OSM, Mapbox, custom)
|
||||
* - Offline tile caching via Service Worker
|
||||
* - Vector tile support
|
||||
* - Custom style management
|
||||
*/
|
||||
|
||||
import type { TileServiceConfig, BoundingBox } from '../types';
|
||||
|
||||
export interface TileSource {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'raster' | 'vector';
|
||||
url: string;
|
||||
attribution: string;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
tileSize?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_TILE_SOURCES: TileSource[] = [
|
||||
{
|
||||
id: 'osm-standard',
|
||||
name: 'OpenStreetMap',
|
||||
type: 'raster',
|
||||
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 19,
|
||||
},
|
||||
{
|
||||
id: 'osm-humanitarian',
|
||||
name: 'Humanitarian',
|
||||
type: 'raster',
|
||||
url: 'https://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap contributors, Tiles: 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: '© OpenStreetMap contributors, © 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: '© OpenStreetMap contributors, © CARTO',
|
||||
maxZoom: 19,
|
||||
},
|
||||
{
|
||||
id: 'stamen-terrain',
|
||||
name: 'Terrain',
|
||||
type: 'raster',
|
||||
url: 'https://stamen-tiles.a.ssl.fastly.net/terrain/{z}/{x}/{y}.png',
|
||||
attribution: 'Map tiles by <a href="http://stamen.com">Stamen Design</a>',
|
||||
maxZoom: 18,
|
||||
},
|
||||
{
|
||||
id: 'cycling',
|
||||
name: 'Cycling Routes',
|
||||
type: 'raster',
|
||||
url: 'https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png',
|
||||
attribution: '<a href="https://waymarkedtrails.org">Waymarked Trails</a>',
|
||||
maxZoom: 18,
|
||||
},
|
||||
{
|
||||
id: 'hiking',
|
||||
name: 'Hiking Trails',
|
||||
type: 'raster',
|
||||
url: 'https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png',
|
||||
attribution: '<a href="https://waymarkedtrails.org">Waymarked Trails</a>',
|
||||
maxZoom: 18,
|
||||
},
|
||||
];
|
||||
|
||||
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(): Promise<void> {
|
||||
if ('caches' in window) {
|
||||
try {
|
||||
this.cache = await caches.open(this.cacheName);
|
||||
} catch (error) {
|
||||
console.warn('TileService: Cache API not available', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available tile sources
|
||||
*/
|
||||
getSources(): TileSource[] {
|
||||
return DEFAULT_TILE_SOURCES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific tile source by ID
|
||||
*/
|
||||
getSource(id: string): TileSource | undefined {
|
||||
return DEFAULT_TILE_SOURCES.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate tile URL for a specific coordinate
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-cache tiles for offline use
|
||||
*/
|
||||
async cacheTilesForArea(
|
||||
sourceId: string,
|
||||
bounds: BoundingBox,
|
||||
minZoom: number,
|
||||
maxZoom: number,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<void> {
|
||||
const source = this.getSource(sourceId);
|
||||
if (!source || !this.cache) {
|
||||
throw new Error('Cannot cache tiles: source not found or cache unavailable');
|
||||
}
|
||||
|
||||
const tiles = this.getTilesInBounds(bounds, minZoom, maxZoom);
|
||||
const total = tiles.length;
|
||||
let completed = 0;
|
||||
|
||||
for (const { z, x, y } of tiles) {
|
||||
const url = this.getTileUrl(source, z, x, y);
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
await this.cache.put(url, response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to cache tile ${z}/${x}/${y}:`, error);
|
||||
}
|
||||
completed++;
|
||||
onProgress?.(completed / total);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached tiles
|
||||
*/
|
||||
async clearCache(): Promise<void> {
|
||||
if ('caches' in window) {
|
||||
await caches.delete(this.cacheName);
|
||||
this.cache = await caches.open(this.cacheName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache size estimate
|
||||
*/
|
||||
async getCacheSize(): Promise<number> {
|
||||
if (!this.cache) return 0;
|
||||
|
||||
const keys = await this.cache.keys();
|
||||
let totalSize = 0;
|
||||
|
||||
for (const request of keys) {
|
||||
const response = await this.cache.match(request);
|
||||
if (response) {
|
||||
const blob = await response.blob();
|
||||
totalSize += blob.size;
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tiles within a bounding box
|
||||
*/
|
||||
private getTilesInBounds(
|
||||
bounds: BoundingBox,
|
||||
minZoom: number,
|
||||
maxZoom: number
|
||||
): Array<{ z: number; x: number; y: number }> {
|
||||
const tiles: Array<{ z: number; x: number; y: number }> = [];
|
||||
|
||||
for (let z = minZoom; z <= maxZoom; z++) {
|
||||
const minTile = this.latLngToTile(bounds.south, bounds.west, z);
|
||||
const maxTile = this.latLngToTile(bounds.north, bounds.east, z);
|
||||
|
||||
for (let x = minTile.x; x <= maxTile.x; x++) {
|
||||
for (let y = maxTile.y; y <= minTile.y; y++) {
|
||||
tiles.push({ z, x, y });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert lat/lng to tile coordinates
|
||||
*/
|
||||
private latLngToTile(lat: number, lng: number, zoom: number): { x: number; y: number } {
|
||||
const n = Math.pow(2, zoom);
|
||||
const x = Math.floor(((lng + 180) / 360) * n);
|
||||
const y = Math.floor(
|
||||
((1 - Math.log(Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)) / Math.PI) / 2) * n
|
||||
);
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
|
||||
export default TileService;
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
export { RoutingService } from './RoutingService';
|
||||
export { TileService, DEFAULT_TILE_SOURCES } from './TileService';
|
||||
export type { TileSource } from './TileService';
|
||||
export { OptimizationService } from './OptimizationService';
|
||||
export type {
|
||||
OptimizationJob,
|
||||
OptimizationConstraints,
|
||||
OptimizationResult,
|
||||
OptimizationCost,
|
||||
CostParameters,
|
||||
} from './OptimizationService';
|
||||
Loading…
Reference in New Issue