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