feat(geo-map): add live traffic visualization and traffic-aware routing with pluggable providers and UI integration

This commit is contained in:
2026-02-05 15:07:33 +00:00
parent 1a0fceadc0
commit df690dc329
22 changed files with 2238 additions and 181 deletions

View File

@@ -10,7 +10,7 @@ import {
css,
} from '@design.estate/dees-element';
import { DeesContextmenu } from '@design.estate/dees-catalog';
import { geoComponentStyles, mapContainerStyles, toolbarStyles, searchStyles, navigationStyles } from '../../00componentstyles.js';
import { geoComponentStyles, mapContainerStyles, toolbarStyles, searchStyles, navigationStyles, trafficStyles, headerToolbarStyles } from '../../00componentstyles.js';
// MapLibre imports
import maplibregl from 'maplibre-gl';
@@ -33,6 +33,8 @@ import { TerraDrawMapLibreGLAdapter } from 'terra-draw-maplibre-gl-adapter';
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 { TrafficController } from './geo-map.traffic.js';
import { HereTrafficProvider, type ITrafficProvider } from './geo-map.traffic.providers.js';
// Re-export types for external consumers
export type { INominatimResult, IAddressSelectedEvent } from './geo-map.search.js';
@@ -44,6 +46,7 @@ export type {
IOSRMStep,
IRouteCalculatedEvent,
} from './geo-map.navigation.js';
export type { ITrafficProvider, ITrafficFlowData, ITrafficAwareRoute } from './geo-map.traffic.providers.js';
export type TDrawTool = 'polygon' | 'rectangle' | 'point' | 'linestring' | 'circle' | 'freehand' | 'select' | 'static';
@@ -114,6 +117,16 @@ export class DeesGeoMap extends DeesElement {
@property({ type: String })
accessor navigationMode: TNavigationMode = 'driving';
// Traffic properties
@property({ type: Boolean })
accessor showTraffic: boolean = false;
@property({ type: Object })
accessor trafficProvider: ITrafficProvider | null = null;
@property({ type: String })
accessor trafficApiKey: string = '';
// ─── State ──────────────────────────────────────────────────────────────────
@state()
@@ -125,9 +138,13 @@ export class DeesGeoMap extends DeesElement {
@state()
private accessor isMapReady: boolean = false;
@state()
private accessor isNavigationOpen: boolean = true;
// Controllers
private searchController: SearchController | null = null;
private navigationController: NavigationController | null = null;
private trafficController: TrafficController | null = null;
// ─── Styles ─────────────────────────────────────────────────────────────────
@@ -138,6 +155,8 @@ export class DeesGeoMap extends DeesElement {
toolbarStyles,
searchStyles,
navigationStyles,
trafficStyles,
headerToolbarStyles,
css`
:host {
display: block;
@@ -163,51 +182,7 @@ export class DeesGeoMap extends DeesElement {
color: rgba(255, 255, 255, 0.8);
}
.toolbar {
user-select: none;
}
.toolbar-title {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.5);
padding: 0 4px 4px;
}
.tool-button {
position: relative;
}
.tool-button::after {
content: attr(title);
position: absolute;
left: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
padding: 4px 8px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
font-size: 12px;
white-space: nowrap;
border-radius: 4px;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.15s ease, visibility 0.15s ease;
}
.tool-button:hover::after {
opacity: 1;
visibility: visible;
}
.feature-count {
position: absolute;
bottom: 12px;
left: 12px;
z-index: 10;
padding: 6px 12px;
background: rgba(30, 30, 30, 0.9);
border: 1px solid rgba(255, 255, 255, 0.1);
@@ -217,22 +192,6 @@ export class DeesGeoMap extends DeesElement {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
}
.zoom-controls {
position: absolute;
bottom: 12px;
right: 12px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px;
background: rgba(30, 30, 30, 0.9);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
`,
];
@@ -269,6 +228,23 @@ export class DeesGeoMap extends DeesElement {
if (changedProperties.has('navigationMode') && this.navigationController) {
this.navigationController.navigationMode = this.navigationMode;
}
// Traffic property changes
if (changedProperties.has('showTraffic') && this.trafficController) {
if (this.showTraffic) {
this.trafficController.enable();
} else {
this.trafficController.disable();
}
}
if (changedProperties.has('trafficProvider') && this.trafficController && this.trafficProvider) {
this.trafficController.setProvider(this.trafficProvider);
}
if (changedProperties.has('trafficApiKey') && this.trafficController && this.trafficApiKey) {
// Auto-configure HERE provider if API key is provided
const hereProvider = new HereTrafficProvider();
hereProvider.configure({ apiKey: this.trafficApiKey });
this.trafficController.setProvider(hereProvider);
}
}
// ─── Controller Initialization ──────────────────────────────────────────────
@@ -312,8 +288,30 @@ export class DeesGeoMap extends DeesElement {
},
onRequestUpdate: () => this.requestUpdate(),
getMap: () => this.map,
// Connect traffic controller for traffic-aware routing
getTrafficRoute: async (start, end, mode) => {
if (this.trafficController?.supportsTrafficRouting()) {
return this.trafficController.fetchRouteWithTraffic(start, end, mode);
}
return null;
},
});
this.navigationController.navigationMode = this.navigationMode;
// Initialize traffic controller
this.trafficController = new TrafficController({
onRequestUpdate: () => this.requestUpdate(),
getMap: () => this.map,
});
// Configure traffic provider if API key or provider is set
if (this.trafficProvider) {
this.trafficController.setProvider(this.trafficProvider);
} else if (this.trafficApiKey) {
const hereProvider = new HereTrafficProvider();
hereProvider.configure({ apiKey: this.trafficApiKey });
this.trafficController.setProvider(hereProvider);
}
}
// ─── Map Initialization ─────────────────────────────────────────────────────
@@ -342,6 +340,12 @@ export class DeesGeoMap extends DeesElement {
this.map!.setProjection({ type: this.projection });
this.initializeTerraDraw();
// Enable traffic if configured
if (this.showTraffic && this.trafficController) {
this.trafficController.enable();
}
this.dispatchEvent(new CustomEvent('map-ready', { detail: { map: this.map } }));
});
@@ -353,6 +357,11 @@ export class DeesGeoMap extends DeesElement {
zoom: this.map?.getZoom(),
},
}));
// Refresh traffic data when map moves
if (this.trafficController) {
this.trafficController.handleMapMoveEnd();
}
});
// Handle clicks for navigation point selection
@@ -672,6 +681,61 @@ export class DeesGeoMap extends DeesElement {
return this.navigationController?.navigationState ?? null;
}
// ─── Traffic Public Methods ────────────────────────────────────────────────
/**
* Enable traffic visualization
*/
public enableTraffic(): void {
this.showTraffic = true;
this.trafficController?.enable();
}
/**
* Disable traffic visualization
*/
public disableTraffic(): void {
this.showTraffic = false;
this.trafficController?.disable();
}
/**
* Toggle traffic visualization
*/
public toggleTraffic(): void {
this.showTraffic = !this.showTraffic;
this.trafficController?.toggle();
}
/**
* Refresh traffic data
*/
public async refreshTraffic(): Promise<void> {
await this.trafficController?.refresh();
}
/**
* Set traffic provider
*/
public setTrafficProvider(provider: ITrafficProvider): void {
this.trafficProvider = provider;
this.trafficController?.setProvider(provider);
}
/**
* Check if traffic-aware routing is available
*/
public supportsTrafficRouting(): boolean {
return this.trafficController?.supportsTrafficRouting() ?? false;
}
/**
* Get traffic controller for advanced usage
*/
public getTrafficController(): TrafficController | null {
return this.trafficController;
}
// ─── Private Methods ────────────────────────────────────────────────────────
private ensureMaplibreCssLoaded() {
@@ -690,8 +754,9 @@ export class DeesGeoMap extends DeesElement {
this.draw.stop();
this.draw = null;
}
// Clean up navigation controller
// Clean up controllers
this.navigationController?.cleanup();
this.trafficController?.cleanup();
if (this.map) {
this.map.remove();
@@ -723,6 +788,8 @@ export class DeesGeoMap extends DeesElement {
private handleMapContextMenu(e: MouseEvent) {
e.preventDefault();
const hasTrafficProvider = this.trafficController?.provider?.isConfigured ?? false;
DeesContextmenu.openContextMenuWithOptions(e, [
{
name: this.dragToDraw ? '✓ Drag to Draw' : 'Drag to Draw',
@@ -739,6 +806,19 @@ export class DeesGeoMap extends DeesElement {
},
},
{ divider: true },
{
name: this.showTraffic ? '✓ Show Traffic' : 'Show Traffic',
iconName: 'lucide:traffic-cone',
action: async () => {
if (hasTrafficProvider) {
this.toggleTraffic();
} else {
console.warn('[dees-geo-map] No traffic provider configured. Set trafficApiKey or trafficProvider property.');
}
},
disabled: !hasTrafficProvider,
},
{ divider: true },
{
name: 'Clear All Features',
iconName: 'lucide:trash2',
@@ -752,26 +832,88 @@ export class DeesGeoMap extends DeesElement {
]);
}
private toggleNavigation(): void {
this.isNavigationOpen = !this.isNavigationOpen;
}
// ─── Render ─────────────────────────────────────────────────────────────────
public render(): TemplateResult {
const featureCount = this.draw?.getSnapshot().length || 0;
const hasTrafficProvider = this.trafficController?.provider?.isConfigured ?? false;
const showTrafficControls = Boolean(hasTrafficProvider || this.trafficApiKey || this.trafficProvider);
return html`
<div class="map-container" @contextmenu=${(e: MouseEvent) => this.handleMapContextMenu(e)}>
<div class="map-wrapper"></div>
<div class="geo-component">
<!-- Header Toolbar Above Map -->
${this.renderHeaderToolbar(showTrafficControls)}
${this.showToolbar ? this.renderToolbar() : ''}
${this.showSearch && this.searchController ? this.searchController.render() : ''}
${this.showNavigation && this.navigationController ? this.navigationController.render() : ''}
<!-- Map Container -->
<div class="map-container" @contextmenu=${(e: MouseEvent) => this.handleMapContextMenu(e)}>
<div class="map-wrapper"></div>
${featureCount > 0 ? html`
<div class="feature-count">
${featureCount} feature${featureCount !== 1 ? 's' : ''}
<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 Right: Empty now (controls moved to header) -->
<div class="overlay-top-right"></div>
<!-- Bottom Left: Traffic Legend + Feature Count -->
<div class="overlay-bottom-left">
${this.showTraffic && this.trafficController ? this.trafficController.renderLegend() : ''}
${featureCount > 0 ? html`
<div class="feature-count">
${featureCount} feature${featureCount !== 1 ? 's' : ''}
</div>
` : ''}
</div>
<!-- Bottom Right: Empty now (zoom moved to header) -->
<div class="overlay-bottom-right"></div>
</div>
` : ''}
</div>
</div>
`;
}
<div class="zoom-controls">
private renderHeaderToolbar(showTrafficControls: boolean): TemplateResult {
return html`
<div class="header-toolbar">
<!-- Left: Draw Tools -->
<div class="toolbar-left">
${this.showToolbar ? html`
${this.renderDrawTools()}
<div class="toolbar-divider"></div>
` : ''}
</div>
<!-- Center: Search Bar -->
<div class="toolbar-center">
${this.showSearch && this.searchController
? this.searchController.render()
: ''}
</div>
<!-- Right: Navigation Toggle + Traffic Toggle + Zoom Controls -->
<div class="toolbar-right">
${this.showNavigation ? html`
<button
class="tool-button ${this.isNavigationOpen ? 'active' : ''}"
title="Navigation"
@click=${() => this.toggleNavigation()}
>
${renderIcon('navigation')}
</button>
` : ''}
${showTrafficControls && this.trafficController
? this.trafficController.render()
: ''}
<div class="toolbar-divider"></div>
<button
class="tool-button"
title="Zoom in"
@@ -791,7 +933,7 @@ export class DeesGeoMap extends DeesElement {
`;
}
private renderToolbar(): TemplateResult {
private renderDrawTools(): TemplateResult {
const tools: { id: TDrawTool; icon: string; label: string }[] = [
{ id: 'point', icon: 'point', label: 'Point' },
{ id: 'linestring', icon: 'line', label: 'Line' },
@@ -802,43 +944,34 @@ export class DeesGeoMap extends DeesElement {
];
return html`
<div class="toolbar">
<div class="toolbar-title">Draw</div>
<div class="toolbar-group">
${tools.map(tool => html`
<button
class="tool-button ${this.activeTool === tool.id ? 'active' : ''}"
title="${tool.label}"
@click=${() => this.handleToolClick(tool.id)}
?disabled=${!this.isMapReady}
>
${renderIcon(tool.icon)}
</button>
`)}
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-title">Edit</div>
<div class="toolbar-group">
<button
class="tool-button ${this.activeTool === 'select' ? 'active' : ''}"
title="Select & Edit"
@click=${() => this.handleToolClick('select')}
?disabled=${!this.isMapReady}
>
${renderIcon('select')}
</button>
<button
class="tool-button"
title="Clear All"
@click=${this.handleClearClick}
?disabled=${!this.isMapReady}
>
${renderIcon('trash')}
</button>
</div>
</div>
${tools.map(tool => html`
<button
class="tool-button ${this.activeTool === tool.id ? 'active' : ''}"
title="${tool.label}"
@click=${() => this.handleToolClick(tool.id)}
?disabled=${!this.isMapReady}
>
${renderIcon(tool.icon)}
</button>
`)}
<div class="toolbar-divider"></div>
<button
class="tool-button ${this.activeTool === 'select' ? 'active' : ''}"
title="Select & Edit"
@click=${() => this.handleToolClick('select')}
?disabled=${!this.isMapReady}
>
${renderIcon('select')}
</button>
<button
class="tool-button"
title="Clear All"
@click=${this.handleClearClick}
?disabled=${!this.isMapReady}
>
${renderIcon('trash')}
</button>
`;
}
}
}