feat(tactical): integrate cross-country terrain slope, basalt rocks, and off-road mobility analysis into Isochrone engine

This commit is contained in:
Hamza-Ayed
2026-08-18 15:40:18 +03:00
parent 9088af0534
commit ad231c6f20
2 changed files with 219 additions and 19 deletions
+157 -18
View File
@@ -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;
+62 -1
View File
@@ -3300,10 +3300,71 @@ ${terrainResult.tacticalRecommendations.map((r, i) => `${i + 1}. ${r}`).join('\n
<div style={{ fontSize: '0.72rem', color: '#94a3b8', marginBottom: 2 }}>مؤشر الامتثال للساعة الذهبية (Golden Hour Index)</div>
<div style={{ fontSize: '1.6rem', fontWeight: 900, color: '#4ade80' }}>96.4%</div>
<div style={{ fontSize: '0.72rem', color: '#cbd5e1' }}>
تغطية سريعة ضمن المحاور المرورية الرئيسية في الأردن
تغطية سريعة هجينة (شبكة الطرق + عبور تكتيكي خارج الطرق)
</div>
</div>
{/* Terrain & Cross-Country Mobility Analysis Card */}
{isochroneData.terrainPassability && (
<div style={{
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: 8,
padding: '10px',
display: 'flex',
flexDirection: 'column',
gap: 6
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '0.75rem', fontWeight: 800, color: '#38bdf8', display: 'flex', alignItems: 'center', gap: 4 }}>
<span>⛰️</span> دراسة عبور الأرض والانحدار والبازلت
</span>
<span style={{
fontSize: '0.68rem',
fontWeight: 800,
padding: '2px 6px',
borderRadius: 4,
background: isochroneData.terrainPassability.passabilityScore > 70 ? 'rgba(34,197,94,0.2)' : 'rgba(234,179,8,0.2)',
color: isochroneData.terrainPassability.passabilityScore > 70 ? '#4ade80' : '#facc15',
border: `1px solid ${isochroneData.terrainPassability.passabilityScore > 70 ? '#22c55e' : '#eab308'}`
}}>
جاهزية العبور: {isochroneData.terrainPassability.passabilityScore}%
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginTop: 2 }}>
<div style={{ background: 'rgba(0,0,0,0.25)', padding: '6px 8px', borderRadius: 6 }}>
<div style={{ fontSize: '0.62rem', color: '#94a3b8' }}>متوسط ميل الأرض</div>
<div style={{ fontSize: '0.85rem', fontWeight: 800, color: '#f8fafc' }}>
{isochroneData.terrainPassability.avgSlopeDeg}°
</div>
</div>
<div style={{ background: 'rgba(0,0,0,0.25)', padding: '6px 8px', borderRadius: 6 }}>
<div style={{ fontSize: '0.62rem', color: '#94a3b8' }}>تغطية الطرق المعبدة</div>
<div style={{ fontSize: '0.85rem', fontWeight: 800, color: '#38bdf8' }}>
{isochroneData.terrainPassability.roadCoveragePct}%
</div>
</div>
</div>
{isochroneData.terrainPassability.basaltZoneDetected && (
<div style={{ fontSize: '0.65rem', color: '#f59e0b', background: 'rgba(245, 158, 11, 0.12)', padding: '4px 8px', borderRadius: 4, border: '1px solid rgba(245, 158, 11, 0.3)' }}>
⚠️ تم رصد حقول صخور بازلتية - تم تطبيق معامل إبطاء لحماية الإطارات
</div>
)}
{isochroneData.terrainPassability.cliffBarriersCount > 0 && (
<div style={{ fontSize: '0.65rem', color: '#ef4444', background: 'rgba(239, 68, 68, 0.12)', padding: '4px 8px', borderRadius: 4, border: '1px solid rgba(239, 68, 68, 0.3)' }}>
⛔ تم رصد {isochroneData.terrainPassability.cliffBarriersCount} قواطع وجروف صخرية حادة (انحدار شديد يعيق المركبات)
</div>
)}
<div style={{ fontSize: '0.68rem', color: '#94a3b8', borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 5 }}>
{isochroneData.terrainPassability.mobilityConditionAr}
</div>
</div>
)}
{/* Tiers Breakdown Cards */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{isochroneData.tiers.map((tier: any, idx: number) => (