feat(geo-map): add live traffic visualization and traffic-aware routing with pluggable providers and UI integration
This commit is contained in:
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user