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, 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_). 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 = {}; 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'), }; } }