feat(tactical): implement real road-network ray-casting routing for Isochrone reachability

This commit is contained in:
Hamza-Ayed
2026-08-18 15:31:56 +03:00
parent fc4be2b8fd
commit 9088af0534
+154 -15
View File
@@ -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<any>(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<RayResult>[] = [];
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);