583 lines
18 KiB
TypeScript
583 lines
18 KiB
TypeScript
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>
|
|
`;
|
|
}
|
|
}
|