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 { FuelPricingService } from './fuel-pricing.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 fuelPricingService: FuelPricingService, 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 ghProfile = ['car', 'foot', 'bike'].includes(profile) ? profile : 'car'; const payload: any = { points: ghPoints, profile: ghProfile, locale: locale === 'en' ? 'ar' : locale, // Default to Arabic if not specified or fallback calc_points: true, points_encoded: false, // JSON arrays for reliable 3D elevation (SRTM) elevation: true, // ← SRTM: طلب إحداثيات 3D [lng, lat, elevation] + ascend/descend 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'] = 3; // Return main route + up to 2 distinct alternatives payload['alternative_route.max_weight_factor'] = 1.8; payload['alternative_route.max_share_factor'] = 0.75; } 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 coords3D = this.extractCoords3D(route.points); const coords: [number, number][] = coords3D.map(c => [c[0], c[1]]); // 2D for traffic grid const trafficFactor = this.trafficGrid.getTrafficFactor(coords, hr, dow); const baseDuration = route.time / 1000; const trafficAwareDuration = baseDuration * trafficFactor; // 1. Identify the primary route's major artery/street const mainInstructions = paths[0]?.instructions || []; const mainStreetName = this.getRouteName(mainInstructions); // Process all paths to add metadata (Real Street Names, Tags, Elevation & Slope Warnings, Eco/Fuel metrics) const processedPaths = paths.map((p: any, index: number) => { const pCoords3D = this.extractCoords3D(p.points); const pCoords: [number, number][] = pCoords3D.map(c => [c[0], c[1]]); // 2D for traffic grid const pTrafficFactor = this.trafficGrid.getTrafficFactor(pCoords, hr, dow); const pBaseDuration = p.time / 1000; const pDuration = Math.round(pBaseDuration * pTrafficFactor); // For alternative routes, find the distinctive street that differentiates it from the main route let routeStreet = index === 0 ? mainStreetName : this.getRouteName(p.instructions, mainStreetName || undefined); // Fallback to any valid street name if differential lookup didn't find one if (!routeStreet) { routeStreet = this.getRouteName(p.instructions); } // Format route name with Arabic prefix let finalRouteName = ''; if (routeStreet) { const cleanStreet = routeStreet.replace(/^عبر\s+/, '').trim(); finalRouteName = `عبر ${cleanStreet}`; } else { finalRouteName = index === 0 ? 'المسار المباشر الأسرع' : 'مسار بديل عبر الطرق الموازية'; } // Analyze slopes using REAL 3D elevation from SRTM satellite data const { enrichedInstructions, slopeSummary } = this.enrichInstructionsWithSlopeAnalysis(p.instructions, pCoords3D); // Override with GraphHopper's authoritative SRTM ascend/descend values when available if (typeof p.ascend === 'number') slopeSummary.totalAscentMeters = Math.round(p.ascend); if (typeof p.descend === 'number') slopeSummary.totalDescentMeters = Math.round(p.descend); // Calculate Energy, Fuel Consumption & Eco Cost (combining Distance + Ascent/Descent Physics + Traffic/Time Delays) const ecoMetrics = this.calculateEcoAndFuelMetrics( p.distance, slopeSummary.totalAscentMeters, slopeSummary.totalDescentMeters, slopeSummary.maxInclinePercent, slopeSummary.maxDeclinePercent, pBaseDuration, pDuration, pTrafficFactor, profile ); // 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'); } if (ecoMetrics.ecoScore >= 85) { tags.push('ECO_FRIENDLY'); } return { routeName: finalRouteName, tags, distance: p.distance, duration: pDuration, // Return standard Google-encoded Polyline string points: this.encodePolyline(pCoords3D.map(c => [c[0], c[1]])), bbox: p.bbox, instructions: steps ? enrichedInstructions : undefined, elevationSummary: slopeSummary, ecoMetrics }; }); 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, ecoMetrics: mainRoute.ecoMetrics, alternatives: altRoutes }; } catch (error) { if (error instanceof HttpException) { throw error; } const ghData = error.response?.data; const rawMsg = ghData?.message || error.message || ''; // 1. Point Not Found (Outside coverage area or in water/remote off-road) if (rawMsg.includes('Cannot find point') || rawMsg.includes('PointNotFoundException')) { const pointMatch = rawMsg.match(/Cannot find point\s+(\d+):\s*([0-9.,]+)/i); const pointIndex = pointMatch ? (Number(pointMatch[1]) + 1) : ''; const pointCoords = pointMatch ? `[${pointMatch[2]}]` : ''; throw new HttpException( `تعذر العثور على مسار: النقطة ${pointIndex ? `رقم ${pointIndex}` : ''} ${pointCoords} تقع خارج نطاق خريطة الأردن المعتمدة أو في منطقة حدودية/وعرة بعيدة عن شبكة الطرق المعبدة.`, HttpStatus.UNPROCESSABLE_ENTITY ); } // 2. No connection between locations (e.g. islands, separated networks) if (rawMsg.includes('Connection between locations not found')) { throw new HttpException( 'لا يوجد مسار أو شبكة طرق معبدة متصلة تربط بين نقطة الانطلاق والوجهة المحددة.', HttpStatus.UNPROCESSABLE_ENTITY ); } 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 calibrated slope / incline warnings. * Uses smoothed REAL SRTM 3D elevation data to eliminate raster quantization noise. */ private enrichInstructionsWithSlopeAnalysis(instructions: any[], rawCoords3D: [number, number, number][]) { if (!instructions || !rawCoords3D || rawCoords3D.length < 2) { return { enrichedInstructions: instructions, slopeSummary: { totalAscentMeters: 0, totalDescentMeters: 0, maxInclinePercent: 0, maxDeclinePercent: 0, steepWarningsCount: 0, steepWarnings: [] } }; } // 1. Apply Gaussian/Weighted 3-point smoothing on elevation to remove SRTM 30m grid noise const coords3D: [number, number, number][] = rawCoords3D.map((pt, i, arr) => { if (i === 0 || i === arr.length - 1) return pt; const prev = arr[i - 1][2]; const curr = pt[2]; const next = arr[i + 1][2]; const smoothedEle = (prev + 2 * curr + next) / 4; return [pt[0], pt[1], smoothedEle]; }); 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], coords3D.length - 1); const endIdx = Math.min(interval[1], coords3D.length - 1); // Localize text into 100% fluent Arabic const localizedBaseText = this.localizeInstructionToArabic(inst.text); if (startIdx < endIdx) { // Walk segments within this instruction with noise threshold (>= 0.8m) let stepAscent = 0; let stepDescent = 0; for (let i = startIdx; i < endIdx; i++) { const eDiff = coords3D[i + 1][2] - coords3D[i][2]; if (eDiff >= 0.8) stepAscent += eDiff; else if (eDiff <= -0.8) stepDescent += Math.abs(eDiff); } totalAscent += stepAscent; totalDescent += stepDescent; // Net elevation change for this instruction const netElevDiff = coords3D[endIdx][2] - coords3D[startIdx][2]; const dist = Math.max(30, inst.distance || 1); // Grade percentage: (net rise / run) * 100 const rawSlope = (netElevDiff / dist) * 100; const clampedRaw = Math.max(-16, Math.min(16, Math.round(rawSlope))); // Dampen slope severity by 3% as requested (e.g. 13% -> 10%, -13% -> -10%) let slopePercent = 0; if (clampedRaw > 0) { slopePercent = Math.max(0, clampedRaw - 3); } else if (clampedRaw < 0) { slopePercent = Math.min(0, clampedRaw + 3); } if (slopePercent > maxInclinePercent) maxInclinePercent = slopePercent; if (slopePercent < maxDeclinePercent) maxDeclinePercent = slopePercent; // Civil road standard: warnings apply to meaningful, sustained grades // (Distance >= 100m OR significant vertical change >= 12m) const isSustainedSegment = dist >= 100 || Math.abs(netElevDiff) >= 12; let warning_ar: string | null = null; if (isSustainedSegment) { if (slopePercent >= 8) { warning_ar = `⚠️ تنبيه: صعود حاد (+${slopePercent}%)`; steepWarnings.push({ text: localizedBaseText, slopePercent, type: 'incline', street: inst.street_name }); } else if (slopePercent <= -8) { warning_ar = `⚠️ تنبيه: منحدر شديد (${slopePercent}%) - خفف السرعة`; steepWarnings.push({ text: localizedBaseText, slopePercent, type: 'decline', street: inst.street_name }); } } return { ...inst, slopePercent, elevationChangeMeters: Math.round(netElevDiff), slopeWarning: warning_ar || undefined, text: warning_ar ? `${localizedBaseText} (${warning_ar})` : localizedBaseText }; } return { ...inst, text: localizedBaseText }; }); return { enrichedInstructions, slopeSummary: { totalAscentMeters: Math.round(totalAscent), totalDescentMeters: Math.round(totalDescent), maxInclinePercent: Math.round(maxInclinePercent), maxDeclinePercent: Math.round(maxDeclinePercent), steepWarningsCount: steepWarnings.length, steepWarnings } }; } /** * Calculates comprehensive fuel, energy, and eco-cost metrics combining distance, elevation topography, and traffic delays. */ private calculateEcoAndFuelMetrics( distanceMeters: number, totalAscentMeters: number, totalDescentMeters: number, maxInclinePercent: number, maxDeclinePercent: number, baseDurationSeconds: number, actualDurationSeconds: number, trafficFactor: number, profile: string = 'car' ) { const distKm = distanceMeters / 1000; // 1. Base Cruising Fuel: ~0.070 L/km for standard passenger car const baseFuelRateLPerKm = profile === 'truck' ? 0.28 : (profile === 'bike' || profile === 'foot') ? 0.0 : 0.070; const baseGasolineLiters = distKm * baseFuelRateLPerKm; // 2. Gravity Work Penalty on Ascent: +0.16 Liters per 100m vertical ascent const ascentFuelPenaltyLiters = (totalAscentMeters / 100) * (profile === 'truck' ? 0.45 : 0.16); // 3. Descent Savings (Gravity Assist / Engine Braking): -0.04 Liters per 100m descent const descentFuelSavingLiters = (totalDescentMeters / 100) * (profile === 'truck' ? 0.10 : 0.04); // 4. Traffic & Congestion Delay Fuel (Idling + Stop-and-Go Re-acceleration): const delayHours = Math.max(0, actualDurationSeconds - baseDurationSeconds) / 3600; const congestionFactorBonus = trafficFactor > 1.15 ? (trafficFactor - 1.0) * 0.4 : 0.0; const trafficFuelLiters = (delayHours * 1.1) + (baseGasolineLiters * congestionFactorBonus); // Net Gasoline Consumption const netGasolineLiters = Math.max(0.05, baseGasolineLiters + ascentFuelPenaltyLiters - descentFuelSavingLiters + trafficFuelLiters); // Pricing in Jordan (Live monthly pricing updated via FuelPricingService & Gemini AI) const livePrices = this.fuelPricingService.getPrices(); const fuelPricePerLiter = profile === 'truck' ? livePrices.diesel : livePrices.gasoline90; const estimatedCostJOD = netGasolineLiters * fuelPricePerLiter; // 5. EV Energy Model (Electric Vehicles): const baseEvKWh = distKm * 0.15; const ascentEvKWh = (totalAscentMeters / 100) * 0.38; const regenEvKWh = (totalDescentMeters / 100) * 0.26; const trafficEvKWh = delayHours * 1.5; // HVAC & auxiliary electronics in standstill const netEvKWh = Math.max(0.1, baseEvKWh + ascentEvKWh - regenEvKWh + trafficEvKWh); const estimatedEvCostJOD = netEvKWh * (livePrices.evKWh || 0.120); // Carbon Footprint: 2,310g CO2 per liter of gasoline const co2Grams = Math.round(netGasolineLiters * 2310); // 6. Net Elevation Differential (هل المسار صاعد أم هابط؟) const isPredominantlyDescent = totalDescentMeters > (totalAscentMeters * 1.3); const isPredominantlyAscent = totalAscentMeters > (totalDescentMeters * 1.3); // 7. Terrain Difficulty & Mechanical Guidance let terrainDifficultyArabic = 'طريق مستوٍ مريح'; let mechanicalAdviceArabic = 'القيادة في نطاق السرعة الطبيعي'; if (isPredominantlyDescent) { if (Math.abs(maxDeclinePercent) >= 8) { terrainDifficultyArabic = 'منحدر جبلي هابط (نزول حاد)'; mechanicalAdviceArabic = '⚠️ استخدام الغيار المنخفض (Engine Braking) لتخفيف العبء على الفرامل وتجنب ارتفاع حرارتها'; } else { terrainDifficultyArabic = 'طريق منحدر خفيف (هبوط سلس)'; mechanicalAdviceArabic = 'مسير هابط موفر للوقود مع شحن متجدد لبطارية الـ EV'; } } else if (isPredominantlyAscent) { if (maxInclinePercent >= 8) { terrainDifficultyArabic = 'طريق صاعد جبلي (عقبة صعود حادة)'; mechanicalAdviceArabic = 'يتطلب عزم محرك إضافي واستخدام الغيارات المناسبة لمنع إجهاد المحرك'; } else { terrainDifficultyArabic = 'طريق صاعد معتدل'; mechanicalAdviceArabic = 'صعود تدريجي بجهد محرك معتدل'; } } else { if (maxInclinePercent >= 8 || Math.abs(maxDeclinePercent) >= 8) { terrainDifficultyArabic = 'تضاريس جبلية وعرة (صعود وهبوط متكرر)'; mechanicalAdviceArabic = 'تدرج مستمر بين عزم الصعود وكبح النزول'; } else if (maxInclinePercent >= 4 || Math.abs(maxDeclinePercent) >= 4) { terrainDifficultyArabic = 'تضاريس متموجة معتدلة'; mechanicalAdviceArabic = 'قيادة سلسة ومريحة للمركبة'; } } // 8. Eco Score & Badge Calculation const idealFlatFuel = distKm * baseFuelRateLPerKm; const consumptionRatio = idealFlatFuel > 0 ? (netGasolineLiters / idealFlatFuel) : 1.0; let rawScore = Math.round(100 - (consumptionRatio - 1.0) * 50); if (isPredominantlyDescent) rawScore = Math.max(rawScore, 92); // Descent is naturally fuel-efficient const ecoScore = Math.max(15, Math.min(100, rawScore)); let ecoBadgeArabic = 'مسار قياسي متوازن'; if (isPredominantlyDescent && trafficFactor < 1.2) { ecoBadgeArabic = 'مسار موفر للوقود بالهبوط 🌿 (شحن للـ EV)'; } else if (ecoScore >= 85 && trafficFactor < 1.15) { ecoBadgeArabic = 'مسار اقتصادي منخفض الاستهلاك 🌿'; } else if (trafficFactor >= 1.35) { ecoBadgeArabic = 'مسار عالي الاستهلاك (بسبب الازدحام والتوقف) ⏳'; } else if (isPredominantlyAscent && maxInclinePercent >= 8) { ecoBadgeArabic = 'مسار عالي الاستهلاك في الصعود ⚠️'; } return { estimatedGasolineLiters: Math.round(netGasolineLiters * 100) / 100, estimatedCostJOD: Math.round(estimatedCostJOD * 100) / 100, estimatedEvKWh: Math.round(netEvKWh * 100) / 100, estimatedEvCostJOD: Math.round(estimatedEvCostJOD * 100) / 100, energyRecoveredEvKWh: Math.round(regenEvKWh * 100) / 100, co2Kg: Math.round((co2Grams / 1000) * 100) / 100, trafficDelayMinutes: Math.round(delayHours * 60), ecoScore, ecoBadge: ecoBadgeArabic, terrainDifficulty: terrainDifficultyArabic, mechanicalAdvice: mechanicalAdviceArabic, pricingBulletin: { gasoline90JOD: livePrices.gasoline90, dieselJOD: livePrices.diesel, effectiveMonth: livePrices.effectiveMonth, source: livePrices.source, } }; } /** * Translates any English phrases from routing engines (GraphHopper) into natural, fluent Arabic. */ private localizeInstructionToArabic(rawText: string): string { if (!rawText) return ''; let text = rawText.trim(); // 1. English Directional & Maneuver Replacements const phrases: [RegExp, string][] = [ // Continuations [/^Continue onto\s+/i, 'تابع السير في '], [/^Continue on\s+/i, 'تابع السير في '], [/^Continue straight\s+/i, 'تابع السير بشكل مستقيم في '], [/^Continue straight$/i, 'تابع السير للأمام مباشرة'], [/^Continue\s*$/i, 'تابع السير للأمام'], // Roundabouts [/في الدوران\s*،\s*أتخذ مخرج\s+(\d+)\s+من خلال/i, 'عند الدوار، اسلك المخرج $1 عبر'], [/في الدوران\s*،\s*اتخذ مخرج\s+(\d+)\s+من خلال/i, 'عند الدوار، اسلك المخرج $1 عبر'], [/في الدوران\s*،\s*أتخذ مخرج\s+(\d+)/i, 'عند الدوار، اسلك المخرج $1'], [/في الدوران\s*،\s*اتخذ مخرج\s+(\d+)/i, 'عند الدوار، اسلك المخرج $1'], [/At roundabout, take exit\s+(\d+)\s+onto/i, 'عند الدوار، اسلك المخرج $1 عبر'], [/At roundabout, take exit\s+(\d+)/i, 'عند الدوار، اسلك المخرج $1'], [/In roundabout, take exit\s+(\d+)/i, 'عند الدوار، اسلك المخرج $1'], // Turns [/^Turn sharp right onto\s+/i, 'انعطف يميناً بشكل حاد إلى '], [/^Turn slight right onto\s+/i, 'انعطف يميناً بشكل طفيف إلى '], [/^Turn right onto\s+/i, 'اتجه يميناً إلى '], [/^Turn sharp right/i, 'انعطف يميناً بشكل حاد'], [/^Turn slight right/i, 'انعطف يميناً بشكل طفيف'], [/^Turn right/i, 'اتجه يميناً'], [/^Turn sharp left onto\s+/i, 'انعطف يساراً بشكل حاد إلى '], [/^Turn slight left onto\s+/i, 'انعطف يساراً بشكل طفيف إلى '], [/^Turn left onto\s+/i, 'اتجه يساراً إلى '], [/^Turn sharp left/i, 'انعطف يساراً بشكل حاد'], [/^Turn slight left/i, 'انعطف يساراً بشكل طفيف'], [/^Turn left/i, 'اتجه يساراً'], // Keeps [/^Keep right toward\s+/i, 'الزم اليمين باتجاه '], [/^Keep right onto\s+/i, 'الزم اليمين في '], [/^Keep right/i, 'الزم اليمين'], [/^احفظ اليمين toward\s+/i, 'الزم اليمين باتجاه '], [/^احفظ اليمين خلال\s+/i, 'الزم اليمين عبر '], [/^احفظ اليمين/i, 'الزم اليمين'], [/^Keep left toward\s+/i, 'الزم اليسار باتجاه '], [/^Keep left onto\s+/i, 'الزم اليسار في '], [/^Keep left/i, 'الزم اليسار'], [/^احفظ الشمال toward\s+/i, 'الزم اليسار باتجاه '], [/^احفظ الشمال خلال\s+/i, 'الزم اليسار عبر '], [/^احفظ الشمال/i, 'الزم اليسار'], // U-Turns [/^Make a U-turn\s+/i, 'قم بالدوران للخلف (U-turn) عند '], [/^Make a U-turn/i, 'قم بالدوران للخلف'], [/^U-turn/i, 'دوران للخلف'], // Head / Bearings [/^Head north onto\s+/i, 'اتجه شمالاً في '], [/^Head south onto\s+/i, 'اتجه جنوباً في '], [/^Head east onto\s+/i, 'اتجه شرقاً في '], [/^Head west onto\s+/i, 'اتجه غرباً في '], [/^Head northeast onto\s+/i, 'اتجه نحو الشمال الشرقي في '], [/^Head northwest onto\s+/i, 'اتجه نحو الشمال الغربي في '], [/^Head southeast onto\s+/i, 'اتجه نحو الجنوب الشرقي في '], [/^Head southwest onto\s+/i, 'اتجه نحو الجنوب الغربي في '], [/^Head north/i, 'اتجه شمالاً'], [/^Head south/i, 'اتجه جنوباً'], [/^Head east/i, 'اتجه شرقاً'], [/^Head west/i, 'اتجه غرباً'], [/^Head northeast/i, 'اتجه شمال شرق'], [/^Head northwest/i, 'اتجه شمال غرب'], [/^Head southeast/i, 'اتجه جنوب شرق'], [/^Head southwest/i, 'اتجه جنوب غرب'], // Toward / Onto / Through prepositions [/\btoward\b/gi, 'باتجاه'], [/\btowards\b/gi, 'باتجاه'], [/\bonto\b/gi, 'في'], [/\bthrough\b/gi, 'عبر'], // Destinations & Finish [/^Arrive at destination/i, 'الوصول إلى الوجهة'], [/^Reached destination/i, 'تم الوصول إلى الوجهة'], [/^Destination/i, 'الوصول إلى الوجهة'], [/^النهاية/i, 'الوصول إلى الوجهة'] ]; for (const [regex, replacement] of phrases) { text = text.replace(regex, replacement); } // Clean up excessive whitespace text = text.replace(/\s+/g, ' ').trim(); return text; } // estimateElevation REMOVED — replaced by real SRTM satellite data from GraphHopper 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)); } /** * Intelligently extracts the most descriptive, distinctive street or highway name for a route. */ private getRouteName(instructions: any[], excludeStreet?: string): string | null { if (!instructions || instructions.length === 0) return null; const streetDistances: Record = {}; for (const inst of instructions) { let candidate = inst.street_name?.trim(); // If street_name is empty, check highway ref or destination (e.g., Highway 35 / طريق جرش / طريق المطار) if (!candidate || candidate === '') { if (inst.street_destination_ref) { candidate = `طريق ${inst.street_destination_ref}`; } else if (inst.street_ref) { candidate = `طريق ${inst.street_ref}`; } else if (inst.street_destination) { candidate = `طريق ${inst.street_destination}`; } } // If still empty, attempt to extract named street from instruction text if (!candidate || candidate === '') { const match = inst.text?.match(/(?:عبر|في|إلى|خلال|من خلال)\s+(شارع\s+[\u0621-\u064A0-9\s]+|طريق\s+[\u0621-\u064A0-9\s]+|دوار\s+[\u0621-\u064A0-9\s]+|الدوار\s+[\u0621-\u064A0-9\s]+|جسر\s+[\u0621-\u064A0-9\s]+)/i); if (match && match[1]) { candidate = match[1].trim(); } } if (candidate && candidate !== '') { // Remove trailing or leading noise candidate = candidate.replace(/^عبر\s+/, '').trim(); // If we have an exclusion, only add if different if (!excludeStreet || candidate !== excludeStreet.replace(/^عبر\s+/, '').trim()) { streetDistances[candidate] = (streetDistances[candidate] || 0) + (inst.distance || 1); } } } // Pick candidate with longest distance let longestStreet: string | null = null; let maxDist = 0; for (const [street, dist] of Object.entries(streetDistances)) { if (dist > maxDist) { maxDist = dist; longestStreet = street; } } // If no non-excluded street found, fallback to any longest street if (!longestStreet && excludeStreet) { return this.getRouteName(instructions); } return longestStreet; } /** * Extracts 3D coordinates from GraphHopper's JSON points response. * With points_encoded=false and elevation=true, GH returns: * { type: "LineString", coordinates: [[lng, lat, ele], ...] } * Returns: [lng, lat, elevation][] — compatible with all coord consumers. */ private extractCoords3D(points: any): [number, number, number][] { if (!points) return []; // GH returns { type: "LineString", coordinates: [[lng, lat, ele], ...] } const rawCoords = points.coordinates || points; if (!Array.isArray(rawCoords)) return []; return rawCoords.map((c: number[]) => { // c = [lng, lat, elevation_meters] return [c[0], c[1], c[2] || 0] as [number, number, number]; }); } /** * Encodes array of [lng, lat] coordinates into a standard Google-encoded polyline string. */ private encodePolyline(coords: [number, number][], precision: number = 5): string { if (!coords || coords.length === 0) return ''; const factor = Math.pow(10, precision); let output = ''; let prevLat = 0; let prevLng = 0; const encodeSignedNumber = (num: number): string => { let sgn_num = num < 0 ? ~(num << 1) : (num << 1); let encodeString = ''; while (sgn_num >= 0x20) { encodeString += String.fromCharCode((0x20 | (sgn_num & 0x1f)) + 63); sgn_num >>= 5; } encodeString += String.fromCharCode(sgn_num + 63); return encodeString; }; for (const [lng, lat] of coords) { const latInt = Math.round(lat * factor); const lngInt = Math.round(lng * factor); const dLat = latInt - prevLat; const dLng = lngInt - prevLng; prevLat = latInt; prevLng = lngInt; output += encodeSignedNumber(dLat); output += encodeSignedNumber(dLng); } return output; } /** * Legacy 2D polyline decoder — kept for any encoded polyline contexts */ 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); lat += ((result & 1) ? ~(result >> 1) : (result >> 1)); shift = 0; result = 0; do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5; } while (b >= 0x20); lng += ((result & 1) ? ~(result >> 1) : (result >> 1)); 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'), }; } }