diff --git a/apps/api/src/tactical/tactical.service.ts b/apps/api/src/tactical/tactical.service.ts index 1be6a18..f85ff41 100644 --- a/apps/api/src/tactical/tactical.service.ts +++ b/apps/api/src/tactical/tactical.service.ts @@ -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[] = []; + // Concurrently probe road networks and compute terrain slope/passability across all 32 radial directions + const rayPromises: Promise[] = []; 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; diff --git a/apps/web/src/pages/TacticalDefenseView.tsx b/apps/web/src/pages/TacticalDefenseView.tsx index fa57b8c..b5e537a 100644 --- a/apps/web/src/pages/TacticalDefenseView.tsx +++ b/apps/web/src/pages/TacticalDefenseView.tsx @@ -3300,10 +3300,71 @@ ${terrainResult.tacticalRecommendations.map((r, i) => `${i + 1}. ${r}`).join('\n
مؤشر الامتثال للساعة الذهبية (Golden Hour Index)
96.4%
- تغطية سريعة ضمن المحاور المرورية الرئيسية في الأردن + تغطية سريعة هجينة (شبكة الطرق + عبور تكتيكي خارج الطرق)
+ {/* Terrain & Cross-Country Mobility Analysis Card */} + {isochroneData.terrainPassability && ( +
+
+ + ⛰️ دراسة عبور الأرض والانحدار والبازلت + + 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}% + +
+ +
+
+
متوسط ميل الأرض
+
+ {isochroneData.terrainPassability.avgSlopeDeg}° +
+
+
+
تغطية الطرق المعبدة
+
+ {isochroneData.terrainPassability.roadCoveragePct}% +
+
+
+ + {isochroneData.terrainPassability.basaltZoneDetected && ( +
+ ⚠️ تم رصد حقول صخور بازلتية - تم تطبيق معامل إبطاء لحماية الإطارات +
+ )} + + {isochroneData.terrainPassability.cliffBarriersCount > 0 && ( +
+ ⛔ تم رصد {isochroneData.terrainPassability.cliffBarriersCount} قواطع وجروف صخرية حادة (انحدار شديد يعيق المركبات) +
+ )} + +
+ {isochroneData.terrainPassability.mobilityConditionAr} +
+
+ )} + {/* Tiers Breakdown Cards */}
{isochroneData.tiers.map((tier: any, idx: number) => (