From 7a471b0e3797e05555306775d1458480c12e15db Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 4 Dec 2025 06:30:50 -0800 Subject: [PATCH 1/3] fix: properly validate tldraw fractional indexing format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous validation allowed "b1" which is invalid because 'b' prefix expects 2-digit integers (10-99), not 1-digit. This caused ValidationError when selecting old format content. Now validates that: - 'a' prefix: 1 digit (a0-a9) - 'b' prefix: 2 digits (b10-b99) - 'c' prefix: 3 digits (c100-c999) - etc. Invalid indices are converted to 'a1' as a safe default. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- OPEN_MAPPING_PROJECT.md | 336 ++++++++++++ open-mapping.docker-compose.yml | 160 ++++++ open-mapping.setup.sh | 138 +++++ src/automerge/AutomergeToTLStore.ts | 47 +- src/automerge/MinimalSanitization.ts | 39 +- .../services/OptimizationService.ts | 325 +++++++++++ src/open-mapping/services/RoutingService.ts | 517 ++++++++++++++++++ src/open-mapping/services/TileService.ts | 228 ++++++++ src/open-mapping/services/index.ts | 11 + src/routes/Board.tsx | 42 +- 10 files changed, 1812 insertions(+), 31 deletions(-) create mode 100644 OPEN_MAPPING_PROJECT.md create mode 100644 open-mapping.docker-compose.yml create mode 100644 open-mapping.setup.sh create mode 100644 src/open-mapping/services/OptimizationService.ts create mode 100644 src/open-mapping/services/RoutingService.ts create mode 100644 src/open-mapping/services/TileService.ts create mode 100644 src/open-mapping/services/index.ts diff --git a/OPEN_MAPPING_PROJECT.md b/OPEN_MAPPING_PROJECT.md new file mode 100644 index 0000000..dbce209 --- /dev/null +++ b/OPEN_MAPPING_PROJECT.md @@ -0,0 +1,336 @@ +# 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/) diff --git a/open-mapping.docker-compose.yml b/open-mapping.docker-compose.yml new file mode 100644 index 0000000..9f2bcd1 --- /dev/null +++ b/open-mapping.docker-compose.yml @@ -0,0 +1,160 @@ +# 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: diff --git a/open-mapping.setup.sh b/open-mapping.setup.sh new file mode 100644 index 0000000..eb87ed1 --- /dev/null +++ b/open-mapping.setup.sh @@ -0,0 +1,138 @@ +#!/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" diff --git a/src/automerge/AutomergeToTLStore.ts b/src/automerge/AutomergeToTLStore.ts index d0dd894..6c90a35 100644 --- a/src/automerge/AutomergeToTLStore.ts +++ b/src/automerge/AutomergeToTLStore.ts @@ -3,28 +3,45 @@ import * as Automerge from "@automerge/automerge" // Helper function to validate if a string is a valid tldraw IndexKey // tldraw uses fractional indexing based on https://observablehq.com/@dgreensp/implementing-fractional-indexing -// Valid indices have an integer part (letter indicating length) followed by digits and optional alphanumeric fraction -// Examples: "a0", "a1", "a1V", "a24sT", "a1V4rr" -// Invalid: "b1" (old format), simple sequential numbers +// The first letter encodes integer part length: a=1 digit, b=2 digits, c=3 digits, etc. +// Examples: "a0"-"a9", "b10"-"b99", "c100"-"c999", with optional fraction "a1V4rr" +// Invalid: "b1" (b expects 2 digits but has 1), simple sequential numbers function isValidIndexKey(index: string): boolean { if (!index || typeof index !== 'string' || index.length === 0) { return false } - // tldraw uses fractional indexing where: - // - First character is a lowercase letter indicating integer part length (a=1, b=2, c=3, etc.) - // - Followed by alphanumeric characters for the value and optional jitter - // Examples: "a0", "a1", "b10", "b99", "c100", "a1V4rr", "b10Lz" - // - // Also uppercase letters for negative indices (Z=1, Y=2, etc.) - - // Valid fractional index: lowercase letter followed by alphanumeric characters - if (/^[a-z][a-zA-Z0-9]+$/.test(index)) { - return true + // Must start with a letter + if (!/^[a-zA-Z]/.test(index)) { + return false } - // Also allow uppercase prefix for negative/very high indices - if (/^[A-Z][a-zA-Z0-9]+$/.test(index)) { + const prefix = index[0] + const rest = index.slice(1) + + // For lowercase prefixes, validate digit count matches the prefix + if (prefix >= 'a' && prefix <= 'z') { + // Calculate expected minimum digit count: a=1, b=2, c=3, etc. + const expectedDigits = prefix.charCodeAt(0) - 'a'.charCodeAt(0) + 1 + + // Extract the integer part (leading digits) + const integerMatch = rest.match(/^(\d+)/) + if (!integerMatch) { + // No digits at all - invalid + return false + } + + const integerPart = integerMatch[1] + + // Check if integer part has correct number of digits for the prefix + if (integerPart.length < expectedDigits) { + // Invalid: "b1" has b (expects 2 digits) but only has 1 digit + return false + } + } + + // Check overall format: letter followed by alphanumeric + if (/^[a-zA-Z][a-zA-Z0-9]+$/.test(index)) { return true } diff --git a/src/automerge/MinimalSanitization.ts b/src/automerge/MinimalSanitization.ts index 1613e85..904ba5d 100644 --- a/src/automerge/MinimalSanitization.ts +++ b/src/automerge/MinimalSanitization.ts @@ -21,19 +21,36 @@ function minimalSanitizeRecord(record: any): any { if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1 if (!sanitized.meta || typeof sanitized.meta !== 'object') sanitized.meta = {} // NOTE: Index assignment is handled by assignSequentialIndices() during format conversion - // Here we only ensure index exists with a valid format, not strictly validate - // This preserves layer order that was established during conversion - // tldraw uses fractional indexing: a0, a1, b10, c100, a1V4rr, etc. - // - First letter (a-z) indicates integer part length (a=1 digit, b=2 digits, etc.) - // - Uppercase (A-Z) for negative/special indices + // Here we validate using tldraw's fractional indexing rules + // The first letter encodes integer part length: a=1 digit, b=2 digits, c=3 digits, etc. + // Examples: "a0"-"a9", "b10"-"b99", "c100"-"c999", with optional fraction "a1V4rr" + // Invalid: "b1" (b expects 2 digits but has 1) if (!sanitized.index || typeof sanitized.index !== 'string' || sanitized.index.length === 0) { - // Only assign default if truly missing - sanitized.index = 'a1' - } else if (!/^[a-zA-Z][a-zA-Z0-9]+$/.test(sanitized.index)) { - // Accept any letter followed by alphanumeric characters - // Only reset clearly invalid formats (e.g., numbers, empty, single char) - console.warn(`⚠️ MinimalSanitization: Invalid index format "${sanitized.index}" for shape ${sanitized.id}`) sanitized.index = 'a1' + } else { + // Validate fractional indexing format + let isValid = false + const prefix = sanitized.index[0] + const rest = sanitized.index.slice(1) + + if (/^[a-zA-Z]/.test(sanitized.index) && /^[a-zA-Z][a-zA-Z0-9]+$/.test(sanitized.index)) { + if (prefix >= 'a' && prefix <= 'z') { + // Calculate expected minimum digit count: a=1, b=2, c=3, etc. + const expectedDigits = prefix.charCodeAt(0) - 'a'.charCodeAt(0) + 1 + const integerMatch = rest.match(/^(\d+)/) + if (integerMatch && integerMatch[1].length >= expectedDigits) { + isValid = true + } + } else if (prefix >= 'A' && prefix <= 'Z') { + // Uppercase for negative/special indices - allow + isValid = true + } + } + + if (!isValid) { + console.warn(`⚠️ MinimalSanitization: Invalid index format "${sanitized.index}" for shape ${sanitized.id}`) + sanitized.index = 'a1' + } } if (!sanitized.parentId) sanitized.parentId = 'page:page' diff --git a/src/open-mapping/services/OptimizationService.ts b/src/open-mapping/services/OptimizationService.ts new file mode 100644 index 0000000..0c67419 --- /dev/null +++ b/src/open-mapping/services/OptimizationService.ts @@ -0,0 +1,325 @@ +/** + * 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 { + 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 { + // 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 + ): 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): void { + this.costParams = { ...this.costParams, ...params }; + } + + // ========================================================================= + // Private Methods + // ========================================================================= + + private async optimizeWithVROOM( + waypoints: Waypoint[], + constraints?: OptimizationConstraints + ): Promise { + // 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; diff --git a/src/open-mapping/services/RoutingService.ts b/src/open-mapping/services/RoutingService.ts new file mode 100644 index 0000000..1d80f13 --- /dev/null +++ b/src/open-mapping/services/RoutingService.ts @@ -0,0 +1,517 @@ +/** + * 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 + ): Promise { + const profile = options?.profile ?? 'car'; + const coordinates = waypoints.map((w) => w.coordinate); + + switch (this.config.provider) { + case 'osrm': + return this.calculateOSRMRoute(coordinates, profile, options); + case 'valhalla': + return this.calculateValhallaRoute(coordinates, profile, options); + 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 { + // 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 { + 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 { + // 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 + ): Promise { + 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 + ): Promise { + 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 + ): Promise { + 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 + ): Promise { + 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 = { + '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 { + 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 { + const body = { + locations: [[center.lng, center.lat]], + range: minutes.map((m) => m * 60), // ORS uses seconds + range_type: 'time', + }; + + const headers: Record = { + '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> = { + car: 'driving', + bicycle: 'cycling', + foot: 'walking', + hiking: 'walking', + }; + return mapping[profile] ?? 'driving'; + } + + private mapProfileToValhalla(profile: RoutingProfile): string { + const mapping: Partial> = { + 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> = { + 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> = { + 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; diff --git a/src/open-mapping/services/TileService.ts b/src/open-mapping/services/TileService.ts new file mode 100644 index 0000000..dbf15f1 --- /dev/null +++ b/src/open-mapping/services/TileService.ts @@ -0,0 +1,228 @@ +/** + * 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: '© OpenStreetMap 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 Stamen Design', + maxZoom: 18, + }, + { + id: 'cycling', + name: 'Cycling Routes', + type: 'raster', + url: 'https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png', + attribution: 'Waymarked Trails', + maxZoom: 18, + }, + { + id: 'hiking', + name: 'Hiking Trails', + type: 'raster', + url: 'https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png', + attribution: 'Waymarked Trails', + 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 { + 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 { + 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 { + if ('caches' in window) { + await caches.delete(this.cacheName); + this.cache = await caches.open(this.cacheName); + } + } + + /** + * Get cache size estimate + */ + async getCacheSize(): Promise { + 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; diff --git a/src/open-mapping/services/index.ts b/src/open-mapping/services/index.ts new file mode 100644 index 0000000..b866ac0 --- /dev/null +++ b/src/open-mapping/services/index.ts @@ -0,0 +1,11 @@ +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'; diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 4fc7ddc..cff42b6 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -68,18 +68,50 @@ import "@/css/style.css" import "@/css/obsidian-browser.css" // Helper to validate and fix tldraw IndexKey format -// tldraw uses fractional indexing: a0, a1, b10, c100, a1V4rr, etc. -// - First letter (a-z) indicates integer part length (a=1 digit, b=2 digits, etc.) -// - Uppercase (A-Z) for negative/special indices +// tldraw uses fractional indexing where the first letter encodes integer part length: +// - 'a' = 1-digit integer (a0-a9), 'b' = 2-digit (b10-b99), 'c' = 3-digit (c100-c999), etc. +// - Optional fractional part can follow (a1V, a1V4rr, etc.) +// Common invalid formats from old data: "b1" (b expects 2 digits but has 1) function sanitizeIndex(index: any): IndexKey { if (!index || typeof index !== 'string' || index.length === 0) { return 'a1' as IndexKey } - // Valid: letter followed by alphanumeric characters + + // Must start with a letter + if (!/^[a-zA-Z]/.test(index)) { + return 'a1' as IndexKey + } + + // Check fractional indexing rules for lowercase prefixes + const prefix = index[0] + const rest = index.slice(1) + + if (prefix >= 'a' && prefix <= 'z') { + // Calculate expected minimum digit count: a=1, b=2, c=3, etc. + const expectedDigits = prefix.charCodeAt(0) - 'a'.charCodeAt(0) + 1 + + // Extract the integer part (leading digits) + const integerMatch = rest.match(/^(\d+)/) + if (!integerMatch) { + // No digits at all - invalid + return 'a1' as IndexKey + } + + const integerPart = integerMatch[1] + + // Check if integer part has correct number of digits for the prefix + if (integerPart.length < expectedDigits) { + // Invalid: "b1" has b (expects 2 digits) but only has 1 digit + // Convert to safe format + return 'a1' as IndexKey + } + } + + // Check overall format: letter followed by alphanumeric if (/^[a-zA-Z][a-zA-Z0-9]+$/.test(index)) { return index as IndexKey } - // Fallback for invalid formats + return 'a1' as IndexKey } From 8c90727b93a1b1a9b684be0b6c476aeecc048a37 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 4 Dec 2025 06:30:57 -0800 Subject: [PATCH 2/3] Create task task-024 --- ...ing-Collaborative-Route-Planning-Module.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 backlog/tasks/task-024 - Open-Mapping-Collaborative-Route-Planning-Module.md diff --git a/backlog/tasks/task-024 - Open-Mapping-Collaborative-Route-Planning-Module.md b/backlog/tasks/task-024 - Open-Mapping-Collaborative-Route-Planning-Module.md new file mode 100644 index 0000000..3702ea1 --- /dev/null +++ b/backlog/tasks/task-024 - Open-Mapping-Collaborative-Route-Planning-Module.md @@ -0,0 +1,63 @@ +--- +id: task-024 +title: 'Open Mapping: Collaborative Route Planning Module' +status: To Do +assignee: [] +created_date: '2025-12-04 14:30' +labels: + - feature + - mapping +dependencies: [] +priority: high +--- + +## Description + + +Implement an open-source mapping and routing layer for the canvas that provides advanced route planning capabilities beyond Google Maps. Built on OpenStreetMap, OSRM/Valhalla, and MapLibre GL JS. + + +## Acceptance Criteria + +- [ ] #1 MapLibre GL JS integrated with tldraw canvas +- [ ] #2 OSRM routing backend deployed to Netcup +- [ ] #3 Waypoint placement and route calculation working +- [ ] #4 Multi-route comparison UI implemented +- [ ] #5 Y.js collaboration for shared route editing +- [ ] #6 Layer management panel with basemap switching +- [ ] #7 Offline tile caching via Service Worker +- [ ] #8 Budget tracking per waypoint/route + + +## Implementation Plan + + +Phase 1 - Foundation: +- Integrate MapLibre GL JS with tldraw +- Deploy OSRM to /opt/apps/open-mapping/ +- Basic waypoint and route UI + +Phase 2 - Multi-Route: +- Alternative routes visualization +- Route comparison panel +- Elevation profiles + +Phase 3 - Collaboration: +- Y.js integration +- Real-time cursor presence +- Share links + +Phase 4 - Layers: +- Layer panel UI +- Multiple basemaps +- Custom overlays + +Phase 5 - Calendar/Budget: +- Time windows on waypoints +- Cost estimation +- iCal export + +Phase 6 - Optimization: +- VROOM TSP/VRP +- Offline PWA + From 966e1855c19b17d91aad554008ee8d508cbc913d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 4 Dec 2025 06:45:27 -0800 Subject: [PATCH 3/3] chore: remove open-mapping files (should be on feature branch) --- OPEN_MAPPING_PROJECT.md | 336 ------------ open-mapping.docker-compose.yml | 160 ------ open-mapping.setup.sh | 138 ----- .../services/OptimizationService.ts | 325 ----------- src/open-mapping/services/RoutingService.ts | 517 ------------------ src/open-mapping/services/TileService.ts | 228 -------- src/open-mapping/services/index.ts | 11 - 7 files changed, 1715 deletions(-) delete mode 100644 OPEN_MAPPING_PROJECT.md delete mode 100644 open-mapping.docker-compose.yml delete mode 100644 open-mapping.setup.sh delete mode 100644 src/open-mapping/services/OptimizationService.ts delete mode 100644 src/open-mapping/services/RoutingService.ts delete mode 100644 src/open-mapping/services/TileService.ts delete mode 100644 src/open-mapping/services/index.ts diff --git a/OPEN_MAPPING_PROJECT.md b/OPEN_MAPPING_PROJECT.md deleted file mode 100644 index dbce209..0000000 --- a/OPEN_MAPPING_PROJECT.md +++ /dev/null @@ -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/) diff --git a/open-mapping.docker-compose.yml b/open-mapping.docker-compose.yml deleted file mode 100644 index 9f2bcd1..0000000 --- a/open-mapping.docker-compose.yml +++ /dev/null @@ -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: diff --git a/open-mapping.setup.sh b/open-mapping.setup.sh deleted file mode 100644 index eb87ed1..0000000 --- a/open-mapping.setup.sh +++ /dev/null @@ -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" diff --git a/src/open-mapping/services/OptimizationService.ts b/src/open-mapping/services/OptimizationService.ts deleted file mode 100644 index 0c67419..0000000 --- a/src/open-mapping/services/OptimizationService.ts +++ /dev/null @@ -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 { - 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 { - // 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 - ): 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): void { - this.costParams = { ...this.costParams, ...params }; - } - - // ========================================================================= - // Private Methods - // ========================================================================= - - private async optimizeWithVROOM( - waypoints: Waypoint[], - constraints?: OptimizationConstraints - ): Promise { - // 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; diff --git a/src/open-mapping/services/RoutingService.ts b/src/open-mapping/services/RoutingService.ts deleted file mode 100644 index 1d80f13..0000000 --- a/src/open-mapping/services/RoutingService.ts +++ /dev/null @@ -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 - ): Promise { - const profile = options?.profile ?? 'car'; - const coordinates = waypoints.map((w) => w.coordinate); - - switch (this.config.provider) { - case 'osrm': - return this.calculateOSRMRoute(coordinates, profile, options); - case 'valhalla': - return this.calculateValhallaRoute(coordinates, profile, options); - 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 { - // 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 { - 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 { - // 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 - ): Promise { - 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 - ): Promise { - 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 - ): Promise { - 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 - ): Promise { - 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 = { - '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 { - 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 { - const body = { - locations: [[center.lng, center.lat]], - range: minutes.map((m) => m * 60), // ORS uses seconds - range_type: 'time', - }; - - const headers: Record = { - '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> = { - car: 'driving', - bicycle: 'cycling', - foot: 'walking', - hiking: 'walking', - }; - return mapping[profile] ?? 'driving'; - } - - private mapProfileToValhalla(profile: RoutingProfile): string { - const mapping: Partial> = { - 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> = { - 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> = { - 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; diff --git a/src/open-mapping/services/TileService.ts b/src/open-mapping/services/TileService.ts deleted file mode 100644 index dbf15f1..0000000 --- a/src/open-mapping/services/TileService.ts +++ /dev/null @@ -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: '© OpenStreetMap 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 Stamen Design', - maxZoom: 18, - }, - { - id: 'cycling', - name: 'Cycling Routes', - type: 'raster', - url: 'https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png', - attribution: 'Waymarked Trails', - maxZoom: 18, - }, - { - id: 'hiking', - name: 'Hiking Trails', - type: 'raster', - url: 'https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png', - attribution: 'Waymarked Trails', - 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 { - 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 { - 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 { - if ('caches' in window) { - await caches.delete(this.cacheName); - this.cache = await caches.open(this.cacheName); - } - } - - /** - * Get cache size estimate - */ - async getCacheSize(): Promise { - 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; diff --git a/src/open-mapping/services/index.ts b/src/open-mapping/services/index.ts deleted file mode 100644 index b866ac0..0000000 --- a/src/open-mapping/services/index.ts +++ /dev/null @@ -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';