diff --git a/apps/api/src/maps/maps.module.ts b/apps/api/src/maps/maps.module.ts index 755915e..7ebad64 100644 --- a/apps/api/src/maps/maps.module.ts +++ b/apps/api/src/maps/maps.module.ts @@ -4,11 +4,12 @@ import { MapsService } from './maps.service'; import { MapsController } from './maps.controller'; import { RoadSegmentStat } from './road-stat.entity'; import { CandidateRoad } from './candidate-road.entity'; +import { RoadSpeedProfile } from './road-speed-profile.entity'; import { RedisModule } from '../common/redis.module'; @Module({ imports: [ - TypeOrmModule.forFeature([RoadSegmentStat, CandidateRoad]), + TypeOrmModule.forFeature([RoadSegmentStat, CandidateRoad, RoadSpeedProfile]), RedisModule, ], controllers: [MapsController], diff --git a/apps/api/src/maps/maps.service.ts b/apps/api/src/maps/maps.service.ts index d26db18..ad9d80c 100644 --- a/apps/api/src/maps/maps.service.ts +++ b/apps/api/src/maps/maps.service.ts @@ -1,12 +1,20 @@ import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { RoadSpeedProfile } from './road-speed-profile.entity'; import axios from 'axios'; @Injectable() export class MapsService { private readonly graphHopperUrl: string; - constructor(private configService: ConfigService) { + constructor( + private configService: ConfigService, + @InjectRepository(RoadSpeedProfile) + private speedProfileRepo: Repository, + private dataSource: DataSource + ) { this.graphHopperUrl = this.configService.get('GRAPH_HOPPER_URL', 'http://routing:8080'); } @@ -25,20 +33,73 @@ export class MapsService { locale: 'en', calc_points: true, points_encoded: true, + algorithm: 'alternative_route', + 'ch.disable': true, // Required for alternative routes + 'alternative_route.max_paths': 2, // Return main route + 1 alternative + 'alternative_route.max_weight_factor': 1.6, + 'alternative_route.max_share_factor': 0.6 }; console.log(`Routing Request: ${waypoints.length} points via ${profile} on ${this.graphHopperUrl}`); const response = await axios.post(`${this.graphHopperUrl}/route`, payload, { timeout: 10000 }); - + console.log('Routing SUCCESS'); - const route = response.data.paths[0]; - if (!route) throw new HttpException('No route found', HttpStatus.NOT_FOUND); + const paths = response.data.paths; + if (!paths || paths.length === 0) throw new HttpException('No route found', HttpStatus.NOT_FOUND); + + const route = paths[0]; + + // --- PHASE 3: TRAFFIC-AWARE ADJUSTMENT --- + let baseDuration = route.time / 1000; + let trafficAwareDuration = baseDuration; + let trafficFactor = 1.0; + + try { + const now = new Date(); + const hr = now.getHours(); + const dow = now.getDay(); + + // Decode polyline for spatial mapping (server-side only) + const coords = this.decodePolyline(route.points); + const routeGeo = { + type: 'LineString', + coordinates: coords + }; + + // Query historical profiles that intersect with our route + const profiles = await this.dataSource.query(` + SELECT AVG(p."averageSpeed") as profile_speed, AVG(s."averageSpeed") as base_speed + FROM road_speed_profiles p + JOIN road_segment_stats s ON p."segmentId" = s."segmentId" + WHERE p."hourOfDay" = $1 AND p."dayOfWeek" = $2 + AND ST_DWithin(s.geometry, ST_GeomFromGeoJSON($3), 0.0002) + `, [hr, dow, JSON.stringify(routeGeo)]); + + if (profiles[0] && profiles[0].profile_speed && profiles[0].base_speed) { + trafficFactor = profiles[0].base_speed / Math.max(profiles[0].profile_speed, 1); + trafficFactor = Math.min(Math.max(trafficFactor, 0.8), 3.0); + trafficAwareDuration = baseDuration * trafficFactor; + } + } catch (e) { + console.warn('Traffic adjustment failed, using calibrated base:', e.message); + } + + // Process alternative routes if any without breaking existing frontend variables + const alternatives = paths.slice(1).map(alt => ({ + distance: alt.distance, + duration: Math.round(alt.time / 1000), + points: alt.points, + bbox: alt.bbox + })); return { distance: route.distance, - duration: route.time / 1000, + duration: Math.round(baseDuration), + trafficAwareDuration: Math.round(trafficAwareDuration), + trafficFactor: Math.round(trafficFactor * 100) / 100, points: route.points, bbox: route.bbox, + alternatives: alternatives // NEW: array of other routes }; } catch (error) { const msg = error.response ? `GH Error: ${JSON.stringify(error.response.data)}` : `DNS/Connection Error: ${error.message}`; @@ -47,6 +108,41 @@ export class MapsService { } } + /** + * Manual decoder for Google Polyline algorithm (Server-side spatial matching) + */ + private decodePolyline(encoded: string): [number, number][] { + const points: [number, number][] = []; + let index = 0, len = encoded.length; + let lat = 0, lng = 0; + + while (index < len) { + let b, shift = 0, result = 0; + do { + b = encoded.charCodeAt(index++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20); + let dlat = ((result & 1) ? ~(result >> 1) : (result >> 1)); + lat += dlat; + + shift = 0; + result = 0; + do { + b = encoded.charCodeAt(index++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20); + let dlng = ((result & 1) ? ~(result >> 1) : (result >> 1)); + lng += dlng; + + points.push([lng * 1e-5, lat * 1e-5]); // Note: GraphHopper polyline is [lng, lat] usually or lat, lng depending on config. + // GH polyline standard is [lat, lng] but we need [lng, lat] for PostGIS GeoJSON Coordinates. + // Let's verify: GraphHopper default polyline is Lat,Lng. + } + return points; + } + async getMapConfig() { // Return basic map configuration for clients // إرجاع إعدادات الخريطة للواجهة الأمامية diff --git a/docker-compose.yml b/docker-compose.yml index 49646ed..1fccf4f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,7 +52,7 @@ services: volumes: - ./infrastructure/osm-data:/data - ./infrastructure/docker/graphhopper/config.yml:/graphhopper/config.yml - command: ["-i", "/data/mena_full.osm.pbf", "-c", "config.yml"] + command: ["-i", "/data/master_map.osm.pbf", "-c", "config.yml"] healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"] interval: 30s diff --git a/infrastructure/.DS_Store b/infrastructure/.DS_Store index f7b44eb..b9a6e56 100644 Binary files a/infrastructure/.DS_Store and b/infrastructure/.DS_Store differ diff --git a/infrastructure/docker/graphhopper/config.yml b/infrastructure/docker/graphhopper/config.yml index 0c4d236..add56f2 100644 --- a/infrastructure/docker/graphhopper/config.yml +++ b/infrastructure/docker/graphhopper/config.yml @@ -1,5 +1,5 @@ graphhopper: - datareader.file: /data/jordan-latest.osm.pbf + datareader.file: /data/master_map.osm.pbf graph.location: /data/graph-cache import.osm.ignored_highways: "" graph.encoded_values: road_class,road_environment,max_speed,road_access,surface @@ -8,12 +8,21 @@ graphhopper: - name: car custom_model: distance_influence: 70 - priority: - - if: "true" - multiply_by: 1.0 speed: - if: "true" - limit_to: 100 + limit_to: 60 + - if: "road_class == MOTORWAY || road_class == TRUNK" + limit_to: 60 + - if: "road_class == PRIMARY" + limit_to: 45 + - if: "road_class == SECONDARY" + limit_to: 28 + - if: "road_class == TERTIARY" + limit_to: 20 + - if: "road_class == RESIDENTIAL || road_class == UNCLASSIFIED" + limit_to: 14 + - if: "road_class == LIVING_STREET || road_class == SERVICE" + limit_to: 14 profiles_ch: - profile: car