2026-04-13-2
This commit is contained in:
@@ -1,19 +1,19 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { MapsService } from './maps.service';
|
import { MapsService } from './maps.service';
|
||||||
|
import { TrafficGridService } from './traffic-grid.service';
|
||||||
import { MapsController } from './maps.controller';
|
import { MapsController } from './maps.controller';
|
||||||
import { RoadSegmentStat } from './road-stat.entity';
|
import { RoadSegmentStat } from './road-stat.entity';
|
||||||
import { CandidateRoad } from './candidate-road.entity';
|
import { CandidateRoad } from './candidate-road.entity';
|
||||||
import { RoadSpeedProfile } from './road-speed-profile.entity';
|
|
||||||
import { RedisModule } from '../common/redis.module';
|
import { RedisModule } from '../common/redis.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([RoadSegmentStat, CandidateRoad, RoadSpeedProfile]),
|
TypeOrmModule.forFeature([RoadSegmentStat, CandidateRoad]),
|
||||||
RedisModule,
|
RedisModule,
|
||||||
],
|
],
|
||||||
controllers: [MapsController],
|
controllers: [MapsController],
|
||||||
providers: [MapsService],
|
providers: [MapsService, TrafficGridService],
|
||||||
exports: [MapsService],
|
exports: [MapsService, TrafficGridService],
|
||||||
})
|
})
|
||||||
export class MapsModule {}
|
export class MapsModule {}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository, DataSource } from 'typeorm';
|
import { Repository, DataSource } from 'typeorm';
|
||||||
import { RoadSpeedProfile } from './road-speed-profile.entity';
|
import { RoadSpeedProfile } from './road-speed-profile.entity';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { RoadSegmentStat } from './road-stat.entity';
|
||||||
|
import { TrafficGridService } from './traffic-grid.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MapsService {
|
export class MapsService {
|
||||||
@@ -11,9 +13,9 @@ export class MapsService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
@InjectRepository(RoadSpeedProfile)
|
@InjectRepository(RoadSegmentStat)
|
||||||
private speedProfileRepo: Repository<RoadSpeedProfile>,
|
private roadStatRepo: Repository<RoadSegmentStat>,
|
||||||
private dataSource: DataSource
|
private trafficGrid: TrafficGridService
|
||||||
) {
|
) {
|
||||||
this.graphHopperUrl = this.configService.get('GRAPH_HOPPER_URL', 'http://routing:8080');
|
this.graphHopperUrl = this.configService.get('GRAPH_HOPPER_URL', 'http://routing:8080');
|
||||||
}
|
}
|
||||||
@@ -33,13 +35,17 @@ export class MapsService {
|
|||||||
locale: 'en',
|
locale: 'en',
|
||||||
calc_points: true,
|
calc_points: true,
|
||||||
points_encoded: 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
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// GraphHopper ONLY supports alternative routes if there are exactly 2 points (Start and End)
|
||||||
|
if (waypoints.length === 2) {
|
||||||
|
payload.algorithm = 'alternative_route';
|
||||||
|
payload['ch.disable'] = true; // Required for alternative routes
|
||||||
|
payload['alternative_route.max_paths'] = 2; // Return main route + 1 alternative
|
||||||
|
payload['alternative_route.max_weight_factor'] = 1.6;
|
||||||
|
payload['alternative_route.max_share_factor'] = 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Routing Request: ${waypoints.length} points via ${profile} on ${this.graphHopperUrl}`);
|
console.log(`Routing Request: ${waypoints.length} points via ${profile} on ${this.graphHopperUrl}`);
|
||||||
const response = await axios.post(`${this.graphHopperUrl}/route`, payload, { timeout: 10000 });
|
const response = await axios.post(`${this.graphHopperUrl}/route`, payload, { timeout: 10000 });
|
||||||
|
|
||||||
@@ -49,40 +55,16 @@ export class MapsService {
|
|||||||
|
|
||||||
const route = paths[0];
|
const route = paths[0];
|
||||||
|
|
||||||
// --- PHASE 3: TRAFFIC-AWARE ADJUSTMENT ---
|
// --- PHASE 3: TRAFFIC-AWARE ADJUSTMENT (V3 Optimized) ---
|
||||||
let baseDuration = route.time / 1000;
|
const now = new Date();
|
||||||
let trafficAwareDuration = baseDuration;
|
const hr = now.getHours();
|
||||||
let trafficFactor = 1.0;
|
const dow = now.getDay();
|
||||||
|
|
||||||
try {
|
const coords = this.decodePolyline(route.points);
|
||||||
const now = new Date();
|
const trafficFactor = this.trafficGrid.getTrafficFactor(coords, hr, dow);
|
||||||
const hr = now.getHours();
|
|
||||||
const dow = now.getDay();
|
|
||||||
|
|
||||||
// Decode polyline for spatial mapping (server-side only)
|
const baseDuration = route.time / 1000;
|
||||||
const coords = this.decodePolyline(route.points);
|
const trafficAwareDuration = baseDuration * trafficFactor;
|
||||||
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
|
// Process alternative routes if any without breaking existing frontend variables
|
||||||
const alternatives = paths.slice(1).map(alt => ({
|
const alternatives = paths.slice(1).map(alt => ({
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traffic Grid Service - In-Memory GeoHash Grid
|
||||||
|
* نظام شبكة التنبؤ المروري الذكي: يحول بيانات قاعدة البيانات إلى خارطة سرعة في الذاكرة
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TrafficGridService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(TrafficGridService.name);
|
||||||
|
|
||||||
|
// Key: "geohash:hr:dow", Value: trafficFactor
|
||||||
|
private grid: Map<string, number> = new Map();
|
||||||
|
private readonly GEOHASH_PRECISION = 6; // ~100m accuracy
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.refreshGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the in-memory lookup table from PostGIS
|
||||||
|
* إعادة بناء الجدول المرجعي في الذاكرة من قاعدة البيانات
|
||||||
|
*/
|
||||||
|
async refreshGrid() {
|
||||||
|
this.logger.log('📡 Rebuilding Traffic Grid v3 (In-Memory Lookup)...');
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const data = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
ST_X(ST_Centroid(geometry::geometry)) as lng,
|
||||||
|
ST_Y(ST_Centroid(geometry::geometry)) as lat,
|
||||||
|
p."hourOfDay", p."dayOfWeek", p."averageSpeed",
|
||||||
|
s."averageSpeed" as base_speed
|
||||||
|
FROM road_speed_profiles p
|
||||||
|
JOIN road_segment_stats s ON p."segmentId" = s."segmentId"
|
||||||
|
WHERE s.geometry IS NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.grid.clear();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
const gh = this.encodeGeohash(row.lat, row.lng, this.GEOHASH_PRECISION);
|
||||||
|
const factor = row.base_speed / Math.max(row.averageSpeed, 1);
|
||||||
|
|
||||||
|
// Key includes time buckets to handle day/night variations
|
||||||
|
const key = `${gh}:${row.hourOfDay}:${row.dayOfWeek}`;
|
||||||
|
this.grid.set(key, factor);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`✅ Grid rebuilt in ${Date.now() - startTime}ms. Cached ${count} cells (${this.grid.size} unique keys).`);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('❌ Failed to refresh traffic grid:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the average traffic factor for a decoded polyline
|
||||||
|
* جلب معامل الازدحام المتوسط لمخطط الرحلة من الذاكرة
|
||||||
|
*/
|
||||||
|
getTrafficFactor(coords: [number, number][], hr: number, dow: number): number {
|
||||||
|
if (this.grid.size === 0) return 1.0;
|
||||||
|
|
||||||
|
let totalFactor = 0;
|
||||||
|
let matches = 0;
|
||||||
|
|
||||||
|
// We sample points every ~100m to speed up (every 2nd or 3rd coord)
|
||||||
|
for (let i = 0; i < coords.length; i += 2) {
|
||||||
|
const [lng, lat] = coords[i];
|
||||||
|
const gh = this.encodeGeohash(lat, lng, this.GEOHASH_PRECISION);
|
||||||
|
const key = `${gh}:${hr}:${dow}`;
|
||||||
|
|
||||||
|
const factor = this.grid.get(key);
|
||||||
|
if (factor) {
|
||||||
|
totalFactor += factor;
|
||||||
|
matches++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches === 0) return 1.0;
|
||||||
|
|
||||||
|
// Average factor clipped to sane ranges (0.8 - 3.0)
|
||||||
|
let finalFactor = totalFactor / matches;
|
||||||
|
return Math.min(Math.max(finalFactor, 0.8), 3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure Math GeoHash implementation (No outside dependencies)
|
||||||
|
*/
|
||||||
|
private encodeGeohash(lat: number, lng: number, precision: number): string {
|
||||||
|
const BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
|
||||||
|
let minLat = -90, maxLat = 90;
|
||||||
|
let minLng = -180, maxLng = 180;
|
||||||
|
let result = "";
|
||||||
|
let isEven = true;
|
||||||
|
let bit = 0;
|
||||||
|
let ch = 0;
|
||||||
|
|
||||||
|
while (result.length < precision) {
|
||||||
|
if (isEven) {
|
||||||
|
let mid = (minLng + maxLng) / 2;
|
||||||
|
if (lng > mid) { ch |= (1 << (4 - bit)); minLng = mid; }
|
||||||
|
else { maxLng = mid; }
|
||||||
|
} else {
|
||||||
|
let mid = (minLat + maxLat) / 2;
|
||||||
|
if (lat > mid) { ch |= (1 << (4 - bit)); minLat = mid; }
|
||||||
|
else { maxLat = mid; }
|
||||||
|
}
|
||||||
|
|
||||||
|
isEven = !isEven;
|
||||||
|
if (bit < 4) {
|
||||||
|
bit++;
|
||||||
|
} else {
|
||||||
|
result += BASE32[ch];
|
||||||
|
bit = 0;
|
||||||
|
ch = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user