Files
dees-catalog-geo/ts_web/elements/00group-map/dees-geo-map/geo-map.traffic.ts

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>
`;
}
}