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 }