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

767 lines
22 KiB
TypeScript

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<ITrafficFlowData | null>;
/**
* 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<ITrafficAwareRoute | null>;
/**
* 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<ITrafficFlowData | null> {
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<ITrafficAwareRoute | null> {
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<string, string> = {
'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<ITrafficFlowData | null> {
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<ITrafficAwareRoute | null> {
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<number, string> = {
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;
}>;
}>;
};
}