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 { 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);
|
||||
|
||||
Reference in New Issue
Block a user