feat: add open-mapping collaborative route planning module
Introduces a comprehensive mapping and routing layer for the canvas that provides advanced route planning capabilities beyond Google Maps. Built on open-source foundations: - OpenStreetMap for base map data - OSRM/Valhalla for routing engines - MapLibre GL JS for map rendering - VROOM for route optimization - Y.js for real-time collaboration Features planned: - Multi-path routing with alternatives comparison - Real-time collaborative waypoint editing - Layer management (basemaps, overlays, custom GeoJSON) - Calendar/scheduling integration - Budget tracking per waypoint/route - Offline tile caching via PWA Includes: - TypeScript types for routes, waypoints, layers - React hooks for map instance, routing, collaboration - Service abstractions for multiple routing providers - Docker Compose config for backend deployment - Setup script for OSRM data preparation Backlog task: task-024 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
068ff7d3be
commit
4f1a6d1314
|
|
@ -0,0 +1,139 @@
|
|||
# Open Mapping Project
|
||||
|
||||
## Overview
|
||||
|
||||
**Open Mapping** is a collaborative route planning module for canvas-website that provides advanced mapping functionality beyond traditional tools like Google Maps. Built on open-source foundations (OpenStreetMap, OSRM, Valhalla, MapLibre), it integrates seamlessly with the tldraw canvas environment.
|
||||
|
||||
## Vision
|
||||
|
||||
Create a "living map" that exists as a layer within the collaborative canvas, enabling teams to:
|
||||
- Plan multi-destination trips with optimized routing
|
||||
- Compare alternative routes visually
|
||||
- Share and collaborate on itineraries in real-time
|
||||
- Track budgets and schedules alongside geographic planning
|
||||
- Work offline with cached map data
|
||||
|
||||
## Core Features
|
||||
|
||||
### 1. Map Canvas Integration
|
||||
- MapLibre GL JS as the rendering engine
|
||||
- Seamless embedding within tldraw canvas
|
||||
- Pan/zoom synchronized with canvas viewport
|
||||
|
||||
### 2. Multi-Path Routing
|
||||
- Support for multiple routing profiles (car, bike, foot, transit)
|
||||
- Side-by-side route comparison
|
||||
- Alternative route suggestions
|
||||
- Turn-by-turn directions with elevation profiles
|
||||
|
||||
### 3. Collaborative Editing
|
||||
- Real-time waypoint sharing via Y.js/CRDT
|
||||
- Cursor presence on map
|
||||
- Concurrent route editing without conflicts
|
||||
- Share links for view-only or edit access
|
||||
|
||||
### 4. Layer Management
|
||||
- Multiple basemap options (OSM, satellite, terrain)
|
||||
- Custom overlay layers (GeoJSON import)
|
||||
- Route-specific layers (cycling, hiking trails)
|
||||
|
||||
### 5. Calendar Integration
|
||||
- Attach time windows to waypoints
|
||||
- Visualize itinerary timeline
|
||||
- Sync with external calendars (iCal export)
|
||||
|
||||
### 6. Budget Tracking
|
||||
- Cost estimates per route (fuel, tolls)
|
||||
- Per-waypoint expense tracking
|
||||
- Trip budget aggregation
|
||||
|
||||
### 7. Offline Capability
|
||||
- Tile caching for offline use
|
||||
- Route pre-computation and storage
|
||||
- PWA support
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Component | Technology | License |
|
||||
|-----------|------------|---------|
|
||||
| Map Renderer | MapLibre GL JS | BSD-3 |
|
||||
| Base Maps | OpenStreetMap | ODbL |
|
||||
| Routing Engine | OSRM / Valhalla | BSD-2 / MIT |
|
||||
| Optimization | VROOM | BSD |
|
||||
| Collaboration | Y.js | MIT |
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Foundation (MVP)
|
||||
- [ ] MapLibre GL JS integration with tldraw
|
||||
- [ ] Basic waypoint placement and rendering
|
||||
- [ ] Single-route calculation via OSRM
|
||||
- [ ] Route polyline display
|
||||
|
||||
### Phase 2: Multi-Route & Comparison
|
||||
- [ ] Alternative routes visualization
|
||||
- [ ] Route comparison panel
|
||||
- [ ] Elevation profile display
|
||||
- [ ] Drag-to-reroute functionality
|
||||
|
||||
### Phase 3: Collaboration
|
||||
- [ ] Y.js integration for real-time sync
|
||||
- [ ] Cursor presence on map
|
||||
- [ ] Share link generation
|
||||
|
||||
### Phase 4: Layers & Customization
|
||||
- [ ] Layer panel UI
|
||||
- [ ] Multiple basemap options
|
||||
- [ ] Overlay layer support
|
||||
|
||||
### Phase 5: Calendar & Budget
|
||||
- [ ] Time window attachment
|
||||
- [ ] Budget tracking per waypoint
|
||||
- [ ] iCal export
|
||||
|
||||
### Phase 6: Optimization & Offline
|
||||
- [ ] VROOM integration for TSP/VRP
|
||||
- [ ] Tile caching via Service Worker
|
||||
- [ ] PWA manifest
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/open-mapping/
|
||||
├── index.ts # Public exports
|
||||
├── types/index.ts # TypeScript definitions
|
||||
├── components/
|
||||
│ ├── MapCanvas.tsx # Main map component
|
||||
│ ├── RouteLayer.tsx # Route rendering
|
||||
│ ├── WaypointMarker.tsx # Interactive markers
|
||||
│ └── LayerPanel.tsx # Layer management UI
|
||||
├── hooks/
|
||||
│ ├── useMapInstance.ts # MapLibre instance
|
||||
│ ├── useRouting.ts # Route calculation
|
||||
│ ├── useCollaboration.ts # Y.js sync
|
||||
│ └── useLayers.ts # Layer state
|
||||
├── services/
|
||||
│ ├── RoutingService.ts # Multi-provider routing
|
||||
│ ├── TileService.ts # Tile management
|
||||
│ └── OptimizationService.ts # VROOM integration
|
||||
└── utils/index.ts # Helper functions
|
||||
```
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
Backend services deploy to `/opt/apps/open-mapping/` on Netcup RS 8000:
|
||||
|
||||
- **OSRM** - Primary routing engine
|
||||
- **Valhalla** - Extended routing with transit/isochrones
|
||||
- **TileServer GL** - Vector tiles
|
||||
- **VROOM** - Route optimization
|
||||
|
||||
See `open-mapping.docker-compose.yml` for full configuration.
|
||||
|
||||
## References
|
||||
|
||||
- [OSRM Documentation](https://project-osrm.org/docs/v5.24.0/api/)
|
||||
- [Valhalla API](https://valhalla.github.io/valhalla/api/)
|
||||
- [MapLibre GL JS](https://maplibre.org/maplibre-gl-js-docs/api/)
|
||||
- [VROOM Project](http://vroom-project.org/)
|
||||
- [Y.js Documentation](https://docs.yjs.dev/)
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
# Open Mapping Backend Services
|
||||
# Deploy to: /opt/apps/open-mapping/ on Netcup RS 8000
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# OSRM - Open Source Routing Machine
|
||||
osrm:
|
||||
image: osrm/osrm-backend:v5.27.1
|
||||
container_name: open-mapping-osrm
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data/osrm:/data:ro
|
||||
command: osrm-routed --algorithm mld /data/germany-latest.osrm --max-table-size 10000
|
||||
ports:
|
||||
- "5000:5000"
|
||||
networks:
|
||||
- traefik-public
|
||||
- open-mapping-internal
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.osrm.rule=Host(`routing.jeffemmett.com`) && PathPrefix(`/osrm`)"
|
||||
- "traefik.http.routers.osrm.middlewares=osrm-stripprefix"
|
||||
- "traefik.http.middlewares.osrm-stripprefix.stripprefix.prefixes=/osrm"
|
||||
- "traefik.http.services.osrm.loadbalancer.server.port=5000"
|
||||
|
||||
# Valhalla - Extended Routing
|
||||
valhalla:
|
||||
image: ghcr.io/gis-ops/docker-valhalla/valhalla:latest
|
||||
container_name: open-mapping-valhalla
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data/valhalla:/custom_files
|
||||
environment:
|
||||
- tile_urls=https://download.geofabrik.de/europe/germany-latest.osm.pbf
|
||||
- use_tiles_ignore_pbf=True
|
||||
- build_elevation=True
|
||||
- build_admins=True
|
||||
- build_time_zones=True
|
||||
ports:
|
||||
- "8002:8002"
|
||||
networks:
|
||||
- traefik-public
|
||||
- open-mapping-internal
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.valhalla.rule=Host(`routing.jeffemmett.com`) && PathPrefix(`/valhalla`)"
|
||||
- "traefik.http.services.valhalla.loadbalancer.server.port=8002"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 8G
|
||||
|
||||
# TileServer GL - Vector Tiles
|
||||
tileserver:
|
||||
image: maptiler/tileserver-gl:v4.6.5
|
||||
container_name: open-mapping-tiles
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data/tiles:/data:ro
|
||||
ports:
|
||||
- "8080:8080"
|
||||
networks:
|
||||
- traefik-public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.tiles.rule=Host(`tiles.jeffemmett.com`)"
|
||||
- "traefik.http.services.tiles.loadbalancer.server.port=8080"
|
||||
|
||||
# VROOM - Route Optimization
|
||||
vroom:
|
||||
image: vroomvrp/vroom-docker:v1.14.0
|
||||
container_name: open-mapping-vroom
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- VROOM_ROUTER=osrm
|
||||
- OSRM_URL=http://osrm:5000
|
||||
ports:
|
||||
- "3000:3000"
|
||||
networks:
|
||||
- traefik-public
|
||||
- open-mapping-internal
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.vroom.rule=Host(`routing.jeffemmett.com`) && PathPrefix(`/optimize`)"
|
||||
- "traefik.http.services.vroom.loadbalancer.server.port=3000"
|
||||
depends_on:
|
||||
- osrm
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
open-mapping-internal:
|
||||
driver: bridge
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
#!/bin/bash
|
||||
# Open Mapping Backend Setup Script
|
||||
# Run on Netcup RS 8000 to prepare routing data
|
||||
|
||||
set -e
|
||||
|
||||
REGION=${1:-germany}
|
||||
DATA_DIR="/opt/apps/open-mapping/data"
|
||||
|
||||
echo "=== Open Mapping Setup ==="
|
||||
echo "Region: $REGION"
|
||||
|
||||
mkdir -p "$DATA_DIR/osrm" "$DATA_DIR/valhalla" "$DATA_DIR/tiles"
|
||||
cd "$DATA_DIR"
|
||||
|
||||
# Download OSM data
|
||||
case $REGION in
|
||||
germany) OSM_URL="https://download.geofabrik.de/europe/germany-latest.osm.pbf"; OSM_FILE="germany-latest.osm.pbf" ;;
|
||||
europe) OSM_URL="https://download.geofabrik.de/europe-latest.osm.pbf"; OSM_FILE="europe-latest.osm.pbf" ;;
|
||||
*) echo "Unknown region: $REGION"; exit 1 ;;
|
||||
esac
|
||||
|
||||
[ ! -f "osrm/$OSM_FILE" ] && wget -O "osrm/$OSM_FILE" "$OSM_URL"
|
||||
|
||||
# Process OSRM data
|
||||
cd osrm
|
||||
[ ! -f "${OSM_FILE%.osm.pbf}.osrm" ] && docker run -t -v "${PWD}:/data" osrm/osrm-backend:v5.27.1 osrm-extract -p /opt/car.lua /data/$OSM_FILE
|
||||
[ ! -f "${OSM_FILE%.osm.pbf}.osrm.partition" ] && docker run -t -v "${PWD}:/data" osrm/osrm-backend:v5.27.1 osrm-partition /data/${OSM_FILE%.osm.pbf}.osrm
|
||||
[ ! -f "${OSM_FILE%.osm.pbf}.osrm.mldgr" ] && docker run -t -v "${PWD}:/data" osrm/osrm-backend:v5.27.1 osrm-customize /data/${OSM_FILE%.osm.pbf}.osrm
|
||||
|
||||
echo "=== Setup Complete ==="
|
||||
echo "Next: docker compose up -d"
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* LayerPanel - UI for managing map layers
|
||||
*/
|
||||
|
||||
import type { MapLayer } from '../types';
|
||||
|
||||
interface LayerPanelProps {
|
||||
layers: MapLayer[];
|
||||
onLayerToggle?: (layerId: string, visible: boolean) => void;
|
||||
onLayerOpacity?: (layerId: string, opacity: number) => void;
|
||||
onLayerReorder?: (layerIds: string[]) => void;
|
||||
onLayerAdd?: (layer: Omit<MapLayer, 'id'>) => void;
|
||||
onLayerRemove?: (layerId: string) => void;
|
||||
}
|
||||
|
||||
export function LayerPanel({ layers, onLayerToggle }: LayerPanelProps) {
|
||||
return (
|
||||
<div className="open-mapping-layer-panel">
|
||||
<h3>Layers</h3>
|
||||
<ul>
|
||||
{layers.map((layer) => (
|
||||
<li key={layer.id}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={layer.visible}
|
||||
onChange={(e) => onLayerToggle?.(layer.id, e.target.checked)}
|
||||
/>
|
||||
{layer.name}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LayerPanel;
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* MapCanvas - Main map component integrating with tldraw canvas
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { MapViewport, MapLayer, Coordinate } from '../types';
|
||||
|
||||
interface MapCanvasProps {
|
||||
viewport: MapViewport;
|
||||
layers: MapLayer[];
|
||||
onViewportChange?: (viewport: MapViewport) => void;
|
||||
onMapClick?: (coordinate: Coordinate) => void;
|
||||
onMapLoad?: () => void;
|
||||
style?: string;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export function MapCanvas({
|
||||
viewport,
|
||||
layers,
|
||||
onViewportChange,
|
||||
onMapClick,
|
||||
onMapLoad,
|
||||
style = 'https://demotiles.maplibre.org/style.json',
|
||||
interactive = true,
|
||||
}: MapCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: Initialize MapLibre GL JS instance (Phase 1)
|
||||
console.log('MapCanvas: Initializing with viewport', viewport);
|
||||
return () => { /* Cleanup */ };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="open-mapping-canvas"
|
||||
style={{ width: '100%', height: '100%', position: 'relative' }}
|
||||
>
|
||||
{!isLoaded && <div className="open-mapping-loading">Loading map...</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapCanvas;
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* RouteLayer - Renders route polylines on the map
|
||||
*/
|
||||
|
||||
import type { Route, RoutingProfile } from '../types';
|
||||
|
||||
interface RouteLayerProps {
|
||||
routes: Route[];
|
||||
selectedRouteId?: string;
|
||||
showAlternatives?: boolean;
|
||||
showElevation?: boolean;
|
||||
onRouteSelect?: (routeId: string) => void;
|
||||
onRouteEdit?: (routeId: string, geometry: GeoJSON.LineString) => void;
|
||||
profileColors?: Partial<Record<RoutingProfile, string>>;
|
||||
}
|
||||
|
||||
const DEFAULT_PROFILE_COLORS: Record<RoutingProfile, string> = {
|
||||
car: '#3B82F6', truck: '#6366F1', motorcycle: '#8B5CF6',
|
||||
bicycle: '#10B981', mountain_bike: '#059669', road_bike: '#14B8A6',
|
||||
foot: '#F59E0B', hiking: '#D97706', wheelchair: '#EC4899', transit: '#6B7280',
|
||||
};
|
||||
|
||||
export function RouteLayer({ routes, selectedRouteId, profileColors = {} }: RouteLayerProps) {
|
||||
// TODO: Implement route rendering (Phase 2)
|
||||
return null;
|
||||
}
|
||||
|
||||
export default RouteLayer;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* WaypointMarker - Interactive waypoint markers
|
||||
*/
|
||||
|
||||
import type { Waypoint } from '../types';
|
||||
|
||||
interface WaypointMarkerProps {
|
||||
waypoint: Waypoint;
|
||||
index?: number;
|
||||
isSelected?: boolean;
|
||||
isDraggable?: boolean;
|
||||
onSelect?: (waypointId: string) => void;
|
||||
onDragEnd?: (waypointId: string, newCoordinate: { lat: number; lng: number }) => void;
|
||||
onDelete?: (waypointId: string) => void;
|
||||
}
|
||||
|
||||
export function WaypointMarker({ waypoint, isSelected = false }: WaypointMarkerProps) {
|
||||
// TODO: Implement marker rendering (Phase 1)
|
||||
return null;
|
||||
}
|
||||
|
||||
export default WaypointMarker;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export { MapCanvas } from './MapCanvas';
|
||||
export { RouteLayer } from './RouteLayer';
|
||||
export { WaypointMarker } from './WaypointMarker';
|
||||
export { LayerPanel } from './LayerPanel';
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export { useMapInstance } from './useMapInstance';
|
||||
export { useRouting } from './useRouting';
|
||||
export { useCollaboration } from './useCollaboration';
|
||||
export { useLayers } from './useLayers';
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* useCollaboration - Hook for real-time collaborative map editing via Y.js
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { CollaborationSession, Participant, Route, Waypoint, MapLayer, Coordinate } from '../types';
|
||||
|
||||
interface UseCollaborationOptions {
|
||||
sessionId?: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
userColor?: string;
|
||||
serverUrl?: string;
|
||||
onParticipantJoin?: (participant: Participant) => void;
|
||||
onParticipantLeave?: (participantId: string) => void;
|
||||
}
|
||||
|
||||
export function useCollaboration({
|
||||
sessionId, userId, userName, userColor = '#3B82F6', serverUrl,
|
||||
}: UseCollaborationOptions) {
|
||||
const [session, setSession] = useState<CollaborationSession | null>(null);
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
// TODO: Initialize Y.js (Phase 3)
|
||||
setIsConnected(true);
|
||||
return () => { setIsConnected(false); };
|
||||
}, [sessionId, serverUrl]);
|
||||
|
||||
const createSession = useCallback(async (name: string) => {
|
||||
const newSessionId = `session-${Date.now()}`;
|
||||
return newSessionId;
|
||||
}, []);
|
||||
|
||||
const joinSession = useCallback(async (sessionIdToJoin: string) => {
|
||||
console.log('Joining session', sessionIdToJoin);
|
||||
}, []);
|
||||
|
||||
const leaveSession = useCallback(() => {
|
||||
setSession(null); setParticipants([]); setIsConnected(false);
|
||||
}, []);
|
||||
|
||||
const updateCursor = useCallback((coordinate: Coordinate) => {}, []);
|
||||
const broadcastRouteChange = useCallback((route: Route) => {}, []);
|
||||
const broadcastWaypointChange = useCallback((waypoint: Waypoint) => {}, []);
|
||||
const broadcastLayerChange = useCallback((layer: MapLayer) => {}, []);
|
||||
|
||||
return {
|
||||
session, participants, isConnected, createSession, joinSession, leaveSession,
|
||||
updateCursor, broadcastRouteChange, broadcastWaypointChange, broadcastLayerChange,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCollaboration;
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* useLayers - Hook for managing map layers
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { MapLayer } from '../types';
|
||||
|
||||
interface UseLayersOptions {
|
||||
initialLayers?: MapLayer[];
|
||||
onLayerChange?: (layers: MapLayer[]) => void;
|
||||
}
|
||||
|
||||
export type LayerPreset = 'osm-standard' | 'osm-humanitarian' | 'satellite' | 'terrain' | 'cycling' | 'hiking';
|
||||
|
||||
const PRESET_LAYERS: Record<LayerPreset, Omit<MapLayer, 'id'>> = {
|
||||
'osm-standard': { name: 'OpenStreetMap', type: 'basemap', visible: true, opacity: 1, zIndex: 0, source: { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], attribution: '© OpenStreetMap' } },
|
||||
'osm-humanitarian': { name: 'Humanitarian', type: 'basemap', visible: false, opacity: 1, zIndex: 0, source: { type: 'raster', tiles: ['https://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png'], attribution: '© OSM, HOT' } },
|
||||
'satellite': { name: 'Satellite', type: 'satellite', visible: false, opacity: 1, zIndex: 0, source: { type: 'raster', tiles: [] } },
|
||||
'terrain': { name: 'Terrain', type: 'terrain', visible: false, opacity: 0.5, zIndex: 1, source: { type: 'raster', tiles: ['https://stamen-tiles.a.ssl.fastly.net/terrain/{z}/{x}/{y}.png'], attribution: 'Stamen' } },
|
||||
'cycling': { name: 'Cycling Routes', type: 'route', visible: false, opacity: 0.8, zIndex: 2, source: { type: 'raster', tiles: ['https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png'], attribution: 'Waymarked Trails' } },
|
||||
'hiking': { name: 'Hiking Trails', type: 'route', visible: false, opacity: 0.8, zIndex: 2, source: { type: 'raster', tiles: ['https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png'], attribution: 'Waymarked Trails' } },
|
||||
};
|
||||
|
||||
let layerIdCounter = 0;
|
||||
|
||||
export function useLayers({ initialLayers = [], onLayerChange }: UseLayersOptions = {}) {
|
||||
const [layers, setLayers] = useState<MapLayer[]>(initialLayers);
|
||||
|
||||
const updateAndNotify = useCallback((newLayers: MapLayer[]) => {
|
||||
setLayers(newLayers);
|
||||
onLayerChange?.(newLayers);
|
||||
}, [onLayerChange]);
|
||||
|
||||
const addLayer = useCallback((layer: Omit<MapLayer, 'id'>) => {
|
||||
const id = `layer-${++layerIdCounter}-${Date.now()}`;
|
||||
updateAndNotify([...layers, { ...layer, id }]);
|
||||
return id;
|
||||
}, [layers, updateAndNotify]);
|
||||
|
||||
const removeLayer = useCallback((layerId: string) => updateAndNotify(layers.filter((l) => l.id !== layerId)), [layers, updateAndNotify]);
|
||||
const updateLayer = useCallback((layerId: string, updates: Partial<MapLayer>) => updateAndNotify(layers.map((l) => l.id === layerId ? { ...l, ...updates } : l)), [layers, updateAndNotify]);
|
||||
const toggleVisibility = useCallback((layerId: string) => updateAndNotify(layers.map((l) => l.id === layerId ? { ...l, visible: !l.visible } : l)), [layers, updateAndNotify]);
|
||||
const setOpacity = useCallback((layerId: string, opacity: number) => updateAndNotify(layers.map((l) => l.id === layerId ? { ...l, opacity: Math.max(0, Math.min(1, opacity)) } : l)), [layers, updateAndNotify]);
|
||||
const reorderLayers = useCallback((layerIds: string[]) => {
|
||||
const reordered = layerIds.map((id, i) => { const l = layers.find((x) => x.id === id); return l ? { ...l, zIndex: i } : null; }).filter((l): l is MapLayer => !!l);
|
||||
updateAndNotify(reordered);
|
||||
}, [layers, updateAndNotify]);
|
||||
const getLayer = useCallback((layerId: string) => layers.find((l) => l.id === layerId), [layers]);
|
||||
const addPresetLayer = useCallback((preset: LayerPreset) => addLayer(PRESET_LAYERS[preset]), [addLayer]);
|
||||
|
||||
return { layers, addLayer, removeLayer, updateLayer, toggleVisibility, setOpacity, reorderLayers, getLayer, addPresetLayer };
|
||||
}
|
||||
|
||||
export default useLayers;
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* useMapInstance - Hook for managing MapLibre GL JS instance
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import type { MapViewport, Coordinate, TileServiceConfig } from '../types';
|
||||
|
||||
interface UseMapInstanceOptions {
|
||||
container: HTMLElement | null;
|
||||
config: TileServiceConfig;
|
||||
initialViewport?: MapViewport;
|
||||
onViewportChange?: (viewport: MapViewport) => void;
|
||||
onClick?: (coordinate: Coordinate) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_VIEWPORT: MapViewport = {
|
||||
center: { lat: 0, lng: 0 }, zoom: 2, bearing: 0, pitch: 0,
|
||||
};
|
||||
|
||||
export function useMapInstance({
|
||||
container, config, initialViewport = DEFAULT_VIEWPORT, onViewportChange,
|
||||
}: UseMapInstanceOptions) {
|
||||
const mapRef = useRef<unknown>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [viewport, setViewportState] = useState<MapViewport>(initialViewport);
|
||||
|
||||
useEffect(() => {
|
||||
if (!container) return;
|
||||
// TODO: Initialize MapLibre GL JS (Phase 1)
|
||||
setIsLoaded(true);
|
||||
return () => { mapRef.current = null; setIsLoaded(false); };
|
||||
}, [container]);
|
||||
|
||||
const setViewport = useCallback((newViewport: MapViewport) => {
|
||||
setViewportState(newViewport);
|
||||
onViewportChange?.(newViewport);
|
||||
}, [onViewportChange]);
|
||||
|
||||
const flyTo = useCallback((coordinate: Coordinate, zoom?: number) => {
|
||||
console.log('flyTo', coordinate, zoom);
|
||||
}, []);
|
||||
|
||||
const fitBounds = useCallback((bounds: [[number, number], [number, number]]) => {
|
||||
console.log('fitBounds', bounds);
|
||||
}, []);
|
||||
|
||||
return { isLoaded, viewport, setViewport, flyTo, fitBounds, getMap: () => mapRef.current };
|
||||
}
|
||||
|
||||
export default useMapInstance;
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* useRouting - Hook for route calculation and management
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { Waypoint, Route, RoutingOptions, RoutingServiceConfig, Coordinate } from '../types';
|
||||
import { RoutingService } from '../services/RoutingService';
|
||||
|
||||
interface UseRoutingOptions {
|
||||
config: RoutingServiceConfig;
|
||||
onRouteCalculated?: (route: Route) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export function useRouting({ config, onRouteCalculated, onError }: UseRoutingOptions) {
|
||||
const [routes, setRoutes] = useState<Route[]>([]);
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const service = new RoutingService(config);
|
||||
|
||||
const calculateRoute = useCallback(async (waypoints: Waypoint[], options?: Partial<RoutingOptions>) => {
|
||||
if (waypoints.length < 2) { setError(new Error('At least 2 waypoints required')); return null; }
|
||||
setIsCalculating(true); setError(null);
|
||||
try {
|
||||
const route = await service.calculateRoute(waypoints, options);
|
||||
setRoutes((prev) => [...prev, route]);
|
||||
onRouteCalculated?.(route);
|
||||
return route;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Route calculation failed');
|
||||
setError(error); onError?.(error); return null;
|
||||
} finally { setIsCalculating(false); }
|
||||
}, [service, onRouteCalculated, onError]);
|
||||
|
||||
const calculateAlternatives = useCallback(async (waypoints: Waypoint[], count = 3) => {
|
||||
setIsCalculating(true); setError(null);
|
||||
try {
|
||||
const alternatives = await service.calculateAlternatives(waypoints, count);
|
||||
setRoutes(alternatives);
|
||||
return alternatives;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Failed');
|
||||
setError(error); onError?.(error); return [];
|
||||
} finally { setIsCalculating(false); }
|
||||
}, [service, onError]);
|
||||
|
||||
const optimizeOrder = useCallback(async (waypoints: Waypoint[]) => {
|
||||
setIsCalculating(true); setError(null);
|
||||
try { return await service.optimizeWaypointOrder(waypoints); }
|
||||
catch (err) { const error = err instanceof Error ? err : new Error('Failed'); setError(error); return waypoints; }
|
||||
finally { setIsCalculating(false); }
|
||||
}, [service]);
|
||||
|
||||
const calculateIsochrone = useCallback(async (center: Coordinate, minutes: number[]) => {
|
||||
setIsCalculating(true);
|
||||
try { return await service.calculateIsochrone(center, minutes); }
|
||||
catch { return { type: 'FeatureCollection' as const, features: [] }; }
|
||||
finally { setIsCalculating(false); }
|
||||
}, [service]);
|
||||
|
||||
return { routes, isCalculating, error, calculateRoute, calculateAlternatives, optimizeOrder, calculateIsochrone, clearRoutes: () => setRoutes([]) };
|
||||
}
|
||||
|
||||
export default useRouting;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Open Mapping - Collaborative Route Planning for Canvas
|
||||
*
|
||||
* A tldraw canvas integration providing advanced mapping and routing capabilities
|
||||
* beyond traditional mapping tools like Google Maps.
|
||||
*
|
||||
* Features:
|
||||
* - OpenStreetMap base layers with MapLibre GL JS
|
||||
* - Multi-path routing via OSRM/Valhalla
|
||||
* - Real-time collaborative route planning
|
||||
* - Layer management (custom overlays, POIs, routes)
|
||||
* - Calendar/scheduling integration
|
||||
* - Budget and cost tracking
|
||||
* - Offline capability via PWA
|
||||
*/
|
||||
|
||||
// Components
|
||||
export { MapCanvas } from './components/MapCanvas';
|
||||
export { RouteLayer } from './components/RouteLayer';
|
||||
export { WaypointMarker } from './components/WaypointMarker';
|
||||
export { LayerPanel } from './components/LayerPanel';
|
||||
|
||||
// Hooks
|
||||
export { useMapInstance } from './hooks/useMapInstance';
|
||||
export { useRouting } from './hooks/useRouting';
|
||||
export { useCollaboration } from './hooks/useCollaboration';
|
||||
export { useLayers } from './hooks/useLayers';
|
||||
|
||||
// Services
|
||||
export { RoutingService } from './services/RoutingService';
|
||||
export { TileService } from './services/TileService';
|
||||
export { OptimizationService } from './services/OptimizationService';
|
||||
|
||||
// Types
|
||||
export type * from './types';
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* OptimizationService - Route optimization using VROOM
|
||||
*/
|
||||
|
||||
import type { Waypoint, Coordinate, OptimizationServiceConfig } from '../types';
|
||||
|
||||
export interface OptimizationResult {
|
||||
orderedWaypoints: Waypoint[];
|
||||
totalDistance: number;
|
||||
totalDuration: number;
|
||||
estimatedCost: { fuel: number; time: number; total: number; currency: string };
|
||||
}
|
||||
|
||||
export interface CostParameters {
|
||||
fuelPricePerLiter: number;
|
||||
fuelConsumptionPer100km: number;
|
||||
valueOfTimePerHour: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
const DEFAULT_COST_PARAMS: CostParameters = { fuelPricePerLiter: 1.5, fuelConsumptionPer100km: 8, valueOfTimePerHour: 20, currency: 'EUR' };
|
||||
|
||||
export class OptimizationService {
|
||||
private config: OptimizationServiceConfig;
|
||||
private costParams: CostParameters;
|
||||
|
||||
constructor(config: OptimizationServiceConfig, costParams = DEFAULT_COST_PARAMS) {
|
||||
this.config = config;
|
||||
this.costParams = costParams;
|
||||
}
|
||||
|
||||
async optimizeRoute(waypoints: Waypoint[]): Promise<OptimizationResult> {
|
||||
if (waypoints.length <= 2) return { orderedWaypoints: waypoints, totalDistance: 0, totalDuration: 0, estimatedCost: { fuel: 0, time: 0, total: 0, currency: this.costParams.currency } };
|
||||
|
||||
if (this.config.provider === 'vroom') {
|
||||
return this.optimizeWithVROOM(waypoints);
|
||||
}
|
||||
return this.nearestNeighbor(waypoints);
|
||||
}
|
||||
|
||||
estimateCosts(distance: number, duration: number) {
|
||||
const km = distance / 1000, hours = duration / 3600;
|
||||
const fuel = (km / 100) * this.costParams.fuelConsumptionPer100km * this.costParams.fuelPricePerLiter;
|
||||
const time = hours * this.costParams.valueOfTimePerHour;
|
||||
return { fuel: Math.round(fuel * 100) / 100, time: Math.round(time * 100) / 100, total: Math.round((fuel + time) * 100) / 100, currency: this.costParams.currency };
|
||||
}
|
||||
|
||||
private async optimizeWithVROOM(waypoints: Waypoint[]): Promise<OptimizationResult> {
|
||||
const jobs = waypoints.map((wp, i) => ({ id: i, location: [wp.coordinate.lng, wp.coordinate.lat] }));
|
||||
const vehicles = [{ id: 0, start: [waypoints[0].coordinate.lng, waypoints[0].coordinate.lat] }];
|
||||
try {
|
||||
const res = await fetch(this.config.baseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jobs, vehicles }) });
|
||||
const data = await res.json();
|
||||
if (data.code !== 0) throw new Error(data.error);
|
||||
const indices = data.routes[0].steps.filter((s: any) => s.type === 'job').map((s: any) => s.job);
|
||||
return { orderedWaypoints: indices.map((i: number) => waypoints[i]), totalDistance: data.summary.distance, totalDuration: data.summary.duration, estimatedCost: this.estimateCosts(data.summary.distance, data.summary.duration) };
|
||||
} catch { return this.nearestNeighbor(waypoints); }
|
||||
}
|
||||
|
||||
private nearestNeighbor(waypoints: Waypoint[]): OptimizationResult {
|
||||
const remaining = [...waypoints], ordered: Waypoint[] = [];
|
||||
let current = remaining.shift()!;
|
||||
ordered.push(current);
|
||||
while (remaining.length) {
|
||||
let nearest = 0, minDist = Infinity;
|
||||
for (let i = 0; i < remaining.length; i++) {
|
||||
const d = this.haversine(current.coordinate, remaining[i].coordinate);
|
||||
if (d < minDist) { minDist = d; nearest = i; }
|
||||
}
|
||||
current = remaining.splice(nearest, 1)[0];
|
||||
ordered.push(current);
|
||||
}
|
||||
let dist = 0;
|
||||
for (let i = 0; i < ordered.length - 1; i++) dist += this.haversine(ordered[i].coordinate, ordered[i + 1].coordinate);
|
||||
const dur = (dist / 50000) * 3600;
|
||||
return { orderedWaypoints: ordered, totalDistance: dist, totalDuration: dur, estimatedCost: this.estimateCosts(dist, dur) };
|
||||
}
|
||||
|
||||
private haversine(a: Coordinate, b: Coordinate): number {
|
||||
const R = 6371000, lat1 = (a.lat * Math.PI) / 180, lat2 = (b.lat * Math.PI) / 180;
|
||||
const dLat = ((b.lat - a.lat) * Math.PI) / 180, dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
||||
const x = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x));
|
||||
}
|
||||
}
|
||||
|
||||
export default OptimizationService;
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* RoutingService - Multi-provider routing abstraction
|
||||
* Supports: OSRM, Valhalla, GraphHopper, OpenRouteService
|
||||
*/
|
||||
|
||||
import type { Waypoint, Route, RoutingOptions, RoutingServiceConfig, Coordinate, RoutingProfile } from '../types';
|
||||
|
||||
export class RoutingService {
|
||||
private config: RoutingServiceConfig;
|
||||
|
||||
constructor(config: RoutingServiceConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async calculateRoute(waypoints: Waypoint[], options?: Partial<RoutingOptions>): Promise<Route> {
|
||||
const profile = options?.profile ?? 'car';
|
||||
const coordinates = waypoints.map((w) => w.coordinate);
|
||||
|
||||
switch (this.config.provider) {
|
||||
case 'osrm': return this.calculateOSRMRoute(coordinates, profile, options);
|
||||
case 'valhalla': return this.calculateValhallaRoute(coordinates, profile, options);
|
||||
default: throw new Error(`Unsupported provider: ${this.config.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
async calculateAlternatives(waypoints: Waypoint[], count = 3): Promise<Route[]> {
|
||||
const mainRoute = await this.calculateRoute(waypoints, { alternatives: count });
|
||||
return mainRoute.alternatives ? [mainRoute, ...mainRoute.alternatives] : [mainRoute];
|
||||
}
|
||||
|
||||
async optimizeWaypointOrder(waypoints: Waypoint[]): Promise<Waypoint[]> {
|
||||
if (waypoints.length <= 2) return waypoints;
|
||||
const coords = waypoints.map((w) => `${w.coordinate.lng},${w.coordinate.lat}`).join(';');
|
||||
const url = `${this.config.baseUrl}/trip/v1/driving/${coords}?roundtrip=false&source=first&destination=last`;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.code !== 'Ok') return waypoints;
|
||||
return data.waypoints.map((wp: { waypoint_index: number }) => waypoints[wp.waypoint_index]);
|
||||
} catch { return waypoints; }
|
||||
}
|
||||
|
||||
async calculateIsochrone(center: Coordinate, minutes: number[]): Promise<GeoJSON.FeatureCollection> {
|
||||
if (this.config.provider !== 'valhalla') return { type: 'FeatureCollection', features: [] };
|
||||
const body = { locations: [{ lat: center.lat, lon: center.lng }], costing: 'auto', contours: minutes.map((m) => ({ time: m })), polygons: true };
|
||||
const res = await fetch(`${this.config.baseUrl}/isochrone`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
private async calculateOSRMRoute(coords: Coordinate[], profile: RoutingProfile, options?: Partial<RoutingOptions>): Promise<Route> {
|
||||
const coordStr = coords.map((c) => `${c.lng},${c.lat}`).join(';');
|
||||
const osrmProfile = profile === 'bicycle' ? 'cycling' : profile === 'foot' ? 'walking' : 'driving';
|
||||
const url = new URL(`${this.config.baseUrl}/route/v1/${osrmProfile}/${coordStr}`);
|
||||
url.searchParams.set('overview', 'full');
|
||||
url.searchParams.set('geometries', 'geojson');
|
||||
url.searchParams.set('steps', 'true');
|
||||
if (options?.alternatives) url.searchParams.set('alternatives', 'true');
|
||||
const res = await fetch(url.toString());
|
||||
const data = await res.json();
|
||||
if (data.code !== 'Ok') throw new Error(`OSRM error: ${data.message || data.code}`);
|
||||
return this.parseOSRMResponse(data, profile);
|
||||
}
|
||||
|
||||
private async calculateValhallaRoute(coords: Coordinate[], profile: RoutingProfile, options?: Partial<RoutingOptions>): Promise<Route> {
|
||||
const costing = profile === 'bicycle' ? 'bicycle' : profile === 'foot' ? 'pedestrian' : 'auto';
|
||||
const body = { locations: coords.map((c) => ({ lat: c.lat, lon: c.lng })), costing, alternates: options?.alternatives ?? 0 };
|
||||
const res = await fetch(`${this.config.baseUrl}/route`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(`Valhalla error: ${data.error}`);
|
||||
return this.parseValhallaResponse(data, profile);
|
||||
}
|
||||
|
||||
private parseOSRMResponse(data: any, profile: RoutingProfile): Route {
|
||||
const r = data.routes[0];
|
||||
return {
|
||||
id: `route-${Date.now()}`, waypoints: [], geometry: r.geometry, profile,
|
||||
summary: { distance: r.distance, duration: r.duration },
|
||||
legs: r.legs.map((leg: any, i: number) => ({ startWaypoint: `wp-${i}`, endWaypoint: `wp-${i + 1}`, distance: leg.distance, duration: leg.duration, geometry: { type: 'LineString', coordinates: [] } })),
|
||||
alternatives: data.routes.slice(1).map((alt: any) => this.parseOSRMResponse({ routes: [alt] }, profile)),
|
||||
};
|
||||
}
|
||||
|
||||
private parseValhallaResponse(data: any, profile: RoutingProfile): Route {
|
||||
const trip = data.trip;
|
||||
return {
|
||||
id: `route-${Date.now()}`, waypoints: [], geometry: { type: 'LineString', coordinates: [] }, profile,
|
||||
summary: { distance: trip.summary.length * 1000, duration: trip.summary.time },
|
||||
legs: trip.legs.map((leg: any, i: number) => ({ startWaypoint: `wp-${i}`, endWaypoint: `wp-${i + 1}`, distance: leg.summary.length * 1000, duration: leg.summary.time, geometry: { type: 'LineString', coordinates: [] } })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default RoutingService;
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* TileService - Map tile management and caching
|
||||
*/
|
||||
|
||||
import type { TileServiceConfig, BoundingBox } from '../types';
|
||||
|
||||
export interface TileSource {
|
||||
id: string; name: string; type: 'raster' | 'vector'; url: string; attribution: string; maxZoom?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_TILE_SOURCES: TileSource[] = [
|
||||
{ id: 'osm-standard', name: 'OpenStreetMap', type: 'raster', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors', maxZoom: 19 },
|
||||
{ id: 'osm-humanitarian', name: 'Humanitarian', type: 'raster', url: 'https://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', attribution: '© OSM, HOT', maxZoom: 19 },
|
||||
{ id: 'carto-light', name: 'Carto Light', type: 'raster', url: 'https://cartodb-basemaps-a.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png', attribution: '© OSM, CARTO', maxZoom: 19 },
|
||||
{ id: 'carto-dark', name: 'Carto Dark', type: 'raster', url: 'https://cartodb-basemaps-a.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', attribution: '© OSM, CARTO', maxZoom: 19 },
|
||||
];
|
||||
|
||||
export class TileService {
|
||||
private config: TileServiceConfig;
|
||||
private cache: Cache | null = null;
|
||||
private cacheName = 'open-mapping-tiles-v1';
|
||||
|
||||
constructor(config: TileServiceConfig) {
|
||||
this.config = config;
|
||||
this.initCache();
|
||||
}
|
||||
|
||||
private async initCache() {
|
||||
if ('caches' in window) { try { this.cache = await caches.open(this.cacheName); } catch {} }
|
||||
}
|
||||
|
||||
getSources(): TileSource[] { return DEFAULT_TILE_SOURCES; }
|
||||
getSource(id: string): TileSource | undefined { return DEFAULT_TILE_SOURCES.find((s) => s.id === id); }
|
||||
getTileUrl(source: TileSource, z: number, x: number, y: number): string {
|
||||
return source.url.replace('{z}', String(z)).replace('{x}', String(x)).replace('{y}', String(y));
|
||||
}
|
||||
|
||||
async cacheTilesForArea(sourceId: string, bounds: BoundingBox, minZoom: number, maxZoom: number, onProgress?: (p: number) => void) {
|
||||
const source = this.getSource(sourceId);
|
||||
if (!source || !this.cache) throw new Error('Cannot cache');
|
||||
const tiles = this.getTilesInBounds(bounds, minZoom, maxZoom);
|
||||
let done = 0;
|
||||
for (const { z, x, y } of tiles) {
|
||||
try { const res = await fetch(this.getTileUrl(source, z, x, y)); if (res.ok) await this.cache.put(this.getTileUrl(source, z, x, y), res); } catch {}
|
||||
onProgress?.(++done / tiles.length);
|
||||
}
|
||||
}
|
||||
|
||||
async clearCache() { if ('caches' in window) { await caches.delete(this.cacheName); this.cache = await caches.open(this.cacheName); } }
|
||||
|
||||
private getTilesInBounds(bounds: BoundingBox, minZoom: number, maxZoom: number) {
|
||||
const tiles: Array<{ z: number; x: number; y: number }> = [];
|
||||
for (let z = minZoom; z <= maxZoom; z++) {
|
||||
const min = this.latLngToTile(bounds.south, bounds.west, z);
|
||||
const max = this.latLngToTile(bounds.north, bounds.east, z);
|
||||
for (let x = min.x; x <= max.x; x++) for (let y = max.y; y <= min.y; y++) tiles.push({ z, x, y });
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private latLngToTile(lat: number, lng: number, z: number) {
|
||||
const n = Math.pow(2, z);
|
||||
return { x: Math.floor(((lng + 180) / 360) * n), y: Math.floor(((1 - Math.log(Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)) / Math.PI) / 2) * n) };
|
||||
}
|
||||
}
|
||||
|
||||
export default TileService;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export { RoutingService } from './RoutingService';
|
||||
export { TileService, DEFAULT_TILE_SOURCES } from './TileService';
|
||||
export type { TileSource } from './TileService';
|
||||
export { OptimizationService } from './OptimizationService';
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
/**
|
||||
* Open Mapping Type Definitions
|
||||
*/
|
||||
|
||||
// Core Geographic Types
|
||||
export interface Coordinate {
|
||||
lat: number;
|
||||
lng: number;
|
||||
alt?: number;
|
||||
}
|
||||
|
||||
export interface BoundingBox {
|
||||
north: number;
|
||||
south: number;
|
||||
east: number;
|
||||
west: number;
|
||||
}
|
||||
|
||||
// Waypoint & Route Types
|
||||
export interface Waypoint {
|
||||
id: string;
|
||||
coordinate: Coordinate;
|
||||
name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
arrivalTime?: Date;
|
||||
departureTime?: Date;
|
||||
stayDuration?: number;
|
||||
budget?: WaypointBudget;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WaypointBudget {
|
||||
estimated: number;
|
||||
actual?: number;
|
||||
currency: string;
|
||||
category?: 'lodging' | 'food' | 'transport' | 'activity' | 'other';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface Route {
|
||||
id: string;
|
||||
waypoints: Waypoint[];
|
||||
geometry: GeoJSON.LineString;
|
||||
profile: RoutingProfile;
|
||||
summary: RouteSummary;
|
||||
legs: RouteLeg[];
|
||||
alternatives?: Route[];
|
||||
metadata?: RouteMetadata;
|
||||
}
|
||||
|
||||
export interface RouteSummary {
|
||||
distance: number;
|
||||
duration: number;
|
||||
ascent?: number;
|
||||
descent?: number;
|
||||
cost?: RouteCost;
|
||||
}
|
||||
|
||||
export interface RouteCost {
|
||||
fuel?: number;
|
||||
tolls?: number;
|
||||
total: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface RouteLeg {
|
||||
startWaypoint: string;
|
||||
endWaypoint: string;
|
||||
distance: number;
|
||||
duration: number;
|
||||
geometry: GeoJSON.LineString;
|
||||
steps?: RouteStep[];
|
||||
}
|
||||
|
||||
export interface RouteStep {
|
||||
instruction: string;
|
||||
distance: number;
|
||||
duration: number;
|
||||
geometry: GeoJSON.LineString;
|
||||
maneuver: RouteManeuver;
|
||||
}
|
||||
|
||||
export interface RouteManeuver {
|
||||
type: ManeuverType;
|
||||
modifier?: string;
|
||||
bearingBefore: number;
|
||||
bearingAfter: number;
|
||||
location: Coordinate;
|
||||
}
|
||||
|
||||
export type ManeuverType =
|
||||
| 'turn' | 'new name' | 'depart' | 'arrive'
|
||||
| 'merge' | 'on ramp' | 'off ramp' | 'fork'
|
||||
| 'end of road' | 'continue' | 'roundabout' | 'rotary'
|
||||
| 'roundabout turn' | 'notification' | 'exit roundabout';
|
||||
|
||||
export interface RouteMetadata {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdBy: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
isPublic?: boolean;
|
||||
shareLink?: string;
|
||||
}
|
||||
|
||||
// Routing Profiles & Options
|
||||
export type RoutingProfile =
|
||||
| 'car' | 'truck' | 'motorcycle'
|
||||
| 'bicycle' | 'mountain_bike' | 'road_bike'
|
||||
| 'foot' | 'hiking'
|
||||
| 'wheelchair'
|
||||
| 'transit';
|
||||
|
||||
export interface RoutingOptions {
|
||||
profile: RoutingProfile;
|
||||
avoidTolls?: boolean;
|
||||
avoidHighways?: boolean;
|
||||
avoidFerries?: boolean;
|
||||
preferScenic?: boolean;
|
||||
alternatives?: number;
|
||||
departureTime?: Date;
|
||||
arrivalTime?: Date;
|
||||
optimize?: OptimizationType;
|
||||
constraints?: RoutingConstraints;
|
||||
}
|
||||
|
||||
export type OptimizationType = 'fastest' | 'shortest' | 'balanced' | 'eco';
|
||||
|
||||
export interface RoutingConstraints {
|
||||
maxDistance?: number;
|
||||
maxDuration?: number;
|
||||
maxCost?: number;
|
||||
vehicleHeight?: number;
|
||||
vehicleWeight?: number;
|
||||
vehicleWidth?: number;
|
||||
}
|
||||
|
||||
// Layer Management
|
||||
export interface MapLayer {
|
||||
id: string;
|
||||
name: string;
|
||||
type: LayerType;
|
||||
visible: boolean;
|
||||
opacity: number;
|
||||
zIndex: number;
|
||||
source: LayerSource;
|
||||
style?: LayerStyle;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type LayerType =
|
||||
| 'basemap' | 'satellite' | 'terrain'
|
||||
| 'route' | 'waypoint' | 'poi'
|
||||
| 'heatmap' | 'cluster'
|
||||
| 'geojson' | 'custom';
|
||||
|
||||
export interface LayerSource {
|
||||
type: 'vector' | 'raster' | 'geojson' | 'image';
|
||||
url?: string;
|
||||
data?: GeoJSON.FeatureCollection;
|
||||
tiles?: string[];
|
||||
attribution?: string;
|
||||
}
|
||||
|
||||
export interface LayerStyle {
|
||||
color?: string;
|
||||
fillColor?: string;
|
||||
strokeWidth?: number;
|
||||
opacity?: number;
|
||||
icon?: string;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
// Collaboration Types
|
||||
export interface CollaborationSession {
|
||||
id: string;
|
||||
name: string;
|
||||
participants: Participant[];
|
||||
routes: Route[];
|
||||
layers: MapLayer[];
|
||||
viewport: MapViewport;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Participant {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
cursor?: Coordinate;
|
||||
isActive: boolean;
|
||||
lastSeen: Date;
|
||||
permissions: ParticipantPermissions;
|
||||
}
|
||||
|
||||
export interface ParticipantPermissions {
|
||||
canEdit: boolean;
|
||||
canAddWaypoints: boolean;
|
||||
canDeleteWaypoints: boolean;
|
||||
canChangeRoute: boolean;
|
||||
canInvite: boolean;
|
||||
}
|
||||
|
||||
export interface MapViewport {
|
||||
center: Coordinate;
|
||||
zoom: number;
|
||||
bearing: number;
|
||||
pitch: number;
|
||||
}
|
||||
|
||||
// Calendar & Scheduling
|
||||
export interface TripItinerary {
|
||||
id: string;
|
||||
name: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
routes: Route[];
|
||||
events: ItineraryEvent[];
|
||||
budget: TripBudget;
|
||||
participants: string[];
|
||||
}
|
||||
|
||||
export interface ItineraryEvent {
|
||||
id: string;
|
||||
waypointId?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
type: EventType;
|
||||
confirmed: boolean;
|
||||
cost?: number;
|
||||
bookingRef?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export type EventType =
|
||||
| 'travel' | 'lodging' | 'activity'
|
||||
| 'meal' | 'meeting' | 'rest' | 'other';
|
||||
|
||||
export interface TripBudget {
|
||||
total: number;
|
||||
spent: number;
|
||||
currency: string;
|
||||
categories: BudgetCategory[];
|
||||
}
|
||||
|
||||
export interface BudgetCategory {
|
||||
name: string;
|
||||
allocated: number;
|
||||
spent: number;
|
||||
items: BudgetItem[];
|
||||
}
|
||||
|
||||
export interface BudgetItem {
|
||||
description: string;
|
||||
amount: number;
|
||||
date?: Date;
|
||||
waypointId?: string;
|
||||
eventId?: string;
|
||||
receipt?: string;
|
||||
}
|
||||
|
||||
// Service Configurations
|
||||
export interface RoutingServiceConfig {
|
||||
provider: 'osrm' | 'valhalla' | 'graphhopper' | 'openrouteservice';
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface TileServiceConfig {
|
||||
provider: 'maplibre' | 'leaflet';
|
||||
styleUrl?: string;
|
||||
tileUrl?: string;
|
||||
maxZoom?: number;
|
||||
attribution?: string;
|
||||
}
|
||||
|
||||
export interface OptimizationServiceConfig {
|
||||
provider: 'vroom' | 'graphhopper';
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Open Mapping Utilities
|
||||
*/
|
||||
|
||||
import type { Coordinate, BoundingBox } from '../types';
|
||||
|
||||
export function haversineDistance(a: Coordinate, b: Coordinate): number {
|
||||
const R = 6371000;
|
||||
const lat1 = (a.lat * Math.PI) / 180;
|
||||
const lat2 = (b.lat * Math.PI) / 180;
|
||||
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
||||
const x = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x));
|
||||
}
|
||||
|
||||
export function getBounds(coords: Coordinate[]): BoundingBox {
|
||||
if (!coords.length) return { north: 0, south: 0, east: 0, west: 0 };
|
||||
let north = -90, south = 90, east = -180, west = 180;
|
||||
for (const c of coords) {
|
||||
if (c.lat > north) north = c.lat;
|
||||
if (c.lat < south) south = c.lat;
|
||||
if (c.lng > east) east = c.lng;
|
||||
if (c.lng < west) west = c.lng;
|
||||
}
|
||||
return { north, south, east, west };
|
||||
}
|
||||
|
||||
export function formatDistance(meters: number): string {
|
||||
if (meters < 1000) return `${Math.round(meters)} m`;
|
||||
return `${(meters / 1000).toFixed(1)} km`;
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
if (hours === 0) return `${mins} min`;
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
|
||||
export function decodePolyline(encoded: string): [number, number][] {
|
||||
const coords: [number, number][] = [];
|
||||
let index = 0, lat = 0, lng = 0;
|
||||
while (index < encoded.length) {
|
||||
let shift = 0, result = 0, byte: number;
|
||||
do { byte = encoded.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20);
|
||||
lat += result & 1 ? ~(result >> 1) : result >> 1;
|
||||
shift = 0; result = 0;
|
||||
do { byte = encoded.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20);
|
||||
lng += result & 1 ? ~(result >> 1) : result >> 1;
|
||||
coords.push([lng / 1e5, lat / 1e5]);
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
Loading…
Reference in New Issue