Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd7ec6a383 | |||
| 89670ecad3 | |||
| 0617822116 | |||
| 50b5c9325c | |||
| 428e0546bd | |||
| 54999a52b7 | |||
| 32454a7d19 | |||
| f215133997 | |||
| 32bec849ee | |||
| d254d286c7 |
47
changelog.md
@@ -1,5 +1,52 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-05 - 1.5.0 - feat(dees-geo-map)
|
||||
Highlight current navigation step with progress, mark completed steps, auto-scroll turn-by-turn list, expose guidance state for synchronization, and refine instruction/voice wording
|
||||
|
||||
- Add .nav-step.current and .nav-step.completed styles and a .nav-step-progress-bar to visually indicate current step and progress
|
||||
- Expose getGuidanceState callback to read current navigation/guidance state for turn-by-turn synchronization
|
||||
- Auto-scroll the turn-by-turn list when the active step changes and add scrollToCurrentStep(stepIndex) to perform smooth scrolling
|
||||
- Render current/completed state in the step list (checkmark for completed) and calculate/display progress percent for the active step
|
||||
- Use step.ref as a fallback for step.name and add handling/icons for the 'new name' maneuver
|
||||
- Refine instruction formatting: use 'on' for continue maneuvers and 'onto' for turns/merges, and update voice output to reflect this
|
||||
|
||||
## 2026-02-05 - 1.4.0 - feat(dees-geo-map)
|
||||
Add voice-guided navigation with TTS, navigation guide controller and mock GPS simulator
|
||||
|
||||
- Introduce VoiceSynthesisManager (ts_web/elements/00group-map/dees-geo-map/geo-map.voice.ts) — queue-based Web Speech API wrapper with urgent interruption and navigation-specific helpers (approach, maneuver, arrival, off-route).
|
||||
- Add NavigationGuideController (geo-map.navigation-guide.ts) to provide real-time GPS guidance, step progression, off-route/arrival detection, guidance events and navigation camera controls.
|
||||
- Add MockGPSSimulator (ts_web/elements/00group-map/dees-geo-map/geo-map.mock-gps.ts) to simulate movement along a route with speed presets, jitter, and control methods (start/pause/stop/jump).
|
||||
- Expose new public APIs on DeesGeoMap: setPosition, startGuidance, stopGuidance, setVoiceEnabled, isVoiceEnabled, getGuidanceState, isNavigating, createMockGPSSimulator, getMockGPSSimulator, getGuidanceController, and navigation camera config helpers.
|
||||
- Add guidance UI/assets: new icons, guidance panel rendering, styles (guidanceStyles, maplibreMarkerStyles), and demo controls to ts_web/elements/00group-map/dees-geo-map/dees-geo-map.demo.ts.
|
||||
- Wire guidance controller into lifecycle: initialize when map is created, dispatch 'guidance-event' CustomEvents, clean up on teardown, and render guidance panel during navigation.
|
||||
- Update module exports (index.ts) to re-export VoiceSynthesisManager, NavigationGuideController, MockGPSSimulator and related types.
|
||||
|
||||
## 2026-02-05 - 1.3.0 - feat(dees-geo-map)
|
||||
Add dark/light theme support for UI and map tiles, subscribe to theme changes and add traffic toggle in navigation
|
||||
|
||||
- Replace hard-coded colors with cssManager.bdTheme(...) across component styles to enable automatic light/dark theming
|
||||
- When mapStyle is 'osm', switch between CartoDB Voyager (light) and CartoDB Dark Matter (dark) vector basemaps
|
||||
- Subscribe to domtools.themeManager.themeObservable to update the map style at runtime and unsubscribe on disconnectedCallback
|
||||
- Add a traffic-aware routing toggle in the navigation panel and expose callbacks (onTrafficToggle, getTrafficEnabled) to wire UI state to the trafficController
|
||||
- Integrate traffic toggle state into navigation controller callbacks and simplify traffic controller render usage
|
||||
|
||||
## 2026-02-05 - 1.2.0 - feat(map)
|
||||
Introduce CSS Grid sidebar layout and integrated navigation + draw panels, add directions view and step-to-map interaction
|
||||
|
||||
- Replace overlay/header layout with a CSS Grid: left and right sidebars (controlled via --left-panel-width/--right-panel-width) that push the map area instead of overlaying it.
|
||||
- Move navigation panel from map overlay into a left sidebar and add NavigationController viewMode with a new directions view (summary, back button) and planning view separation.
|
||||
- Add a right-side draw tools panel (draw panel UI, tool grid, actions) with isDrawPanelOpen state and toolbar toggle to show/hide draw tools.
|
||||
- Add interactivity: clicking a turn-by-turn step flies the map to that step (flyToStep), and directions->planning switching via setViewMode/back button.
|
||||
- Numerous style additions and adjustments: header toolbar grid area, sidebar/draw panel styling, nav directions styles, z-index tweaks, cursor and hover states.
|
||||
- Add new arrowLeft icon and reorganize toolbar controls (navigation/draw toggles, traffic, zoom) to accommodate sidebar layout
|
||||
|
||||
## 2026-02-05 - 1.1.1 - fix(assets)
|
||||
remove header toolbar PNG assets (header-toolbar-fixed, header-toolbar-full, header-toolbar-layout, header-toolbar-v3)
|
||||
|
||||
- Deleted four header toolbar PNG files: header-toolbar-fixed.png, header-toolbar-full.png, header-toolbar-layout.png, header-toolbar-v3.png
|
||||
- Change is asset-only (binary removals); no source code or behavioral changes in JS/TS
|
||||
- Recommended patch bump from 1.1.0 to 1.1.1
|
||||
|
||||
## 2026-02-05 - 1.1.0 - feat(geo-map)
|
||||
add live traffic visualization and traffic-aware routing with pluggable providers and UI integration
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 951 KiB |
|
Before Width: | Height: | Size: 968 KiB |
BIN
marker-debug.png
Normal file
|
After Width: | Height: | Size: 286 KiB |
BIN
marker-positioning-issue.png
Normal file
|
After Width: | Height: | Size: 342 KiB |
BIN
markers-after-stop.png
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
markers-before-navigation.png
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
markers-during-navigation.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
markers-flat-view.png
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
navigation-3d-markers.png
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
navigation-marker-check.png
Normal file
|
After Width: | Height: | Size: 292 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@design.estate/dees-catalog-geo",
|
||||
"version": "1.1.0",
|
||||
"version": "1.5.0",
|
||||
"private": false,
|
||||
"description": "A geospatial web components library with MapLibre GL JS maps and terra-draw drawing tools",
|
||||
"main": "dist_ts_web/index.js",
|
||||
|
||||
209
readme.hints.md
@@ -26,6 +26,8 @@ Geospatial web components library using MapLibre GL JS for map rendering and ter
|
||||
| `showTraffic` | `boolean` | `false` | Enable traffic layer visualization |
|
||||
| `trafficApiKey` | `string` | `''` | HERE API key for traffic data |
|
||||
| `trafficProvider` | `ITrafficProvider` | `null` | Custom traffic data provider |
|
||||
| `enableGuidance` | `boolean` | `false` | Enable voice-guided navigation |
|
||||
| `voiceConfig` | `Partial<IVoiceConfig>` | `{}` | Voice synthesis configuration |
|
||||
|
||||
### Drawing Tools (TDrawTool)
|
||||
- `point` - Draw points
|
||||
@@ -45,6 +47,7 @@ Geospatial web components library using MapLibre GL JS for map rendering and ter
|
||||
- `address-selected` - Fired when a search result is selected
|
||||
- `route-calculated` - Fired when a navigation route is calculated (includes route, startPoint, endPoint, mode)
|
||||
- `traffic-updated` - Fired when traffic data is refreshed
|
||||
- `guidance-event` - Fired during voice-guided navigation (includes type, position, stepIndex, instruction)
|
||||
|
||||
### Public Methods
|
||||
- `getFeatures()` - Get all drawn features
|
||||
@@ -68,6 +71,22 @@ Geospatial web components library using MapLibre GL JS for map rendering and ter
|
||||
- `setTrafficProvider(provider)` - Set custom traffic provider
|
||||
- `supportsTrafficRouting()` - Check if traffic-aware routing is available
|
||||
- `getTrafficController()` - Get the TrafficController instance
|
||||
- `setPosition(coords, heading?, speed?)` - Set current GPS position for navigation guidance
|
||||
- `startGuidance()` - Start voice-guided navigation for the current route
|
||||
- `stopGuidance()` - Stop voice-guided navigation
|
||||
- `setVoiceEnabled(enabled)` - Enable/disable voice guidance
|
||||
- `isVoiceEnabled()` - Check if voice guidance is enabled
|
||||
- `getGuidanceState()` - Get current navigation guidance state
|
||||
- `isNavigating()` - Check if actively navigating
|
||||
- `createMockGPSSimulator(config?)` - Create a mock GPS simulator for testing/demo
|
||||
- `getMockGPSSimulator()` - Get the mock GPS simulator instance
|
||||
- `getGuidanceController()` - Get the NavigationGuideController instance
|
||||
- `setNavigationFollowPosition(enabled)` - Enable/disable camera following GPS position during navigation
|
||||
- `setNavigationFollowBearing(enabled)` - Enable/disable camera rotating with heading during navigation
|
||||
- `setNavigationPitch(pitch)` - Set navigation camera pitch (0-85 degrees, default 60)
|
||||
- `setNavigationZoom(zoom)` - Set navigation zoom level (default 17)
|
||||
- `getNavigationCameraConfig()` - Get full navigation camera configuration
|
||||
- `setNavigationCameraConfig(config)` - Set navigation camera configuration
|
||||
|
||||
### Context Menu
|
||||
Right-click on the map to access a context menu with the following options:
|
||||
@@ -82,6 +101,7 @@ The navigation panel (`showNavigation={true}`) provides A-to-B routing using OSR
|
||||
- **Transport modes**: Driving, Walking, Cycling
|
||||
- **Point selection**: Type an address or click on the map
|
||||
- **Route display**: Blue line overlay with turn-by-turn directions
|
||||
- **Click on step**: Click any turn-by-turn step to fly/pan the map to that location
|
||||
- **API**: Uses free OSRM API (https://router.project-osrm.org) with fair-use rate limit
|
||||
- **Traffic-aware routing**: When a traffic provider is configured, shows duration with/without traffic
|
||||
|
||||
@@ -147,7 +167,10 @@ ts_web/
|
||||
├── geo-map.search.ts # SearchController class
|
||||
├── geo-map.navigation.ts # NavigationController class
|
||||
├── geo-map.traffic.ts # TrafficController class
|
||||
└── geo-map.traffic.providers.ts # Traffic provider implementations
|
||||
├── geo-map.traffic.providers.ts # Traffic provider implementations
|
||||
├── geo-map.voice.ts # VoiceSynthesisManager class
|
||||
├── geo-map.navigation-guide.ts # NavigationGuideController class
|
||||
└── geo-map.mock-gps.ts # MockGPSSimulator class
|
||||
```
|
||||
|
||||
## Modular Architecture
|
||||
@@ -183,6 +206,34 @@ Traffic data provider implementations:
|
||||
- `HereTrafficProvider` - HERE Traffic API v7 (freemium)
|
||||
- `ValhallaTrafficProvider` - Self-hosted Valhalla server
|
||||
|
||||
### geo-map.voice.ts
|
||||
`VoiceSynthesisManager` class for voice-guided navigation:
|
||||
- Uses Web Speech API for text-to-speech
|
||||
- Queue-based speech with interrupt capability for urgent instructions
|
||||
- Configurable language, rate, pitch, volume
|
||||
- Navigation-specific methods: `speakApproach()`, `speakManeuver()`, `speakArrival()`, `speakOffRoute()`
|
||||
- Graceful fallback if speech synthesis not supported
|
||||
|
||||
### geo-map.navigation-guide.ts
|
||||
`NavigationGuideController` class for real-time GPS navigation guidance:
|
||||
- Position tracking with GPS updates via `updatePosition()` or `setPosition()`
|
||||
- Step progression tracking along route
|
||||
- Distance thresholds for voice announcements (500m, 200m, 50m, at maneuver)
|
||||
- Off-route detection (>50m from route line)
|
||||
- Arrival detection (within 30m of destination)
|
||||
- Renders GPS position marker on map with heading indicator
|
||||
- Emits guidance events: `approach-maneuver`, `execute-maneuver`, `step-change`, `off-route`, `arrived`
|
||||
- **Camera following**: Automatically moves camera to follow GPS position with smooth transitions
|
||||
- **Camera configuration**: Configurable pitch (60° default for 3D view), zoom (17 default), and bearing following
|
||||
|
||||
### geo-map.mock-gps.ts
|
||||
`MockGPSSimulator` class for testing/demo:
|
||||
- Interpolates positions along route geometry
|
||||
- Speed presets: Walking (5 km/h), Cycling (20 km/h), City (50 km/h), Highway (100 km/h)
|
||||
- Configurable update interval and GPS jitter
|
||||
- Calculates heading between consecutive points
|
||||
- Methods: `start()`, `pause()`, `stop()`, `jumpToProgress()`
|
||||
|
||||
### Usage of Controllers
|
||||
```typescript
|
||||
// SearchController is reusable
|
||||
@@ -209,34 +260,75 @@ const nav = new NavigationController({
|
||||
|
||||
### UI Layout
|
||||
|
||||
The component uses a header toolbar above the map for a cleaner layout:
|
||||
The component uses a CSS Grid layout with sidebars that **push** the map (not overlay):
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ HEADER TOOLBAR │
|
||||
│ [Draw Tools] | [Search Bar] | [Nav Toggle] [Traffic] [Zoom +/-] │
|
||||
├──────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [Navigation] MAP │
|
||||
│ (toggleable) │
|
||||
│ │
|
||||
│ [Traffic Legend] [Feature Count] │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
│ [Nav Toggle] | [Search Bar] | [Draw Toggle] [Traffic] [+/-] │
|
||||
├───────────────┬──────────────────────────────┬───────────────────────┤
|
||||
│ LEFT SIDEBAR │ MAP │ RIGHT SIDEBAR │
|
||||
│ (Navigation) │ │ (Draw Tools) │
|
||||
│ │ │ │
|
||||
│ - Mode select │ │ [Point] [Line] │
|
||||
│ - Start input │ │ [Polygon][Rect] │
|
||||
│ - End input │ │ [Circle] [Free] │
|
||||
│ - Route steps │ │ ───────────────── │
|
||||
│ │ [Traffic Legend] │ [Select & Edit] │
|
||||
│ │ [Feature Count] │ [Clear All] │
|
||||
└───────────────┴──────────────────────────────┴───────────────────────┘
|
||||
```
|
||||
|
||||
**Header Toolbar Sections:**
|
||||
- **Left**: Draw tools (Point, Line, Polygon, Rectangle, Circle, Freehand, Select, Clear)
|
||||
- **Center**: Search bar (expandable width)
|
||||
- **Right**: Navigation toggle, Traffic toggle, Zoom in/out buttons
|
||||
**CSS Grid Layout:**
|
||||
- Grid columns: `var(--left-panel-width) 1fr var(--right-panel-width)`
|
||||
- Grid rows: `auto 1fr` (header + content)
|
||||
- Sidebars push the map area, not overlay it
|
||||
|
||||
**Map Overlays:**
|
||||
- Navigation panel: Toggleable overlay on top-left of map
|
||||
**Header Toolbar Sections:**
|
||||
- **Left**: Navigation panel toggle button
|
||||
- **Center**: Search bar (expandable width)
|
||||
- **Right**: Draw panel toggle, Traffic toggle, Zoom in/out buttons
|
||||
|
||||
**Left Sidebar (300px when open):**
|
||||
- Contains NavigationController render output
|
||||
- Slides in/out with 0.25s ease transition
|
||||
- Default open when `showNavigation={true}`
|
||||
|
||||
**Right Sidebar (180px when open):**
|
||||
- Draw tools panel with 2-column grid layout
|
||||
- Tool buttons with icons AND labels
|
||||
- Select & Edit and Clear All actions
|
||||
- Slides in/out with 0.25s ease transition
|
||||
- Default open when `showToolbar={true}`
|
||||
|
||||
**Map Overlays (remaining):**
|
||||
- Traffic legend: Bottom-left overlay (when traffic enabled)
|
||||
- Feature count: Bottom-left overlay (when features exist)
|
||||
|
||||
**Z-index hierarchy:**
|
||||
- `z-index: 20` - Dropdowns (search results, nav search results)
|
||||
- `z-index: 5` - Map overlays (navigation panel, traffic legend, feature count)
|
||||
- `z-index: 10` - Header toolbar
|
||||
- `z-index: 5` - Map overlays (traffic legend, feature count)
|
||||
|
||||
### Dark/Light Theme Support
|
||||
|
||||
The component fully supports automatic dark/light theme switching:
|
||||
|
||||
**UI Elements Theming:**
|
||||
- All UI elements (toolbar, panels, search, navigation) use `cssManager.bdTheme()` for automatic color switching
|
||||
- Pattern: `cssManager.bdTheme('lightValue', 'darkValue')` - first arg is light theme, second is dark
|
||||
|
||||
**Map Tiles Theming:**
|
||||
- When `mapStyle="osm"` (default), the map automatically switches between:
|
||||
- **Light theme**: CartoDB Voyager GL (vector tiles) - clean, detailed style
|
||||
- **Dark theme**: CartoDB Dark Matter GL (vector tiles) - sleek dark style
|
||||
- Theme changes are detected via `domtools.themeManager.themeObservable`
|
||||
- No API key required for CartoDB basemaps
|
||||
|
||||
**Semantic Colors (unchanged by theme):**
|
||||
- Navigation markers: Green (#22c55e) for start, Red (#ef4444) for end
|
||||
- Route line: Blue (#3b82f6) with dark outline (#1e40af)
|
||||
- Traffic congestion: Green → Yellow → Orange → Red → Dark Red (universal traffic colors)
|
||||
|
||||
### Shadow DOM & Terra-Draw Drawing Fix
|
||||
Terra-draw's event listeners normally intercept map events through MapLibre's canvas element. In Shadow DOM contexts, these events are scoped locally and don't propagate correctly, causing terra-draw handlers to fail while MapLibre's drag handlers continue working.
|
||||
@@ -245,3 +337,86 @@ Terra-draw's event listeners normally intercept map events through MapLibre's ca
|
||||
- When a drawing tool is active (`polygon`, `rectangle`, `point`, `linestring`, `circle`, `freehand`), MapLibre's `dragPan` and `dragRotate` are disabled
|
||||
- When `static` or `select` mode is active, dragging is re-enabled
|
||||
- The `TerraDrawMapLibreGLAdapter` does NOT accept a `lib` parameter - only `map` is required
|
||||
|
||||
### Voice-Guided Navigation Feature
|
||||
|
||||
Real-time GPS tracking with voice-guided turn-by-turn instructions:
|
||||
|
||||
#### How to Use
|
||||
1. Calculate a route using the navigation panel (set start and end points)
|
||||
2. Call `startGuidance()` to begin voice-guided navigation
|
||||
3. Update position with `setPosition([lng, lat], heading?, speed?)` or use `createMockGPSSimulator()` for testing
|
||||
4. Listen to `guidance-event` for navigation updates
|
||||
5. Call `stopGuidance()` when done
|
||||
|
||||
#### Example Usage
|
||||
```typescript
|
||||
const map = document.querySelector('dees-geo-map');
|
||||
|
||||
// 1. Set up route
|
||||
map.setNavigationStart([8.68, 50.11], 'Frankfurt');
|
||||
map.setNavigationEnd([8.75, 50.08], 'Sachsenhausen');
|
||||
await map.calculateRoute();
|
||||
|
||||
// 2. Listen to guidance events
|
||||
map.addEventListener('guidance-event', (e) => {
|
||||
console.log(e.detail.type, e.detail.instruction);
|
||||
});
|
||||
|
||||
// 3. Start guidance with mock GPS simulation
|
||||
const simulator = map.createMockGPSSimulator({ speed: 'city' });
|
||||
map.startGuidance();
|
||||
simulator.start();
|
||||
|
||||
// 4. Or use real GPS input
|
||||
navigator.geolocation.watchPosition((pos) => {
|
||||
map.setPosition([pos.coords.longitude, pos.coords.latitude], pos.coords.heading, pos.coords.speed);
|
||||
});
|
||||
```
|
||||
|
||||
#### Voice Announcements
|
||||
- **500m**: "In 500 meters, turn left onto Main Street"
|
||||
- **200m**: "In 200 meters, turn left"
|
||||
- **50m**: "Turn left ahead"
|
||||
- **At maneuver**: "Turn left now"
|
||||
- **Arrival**: "You have arrived at your destination"
|
||||
- **Off-route**: "You are off route. Recalculating."
|
||||
|
||||
#### Guidance Event Types
|
||||
- `approach-maneuver` - Approaching a turn/maneuver
|
||||
- `execute-maneuver` - At the maneuver point (urgent)
|
||||
- `step-change` - Advanced to next route step
|
||||
- `off-route` - Deviated more than 50m from route
|
||||
- `arrived` - Within 30m of destination
|
||||
- `position-updated` - Position was updated
|
||||
|
||||
#### Camera Following
|
||||
During navigation, the map camera automatically:
|
||||
- **Follows position**: Centers on current GPS location with smooth continuous transitions
|
||||
- **Follows bearing**: Rotates map to match driving direction (heading)
|
||||
- **3D tilt**: Uses 60° pitch by default for immersive driving view
|
||||
- **Street-level zoom**: Uses zoom level 17 for optimal route visibility
|
||||
- **Smooth animation**: Animation duration dynamically matches GPS update interval for fluid movement (no jerky pauses)
|
||||
|
||||
Camera behavior can be customized:
|
||||
```typescript
|
||||
// Disable camera following (user can pan freely)
|
||||
map.setNavigationFollowPosition(false);
|
||||
|
||||
// Disable bearing rotation (map stays north-up)
|
||||
map.setNavigationFollowBearing(false);
|
||||
|
||||
// Use flat (2D) view instead of tilted
|
||||
map.setNavigationPitch(0);
|
||||
|
||||
// Zoom out for wider view
|
||||
map.setNavigationZoom(15);
|
||||
|
||||
// Or set multiple options at once
|
||||
map.setNavigationCameraConfig({
|
||||
followPosition: true,
|
||||
followBearing: true,
|
||||
pitch: 60,
|
||||
zoom: 17,
|
||||
});
|
||||
```
|
||||
|
||||
BIN
route-markers-check.png
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
route-markers-verification.png
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
route-with-markers.png
Normal file
|
After Width: | Height: | Size: 562 KiB |
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@design.estate/dees-catalog-geo',
|
||||
version: '1.1.0',
|
||||
version: '1.5.0',
|
||||
description: 'A geospatial web components library with MapLibre GL JS maps and terra-draw drawing tools'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import type { DeesGeoMap } from './dees-geo-map.js';
|
||||
import { getSpeedPresets, type TSimulationSpeed } from './geo-map.mock-gps.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<style>
|
||||
@@ -87,11 +88,42 @@ export const demoFunc = () => html`
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.control-button:hover {
|
||||
.control-button:hover:not(:disabled) {
|
||||
background: ${cssManager.bdTheme('#f0f0f0', '#333')};
|
||||
border-color: ${cssManager.bdTheme('#999', '#666')};
|
||||
}
|
||||
|
||||
.control-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.control-button.active {
|
||||
background: ${cssManager.bdTheme('#0066cc', '#0084ff')};
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.control-button.primary {
|
||||
background: ${cssManager.bdTheme('#0066cc', '#0084ff')};
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.control-button.primary:hover:not(:disabled) {
|
||||
background: ${cssManager.bdTheme('#0055aa', '#0073e6')};
|
||||
}
|
||||
|
||||
.control-button.danger {
|
||||
background: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.control-button.danger:hover:not(:disabled) {
|
||||
background: ${cssManager.bdTheme('#b91c1c', '#dc2626')};
|
||||
}
|
||||
|
||||
.feature-display {
|
||||
background: ${cssManager.bdTheme('#f9f9f9', '#1e1e1e')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
||||
@@ -132,6 +164,137 @@ export const demoFunc = () => html`
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* Simulation Panel Styles */
|
||||
.simulation-section {
|
||||
background: ${cssManager.bdTheme('#f0f9ff', '#1a2633')};
|
||||
border: 1px solid ${cssManager.bdTheme('#bae6fd', '#1e3a5f')};
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.simulation-controls-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sim-playback {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sim-speed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sim-speed-label {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sim-speed-select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid ${cssManager.bdTheme('#ccc', '#444')};
|
||||
border-radius: 6px;
|
||||
background: ${cssManager.bdTheme('#fff', '#2a2a2a')};
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.sim-voice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sim-voice-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sim-voice-label {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sim-progress {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.sim-progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sim-progress-label {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
}
|
||||
|
||||
.sim-progress-value {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
}
|
||||
|
||||
.sim-progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sim-progress-fill {
|
||||
height: 100%;
|
||||
background: ${cssManager.bdTheme('#0066cc', '#0084ff')};
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.sim-status {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sim-status.idle {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
}
|
||||
|
||||
.sim-status.running {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: ${cssManager.bdTheme('#166534', '#86efac')};
|
||||
}
|
||||
|
||||
.sim-status.paused {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: ${cssManager.bdTheme('#92400e', '#fcd34d')};
|
||||
}
|
||||
|
||||
.sim-status.completed {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
|
||||
}
|
||||
|
||||
.sim-status.no-route {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: ${cssManager.bdTheme('#991b1b', '#fca5a5')};
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
@@ -141,6 +304,59 @@ export const demoFunc = () => html`
|
||||
const eventLog = elementArg.querySelector('#event-log') as HTMLElement;
|
||||
const featureJson = elementArg.querySelector('#feature-json') as HTMLElement;
|
||||
|
||||
// Simulation state
|
||||
let simulationState = {
|
||||
isRunning: false,
|
||||
isPaused: false,
|
||||
progress: 0,
|
||||
hasRoute: false,
|
||||
};
|
||||
|
||||
const updateSimulationUI = () => {
|
||||
const startBtn = elementArg.querySelector('#sim-start') as HTMLButtonElement;
|
||||
const pauseBtn = elementArg.querySelector('#sim-pause') as HTMLButtonElement;
|
||||
const stopBtn = elementArg.querySelector('#sim-stop') as HTMLButtonElement;
|
||||
const progressFill = elementArg.querySelector('#sim-progress-fill') as HTMLElement;
|
||||
const progressValue = elementArg.querySelector('#sim-progress-value') as HTMLElement;
|
||||
const statusEl = elementArg.querySelector('#sim-status') as HTMLElement;
|
||||
|
||||
if (!startBtn) return;
|
||||
|
||||
// Update button states
|
||||
startBtn.disabled = !simulationState.hasRoute || (simulationState.isRunning && !simulationState.isPaused);
|
||||
pauseBtn.disabled = !simulationState.isRunning || simulationState.isPaused;
|
||||
stopBtn.disabled = !simulationState.isRunning && !simulationState.isPaused;
|
||||
|
||||
// Update progress
|
||||
if (progressFill) {
|
||||
progressFill.style.width = `${simulationState.progress}%`;
|
||||
}
|
||||
if (progressValue) {
|
||||
progressValue.textContent = `${simulationState.progress.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
// Update status
|
||||
if (statusEl) {
|
||||
statusEl.className = 'sim-status';
|
||||
if (!simulationState.hasRoute) {
|
||||
statusEl.className += ' no-route';
|
||||
statusEl.textContent = 'Calculate a route first to enable simulation';
|
||||
} else if (simulationState.isRunning && !simulationState.isPaused) {
|
||||
statusEl.className += ' running';
|
||||
statusEl.textContent = 'Simulation running...';
|
||||
} else if (simulationState.isPaused) {
|
||||
statusEl.className += ' paused';
|
||||
statusEl.textContent = 'Simulation paused';
|
||||
} else if (simulationState.progress >= 100) {
|
||||
statusEl.className += ' completed';
|
||||
statusEl.textContent = 'Simulation completed!';
|
||||
} else {
|
||||
statusEl.className += ' idle';
|
||||
statusEl.textContent = 'Ready to simulate';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addLogEntry = (type: string, message: string) => {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'event-entry';
|
||||
@@ -157,6 +373,7 @@ export const demoFunc = () => html`
|
||||
if (map) {
|
||||
map.addEventListener('map-ready', () => {
|
||||
addLogEntry('ready', 'Map initialized successfully');
|
||||
updateSimulationUI();
|
||||
});
|
||||
|
||||
map.addEventListener('draw-change', (e: CustomEvent) => {
|
||||
@@ -183,9 +400,106 @@ export const demoFunc = () => html`
|
||||
const durationMin = Math.round(route.duration / 60);
|
||||
addLogEntry('route', `${mode}: ${distKm} km, ${durationMin} min`);
|
||||
console.log('Route calculated:', e.detail);
|
||||
|
||||
// Update simulation state
|
||||
simulationState.hasRoute = true;
|
||||
simulationState.progress = 0;
|
||||
updateSimulationUI();
|
||||
|
||||
// Create/update the simulator with the new route
|
||||
const simulator = map.createMockGPSSimulator();
|
||||
simulator.setRoute(route);
|
||||
});
|
||||
|
||||
map.addEventListener('guidance-event', (e: CustomEvent) => {
|
||||
const event = e.detail;
|
||||
addLogEntry('guidance', `${event.type}: ${event.instruction || 'Step ' + event.stepIndex}`);
|
||||
console.log('Guidance event:', event);
|
||||
|
||||
// Update progress
|
||||
const simulator = map.getMockGPSSimulator();
|
||||
if (simulator) {
|
||||
simulationState.progress = simulator.getProgress();
|
||||
updateSimulationUI();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set up simulation controls
|
||||
const setupSimulationControls = () => {
|
||||
const startBtn = elementArg.querySelector('#sim-start') as HTMLButtonElement;
|
||||
const pauseBtn = elementArg.querySelector('#sim-pause') as HTMLButtonElement;
|
||||
const stopBtn = elementArg.querySelector('#sim-stop') as HTMLButtonElement;
|
||||
const speedSelect = elementArg.querySelector('#sim-speed') as HTMLSelectElement;
|
||||
const voiceCheckbox = elementArg.querySelector('#sim-voice') as HTMLInputElement;
|
||||
|
||||
if (startBtn && map) {
|
||||
startBtn.addEventListener('click', () => {
|
||||
let simulator = map.getMockGPSSimulator();
|
||||
if (!simulator) {
|
||||
simulator = map.createMockGPSSimulator();
|
||||
const route = map.getNavigationState()?.route;
|
||||
if (route) {
|
||||
simulator.setRoute(route);
|
||||
}
|
||||
}
|
||||
|
||||
// Start guidance
|
||||
map.startGuidance();
|
||||
simulator.start();
|
||||
|
||||
simulationState.isRunning = true;
|
||||
simulationState.isPaused = false;
|
||||
updateSimulationUI();
|
||||
addLogEntry('simulation', 'Started');
|
||||
});
|
||||
}
|
||||
|
||||
if (pauseBtn && map) {
|
||||
pauseBtn.addEventListener('click', () => {
|
||||
const simulator = map.getMockGPSSimulator();
|
||||
if (simulator) {
|
||||
simulator.pause();
|
||||
simulationState.isPaused = true;
|
||||
updateSimulationUI();
|
||||
addLogEntry('simulation', 'Paused');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (stopBtn && map) {
|
||||
stopBtn.addEventListener('click', () => {
|
||||
map.stopGuidance();
|
||||
simulationState.isRunning = false;
|
||||
simulationState.isPaused = false;
|
||||
simulationState.progress = 0;
|
||||
updateSimulationUI();
|
||||
addLogEntry('simulation', 'Stopped');
|
||||
});
|
||||
}
|
||||
|
||||
if (speedSelect && map) {
|
||||
speedSelect.addEventListener('change', () => {
|
||||
const speed = speedSelect.value as TSimulationSpeed;
|
||||
const simulator = map.getMockGPSSimulator();
|
||||
if (simulator) {
|
||||
simulator.setSpeed(speed);
|
||||
addLogEntry('simulation', `Speed changed to ${speedSelect.options[speedSelect.selectedIndex].text}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (voiceCheckbox && map) {
|
||||
// Set initial state
|
||||
voiceCheckbox.checked = true;
|
||||
|
||||
voiceCheckbox.addEventListener('change', () => {
|
||||
map.setVoiceEnabled(voiceCheckbox.checked);
|
||||
addLogEntry('simulation', `Voice ${voiceCheckbox.checked ? 'enabled' : 'disabled'}`);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Set up navigation buttons
|
||||
const locations: Record<string, [number, number]> = {
|
||||
paris: [2.3522, 48.8566],
|
||||
@@ -257,6 +571,12 @@ export const demoFunc = () => html`
|
||||
updateFeatureDisplay();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize simulation controls after a tick
|
||||
setTimeout(() => {
|
||||
setupSimulationControls();
|
||||
updateSimulationUI();
|
||||
}, 100);
|
||||
}}>
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Interactive Map with Drawing Tools</h2>
|
||||
@@ -281,6 +601,58 @@ export const demoFunc = () => html`
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="demo-section simulation-section">
|
||||
<h2 class="demo-title">GPS Simulation & Voice Navigation</h2>
|
||||
<p class="demo-description">
|
||||
Calculate a route using the navigation panel, then use these controls to simulate GPS movement
|
||||
along the route with voice-guided turn-by-turn instructions.
|
||||
</p>
|
||||
|
||||
<div class="simulation-controls-grid">
|
||||
<div class="sim-playback">
|
||||
<button class="control-button primary" id="sim-start" disabled>
|
||||
▶ Start
|
||||
</button>
|
||||
<button class="control-button" id="sim-pause" disabled>
|
||||
⏸ Pause
|
||||
</button>
|
||||
<button class="control-button danger" id="sim-stop" disabled>
|
||||
⏹ Stop
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sim-speed">
|
||||
<span class="sim-speed-label">Speed:</span>
|
||||
<select class="sim-speed-select" id="sim-speed">
|
||||
${getSpeedPresets().map(preset => html`
|
||||
<option value="${preset.id}" ?selected=${preset.id === 'city'}>
|
||||
${preset.name} (${preset.kmh} km/h)
|
||||
</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sim-voice">
|
||||
<input type="checkbox" id="sim-voice" class="sim-voice-checkbox" checked />
|
||||
<label for="sim-voice" class="sim-voice-label">Voice Guidance</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sim-progress">
|
||||
<div class="sim-progress-header">
|
||||
<span class="sim-progress-label">Route Progress</span>
|
||||
<span class="sim-progress-value" id="sim-progress-value">0%</span>
|
||||
</div>
|
||||
<div class="sim-progress-bar">
|
||||
<div class="sim-progress-fill" id="sim-progress-fill" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sim-status no-route" id="sim-status">
|
||||
Calculate a route first to enable simulation
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Quick Navigation</h2>
|
||||
<div class="locations-grid">
|
||||
|
||||
@@ -8,9 +8,10 @@ import {
|
||||
type TemplateResult,
|
||||
cssManager,
|
||||
css,
|
||||
domtools,
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesContextmenu } from '@design.estate/dees-catalog';
|
||||
import { geoComponentStyles, mapContainerStyles, toolbarStyles, searchStyles, navigationStyles, trafficStyles, headerToolbarStyles } from '../../00componentstyles.js';
|
||||
import { geoComponentStyles, mapContainerStyles, toolbarStyles, searchStyles, navigationStyles, trafficStyles, headerToolbarStyles, guidanceStyles, maplibreMarkerStyles } from '../../00componentstyles.js';
|
||||
|
||||
// MapLibre imports
|
||||
import maplibregl from 'maplibre-gl';
|
||||
@@ -32,9 +33,12 @@ import { TerraDrawMapLibreGLAdapter } from 'terra-draw-maplibre-gl-adapter';
|
||||
// Modular imports
|
||||
import { renderIcon } from './geo-map.icons.js';
|
||||
import { SearchController, type INominatimResult, type IAddressSelectedEvent } from './geo-map.search.js';
|
||||
import { NavigationController, type TNavigationMode, type INavigationState, type IRouteCalculatedEvent } from './geo-map.navigation.js';
|
||||
import { NavigationController, type TNavigationMode, type INavigationState, type IRouteCalculatedEvent, type IOSRMRoute } from './geo-map.navigation.js';
|
||||
import { TrafficController } from './geo-map.traffic.js';
|
||||
import { HereTrafficProvider, type ITrafficProvider } from './geo-map.traffic.providers.js';
|
||||
import { NavigationGuideController, type IGPSPosition, type IGuidanceEvent, type INavigationGuideState, type INavigationCameraConfig } from './geo-map.navigation-guide.js';
|
||||
import { MockGPSSimulator, type TSimulationSpeed, type IMockGPSConfig } from './geo-map.mock-gps.js';
|
||||
import type { IVoiceConfig } from './geo-map.voice.js';
|
||||
|
||||
// Re-export types for external consumers
|
||||
export type { INominatimResult, IAddressSelectedEvent } from './geo-map.search.js';
|
||||
@@ -47,6 +51,9 @@ export type {
|
||||
IRouteCalculatedEvent,
|
||||
} from './geo-map.navigation.js';
|
||||
export type { ITrafficProvider, ITrafficFlowData, ITrafficAwareRoute } from './geo-map.traffic.providers.js';
|
||||
export type { IGPSPosition, IGuidanceEvent, INavigationGuideState, TGuidanceEventType, INavigationCameraConfig } from './geo-map.navigation-guide.js';
|
||||
export type { IVoiceConfig } from './geo-map.voice.js';
|
||||
export type { TSimulationSpeed, IMockGPSConfig } from './geo-map.mock-gps.js';
|
||||
|
||||
export type TDrawTool = 'polygon' | 'rectangle' | 'point' | 'linestring' | 'circle' | 'freehand' | 'select' | 'static';
|
||||
|
||||
@@ -127,6 +134,13 @@ export class DeesGeoMap extends DeesElement {
|
||||
@property({ type: String })
|
||||
accessor trafficApiKey: string = '';
|
||||
|
||||
// Guidance properties
|
||||
@property({ type: Boolean })
|
||||
accessor enableGuidance: boolean = false;
|
||||
|
||||
@property({ type: Object })
|
||||
accessor voiceConfig: Partial<IVoiceConfig> = {};
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@state()
|
||||
@@ -141,10 +155,15 @@ export class DeesGeoMap extends DeesElement {
|
||||
@state()
|
||||
private accessor isNavigationOpen: boolean = true;
|
||||
|
||||
@state()
|
||||
private accessor isDrawPanelOpen: boolean = true;
|
||||
|
||||
// Controllers
|
||||
private searchController: SearchController | null = null;
|
||||
private navigationController: NavigationController | null = null;
|
||||
private trafficController: TrafficController | null = null;
|
||||
private guidanceController: NavigationGuideController | null = null;
|
||||
private mockGPSSimulator: MockGPSSimulator | null = null;
|
||||
|
||||
// ─── Styles ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -157,6 +176,8 @@ export class DeesGeoMap extends DeesElement {
|
||||
navigationStyles,
|
||||
trafficStyles,
|
||||
headerToolbarStyles,
|
||||
guidanceStyles,
|
||||
maplibreMarkerStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
@@ -172,41 +193,63 @@ export class DeesGeoMap extends DeesElement {
|
||||
|
||||
.maplibregl-ctrl-attrib {
|
||||
font-size: 11px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: ${cssManager.bdTheme('rgba(255, 255, 255, 0.8)', 'rgba(0, 0, 0, 0.5)')};
|
||||
color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.7)', 'rgba(255, 255, 255, 0.8)')};
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib a {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.7)', 'rgba(255, 255, 255, 0.8)')};
|
||||
}
|
||||
|
||||
.feature-count {
|
||||
padding: 6px 12px;
|
||||
background: rgba(30, 30, 30, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: ${cssManager.bdTheme('rgba(255, 255, 255, 0.95)', 'rgba(30, 30, 30, 0.9)')};
|
||||
border: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||
border-radius: 6px;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.6)', 'rgba(255, 255, 255, 0.7)')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
// Map theme subscription for cleanup
|
||||
private mapThemeSubscription: { unsubscribe: () => void } | null = null;
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
public async firstUpdated() {
|
||||
this.initializeControllers();
|
||||
await this.initializeMap();
|
||||
|
||||
// Subscribe to theme changes to update map style
|
||||
this.subscribeToThemeChanges();
|
||||
}
|
||||
|
||||
public async disconnectedCallback() {
|
||||
await super.disconnectedCallback();
|
||||
if (this.mapThemeSubscription) {
|
||||
this.mapThemeSubscription.unsubscribe();
|
||||
this.mapThemeSubscription = null;
|
||||
}
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to theme changes via domtools
|
||||
*/
|
||||
private async subscribeToThemeChanges(): Promise<void> {
|
||||
const domtoolsInstance = await domtools.DomTools.setupDomTools();
|
||||
this.mapThemeSubscription = domtoolsInstance.themeManager.themeObservable.subscribe(
|
||||
(_goBright: boolean) => {
|
||||
this.updateMapStyleForTheme();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public updated(changedProperties: Map<string, unknown>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('dragToDraw') && this.draw && this.map) {
|
||||
@@ -290,11 +333,23 @@ export class DeesGeoMap extends DeesElement {
|
||||
getMap: () => this.map,
|
||||
// Connect traffic controller for traffic-aware routing
|
||||
getTrafficRoute: async (start, end, mode) => {
|
||||
if (this.trafficController?.supportsTrafficRouting()) {
|
||||
if (this.showTraffic && this.trafficController?.supportsTrafficRouting()) {
|
||||
return this.trafficController.fetchRouteWithTraffic(start, end, mode);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
// Traffic toggle callbacks
|
||||
onTrafficToggle: (enabled) => {
|
||||
if (enabled) {
|
||||
this.trafficController?.enable();
|
||||
} else {
|
||||
this.trafficController?.disable();
|
||||
}
|
||||
this.showTraffic = enabled;
|
||||
},
|
||||
getTrafficEnabled: () => this.showTraffic,
|
||||
// Pass guidance state for turn-by-turn synchronization
|
||||
getGuidanceState: () => this.guidanceController?.state ?? null,
|
||||
});
|
||||
this.navigationController.navigationMode = this.navigationMode;
|
||||
|
||||
@@ -312,6 +367,27 @@ export class DeesGeoMap extends DeesElement {
|
||||
hereProvider.configure({ apiKey: this.trafficApiKey });
|
||||
this.trafficController.setProvider(hereProvider);
|
||||
}
|
||||
|
||||
// Initialize guidance controller
|
||||
this.guidanceController = new NavigationGuideController(
|
||||
{
|
||||
onGuidanceEvent: (event) => {
|
||||
this.dispatchEvent(new CustomEvent<IGuidanceEvent>('guidance-event', {
|
||||
detail: event,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
|
||||
// Auto-scroll turn-by-turn list when step changes
|
||||
if (event.type === 'step-change' && this.navigationController) {
|
||||
this.navigationController.scrollToCurrentStep(event.stepIndex);
|
||||
}
|
||||
},
|
||||
onRequestUpdate: () => this.requestUpdate(),
|
||||
getMap: () => this.map,
|
||||
},
|
||||
this.voiceConfig
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Map Initialization ─────────────────────────────────────────────────────
|
||||
@@ -374,30 +450,30 @@ export class DeesGeoMap extends DeesElement {
|
||||
|
||||
private getMapStyle(): maplibregl.StyleSpecification | string {
|
||||
if (this.mapStyle === 'osm') {
|
||||
return {
|
||||
version: 8,
|
||||
sources: {
|
||||
'osm-tiles': {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'osm-tiles',
|
||||
type: 'raster',
|
||||
source: 'osm-tiles',
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
},
|
||||
],
|
||||
};
|
||||
// Check current theme and use appropriate tiles
|
||||
const isDarkTheme = !cssManager.goBright;
|
||||
|
||||
if (isDarkTheme) {
|
||||
// CartoDB Dark Matter GL vector style - high quality dark theme
|
||||
return 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
||||
} else {
|
||||
// CartoDB Voyager GL vector style - high quality light theme
|
||||
return 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
|
||||
}
|
||||
}
|
||||
return this.mapStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the map style when theme changes
|
||||
*/
|
||||
private updateMapStyleForTheme(): void {
|
||||
if (!this.map || !this.isMapReady || this.mapStyle !== 'osm') return;
|
||||
|
||||
const newStyle = this.getMapStyle();
|
||||
this.map.setStyle(newStyle as maplibregl.StyleSpecification);
|
||||
}
|
||||
|
||||
// ─── Terra Draw Initialization ──────────────────────────────────────────────
|
||||
|
||||
private initializeTerraDraw() {
|
||||
@@ -736,6 +812,199 @@ export class DeesGeoMap extends DeesElement {
|
||||
return this.trafficController;
|
||||
}
|
||||
|
||||
// ─── Guidance Public Methods ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Set current GPS position for navigation guidance
|
||||
* @param coords - [lng, lat] coordinates
|
||||
* @param heading - Optional heading in degrees (0 = North)
|
||||
* @param speed - Optional speed in meters/second
|
||||
*/
|
||||
public setPosition(coords: [number, number], heading?: number, speed?: number): void {
|
||||
this.guidanceController?.setPosition(coords[0], coords[1], heading, speed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start voice-guided navigation for the current route
|
||||
*/
|
||||
public startGuidance(): void {
|
||||
const route = this.navigationController?.navigationState?.route;
|
||||
if (route && this.guidanceController) {
|
||||
this.guidanceController.startGuidance(route);
|
||||
} else {
|
||||
console.warn('[dees-geo-map] Cannot start guidance: no route calculated');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop voice-guided navigation
|
||||
*/
|
||||
public stopGuidance(): void {
|
||||
this.guidanceController?.stopGuidance();
|
||||
this.mockGPSSimulator?.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable voice guidance
|
||||
*/
|
||||
public setVoiceEnabled(enabled: boolean): void {
|
||||
this.guidanceController?.setVoiceEnabled(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if voice guidance is enabled
|
||||
*/
|
||||
public isVoiceEnabled(): boolean {
|
||||
return this.guidanceController?.isVoiceEnabled() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current guidance state
|
||||
*/
|
||||
public getGuidanceState(): INavigationGuideState | null {
|
||||
return this.guidanceController?.state ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if actively navigating
|
||||
*/
|
||||
public isNavigating(): boolean {
|
||||
return this.guidanceController?.isNavigating() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock GPS simulator for testing/demo
|
||||
* The simulator emits position updates along the current route
|
||||
*/
|
||||
public createMockGPSSimulator(config?: Partial<IMockGPSConfig>): MockGPSSimulator {
|
||||
// Clean up existing simulator
|
||||
if (this.mockGPSSimulator) {
|
||||
this.mockGPSSimulator.cleanup();
|
||||
}
|
||||
|
||||
this.mockGPSSimulator = new MockGPSSimulator(
|
||||
{
|
||||
onPositionUpdate: (position: IGPSPosition) => {
|
||||
if (this.guidanceController) {
|
||||
this.guidanceController.updatePosition(position);
|
||||
}
|
||||
},
|
||||
onSimulationStart: () => {
|
||||
console.log('[MockGPSSimulator] Simulation started');
|
||||
},
|
||||
onSimulationPause: () => {
|
||||
console.log('[MockGPSSimulator] Simulation paused');
|
||||
},
|
||||
onSimulationStop: () => {
|
||||
console.log('[MockGPSSimulator] Simulation stopped');
|
||||
},
|
||||
onSimulationComplete: () => {
|
||||
console.log('[MockGPSSimulator] Simulation complete');
|
||||
},
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
// Set the route if available
|
||||
const route = this.navigationController?.navigationState?.route;
|
||||
if (route) {
|
||||
this.mockGPSSimulator.setRoute(route);
|
||||
}
|
||||
|
||||
return this.mockGPSSimulator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mock GPS simulator (if created)
|
||||
*/
|
||||
public getMockGPSSimulator(): MockGPSSimulator | null {
|
||||
return this.mockGPSSimulator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the guidance controller for advanced usage
|
||||
*/
|
||||
public getGuidanceController(): NavigationGuideController | null {
|
||||
return this.guidanceController;
|
||||
}
|
||||
|
||||
// ─── Navigation Camera Control Methods ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Enable or disable camera following the GPS position during navigation
|
||||
* @param enabled - Whether the camera should follow the position
|
||||
*/
|
||||
public setNavigationFollowPosition(enabled: boolean): void {
|
||||
this.guidanceController?.setFollowPosition(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if camera is following position during navigation
|
||||
*/
|
||||
public isNavigationFollowingPosition(): boolean {
|
||||
return this.guidanceController?.isFollowingPosition() ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable camera rotating with heading during navigation
|
||||
* @param enabled - Whether the camera should rotate with heading
|
||||
*/
|
||||
public setNavigationFollowBearing(enabled: boolean): void {
|
||||
this.guidanceController?.setFollowBearing(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if camera is following bearing during navigation
|
||||
*/
|
||||
public isNavigationFollowingBearing(): boolean {
|
||||
return this.guidanceController?.isFollowingBearing() ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the navigation camera pitch (3D tilt angle)
|
||||
* @param pitch - Angle in degrees (0 = flat, 60 = tilted for 3D view)
|
||||
*/
|
||||
public setNavigationPitch(pitch: number): void {
|
||||
this.guidanceController?.setPitch(pitch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current navigation pitch setting
|
||||
*/
|
||||
public getNavigationPitch(): number {
|
||||
return this.guidanceController?.getPitch() ?? 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the navigation zoom level
|
||||
* @param zoom - Zoom level (typically 15-19 for street-level navigation)
|
||||
*/
|
||||
public setNavigationZoom(zoom: number): void {
|
||||
this.guidanceController?.setZoom(zoom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current navigation zoom setting
|
||||
*/
|
||||
public getNavigationZoom(): number {
|
||||
return this.guidanceController?.getZoom() ?? 17;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full navigation camera configuration
|
||||
*/
|
||||
public getNavigationCameraConfig(): INavigationCameraConfig | null {
|
||||
return this.guidanceController?.getCameraConfig() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set navigation camera configuration
|
||||
* @param config - Partial camera configuration to apply
|
||||
*/
|
||||
public setNavigationCameraConfig(config: Partial<INavigationCameraConfig>): void {
|
||||
this.guidanceController?.setCameraConfig(config);
|
||||
}
|
||||
|
||||
// ─── Private Methods ────────────────────────────────────────────────────────
|
||||
|
||||
private ensureMaplibreCssLoaded() {
|
||||
@@ -757,6 +1026,8 @@ export class DeesGeoMap extends DeesElement {
|
||||
// Clean up controllers
|
||||
this.navigationController?.cleanup();
|
||||
this.trafficController?.cleanup();
|
||||
this.guidanceController?.cleanup();
|
||||
this.mockGPSSimulator?.cleanup();
|
||||
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
@@ -836,6 +1107,10 @@ export class DeesGeoMap extends DeesElement {
|
||||
this.isNavigationOpen = !this.isNavigationOpen;
|
||||
}
|
||||
|
||||
private toggleDrawPanel(): void {
|
||||
this.isDrawPanelOpen = !this.isDrawPanelOpen;
|
||||
}
|
||||
|
||||
// ─── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public render(): TemplateResult {
|
||||
@@ -843,24 +1118,31 @@ export class DeesGeoMap extends DeesElement {
|
||||
const hasTrafficProvider = this.trafficController?.provider?.isConfigured ?? false;
|
||||
const showTrafficControls = Boolean(hasTrafficProvider || this.trafficApiKey || this.trafficProvider);
|
||||
|
||||
// Calculate panel widths for CSS Grid
|
||||
const leftPanelWidth = this.showNavigation && this.isNavigationOpen ? '300px' : '0px';
|
||||
const rightPanelWidth = this.showToolbar && this.isDrawPanelOpen ? '180px' : '0px';
|
||||
|
||||
return html`
|
||||
<div class="geo-component">
|
||||
<div class="geo-component" style="--left-panel-width: ${leftPanelWidth}; --right-panel-width: ${rightPanelWidth};">
|
||||
<!-- Header Toolbar Above Map -->
|
||||
${this.renderHeaderToolbar(showTrafficControls)}
|
||||
|
||||
<!-- Left Sidebar: Navigation Panel -->
|
||||
<div class="left-sidebar ${!this.showNavigation || !this.isNavigationOpen ? 'collapsed' : ''}">
|
||||
${this.showNavigation && this.isNavigationOpen && this.navigationController
|
||||
? this.navigationController.render()
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
<!-- Map Container -->
|
||||
<div class="map-container" @contextmenu=${(e: MouseEvent) => this.handleMapContextMenu(e)}>
|
||||
<div class="map-wrapper"></div>
|
||||
|
||||
<div class="map-overlay">
|
||||
<!-- Top Left: Navigation Panel (toggleable) -->
|
||||
<div class="overlay-top-left">
|
||||
${this.showNavigation && this.isNavigationOpen && this.navigationController
|
||||
? this.navigationController.render()
|
||||
: ''}
|
||||
</div>
|
||||
<!-- Top Left: Empty (navigation moved to sidebar) -->
|
||||
<div class="overlay-top-left"></div>
|
||||
|
||||
<!-- Top Right: Empty now (controls moved to header) -->
|
||||
<!-- Top Right: Empty (controls in header) -->
|
||||
<div class="overlay-top-right"></div>
|
||||
|
||||
<!-- Bottom Left: Traffic Legend + Feature Count -->
|
||||
@@ -873,9 +1155,17 @@ export class DeesGeoMap extends DeesElement {
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Bottom Right: Empty now (zoom moved to header) -->
|
||||
<!-- Bottom Right: Empty (zoom in header) -->
|
||||
<div class="overlay-bottom-right"></div>
|
||||
</div>
|
||||
|
||||
<!-- Guidance Panel (shown during active navigation) -->
|
||||
${this.guidanceController?.isNavigating() ? this.guidanceController.render() : ''}
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar: Draw Panel -->
|
||||
<div class="right-sidebar ${!this.showToolbar || !this.isDrawPanelOpen ? 'collapsed' : ''}">
|
||||
${this.showToolbar && this.isDrawPanelOpen ? this.renderDrawPanel() : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -884,11 +1174,16 @@ export class DeesGeoMap extends DeesElement {
|
||||
private renderHeaderToolbar(showTrafficControls: boolean): TemplateResult {
|
||||
return html`
|
||||
<div class="header-toolbar">
|
||||
<!-- Left: Draw Tools -->
|
||||
<!-- Left: Navigation Panel Toggle -->
|
||||
<div class="toolbar-left">
|
||||
${this.showToolbar ? html`
|
||||
${this.renderDrawTools()}
|
||||
<div class="toolbar-divider"></div>
|
||||
${this.showNavigation ? html`
|
||||
<button
|
||||
class="tool-button ${this.isNavigationOpen ? 'active' : ''}"
|
||||
title="Toggle Navigation Panel"
|
||||
@click=${() => this.toggleNavigation()}
|
||||
>
|
||||
${renderIcon('navigation')}
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
@@ -899,20 +1194,18 @@ export class DeesGeoMap extends DeesElement {
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
<!-- Right: Navigation Toggle + Traffic Toggle + Zoom Controls -->
|
||||
<!-- Right: Draw Panel Toggle + Traffic Toggle + Zoom Controls -->
|
||||
<div class="toolbar-right">
|
||||
${this.showNavigation ? html`
|
||||
${this.showToolbar ? html`
|
||||
<button
|
||||
class="tool-button ${this.isNavigationOpen ? 'active' : ''}"
|
||||
title="Navigation"
|
||||
@click=${() => this.toggleNavigation()}
|
||||
class="tool-button ${this.isDrawPanelOpen ? 'active' : ''}"
|
||||
title="Toggle Draw Tools"
|
||||
@click=${() => this.toggleDrawPanel()}
|
||||
>
|
||||
${renderIcon('navigation')}
|
||||
${renderIcon('polygon')}
|
||||
</button>
|
||||
` : ''}
|
||||
${showTrafficControls && this.trafficController
|
||||
? this.trafficController.render()
|
||||
: ''}
|
||||
${this.trafficController?.render()}
|
||||
<div class="toolbar-divider"></div>
|
||||
<button
|
||||
class="tool-button"
|
||||
@@ -933,7 +1226,7 @@ export class DeesGeoMap extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDrawTools(): TemplateResult {
|
||||
private renderDrawPanel(): TemplateResult {
|
||||
const tools: { id: TDrawTool; icon: string; label: string }[] = [
|
||||
{ id: 'point', icon: 'point', label: 'Point' },
|
||||
{ id: 'linestring', icon: 'line', label: 'Line' },
|
||||
@@ -944,33 +1237,51 @@ export class DeesGeoMap extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="draw-panel">
|
||||
<div class="draw-panel-header">
|
||||
<div class="draw-panel-header-icon">
|
||||
${renderIcon('polygon')}
|
||||
</div>
|
||||
<div class="draw-panel-header-title">Draw Tools</div>
|
||||
</div>
|
||||
|
||||
<div class="draw-tools-grid">
|
||||
${tools.map(tool => html`
|
||||
<button
|
||||
class="tool-button ${this.activeTool === tool.id ? 'active' : ''}"
|
||||
class="draw-tool-button ${this.activeTool === tool.id ? 'active' : ''}"
|
||||
title="${tool.label}"
|
||||
@click=${() => this.handleToolClick(tool.id)}
|
||||
?disabled=${!this.isMapReady}
|
||||
>
|
||||
${renderIcon(tool.icon)}
|
||||
<span class="draw-tool-button-label">${tool.label}</span>
|
||||
</button>
|
||||
`)}
|
||||
<div class="toolbar-divider"></div>
|
||||
</div>
|
||||
|
||||
<div class="draw-panel-divider"></div>
|
||||
|
||||
<div class="draw-panel-actions">
|
||||
<button
|
||||
class="tool-button ${this.activeTool === 'select' ? 'active' : ''}"
|
||||
class="draw-action-button ${this.activeTool === 'select' ? 'active' : ''}"
|
||||
title="Select & Edit"
|
||||
@click=${() => this.handleToolClick('select')}
|
||||
?disabled=${!this.isMapReady}
|
||||
>
|
||||
${renderIcon('select')}
|
||||
<span>Select & Edit</span>
|
||||
</button>
|
||||
<button
|
||||
class="tool-button"
|
||||
class="draw-action-button danger"
|
||||
title="Clear All"
|
||||
@click=${this.handleClearClick}
|
||||
?disabled=${!this.isMapReady}
|
||||
>
|
||||
${renderIcon('trash')}
|
||||
<span>Clear All</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,10 +37,23 @@ export const GEO_MAP_ICONS: Record<string, TemplateResult> = {
|
||||
ruler: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 8.7 8.7 21.3c-1 1-2.5 1-3.4 0l-2.6-2.6c-1-1-1-2.5 0-3.4L15.3 2.7c1-1 2.5-1 3.4 0l2.6 2.6c1 1 1 2.5 0 3.4Z"/><path d="m7.5 10.5 2 2"/><path d="m10.5 7.5 2 2"/><path d="m13.5 4.5 2 2"/><path d="m4.5 13.5 2 2"/></svg>`,
|
||||
error: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>`,
|
||||
|
||||
// Arrows
|
||||
arrowLeft: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>`,
|
||||
|
||||
// Traffic
|
||||
traffic: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="3" width="12" height="18" rx="2"/><circle cx="12" cy="7" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/><circle cx="12" cy="17" r="1.5" fill="currentColor"/></svg>`,
|
||||
trafficLight: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h5"/><path d="M15 7h5a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-5"/><path d="M12 4v4"/><path d="M12 16v4"/><circle cx="12" cy="12" r="3"/></svg>`,
|
||||
congestion: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4"/><path d="M9 12h6"/><path d="M18 12h4"/><circle cx="6" cy="12" r="2" fill="currentColor"/><circle cx="12" cy="12" r="2" fill="currentColor"/><circle cx="18" cy="12" r="2" fill="currentColor"/></svg>`,
|
||||
|
||||
// Guidance / Simulation
|
||||
volume: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>`,
|
||||
volumeOff: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>`,
|
||||
play: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3" fill="currentColor"/></svg>`,
|
||||
pause: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16" fill="currentColor"/><rect x="14" y="4" width="4" height="16" fill="currentColor"/></svg>`,
|
||||
stop: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2" fill="currentColor"/></svg>`,
|
||||
gpsPosition: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3" fill="currentColor"/><circle cx="12" cy="12" r="8"/><line x1="12" y1="2" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="22"/><line x1="2" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>`,
|
||||
speed: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 1 0 10 10"/><path d="M12 12l4-8"/><circle cx="12" cy="12" r="2"/></svg>`,
|
||||
simulate: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h2"/><path d="M18 12h2"/><circle cx="12" cy="12" r="4"/><path d="M12 4v2"/><path d="M12 18v2"/><path d="M7.05 7.05l1.4 1.4"/><path d="M15.55 15.55l1.4 1.4"/><path d="M15.55 7.05l1.4-1.4"/><path d="M7.05 16.95l1.4-1.4"/></svg>`,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
494
ts_web/elements/00group-map/dees-geo-map/geo-map.mock-gps.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
import type { IOSRMRoute } from './geo-map.navigation.js';
|
||||
import type { IGPSPosition } from './geo-map.navigation-guide.js';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TSimulationSpeed = 'walking' | 'cycling' | 'city' | 'highway' | 'custom';
|
||||
|
||||
export interface ISimulationSpeedConfig {
|
||||
walking: number; // m/s
|
||||
cycling: number;
|
||||
city: number;
|
||||
highway: number;
|
||||
}
|
||||
|
||||
export interface IMockGPSConfig {
|
||||
speed: TSimulationSpeed;
|
||||
customSpeedMps?: number; // Custom speed in m/s
|
||||
updateInterval: number; // ms between position updates
|
||||
jitterMeters: number; // Random GPS jitter amount
|
||||
startFromBeginning: boolean;
|
||||
}
|
||||
|
||||
export interface IMockGPSCallbacks {
|
||||
onPositionUpdate: (position: IGPSPosition) => void;
|
||||
onSimulationStart?: () => void;
|
||||
onSimulationPause?: () => void;
|
||||
onSimulationStop?: () => void;
|
||||
onSimulationComplete?: () => void;
|
||||
}
|
||||
|
||||
// ─── Speed Presets ────────────────────────────────────────────────────────────
|
||||
|
||||
const SPEED_PRESETS: ISimulationSpeedConfig = {
|
||||
walking: 1.4, // ~5 km/h
|
||||
cycling: 5.5, // ~20 km/h
|
||||
city: 13.9, // ~50 km/h
|
||||
highway: 27.8, // ~100 km/h
|
||||
};
|
||||
|
||||
// ─── Utility Functions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate distance between two points using Haversine formula
|
||||
*/
|
||||
function haversineDistance(
|
||||
lat1: number, lng1: number,
|
||||
lat2: number, lng2: number
|
||||
): number {
|
||||
const R = 6371000; // Earth's radius in meters
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bearing between two points (in degrees, 0 = North)
|
||||
*/
|
||||
function calculateBearing(
|
||||
lat1: number, lng1: number,
|
||||
lat2: number, lng2: number
|
||||
): number {
|
||||
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
const lat1Rad = lat1 * Math.PI / 180;
|
||||
const lat2Rad = lat2 * Math.PI / 180;
|
||||
|
||||
const y = Math.sin(dLng) * Math.cos(lat2Rad);
|
||||
const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) -
|
||||
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLng);
|
||||
|
||||
const bearing = Math.atan2(y, x) * 180 / Math.PI;
|
||||
return (bearing + 360) % 360;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate between two coordinates
|
||||
*/
|
||||
function interpolateCoord(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
fraction: number
|
||||
): [number, number] {
|
||||
return [
|
||||
start[0] + (end[0] - start[0]) * fraction,
|
||||
start[1] + (end[1] - start[1]) * fraction,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add random GPS jitter to coordinates
|
||||
*/
|
||||
function addJitter(lng: number, lat: number, jitterMeters: number): [number, number] {
|
||||
// Convert jitter meters to approximate degrees
|
||||
// 1 degree latitude ≈ 111,000 meters
|
||||
// 1 degree longitude ≈ 111,000 * cos(latitude) meters
|
||||
const jitterLat = ((Math.random() - 0.5) * 2 * jitterMeters) / 111000;
|
||||
const jitterLng = ((Math.random() - 0.5) * 2 * jitterMeters) / (111000 * Math.cos(lat * Math.PI / 180));
|
||||
|
||||
return [lng + jitterLng, lat + jitterLat];
|
||||
}
|
||||
|
||||
// ─── MockGPSSimulator ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Simulates GPS positions along a route for testing/demo purposes
|
||||
*/
|
||||
export class MockGPSSimulator {
|
||||
// Configuration
|
||||
private config: IMockGPSConfig = {
|
||||
speed: 'city',
|
||||
updateInterval: 1000,
|
||||
jitterMeters: 2,
|
||||
startFromBeginning: true,
|
||||
};
|
||||
|
||||
// Route data
|
||||
private route: IOSRMRoute | null = null;
|
||||
private coordinates: [number, number][] = [];
|
||||
private segmentDistances: number[] = [];
|
||||
private totalDistance: number = 0;
|
||||
|
||||
// Simulation state
|
||||
private isRunning: boolean = false;
|
||||
private isPaused: boolean = false;
|
||||
private currentDistanceTraveled: number = 0;
|
||||
private lastPosition: IGPSPosition | null = null;
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Callbacks
|
||||
private callbacks: IMockGPSCallbacks;
|
||||
|
||||
constructor(callbacks: IMockGPSCallbacks, config?: Partial<IMockGPSConfig>) {
|
||||
this.callbacks = callbacks;
|
||||
if (config) {
|
||||
this.configure(config);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Configuration ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Configure simulator settings
|
||||
*/
|
||||
public configure(config: Partial<IMockGPSConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
public getConfig(): IMockGPSConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set simulation speed
|
||||
*/
|
||||
public setSpeed(speed: TSimulationSpeed, customSpeedMps?: number): void {
|
||||
this.config.speed = speed;
|
||||
if (speed === 'custom' && customSpeedMps !== undefined) {
|
||||
this.config.customSpeedMps = customSpeedMps;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current speed in m/s
|
||||
*/
|
||||
public getSpeedMps(): number {
|
||||
if (this.config.speed === 'custom' && this.config.customSpeedMps !== undefined) {
|
||||
return this.config.customSpeedMps;
|
||||
}
|
||||
return SPEED_PRESETS[this.config.speed] || SPEED_PRESETS.city;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get speed in km/h
|
||||
*/
|
||||
public getSpeedKmh(): number {
|
||||
return this.getSpeedMps() * 3.6;
|
||||
}
|
||||
|
||||
// ─── Route Setup ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Set the route to simulate
|
||||
*/
|
||||
public setRoute(route: IOSRMRoute): void {
|
||||
this.route = route;
|
||||
this.coordinates = route.geometry.coordinates as [number, number][];
|
||||
|
||||
// Pre-calculate segment distances
|
||||
this.segmentDistances = [];
|
||||
this.totalDistance = 0;
|
||||
|
||||
for (let i = 0; i < this.coordinates.length - 1; i++) {
|
||||
const [lng1, lat1] = this.coordinates[i];
|
||||
const [lng2, lat2] = this.coordinates[i + 1];
|
||||
const distance = haversineDistance(lat1, lng1, lat2, lng2);
|
||||
this.segmentDistances.push(distance);
|
||||
this.totalDistance += distance;
|
||||
}
|
||||
|
||||
// Reset state
|
||||
this.currentDistanceTraveled = 0;
|
||||
this.lastPosition = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total route distance
|
||||
*/
|
||||
public getTotalDistance(): number {
|
||||
return this.totalDistance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current distance traveled
|
||||
*/
|
||||
public getDistanceTraveled(): number {
|
||||
return this.currentDistanceTraveled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress as percentage (0-100)
|
||||
*/
|
||||
public getProgress(): number {
|
||||
if (this.totalDistance === 0) return 0;
|
||||
return (this.currentDistanceTraveled / this.totalDistance) * 100;
|
||||
}
|
||||
|
||||
// ─── Simulation Control ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start the simulation
|
||||
*/
|
||||
public start(): void {
|
||||
if (!this.route || this.coordinates.length < 2) {
|
||||
console.warn('[MockGPSSimulator] No route set or route too short');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isRunning && !this.isPaused) {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
if (this.isPaused) {
|
||||
// Resume from paused state
|
||||
this.isPaused = false;
|
||||
} else {
|
||||
// Fresh start
|
||||
if (this.config.startFromBeginning) {
|
||||
this.currentDistanceTraveled = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
this.callbacks.onSimulationStart?.();
|
||||
|
||||
// Start interval
|
||||
this.intervalId = setInterval(() => {
|
||||
this.tick();
|
||||
}, this.config.updateInterval);
|
||||
|
||||
// Emit initial position
|
||||
this.tick();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the simulation
|
||||
*/
|
||||
public pause(): void {
|
||||
if (!this.isRunning || this.isPaused) return;
|
||||
|
||||
this.isPaused = true;
|
||||
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
this.callbacks.onSimulationPause?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the simulation
|
||||
*/
|
||||
public stop(): void {
|
||||
this.isRunning = false;
|
||||
this.isPaused = false;
|
||||
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
this.currentDistanceTraveled = 0;
|
||||
this.lastPosition = null;
|
||||
|
||||
this.callbacks.onSimulationStop?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if simulation is running
|
||||
*/
|
||||
public isSimulationRunning(): boolean {
|
||||
return this.isRunning && !this.isPaused;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if simulation is paused
|
||||
*/
|
||||
public isSimulationPaused(): boolean {
|
||||
return this.isPaused;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to a specific point in the route (by percentage)
|
||||
*/
|
||||
public jumpToProgress(percentage: number): void {
|
||||
const clamped = Math.max(0, Math.min(100, percentage));
|
||||
this.currentDistanceTraveled = (clamped / 100) * this.totalDistance;
|
||||
|
||||
// Emit the new position immediately
|
||||
if (this.isRunning) {
|
||||
this.emitCurrentPosition();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Simulation Tick ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Process one simulation tick
|
||||
*/
|
||||
private tick(): void {
|
||||
if (!this.isRunning || this.isPaused) return;
|
||||
|
||||
// Calculate distance to travel this tick
|
||||
const speedMps = this.getSpeedMps();
|
||||
const tickDuration = this.config.updateInterval / 1000; // seconds
|
||||
const distanceThisTick = speedMps * tickDuration;
|
||||
|
||||
// Update distance traveled
|
||||
this.currentDistanceTraveled += distanceThisTick;
|
||||
|
||||
// Check if we've reached the end
|
||||
if (this.currentDistanceTraveled >= this.totalDistance) {
|
||||
this.currentDistanceTraveled = this.totalDistance;
|
||||
this.emitCurrentPosition();
|
||||
this.completeSimulation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit current position
|
||||
this.emitCurrentPosition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the current interpolated position
|
||||
*/
|
||||
private emitCurrentPosition(): void {
|
||||
const position = this.calculatePosition(this.currentDistanceTraveled);
|
||||
if (position) {
|
||||
this.lastPosition = position;
|
||||
this.callbacks.onPositionUpdate(position);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate position at a given distance along the route
|
||||
*/
|
||||
private calculatePosition(distanceAlongRoute: number): IGPSPosition | null {
|
||||
if (this.coordinates.length < 2) return null;
|
||||
|
||||
// Find which segment we're on
|
||||
let accumulatedDistance = 0;
|
||||
|
||||
for (let i = 0; i < this.segmentDistances.length; i++) {
|
||||
const segmentDistance = this.segmentDistances[i];
|
||||
|
||||
if (accumulatedDistance + segmentDistance >= distanceAlongRoute) {
|
||||
// We're on this segment
|
||||
const distanceIntoSegment = distanceAlongRoute - accumulatedDistance;
|
||||
const fraction = segmentDistance > 0 ? distanceIntoSegment / segmentDistance : 0;
|
||||
|
||||
const start = this.coordinates[i];
|
||||
const end = this.coordinates[i + 1];
|
||||
|
||||
// Interpolate position
|
||||
const [lng, lat] = interpolateCoord(start, end, fraction);
|
||||
|
||||
// Add jitter
|
||||
const [jitteredLng, jitteredLat] = addJitter(lng, lat, this.config.jitterMeters);
|
||||
|
||||
// Calculate heading
|
||||
const heading = calculateBearing(start[1], start[0], end[1], end[0]);
|
||||
|
||||
// Calculate speed (current configured speed)
|
||||
const speed = this.getSpeedMps();
|
||||
|
||||
return {
|
||||
lng: jitteredLng,
|
||||
lat: jitteredLat,
|
||||
heading,
|
||||
speed,
|
||||
accuracy: this.config.jitterMeters,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
accumulatedDistance += segmentDistance;
|
||||
}
|
||||
|
||||
// If we're past the end, return the last coordinate
|
||||
const lastCoord = this.coordinates[this.coordinates.length - 1];
|
||||
const prevCoord = this.coordinates[this.coordinates.length - 2];
|
||||
const heading = calculateBearing(prevCoord[1], prevCoord[0], lastCoord[1], lastCoord[0]);
|
||||
|
||||
return {
|
||||
lng: lastCoord[0],
|
||||
lat: lastCoord[1],
|
||||
heading,
|
||||
speed: 0,
|
||||
accuracy: this.config.jitterMeters,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the simulation
|
||||
*/
|
||||
private completeSimulation(): void {
|
||||
this.isRunning = false;
|
||||
this.isPaused = false;
|
||||
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
this.callbacks.onSimulationComplete?.();
|
||||
}
|
||||
|
||||
// ─── Cleanup ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
public cleanup(): void {
|
||||
this.stop();
|
||||
this.route = null;
|
||||
this.coordinates = [];
|
||||
this.segmentDistances = [];
|
||||
this.totalDistance = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Speed Helper Functions ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get display name for speed preset
|
||||
*/
|
||||
export function getSpeedDisplayName(speed: TSimulationSpeed): string {
|
||||
const names: Record<TSimulationSpeed, string> = {
|
||||
walking: 'Walking',
|
||||
cycling: 'Cycling',
|
||||
city: 'City Driving',
|
||||
highway: 'Highway',
|
||||
custom: 'Custom',
|
||||
};
|
||||
return names[speed];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get speed in km/h for a preset
|
||||
*/
|
||||
export function getSpeedKmh(speed: TSimulationSpeed): number {
|
||||
if (speed === 'custom') return 0;
|
||||
return SPEED_PRESETS[speed] * 3.6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all speed presets with display info
|
||||
*/
|
||||
export function getSpeedPresets(): Array<{ id: TSimulationSpeed; name: string; kmh: number }> {
|
||||
return [
|
||||
{ id: 'walking', name: 'Walking', kmh: 5 },
|
||||
{ id: 'cycling', name: 'Cycling', kmh: 20 },
|
||||
{ id: 'city', name: 'City Driving', kmh: 50 },
|
||||
{ id: 'highway', name: 'Highway', kmh: 100 },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { VoiceSynthesisManager, type IVoiceConfig } from './geo-map.voice.js';
|
||||
import type { IOSRMRoute, IOSRMStep } from './geo-map.navigation.js';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface IGPSPosition {
|
||||
lng: number;
|
||||
lat: number;
|
||||
heading?: number; // 0-360 degrees, 0 = North
|
||||
speed?: number; // meters/second
|
||||
accuracy?: number; // meters
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface INavigationGuideState {
|
||||
isNavigating: boolean;
|
||||
currentStepIndex: number;
|
||||
distanceToNextManeuver: number; // meters
|
||||
distanceRemaining: number; // meters
|
||||
timeRemaining: number; // seconds
|
||||
currentPosition: IGPSPosition | null;
|
||||
isOffRoute: boolean;
|
||||
hasArrived: boolean;
|
||||
}
|
||||
|
||||
export type TGuidanceEventType =
|
||||
| 'approach-maneuver' // "In 200m, turn left"
|
||||
| 'execute-maneuver' // "Turn left now"
|
||||
| 'step-change' // Moved to next step
|
||||
| 'off-route' // User deviated from route
|
||||
| 'arrived' // Reached destination
|
||||
| 'position-updated'; // Position was updated
|
||||
|
||||
export interface IGuidanceEvent {
|
||||
type: TGuidanceEventType;
|
||||
position: IGPSPosition;
|
||||
stepIndex: number;
|
||||
step?: IOSRMStep;
|
||||
distanceToManeuver: number;
|
||||
instruction?: string;
|
||||
}
|
||||
|
||||
export interface INavigationGuideCallbacks {
|
||||
onGuidanceEvent: (event: IGuidanceEvent) => void;
|
||||
onRequestUpdate: () => void;
|
||||
getMap: () => maplibregl.Map | null;
|
||||
}
|
||||
|
||||
// ─── Distance Thresholds ──────────────────────────────────────────────────────
|
||||
|
||||
const GUIDANCE_THRESHOLDS = {
|
||||
FAR: 500, // "In 500 meters..."
|
||||
APPROACHING: 200, // "In 200 meters..."
|
||||
NEAR: 50, // "Turn left ahead"
|
||||
AT_MANEUVER: 20, // "Turn left now"
|
||||
OFF_ROUTE: 50, // Distance from route line to trigger off-route
|
||||
ARRIVED: 30, // Distance from destination to trigger arrival
|
||||
};
|
||||
|
||||
// ─── Utility Functions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate distance between two points using Haversine formula
|
||||
*/
|
||||
function haversineDistance(
|
||||
lat1: number, lng1: number,
|
||||
lat2: number, lng2: number
|
||||
): number {
|
||||
const R = 6371000; // Earth's radius in meters
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance from a point to a line segment
|
||||
*/
|
||||
function pointToLineDistance(
|
||||
px: number, py: number,
|
||||
x1: number, y1: number,
|
||||
x2: number, y2: number
|
||||
): number {
|
||||
const A = px - x1;
|
||||
const B = py - y1;
|
||||
const C = x2 - x1;
|
||||
const D = y2 - y1;
|
||||
|
||||
const dot = A * C + B * D;
|
||||
const lenSq = C * C + D * D;
|
||||
let param = -1;
|
||||
|
||||
if (lenSq !== 0) {
|
||||
param = dot / lenSq;
|
||||
}
|
||||
|
||||
let xx: number, yy: number;
|
||||
|
||||
if (param < 0) {
|
||||
xx = x1;
|
||||
yy = y1;
|
||||
} else if (param > 1) {
|
||||
xx = x2;
|
||||
yy = y2;
|
||||
} else {
|
||||
xx = x1 + param * C;
|
||||
yy = y1 + param * D;
|
||||
}
|
||||
|
||||
return haversineDistance(py, px, yy, xx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the minimum distance from a point to a polyline
|
||||
*/
|
||||
function pointToPolylineDistance(
|
||||
px: number, py: number,
|
||||
coordinates: [number, number][]
|
||||
): number {
|
||||
let minDist = Infinity;
|
||||
|
||||
for (let i = 0; i < coordinates.length - 1; i++) {
|
||||
const [x1, y1] = coordinates[i];
|
||||
const [x2, y2] = coordinates[i + 1];
|
||||
const dist = pointToLineDistance(px, py, x1, y1, x2, y2);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format distance for voice instructions
|
||||
*/
|
||||
function formatDistanceForVoice(meters: number): string {
|
||||
if (meters < 100) {
|
||||
return `${Math.round(meters / 10) * 10} meters`;
|
||||
} else if (meters < 1000) {
|
||||
return `${Math.round(meters / 50) * 50} meters`;
|
||||
} else {
|
||||
const km = meters / 1000;
|
||||
if (km < 10) {
|
||||
return `${km.toFixed(1)} kilometers`;
|
||||
}
|
||||
return `${Math.round(km)} kilometers`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format maneuver type for voice
|
||||
*/
|
||||
function formatManeuverForVoice(type: string, modifier?: string): string {
|
||||
switch (type) {
|
||||
case 'turn':
|
||||
if (modifier === 'left') return 'turn left';
|
||||
if (modifier === 'right') return 'turn right';
|
||||
if (modifier === 'slight left') return 'bear left';
|
||||
if (modifier === 'slight right') return 'bear right';
|
||||
if (modifier === 'sharp left') return 'take a sharp left';
|
||||
if (modifier === 'sharp right') return 'take a sharp right';
|
||||
return `turn ${modifier || ''}`.trim();
|
||||
case 'depart':
|
||||
return 'head forward';
|
||||
case 'arrive':
|
||||
return 'arrive at your destination';
|
||||
case 'merge':
|
||||
return `merge ${modifier || ''}`.trim();
|
||||
case 'fork':
|
||||
if (modifier === 'left') return 'keep left at the fork';
|
||||
if (modifier === 'right') return 'keep right at the fork';
|
||||
return 'take the fork';
|
||||
case 'roundabout':
|
||||
case 'rotary':
|
||||
return 'enter the roundabout';
|
||||
case 'continue':
|
||||
return 'continue straight';
|
||||
case 'end of road':
|
||||
return `at the end of the road, turn ${modifier || 'around'}`;
|
||||
case 'new name':
|
||||
return 'continue straight';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Camera Configuration ────────────────────────────────────────────────────
|
||||
|
||||
export interface INavigationCameraConfig {
|
||||
followPosition: boolean; // Camera follows GPS position (default: true)
|
||||
followBearing: boolean; // Camera rotates with heading (default: true)
|
||||
pitch: number; // 3D tilt angle in degrees (default: 60)
|
||||
zoom: number; // Navigation zoom level (default: 17)
|
||||
}
|
||||
|
||||
// ─── NavigationGuideController ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Controller for real-time GPS navigation guidance
|
||||
* Tracks position, step progression, and fires guidance events
|
||||
*/
|
||||
export class NavigationGuideController {
|
||||
// State
|
||||
public state: INavigationGuideState = {
|
||||
isNavigating: false,
|
||||
currentStepIndex: 0,
|
||||
distanceToNextManeuver: 0,
|
||||
distanceRemaining: 0,
|
||||
timeRemaining: 0,
|
||||
currentPosition: null,
|
||||
isOffRoute: false,
|
||||
hasArrived: false,
|
||||
};
|
||||
|
||||
// Route data
|
||||
private route: IOSRMRoute | null = null;
|
||||
private allSteps: IOSRMStep[] = [];
|
||||
|
||||
// Voice synthesis
|
||||
private voiceManager: VoiceSynthesisManager;
|
||||
|
||||
// Position marker
|
||||
private positionMarker: maplibregl.Marker | null = null;
|
||||
|
||||
// Track last position time for smooth animation timing
|
||||
private lastPositionTimestamp: number = 0;
|
||||
|
||||
// Camera configuration for navigation mode
|
||||
private cameraConfig: INavigationCameraConfig = {
|
||||
followPosition: true,
|
||||
followBearing: true,
|
||||
pitch: 60,
|
||||
zoom: 17,
|
||||
};
|
||||
|
||||
// Announcement tracking to avoid repetition
|
||||
private lastAnnouncedThreshold: number | null = null;
|
||||
private lastAnnouncedStepIndex: number = -1;
|
||||
|
||||
// Callbacks
|
||||
private callbacks: INavigationGuideCallbacks;
|
||||
|
||||
constructor(callbacks: INavigationGuideCallbacks, voiceConfig?: Partial<IVoiceConfig>) {
|
||||
this.callbacks = callbacks;
|
||||
this.voiceManager = new VoiceSynthesisManager(voiceConfig);
|
||||
}
|
||||
|
||||
// ─── Configuration ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Configure voice settings
|
||||
*/
|
||||
public configureVoice(config: Partial<IVoiceConfig>): void {
|
||||
this.voiceManager.configure(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable voice
|
||||
*/
|
||||
public setVoiceEnabled(enabled: boolean): void {
|
||||
if (enabled) {
|
||||
this.voiceManager.enable();
|
||||
} else {
|
||||
this.voiceManager.disable();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if voice is enabled
|
||||
*/
|
||||
public isVoiceEnabled(): boolean {
|
||||
return this.voiceManager.isEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get voice manager for direct access
|
||||
*/
|
||||
public getVoiceManager(): VoiceSynthesisManager {
|
||||
return this.voiceManager;
|
||||
}
|
||||
|
||||
// ─── Camera Configuration ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Enable/disable camera following the GPS position
|
||||
*/
|
||||
public setFollowPosition(enabled: boolean): void {
|
||||
this.cameraConfig.followPosition = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if camera is following position
|
||||
*/
|
||||
public isFollowingPosition(): boolean {
|
||||
return this.cameraConfig.followPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable camera rotating with heading
|
||||
*/
|
||||
public setFollowBearing(enabled: boolean): void {
|
||||
this.cameraConfig.followBearing = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if camera is following bearing
|
||||
*/
|
||||
public isFollowingBearing(): boolean {
|
||||
return this.cameraConfig.followBearing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the navigation camera pitch (3D tilt angle)
|
||||
* @param pitch - Angle in degrees (0 = flat, 60 = tilted)
|
||||
*/
|
||||
public setPitch(pitch: number): void {
|
||||
this.cameraConfig.pitch = Math.max(0, Math.min(85, pitch));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current pitch setting
|
||||
*/
|
||||
public getPitch(): number {
|
||||
return this.cameraConfig.pitch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the navigation zoom level
|
||||
* @param zoom - Zoom level (typically 15-19 for street-level navigation)
|
||||
*/
|
||||
public setZoom(zoom: number): void {
|
||||
this.cameraConfig.zoom = Math.max(1, Math.min(22, zoom));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current zoom setting
|
||||
*/
|
||||
public getZoom(): number {
|
||||
return this.cameraConfig.zoom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full camera configuration
|
||||
*/
|
||||
public getCameraConfig(): INavigationCameraConfig {
|
||||
return { ...this.cameraConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the full camera configuration
|
||||
*/
|
||||
public setCameraConfig(config: Partial<INavigationCameraConfig>): void {
|
||||
if (config.followPosition !== undefined) {
|
||||
this.cameraConfig.followPosition = config.followPosition;
|
||||
}
|
||||
if (config.followBearing !== undefined) {
|
||||
this.cameraConfig.followBearing = config.followBearing;
|
||||
}
|
||||
if (config.pitch !== undefined) {
|
||||
this.cameraConfig.pitch = Math.max(0, Math.min(85, config.pitch));
|
||||
}
|
||||
if (config.zoom !== undefined) {
|
||||
this.cameraConfig.zoom = Math.max(1, Math.min(22, config.zoom));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Navigation Lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start navigation guidance for a route
|
||||
*/
|
||||
public startGuidance(route: IOSRMRoute): void {
|
||||
this.route = route;
|
||||
this.allSteps = route.legs.flatMap(leg => leg.steps);
|
||||
|
||||
this.state = {
|
||||
isNavigating: true,
|
||||
currentStepIndex: 0,
|
||||
distanceToNextManeuver: this.allSteps[0]?.distance ?? 0,
|
||||
distanceRemaining: route.distance,
|
||||
timeRemaining: route.duration,
|
||||
currentPosition: null,
|
||||
isOffRoute: false,
|
||||
hasArrived: false,
|
||||
};
|
||||
|
||||
this.lastAnnouncedThreshold = null;
|
||||
this.lastAnnouncedStepIndex = -1;
|
||||
|
||||
this.callbacks.onRequestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop navigation guidance
|
||||
*/
|
||||
public stopGuidance(): void {
|
||||
this.state = {
|
||||
isNavigating: false,
|
||||
currentStepIndex: 0,
|
||||
distanceToNextManeuver: 0,
|
||||
distanceRemaining: 0,
|
||||
timeRemaining: 0,
|
||||
currentPosition: null,
|
||||
isOffRoute: false,
|
||||
hasArrived: false,
|
||||
};
|
||||
|
||||
this.route = null;
|
||||
this.allSteps = [];
|
||||
this.lastAnnouncedThreshold = null;
|
||||
this.lastAnnouncedStepIndex = -1;
|
||||
this.lastPositionTimestamp = 0;
|
||||
|
||||
this.voiceManager.stop();
|
||||
this.removePositionMarker();
|
||||
|
||||
this.callbacks.onRequestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently navigating
|
||||
*/
|
||||
public isNavigating(): boolean {
|
||||
return this.state.isNavigating;
|
||||
}
|
||||
|
||||
// ─── Position Updates ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update position from external GPS source
|
||||
*/
|
||||
public updatePosition(position: IGPSPosition): void {
|
||||
if (!this.state.isNavigating || !this.route) return;
|
||||
|
||||
this.state.currentPosition = position;
|
||||
|
||||
// Update position marker
|
||||
this.updatePositionMarker(position);
|
||||
|
||||
// Check if off-route
|
||||
const distanceFromRoute = this.calculateDistanceFromRoute(position);
|
||||
const wasOffRoute = this.state.isOffRoute;
|
||||
this.state.isOffRoute = distanceFromRoute > GUIDANCE_THRESHOLDS.OFF_ROUTE;
|
||||
|
||||
if (this.state.isOffRoute && !wasOffRoute) {
|
||||
this.handleOffRoute(position);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find current step and calculate distances
|
||||
this.updateStepProgress(position);
|
||||
|
||||
// Check for arrival
|
||||
this.checkArrival(position);
|
||||
|
||||
// Fire position updated event
|
||||
this.emitGuidanceEvent('position-updated', position);
|
||||
|
||||
this.callbacks.onRequestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set position (convenience method for external use)
|
||||
*/
|
||||
public setPosition(lng: number, lat: number, heading?: number, speed?: number): void {
|
||||
this.updatePosition({
|
||||
lng,
|
||||
lat,
|
||||
heading,
|
||||
speed,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Step Progress Tracking ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update step progress based on current position
|
||||
*/
|
||||
private updateStepProgress(position: IGPSPosition): void {
|
||||
if (this.allSteps.length === 0) return;
|
||||
|
||||
// Find the closest step maneuver point
|
||||
let closestStepIndex = this.state.currentStepIndex;
|
||||
let minDistance = Infinity;
|
||||
|
||||
// Look ahead a few steps (don't go backwards)
|
||||
const searchEnd = Math.min(this.state.currentStepIndex + 3, this.allSteps.length);
|
||||
|
||||
for (let i = this.state.currentStepIndex; i < searchEnd; i++) {
|
||||
const step = this.allSteps[i];
|
||||
const [maneuverLng, maneuverLat] = step.maneuver.location;
|
||||
const distance = haversineDistance(position.lat, position.lng, maneuverLat, maneuverLng);
|
||||
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestStepIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we've passed the current maneuver (close to it)
|
||||
const currentStep = this.allSteps[this.state.currentStepIndex];
|
||||
if (currentStep) {
|
||||
const [maneuverLng, maneuverLat] = currentStep.maneuver.location;
|
||||
const distanceToManeuver = haversineDistance(position.lat, position.lng, maneuverLat, maneuverLng);
|
||||
|
||||
// If we're very close to the maneuver or past it, advance to next step
|
||||
if (distanceToManeuver < GUIDANCE_THRESHOLDS.AT_MANEUVER &&
|
||||
this.state.currentStepIndex < this.allSteps.length - 1) {
|
||||
this.advanceToNextStep(position);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update distance to maneuver
|
||||
this.state.distanceToNextManeuver = distanceToManeuver;
|
||||
|
||||
// Check guidance thresholds
|
||||
this.checkGuidanceThresholds(position, distanceToManeuver, currentStep);
|
||||
}
|
||||
|
||||
// Calculate remaining distance and time
|
||||
this.calculateRemaining(position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance to the next step
|
||||
*/
|
||||
private advanceToNextStep(position: IGPSPosition): void {
|
||||
const previousStepIndex = this.state.currentStepIndex;
|
||||
this.state.currentStepIndex++;
|
||||
|
||||
if (this.state.currentStepIndex >= this.allSteps.length) {
|
||||
// Reached end of route
|
||||
return;
|
||||
}
|
||||
|
||||
const newStep = this.allSteps[this.state.currentStepIndex];
|
||||
|
||||
// Reset announcement tracking for new step
|
||||
this.lastAnnouncedThreshold = null;
|
||||
|
||||
// Emit step change event
|
||||
this.emitGuidanceEvent('step-change', position, newStep);
|
||||
|
||||
// Announce the new step if it's not just departing
|
||||
if (this.lastAnnouncedStepIndex !== previousStepIndex) {
|
||||
this.lastAnnouncedStepIndex = previousStepIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check guidance thresholds and announce instructions
|
||||
*/
|
||||
private checkGuidanceThresholds(position: IGPSPosition, distance: number, step: IOSRMStep): void {
|
||||
// Determine current threshold zone
|
||||
let currentThreshold: number | null = null;
|
||||
|
||||
if (distance <= GUIDANCE_THRESHOLDS.AT_MANEUVER) {
|
||||
currentThreshold = GUIDANCE_THRESHOLDS.AT_MANEUVER;
|
||||
} else if (distance <= GUIDANCE_THRESHOLDS.NEAR) {
|
||||
currentThreshold = GUIDANCE_THRESHOLDS.NEAR;
|
||||
} else if (distance <= GUIDANCE_THRESHOLDS.APPROACHING) {
|
||||
currentThreshold = GUIDANCE_THRESHOLDS.APPROACHING;
|
||||
} else if (distance <= GUIDANCE_THRESHOLDS.FAR) {
|
||||
currentThreshold = GUIDANCE_THRESHOLDS.FAR;
|
||||
}
|
||||
|
||||
// Only announce if we've entered a new threshold zone
|
||||
if (currentThreshold !== null && currentThreshold !== this.lastAnnouncedThreshold) {
|
||||
this.lastAnnouncedThreshold = currentThreshold;
|
||||
this.announceManeuver(position, distance, step, currentThreshold);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce maneuver based on threshold
|
||||
*/
|
||||
private announceManeuver(position: IGPSPosition, distance: number, step: IOSRMStep, threshold: number): void {
|
||||
const maneuver = formatManeuverForVoice(step.maneuver.type, step.maneuver.modifier);
|
||||
const streetName = step.name !== '' ? step.name : undefined;
|
||||
|
||||
if (threshold === GUIDANCE_THRESHOLDS.AT_MANEUVER) {
|
||||
// Execute maneuver
|
||||
this.voiceManager.speakManeuver(maneuver, true);
|
||||
this.emitGuidanceEvent('execute-maneuver', position, step, maneuver);
|
||||
} else if (threshold === GUIDANCE_THRESHOLDS.NEAR) {
|
||||
// "Turn left ahead"
|
||||
const instruction = `${maneuver} ahead`;
|
||||
this.voiceManager.speak(instruction);
|
||||
this.emitGuidanceEvent('approach-maneuver', position, step, instruction);
|
||||
} else {
|
||||
// Approach - "In 200 meters, turn left"
|
||||
const distanceStr = formatDistanceForVoice(distance);
|
||||
this.voiceManager.speakApproach(distanceStr, maneuver, streetName);
|
||||
// Use "on" for continue, "onto" for turns/merges
|
||||
const preposition = maneuver.startsWith('continue') ? 'on' : 'onto';
|
||||
const instruction = `In ${distanceStr}, ${maneuver}${streetName ? ` ${preposition} ${streetName}` : ''}`;
|
||||
this.emitGuidanceEvent('approach-maneuver', position, step, instruction);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Route Calculations ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate distance from current position to route line
|
||||
*/
|
||||
private calculateDistanceFromRoute(position: IGPSPosition): number {
|
||||
if (!this.route) return 0;
|
||||
|
||||
const coords = this.route.geometry.coordinates as [number, number][];
|
||||
return pointToPolylineDistance(position.lng, position.lat, coords);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate remaining distance and time
|
||||
*/
|
||||
private calculateRemaining(position: IGPSPosition): void {
|
||||
if (!this.route || this.allSteps.length === 0) return;
|
||||
|
||||
// Sum distance/duration of remaining steps
|
||||
let remainingDistance = 0;
|
||||
let remainingDuration = 0;
|
||||
|
||||
for (let i = this.state.currentStepIndex; i < this.allSteps.length; i++) {
|
||||
remainingDistance += this.allSteps[i].distance;
|
||||
remainingDuration += this.allSteps[i].duration;
|
||||
}
|
||||
|
||||
// Subtract distance already traveled in current step
|
||||
if (this.state.currentStepIndex < this.allSteps.length) {
|
||||
const currentStep = this.allSteps[this.state.currentStepIndex];
|
||||
const stepProgress = 1 - (this.state.distanceToNextManeuver / currentStep.distance);
|
||||
const progressDistance = currentStep.distance * Math.max(0, Math.min(1, stepProgress));
|
||||
remainingDistance -= progressDistance;
|
||||
}
|
||||
|
||||
this.state.distanceRemaining = Math.max(0, remainingDistance);
|
||||
this.state.timeRemaining = Math.max(0, remainingDuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has arrived at destination
|
||||
*/
|
||||
private checkArrival(position: IGPSPosition): void {
|
||||
if (!this.route || this.state.hasArrived) return;
|
||||
|
||||
// Get the last coordinate of the route (destination)
|
||||
const coords = this.route.geometry.coordinates;
|
||||
const destination = coords[coords.length - 1] as [number, number];
|
||||
|
||||
const distanceToDestination = haversineDistance(
|
||||
position.lat, position.lng,
|
||||
destination[1], destination[0]
|
||||
);
|
||||
|
||||
if (distanceToDestination <= GUIDANCE_THRESHOLDS.ARRIVED) {
|
||||
this.state.hasArrived = true;
|
||||
this.voiceManager.speakArrival();
|
||||
this.emitGuidanceEvent('arrived', position);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle off-route scenario
|
||||
*/
|
||||
private handleOffRoute(position: IGPSPosition): void {
|
||||
this.voiceManager.speakOffRoute();
|
||||
this.emitGuidanceEvent('off-route', position);
|
||||
}
|
||||
|
||||
// ─── Position Marker ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update position marker on map and move camera to follow
|
||||
*/
|
||||
private updatePositionMarker(position: IGPSPosition): void {
|
||||
const map = this.callbacks.getMap();
|
||||
if (!map) return;
|
||||
|
||||
if (!this.positionMarker) {
|
||||
// Create marker element with inline styles (Shadow DOM fix)
|
||||
// MapLibre markers are added to the light DOM, so CSS classes from
|
||||
// Shadow DOM won't apply. Using inline styles ensures proper styling.
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'width: 32px; height: 32px; cursor: default; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));';
|
||||
el.innerHTML = this.createPositionMarkerSVG();
|
||||
|
||||
this.positionMarker = new maplibregl.Marker({
|
||||
element: el,
|
||||
anchor: 'center',
|
||||
rotationAlignment: 'map',
|
||||
pitchAlignment: 'viewport',
|
||||
})
|
||||
.setLngLat([position.lng, position.lat])
|
||||
.addTo(map);
|
||||
} else {
|
||||
this.positionMarker.setLngLat([position.lng, position.lat]);
|
||||
}
|
||||
|
||||
// Apply rotation via MapLibre's marker rotation (correct anchor handling)
|
||||
if (position.heading !== undefined) {
|
||||
this.positionMarker.setRotation(position.heading);
|
||||
}
|
||||
|
||||
// Calculate animation duration based on time since last update
|
||||
// This creates smooth, continuous animation that matches the GPS update interval
|
||||
const now = Date.now();
|
||||
const timeSinceLastUpdate = this.lastPositionTimestamp > 0
|
||||
? now - this.lastPositionTimestamp
|
||||
: 1000;
|
||||
this.lastPositionTimestamp = now;
|
||||
|
||||
// Clamp duration: min 100ms (prevents jarring), max 2000ms (prevents lag)
|
||||
const animationDuration = Math.max(100, Math.min(2000, timeSinceLastUpdate));
|
||||
|
||||
// Move camera to follow position
|
||||
if (this.cameraConfig.followPosition) {
|
||||
const bearing = this.cameraConfig.followBearing && position.heading !== undefined
|
||||
? position.heading
|
||||
: map.getBearing();
|
||||
|
||||
map.easeTo({
|
||||
center: [position.lng, position.lat],
|
||||
bearing,
|
||||
pitch: this.cameraConfig.pitch,
|
||||
zoom: this.cameraConfig.zoom,
|
||||
duration: animationDuration,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SVG for position marker with heading indicator
|
||||
* Note: Rotation is handled by MapLibre's marker.setRotation() for correct anchor handling
|
||||
*/
|
||||
private createPositionMarkerSVG(): string {
|
||||
return `
|
||||
<svg width="32" height="32" viewBox="0 0 32 32">
|
||||
<!-- Blue circle -->
|
||||
<circle cx="16" cy="16" r="10" fill="#3b82f6" stroke="#fff" stroke-width="3"/>
|
||||
<!-- Heading indicator triangle (pointing up/north, rotation handled by MapLibre) -->
|
||||
<polygon points="16,2 12,10 20,10" fill="#fff"/>
|
||||
<!-- Accuracy circle (semi-transparent) -->
|
||||
<circle cx="16" cy="16" r="14" fill="rgba(59, 130, 246, 0.2)" stroke="none"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove position marker from map
|
||||
*/
|
||||
private removePositionMarker(): void {
|
||||
if (this.positionMarker) {
|
||||
this.positionMarker.remove();
|
||||
this.positionMarker = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Event Emission ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emit a guidance event
|
||||
*/
|
||||
private emitGuidanceEvent(
|
||||
type: TGuidanceEventType,
|
||||
position: IGPSPosition,
|
||||
step?: IOSRMStep,
|
||||
instruction?: string
|
||||
): void {
|
||||
const event: IGuidanceEvent = {
|
||||
type,
|
||||
position,
|
||||
stepIndex: this.state.currentStepIndex,
|
||||
step,
|
||||
distanceToManeuver: this.state.distanceToNextManeuver,
|
||||
instruction,
|
||||
};
|
||||
|
||||
this.callbacks.onGuidanceEvent(event);
|
||||
}
|
||||
|
||||
// ─── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render guidance panel UI
|
||||
*/
|
||||
public render(): TemplateResult {
|
||||
if (!this.state.isNavigating || !this.route) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const currentStep = this.allSteps[this.state.currentStepIndex];
|
||||
const distance = this.formatDistance(this.state.distanceToNextManeuver);
|
||||
const maneuverIcon = this.getManeuverIcon(currentStep?.maneuver.type, currentStep?.maneuver.modifier);
|
||||
const instruction = currentStep ? this.formatInstruction(currentStep) : '';
|
||||
|
||||
return html`
|
||||
<div class="guidance-panel">
|
||||
<div class="guidance-maneuver">
|
||||
<div class="guidance-maneuver-icon">${maneuverIcon}</div>
|
||||
<div class="guidance-maneuver-distance">${distance}</div>
|
||||
</div>
|
||||
<div class="guidance-instruction">${instruction}</div>
|
||||
<div class="guidance-remaining">
|
||||
${this.formatDistance(this.state.distanceRemaining)} remaining
|
||||
•
|
||||
${this.formatDuration(this.state.timeRemaining)}
|
||||
</div>
|
||||
${this.state.isOffRoute ? html`
|
||||
<div class="guidance-off-route">
|
||||
Off route - recalculating...
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── Formatting Utilities ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format distance for display
|
||||
*/
|
||||
private formatDistance(meters: number): string {
|
||||
if (meters < 1000) {
|
||||
return `${Math.round(meters)} m`;
|
||||
}
|
||||
return `${(meters / 1000).toFixed(1)} km`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration for display
|
||||
*/
|
||||
private formatDuration(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)} sec`;
|
||||
}
|
||||
if (seconds < 3600) {
|
||||
return `${Math.round(seconds / 60)} min`;
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.round((seconds % 3600) / 60);
|
||||
return mins > 0 ? `${hours} hr ${mins} min` : `${hours} hr`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maneuver icon
|
||||
*/
|
||||
private getManeuverIcon(type?: string, modifier?: string): string {
|
||||
if (!type) return '➡';
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
'depart': '⬆️',
|
||||
'arrive': '🏁',
|
||||
'turn-left': '↰',
|
||||
'turn-right': '↱',
|
||||
'turn-slight left': '↖',
|
||||
'turn-slight right': '↗',
|
||||
'turn-sharp left': '⬅',
|
||||
'turn-sharp right': '➡',
|
||||
'continue-straight': '⬆️',
|
||||
'continue': '⬆️',
|
||||
'roundabout': '🔄',
|
||||
'rotary': '🔄',
|
||||
'merge': '⤵️',
|
||||
'fork-left': '↖',
|
||||
'fork-right': '↗',
|
||||
'new name': '⬆️',
|
||||
};
|
||||
|
||||
const key = modifier ? `${type}-${modifier}` : type;
|
||||
return icons[key] || icons[type] || '➡';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format step instruction
|
||||
*/
|
||||
private formatInstruction(step: IOSRMStep): string {
|
||||
const { type, modifier } = step.maneuver;
|
||||
const name = step.name || 'unnamed road';
|
||||
|
||||
switch (type) {
|
||||
case 'depart':
|
||||
return `Head ${modifier || 'forward'} on ${name}`;
|
||||
case 'arrive':
|
||||
return 'Arrive at your destination';
|
||||
case 'turn':
|
||||
return `Turn ${modifier || ''} onto ${name}`;
|
||||
case 'continue':
|
||||
return `Continue on ${name}`;
|
||||
case 'merge':
|
||||
return `Merge ${modifier || ''} onto ${name}`;
|
||||
case 'roundabout':
|
||||
case 'rotary':
|
||||
return `At the roundabout, take the exit onto ${name}`;
|
||||
case 'fork':
|
||||
return `Take the ${modifier || ''} fork onto ${name}`;
|
||||
case 'end of road':
|
||||
return `At the end of the road, turn ${modifier || ''} onto ${name}`;
|
||||
case 'new name':
|
||||
return `Continue on ${name}`;
|
||||
default:
|
||||
return `${type} on ${name}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cleanup ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
public cleanup(): void {
|
||||
this.stopGuidance();
|
||||
this.removePositionMarker();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import maplibregl from 'maplibre-gl';
|
||||
import { renderIcon } from './geo-map.icons.js';
|
||||
import { type INominatimResult } from './geo-map.search.js';
|
||||
import type { ITrafficAwareRoute } from './geo-map.traffic.providers.js';
|
||||
import type { INavigationGuideState } from './geo-map.navigation-guide.js';
|
||||
|
||||
// ─── Navigation/Routing Types ────────────────────────────────────────────────
|
||||
|
||||
@@ -29,6 +30,7 @@ export interface IOSRMStep {
|
||||
location: [number, number];
|
||||
};
|
||||
name: string; // Street name
|
||||
ref?: string; // Road reference (A1, M25, etc.)
|
||||
distance: number;
|
||||
duration: number;
|
||||
driving_side: string;
|
||||
@@ -65,6 +67,12 @@ export interface INavigationControllerCallbacks {
|
||||
end: [number, number],
|
||||
mode: TNavigationMode
|
||||
) => Promise<ITrafficAwareRoute | null>;
|
||||
/** Optional callback when traffic toggle is changed */
|
||||
onTrafficToggle?: (enabled: boolean) => void;
|
||||
/** Optional callback to get current traffic state */
|
||||
getTrafficEnabled?: () => boolean;
|
||||
/** Optional callback to get current guidance state for turn-by-turn synchronization */
|
||||
getGuidanceState?: () => INavigationGuideState | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,6 +104,9 @@ export class NavigationController {
|
||||
// Mode
|
||||
public navigationMode: TNavigationMode = 'driving';
|
||||
|
||||
// View mode: 'planning' for route input, 'directions' for turn-by-turn
|
||||
public viewMode: 'planning' | 'directions' = 'planning';
|
||||
|
||||
// Internal
|
||||
private callbacks: INavigationControllerCallbacks;
|
||||
private navSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -198,6 +209,9 @@ export class NavigationController {
|
||||
|
||||
// Fit map to route bounds
|
||||
this.fitToRoute(routeToRender);
|
||||
|
||||
// Switch to directions view after successful route calculation
|
||||
this.viewMode = 'directions';
|
||||
}
|
||||
} catch (error) {
|
||||
this.navigationState = {
|
||||
@@ -272,6 +286,7 @@ export class NavigationController {
|
||||
this.navStartSearchResults = [];
|
||||
this.navEndSearchResults = [];
|
||||
this.navClickMode = null;
|
||||
this.viewMode = 'planning';
|
||||
|
||||
// Remove markers
|
||||
if (this.startMarker) {
|
||||
@@ -406,7 +421,7 @@ export class NavigationController {
|
||||
el.className = 'nav-marker nav-marker-start';
|
||||
el.innerHTML = `<svg viewBox="0 0 24 24" fill="#22c55e" stroke="#fff" stroke-width="2"><circle cx="12" cy="12" r="8"/></svg>`;
|
||||
el.style.cssText = 'width: 24px; height: 24px; cursor: pointer;';
|
||||
this.startMarker = new maplibregl.Marker({ element: el, anchor: 'center' })
|
||||
this.startMarker = new maplibregl.Marker({ element: el, anchor: 'center', pitchAlignment: 'viewport' })
|
||||
.setLngLat(this.navigationState.startPoint)
|
||||
.addTo(map);
|
||||
} else {
|
||||
@@ -424,7 +439,7 @@ export class NavigationController {
|
||||
el.className = 'nav-marker nav-marker-end';
|
||||
el.innerHTML = `<svg viewBox="0 0 24 24" fill="#ef4444" stroke="#fff" stroke-width="2"><circle cx="12" cy="12" r="8"/></svg>`;
|
||||
el.style.cssText = 'width: 24px; height: 24px; cursor: pointer;';
|
||||
this.endMarker = new maplibregl.Marker({ element: el, anchor: 'center' })
|
||||
this.endMarker = new maplibregl.Marker({ element: el, anchor: 'center', pitchAlignment: 'viewport' })
|
||||
.setLngLat(this.navigationState.endPoint)
|
||||
.addTo(map);
|
||||
} else {
|
||||
@@ -516,6 +531,20 @@ export class NavigationController {
|
||||
map.fitBounds(bounds, { padding: 80 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fly to a specific navigation step location
|
||||
*/
|
||||
public flyToStep(step: IOSRMStep): void {
|
||||
const map = this.callbacks.getMap();
|
||||
if (!map) return;
|
||||
|
||||
map.flyTo({
|
||||
center: step.maneuver.location,
|
||||
zoom: 17,
|
||||
duration: 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Search within Navigation ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -717,7 +746,7 @@ export class NavigationController {
|
||||
*/
|
||||
public formatStepInstruction(step: IOSRMStep): string {
|
||||
const { type, modifier } = step.maneuver;
|
||||
const name = step.name || 'unnamed road';
|
||||
const name = step.name || step.ref || 'unnamed road';
|
||||
|
||||
switch (type) {
|
||||
case 'depart':
|
||||
@@ -742,7 +771,7 @@ export class NavigationController {
|
||||
case 'end of road':
|
||||
return `At the end of the road, turn ${modifier || ''} onto ${name}`;
|
||||
case 'new name':
|
||||
return `Continue onto ${name}`;
|
||||
return `Continue on ${name}`;
|
||||
default:
|
||||
return `${type} ${modifier || ''} on ${name}`.trim();
|
||||
}
|
||||
@@ -763,6 +792,14 @@ export class NavigationController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch between planning and directions view
|
||||
*/
|
||||
public setViewMode(mode: 'planning' | 'directions'): void {
|
||||
this.viewMode = mode;
|
||||
this.callbacks.onRequestUpdate();
|
||||
}
|
||||
|
||||
// ─── Cleanup ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -790,8 +827,20 @@ export class NavigationController {
|
||||
* @param extraClass - Optional CSS class to add to the panel for positioning
|
||||
*/
|
||||
public render(extraClass?: string): TemplateResult {
|
||||
const { route, isLoading, error, startPoint, endPoint } = this.navigationState;
|
||||
// If we have a route and we're in directions view, show that
|
||||
if (this.viewMode === 'directions' && this.navigationState.route) {
|
||||
return this.renderDirectionsView(extraClass);
|
||||
}
|
||||
return this.renderPlanningView(extraClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the route planning view (inputs, mode selector, actions)
|
||||
*/
|
||||
private renderPlanningView(extraClass?: string): TemplateResult {
|
||||
const { isLoading, error, startPoint, endPoint } = this.navigationState;
|
||||
const canCalculate = startPoint && endPoint && !isLoading;
|
||||
const trafficEnabled = this.callbacks.getTrafficEnabled?.() ?? false;
|
||||
|
||||
return html`
|
||||
<div class="navigation-panel ${extraClass || ''}">
|
||||
@@ -824,6 +873,22 @@ export class NavigationController {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-traffic-toggle">
|
||||
<div class="nav-traffic-toggle-label">
|
||||
${renderIcon('traffic')}
|
||||
<span>Traffic-aware routing</span>
|
||||
</div>
|
||||
<button
|
||||
class="nav-traffic-toggle-btn ${trafficEnabled ? 'active' : ''}"
|
||||
@click=${() => this.callbacks.onTrafficToggle?.(!trafficEnabled)}
|
||||
title="${trafficEnabled ? 'Disable traffic' : 'Enable traffic'}"
|
||||
>
|
||||
<span class="toggle-track">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-inputs">
|
||||
${this.renderNavInput('start', 'Start point', this.navStartSearchQuery, this.navStartSearchResults)}
|
||||
${this.renderNavInput('end', 'End point', this.navEndSearchQuery, this.navEndSearchResults)}
|
||||
@@ -858,26 +923,47 @@ export class NavigationController {
|
||||
<span>Calculating route...</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
${route && !isLoading ? html`
|
||||
<div class="nav-summary">
|
||||
<div class="nav-summary-item">
|
||||
/**
|
||||
* Render the directions view (back button, summary, turn-by-turn steps)
|
||||
*/
|
||||
private renderDirectionsView(extraClass?: string): TemplateResult {
|
||||
const { route, trafficRoute } = this.navigationState;
|
||||
|
||||
if (!route) {
|
||||
// Shouldn't happen, but fallback to planning view
|
||||
return this.renderPlanningView(extraClass);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="navigation-panel nav-directions-view ${extraClass || ''}">
|
||||
<div class="nav-directions-header">
|
||||
<button
|
||||
class="nav-back-btn"
|
||||
@click=${() => this.setViewMode('planning')}
|
||||
title="Back to route planning"
|
||||
>
|
||||
${renderIcon('arrowLeft')}
|
||||
</button>
|
||||
<div class="nav-directions-summary">
|
||||
${renderIcon('ruler')}
|
||||
<span>${this.formatDistance(route.distance)}</span>
|
||||
</div>
|
||||
<div class="nav-summary-item">
|
||||
<span class="nav-directions-separator">•</span>
|
||||
${renderIcon('clock')}
|
||||
<span>${this.formatDuration(this.navigationState.trafficRoute?.duration ?? route.duration)}</span>
|
||||
<span>${this.formatDuration(trafficRoute?.duration ?? route.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.navigationState.trafficRoute ? html`
|
||||
<div class="nav-traffic-info ${this.navigationState.trafficRoute.congestionLevel}">
|
||||
<span class="nav-traffic-indicator ${this.navigationState.trafficRoute.congestionLevel}"></span>
|
||||
<span class="nav-traffic-text">${this.getCongestionLabel(this.navigationState.trafficRoute.congestionLevel)}</span>
|
||||
${this.navigationState.trafficRoute.duration > this.navigationState.trafficRoute.durationWithoutTraffic ? html`
|
||||
${trafficRoute ? html`
|
||||
<div class="nav-traffic-info ${trafficRoute.congestionLevel}">
|
||||
<span class="nav-traffic-indicator ${trafficRoute.congestionLevel}"></span>
|
||||
<span class="nav-traffic-text">${this.getCongestionLabel(trafficRoute.congestionLevel)}</span>
|
||||
${trafficRoute.duration > trafficRoute.durationWithoutTraffic ? html`
|
||||
<span class="nav-traffic-delay">
|
||||
+${this.formatDuration(this.navigationState.trafficRoute.duration - this.navigationState.trafficRoute.durationWithoutTraffic)} due to traffic
|
||||
+${this.formatDuration(trafficRoute.duration - trafficRoute.durationWithoutTraffic)} due to traffic
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -886,7 +972,6 @@ export class NavigationController {
|
||||
<div class="nav-steps">
|
||||
${this.renderTurnByTurn(route)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -963,20 +1048,44 @@ export class NavigationController {
|
||||
}
|
||||
|
||||
const steps = route.legs.flatMap(leg => leg.steps);
|
||||
const guidanceState = this.callbacks.getGuidanceState?.();
|
||||
const isNavigating = guidanceState?.isNavigating ?? false;
|
||||
const currentStepIndex = guidanceState?.currentStepIndex ?? -1;
|
||||
|
||||
if (steps.length === 0) {
|
||||
return html`<div class="nav-empty-steps">No turn-by-turn directions available</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
${steps.map(step => {
|
||||
${steps.map((step, index) => {
|
||||
const icon = this.getManeuverIcon(step.maneuver.type, step.maneuver.modifier);
|
||||
const instruction = this.formatStepInstruction(step);
|
||||
const distance = this.formatDistance(step.distance);
|
||||
|
||||
// currentStepIndex points to the maneuver we're approaching,
|
||||
// but we're traveling on the PREVIOUS step's road
|
||||
const isCurrent = isNavigating && index === currentStepIndex - 1;
|
||||
const isCompleted = isNavigating && index < currentStepIndex - 1;
|
||||
|
||||
// Calculate progress percentage for current step
|
||||
let progressPercent = 0;
|
||||
if (isCurrent && step.distance > 0) {
|
||||
const distanceRemaining = guidanceState?.distanceToNextManeuver ?? step.distance;
|
||||
progressPercent = Math.max(0, Math.min(100,
|
||||
((step.distance - distanceRemaining) / step.distance) * 100
|
||||
));
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="nav-step">
|
||||
<div class="nav-step-icon">${icon}</div>
|
||||
<div
|
||||
class="nav-step ${isCurrent ? 'current' : ''} ${isCompleted ? 'completed' : ''}"
|
||||
@click=${() => this.flyToStep(step)}
|
||||
data-step-index="${index}"
|
||||
>
|
||||
${isCurrent ? html`
|
||||
<div class="nav-step-progress-bar" style="width: ${progressPercent}%"></div>
|
||||
` : ''}
|
||||
<div class="nav-step-icon">${isCompleted ? '✓' : icon}</div>
|
||||
<div class="nav-step-content">
|
||||
<div class="nav-step-instruction">${instruction}</div>
|
||||
<div class="nav-step-distance">${distance}</div>
|
||||
@@ -986,4 +1095,26 @@ export class NavigationController {
|
||||
})}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the turn-by-turn list to show the current step
|
||||
* Called externally when guidance state changes
|
||||
*/
|
||||
public scrollToCurrentStep(stepIndex: number): void {
|
||||
// Use requestAnimationFrame to ensure DOM is updated
|
||||
requestAnimationFrame(() => {
|
||||
// Find elements in document - they may be in shadow DOM
|
||||
const stepsContainer = document.querySelector('.nav-steps')
|
||||
?? document.querySelector('dees-geo-map')?.shadowRoot?.querySelector('.nav-steps');
|
||||
const currentStep = document.querySelector(`.nav-step[data-step-index="${stepIndex}"]`)
|
||||
?? document.querySelector('dees-geo-map')?.shadowRoot?.querySelector(`.nav-step[data-step-index="${stepIndex}"]`);
|
||||
|
||||
if (stepsContainer && currentStep) {
|
||||
(currentStep as HTMLElement).scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
339
ts_web/elements/00group-map/dees-geo-map/geo-map.voice.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Voice synthesis manager for turn-by-turn navigation
|
||||
* Uses Web Speech API for voice instructions
|
||||
*/
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface IVoiceConfig {
|
||||
enabled: boolean;
|
||||
language: string;
|
||||
rate: number; // 0.1 - 10, default 1
|
||||
pitch: number; // 0 - 2, default 1
|
||||
volume: number; // 0 - 1, default 1
|
||||
voiceName?: string; // Specific voice to use (optional)
|
||||
}
|
||||
|
||||
export interface IVoiceQueueItem {
|
||||
text: string;
|
||||
priority: 'normal' | 'urgent';
|
||||
}
|
||||
|
||||
// ─── VoiceSynthesisManager ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Manager for voice synthesis using Web Speech API
|
||||
* Provides queue-based speech with interrupt capability
|
||||
*/
|
||||
export class VoiceSynthesisManager {
|
||||
private config: IVoiceConfig = {
|
||||
enabled: true,
|
||||
language: 'en-US',
|
||||
rate: 1,
|
||||
pitch: 1,
|
||||
volume: 1,
|
||||
};
|
||||
|
||||
private queue: IVoiceQueueItem[] = [];
|
||||
private isSpeaking: boolean = false;
|
||||
private currentUtterance: SpeechSynthesisUtterance | null = null;
|
||||
private availableVoices: SpeechSynthesisVoice[] = [];
|
||||
private voicesLoaded: boolean = false;
|
||||
|
||||
constructor(config?: Partial<IVoiceConfig>) {
|
||||
if (config) {
|
||||
this.configure(config);
|
||||
}
|
||||
this.initVoices();
|
||||
}
|
||||
|
||||
// ─── Configuration ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Configure voice settings
|
||||
*/
|
||||
public configure(config: Partial<IVoiceConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
public getConfig(): IVoiceConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if speech synthesis is supported
|
||||
*/
|
||||
public isSupported(): boolean {
|
||||
return typeof window !== 'undefined' && 'speechSynthesis' in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable voice synthesis
|
||||
*/
|
||||
public enable(): void {
|
||||
this.config.enabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable voice synthesis
|
||||
*/
|
||||
public disable(): void {
|
||||
this.config.enabled = false;
|
||||
this.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle voice synthesis
|
||||
*/
|
||||
public toggle(): void {
|
||||
if (this.config.enabled) {
|
||||
this.disable();
|
||||
} else {
|
||||
this.enable();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if voice is enabled
|
||||
*/
|
||||
public isEnabled(): boolean {
|
||||
return this.config.enabled;
|
||||
}
|
||||
|
||||
// ─── Voice Selection ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialize available voices
|
||||
*/
|
||||
private initVoices(): void {
|
||||
if (!this.isSupported()) return;
|
||||
|
||||
// Voices may load asynchronously
|
||||
const loadVoices = () => {
|
||||
this.availableVoices = window.speechSynthesis.getVoices();
|
||||
this.voicesLoaded = this.availableVoices.length > 0;
|
||||
};
|
||||
|
||||
loadVoices();
|
||||
|
||||
// Some browsers need to wait for voiceschanged event
|
||||
if (window.speechSynthesis.onvoiceschanged !== undefined) {
|
||||
window.speechSynthesis.onvoiceschanged = loadVoices;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices
|
||||
*/
|
||||
public getAvailableVoices(): SpeechSynthesisVoice[] {
|
||||
if (!this.isSupported()) return [];
|
||||
return window.speechSynthesis.getVoices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get voices for a specific language
|
||||
*/
|
||||
public getVoicesForLanguage(lang: string): SpeechSynthesisVoice[] {
|
||||
return this.getAvailableVoices().filter(voice =>
|
||||
voice.lang.startsWith(lang) || voice.lang.startsWith(lang.split('-')[0])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best voice for current language
|
||||
*/
|
||||
private findBestVoice(): SpeechSynthesisVoice | null {
|
||||
if (!this.voicesLoaded) {
|
||||
this.availableVoices = window.speechSynthesis.getVoices();
|
||||
}
|
||||
|
||||
// If a specific voice is requested, try to find it
|
||||
if (this.config.voiceName) {
|
||||
const requestedVoice = this.availableVoices.find(
|
||||
v => v.name === this.config.voiceName
|
||||
);
|
||||
if (requestedVoice) return requestedVoice;
|
||||
}
|
||||
|
||||
// Find a voice matching the language
|
||||
const langVoices = this.availableVoices.filter(
|
||||
v => v.lang.startsWith(this.config.language.split('-')[0])
|
||||
);
|
||||
|
||||
// Prefer native/local voices, then default voices
|
||||
const nativeVoice = langVoices.find(v => v.localService);
|
||||
if (nativeVoice) return nativeVoice;
|
||||
|
||||
const defaultVoice = langVoices.find(v => v.default);
|
||||
if (defaultVoice) return defaultVoice;
|
||||
|
||||
// Return any matching voice
|
||||
return langVoices[0] || null;
|
||||
}
|
||||
|
||||
// ─── Speech Methods ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Speak text with normal priority (queued)
|
||||
*/
|
||||
public speak(text: string): void {
|
||||
if (!this.config.enabled || !this.isSupported()) return;
|
||||
|
||||
this.queue.push({ text, priority: 'normal' });
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text with urgent priority (interrupts current speech)
|
||||
*/
|
||||
public speakUrgent(text: string): void {
|
||||
if (!this.config.enabled || !this.isSupported()) return;
|
||||
|
||||
// Cancel current speech and clear queue
|
||||
this.stop();
|
||||
|
||||
// Add to front of queue
|
||||
this.queue.unshift({ text, priority: 'urgent' });
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the speech queue
|
||||
*/
|
||||
private processQueue(): void {
|
||||
if (this.isSpeaking || this.queue.length === 0) return;
|
||||
if (!this.isSupported()) return;
|
||||
|
||||
const item = this.queue.shift();
|
||||
if (!item) return;
|
||||
|
||||
this.isSpeaking = true;
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(item.text);
|
||||
this.currentUtterance = utterance;
|
||||
|
||||
// Apply configuration
|
||||
utterance.lang = this.config.language;
|
||||
utterance.rate = this.config.rate;
|
||||
utterance.pitch = this.config.pitch;
|
||||
utterance.volume = this.config.volume;
|
||||
|
||||
// Set voice if available
|
||||
const voice = this.findBestVoice();
|
||||
if (voice) {
|
||||
utterance.voice = voice;
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
utterance.onend = () => {
|
||||
this.isSpeaking = false;
|
||||
this.currentUtterance = null;
|
||||
this.processQueue();
|
||||
};
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
console.warn('[VoiceSynthesisManager] Speech error:', event.error);
|
||||
this.isSpeaking = false;
|
||||
this.currentUtterance = null;
|
||||
this.processQueue();
|
||||
};
|
||||
|
||||
// Speak
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop current speech and clear queue
|
||||
*/
|
||||
public stop(): void {
|
||||
if (!this.isSupported()) return;
|
||||
|
||||
window.speechSynthesis.cancel();
|
||||
this.queue = [];
|
||||
this.isSpeaking = false;
|
||||
this.currentUtterance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause current speech
|
||||
*/
|
||||
public pause(): void {
|
||||
if (!this.isSupported()) return;
|
||||
window.speechSynthesis.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume paused speech
|
||||
*/
|
||||
public resume(): void {
|
||||
if (!this.isSupported()) return;
|
||||
window.speechSynthesis.resume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently speaking
|
||||
*/
|
||||
public isSpeakingNow(): boolean {
|
||||
return this.isSpeaking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue length
|
||||
*/
|
||||
public getQueueLength(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
// ─── Navigation-Specific Methods ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Speak approach maneuver instruction
|
||||
* "In [distance], [maneuver] on/onto [street]"
|
||||
*/
|
||||
public speakApproach(distance: string, maneuver: string, streetName?: string): void {
|
||||
let text = `In ${distance}, ${maneuver}`;
|
||||
if (streetName && streetName !== 'unnamed road') {
|
||||
// Use "on" for continue, "onto" for turns/merges
|
||||
const preposition = maneuver.startsWith('continue') ? 'on' : 'onto';
|
||||
text += ` ${preposition} ${streetName}`;
|
||||
}
|
||||
this.speak(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak execute maneuver instruction (urgent - immediate action)
|
||||
* "[Maneuver] now" or just "[Maneuver]"
|
||||
*/
|
||||
public speakManeuver(maneuver: string, urgent: boolean = true): void {
|
||||
const text = urgent ? `${maneuver} now` : maneuver;
|
||||
if (urgent) {
|
||||
this.speakUrgent(text);
|
||||
} else {
|
||||
this.speak(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak arrival
|
||||
*/
|
||||
public speakArrival(): void {
|
||||
this.speakUrgent('You have arrived at your destination');
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak off-route warning
|
||||
*/
|
||||
public speakOffRoute(): void {
|
||||
this.speakUrgent('You are off route. Recalculating.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak route recalculated
|
||||
*/
|
||||
public speakRecalculated(): void {
|
||||
this.speak('Route recalculated');
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,29 @@ export {
|
||||
type ITrafficFlowSegment,
|
||||
type ITrafficAwareRoute,
|
||||
} from './geo-map.traffic.providers.js';
|
||||
|
||||
// Voice synthesis exports
|
||||
export { VoiceSynthesisManager, type IVoiceConfig, type IVoiceQueueItem } from './geo-map.voice.js';
|
||||
|
||||
// Navigation guidance exports
|
||||
export {
|
||||
NavigationGuideController,
|
||||
type IGPSPosition,
|
||||
type INavigationGuideState,
|
||||
type TGuidanceEventType,
|
||||
type IGuidanceEvent,
|
||||
type INavigationGuideCallbacks,
|
||||
type INavigationCameraConfig,
|
||||
} from './geo-map.navigation-guide.js';
|
||||
|
||||
// Mock GPS simulator exports
|
||||
export {
|
||||
MockGPSSimulator,
|
||||
getSpeedDisplayName,
|
||||
getSpeedKmh,
|
||||
getSpeedPresets,
|
||||
type TSimulationSpeed,
|
||||
type ISimulationSpeedConfig,
|
||||
type IMockGPSConfig,
|
||||
type IMockGPSCallbacks,
|
||||
} from './geo-map.mock-gps.js';
|
||||
|
||||