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 | 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 { 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 { 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`
${isEnabled && !error ? html`
${segmentCount > 0 ? `${segmentCount} segments` : 'Loading...'}
` : ''} ${error ? html`
${renderIcon('error')}
` : ''}
`; } /** * 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`
Traffic
Free flow
Light
Moderate
Heavy
Severe
Updated: ${this.formatLastUpdate()}
`; } }