From 9088af053432930eb3ef05287a5642d0339e8fee Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Tue, 18 Aug 2026 15:31:56 +0300 Subject: [PATCH] feat(tactical): implement real road-network ray-casting routing for Isochrone reachability --- apps/api/src/tactical/tactical.service.ts | 169 ++++++++++++++++++++-- 1 file changed, 154 insertions(+), 15 deletions(-) diff --git a/apps/api/src/tactical/tactical.service.ts b/apps/api/src/tactical/tactical.service.ts index 07650e3..1be6a18 100644 --- a/apps/api/src/tactical/tactical.service.ts +++ b/apps/api/src/tactical/tactical.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; import { RedisService } from '../common/redis.service'; import { ArtilleryMissionRequestDto, TacticalSymbolDto } from './dto/tactical.dto'; @@ -66,8 +68,14 @@ export interface LineOfSightResponse { @Injectable() export class TacticalService { private readonly logger = new Logger(TacticalService.name); + private readonly graphHopperUrl: string; - constructor(private readonly redisService: RedisService) {} + constructor( + private readonly redisService: RedisService, + private readonly configService: ConfigService, + ) { + this.graphHopperUrl = this.configService.get('GRAPH_HOPPER_URL', 'http://routing:8080'); + } /** * Tactical Line of Sight (LOS) & Intervisibility Engine @@ -445,42 +453,157 @@ export class TacticalService { /** * Calculate Isochrone / Response Time Reachability Polygons - * حساب مضلعات زمن الوصول لعمليات الدفاع المدني والأمن والإسعاف + * حساب مضلعات نطاق زمن الوصول الفعلي بالاعتماد على شبكة الطرق الحقيقية */ async calculateIsochrone(lat: number, lng: number, timeBuckets: number[] = [300, 600, 900], vehicleProfile: string = 'emergency') { const key = `tactical:isochrone:${lat.toFixed(4)}:${lng.toFixed(4)}:${timeBuckets.join('-')}:${vehicleProfile}`; const cached = await this.redisService.get(key); if (cached) return cached; + // Profile Multipliers for tactical speeds + // emergency: 1.25x (sirens, traffic yielding), patrol: 1.0x, heavy: 0.75x (fire trucks, heavy gear) + const profileSpeedFactor = vehicleProfile === 'emergency' ? 1.25 : vehicleProfile === 'heavy' ? 0.75 : 1.0; + const baseSpeedKmh = vehicleProfile === 'emergency' ? 60 : vehicleProfile === 'heavy' ? 38 : 50; + + // 1. Try GraphHopper Native Isochrone Endpoint if available + try { + const maxSeconds = Math.max(...timeBuckets); + const ghIsoRes = await axios.get(`${this.graphHopperUrl}/isochrone`, { + params: { + point: `${lat},${lng}`, + time_limit: Math.round(maxSeconds / profileSpeedFactor), + buckets: timeBuckets.length, + profile: 'car', + }, + timeout: 2500, + }); + + if (ghIsoRes.data && ghIsoRes.data.polygons && ghIsoRes.data.polygons.length > 0) { + const colors = ['#22c55e', '#eab308', '#ef4444', '#8b5cf6']; + const tiers = ghIsoRes.data.polygons.map((poly: any, idx: number) => { + const seconds = timeBuckets[idx] || (idx + 1) * 300; + const minutes = Math.round(seconds / 60); + const color = colors[idx % colors.length]; + const coords = poly.geometry.coordinates; + const areaKm2 = this.calculatePolygonAreaKm2(coords[0] || []); + + return { + timeSeconds: seconds, + timeMinutes: minutes, + label: `${minutes} دقائق استجابة`, + color, + estimatedAreaKm2: areaKm2, + polygon: { + type: 'Feature', + properties: { + timeMinutes: minutes, + timeSeconds: seconds, + color, + label: `${minutes} دقائق` + }, + geometry: poly.geometry + } + }; + }); + + const result = { + center: { lat, lng }, + vehicleProfile, + tiers, + featureCollection: { + type: 'FeatureCollection', + features: tiers.map((t: any) => t.polygon) + } + }; + + await this.redisService.set(key, JSON.stringify(result), 3600); + return result; + } + } catch (err: any) { + // If native /isochrone is not configured, fall through to high-speed radial road routing + this.logger.debug(`GraphHopper native isochrone probe unavailable (${err?.message}), using high-speed radial road network engine`); + } + + // 2. High-Speed Radial Road Network Ray-Casting (24 Directional Azimuth Probes) + const numRays = 24; + const maxSeconds = Math.max(...timeBuckets); + const maxDistMeters = (baseSpeedKmh * 1000 / 3600) * maxSeconds * profileSpeedFactor * 1.35; + + interface RayResult { + angle: number; + effectiveSpeed: number; + maxPathDist: number; + success: boolean; + } + + // Concurrently probe road networks across all 24 radial directions + const rayPromises: Promise[] = []; + for (let i = 0; i < numRays; i++) { + const angle = (i * 2 * Math.PI) / numRays; + const dLat = (maxDistMeters / 6371000) * (180 / Math.PI) * Math.cos(angle); + const dLng = (maxDistMeters / (6371000 * Math.cos((lat * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(angle); + const probeLat = lat + dLat; + const probeLng = lng + dLng; + + rayPromises.push( + (async () => { + try { + // Lightweight internal route query: no instructions, no points array, no alternative routes + const routeRes = await axios.post( + `${this.graphHopperUrl}/route`, + { + points: [[lng, lat], [probeLng, probeLat]], + profile: 'car', + calc_points: false, + instructions: false, + points_encoded: true, + }, + { timeout: 1500 } + ); + + if (routeRes.data && routeRes.data.paths && routeRes.data.paths.length > 0) { + const path = routeRes.data.paths[0]; + const pathDistance = path.distance; // meters + const pathTimeSec = (path.time / 1000) / profileSpeedFactor; // effective seconds + const effectiveSpeed = pathTimeSec > 0 ? (pathDistance / pathTimeSec) : (baseSpeedKmh / 3.6); + return { angle, effectiveSpeed, maxPathDist: pathDistance, success: true }; + } + } catch (e) { + // Fallback for this individual angle + } + // Default road approximation along bearing + return { angle, effectiveSpeed: (baseSpeedKmh * 0.7) / 3.6, maxPathDist: maxDistMeters, success: false }; + })() + ); + } + + const rayResults = await Promise.all(rayPromises); + + const colors = ['#22c55e', '#eab308', '#ef4444', '#8b5cf6']; const tiers = timeBuckets.map((seconds, idx) => { const minutes = Math.round(seconds / 60); - const avgSpeedKmh = vehicleProfile === 'emergency' ? 55 : 40; - const baseRadiusMeters = (avgSpeedKmh * 1000 / 3600) * seconds * 0.72; - - const numPoints = 32; const ringCoords: [number, number][] = []; - for (let i = 0; i < numPoints; i++) { - const angle = (i * 2 * Math.PI) / numPoints; - const northSouthFactor = 1 + 0.35 * Math.pow(Math.cos(angle), 2); - const r = baseRadiusMeters * northSouthFactor * (0.88 + Math.sin(angle * 3) * 0.12); - - const dLat = (r / 6371000) * (180 / Math.PI) * Math.cos(angle); - const dLng = (r / (6371000 * Math.cos((lat * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(angle); + for (const ray of rayResults) { + // Distance reachable within 'seconds' along this road corridor + const reachableMeters = Math.min(ray.maxPathDist, ray.effectiveSpeed * seconds); + const dLat = (reachableMeters / 6371000) * (180 / Math.PI) * Math.cos(ray.angle); + const dLng = (reachableMeters / (6371000 * Math.cos((lat * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(ray.angle); ringCoords.push([Number((lng + dLng).toFixed(6)), Number((lat + dLat).toFixed(6))]); } + // Close polygon ring ringCoords.push(ringCoords[0]); - const colors = ['#22c55e', '#eab308', '#ef4444', '#8b5cf6']; const tierColor = colors[idx % colors.length]; + const areaKm2 = this.calculatePolygonAreaKm2(ringCoords); return { timeSeconds: seconds, timeMinutes: minutes, label: `${minutes} دقائق استجابة`, color: tierColor, - estimatedAreaKm2: Math.round(Math.PI * Math.pow(baseRadiusMeters / 1000, 2) * 10) / 10, + estimatedAreaKm2: areaKm2, polygon: { type: 'Feature', properties: { @@ -512,6 +635,22 @@ export class TacticalService { } // --- Utility GIS Math --- + private calculatePolygonAreaKm2(coords: [number, number][]): number { + if (!coords || coords.length < 3) return 0; + let total = 0; + const R = 6371; // Earth radius in km + for (let i = 0; i < coords.length - 1; i++) { + const p1 = coords[i]; + const p2 = coords[i + 1]; + const radP1Lng = (p1[0] * Math.PI) / 180; + const radP1Lat = (p1[1] * Math.PI) / 180; + const radP2Lng = (p2[0] * Math.PI) / 180; + const radP2Lat = (p2[1] * Math.PI) / 180; + total += (radP2Lng - radP1Lng) * (2 + Math.sin(radP1Lat) + Math.sin(radP2Lat)); + } + const area = Math.abs((total * R * R) / 2); + return Math.round(area * 10) / 10; + } private haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 6371000; const dLat = (lat2 - lat1) * (Math.PI / 180);