Compare commits
No commits in common. "8cda0d4e28b6b7a8d92a5c5061bcb3ffc96a49a1" and "4f1a6d131496cefa4bab2abc1c940813da8476a1" have entirely different histories.
8cda0d4e28
...
4f1a6d1314
|
|
@ -1,63 +0,0 @@
|
||||||
---
|
|
||||||
id: task-024
|
|
||||||
title: 'Open Mapping: Collaborative Route Planning Module'
|
|
||||||
status: To Do
|
|
||||||
assignee: []
|
|
||||||
created_date: '2025-12-04 14:30'
|
|
||||||
labels:
|
|
||||||
- feature
|
|
||||||
- mapping
|
|
||||||
dependencies: []
|
|
||||||
priority: high
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Implement an open-source mapping and routing layer for the canvas that provides advanced route planning capabilities beyond Google Maps. Built on OpenStreetMap, OSRM/Valhalla, and MapLibre GL JS.
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [ ] #1 MapLibre GL JS integrated with tldraw canvas
|
|
||||||
- [ ] #2 OSRM routing backend deployed to Netcup
|
|
||||||
- [ ] #3 Waypoint placement and route calculation working
|
|
||||||
- [ ] #4 Multi-route comparison UI implemented
|
|
||||||
- [ ] #5 Y.js collaboration for shared route editing
|
|
||||||
- [ ] #6 Layer management panel with basemap switching
|
|
||||||
- [ ] #7 Offline tile caching via Service Worker
|
|
||||||
- [ ] #8 Budget tracking per waypoint/route
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
<!-- SECTION:PLAN:BEGIN -->
|
|
||||||
Phase 1 - Foundation:
|
|
||||||
- Integrate MapLibre GL JS with tldraw
|
|
||||||
- Deploy OSRM to /opt/apps/open-mapping/
|
|
||||||
- Basic waypoint and route UI
|
|
||||||
|
|
||||||
Phase 2 - Multi-Route:
|
|
||||||
- Alternative routes visualization
|
|
||||||
- Route comparison panel
|
|
||||||
- Elevation profiles
|
|
||||||
|
|
||||||
Phase 3 - Collaboration:
|
|
||||||
- Y.js integration
|
|
||||||
- Real-time cursor presence
|
|
||||||
- Share links
|
|
||||||
|
|
||||||
Phase 4 - Layers:
|
|
||||||
- Layer panel UI
|
|
||||||
- Multiple basemaps
|
|
||||||
- Custom overlays
|
|
||||||
|
|
||||||
Phase 5 - Calendar/Budget:
|
|
||||||
- Time windows on waypoints
|
|
||||||
- Cost estimation
|
|
||||||
- iCal export
|
|
||||||
|
|
||||||
Phase 6 - Optimization:
|
|
||||||
- VROOM TSP/VRP
|
|
||||||
- Offline PWA
|
|
||||||
<!-- SECTION:PLAN:END -->
|
|
||||||
|
|
@ -3,45 +3,28 @@ import * as Automerge from "@automerge/automerge"
|
||||||
|
|
||||||
// Helper function to validate if a string is a valid tldraw IndexKey
|
// 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
|
// tldraw uses fractional indexing based on https://observablehq.com/@dgreensp/implementing-fractional-indexing
|
||||||
// The first letter encodes integer part length: a=1 digit, b=2 digits, c=3 digits, etc.
|
// Valid indices have an integer part (letter indicating length) followed by digits and optional alphanumeric fraction
|
||||||
// Examples: "a0"-"a9", "b10"-"b99", "c100"-"c999", with optional fraction "a1V4rr"
|
// Examples: "a0", "a1", "a1V", "a24sT", "a1V4rr"
|
||||||
// Invalid: "b1" (b expects 2 digits but has 1), simple sequential numbers
|
// Invalid: "b1" (old format), simple sequential numbers
|
||||||
function isValidIndexKey(index: string): boolean {
|
function isValidIndexKey(index: string): boolean {
|
||||||
if (!index || typeof index !== 'string' || index.length === 0) {
|
if (!index || typeof index !== 'string' || index.length === 0) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must start with a letter
|
// tldraw uses fractional indexing where:
|
||||||
if (!/^[a-zA-Z]/.test(index)) {
|
// - First character is a lowercase letter indicating integer part length (a=1, b=2, c=3, etc.)
|
||||||
return false
|
// - 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
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = index[0]
|
// Also allow uppercase prefix for negative/very high indices
|
||||||
const rest = index.slice(1)
|
if (/^[A-Z][a-zA-Z0-9]+$/.test(index)) {
|
||||||
|
|
||||||
// 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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,36 +21,19 @@ function minimalSanitizeRecord(record: any): any {
|
||||||
if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1
|
if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1
|
||||||
if (!sanitized.meta || typeof sanitized.meta !== 'object') sanitized.meta = {}
|
if (!sanitized.meta || typeof sanitized.meta !== 'object') sanitized.meta = {}
|
||||||
// NOTE: Index assignment is handled by assignSequentialIndices() during format conversion
|
// NOTE: Index assignment is handled by assignSequentialIndices() during format conversion
|
||||||
// Here we validate using tldraw's fractional indexing rules
|
// Here we only ensure index exists with a valid format, not strictly validate
|
||||||
// The first letter encodes integer part length: a=1 digit, b=2 digits, c=3 digits, etc.
|
// This preserves layer order that was established during conversion
|
||||||
// Examples: "a0"-"a9", "b10"-"b99", "c100"-"c999", with optional fraction "a1V4rr"
|
// tldraw uses fractional indexing: a0, a1, b10, c100, a1V4rr, etc.
|
||||||
// Invalid: "b1" (b expects 2 digits but has 1)
|
// - First letter (a-z) indicates integer part length (a=1 digit, b=2 digits, etc.)
|
||||||
|
// - Uppercase (A-Z) for negative/special indices
|
||||||
if (!sanitized.index || typeof sanitized.index !== 'string' || sanitized.index.length === 0) {
|
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'
|
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'
|
if (!sanitized.parentId) sanitized.parentId = 'page:page'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* LayerPanel - UI for managing map layers
|
* LayerPanel - UI for managing map layers
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Toggle layer visibility
|
|
||||||
* - Adjust layer opacity
|
|
||||||
* - Reorder layers (z-index)
|
|
||||||
* - Add custom layers (GeoJSON, tiles)
|
|
||||||
* - Import/export layer configurations
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { MapLayer } from '../types';
|
import type { MapLayer } from '../types';
|
||||||
|
|
@ -18,21 +11,9 @@ interface LayerPanelProps {
|
||||||
onLayerReorder?: (layerIds: string[]) => void;
|
onLayerReorder?: (layerIds: string[]) => void;
|
||||||
onLayerAdd?: (layer: Omit<MapLayer, 'id'>) => void;
|
onLayerAdd?: (layer: Omit<MapLayer, 'id'>) => void;
|
||||||
onLayerRemove?: (layerId: string) => void;
|
onLayerRemove?: (layerId: string) => void;
|
||||||
onLayerEdit?: (layerId: string, updates: Partial<MapLayer>) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayerPanel({
|
export function LayerPanel({ layers, onLayerToggle }: LayerPanelProps) {
|
||||||
layers,
|
|
||||||
onLayerToggle,
|
|
||||||
onLayerOpacity,
|
|
||||||
onLayerReorder,
|
|
||||||
onLayerAdd,
|
|
||||||
onLayerRemove,
|
|
||||||
onLayerEdit,
|
|
||||||
}: LayerPanelProps) {
|
|
||||||
// TODO: Implement layer panel UI
|
|
||||||
// This will be implemented in Phase 2
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="open-mapping-layer-panel">
|
<div className="open-mapping-layer-panel">
|
||||||
<h3>Layers</h3>
|
<h3>Layers</h3>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* MapCanvas - Main map component that integrates with tldraw canvas
|
* MapCanvas - Main map component integrating with tldraw canvas
|
||||||
*
|
|
||||||
* Renders a MapLibre GL JS map as a layer within the tldraw canvas,
|
|
||||||
* enabling collaborative route planning with full canvas editing capabilities.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
@ -14,7 +11,7 @@ interface MapCanvasProps {
|
||||||
onViewportChange?: (viewport: MapViewport) => void;
|
onViewportChange?: (viewport: MapViewport) => void;
|
||||||
onMapClick?: (coordinate: Coordinate) => void;
|
onMapClick?: (coordinate: Coordinate) => void;
|
||||||
onMapLoad?: () => void;
|
onMapLoad?: () => void;
|
||||||
style?: string; // MapLibre style URL
|
style?: string;
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,42 +28,18 @@ export function MapCanvas({
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// TODO: Initialize MapLibre GL JS instance
|
// TODO: Initialize MapLibre GL JS instance (Phase 1)
|
||||||
// This will be implemented in Phase 1
|
|
||||||
console.log('MapCanvas: Initializing with viewport', viewport);
|
console.log('MapCanvas: Initializing with viewport', viewport);
|
||||||
|
return () => { /* Cleanup */ };
|
||||||
return () => {
|
|
||||||
// Cleanup map instance
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// TODO: Update layers when they change
|
|
||||||
console.log('MapCanvas: Updating layers', layers);
|
|
||||||
}, [layers]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// TODO: Sync viewport changes
|
|
||||||
if (isLoaded) {
|
|
||||||
console.log('MapCanvas: Viewport changed', viewport);
|
|
||||||
}
|
|
||||||
}, [viewport, isLoaded]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="open-mapping-canvas"
|
className="open-mapping-canvas"
|
||||||
style={{
|
style={{ width: '100%', height: '100%', position: 'relative' }}
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{!isLoaded && (
|
{!isLoaded && <div className="open-mapping-loading">Loading map...</div>}
|
||||||
<div className="open-mapping-loading">
|
|
||||||
Loading map...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* RouteLayer - Renders route polylines on the map
|
* RouteLayer - Renders route polylines on the map
|
||||||
*
|
|
||||||
* Displays computed routes with support for:
|
|
||||||
* - Multiple alternative routes
|
|
||||||
* - Turn-by-turn visualization
|
|
||||||
* - Elevation profile overlay
|
|
||||||
* - Interactive route editing
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Route, RoutingProfile } from '../types';
|
import type { Route, RoutingProfile } from '../types';
|
||||||
|
|
@ -21,33 +15,14 @@ interface RouteLayerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PROFILE_COLORS: Record<RoutingProfile, string> = {
|
const DEFAULT_PROFILE_COLORS: Record<RoutingProfile, string> = {
|
||||||
car: '#3B82F6', // blue
|
car: '#3B82F6', truck: '#6366F1', motorcycle: '#8B5CF6',
|
||||||
truck: '#6366F1', // indigo
|
bicycle: '#10B981', mountain_bike: '#059669', road_bike: '#14B8A6',
|
||||||
motorcycle: '#8B5CF6', // violet
|
foot: '#F59E0B', hiking: '#D97706', wheelchair: '#EC4899', transit: '#6B7280',
|
||||||
bicycle: '#10B981', // emerald
|
|
||||||
mountain_bike: '#059669', // green
|
|
||||||
road_bike: '#14B8A6', // teal
|
|
||||||
foot: '#F59E0B', // amber
|
|
||||||
hiking: '#D97706', // orange
|
|
||||||
wheelchair: '#EC4899', // pink
|
|
||||||
transit: '#6B7280', // gray
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RouteLayer({
|
export function RouteLayer({ routes, selectedRouteId, profileColors = {} }: RouteLayerProps) {
|
||||||
routes,
|
// TODO: Implement route rendering (Phase 2)
|
||||||
selectedRouteId,
|
return null;
|
||||||
showAlternatives = true,
|
|
||||||
showElevation = false,
|
|
||||||
onRouteSelect,
|
|
||||||
onRouteEdit,
|
|
||||||
profileColors = {},
|
|
||||||
}: RouteLayerProps) {
|
|
||||||
const colors = { ...DEFAULT_PROFILE_COLORS, ...profileColors };
|
|
||||||
|
|
||||||
// TODO: Implement route rendering with MapLibre GL JS
|
|
||||||
// This will be implemented in Phase 2
|
|
||||||
|
|
||||||
return null; // Routes are rendered directly on the map canvas
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RouteLayer;
|
export default RouteLayer;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* WaypointMarker - Interactive waypoint markers on the map
|
* WaypointMarker - Interactive waypoint markers
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Drag-and-drop repositioning
|
|
||||||
* - Custom icons and colors
|
|
||||||
* - Info popups with waypoint details
|
|
||||||
* - Time/budget annotations
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Waypoint } from '../types';
|
import type { Waypoint } from '../types';
|
||||||
|
|
@ -15,30 +9,14 @@ interface WaypointMarkerProps {
|
||||||
index?: number;
|
index?: number;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
isDraggable?: boolean;
|
isDraggable?: boolean;
|
||||||
showLabel?: boolean;
|
|
||||||
showTime?: boolean;
|
|
||||||
showBudget?: boolean;
|
|
||||||
onSelect?: (waypointId: string) => void;
|
onSelect?: (waypointId: string) => void;
|
||||||
onDragEnd?: (waypointId: string, newCoordinate: { lat: number; lng: number }) => void;
|
onDragEnd?: (waypointId: string, newCoordinate: { lat: number; lng: number }) => void;
|
||||||
onDelete?: (waypointId: string) => void;
|
onDelete?: (waypointId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WaypointMarker({
|
export function WaypointMarker({ waypoint, isSelected = false }: WaypointMarkerProps) {
|
||||||
waypoint,
|
// TODO: Implement marker rendering (Phase 1)
|
||||||
index,
|
return null;
|
||||||
isSelected = false,
|
|
||||||
isDraggable = true,
|
|
||||||
showLabel = true,
|
|
||||||
showTime = false,
|
|
||||||
showBudget = false,
|
|
||||||
onSelect,
|
|
||||||
onDragEnd,
|
|
||||||
onDelete,
|
|
||||||
}: WaypointMarkerProps) {
|
|
||||||
// TODO: Implement marker rendering with MapLibre GL JS
|
|
||||||
// This will be implemented in Phase 1
|
|
||||||
|
|
||||||
return null; // Markers are rendered directly on the map
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default WaypointMarker;
|
export default WaypointMarker;
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* useCollaboration - Hook for real-time collaborative map editing
|
* useCollaboration - Hook for real-time collaborative map editing via Y.js
|
||||||
*
|
|
||||||
* Uses Y.js for CRDT-based synchronization, enabling:
|
|
||||||
* - Real-time waypoint/route sharing
|
|
||||||
* - Cursor presence awareness
|
|
||||||
* - Conflict-free concurrent edits
|
|
||||||
* - Offline-first with sync on reconnect
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import type {
|
import type { CollaborationSession, Participant, Route, Waypoint, MapLayer, Coordinate } from '../types';
|
||||||
CollaborationSession,
|
|
||||||
Participant,
|
|
||||||
Route,
|
|
||||||
Waypoint,
|
|
||||||
MapLayer,
|
|
||||||
Coordinate,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
interface UseCollaborationOptions {
|
interface UseCollaborationOptions {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
|
@ -26,106 +13,43 @@ interface UseCollaborationOptions {
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
onParticipantJoin?: (participant: Participant) => void;
|
onParticipantJoin?: (participant: Participant) => void;
|
||||||
onParticipantLeave?: (participantId: string) => void;
|
onParticipantLeave?: (participantId: string) => void;
|
||||||
onRouteUpdate?: (routes: Route[]) => void;
|
|
||||||
onWaypointUpdate?: (waypoints: Waypoint[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseCollaborationReturn {
|
|
||||||
session: CollaborationSession | null;
|
|
||||||
participants: Participant[];
|
|
||||||
isConnected: boolean;
|
|
||||||
createSession: (name: string) => Promise<string>;
|
|
||||||
joinSession: (sessionId: string) => Promise<void>;
|
|
||||||
leaveSession: () => void;
|
|
||||||
updateCursor: (coordinate: Coordinate) => void;
|
|
||||||
broadcastRouteChange: (route: Route) => void;
|
|
||||||
broadcastWaypointChange: (waypoint: Waypoint) => void;
|
|
||||||
broadcastLayerChange: (layer: MapLayer) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCollaboration({
|
export function useCollaboration({
|
||||||
sessionId,
|
sessionId, userId, userName, userColor = '#3B82F6', serverUrl,
|
||||||
userId,
|
}: UseCollaborationOptions) {
|
||||||
userName,
|
|
||||||
userColor = '#3B82F6',
|
|
||||||
serverUrl,
|
|
||||||
onParticipantJoin,
|
|
||||||
onParticipantLeave,
|
|
||||||
onRouteUpdate,
|
|
||||||
onWaypointUpdate,
|
|
||||||
}: UseCollaborationOptions): UseCollaborationReturn {
|
|
||||||
const [session, setSession] = useState<CollaborationSession | null>(null);
|
const [session, setSession] = useState<CollaborationSession | null>(null);
|
||||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
// TODO: Initialize Y.js document and WebSocket provider
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
// TODO: Initialize Y.js (Phase 3)
|
||||||
console.log('useCollaboration: Would connect to session', sessionId);
|
|
||||||
// const ydoc = new Y.Doc();
|
|
||||||
// const provider = new WebsocketProvider(serverUrl, sessionId, ydoc);
|
|
||||||
|
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
|
return () => { setIsConnected(false); };
|
||||||
return () => {
|
|
||||||
// provider.destroy();
|
|
||||||
// ydoc.destroy();
|
|
||||||
setIsConnected(false);
|
|
||||||
};
|
|
||||||
}, [sessionId, serverUrl]);
|
}, [sessionId, serverUrl]);
|
||||||
|
|
||||||
const createSession = useCallback(async (name: string): Promise<string> => {
|
const createSession = useCallback(async (name: string) => {
|
||||||
// TODO: Create new Y.js document and return session ID
|
|
||||||
const newSessionId = `session-${Date.now()}`;
|
const newSessionId = `session-${Date.now()}`;
|
||||||
console.log('useCollaboration: Creating session', name, newSessionId);
|
|
||||||
return newSessionId;
|
return newSessionId;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const joinSession = useCallback(async (sessionIdToJoin: string): Promise<void> => {
|
const joinSession = useCallback(async (sessionIdToJoin: string) => {
|
||||||
// TODO: Join existing Y.js session
|
console.log('Joining session', sessionIdToJoin);
|
||||||
console.log('useCollaboration: Joining session', sessionIdToJoin);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const leaveSession = useCallback(() => {
|
const leaveSession = useCallback(() => {
|
||||||
// TODO: Disconnect from session
|
setSession(null); setParticipants([]); setIsConnected(false);
|
||||||
console.log('useCollaboration: Leaving session');
|
|
||||||
setSession(null);
|
|
||||||
setParticipants([]);
|
|
||||||
setIsConnected(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateCursor = useCallback((coordinate: Coordinate) => {
|
const updateCursor = useCallback((coordinate: Coordinate) => {}, []);
|
||||||
// TODO: Broadcast cursor position via Y.js awareness
|
const broadcastRouteChange = useCallback((route: Route) => {}, []);
|
||||||
// awareness.setLocalStateField('cursor', coordinate);
|
const broadcastWaypointChange = useCallback((waypoint: Waypoint) => {}, []);
|
||||||
}, []);
|
const broadcastLayerChange = useCallback((layer: MapLayer) => {}, []);
|
||||||
|
|
||||||
const broadcastRouteChange = useCallback((route: Route) => {
|
|
||||||
// TODO: Update Y.js shared route array
|
|
||||||
console.log('useCollaboration: Broadcasting route change', route.id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const broadcastWaypointChange = useCallback((waypoint: Waypoint) => {
|
|
||||||
// TODO: Update Y.js shared waypoint array
|
|
||||||
console.log('useCollaboration: Broadcasting waypoint change', waypoint.id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const broadcastLayerChange = useCallback((layer: MapLayer) => {
|
|
||||||
// TODO: Update Y.js shared layer array
|
|
||||||
console.log('useCollaboration: Broadcasting layer change', layer.id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
session,
|
session, participants, isConnected, createSession, joinSession, leaveSession,
|
||||||
participants,
|
updateCursor, broadcastRouteChange, broadcastWaypointChange, broadcastLayerChange,
|
||||||
isConnected,
|
|
||||||
createSession,
|
|
||||||
joinSession,
|
|
||||||
leaveSession,
|
|
||||||
updateCursor,
|
|
||||||
broadcastRouteChange,
|
|
||||||
broadcastWaypointChange,
|
|
||||||
broadcastLayerChange,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,124 +1,29 @@
|
||||||
/**
|
/**
|
||||||
* useLayers - Hook for managing map layers
|
* useLayers - Hook for managing map layers
|
||||||
*
|
|
||||||
* Provides:
|
|
||||||
* - Layer CRUD operations
|
|
||||||
* - Visibility and opacity controls
|
|
||||||
* - Layer ordering (z-index)
|
|
||||||
* - Preset layer templates
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import type { MapLayer, LayerType, LayerSource, LayerStyle } from '../types';
|
import type { MapLayer } from '../types';
|
||||||
|
|
||||||
interface UseLayersOptions {
|
interface UseLayersOptions {
|
||||||
initialLayers?: MapLayer[];
|
initialLayers?: MapLayer[];
|
||||||
onLayerChange?: (layers: MapLayer[]) => void;
|
onLayerChange?: (layers: MapLayer[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseLayersReturn {
|
export type LayerPreset = 'osm-standard' | 'osm-humanitarian' | 'satellite' | 'terrain' | 'cycling' | 'hiking';
|
||||||
layers: MapLayer[];
|
|
||||||
addLayer: (layer: Omit<MapLayer, 'id'>) => string;
|
|
||||||
removeLayer: (layerId: string) => void;
|
|
||||||
updateLayer: (layerId: string, updates: Partial<MapLayer>) => void;
|
|
||||||
toggleVisibility: (layerId: string) => void;
|
|
||||||
setOpacity: (layerId: string, opacity: number) => void;
|
|
||||||
reorderLayers: (layerIds: string[]) => void;
|
|
||||||
getLayer: (layerId: string) => MapLayer | undefined;
|
|
||||||
addPresetLayer: (preset: LayerPreset) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LayerPreset =
|
|
||||||
| 'osm-standard'
|
|
||||||
| 'osm-humanitarian'
|
|
||||||
| 'satellite'
|
|
||||||
| 'terrain'
|
|
||||||
| 'cycling'
|
|
||||||
| 'hiking';
|
|
||||||
|
|
||||||
const PRESET_LAYERS: Record<LayerPreset, Omit<MapLayer, 'id'>> = {
|
const PRESET_LAYERS: Record<LayerPreset, Omit<MapLayer, 'id'>> = {
|
||||||
'osm-standard': {
|
'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' } },
|
||||||
name: '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' } },
|
||||||
type: 'basemap',
|
'satellite': { name: 'Satellite', type: 'satellite', visible: false, opacity: 1, zIndex: 0, source: { type: 'raster', tiles: [] } },
|
||||||
visible: true,
|
'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' } },
|
||||||
opacity: 1,
|
'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' } },
|
||||||
zIndex: 0,
|
'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' } },
|
||||||
source: {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
|
||||||
attribution: '© OpenStreetMap contributors',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'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: '© OpenStreetMap contributors, Tiles: HOT',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'satellite': {
|
|
||||||
name: 'Satellite',
|
|
||||||
type: 'satellite',
|
|
||||||
visible: false,
|
|
||||||
opacity: 1,
|
|
||||||
zIndex: 0,
|
|
||||||
source: {
|
|
||||||
type: 'raster',
|
|
||||||
// Note: Would need proper satellite tile source (e.g., Mapbox, ESRI)
|
|
||||||
tiles: [],
|
|
||||||
attribution: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'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: 'Map tiles by Stamen Design',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'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;
|
let layerIdCounter = 0;
|
||||||
const generateLayerId = () => `layer-${++layerIdCounter}-${Date.now()}`;
|
|
||||||
|
|
||||||
export function useLayers({
|
export function useLayers({ initialLayers = [], onLayerChange }: UseLayersOptions = {}) {
|
||||||
initialLayers = [],
|
|
||||||
onLayerChange,
|
|
||||||
}: UseLayersOptions = {}): UseLayersReturn {
|
|
||||||
const [layers, setLayers] = useState<MapLayer[]>(initialLayers);
|
const [layers, setLayers] = useState<MapLayer[]>(initialLayers);
|
||||||
|
|
||||||
const updateAndNotify = useCallback((newLayers: MapLayer[]) => {
|
const updateAndNotify = useCallback((newLayers: MapLayer[]) => {
|
||||||
|
|
@ -126,68 +31,24 @@ export function useLayers({
|
||||||
onLayerChange?.(newLayers);
|
onLayerChange?.(newLayers);
|
||||||
}, [onLayerChange]);
|
}, [onLayerChange]);
|
||||||
|
|
||||||
const addLayer = useCallback((layer: Omit<MapLayer, 'id'>): string => {
|
const addLayer = useCallback((layer: Omit<MapLayer, 'id'>) => {
|
||||||
const id = generateLayerId();
|
const id = `layer-${++layerIdCounter}-${Date.now()}`;
|
||||||
const newLayer: MapLayer = { ...layer, id };
|
updateAndNotify([...layers, { ...layer, id }]);
|
||||||
updateAndNotify([...layers, newLayer]);
|
|
||||||
return id;
|
return id;
|
||||||
}, [layers, updateAndNotify]);
|
}, [layers, updateAndNotify]);
|
||||||
|
|
||||||
const removeLayer = useCallback((layerId: string) => {
|
const removeLayer = useCallback((layerId: string) => updateAndNotify(layers.filter((l) => l.id !== layerId)), [layers, updateAndNotify]);
|
||||||
updateAndNotify(layers.filter((l) => l.id !== layerId));
|
const updateLayer = useCallback((layerId: string, updates: Partial<MapLayer>) => updateAndNotify(layers.map((l) => l.id === layerId ? { ...l, ...updates } : l)), [layers, updateAndNotify]);
|
||||||
}, [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 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 reorderLayers = useCallback((layerIds: string[]) => {
|
||||||
const reordered = layerIds
|
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);
|
||||||
.map((id, index) => {
|
|
||||||
const layer = layers.find((l) => l.id === id);
|
|
||||||
return layer ? { ...layer, zIndex: index } : null;
|
|
||||||
})
|
|
||||||
.filter((l): l is MapLayer => l !== null);
|
|
||||||
updateAndNotify(reordered);
|
updateAndNotify(reordered);
|
||||||
}, [layers, updateAndNotify]);
|
}, [layers, updateAndNotify]);
|
||||||
|
const getLayer = useCallback((layerId: string) => layers.find((l) => l.id === layerId), [layers]);
|
||||||
|
const addPresetLayer = useCallback((preset: LayerPreset) => addLayer(PRESET_LAYERS[preset]), [addLayer]);
|
||||||
|
|
||||||
const getLayer = useCallback((layerId: string): MapLayer | undefined => {
|
return { layers, addLayer, removeLayer, updateLayer, toggleVisibility, setOpacity, reorderLayers, getLayer, addPresetLayer };
|
||||||
return layers.find((l) => l.id === layerId);
|
|
||||||
}, [layers]);
|
|
||||||
|
|
||||||
const addPresetLayer = useCallback((preset: LayerPreset): string => {
|
|
||||||
const presetConfig = PRESET_LAYERS[preset];
|
|
||||||
if (!presetConfig) {
|
|
||||||
throw new Error(`Unknown layer preset: ${preset}`);
|
|
||||||
}
|
|
||||||
return addLayer(presetConfig);
|
|
||||||
}, [addLayer]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
layers,
|
|
||||||
addLayer,
|
|
||||||
removeLayer,
|
|
||||||
updateLayer,
|
|
||||||
toggleVisibility,
|
|
||||||
setOpacity,
|
|
||||||
reorderLayers,
|
|
||||||
getLayer,
|
|
||||||
addPresetLayer,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useLayers;
|
export default useLayers;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* useMapInstance - Hook for managing MapLibre GL JS instance
|
* useMapInstance - Hook for managing MapLibre GL JS instance
|
||||||
*
|
|
||||||
* Provides:
|
|
||||||
* - Map initialization and cleanup
|
|
||||||
* - Viewport state management
|
|
||||||
* - Event handlers (click, move, zoom)
|
|
||||||
* - Ref to underlying map instance for advanced usage
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
|
@ -19,83 +13,38 @@ interface UseMapInstanceOptions {
|
||||||
onClick?: (coordinate: Coordinate) => void;
|
onClick?: (coordinate: Coordinate) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseMapInstanceReturn {
|
|
||||||
isLoaded: boolean;
|
|
||||||
viewport: MapViewport;
|
|
||||||
setViewport: (viewport: MapViewport) => void;
|
|
||||||
flyTo: (coordinate: Coordinate, zoom?: number) => void;
|
|
||||||
fitBounds: (bounds: [[number, number], [number, number]]) => void;
|
|
||||||
getMap: () => unknown; // MapLibre map instance
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_VIEWPORT: MapViewport = {
|
const DEFAULT_VIEWPORT: MapViewport = {
|
||||||
center: { lat: 0, lng: 0 },
|
center: { lat: 0, lng: 0 }, zoom: 2, bearing: 0, pitch: 0,
|
||||||
zoom: 2,
|
|
||||||
bearing: 0,
|
|
||||||
pitch: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useMapInstance({
|
export function useMapInstance({
|
||||||
container,
|
container, config, initialViewport = DEFAULT_VIEWPORT, onViewportChange,
|
||||||
config,
|
}: UseMapInstanceOptions) {
|
||||||
initialViewport = DEFAULT_VIEWPORT,
|
|
||||||
onViewportChange,
|
|
||||||
onClick,
|
|
||||||
}: UseMapInstanceOptions): UseMapInstanceReturn {
|
|
||||||
const mapRef = useRef<unknown>(null);
|
const mapRef = useRef<unknown>(null);
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const [viewport, setViewportState] = useState<MapViewport>(initialViewport);
|
const [viewport, setViewportState] = useState<MapViewport>(initialViewport);
|
||||||
|
|
||||||
// Initialize map
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
// TODO: Initialize MapLibre GL JS (Phase 1)
|
||||||
// TODO: Initialize MapLibre GL JS
|
|
||||||
// const map = new maplibregl.Map({
|
|
||||||
// container,
|
|
||||||
// style: config.styleUrl,
|
|
||||||
// center: [initialViewport.center.lng, initialViewport.center.lat],
|
|
||||||
// zoom: initialViewport.zoom,
|
|
||||||
// bearing: initialViewport.bearing,
|
|
||||||
// pitch: initialViewport.pitch,
|
|
||||||
// });
|
|
||||||
|
|
||||||
console.log('useMapInstance: Would initialize map with config', config);
|
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
|
return () => { mapRef.current = null; setIsLoaded(false); };
|
||||||
return () => {
|
|
||||||
// map.remove();
|
|
||||||
mapRef.current = null;
|
|
||||||
setIsLoaded(false);
|
|
||||||
};
|
|
||||||
}, [container]);
|
}, [container]);
|
||||||
|
|
||||||
const setViewport = useCallback((newViewport: MapViewport) => {
|
const setViewport = useCallback((newViewport: MapViewport) => {
|
||||||
setViewportState(newViewport);
|
setViewportState(newViewport);
|
||||||
onViewportChange?.(newViewport);
|
onViewportChange?.(newViewport);
|
||||||
// TODO: Update map instance
|
|
||||||
}, [onViewportChange]);
|
}, [onViewportChange]);
|
||||||
|
|
||||||
const flyTo = useCallback((coordinate: Coordinate, zoom?: number) => {
|
const flyTo = useCallback((coordinate: Coordinate, zoom?: number) => {
|
||||||
// TODO: Implement flyTo animation
|
console.log('flyTo', coordinate, zoom);
|
||||||
console.log('useMapInstance: flyTo', coordinate, zoom);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fitBounds = useCallback((bounds: [[number, number], [number, number]]) => {
|
const fitBounds = useCallback((bounds: [[number, number], [number, number]]) => {
|
||||||
// TODO: Implement fitBounds
|
console.log('fitBounds', bounds);
|
||||||
console.log('useMapInstance: fitBounds', bounds);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getMap = useCallback(() => mapRef.current, []);
|
return { isLoaded, viewport, setViewport, flyTo, fitBounds, getMap: () => mapRef.current };
|
||||||
|
|
||||||
return {
|
|
||||||
isLoaded,
|
|
||||||
viewport,
|
|
||||||
setViewport,
|
|
||||||
flyTo,
|
|
||||||
fitBounds,
|
|
||||||
getMap,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useMapInstance;
|
export default useMapInstance;
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* useRouting - Hook for route calculation and management
|
* useRouting - Hook for route calculation and management
|
||||||
*
|
|
||||||
* Provides:
|
|
||||||
* - Route calculation between waypoints
|
|
||||||
* - Multi-route comparison
|
|
||||||
* - Route optimization (reorder waypoints)
|
|
||||||
* - Isochrone calculation
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import type {
|
import type { Waypoint, Route, RoutingOptions, RoutingServiceConfig, Coordinate } from '../types';
|
||||||
Waypoint,
|
|
||||||
Route,
|
|
||||||
RoutingOptions,
|
|
||||||
RoutingServiceConfig,
|
|
||||||
Coordinate,
|
|
||||||
} from '../types';
|
|
||||||
import { RoutingService } from '../services/RoutingService';
|
import { RoutingService } from '../services/RoutingService';
|
||||||
|
|
||||||
interface UseRoutingOptions {
|
interface UseRoutingOptions {
|
||||||
|
|
@ -24,40 +12,15 @@ interface UseRoutingOptions {
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseRoutingReturn {
|
export function useRouting({ config, onRouteCalculated, onError }: UseRoutingOptions) {
|
||||||
routes: Route[];
|
|
||||||
isCalculating: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
calculateRoute: (waypoints: Waypoint[], options?: Partial<RoutingOptions>) => Promise<Route | null>;
|
|
||||||
calculateAlternatives: (waypoints: Waypoint[], count?: number) => Promise<Route[]>;
|
|
||||||
optimizeOrder: (waypoints: Waypoint[]) => Promise<Waypoint[]>;
|
|
||||||
calculateIsochrone: (center: Coordinate, minutes: number[]) => Promise<GeoJSON.FeatureCollection>;
|
|
||||||
clearRoutes: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRouting({
|
|
||||||
config,
|
|
||||||
onRouteCalculated,
|
|
||||||
onError,
|
|
||||||
}: UseRoutingOptions): UseRoutingReturn {
|
|
||||||
const [routes, setRoutes] = useState<Route[]>([]);
|
const [routes, setRoutes] = useState<Route[]>([]);
|
||||||
const [isCalculating, setIsCalculating] = useState(false);
|
const [isCalculating, setIsCalculating] = useState(false);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
const service = new RoutingService(config);
|
const service = new RoutingService(config);
|
||||||
|
|
||||||
const calculateRoute = useCallback(async (
|
const calculateRoute = useCallback(async (waypoints: Waypoint[], options?: Partial<RoutingOptions>) => {
|
||||||
waypoints: Waypoint[],
|
if (waypoints.length < 2) { setError(new Error('At least 2 waypoints required')); return null; }
|
||||||
options?: Partial<RoutingOptions>
|
setIsCalculating(true); setError(null);
|
||||||
): Promise<Route | null> => {
|
|
||||||
if (waypoints.length < 2) {
|
|
||||||
setError(new Error('At least 2 waypoints required'));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsCalculating(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const route = await service.calculateRoute(waypoints, options);
|
const route = await service.calculateRoute(waypoints, options);
|
||||||
setRoutes((prev) => [...prev, route]);
|
setRoutes((prev) => [...prev, route]);
|
||||||
|
|
@ -65,85 +28,37 @@ export function useRouting({
|
||||||
return route;
|
return route;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? err : new Error('Route calculation failed');
|
const error = err instanceof Error ? err : new Error('Route calculation failed');
|
||||||
setError(error);
|
setError(error); onError?.(error); return null;
|
||||||
onError?.(error);
|
} finally { setIsCalculating(false); }
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setIsCalculating(false);
|
|
||||||
}
|
|
||||||
}, [service, onRouteCalculated, onError]);
|
}, [service, onRouteCalculated, onError]);
|
||||||
|
|
||||||
const calculateAlternatives = useCallback(async (
|
const calculateAlternatives = useCallback(async (waypoints: Waypoint[], count = 3) => {
|
||||||
waypoints: Waypoint[],
|
setIsCalculating(true); setError(null);
|
||||||
count = 3
|
|
||||||
): Promise<Route[]> => {
|
|
||||||
setIsCalculating(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const alternatives = await service.calculateAlternatives(waypoints, count);
|
const alternatives = await service.calculateAlternatives(waypoints, count);
|
||||||
setRoutes(alternatives);
|
setRoutes(alternatives);
|
||||||
return alternatives;
|
return alternatives;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? err : new Error('Alternative routes calculation failed');
|
const error = err instanceof Error ? err : new Error('Failed');
|
||||||
setError(error);
|
setError(error); onError?.(error); return [];
|
||||||
onError?.(error);
|
} finally { setIsCalculating(false); }
|
||||||
return [];
|
|
||||||
} finally {
|
|
||||||
setIsCalculating(false);
|
|
||||||
}
|
|
||||||
}, [service, onError]);
|
}, [service, onError]);
|
||||||
|
|
||||||
const optimizeOrder = useCallback(async (waypoints: Waypoint[]): Promise<Waypoint[]> => {
|
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);
|
setIsCalculating(true);
|
||||||
setError(null);
|
try { return await service.calculateIsochrone(center, minutes); }
|
||||||
|
catch { return { type: 'FeatureCollection' as const, features: [] }; }
|
||||||
|
finally { setIsCalculating(false); }
|
||||||
|
}, [service]);
|
||||||
|
|
||||||
try {
|
return { routes, isCalculating, error, calculateRoute, calculateAlternatives, optimizeOrder, calculateIsochrone, clearRoutes: () => setRoutes([]) };
|
||||||
return await service.optimizeWaypointOrder(waypoints);
|
|
||||||
} catch (err) {
|
|
||||||
const error = err instanceof Error ? err : new Error('Waypoint optimization failed');
|
|
||||||
setError(error);
|
|
||||||
onError?.(error);
|
|
||||||
return waypoints;
|
|
||||||
} finally {
|
|
||||||
setIsCalculating(false);
|
|
||||||
}
|
|
||||||
}, [service, onError]);
|
|
||||||
|
|
||||||
const calculateIsochrone = useCallback(async (
|
|
||||||
center: Coordinate,
|
|
||||||
minutes: number[]
|
|
||||||
): Promise<GeoJSON.FeatureCollection> => {
|
|
||||||
setIsCalculating(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await service.calculateIsochrone(center, minutes);
|
|
||||||
} catch (err) {
|
|
||||||
const error = err instanceof Error ? err : new Error('Isochrone calculation failed');
|
|
||||||
setError(error);
|
|
||||||
onError?.(error);
|
|
||||||
return { type: 'FeatureCollection', features: [] };
|
|
||||||
} finally {
|
|
||||||
setIsCalculating(false);
|
|
||||||
}
|
|
||||||
}, [service, onError]);
|
|
||||||
|
|
||||||
const clearRoutes = useCallback(() => {
|
|
||||||
setRoutes([]);
|
|
||||||
setError(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
routes,
|
|
||||||
isCalculating,
|
|
||||||
error,
|
|
||||||
calculateRoute,
|
|
||||||
calculateAlternatives,
|
|
||||||
optimizeOrder,
|
|
||||||
calculateIsochrone,
|
|
||||||
clearRoutes,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useRouting;
|
export default useRouting;
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,11 @@
|
||||||
* Open Mapping Type Definitions
|
* Open Mapping Type Definitions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Core Geographic Types
|
// Core Geographic Types
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface Coordinate {
|
export interface Coordinate {
|
||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
alt?: number; // elevation in meters
|
alt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoundingBox {
|
export interface BoundingBox {
|
||||||
|
|
@ -19,10 +16,7 @@ export interface BoundingBox {
|
||||||
west: number;
|
west: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Waypoint & Route Types
|
// Waypoint & Route Types
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface Waypoint {
|
export interface Waypoint {
|
||||||
id: string;
|
id: string;
|
||||||
coordinate: Coordinate;
|
coordinate: Coordinate;
|
||||||
|
|
@ -32,7 +26,7 @@ export interface Waypoint {
|
||||||
color?: string;
|
color?: string;
|
||||||
arrivalTime?: Date;
|
arrivalTime?: Date;
|
||||||
departureTime?: Date;
|
departureTime?: Date;
|
||||||
stayDuration?: number; // minutes
|
stayDuration?: number;
|
||||||
budget?: WaypointBudget;
|
budget?: WaypointBudget;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
@ -57,10 +51,10 @@ export interface Route {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteSummary {
|
export interface RouteSummary {
|
||||||
distance: number; // meters
|
distance: number;
|
||||||
duration: number; // seconds
|
duration: number;
|
||||||
ascent?: number; // meters
|
ascent?: number;
|
||||||
descent?: number; // meters
|
descent?: number;
|
||||||
cost?: RouteCost;
|
cost?: RouteCost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +66,7 @@ export interface RouteCost {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteLeg {
|
export interface RouteLeg {
|
||||||
startWaypoint: string; // waypoint id
|
startWaypoint: string;
|
||||||
endWaypoint: string;
|
endWaypoint: string;
|
||||||
distance: number;
|
distance: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
|
@ -113,10 +107,7 @@ export interface RouteMetadata {
|
||||||
shareLink?: string;
|
shareLink?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Routing Profiles & Options
|
// Routing Profiles & Options
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type RoutingProfile =
|
export type RoutingProfile =
|
||||||
| 'car' | 'truck' | 'motorcycle'
|
| 'car' | 'truck' | 'motorcycle'
|
||||||
| 'bicycle' | 'mountain_bike' | 'road_bike'
|
| 'bicycle' | 'mountain_bike' | 'road_bike'
|
||||||
|
|
@ -130,7 +121,7 @@ export interface RoutingOptions {
|
||||||
avoidHighways?: boolean;
|
avoidHighways?: boolean;
|
||||||
avoidFerries?: boolean;
|
avoidFerries?: boolean;
|
||||||
preferScenic?: boolean;
|
preferScenic?: boolean;
|
||||||
alternatives?: number; // number of alternative routes to compute
|
alternatives?: number;
|
||||||
departureTime?: Date;
|
departureTime?: Date;
|
||||||
arrivalTime?: Date;
|
arrivalTime?: Date;
|
||||||
optimize?: OptimizationType;
|
optimize?: OptimizationType;
|
||||||
|
|
@ -148,10 +139,7 @@ export interface RoutingConstraints {
|
||||||
vehicleWidth?: number;
|
vehicleWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Layer Management
|
// Layer Management
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface MapLayer {
|
export interface MapLayer {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -187,10 +175,7 @@ export interface LayerStyle {
|
||||||
iconSize?: number;
|
iconSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Collaboration Types
|
// Collaboration Types
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface CollaborationSession {
|
export interface CollaborationSession {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -227,10 +212,7 @@ export interface MapViewport {
|
||||||
pitch: number;
|
pitch: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// Calendar & Scheduling
|
||||||
// Calendar & Scheduling Integration
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface TripItinerary {
|
export interface TripItinerary {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -280,13 +262,10 @@ export interface BudgetItem {
|
||||||
date?: Date;
|
date?: Date;
|
||||||
waypointId?: string;
|
waypointId?: string;
|
||||||
eventId?: string;
|
eventId?: string;
|
||||||
receipt?: string; // URL or file path
|
receipt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Service Configurations
|
// Service Configurations
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface RoutingServiceConfig {
|
export interface RoutingServiceConfig {
|
||||||
provider: 'osrm' | 'valhalla' | 'graphhopper' | 'openrouteservice';
|
provider: 'osrm' | 'valhalla' | 'graphhopper' | 'openrouteservice';
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|
|
||||||
|
|
@ -68,50 +68,18 @@ import "@/css/style.css"
|
||||||
import "@/css/obsidian-browser.css"
|
import "@/css/obsidian-browser.css"
|
||||||
|
|
||||||
// Helper to validate and fix tldraw IndexKey format
|
// Helper to validate and fix tldraw IndexKey format
|
||||||
// tldraw uses fractional indexing where the first letter encodes integer part length:
|
// tldraw uses fractional indexing: a0, a1, b10, c100, a1V4rr, etc.
|
||||||
// - 'a' = 1-digit integer (a0-a9), 'b' = 2-digit (b10-b99), 'c' = 3-digit (c100-c999), etc.
|
// - First letter (a-z) indicates integer part length (a=1 digit, b=2 digits, etc.)
|
||||||
// - Optional fractional part can follow (a1V, a1V4rr, etc.)
|
// - Uppercase (A-Z) for negative/special indices
|
||||||
// Common invalid formats from old data: "b1" (b expects 2 digits but has 1)
|
|
||||||
function sanitizeIndex(index: any): IndexKey {
|
function sanitizeIndex(index: any): IndexKey {
|
||||||
if (!index || typeof index !== 'string' || index.length === 0) {
|
if (!index || typeof index !== 'string' || index.length === 0) {
|
||||||
return 'a1' as IndexKey
|
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)) {
|
if (/^[a-zA-Z][a-zA-Z0-9]+$/.test(index)) {
|
||||||
return index as IndexKey
|
return index as IndexKey
|
||||||
}
|
}
|
||||||
|
// Fallback for invalid formats
|
||||||
return 'a1' as IndexKey
|
return 'a1' as IndexKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue