feat(geo-map): add live traffic visualization and traffic-aware routing with pluggable providers and UI integration
This commit is contained in:
582
ts_web/elements/00group-map/dees-geo-map/geo-map.traffic.ts
Normal file
582
ts_web/elements/00group-map/dees-geo-map/geo-map.traffic.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { renderIcon } from './geo-map.icons.js';
|
||||
import type {
|
||||
ITrafficProvider,
|
||||
ITrafficFlowData,
|
||||
ITrafficFlowSegment,
|
||||
ITrafficAwareRoute,
|
||||
} from './geo-map.traffic.providers.js';
|
||||
import type { TNavigationMode } from './geo-map.navigation.js';
|
||||
|
||||
// ─── Traffic Controller Types ────────────────────────────────────────────────
|
||||
|
||||
export interface ITrafficControllerCallbacks {
|
||||
/** Called when traffic state changes, to trigger re-render */
|
||||
onRequestUpdate: () => void;
|
||||
/** Returns the MapLibre map instance */
|
||||
getMap: () => maplibregl.Map | null;
|
||||
}
|
||||
|
||||
export interface ITrafficState {
|
||||
/** Whether traffic layer is enabled */
|
||||
isEnabled: boolean;
|
||||
/** Whether data is being loaded */
|
||||
isLoading: boolean;
|
||||
/** Last update timestamp */
|
||||
lastUpdate: Date | null;
|
||||
/** Error message if fetch failed */
|
||||
error: string | null;
|
||||
/** Number of traffic segments displayed */
|
||||
segmentCount: number;
|
||||
}
|
||||
|
||||
// ─── Traffic Controller ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Controller for traffic visualization and traffic-aware routing
|
||||
*
|
||||
* Manages:
|
||||
* - Traffic flow layer on the map
|
||||
* - Auto-refresh of traffic data
|
||||
* - Traffic-aware routing through providers
|
||||
*/
|
||||
export class TrafficController {
|
||||
// ─── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Traffic display state */
|
||||
public trafficState: ITrafficState = {
|
||||
isEnabled: false,
|
||||
isLoading: false,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
segmentCount: 0,
|
||||
};
|
||||
|
||||
/** Currently active traffic provider */
|
||||
public provider: ITrafficProvider | null = null;
|
||||
|
||||
// ─── Configuration ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Auto-refresh interval in milliseconds (default: 60 seconds) */
|
||||
public updateInterval: number = 60000;
|
||||
|
||||
/** Whether to automatically refresh traffic data */
|
||||
public autoRefresh: boolean = true;
|
||||
|
||||
/** Minimum zoom level to show traffic (higher zoom = more detail but more API calls) */
|
||||
public minZoomForTraffic: number = 10;
|
||||
|
||||
// ─── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
private callbacks: ITrafficControllerCallbacks;
|
||||
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private currentTrafficData: ITrafficFlowData | null = null;
|
||||
|
||||
// MapLibre source/layer IDs
|
||||
private static readonly SOURCE_ID = 'traffic-flow-source';
|
||||
private static readonly LAYER_ID = 'traffic-flow-layer';
|
||||
private static readonly LAYER_OUTLINE_ID = 'traffic-flow-outline-layer';
|
||||
|
||||
constructor(callbacks: ITrafficControllerCallbacks) {
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
// ─── Provider Management ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Set the traffic data provider
|
||||
*/
|
||||
public setProvider(provider: ITrafficProvider): void {
|
||||
this.provider = provider;
|
||||
|
||||
// If traffic is enabled, refresh data with new provider
|
||||
if (this.trafficState.isEnabled) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Enable/Disable ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Enable traffic visualization
|
||||
*/
|
||||
public enable(): void {
|
||||
if (this.trafficState.isEnabled) return;
|
||||
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
isEnabled: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Start auto-refresh if configured
|
||||
if (this.autoRefresh) {
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
// Initial data fetch
|
||||
this.refresh();
|
||||
|
||||
this.callbacks.onRequestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable traffic visualization
|
||||
*/
|
||||
public disable(): void {
|
||||
if (!this.trafficState.isEnabled) return;
|
||||
|
||||
this.stopAutoRefresh();
|
||||
this.removeTrafficLayer();
|
||||
|
||||
this.trafficState = {
|
||||
isEnabled: false,
|
||||
isLoading: false,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
segmentCount: 0,
|
||||
};
|
||||
|
||||
this.currentTrafficData = null;
|
||||
this.callbacks.onRequestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle traffic visualization
|
||||
*/
|
||||
public toggle(): void {
|
||||
if (this.trafficState.isEnabled) {
|
||||
this.disable();
|
||||
} else {
|
||||
this.enable();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Data Fetching ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Refresh traffic data from the provider
|
||||
*/
|
||||
public async refresh(): Promise<void> {
|
||||
if (!this.trafficState.isEnabled) return;
|
||||
|
||||
const map = this.callbacks.getMap();
|
||||
if (!map) return;
|
||||
|
||||
// Check zoom level
|
||||
const currentZoom = map.getZoom();
|
||||
if (currentZoom < this.minZoomForTraffic) {
|
||||
// Remove traffic layer if zoom is too low
|
||||
this.removeTrafficLayer();
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
error: `Zoom in to see traffic (current: ${Math.round(currentZoom)}, required: ${this.minZoomForTraffic})`,
|
||||
segmentCount: 0,
|
||||
};
|
||||
this.callbacks.onRequestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.provider) {
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
error: 'No traffic provider configured',
|
||||
};
|
||||
this.callbacks.onRequestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.provider.isConfigured) {
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
error: `${this.provider.name} is not configured (missing API key?)`,
|
||||
};
|
||||
this.callbacks.onRequestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current map bounds
|
||||
const mapBounds = map.getBounds();
|
||||
const bounds: [number, number, number, number] = [
|
||||
mapBounds.getWest(),
|
||||
mapBounds.getSouth(),
|
||||
mapBounds.getEast(),
|
||||
mapBounds.getNorth(),
|
||||
];
|
||||
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
};
|
||||
this.callbacks.onRequestUpdate();
|
||||
|
||||
try {
|
||||
const data = await this.provider.fetchTrafficFlow(bounds);
|
||||
|
||||
if (data) {
|
||||
this.currentTrafficData = data;
|
||||
this.renderTrafficLayer(data);
|
||||
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
isLoading: false,
|
||||
lastUpdate: data.timestamp,
|
||||
segmentCount: data.segments.length,
|
||||
};
|
||||
} else {
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
isLoading: false,
|
||||
error: 'No traffic data available for this area',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch traffic data',
|
||||
};
|
||||
}
|
||||
|
||||
this.callbacks.onRequestUpdate();
|
||||
}
|
||||
|
||||
// ─── Traffic-Aware Routing ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch a route with traffic-aware duration
|
||||
* Returns null if the provider doesn't support traffic-aware routing
|
||||
*/
|
||||
public async fetchRouteWithTraffic(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
mode: TNavigationMode
|
||||
): Promise<ITrafficAwareRoute | null> {
|
||||
if (!this.provider || !this.provider.fetchRouteWithTraffic) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.provider.isConfigured) {
|
||||
console.warn('[TrafficController] Provider not configured for traffic routing');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.provider.fetchRouteWithTraffic(start, end, mode);
|
||||
} catch (error) {
|
||||
console.error('[TrafficController] Traffic routing error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current provider supports traffic-aware routing
|
||||
*/
|
||||
public supportsTrafficRouting(): boolean {
|
||||
return !!(this.provider?.fetchRouteWithTraffic && this.provider.isConfigured);
|
||||
}
|
||||
|
||||
// ─── Layer Management ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render traffic data as a MapLibre layer
|
||||
*/
|
||||
private renderTrafficLayer(data: ITrafficFlowData): void {
|
||||
const map = this.callbacks.getMap();
|
||||
if (!map || data.segments.length === 0) return;
|
||||
|
||||
// Convert segments to GeoJSON FeatureCollection
|
||||
const geojson = this.createTrafficGeoJson(data.segments);
|
||||
|
||||
// Remove existing layer/source if present
|
||||
this.removeTrafficLayer();
|
||||
|
||||
// Add source
|
||||
map.addSource(TrafficController.SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: geojson,
|
||||
});
|
||||
|
||||
// Add outline layer (for better visibility)
|
||||
map.addLayer({
|
||||
id: TrafficController.LAYER_OUTLINE_ID,
|
||||
type: 'line',
|
||||
source: TrafficController.SOURCE_ID,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#000',
|
||||
'line-width': 6,
|
||||
'line-opacity': 0.3,
|
||||
},
|
||||
});
|
||||
|
||||
// Add main traffic layer with congestion colors
|
||||
map.addLayer({
|
||||
id: TrafficController.LAYER_ID,
|
||||
type: 'line',
|
||||
source: TrafficController.SOURCE_ID,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round',
|
||||
},
|
||||
paint: {
|
||||
// Color based on congestion level (0-1)
|
||||
'line-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['get', 'congestion'],
|
||||
0, '#00c853', // Green - free flow
|
||||
0.3, '#ffeb3b', // Yellow - light congestion
|
||||
0.6, '#ff9800', // Orange - moderate congestion
|
||||
0.8, '#f44336', // Red - heavy congestion
|
||||
1, '#b71c1c', // Dark red - severe/stopped
|
||||
],
|
||||
'line-width': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
10, 2,
|
||||
14, 4,
|
||||
18, 6,
|
||||
],
|
||||
'line-opacity': 0.85,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the traffic layer with new data (without removing/recreating)
|
||||
*/
|
||||
public updateTrafficLayer(data: ITrafficFlowData): void {
|
||||
const map = this.callbacks.getMap();
|
||||
if (!map) return;
|
||||
|
||||
const source = map.getSource(TrafficController.SOURCE_ID) as maplibregl.GeoJSONSource;
|
||||
if (source) {
|
||||
const geojson = this.createTrafficGeoJson(data.segments);
|
||||
source.setData(geojson);
|
||||
this.currentTrafficData = data;
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
lastUpdate: data.timestamp,
|
||||
segmentCount: data.segments.length,
|
||||
};
|
||||
} else {
|
||||
// Source doesn't exist, create full layer
|
||||
this.renderTrafficLayer(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove traffic layer from the map
|
||||
*/
|
||||
public removeTrafficLayer(): void {
|
||||
const map = this.callbacks.getMap();
|
||||
if (!map) return;
|
||||
|
||||
if (map.getLayer(TrafficController.LAYER_ID)) {
|
||||
map.removeLayer(TrafficController.LAYER_ID);
|
||||
}
|
||||
if (map.getLayer(TrafficController.LAYER_OUTLINE_ID)) {
|
||||
map.removeLayer(TrafficController.LAYER_OUTLINE_ID);
|
||||
}
|
||||
if (map.getSource(TrafficController.SOURCE_ID)) {
|
||||
map.removeSource(TrafficController.SOURCE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert traffic segments to GeoJSON FeatureCollection
|
||||
*/
|
||||
private createTrafficGeoJson(segments: ITrafficFlowSegment[]): GeoJSON.FeatureCollection {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: segments.map((segment, index) => ({
|
||||
type: 'Feature' as const,
|
||||
id: index,
|
||||
properties: {
|
||||
congestion: segment.congestion,
|
||||
speed: segment.speed,
|
||||
freeFlowSpeed: segment.freeFlowSpeed,
|
||||
confidence: segment.confidence,
|
||||
},
|
||||
geometry: segment.geometry,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Auto-Refresh ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start automatic refresh timer
|
||||
*/
|
||||
private startAutoRefresh(): void {
|
||||
this.stopAutoRefresh();
|
||||
|
||||
this.refreshTimer = setInterval(() => {
|
||||
if (this.trafficState.isEnabled) {
|
||||
this.refresh();
|
||||
}
|
||||
}, this.updateInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop automatic refresh timer
|
||||
*/
|
||||
private stopAutoRefresh(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Map Event Handling ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle map move/zoom end - refresh traffic for new bounds
|
||||
*/
|
||||
public handleMapMoveEnd(): void {
|
||||
if (this.trafficState.isEnabled && !this.trafficState.isLoading) {
|
||||
// Debounce to avoid too many API calls
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cleanup ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
public cleanup(): void {
|
||||
this.stopAutoRefresh();
|
||||
this.removeTrafficLayer();
|
||||
this.currentTrafficData = null;
|
||||
}
|
||||
|
||||
// ─── Utilities ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format last update time for display
|
||||
*/
|
||||
public formatLastUpdate(): string {
|
||||
if (!this.trafficState.lastUpdate) return 'Never';
|
||||
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - this.trafficState.lastUpdate.getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
|
||||
if (seconds < 60) return 'Just now';
|
||||
if (seconds < 120) return '1 minute ago';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`;
|
||||
return this.trafficState.lastUpdate.toLocaleTimeString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get congestion color for a given level
|
||||
*/
|
||||
public getCongestionColor(congestion: number): string {
|
||||
if (congestion < 0.3) return '#00c853'; // Green
|
||||
if (congestion < 0.6) return '#ffeb3b'; // Yellow
|
||||
if (congestion < 0.8) return '#ff9800'; // Orange
|
||||
if (congestion < 1) return '#f44336'; // Red
|
||||
return '#b71c1c'; // Dark red
|
||||
}
|
||||
|
||||
/**
|
||||
* Get congestion label for a given level
|
||||
*/
|
||||
public getCongestionLabel(level: 'low' | 'moderate' | 'heavy' | 'severe'): string {
|
||||
const labels = {
|
||||
low: 'Light traffic',
|
||||
moderate: 'Moderate traffic',
|
||||
heavy: 'Heavy traffic',
|
||||
severe: 'Severe congestion',
|
||||
};
|
||||
return labels[level];
|
||||
}
|
||||
|
||||
// ─── Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render the traffic toggle button and status
|
||||
*/
|
||||
public render(): TemplateResult {
|
||||
const { isEnabled, isLoading, error, segmentCount } = this.trafficState;
|
||||
const hasProvider = this.provider !== null && this.provider.isConfigured;
|
||||
|
||||
return html`
|
||||
<div class="traffic-control">
|
||||
<button
|
||||
class="traffic-toggle-btn ${isEnabled ? 'active' : ''}"
|
||||
?disabled=${!hasProvider}
|
||||
@click=${() => this.toggle()}
|
||||
title="${!hasProvider ? 'Configure a traffic provider first' : isEnabled ? 'Hide traffic' : 'Show traffic'}"
|
||||
>
|
||||
${renderIcon('traffic')}
|
||||
${isLoading ? html`
|
||||
<span class="traffic-loading-indicator">${renderIcon('spinner')}</span>
|
||||
` : ''}
|
||||
</button>
|
||||
|
||||
${isEnabled && !error ? html`
|
||||
<div class="traffic-status">
|
||||
<span class="traffic-status-dot"></span>
|
||||
<span class="traffic-status-text">
|
||||
${segmentCount > 0 ? `${segmentCount} segments` : 'Loading...'}
|
||||
</span>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${error ? html`
|
||||
<div class="traffic-error" title="${error}">
|
||||
${renderIcon('error')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render traffic legend overlay
|
||||
* @param extraClass - Optional CSS class to add to the legend for positioning
|
||||
*/
|
||||
public renderLegend(extraClass?: string): TemplateResult {
|
||||
if (!this.trafficState.isEnabled) return html``;
|
||||
|
||||
return html`
|
||||
<div class="traffic-legend ${extraClass || ''}">
|
||||
<div class="traffic-legend-title">Traffic</div>
|
||||
<div class="traffic-legend-items">
|
||||
<div class="traffic-legend-item">
|
||||
<span class="traffic-legend-color" style="background: #00c853"></span>
|
||||
<span>Free flow</span>
|
||||
</div>
|
||||
<div class="traffic-legend-item">
|
||||
<span class="traffic-legend-color" style="background: #ffeb3b"></span>
|
||||
<span>Light</span>
|
||||
</div>
|
||||
<div class="traffic-legend-item">
|
||||
<span class="traffic-legend-color" style="background: #ff9800"></span>
|
||||
<span>Moderate</span>
|
||||
</div>
|
||||
<div class="traffic-legend-item">
|
||||
<span class="traffic-legend-color" style="background: #f44336"></span>
|
||||
<span>Heavy</span>
|
||||
</div>
|
||||
<div class="traffic-legend-item">
|
||||
<span class="traffic-legend-color" style="background: #b71c1c"></span>
|
||||
<span>Severe</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="traffic-legend-updated">
|
||||
Updated: ${this.formatLastUpdate()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user