806 lines
35 KiB
TypeScript
806 lines
35 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 { 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<RoadSegmentStat>,
|
|
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_<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'] = 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<string, number> = {};
|
|
|
|
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'),
|
|
};
|
|
}
|
|
}
|