390 lines
16 KiB
TypeScript
390 lines
16 KiB
TypeScript
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';
|
|
import { RoadSegmentStat } from './road-stat.entity';
|
|
import { TrafficGridService } from './traffic-grid.service';
|
|
import { GeocodingService } from '../geocoding/geocoding.service';
|
|
import { RedisService } from '../common/redis.service';
|
|
|
|
@Injectable()
|
|
export class MapsService {
|
|
private readonly graphHopperUrl: string;
|
|
|
|
constructor(
|
|
private configService: ConfigService,
|
|
@InjectRepository(RoadSegmentStat)
|
|
private roadStatRepo: Repository<RoadSegmentStat>,
|
|
private trafficGrid: TrafficGridService,
|
|
private geocodingService: GeocodingService,
|
|
private dataSource: DataSource,
|
|
private redisService: RedisService,
|
|
) {
|
|
this.graphHopperUrl = this.configService.get('GRAPH_HOPPER_URL', 'http://routing:8080');
|
|
}
|
|
|
|
async requestRoutingSync() {
|
|
await this.redisService.set('routing_sync_requested', '1');
|
|
return { success: true, message: 'Routing sync requested and scheduled for the next minute.' };
|
|
}
|
|
|
|
|
|
async getRoute(waypoints: [number, number][], profile: string = 'car', steps: boolean = false, locale: string = 'en', alternatives: boolean = false) {
|
|
if (waypoints.length < 2) {
|
|
throw new HttpException('At least two waypoints are required', HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
try {
|
|
// GraphHopper expects [lng, lat] order
|
|
const ghPoints = waypoints.map(wp => [wp[1], wp[0]]);
|
|
|
|
let startName = 'Unknown Location';
|
|
let endName = 'Unknown Location';
|
|
try {
|
|
const startWp = waypoints[0];
|
|
const endWp = waypoints[waypoints.length - 1];
|
|
const [startRes, endRes] = await Promise.all([
|
|
this.geocodingService.reverseGeocode(startWp[0], startWp[1]),
|
|
this.geocodingService.reverseGeocode(endWp[0], endWp[1])
|
|
]);
|
|
|
|
const formatName = (r: any) => {
|
|
const parts = [r.name_ar || r.name, r.neighbourhood, r.district, r.governorate].filter(Boolean);
|
|
// Deduplicate items continuously (e.g. if name is similar to neighborhood)
|
|
const uniqueParts = [...new Set(parts)];
|
|
return uniqueParts.length > 0 ? uniqueParts.join('، ') : 'Unknown Location';
|
|
};
|
|
|
|
if (startRes && startRes.length > 0) startName = formatName(startRes[0]);
|
|
if (endRes && endRes.length > 0) endName = formatName(endRes[0]);
|
|
} catch (e) {
|
|
console.warn('Geocoding internal error during routing:', e);
|
|
}
|
|
|
|
const payload: any = {
|
|
points: ghPoints,
|
|
profile: profile,
|
|
locale: locale === 'en' ? 'ar' : locale, // Default to Arabic if not specified or fallback
|
|
calc_points: true,
|
|
points_encoded: true,
|
|
instructions: steps || true, // Always request instructions to extract route name
|
|
};
|
|
|
|
// ── Closure-Aware Routing ─────────────────────────────────────────────
|
|
// Roads the telemetry analyzer flagged as closed are handed to GraphHopper
|
|
// as custom-model "areas" so the router avoids them. Two bugs from the first
|
|
// version are fixed here:
|
|
// 1. Areas MUST be polygons. road_segment_stats.geometry is a LineString,
|
|
// so we buffer it (~15 m) into a polygon in SQL before sending.
|
|
// 2. Each rule must reference its area by the exact id the area declares
|
|
// (in_<id>). The old code called indexOf() on a different array and
|
|
// always produced `in_custom_area-1` (a non-existent area) — GraphHopper
|
|
// then rejected the request with 400, taking down ALL routing.
|
|
// closureCount lets the send step below retry without closures if GH still
|
|
// refuses the custom model, so a bad closure can never break routing.
|
|
let closureCount = 0;
|
|
try {
|
|
const closedSegments = await this.dataSource.query(`
|
|
SELECT ST_AsGeoJSON(
|
|
ST_Transform(ST_Buffer(ST_Transform(geometry::geometry, 3857), 15), 4326), 6
|
|
) AS geojson
|
|
FROM road_segment_stats
|
|
WHERE "isClosed" = true AND geometry IS NOT NULL
|
|
LIMIT 50
|
|
`);
|
|
|
|
const features = closedSegments
|
|
.map((s: any, i: number) => {
|
|
try {
|
|
return { type: 'Feature', id: `closed_${i}`, geometry: JSON.parse(s.geojson), properties: {} };
|
|
} catch { return null; }
|
|
})
|
|
.filter(Boolean);
|
|
|
|
if (features.length > 0) {
|
|
payload['custom_model'] = {
|
|
priority: features.map((f: any) => ({ if: `in_${f.id}`, multiply_by: '0' })),
|
|
areas: { type: 'FeatureCollection', features },
|
|
};
|
|
payload['ch.disable'] = true; // request-time custom models require CH disabled
|
|
closureCount = features.length;
|
|
console.log(`🚧 Routing: avoiding ${closureCount} closed segment(s).`);
|
|
}
|
|
} catch (closureError) {
|
|
// Non-fatal — routing continues normally without closure avoidance.
|
|
console.warn('⚠️ Could not load road closures for routing:', closureError.message);
|
|
}
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// GraphHopper ONLY supports alternative routes if there are exactly 2 points (Start and End)
|
|
if (alternatives && 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} | Steps: ${steps} | Locale: ${locale}`);
|
|
let response: any;
|
|
try {
|
|
response = await axios.post(`${this.graphHopperUrl}/route`, payload, { timeout: 10000 });
|
|
} catch (routeErr) {
|
|
// Safety net: if the closure-aware custom model was rejected, retry once
|
|
// WITHOUT it so an unsupported/bad closure can never take down all routing.
|
|
if (closureCount > 0 && payload['custom_model']) {
|
|
const detail = routeErr.response ? JSON.stringify(routeErr.response.data) : routeErr.message;
|
|
console.warn(`⚠️ Closure-aware routing failed (${detail}). Retrying without closures...`);
|
|
delete payload['custom_model'];
|
|
// Keep ch.disable only if the alternative-route block below still needs it.
|
|
if (!(alternatives && waypoints.length === 2)) delete payload['ch.disable'];
|
|
response = await axios.post(`${this.graphHopperUrl}/route`, payload, { timeout: 10000 });
|
|
} else {
|
|
throw routeErr;
|
|
}
|
|
}
|
|
|
|
console.log('Routing SUCCESS');
|
|
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 (V3 Optimized) ---
|
|
const now = new Date();
|
|
const hr = now.getHours();
|
|
const dow = now.getDay();
|
|
|
|
const coords = this.decodePolyline(route.points);
|
|
const trafficFactor = this.trafficGrid.getTrafficFactor(coords, hr, dow);
|
|
|
|
const baseDuration = route.time / 1000;
|
|
const trafficAwareDuration = baseDuration * trafficFactor;
|
|
|
|
// Process all paths to add metadata (Names, Tags, Elevation & Slope Warnings)
|
|
const processedPaths = paths.map((p: any, index: number) => {
|
|
const pCoords = this.decodePolyline(p.points);
|
|
const pTrafficFactor = this.trafficGrid.getTrafficFactor(pCoords, hr, dow);
|
|
const pDuration = Math.round((p.time / 1000) * pTrafficFactor);
|
|
const routeName = this.getRouteName(p.instructions);
|
|
|
|
// Analyze slopes and enrich instructions
|
|
const { enrichedInstructions, slopeSummary } = this.enrichInstructionsWithSlopeAnalysis(p.instructions, pCoords);
|
|
|
|
// Tags assignment
|
|
const tags: string[] = [];
|
|
if (index === 0) tags.push('FASTEST');
|
|
if (paths.length > 1) {
|
|
const isShortest = paths.every((other: any) => p.distance <= other.distance);
|
|
if (isShortest) tags.push('SHORTEST');
|
|
if (index > 0 && !isShortest) tags.push('ALTERNATIVE');
|
|
}
|
|
|
|
return {
|
|
routeName: routeName ? `عبر ${routeName}` : `المسار ${index + 1}`,
|
|
tags,
|
|
distance: p.distance,
|
|
duration: pDuration,
|
|
points: p.points,
|
|
bbox: p.bbox,
|
|
instructions: steps ? enrichedInstructions : undefined,
|
|
elevationSummary: slopeSummary
|
|
};
|
|
});
|
|
|
|
const mainRoute = processedPaths[0];
|
|
const altRoutes = processedPaths.slice(1);
|
|
|
|
return {
|
|
routeName: mainRoute.routeName,
|
|
tags: mainRoute.tags,
|
|
distance: mainRoute.distance,
|
|
duration: mainRoute.duration,
|
|
trafficFactor: Math.round(trafficFactor * 100) / 100,
|
|
startName,
|
|
endName,
|
|
points: mainRoute.points,
|
|
bbox: mainRoute.bbox,
|
|
instructions: mainRoute.instructions,
|
|
elevationSummary: mainRoute.elevationSummary,
|
|
alternatives: altRoutes
|
|
};
|
|
} catch (error) {
|
|
const msg = error.response ? `GH Error: ${JSON.stringify(error.response.data)}` : `DNS/Connection Error: ${error.message}`;
|
|
console.error('CRITICAL ROUTING FAILURE:', msg);
|
|
throw new HttpException(`Routing Failure: ${msg}`, HttpStatus.BAD_GATEWAY);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enriches turn-by-turn routing instructions with steep slope / incline warnings
|
|
*/
|
|
private enrichInstructionsWithSlopeAnalysis(instructions: any[], coords: [number, number][]) {
|
|
if (!instructions || !coords || coords.length < 2) {
|
|
return {
|
|
enrichedInstructions: instructions,
|
|
slopeSummary: { totalAscentMeters: 0, totalDescentMeters: 0, maxInclinePercent: 0, maxDeclinePercent: 0, steepWarningsCount: 0, steepWarnings: [] }
|
|
};
|
|
}
|
|
|
|
let totalAscent = 0;
|
|
let totalDescent = 0;
|
|
let maxInclinePercent = 0;
|
|
let maxDeclinePercent = 0;
|
|
const steepWarnings: any[] = [];
|
|
|
|
const enrichedInstructions = instructions.map((inst: any) => {
|
|
const interval = inst.interval || [0, 0];
|
|
const startIdx = Math.min(interval[0], coords.length - 1);
|
|
const endIdx = Math.min(interval[1], coords.length - 1);
|
|
|
|
if (startIdx < endIdx) {
|
|
const startCoord = coords[startIdx]; // [lng, lat]
|
|
const endCoord = coords[endIdx];
|
|
|
|
const startElev = this.estimateElevation(startCoord[1], startCoord[0]);
|
|
const endElev = this.estimateElevation(endCoord[1], endCoord[0]);
|
|
const elevDiff = endElev - startElev;
|
|
const dist = inst.distance || this.haversineDistance(startCoord[1], startCoord[0], endCoord[1], endCoord[0]) || 1;
|
|
|
|
if (elevDiff > 0) totalAscent += elevDiff;
|
|
else totalDescent += Math.abs(elevDiff);
|
|
|
|
const slopePercent = Math.round((elevDiff / Math.max(20, dist)) * 100);
|
|
|
|
if (slopePercent > maxInclinePercent) maxInclinePercent = slopePercent;
|
|
if (slopePercent < maxDeclinePercent) maxDeclinePercent = slopePercent;
|
|
|
|
let warning_ar: string | null = null;
|
|
|
|
if (slopePercent >= 9) {
|
|
warning_ar = `⚠️ تنبيه: صعود حاد (+${slopePercent}%)`;
|
|
steepWarnings.push({ text: inst.text, slopePercent, type: 'incline', street: inst.street_name });
|
|
} else if (slopePercent <= -9) {
|
|
warning_ar = `⚠️ تنبيه: منحدر شديد (${slopePercent}%) - خفف السرعة`;
|
|
steepWarnings.push({ text: inst.text, slopePercent, type: 'decline', street: inst.street_name });
|
|
}
|
|
|
|
return {
|
|
...inst,
|
|
slopePercent,
|
|
elevationChangeMeters: Math.round(elevDiff),
|
|
slopeWarning: warning_ar || undefined,
|
|
text: warning_ar ? `${inst.text} (${warning_ar})` : inst.text
|
|
};
|
|
}
|
|
|
|
return inst;
|
|
});
|
|
|
|
return {
|
|
enrichedInstructions,
|
|
slopeSummary: {
|
|
totalAscentMeters: Math.round(totalAscent),
|
|
totalDescentMeters: Math.round(totalDescent),
|
|
maxInclinePercent: Math.round(maxInclinePercent),
|
|
maxDeclinePercent: Math.round(maxDeclinePercent),
|
|
steepWarningsCount: steepWarnings.length,
|
|
steepWarnings
|
|
}
|
|
};
|
|
}
|
|
|
|
private estimateElevation(lat: number, lng: number): number {
|
|
if (lng < 35.6 && lat < 32.2 && lat > 31.0) {
|
|
return -400 + Math.abs(lng - 35.5) * 3000;
|
|
}
|
|
if (lat >= 32.1 && lng < 36.0) {
|
|
return 850 + Math.sin(lat * 50) * 250 + Math.cos(lng * 40) * 150;
|
|
}
|
|
if (lat >= 31.8 && lat < 32.1 && lng >= 35.8 && lng < 36.2) {
|
|
return 900 + Math.sin((lat - 31.95) * 100) * 120 + Math.cos((lng - 35.9) * 100) * 100;
|
|
}
|
|
if (lat < 31.5 && lat > 30.0 && lng < 35.7) {
|
|
return 1100 + Math.sin(lat * 30) * 350;
|
|
}
|
|
return 650 + (lng - 36.0) * 30;
|
|
}
|
|
|
|
private haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
|
const R = 6371000;
|
|
const dLat = (lat2 - lat1) * (Math.PI / 180);
|
|
const dLon = (lon2 - lon1) * (Math.PI / 180);
|
|
const a =
|
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
Math.cos(lat1 * (Math.PI / 180)) * Math.cos(lat2 * (Math.PI / 180)) *
|
|
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
/**
|
|
* Extract the most significant street name from instructions to name the route.
|
|
*/
|
|
private getRouteName(instructions: any[]): string | null {
|
|
if (!instructions || instructions.length === 0) return null;
|
|
|
|
const streetDistances: Record<string, number> = {};
|
|
for (const inst of instructions) {
|
|
if (inst.street_name && inst.street_name.trim() !== '') {
|
|
streetDistances[inst.street_name] = (streetDistances[inst.street_name] || 0) + (inst.distance || 0);
|
|
}
|
|
}
|
|
|
|
let longestStreet: string | null = null;
|
|
let maxDist = 0;
|
|
|
|
for (const [street, dist] of Object.entries(streetDistances)) {
|
|
if (dist > maxDist) {
|
|
maxDist = dist;
|
|
longestStreet = street;
|
|
}
|
|
}
|
|
|
|
return longestStreet;
|
|
}
|
|
|
|
/**
|
|
* 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]);
|
|
}
|
|
return points;
|
|
}
|
|
|
|
async getMapConfig() {
|
|
return {
|
|
center: [31.95, 35.91],
|
|
zoom: 12,
|
|
tileServerUrl: this.configService.get('TILE_SERVER_URL', 'http://localhost:3001'),
|
|
};
|
|
}
|
|
}
|