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
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user