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
This commit is contained in:
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-02-05 - 1.4.0 - feat(dees-geo-map)
|
||||||
Add voice-guided navigation with TTS, navigation guide controller and mock GPS simulator
|
Add voice-guided navigation with TTS, navigation guide controller and mock GPS simulator
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@design.estate/dees-catalog-geo',
|
name: '@design.estate/dees-catalog-geo',
|
||||||
version: '1.4.0',
|
version: '1.5.0',
|
||||||
description: 'A geospatial web components library with MapLibre GL JS maps and terra-draw drawing tools'
|
description: 'A geospatial web components library with MapLibre GL JS maps and terra-draw drawing tools'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -754,6 +754,35 @@ export const navigationStyles = css`
|
|||||||
color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.5)', 'rgba(255, 255, 255, 0.5)')};
|
color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.5)', 'rgba(255, 255, 255, 0.5)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Current step - highlighted during active navigation */
|
||||||
|
.nav-step.current {
|
||||||
|
position: relative;
|
||||||
|
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.15)')};
|
||||||
|
border-left: 3px solid #3b82f6;
|
||||||
|
padding-left: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Completed steps - muted appearance */
|
||||||
|
.nav-step.completed {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-step.completed .nav-step-icon {
|
||||||
|
background: ${cssManager.bdTheme('rgba(34, 197, 94, 0.2)', 'rgba(34, 197, 94, 0.25)')};
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar on current step */
|
||||||
|
.nav-step-progress-bar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, #3b82f6 0%, #60a5fa 100%);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
transition: width 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-error {
|
.nav-error {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -348,6 +348,8 @@ export class DeesGeoMap extends DeesElement {
|
|||||||
this.showTraffic = enabled;
|
this.showTraffic = enabled;
|
||||||
},
|
},
|
||||||
getTrafficEnabled: () => this.showTraffic,
|
getTrafficEnabled: () => this.showTraffic,
|
||||||
|
// Pass guidance state for turn-by-turn synchronization
|
||||||
|
getGuidanceState: () => this.guidanceController?.state ?? null,
|
||||||
});
|
});
|
||||||
this.navigationController.navigationMode = this.navigationMode;
|
this.navigationController.navigationMode = this.navigationMode;
|
||||||
|
|
||||||
@@ -375,6 +377,11 @@ export class DeesGeoMap extends DeesElement {
|
|||||||
bubbles: true,
|
bubbles: true,
|
||||||
composed: 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(),
|
onRequestUpdate: () => this.requestUpdate(),
|
||||||
getMap: () => this.map,
|
getMap: () => this.map,
|
||||||
|
|||||||
@@ -184,6 +184,8 @@ function formatManeuverForVoice(type: string, modifier?: string): string {
|
|||||||
return 'continue straight';
|
return 'continue straight';
|
||||||
case 'end of road':
|
case 'end of road':
|
||||||
return `at the end of the road, turn ${modifier || 'around'}`;
|
return `at the end of the road, turn ${modifier || 'around'}`;
|
||||||
|
case 'new name':
|
||||||
|
return 'continue straight';
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
@@ -597,7 +599,9 @@ export class NavigationGuideController {
|
|||||||
// Approach - "In 200 meters, turn left"
|
// Approach - "In 200 meters, turn left"
|
||||||
const distanceStr = formatDistanceForVoice(distance);
|
const distanceStr = formatDistanceForVoice(distance);
|
||||||
this.voiceManager.speakApproach(distanceStr, maneuver, streetName);
|
this.voiceManager.speakApproach(distanceStr, maneuver, streetName);
|
||||||
const instruction = `In ${distanceStr}, ${maneuver}${streetName ? ` onto ${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);
|
this.emitGuidanceEvent('approach-maneuver', position, step, instruction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -867,6 +871,7 @@ export class NavigationGuideController {
|
|||||||
'merge': '⤵️',
|
'merge': '⤵️',
|
||||||
'fork-left': '↖',
|
'fork-left': '↖',
|
||||||
'fork-right': '↗',
|
'fork-right': '↗',
|
||||||
|
'new name': '⬆️',
|
||||||
};
|
};
|
||||||
|
|
||||||
const key = modifier ? `${type}-${modifier}` : type;
|
const key = modifier ? `${type}-${modifier}` : type;
|
||||||
@@ -894,6 +899,12 @@ export class NavigationGuideController {
|
|||||||
case 'roundabout':
|
case 'roundabout':
|
||||||
case 'rotary':
|
case 'rotary':
|
||||||
return `At the roundabout, take the exit onto ${name}`;
|
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:
|
default:
|
||||||
return `${type} on ${name}`;
|
return `${type} on ${name}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import maplibregl from 'maplibre-gl';
|
|||||||
import { renderIcon } from './geo-map.icons.js';
|
import { renderIcon } from './geo-map.icons.js';
|
||||||
import { type INominatimResult } from './geo-map.search.js';
|
import { type INominatimResult } from './geo-map.search.js';
|
||||||
import type { ITrafficAwareRoute } from './geo-map.traffic.providers.js';
|
import type { ITrafficAwareRoute } from './geo-map.traffic.providers.js';
|
||||||
|
import type { INavigationGuideState } from './geo-map.navigation-guide.js';
|
||||||
|
|
||||||
// ─── Navigation/Routing Types ────────────────────────────────────────────────
|
// ─── Navigation/Routing Types ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export interface IOSRMStep {
|
|||||||
location: [number, number];
|
location: [number, number];
|
||||||
};
|
};
|
||||||
name: string; // Street name
|
name: string; // Street name
|
||||||
|
ref?: string; // Road reference (A1, M25, etc.)
|
||||||
distance: number;
|
distance: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
driving_side: string;
|
driving_side: string;
|
||||||
@@ -69,6 +71,8 @@ export interface INavigationControllerCallbacks {
|
|||||||
onTrafficToggle?: (enabled: boolean) => void;
|
onTrafficToggle?: (enabled: boolean) => void;
|
||||||
/** Optional callback to get current traffic state */
|
/** Optional callback to get current traffic state */
|
||||||
getTrafficEnabled?: () => boolean;
|
getTrafficEnabled?: () => boolean;
|
||||||
|
/** Optional callback to get current guidance state for turn-by-turn synchronization */
|
||||||
|
getGuidanceState?: () => INavigationGuideState | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -742,7 +746,7 @@ export class NavigationController {
|
|||||||
*/
|
*/
|
||||||
public formatStepInstruction(step: IOSRMStep): string {
|
public formatStepInstruction(step: IOSRMStep): string {
|
||||||
const { type, modifier } = step.maneuver;
|
const { type, modifier } = step.maneuver;
|
||||||
const name = step.name || 'unnamed road';
|
const name = step.name || step.ref || 'unnamed road';
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'depart':
|
case 'depart':
|
||||||
@@ -767,7 +771,7 @@ export class NavigationController {
|
|||||||
case 'end of road':
|
case 'end of road':
|
||||||
return `At the end of the road, turn ${modifier || ''} onto ${name}`;
|
return `At the end of the road, turn ${modifier || ''} onto ${name}`;
|
||||||
case 'new name':
|
case 'new name':
|
||||||
return `Continue onto ${name}`;
|
return `Continue on ${name}`;
|
||||||
default:
|
default:
|
||||||
return `${type} ${modifier || ''} on ${name}`.trim();
|
return `${type} ${modifier || ''} on ${name}`.trim();
|
||||||
}
|
}
|
||||||
@@ -1044,20 +1048,44 @@ export class NavigationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const steps = route.legs.flatMap(leg => leg.steps);
|
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) {
|
if (steps.length === 0) {
|
||||||
return html`<div class="nav-empty-steps">No turn-by-turn directions available</div>`;
|
return html`<div class="nav-empty-steps">No turn-by-turn directions available</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
${steps.map(step => {
|
${steps.map((step, index) => {
|
||||||
const icon = this.getManeuverIcon(step.maneuver.type, step.maneuver.modifier);
|
const icon = this.getManeuverIcon(step.maneuver.type, step.maneuver.modifier);
|
||||||
const instruction = this.formatStepInstruction(step);
|
const instruction = this.formatStepInstruction(step);
|
||||||
const distance = this.formatDistance(step.distance);
|
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`
|
return html`
|
||||||
<div class="nav-step" @click=${() => this.flyToStep(step)}>
|
<div
|
||||||
<div class="nav-step-icon">${icon}</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-content">
|
||||||
<div class="nav-step-instruction">${instruction}</div>
|
<div class="nav-step-instruction">${instruction}</div>
|
||||||
<div class="nav-step-distance">${distance}</div>
|
<div class="nav-step-distance">${distance}</div>
|
||||||
@@ -1067,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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,12 +291,14 @@ export class VoiceSynthesisManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Speak approach maneuver instruction
|
* Speak approach maneuver instruction
|
||||||
* "In [distance], [maneuver] onto [street]"
|
* "In [distance], [maneuver] on/onto [street]"
|
||||||
*/
|
*/
|
||||||
public speakApproach(distance: string, maneuver: string, streetName?: string): void {
|
public speakApproach(distance: string, maneuver: string, streetName?: string): void {
|
||||||
let text = `In ${distance}, ${maneuver}`;
|
let text = `In ${distance}, ${maneuver}`;
|
||||||
if (streetName && streetName !== 'unnamed road') {
|
if (streetName && streetName !== 'unnamed road') {
|
||||||
text += ` onto ${streetName}`;
|
// Use "on" for continue, "onto" for turns/merges
|
||||||
|
const preposition = maneuver.startsWith('continue') ? 'on' : 'onto';
|
||||||
|
text += ` ${preposition} ${streetName}`;
|
||||||
}
|
}
|
||||||
this.speak(text);
|
this.speak(text);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user