import type { TNavigationMode, IOSRMLeg } from './geo-map.navigation.js'; // ─── Traffic Data Types ────────────────────────────────────────────────────── /** * A segment of road with traffic flow data */ export interface ITrafficFlowSegment { /** GeoJSON LineString geometry for the segment */ geometry: GeoJSON.LineString; /** Congestion level from 0 (free flow) to 1 (blocked) */ congestion: number; /** Current speed in km/h */ speed: number; /** Free-flow (normal) speed in km/h */ freeFlowSpeed: number; /** Confidence/quality of the data (0-1) */ confidence: number; } /** * Traffic flow data for a geographic area */ export interface ITrafficFlowData { /** Array of road segments with traffic data */ segments: ITrafficFlowSegment[]; /** Timestamp when the data was fetched */ timestamp: Date; /** Bounding box [west, south, east, north] */ bounds: [number, number, number, number]; } /** * A route with traffic-aware duration estimates */ export interface ITrafficAwareRoute { /** Route geometry */ geometry: GeoJSON.LineString; /** Total distance in meters */ distance: number; /** Duration with current traffic in seconds */ duration: number; /** Duration without traffic (free-flow) in seconds */ durationWithoutTraffic: number; /** Overall congestion level for the route */ congestionLevel: 'low' | 'moderate' | 'heavy' | 'severe'; /** Route legs with turn-by-turn directions */ legs: IOSRMLeg[]; } /** * Configuration options for traffic providers */ export interface ITrafficProviderConfig { /** API key (for providers that require authentication) */ apiKey?: string; /** Server URL (for self-hosted providers) */ serverUrl?: string; /** Refresh interval in milliseconds */ refreshInterval?: number; /** Whether to include traffic incidents */ includeIncidents?: boolean; /** Additional provider-specific options */ [key: string]: unknown; } // ─── Traffic Provider Interface ────────────────────────────────────────────── /** * Interface for traffic data providers */ export interface ITrafficProvider { /** Provider name */ readonly name: string; /** Whether the provider is configured and ready to use */ readonly isConfigured: boolean; /** * Fetch traffic flow data for a geographic area * @param bounds - [west, south, east, north] bounding box * @returns Traffic flow data or null if unavailable */ fetchTrafficFlow(bounds: [number, number, number, number]): Promise; /** * Fetch a route with traffic-aware duration estimates (optional) * @param start - Start coordinates [lng, lat] * @param end - End coordinates [lng, lat] * @param mode - Navigation mode * @returns Traffic-aware route or null if not supported */ fetchRouteWithTraffic?( start: [number, number], end: [number, number], mode: TNavigationMode ): Promise; /** * Configure the provider with options * @param options - Provider configuration */ configure(options: ITrafficProviderConfig): void; } // ─── HERE Traffic Provider ─────────────────────────────────────────────────── /** * Traffic provider using HERE Traffic API v7 (freemium) * * Free tier includes: * - 250,000 transactions/month * - Traffic flow and incidents * - No credit card required * * @see https://developer.here.com/documentation/traffic-api/dev_guide/index.html */ export class HereTrafficProvider implements ITrafficProvider { public readonly name = 'HERE Traffic API'; private apiKey: string = ''; private includeIncidents: boolean = true; public get isConfigured(): boolean { return this.apiKey.length > 0; } public configure(options: ITrafficProviderConfig): void { if (options.apiKey) { this.apiKey = options.apiKey; } if (typeof options.includeIncidents === 'boolean') { this.includeIncidents = options.includeIncidents; } } public async fetchTrafficFlow( bounds: [number, number, number, number] ): Promise { if (!this.isConfigured) { console.warn('[HereTrafficProvider] No API key configured'); return null; } const [west, south, east, north] = bounds; // HERE Traffic Flow API endpoint // Format: bbox=west,south,east,north const bbox = `${west},${south},${east},${north}`; const url = `https://data.traffic.hereapi.com/v7/flow?in=bbox:${bbox}&apiKey=${this.apiKey}`; try { const response = await fetch(url); if (!response.ok) { if (response.status === 401 || response.status === 403) { console.error('[HereTrafficProvider] Invalid or expired API key'); } else if (response.status === 429) { console.warn('[HereTrafficProvider] Rate limit exceeded'); } else { console.error(`[HereTrafficProvider] API error: ${response.status}`); } return null; } const data = await response.json(); return this.parseHereTrafficFlow(data, bounds); } catch (error) { console.error('[HereTrafficProvider] Fetch error:', error); return null; } } /** * Parse HERE Traffic API response into our standard format */ private parseHereTrafficFlow( data: IHereTrafficFlowResponse, bounds: [number, number, number, number] ): ITrafficFlowData { const segments: ITrafficFlowSegment[] = []; if (data.results) { for (const result of data.results) { if (!result.location?.shape?.links) continue; for (const link of result.location.shape.links) { // Parse the polyline points const coordinates = this.parseHerePolyline(link.points); if (coordinates.length < 2) continue; // Get traffic data from currentFlow const flow = result.currentFlow; const freeFlow = flow?.freeFlow || flow?.speed || 50; const currentSpeed = flow?.speed || freeFlow; const jamFactor = flow?.jamFactor || 0; // 0-10 scale segments.push({ geometry: { type: 'LineString', coordinates, }, congestion: Math.min(jamFactor / 10, 1), // Normalize to 0-1 speed: currentSpeed, freeFlowSpeed: freeFlow, confidence: flow?.confidence || 0.5, }); } } } return { segments, timestamp: new Date(), bounds, }; } /** * Parse HERE polyline format to coordinates array * HERE uses lat,lng format, we need lng,lat */ private parseHerePolyline(points: IHerePoint[]): [number, number][] { return points.map(point => [point.lng, point.lat] as [number, number]); } public async fetchRouteWithTraffic( start: [number, number], end: [number, number], mode: TNavigationMode ): Promise { if (!this.isConfigured) { console.warn('[HereTrafficProvider] No API key configured'); return null; } // Map mode to HERE transport mode const transportMode = mode === 'cycling' ? 'bicycle' : mode === 'walking' ? 'pedestrian' : 'car'; // HERE Routing API v8 with traffic const url = new URL('https://router.hereapi.com/v8/routes'); url.searchParams.set('apiKey', this.apiKey); url.searchParams.set('origin', `${start[1]},${start[0]}`); // HERE uses lat,lng url.searchParams.set('destination', `${end[1]},${end[0]}`); url.searchParams.set('transportMode', transportMode); url.searchParams.set('return', 'polyline,summary,turnByTurnActions'); // Include traffic for driving mode if (mode === 'driving') { url.searchParams.set('departureTime', 'now'); // Enables live traffic } try { const response = await fetch(url.toString()); if (!response.ok) { console.error(`[HereTrafficProvider] Routing error: ${response.status}`); return null; } const data = await response.json() as IHereRoutingResponse; return this.parseHereRoute(data); } catch (error) { console.error('[HereTrafficProvider] Routing fetch error:', error); return null; } } /** * Parse HERE Routing API response */ private parseHereRoute(data: IHereRoutingResponse): ITrafficAwareRoute | null { if (!data.routes || data.routes.length === 0) { return null; } const route = data.routes[0]; const section = route.sections?.[0]; if (!section) return null; // Decode the polyline const coordinates = this.decodeFlexiblePolyline(section.polyline); // Calculate congestion level based on delay const baseDuration = section.summary?.baseDuration || section.summary?.duration || 0; const duration = section.summary?.duration || baseDuration; const delayRatio = baseDuration > 0 ? (duration - baseDuration) / baseDuration : 0; let congestionLevel: 'low' | 'moderate' | 'heavy' | 'severe'; if (delayRatio < 0.1) congestionLevel = 'low'; else if (delayRatio < 0.3) congestionLevel = 'moderate'; else if (delayRatio < 0.5) congestionLevel = 'heavy'; else congestionLevel = 'severe'; // Convert HERE actions to OSRM-style legs/steps const legs = this.convertToOsrmLegs(section); return { geometry: { type: 'LineString', coordinates, }, distance: section.summary?.length || 0, duration, durationWithoutTraffic: baseDuration, congestionLevel, legs, }; } /** * Decode HERE's flexible polyline format * @see https://github.com/heremaps/flexible-polyline */ private decodeFlexiblePolyline(encoded: string): [number, number][] { // Simplified decoder - for full support, use @here/flexpolyline package const coordinates: [number, number][] = []; let index = 0; let lat = 0; let lng = 0; // Skip header byte (version and precision info) const header = this.decodeUnsignedValue(encoded, index); index = header.nextIndex; const precision = Math.pow(10, header.value & 15); while (index < encoded.length) { const latResult = this.decodeSignedValue(encoded, index); index = latResult.nextIndex; lat += latResult.value; const lngResult = this.decodeSignedValue(encoded, index); index = lngResult.nextIndex; lng += lngResult.value; coordinates.push([lng / precision, lat / precision]); } return coordinates; } private decodeUnsignedValue(encoded: string, startIndex: number): { value: number; nextIndex: number } { let result = 0; let shift = 0; let index = startIndex; while (index < encoded.length) { const char = encoded.charCodeAt(index) - 45; index++; result |= (char & 31) << shift; if (char < 32) break; shift += 5; } return { value: result, nextIndex: index }; } private decodeSignedValue(encoded: string, startIndex: number): { value: number; nextIndex: number } { const { value, nextIndex } = this.decodeUnsignedValue(encoded, startIndex); return { value: (value & 1) ? ~(value >> 1) : (value >> 1), nextIndex, }; } /** * Convert HERE routing actions to OSRM-style legs */ private convertToOsrmLegs(section: IHereRouteSection): IOSRMLeg[] { const steps = (section.turnByTurnActions || []).map(action => ({ geometry: { type: 'LineString' as const, coordinates: [] as [number, number][] }, maneuver: { type: this.mapHereActionToOsrm(action.action), modifier: action.direction?.toLowerCase(), location: [action.offset || 0, 0] as [number, number], // Simplified }, name: action.currentRoad?.name?.[0]?.value || action.nextRoad?.name?.[0]?.value || '', distance: action.length || 0, duration: action.duration || 0, driving_side: 'right', })); return [{ steps, distance: section.summary?.length || 0, duration: section.summary?.duration || 0, }]; } /** * Map HERE action types to OSRM maneuver types */ private mapHereActionToOsrm(action: string): string { const mapping: Record = { 'depart': 'depart', 'arrive': 'arrive', 'turn': 'turn', 'continue': 'continue', 'roundaboutEnter': 'roundabout', 'roundaboutExit': 'roundabout', 'merge': 'merge', 'fork': 'fork', 'uturn': 'turn', }; return mapping[action] || action; } } // ─── Valhalla Traffic Provider ─────────────────────────────────────────────── /** * Traffic provider for self-hosted Valhalla servers * * Requires: * - A running Valhalla server with traffic data enabled * - Traffic data in Valhalla's expected format * * @see https://valhalla.github.io/valhalla/ */ export class ValhallaTrafficProvider implements ITrafficProvider { public readonly name = 'Valhalla (Self-Hosted)'; private serverUrl: string = ''; private trafficDataUrl: string = ''; public get isConfigured(): boolean { return this.serverUrl.length > 0; } public configure(options: ITrafficProviderConfig): void { if (options.serverUrl) { this.serverUrl = options.serverUrl.replace(/\/$/, ''); // Remove trailing slash } if (options.trafficDataUrl && typeof options.trafficDataUrl === 'string') { this.trafficDataUrl = options.trafficDataUrl; } } public async fetchTrafficFlow( bounds: [number, number, number, number] ): Promise { if (!this.isConfigured) { console.warn('[ValhallaTrafficProvider] No server URL configured'); return null; } // Valhalla doesn't have a direct traffic flow API like HERE // This would require custom traffic data endpoint setup if (!this.trafficDataUrl) { console.info('[ValhallaTrafficProvider] Traffic flow visualization requires trafficDataUrl'); return null; } try { const [west, south, east, north] = bounds; const url = `${this.trafficDataUrl}?bbox=${west},${south},${east},${north}`; const response = await fetch(url); if (!response.ok) { console.error(`[ValhallaTrafficProvider] Traffic data error: ${response.status}`); return null; } const data = await response.json(); return this.parseValhallaTrafficData(data, bounds); } catch (error) { console.error('[ValhallaTrafficProvider] Fetch error:', error); return null; } } /** * Parse custom traffic data format (user-defined schema) */ private parseValhallaTrafficData( data: IValhallaTrafficData, bounds: [number, number, number, number] ): ITrafficFlowData { const segments: ITrafficFlowSegment[] = []; if (data.segments) { for (const seg of data.segments) { segments.push({ geometry: seg.geometry, congestion: seg.congestion || 0, speed: seg.speed || 0, freeFlowSpeed: seg.freeFlowSpeed || seg.speed || 50, confidence: seg.confidence || 0.5, }); } } return { segments, timestamp: new Date(), bounds, }; } public async fetchRouteWithTraffic( start: [number, number], end: [number, number], mode: TNavigationMode ): Promise { if (!this.isConfigured) { console.warn('[ValhallaTrafficProvider] No server URL configured'); return null; } // Map mode to Valhalla costing model const costing = mode === 'cycling' ? 'bicycle' : mode === 'walking' ? 'pedestrian' : 'auto'; const requestBody = { locations: [ { lat: start[1], lon: start[0] }, { lat: end[1], lon: end[0] }, ], costing, costing_options: { [costing]: { use_traffic: true, // Enable traffic consideration }, }, directions_options: { units: 'kilometers', }, }; try { const response = await fetch(`${this.serverUrl}/route`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), }); if (!response.ok) { console.error(`[ValhallaTrafficProvider] Routing error: ${response.status}`); return null; } const data = await response.json() as IValhallaRouteResponse; return this.parseValhallaRoute(data); } catch (error) { console.error('[ValhallaTrafficProvider] Routing fetch error:', error); return null; } } /** * Parse Valhalla routing response */ private parseValhallaRoute(data: IValhallaRouteResponse): ITrafficAwareRoute | null { if (!data.trip?.legs?.[0]) { return null; } const leg = data.trip.legs[0]; const summary = data.trip.summary; // Decode Valhalla's polyline6 format const coordinates = this.decodePolyline6(leg.shape); // Calculate congestion level const delayRatio = summary.time > 0 ? (summary.time - (summary.time * 0.9)) / summary.time // Estimate without traffic : 0; let congestionLevel: 'low' | 'moderate' | 'heavy' | 'severe'; if (delayRatio < 0.1) congestionLevel = 'low'; else if (delayRatio < 0.3) congestionLevel = 'moderate'; else if (delayRatio < 0.5) congestionLevel = 'heavy'; else congestionLevel = 'severe'; // Convert Valhalla maneuvers to OSRM format const steps = (leg.maneuvers || []).map(maneuver => ({ geometry: { type: 'LineString' as const, coordinates: [] as [number, number][] }, maneuver: { type: this.mapValhallaManeuverToOsrm(maneuver.type), modifier: maneuver.modifier, location: [maneuver.begin_shape_index, 0] as [number, number], }, name: maneuver.street_names?.[0] || '', distance: maneuver.length * 1000, // Convert km to meters duration: maneuver.time, driving_side: 'right', })); return { geometry: { type: 'LineString', coordinates, }, distance: summary.length * 1000, // Convert km to meters duration: summary.time, durationWithoutTraffic: summary.time * 0.9, // Estimate congestionLevel, legs: [{ steps, distance: summary.length * 1000, duration: summary.time, }], }; } /** * Decode polyline6 format (Valhalla uses 6 decimal precision) */ private decodePolyline6(encoded: string): [number, number][] { const coordinates: [number, number][] = []; let index = 0; let lat = 0; let lng = 0; while (index < encoded.length) { // Decode latitude let shift = 0; let result = 0; let byte: number; do { byte = encoded.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20); lat += (result & 1) ? ~(result >> 1) : (result >> 1); // Decode longitude shift = 0; result = 0; do { byte = encoded.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20); lng += (result & 1) ? ~(result >> 1) : (result >> 1); coordinates.push([lng / 1e6, lat / 1e6]); } return coordinates; } /** * Map Valhalla maneuver types to OSRM types */ private mapValhallaManeuverToOsrm(type: number): string { const mapping: Record = { 0: 'none', 1: 'depart', 2: 'depart', 3: 'arrive', 4: 'arrive', 5: 'arrive', 6: 'continue', 7: 'continue', 8: 'turn', 9: 'turn', 10: 'turn', 11: 'turn', 12: 'turn', 13: 'turn', 14: 'turn', 15: 'turn', 16: 'turn', 17: 'roundabout', 18: 'roundabout', 19: 'roundabout', 20: 'roundabout', 21: 'fork', 22: 'fork', 23: 'merge', 24: 'merge', 25: 'merge', 26: 'notification', 27: 'notification', }; return mapping[type] || 'continue'; } } // ─── HERE API Response Types ───────────────────────────────────────────────── interface IHerePoint { lat: number; lng: number; } interface IHereTrafficFlowResponse { results?: Array<{ location?: { shape?: { links?: Array<{ points: IHerePoint[]; }>; }; }; currentFlow?: { speed?: number; freeFlow?: number; jamFactor?: number; confidence?: number; }; }>; } interface IHereRoutingResponse { routes?: Array<{ sections?: IHereRouteSection[]; }>; } interface IHereRouteSection { polyline: string; summary?: { duration: number; baseDuration?: number; length: number; }; turnByTurnActions?: Array<{ action: string; direction?: string; offset?: number; length?: number; duration?: number; currentRoad?: { name?: Array<{ value: string }> }; nextRoad?: { name?: Array<{ value: string }> }; }>; } // ─── Valhalla API Response Types ───────────────────────────────────────────── interface IValhallaTrafficData { segments?: Array<{ geometry: GeoJSON.LineString; congestion?: number; speed?: number; freeFlowSpeed?: number; confidence?: number; }>; } interface IValhallaRouteResponse { trip?: { summary: { time: number; length: number; }; legs?: Array<{ shape: string; maneuvers?: Array<{ type: number; modifier?: string; begin_shape_index: number; street_names?: string[]; length: number; time: number; }>; }>; }; }