feat(tactical): implement real road-network ray-casting routing for Isochrone reachability
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios from 'axios';
|
||||||
import { RedisService } from '../common/redis.service';
|
import { RedisService } from '../common/redis.service';
|
||||||
import { ArtilleryMissionRequestDto, TacticalSymbolDto } from './dto/tactical.dto';
|
import { ArtilleryMissionRequestDto, TacticalSymbolDto } from './dto/tactical.dto';
|
||||||
|
|
||||||
@@ -66,8 +68,14 @@ export interface LineOfSightResponse {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class TacticalService {
|
export class TacticalService {
|
||||||
private readonly logger = new Logger(TacticalService.name);
|
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
|
* Tactical Line of Sight (LOS) & Intervisibility Engine
|
||||||
@@ -445,42 +453,157 @@ export class TacticalService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate Isochrone / Response Time Reachability Polygons
|
* Calculate Isochrone / Response Time Reachability Polygons
|
||||||
* حساب مضلعات زمن الوصول لعمليات الدفاع المدني والأمن والإسعاف
|
* حساب مضلعات نطاق زمن الوصول الفعلي بالاعتماد على شبكة الطرق الحقيقية
|
||||||
*/
|
*/
|
||||||
async calculateIsochrone(lat: number, lng: number, timeBuckets: number[] = [300, 600, 900], vehicleProfile: string = 'emergency') {
|
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 key = `tactical:isochrone:${lat.toFixed(4)}:${lng.toFixed(4)}:${timeBuckets.join('-')}:${vehicleProfile}`;
|
||||||
const cached = await this.redisService.get<any>(key);
|
const cached = await this.redisService.get<any>(key);
|
||||||
if (cached) return cached;
|
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 tiers = timeBuckets.map((seconds, idx) => {
|
||||||
const minutes = Math.round(seconds / 60);
|
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][] = [];
|
const ringCoords: [number, number][] = [];
|
||||||
|
|
||||||
for (let i = 0; i < numPoints; i++) {
|
for (const ray of rayResults) {
|
||||||
const angle = (i * 2 * Math.PI) / numPoints;
|
// Distance reachable within 'seconds' along this road corridor
|
||||||
const northSouthFactor = 1 + 0.35 * Math.pow(Math.cos(angle), 2);
|
const reachableMeters = Math.min(ray.maxPathDist, ray.effectiveSpeed * seconds);
|
||||||
const r = baseRadiusMeters * northSouthFactor * (0.88 + Math.sin(angle * 3) * 0.12);
|
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);
|
||||||
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);
|
|
||||||
|
|
||||||
ringCoords.push([Number((lng + dLng).toFixed(6)), Number((lat + dLat).toFixed(6))]);
|
ringCoords.push([Number((lng + dLng).toFixed(6)), Number((lat + dLat).toFixed(6))]);
|
||||||
}
|
}
|
||||||
|
// Close polygon ring
|
||||||
ringCoords.push(ringCoords[0]);
|
ringCoords.push(ringCoords[0]);
|
||||||
|
|
||||||
const colors = ['#22c55e', '#eab308', '#ef4444', '#8b5cf6'];
|
|
||||||
const tierColor = colors[idx % colors.length];
|
const tierColor = colors[idx % colors.length];
|
||||||
|
const areaKm2 = this.calculatePolygonAreaKm2(ringCoords);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
timeSeconds: seconds,
|
timeSeconds: seconds,
|
||||||
timeMinutes: minutes,
|
timeMinutes: minutes,
|
||||||
label: `${minutes} دقائق استجابة`,
|
label: `${minutes} دقائق استجابة`,
|
||||||
color: tierColor,
|
color: tierColor,
|
||||||
estimatedAreaKm2: Math.round(Math.PI * Math.pow(baseRadiusMeters / 1000, 2) * 10) / 10,
|
estimatedAreaKm2: areaKm2,
|
||||||
polygon: {
|
polygon: {
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -512,6 +635,22 @@ export class TacticalService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Utility GIS Math ---
|
// --- 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 {
|
private haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
const R = 6371000;
|
const R = 6371000;
|
||||||
const dLat = (lat2 - lat1) * (Math.PI / 180);
|
const dLat = (lat2 - lat1) * (Math.PI / 180);
|
||||||
|
|||||||
Reference in New Issue
Block a user