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 # 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

View File

@@ -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'
} }

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)')}; 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;

View File

@@ -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,

View File

@@ -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}`;
} }

View File

@@ -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',
});
}
});
}
} }

View File

@@ -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);
} }