feat(tactical): integrate cross-country terrain slope, basalt rocks, and off-road mobility analysis into Isochrone engine
This commit is contained in:
@@ -524,20 +524,25 @@ export class TacticalService {
|
||||
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;
|
||||
// 2. High-Precision Hybrid Road-Network + Off-Road Terrain Passability Engine (32 Radial Azimuths)
|
||||
const numRays = 32;
|
||||
const maxSeconds = Math.max(...timeBuckets);
|
||||
const maxDistMeters = (baseSpeedKmh * 1000 / 3600) * maxSeconds * profileSpeedFactor * 1.35;
|
||||
|
||||
interface RayResult {
|
||||
interface RayAnalysis {
|
||||
angle: number;
|
||||
effectiveSpeed: number;
|
||||
maxPathDist: number;
|
||||
success: boolean;
|
||||
roadPathDist: number;
|
||||
roadPathTimeSec: number;
|
||||
effectiveRoadSpeed: number;
|
||||
offRoadSpeedMps: number;
|
||||
avgSlopeDeg: number;
|
||||
isCliffBarrier: boolean;
|
||||
isBasalt: boolean;
|
||||
hasRoad: boolean;
|
||||
}
|
||||
|
||||
// Concurrently probe road networks across all 24 radial directions
|
||||
const rayPromises: Promise<RayResult>[] = [];
|
||||
// Concurrently probe road networks and compute terrain slope/passability across all 32 radial directions
|
||||
const rayPromises: Promise<RayAnalysis>[] = [];
|
||||
for (let i = 0; i < numRays; i++) {
|
||||
const angle = (i * 2 * Math.PI) / numRays;
|
||||
const dLat = (maxDistMeters / 6371000) * (180 / Math.PI) * Math.cos(angle);
|
||||
@@ -547,8 +552,16 @@ export class TacticalService {
|
||||
|
||||
rayPromises.push(
|
||||
(async () => {
|
||||
// A. Compute Off-Road Terrain Passability along this exact radial bearing
|
||||
const terrainInfo = this.computeOffRoadTerrainSpeed(lat, lng, angle, maxDistMeters, vehicleProfile);
|
||||
|
||||
// B. Probe Road Network Connectivity via Internal GraphHopper
|
||||
let hasRoad = false;
|
||||
let roadPathDist = 0;
|
||||
let roadPathTimeSec = 0;
|
||||
let effectiveRoadSpeed = (baseSpeedKmh / 3.6);
|
||||
|
||||
try {
|
||||
// Lightweight internal route query: no instructions, no points array, no alternative routes
|
||||
const routeRes = await axios.post(
|
||||
`${this.graphHopperUrl}/route`,
|
||||
{
|
||||
@@ -563,30 +576,75 @@ export class TacticalService {
|
||||
|
||||
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 };
|
||||
roadPathDist = path.distance; // meters
|
||||
roadPathTimeSec = (path.time / 1000) / profileSpeedFactor; // effective seconds
|
||||
if (roadPathTimeSec > 0 && roadPathDist > 50) {
|
||||
hasRoad = true;
|
||||
effectiveRoadSpeed = roadPathDist / roadPathTimeSec;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback for this individual angle
|
||||
// Road not available in this direction
|
||||
}
|
||||
// Default road approximation along bearing
|
||||
return { angle, effectiveSpeed: (baseSpeedKmh * 0.7) / 3.6, maxPathDist: maxDistMeters, success: false };
|
||||
|
||||
return {
|
||||
angle,
|
||||
roadPathDist,
|
||||
roadPathTimeSec,
|
||||
effectiveRoadSpeed,
|
||||
offRoadSpeedMps: terrainInfo.speedMps,
|
||||
avgSlopeDeg: terrainInfo.avgSlopeDeg,
|
||||
isCliffBarrier: terrainInfo.isCliff,
|
||||
isBasalt: terrainInfo.isBasalt,
|
||||
hasRoad,
|
||||
};
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
const rayResults = await Promise.all(rayPromises);
|
||||
|
||||
// Compute Overall Terrain Passability & Soil Intelligence
|
||||
const avgSlopeOverall = Math.round((rayResults.reduce((acc, r) => acc + r.avgSlopeDeg, 0) / numRays) * 10) / 10;
|
||||
const cliffBarriersCount = rayResults.filter(r => r.isCliffBarrier).length;
|
||||
const basaltCount = rayResults.filter(r => r.isBasalt).length;
|
||||
const roadCoveragePct = Math.round((rayResults.filter(r => r.hasRoad).length / numRays) * 100);
|
||||
const passabilityScore = Math.max(15, Math.min(99, Math.round(100 - avgSlopeOverall * 2.2 - cliffBarriersCount * 2)));
|
||||
|
||||
let mobilityConditionAr = 'طبيعة أرض سهلة ومستوية مع حركة سريعة';
|
||||
if (cliffBarriersCount > 4 || avgSlopeOverall > 18) {
|
||||
mobilityConditionAr = 'تضاريس جبلية وعرة جداً مع جروف صخرية وقواطع حادة تعيق الحركة خارج الطرق';
|
||||
} else if (basaltCount > 4 || avgSlopeOverall > 10) {
|
||||
mobilityConditionAr = 'طبيعة أرض صخرية/بازلتية متوسطة الوعورة تبطئ حركة الآليات خارج الطرق المعبدة';
|
||||
} else if (avgSlopeOverall > 5) {
|
||||
mobilityConditionAr = 'تلال متوسطة الانحدار مع إمكانية حركة جيدة لمركبات الدفع الرباعي';
|
||||
}
|
||||
|
||||
const colors = ['#22c55e', '#eab308', '#ef4444', '#8b5cf6'];
|
||||
const tiers = timeBuckets.map((seconds, idx) => {
|
||||
const minutes = Math.round(seconds / 60);
|
||||
const ringCoords: [number, number][] = [];
|
||||
|
||||
for (const ray of rayResults) {
|
||||
// Distance reachable within 'seconds' along this road corridor
|
||||
const reachableMeters = Math.min(ray.maxPathDist, ray.effectiveSpeed * seconds);
|
||||
let reachableMeters = 0;
|
||||
|
||||
if (ray.hasRoad && ray.roadPathTimeSec > 0) {
|
||||
if (seconds <= ray.roadPathTimeSec) {
|
||||
// Reached distance along paved road network
|
||||
reachableMeters = ray.effectiveRoadSpeed * seconds;
|
||||
} else {
|
||||
// Traversed the full road corridor, then continued cross-country off-road at terrain speed
|
||||
const remainingSec = seconds - ray.roadPathTimeSec;
|
||||
reachableMeters = ray.roadPathDist + (remainingSec * ray.offRoadSpeedMps);
|
||||
}
|
||||
} else {
|
||||
// Off-road terrain movement only (no paved road in this bearing)
|
||||
reachableMeters = seconds * ray.offRoadSpeedMps;
|
||||
}
|
||||
|
||||
// Cap to maximum realistic boundary
|
||||
reachableMeters = Math.min(reachableMeters, maxDistMeters * 1.2);
|
||||
|
||||
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);
|
||||
|
||||
@@ -627,6 +685,14 @@ export class TacticalService {
|
||||
featureCollection: {
|
||||
type: 'FeatureCollection',
|
||||
features: tiers.map(t => t.polygon)
|
||||
},
|
||||
terrainPassability: {
|
||||
avgSlopeDeg: avgSlopeOverall,
|
||||
cliffBarriersCount,
|
||||
basaltZoneDetected: basaltCount > 0,
|
||||
roadCoveragePct,
|
||||
passabilityScore,
|
||||
mobilityConditionAr
|
||||
}
|
||||
};
|
||||
|
||||
@@ -634,6 +700,79 @@ export class TacticalService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute Off-Road Cross-Country Terrain Mobility Speed along a radial azimuth
|
||||
* حساب سرعة الحركة خارج الطرق المعبدة بالاعتماد على درجة الانحدار، طبيعة التربة، والصخور والبازلت
|
||||
*/
|
||||
private computeOffRoadTerrainSpeed(
|
||||
lat: number,
|
||||
lng: number,
|
||||
angle: number,
|
||||
maxDistMeters: number,
|
||||
vehicleProfile: string
|
||||
): { speedMps: number; avgSlopeDeg: number; isCliff: boolean; isBasalt: boolean } {
|
||||
const samples = 5;
|
||||
let totalSlopeDeg = 0;
|
||||
let maxSlopeDeg = 0;
|
||||
const stepDist = Math.min(maxDistMeters / samples, 1000);
|
||||
|
||||
let prevElev = this.estimateElevation(lat, lng);
|
||||
for (let s = 1; s <= samples; s++) {
|
||||
const curDist = s * stepDist;
|
||||
const dLat = (curDist / 6371000) * (180 / Math.PI) * Math.cos(angle);
|
||||
const dLng = (curDist / (6371000 * Math.cos((lat * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(angle);
|
||||
const curElev = this.estimateElevation(lat + dLat, lng + dLng);
|
||||
const dz = Math.abs(curElev - prevElev);
|
||||
const slopeRad = Math.atan(dz / stepDist);
|
||||
const slopeDeg = (slopeRad * 180) / Math.PI;
|
||||
totalSlopeDeg += slopeDeg;
|
||||
if (slopeDeg > maxSlopeDeg) maxSlopeDeg = slopeDeg;
|
||||
prevElev = curElev;
|
||||
}
|
||||
|
||||
const avgSlopeDeg = Math.round((totalSlopeDeg / samples) * 10) / 10;
|
||||
|
||||
// Base off-road speeds by vehicle profile (km/h)
|
||||
let baseOffRoadKmh = 22; // emergency 4x4
|
||||
if (vehicleProfile === 'heavy') baseOffRoadKmh = 10; // heavy civil defense / fire engine
|
||||
if (vehicleProfile === 'patrol') baseOffRoadKmh = 16; // security patrol 4x4
|
||||
|
||||
// Terrain slope & ruggedness penalty (Tobler Cross-Country Mobility Model)
|
||||
// < 5°: Flat ground (100% speed)
|
||||
// 5°-12°: Moderate slopes (65% speed)
|
||||
// 12°-20°: Rugged slopes / Basalt terrain (30% speed)
|
||||
// 20°-28°: Steep mountain ridges (10% speed)
|
||||
// > 28°: Impassable cliffs / Rock walls (2% speed - vehicle barrier)
|
||||
let terrainFactor = 1.0;
|
||||
let isCliff = false;
|
||||
|
||||
if (avgSlopeDeg > 25 || maxSlopeDeg > 32) {
|
||||
terrainFactor = 0.04;
|
||||
isCliff = true;
|
||||
} else if (avgSlopeDeg > 18 || maxSlopeDeg > 24) {
|
||||
terrainFactor = 0.15;
|
||||
isCliff = true;
|
||||
} else if (avgSlopeDeg > 10) {
|
||||
terrainFactor = 0.38;
|
||||
} else if (avgSlopeDeg > 5) {
|
||||
terrainFactor = 0.70;
|
||||
}
|
||||
|
||||
// Basalt volcanic rock fields detection (North-Eastern Jordanian Harrah & Desert corridors)
|
||||
const isBasalt = (lat >= 31.8 && lat <= 32.7 && lng >= 36.15 && lng <= 37.5);
|
||||
if (isBasalt && terrainFactor > 0.3) {
|
||||
terrainFactor *= 0.65; // Boulder obstruction & tire puncture risk
|
||||
}
|
||||
|
||||
const effectiveOffRoadKmh = baseOffRoadKmh * terrainFactor;
|
||||
return {
|
||||
speedMps: effectiveOffRoadKmh / 3.6,
|
||||
avgSlopeDeg,
|
||||
isCliff,
|
||||
isBasalt,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Utility GIS Math ---
|
||||
private calculatePolygonAreaKm2(coords: [number, number][]): number {
|
||||
if (!coords || coords.length < 3) return 0;
|
||||
|
||||
Reference in New Issue
Block a user