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:
2026-02-05 22:51:41 +00:00
parent 0617822116
commit 89670ecad3
7 changed files with 118 additions and 9 deletions

View File

@@ -1,5 +1,15 @@
# 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

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
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'
}

View File

@@ -754,6 +754,35 @@ export const navigationStyles = css`
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 {
display: flex;
align-items: center;

View File

@@ -348,6 +348,8 @@ export class DeesGeoMap extends DeesElement {
this.showTraffic = enabled;
},
getTrafficEnabled: () => this.showTraffic,
// Pass guidance state for turn-by-turn synchronization
getGuidanceState: () => this.guidanceController?.state ?? null,
});
this.navigationController.navigationMode = this.navigationMode;
@@ -375,6 +377,11 @@ export class DeesGeoMap extends DeesElement {
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,

View File

@@ -184,6 +184,8 @@ function formatManeuverForVoice(type: string, modifier?: string): string {
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;
}
@@ -597,7 +599,9 @@ export class NavigationGuideController {
// Approach - "In 200 meters, turn left"
const distanceStr = formatDistanceForVoice(distance);
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);
}
}
@@ -867,6 +871,7 @@ export class NavigationGuideController {
'merge': '⤵️',
'fork-left': '↖',
'fork-right': '↗',
'new name': '⬆️',
};
const key = modifier ? `${type}-${modifier}` : type;
@@ -894,6 +899,12 @@ export class NavigationGuideController {
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}`;
}

View File

@@ -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;
@@ -69,6 +71,8 @@ export interface INavigationControllerCallbacks {
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;
}
/**
@@ -742,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':
@@ -767,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();
}
@@ -1044,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" @click=${() => this.flyToStep(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>
@@ -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',
});
}
});
}
}

View File

@@ -291,12 +291,14 @@ export class VoiceSynthesisManager {
/**
* Speak approach maneuver instruction
* "In [distance], [maneuver] onto [street]"
* "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') {
text += ` onto ${streetName}`;
// Use "on" for continue, "onto" for turns/merges
const preposition = maneuver.startsWith('continue') ? 'on' : 'onto';
text += ` ${preposition} ${streetName}`;
}
this.speak(text);
}