feat(tactical): high-resolution 21x21 DEM terrain study, bilinear interpolation, cliff/rock barrier detection, and 4-tier slope classification

This commit is contained in:
Hamza-Ayed
2026-08-18 11:37:48 +03:00
parent b039281972
commit c35f6e61f2
2 changed files with 467 additions and 247 deletions
+73 -12
View File
@@ -551,6 +551,7 @@ export const TacticalDefenseView: React.FC = () => {
'fill-opacity': [
'match',
['get', 'layerType'],
'cliff', 0.50,
'urban', 0.28,
'hazard', 0.32,
'slope-sector', 0.18,
@@ -566,8 +567,18 @@ export const TacticalDefenseView: React.FC = () => {
filter: ['==', '$type', 'Polygon'],
paint: {
'line-color': ['get', 'color'],
'line-width': 2,
'line-dasharray': [3, 2]
'line-width': [
'match',
['get', 'layerType'],
'cliff', 3.5,
2
],
'line-dasharray': [
'match',
['get', 'layerType'],
'cliff', ['literal', [2, 1]],
['literal', [3, 2]]
]
}
});
@@ -581,6 +592,7 @@ export const TacticalDefenseView: React.FC = () => {
'line-width': [
'match',
['get', 'layerType'],
'cliff', 4.5,
'road', 4.5,
'wadi', 3.5,
3
@@ -588,6 +600,7 @@ export const TacticalDefenseView: React.FC = () => {
'line-dasharray': [
'match',
['get', 'layerType'],
'cliff', ['literal', [2, 1]],
'road', ['literal', [1, 0]],
'wadi', ['literal', [2, 1.5]],
['literal', [1, 0]]
@@ -817,10 +830,10 @@ export const TacticalDefenseView: React.FC = () => {
const filtered = terrainResult.spatialGeoJson.features.filter((f: any) => {
if (f.properties.category === 'peak' || f.properties.category === 'valley') return showPeaksAndValleys;
if (f.properties.layerType === 'slope-sector') return showSlopeSectors;
if (f.properties.layerType === 'wadi' || f.properties.layerType === 'ridge') return showNaturalObstacles;
if (f.properties.layerType === 'wadi' || f.properties.layerType === 'ridge' || f.properties.layerType === 'cliff') return showNaturalObstacles;
if (f.properties.layerType === 'road') return showRoadCorridors;
if (f.properties.layerType === 'urban') return showUrbanZones;
if (f.properties.layerType === 'hazard') return showHazardousGround;
if (f.properties.layerType === 'hazard' || f.properties.layerType === 'cliff') return showHazardousGround;
return true;
});
@@ -1652,24 +1665,72 @@ ${terrainResult.tacticalRecommendations.map((r, i) => `${i + 1}. ${r}`).join('\n
</div>
</div>
{/* Slope Distribution Bar */}
{/* Slope Distribution Bar (4 Military Tiers) */}
<div style={{ background: 'rgba(255,255,255,0.04)', padding: 10, borderRadius: 8 }}>
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: '#cbd5e1', marginBottom: 6, display: 'flex', justifyContent: 'space-between' }}>
<span>توزيع درجات الانحدار (Slope Analysis):</span>
<span style={{ color: '#94a3b8' }}>متوسط: {terrainResult.avgSlopeDegrees}°</span>
</div>
<div style={{ height: 10, width: '100%', display: 'flex', borderRadius: 5, overflow: 'hidden', marginBottom: 6 }}>
<div style={{ width: `${terrainResult.slopeDistribution.flatPercentage}%`, background: '#22c55e' }} title={`منبسط: ${terrainResult.slopeDistribution.flatPercentage}%`} />
<div style={{ width: `${terrainResult.slopeDistribution.moderatePercentage}%`, background: '#eab308' }} title={`متوسط: ${terrainResult.slopeDistribution.moderatePercentage}%`} />
<div style={{ width: `${terrainResult.slopeDistribution.steepPercentage}%`, background: '#ef4444' }} title={`شديد الوعورة: ${terrainResult.slopeDistribution.steepPercentage}%`} />
<div style={{ width: `${terrainResult.slopeDistribution.flatPercentage}%`, background: '#22c55e' }} title={`منبسط (<7°): ${terrainResult.slopeDistribution.flatPercentage}%`} />
<div style={{ width: `${terrainResult.slopeDistribution.moderatePercentage}%`, background: '#eab308' }} title={`تضاريس متموجة (7-15°): ${terrainResult.slopeDistribution.moderatePercentage}%`} />
<div style={{ width: `${terrainResult.slopeDistribution.steepPercentage}%`, background: '#f97316' }} title={`انحدار وعر (15-25°): ${terrainResult.slopeDistribution.steepPercentage}%`} />
<div style={{ width: `${terrainResult.slopeDistribution.cliffPercentage}%`, background: '#ef4444' }} title={`قواطع وجروف صخرية (>25°): ${terrainResult.slopeDistribution.cliffPercentage}%`} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.68rem', color: '#94a3b8' }}>
<span style={{ color: '#4ade80' }}>■ منبسط (&lt;5°): {terrainResult.slopeDistribution.flatPercentage}%</span>
<span style={{ color: '#facc15' }}>■ متوسط (5-15°): {terrainResult.slopeDistribution.moderatePercentage}%</span>
<span style={{ color: '#f87171' }}>■ وعر (&gt;15°): {terrainResult.slopeDistribution.steepPercentage}%</span>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '3px 8px', fontSize: '0.66rem', color: '#94a3b8' }}>
<span style={{ color: '#4ade80' }}>■ منبسط (&lt;7°): {terrainResult.slopeDistribution.flatPercentage}%</span>
<span style={{ color: '#facc15' }}>■ متموج (7-15°): {terrainResult.slopeDistribution.moderatePercentage}%</span>
<span style={{ color: '#fb923c' }}>■ وعر (15-25°): {terrainResult.slopeDistribution.steepPercentage}%</span>
<span style={{ color: '#f87171' }}>■ جروف صخرية (&gt;25°): {terrainResult.slopeDistribution.cliffPercentage}%</span>
</div>
</div>
{/* Detected Cliffs & Natural Rock Barriers */}
{terrainResult.cliffs && terrainResult.cliffs.length > 0 && (
<div style={{ background: 'rgba(239, 68, 68, 0.08)', padding: 10, borderRadius: 8, border: '1px solid rgba(239, 68, 68, 0.35)' }}>
<div style={{ fontSize: '0.76rem', fontWeight: 800, color: '#f87171', marginBottom: 6, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<AlertTriangle size={14} /> 🧗‍♂️ القواطع والجروف الصخرية المرصودة ({terrainResult.cliffsCount})
</span>
<span style={{ fontSize: '0.62rem', background: '#ef4444', color: '#fff', padding: '1px 6px', borderRadius: 10, fontWeight: 700 }}>مانع NO-GO</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, maxHeight: 160, overflowY: 'auto' }}>
{terrainResult.cliffs.map((cliff, cIdx) => (
<div
key={cIdx}
onClick={() => {
if (map.current) {
map.current.flyTo({ center: [cliff.lng, cliff.lat], zoom: 15.5, pitch: 55 });
}
}}
style={{
background: 'rgba(0,0,0,0.3)',
padding: '6px 8px',
borderRadius: 6,
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
border: '1px solid rgba(239, 68, 68, 0.2)'
}}
>
<div>
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: '#fca5a5' }}>
قاطع #{cIdx + 1}: سقوط {cliff.dropMeters}م (ميل {cliff.slopeDegrees}°)
</div>
<div style={{ fontSize: '0.62rem', color: '#94a3b8', fontFamily: 'monospace' }}>
{cliff.lat}, {cliff.lng}
</div>
</div>
<div style={{ fontSize: '0.65rem', color: '#f87171', fontWeight: 800, background: 'rgba(239,68,68,0.15)', padding: '2px 6px', borderRadius: 4 }}>
🔍 تركيز
</div>
</div>
))}
</div>
</div>
)}
{/* SECTION 1: NATURAL OBSTACLES */}
<div style={{ background: 'rgba(255,255,255,0.04)', padding: 10, borderRadius: 8, border: '1px solid rgba(239, 68, 68, 0.2)' }}>
<div style={{ fontSize: '0.78rem', fontWeight: 800, color: '#f87171', marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
+394 -235
View File
@@ -70,69 +70,145 @@ export function calculateAzimuth(lat1: number, lon1: number, lat2: number, lon2:
return (theta * (180 / Math.PI) + 360) % 360;
}
/**
* Samples elevation from Terrarium DEM tile or fallback topographic model
*/
/**
* Fetches or retrieves cached DEM tile ImageData
*/
async function fetchTileImageData(zoom: number, x: number, y: number): Promise<ImageData | null> {
const tileKey = `${zoom}/${x}/${y}`;
const cached = tileCache.get(tileKey);
if (cached) return cached;
try {
const tileUrl = `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/${zoom}/${x}/${y}.png`;
const img = new Image();
img.crossOrigin = 'anonymous';
const loadPromise = new Promise<HTMLImageElement>((resolve, reject) => {
img.onload = () => resolve(img);
img.onerror = (e) => reject(e);
img.src = tileUrl;
});
const loadedImg = await Promise.race([
loadPromise,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('DEM Timeout')), 2000))
]);
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(loadedImg, 0, 0);
const imgData = ctx.getImageData(0, 0, 256, 256);
tileCache.set(tileKey, imgData);
return imgData;
}
} catch {
// Offline or network error
}
return null;
}
/**
* Samples sub-pixel elevation from ImageData using Bilinear Interpolation
*/
function interpolateElevation(imgData: ImageData, subX: number, subY: number): number {
const clampedX = Math.max(0, Math.min(254.99, subX));
const clampedY = Math.max(0, Math.min(254.99, subY));
const x0 = Math.floor(clampedX);
const x1 = x0 + 1;
const y0 = Math.floor(clampedY);
const y1 = y0 + 1;
const fx = clampedX - x0;
const fy = clampedY - y0;
const decodePixel = (px: number, py: number): number => {
const idx = (py * 256 + px) * 4;
const r = imgData.data[idx];
const g = imgData.data[idx + 1];
const b = imgData.data[idx + 2];
return (r * 256 + g + b / 256) - 32768;
};
const z00 = decodePixel(x0, y0);
const z10 = decodePixel(x1, y0);
const z01 = decodePixel(x0, y1);
const z11 = decodePixel(x1, y1);
const zTop = z00 * (1 - fx) + z10 * fx;
const zBottom = z01 * (1 - fx) + z11 * fx;
return Math.round((zTop * (1 - fy) + zBottom * fy) * 10) / 10;
}
/**
* Samples elevation for multiple coordinates in batch with tile grouping & cache
*/
export async function sampleElevationsBatch(
coords: Array<{ lat: number; lng: number }>,
zoom: number = 13
): Promise<number[]> {
const n = Math.pow(2, zoom);
// Group coordinates by tile
const tileMap = new Map<string, { zoom: number; x: number; y: number; points: Array<{ index: number; subX: number; subY: number; lat: number; lng: number }> }>();
coords.forEach((coord, index) => {
const x = Math.floor(((coord.lng + 180) / 360) * n);
const latRad = (coord.lat * Math.PI) / 180;
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
const subX = (((coord.lng + 180) / 360) * n - x) * 256;
const subY = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n - y) * 256;
const tileKey = `${zoom}/${x}/${y}`;
if (!tileMap.has(tileKey)) {
tileMap.set(tileKey, { zoom, x, y, points: [] });
}
tileMap.get(tileKey)!.points.push({ index, subX, subY, lat: coord.lat, lng: coord.lng });
});
const results: number[] = new Array(coords.length).fill(0);
// Load all unique tiles in parallel
await Promise.all(
Array.from(tileMap.entries()).map(async ([_key, entry]) => {
const imgData = await fetchTileImageData(entry.zoom, entry.x, entry.y);
entry.points.forEach((pt) => {
if (imgData) {
results[pt.index] = interpolateElevation(imgData, pt.subX, pt.subY);
} else {
results[pt.index] = getApproximateElevation(pt.lat, pt.lng);
}
});
})
);
return results;
}
/**
* Samples elevation from Terrarium DEM tile or fallback topographic model
*/
async function sampleElevationAt(lat: number, lng: number): Promise<number> {
const zoom = 12;
const zoom = 13;
const n = Math.pow(2, zoom);
const x = Math.floor(((lng + 180) / 360) * n);
const latRad = (lat * Math.PI) / 180;
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
const tileKey = `${zoom}/${x}/${y}`;
const subX = (((lng + 180) / 360) * n - x) * 256;
const subY = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n - y) * 256;
try {
let imgData = tileCache.get(tileKey);
if (!imgData) {
const tileUrl = `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/${zoom}/${x}/${y}.png`;
const img = new Image();
img.crossOrigin = 'anonymous';
const loadPromise = new Promise<HTMLImageElement>((resolve, reject) => {
img.onload = () => resolve(img);
img.onerror = (e) => reject(e);
img.src = tileUrl;
});
// 1.5s timeout for fast responsiveness
const loadedImg = await Promise.race([
loadPromise,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('DEM Timeout')), 1500))
]);
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(loadedImg, 0, 0);
imgData = ctx.getImageData(0, 0, 256, 256);
tileCache.set(tileKey, imgData);
}
}
if (imgData) {
// Calculate exact sub-pixel inside tile
const subX = (((lng + 180) / 360) * n - x) * 256;
const subY = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n - y) * 256;
const px = Math.min(255, Math.max(0, Math.floor(subX)));
const py = Math.min(255, Math.max(0, Math.floor(subY)));
const index = (py * 256 + px) * 4;
const r = imgData.data[index];
const g = imgData.data[index + 1];
const b = imgData.data[index + 2];
// Terrarium formula: (R * 256 + G + B / 256) - 32768
const elev = (r * 256 + g + b / 256) - 32768;
return Math.round(elev);
}
} catch (err) {
// Fallback topographic approximation for Jordan/Levant region
const imgData = await fetchTileImageData(zoom, x, y);
if (imgData) {
return Math.round(interpolateElevation(imgData, subX, subY));
}
return getApproximateElevation(lat, lng);
}
@@ -143,34 +219,26 @@ function getApproximateElevation(lat: number, lng: number): number {
// Jordan Valley & Dead Sea trench model
if (lng < 35.6 && lat < 32.2 && lat > 31.0) {
const distFromRift = Math.abs(lng - 35.5);
return -400 + distFromRift * 3000;
return Math.round(-400 + distFromRift * 3000);
}
// Northern Highlands (Ajloun / Jerash / Salt)
if (lat >= 32.1 && lng < 36.0) {
return 850 + Math.sin(lat * 50) * 250 + Math.cos(lng * 40) * 150;
return Math.round(850 + Math.sin(lat * 50) * 250 + Math.cos(lng * 40) * 150);
}
// Amman Plateau
if (lat >= 31.8 && lat < 32.1 && lng >= 35.8 && lng < 36.2) {
return 900 + Math.sin((lat - 31.95) * 100) * 120 + Math.cos((lng - 35.9) * 100) * 100;
return Math.round(900 + Math.sin((lat - 31.95) * 100) * 120 + Math.cos((lng - 35.9) * 100) * 100);
}
// Southern Highlands (Karak / Tafilah / Shobak / Petra)
if (lat < 31.5 && lat > 30.0 && lng < 35.7) {
return 1100 + Math.sin(lat * 30) * 350;
return Math.round(1100 + Math.sin(lat * 30) * 350);
}
// Eastern Desert (Badia)
return 650 + (lng - 36.0) * 30;
return Math.round(650 + (lng - 36.0) * 30);
}
/**
* Calculates Line of Sight and Elevation Profile between two coordinates
*
* @param startLat Observer Latitude
* @param startLng Observer Longitude
* @param endLat Target Latitude
* @param endLng Target Longitude
* @param obsHeight Observer Eye Level offset above ground (default: 2 meters)
* @param tgtHeight Target Height offset above ground (default: 2 meters)
* @param samples Number of sampling steps along the ray (default: 60)
*/
export async function calculateLineOfSight(
startLat: number,
@@ -233,7 +301,7 @@ export async function calculateLineOfSight(
};
}
} catch {
// API not available or timed out, fall back to local DEM tile processing
// Fall back to local DEM processing
}
// Generate sample coordinates along the geodesic path
@@ -246,17 +314,14 @@ export async function calculateLineOfSight(
sampleCoords.push({ lat, lng, dist });
}
// Fetch elevations for all sample points in parallel
const elevations = await Promise.all(
sampleCoords.map((coord) => sampleElevationAt(coord.lat, coord.lng))
);
// Fetch elevations for all sample points in fast batch
const elevations = await sampleElevationsBatch(sampleCoords, 13);
const observerGroundElev = elevations[0];
const targetGroundElev = elevations[elevations.length - 1];
const observerElevation = observerGroundElev + obsHeight;
const targetElevation = targetGroundElev + tgtHeight;
// Earth curvature & atmospheric refraction parameter (k ≈ 0.13 for standard atmosphere)
const R_earth = 6371000;
const k_refraction = 0.13;
const effectiveEarthRadius = R_earth / (1 - k_refraction);
@@ -265,8 +330,6 @@ export async function calculateLineOfSight(
let highestObstacle: ObstacleInfo | null = null;
let maxObstacleExcess = 0;
let deadGroundCount = 0;
// Horizon angle tracking from observer (tan of highest angle encountered so far)
let maxAngleSoFar = -Infinity;
const points: ElevationPoint[] = [];
@@ -279,16 +342,10 @@ export async function calculateLineOfSight(
minElev = Math.min(minElev, elev);
maxElev = Math.max(maxElev, elev);
// Earth curvature sagitta at distance d: deltaH = (d * (totalDistance - d)) / (2 * R_effective)
const earthCurvatureDrop = (d * (totalDistance - d)) / (2 * effectiveEarthRadius);
// Theoretical straight ray height AMSL connecting Observer to Target
const rayHeight = observerElevation + ((targetElevation - observerElevation) * (d / totalDistance)) - earthCurvatureDrop;
// Clearance (positive = ray above terrain, negative = obstacle)
const clearance = rayHeight - elev;
// Check if this point blocks the direct ray to the target (ignore start and end margins)
let isTargetRayBlocked = false;
if (i > 1 && i < samples) {
if (elev > rayHeight) {
@@ -299,7 +356,7 @@ export async function calculateLineOfSight(
maxObstacleExcess = excess;
highestObstacle = {
distance: Math.round(d),
elevation: elev,
elevation: Math.round(elev),
lat: sampleCoords[i].lat,
lng: sampleCoords[i].lng,
excessHeight: Math.round(excess * 10) / 10,
@@ -308,7 +365,6 @@ export async function calculateLineOfSight(
}
}
// Check visibility from observer's eye (Viewshed / Shadowing along profile)
let isVisible = true;
if (i === 0) {
isVisible = true;
@@ -327,7 +383,7 @@ export async function calculateLineOfSight(
distance: Math.round(d),
lat: sampleCoords[i].lat,
lng: sampleCoords[i].lng,
elevation: elev,
elevation: Math.round(elev),
rayHeight: Math.round(rayHeight * 10) / 10,
isVisible,
isTargetRayBlocked,
@@ -335,13 +391,10 @@ export async function calculateLineOfSight(
});
}
// Calculate Vertical Angle (Degrees & Artillery Mils)
// 1 Degree = 17.7778 Artillery Mils (6400 Mils in full circle)
const verticalDiff = targetElevation - observerElevation;
const angleRad = Math.atan2(verticalDiff, totalDistance);
const angleDegrees = Math.round((angleRad * (180 / Math.PI)) * 100) / 100;
const angleMils = Math.round((angleDegrees * (6400 / 360)) * 10) / 10;
const deadGroundPercentage = Math.round((deadGroundCount / samples) * 100);
return {
@@ -374,7 +427,7 @@ export interface Viewshed360Result {
}
/**
* Calculates 360-degree Radial Viewshed (كشف الميدان الدائري) around observer
* Calculates 360-degree Radial Viewshed around observer
*/
export async function calculateRadialViewshed(
centerLat: number,
@@ -390,9 +443,8 @@ export async function calculateRadialViewshed(
const k_refraction = 0.13;
const effectiveEarthRadius = R_earth / (1 - k_refraction);
const polygonCoordinates: [number, number][] = [];
const samplesPerRay = 12;
let totalVisibleDistanceSum = 0;
const samplesPerRay = 14;
const rayCoords: Array<{ rayIdx: number; step: number; sDist: number; sLat: number; sLng: number }> = [];
for (let rayIdx = 0; rayIdx < numRays; rayIdx++) {
const angleDeg = (rayIdx * 360) / numRays;
@@ -404,29 +456,41 @@ export async function calculateRadialViewshed(
const endLat = centerLat + dLat;
const endLng = centerLng + dLng;
let maxAngleSoFar = -Infinity;
let visibleHorizonDist = radiusMeters;
let visibleHorizonLat = endLat;
let visibleHorizonLng = endLng;
for (let step = 1; step <= samplesPerRay; step++) {
const frac = step / samplesPerRay;
const sLat = centerLat + (endLat - centerLat) * frac;
const sLng = centerLng + (endLng - centerLng) * frac;
const sDist = radiusMeters * frac;
rayCoords.push({ rayIdx, step, sDist, sLat, sLng });
}
}
const sElev = await sampleElevationAt(sLat, sLng);
const earthCurvatureDrop = (sDist * sDist) / (2 * effectiveEarthRadius);
// Batch sample all ray points
const elevations = await sampleElevationsBatch(rayCoords.map(rc => ({ lat: rc.sLat, lng: rc.sLng })), 13);
const polygonCoordinates: [number, number][] = [];
let totalVisibleDistanceSum = 0;
for (let rayIdx = 0; rayIdx < numRays; rayIdx++) {
let maxAngleSoFar = -Infinity;
let visibleHorizonDist = radiusMeters;
let visibleHorizonLat = centerLat;
let visibleHorizonLng = centerLng;
const rayPoints = rayCoords.filter(rc => rc.rayIdx === rayIdx);
rayPoints.forEach(rc => {
const sElev = elevations[rc.rayIdx * samplesPerRay + (rc.step - 1)];
const earthCurvatureDrop = (rc.sDist * rc.sDist) / (2 * effectiveEarthRadius);
const apparentElev = sElev - earthCurvatureDrop;
const angle = (apparentElev - observerElevation) / sDist;
const angle = (apparentElev - observerElevation) / rc.sDist;
if (angle >= maxAngleSoFar) {
maxAngleSoFar = angle;
visibleHorizonDist = sDist;
visibleHorizonLat = sLat;
visibleHorizonLng = sLng;
visibleHorizonDist = rc.sDist;
visibleHorizonLat = rc.sLat;
visibleHorizonLng = rc.sLng;
}
}
});
totalVisibleDistanceSum += visibleHorizonDist;
polygonCoordinates.push([visibleHorizonLng, visibleHorizonLat]);
@@ -493,7 +557,6 @@ export interface MinefieldAnalysisResult {
/**
* Tactical Minefield Barrier & Sapper Engineering Analysis
* دراسة جدوى وكميات حقل الألغام الدفاعي وفحص التضاريس
*/
export async function calculateMinefieldAnalysis(
startLat: number,
@@ -505,25 +568,23 @@ export async function calculateMinefieldAnalysis(
const frontageMeters = calculateDistance(startLat, startLng, endLat, endLng);
const azimuthDegrees = calculateAzimuth(startLat, startLng, endLat, endLng);
const startElev = await sampleElevationAt(startLat, startLng);
const endElev = await sampleElevationAt(endLat, endLng);
// Sample intermediate points to determine valley / ridge / choke point profile
const samples = 10;
const elevs: number[] = [];
const samples = 12;
const sampleCoords: Array<{ lat: number; lng: number }> = [];
for (let i = 0; i <= samples; i++) {
const frac = i / samples;
const lat = startLat + (endLat - startLat) * frac;
const lng = startLng + (endLng - startLng) * frac;
const el = await sampleElevationAt(lat, lng);
elevs.push(el);
sampleCoords.push({ lat, lng });
}
const elevs = await sampleElevationsBatch(sampleCoords, 13);
const startElev = elevs[0];
const endElev = elevs[elevs.length - 1];
const elevDiff = Math.abs(endElev - startElev);
const avgSlopeRad = frontageMeters > 0 ? Math.atan(elevDiff / frontageMeters) : 0;
const avgSlopeDegrees = Math.round((avgSlopeRad * (180 / Math.PI)) * 10) / 10;
// Check if center is lower than edges (a defile / valley choke point)
const midElev = elevs[Math.floor(samples / 2)];
const isChokePoint = midElev < Math.min(startElev, endElev) - 10 || avgSlopeDegrees <= 10;
@@ -576,6 +637,15 @@ export async function calculateMinefieldAnalysis(
};
}
export interface CliffFeature {
lat: number;
lng: number;
dropMeters: number;
slopeDegrees: number;
label: string;
tacticalImpact: string;
}
export interface TerrainStudyResult {
centerLat: number;
centerLng: number;
@@ -589,10 +659,13 @@ export interface TerrainStudyResult {
avgSlopeDegrees: number;
maxSlopeDegrees: number;
slopeDistribution: {
flatPercentage: number; // < 5 deg (Go)
moderatePercentage: number; // 5-15 deg (Slow-Go)
steepPercentage: number; // > 15 deg (No-Go / Obstacle)
flatPercentage: number; // < 7 deg (Go)
moderatePercentage: number; // 7-15 deg (Slow-Go / Rolling)
steepPercentage: number; // 15-25 deg (Severe Slow-Go / Rugged)
cliffPercentage: number; // > 25 deg (No-Go / Cliffs & Rock Barriers)
};
cliffsCount: number;
cliffs: CliffFeature[];
terrainClassification: string;
terrainClassificationAr: string;
mobilityStatus: 'GO' | 'SLOW-GO' | 'NO-GO';
@@ -648,10 +721,9 @@ export interface TerrainStudyResult {
tacticalRecommendations: string[];
}
/**
* Military Tactical Terrain Intelligence & Environmental Study (OAKOC Doctrine)
* دراسة الأرض الشاملة: التضاريس، الموانع الطبيعية والصناعية، والمناطق المأهولة
* دراسة الأرض الشاملة عالية الدقة: حساب الميول الدقيقة، كشف القواطع والجروف الصخرية، وممرات الحركة
*/
export async function calculateTerrainStudy(
centerLat: number,
@@ -661,8 +733,8 @@ export async function calculateTerrainStudy(
const centerElev = await sampleElevationAt(centerLat, centerLng);
const areaKm2 = Math.round(Math.PI * Math.pow(radiusMeters / 1000, 2) * 10) / 10;
// Grid Resolution: 11x11 sampling matrix covering the bounding box
const gridSize = 11;
// High-Density Sampling Matrix: 21x21 = 441 nodes (400 terrain cells)
const gridSize = 21;
const R_earth = 6371000;
const dLatTotal = (radiusMeters / R_earth) * (180 / Math.PI);
const dLngTotal = (radiusMeters / (R_earth * Math.cos((centerLat * Math.PI) / 180))) * (180 / Math.PI);
@@ -672,68 +744,81 @@ export async function calculateTerrainStudy(
const minLng = centerLng - dLngTotal;
const maxLng = centerLng + dLngTotal;
// 1. Parallel sampling of all grid points
// 1. Generate all grid coordinates
const gridCoords: Array<{ r: number; c: number; lat: number; lng: number }> = [];
for (let r = 0; r < gridSize; r++) {
const lat = Number((minLat + (r / (gridSize - 1)) * (maxLat - minLat)).toFixed(5));
const lat = Number((minLat + (r / (gridSize - 1)) * (maxLat - minLat)).toFixed(6));
for (let c = 0; c < gridSize; c++) {
const lng = Number((minLng + (c / (gridSize - 1)) * (maxLng - minLng)).toFixed(5));
const lng = Number((minLng + (c / (gridSize - 1)) * (maxLng - minLng)).toFixed(6));
gridCoords.push({ r, c, lat, lng });
}
}
// 2. High-speed parallel batch sampling from DEM tiles
const sampleResults = await sampleElevationsBatch(gridCoords, 13);
const elevationGrid: number[][] = Array(gridSize).fill(0).map(() => Array(gridSize).fill(0));
const elevPromises = gridCoords.map(async ({ r, c, lat, lng }) => {
const el = await sampleElevationAt(lat, lng);
elevationGrid[r][c] = el;
return { r, c, lat, lng, el };
gridCoords.forEach((pt, idx) => {
elevationGrid[pt.r][pt.c] = sampleResults[idx];
});
const sampledPoints = await Promise.all(elevPromises);
// 2. Find Exact Peak and Valley
// 3. Precise Peak and Valley Identification
let maxElev = -9999;
let minElev = 9999;
let highestPoint = { lat: centerLat, lng: centerLng, elevation: centerElev, label: 'أعلى قمة' };
let lowestPoint = { lat: centerLat, lng: centerLng, elevation: centerElev, label: 'أخفض نقطة' };
sampledPoints.forEach(p => {
gridCoords.forEach((p, idx) => {
const el = sampleResults[idx];
const dist = calculateDistance(centerLat, centerLng, p.lat, p.lng);
if (dist <= radiusMeters * 1.05) {
if (p.el > maxElev) {
maxElev = p.el;
highestPoint = { lat: p.lat, lng: p.lng, elevation: p.el, label: `🔺 أعلى قمة: ${p.el}م` };
if (el > maxElev) {
maxElev = el;
highestPoint = { lat: p.lat, lng: p.lng, elevation: Math.round(el), label: `🔺 أعلى قمة: ${Math.round(el)}م` };
}
if (p.el < minElev) {
minElev = p.el;
lowestPoint = { lat: p.lat, lng: p.lng, elevation: p.el, label: `🔻 أخفض نقطة: ${p.el}م` };
if (el < minElev) {
minElev = el;
lowestPoint = { lat: p.lat, lng: p.lng, elevation: Math.round(el), label: `🔻 أخفض نقطة: ${Math.round(el)}م` };
}
}
});
const relief = maxElev - minElev;
const relief = Math.round(maxElev - minElev);
// 3. Compute Local Gradient (Slope) per cell and build Topographic Slope Polygons
// 4. Compute Local Gradients, Slopes, and Detect Natural Rock Barriers / Cliffs
const spatialFeatures: any[] = [];
const slopeValues: number[] = [];
const detectedCliffs: CliffFeature[] = [];
const dxMeters = (2 * radiusMeters) / (gridSize - 1);
const dyMeters = (2 * radiusMeters) / (gridSize - 1);
// Cell-by-cell topographic classification matching contour slopes
let flatCount = 0; // < 7 deg (GO)
let rollingCount = 0; // 7-15 deg (SLOW-GO)
let ruggedCount = 0; // 15-25 deg (SEVERE SLOW-GO)
let cliffCount = 0; // > 25 deg (NO-GO / Cliffs & Rock Barriers)
for (let r = 0; r < gridSize - 1; r++) {
for (let c = 0; c < gridSize - 1; c++) {
const cellCenterLat = (gridCoords[r * gridSize + c].lat + gridCoords[(r + 1) * gridSize + (c + 1)].lat) / 2;
const cellCenterLng = (gridCoords[r * gridSize + c].lng + gridCoords[(r + 1) * gridSize + (c + 1)].lng) / 2;
const p1Coord = gridCoords[r * gridSize + c];
const p2Coord = gridCoords[r * gridSize + (c + 1)];
const p3Coord = gridCoords[(r + 1) * gridSize + (c + 1)];
const p4Coord = gridCoords[(r + 1) * gridSize + c];
const cellCenterLat = (p1Coord.lat + p3Coord.lat) / 2;
const cellCenterLng = (p1Coord.lng + p3Coord.lng) / 2;
const distFromCenter = calculateDistance(centerLat, centerLng, cellCenterLat, cellCenterLng);
if (distFromCenter > radiusMeters * 1.02) continue;
if (distFromCenter > radiusMeters * 1.03) continue;
const z00 = elevationGrid[r][c];
const z01 = elevationGrid[r][c + 1];
const z10 = elevationGrid[r + 1][c];
const z11 = elevationGrid[r + 1][c + 1];
const cellElevAvg = Math.round((z00 + z01 + z10 + z11) / 4);
// Central difference gradient
const dz_dx = ((z01 + z11) - (z00 + z10)) / (2 * dxMeters);
const dz_dy = ((z10 + z11) - (z00 + z01)) / (2 * dyMeters);
@@ -741,58 +826,118 @@ export async function calculateTerrainStudy(
const slopeDeg = Math.round((slopeRad * (180 / Math.PI)) * 10) / 10;
slopeValues.push(slopeDeg);
let cat = 'go';
let catColor = '#22c55e';
let catLabel = `ممر حركة منبسط (GO) [${slopeDeg}°]`;
// Local maximum vertical step drop across cell corners
const localMaxZ = Math.max(z00, z01, z10, z11);
const localMinZ = Math.min(z00, z01, z10, z11);
const localDrop = Math.round(localMaxZ - localMinZ);
if (slopeDeg > 12) {
cat = 'no-go';
catColor = '#ef4444';
catLabel = `جرف ومنحدر وعر (NO-GO) [${slopeDeg}°]`;
} else if (slopeDeg >= 5) {
cat = 'slow-go';
catColor = '#eab308';
catLabel = `تضاريس متموجة (SLOW-GO) [${slopeDeg}°]`;
const p1 = [p1Coord.lng, p1Coord.lat];
const p2 = [p2Coord.lng, p2Coord.lat];
const p3 = [p3Coord.lng, p3Coord.lat];
const p4 = [p4Coord.lng, p4Coord.lat];
// Military Grade Slope & Barrier Classification:
// - Flat (GO): < 7°
// - Rolling (SLOW-GO): 7° - 15°
// - Rugged / Steep (SEVERE SLOW-GO): 15° - 25°
// - Cliffs & Natural Rock Barriers (NO-GO): > 25° or vertical drop >= 12m
const isCliff = slopeDeg >= 25 || (slopeDeg >= 18 && localDrop >= 10) || localDrop >= 18;
if (isCliff) {
cliffCount++;
detectedCliffs.push({
lat: Number(cellCenterLat.toFixed(5)),
lng: Number(cellCenterLng.toFixed(5)),
dropMeters: localDrop,
slopeDegrees: slopeDeg,
label: `🧗‍♂️ قاطع صخري وجرف حاد (سقوط ${localDrop}م - ميل ${slopeDeg}°)`,
tacticalImpact: 'مانع طبيعي قطعي - غير قابل لاجتياز الدروع والآليات، يفرض ممرات إجبارية'
});
// Add prominent cliff barrier polygon & line
spatialFeatures.push({
type: 'Feature',
properties: {
layerType: 'cliff',
category: 'natural-obstacle',
color: '#dc2626',
title: `🧗‍♂️ قاطع صخري وجرف حاد (${localDrop}م / ${slopeDeg}°)`,
slope: slopeDeg,
drop: localDrop,
elevation: cellElevAvg
},
geometry: {
type: 'Polygon',
coordinates: [[p1, p2, p3, p4, p1]]
}
});
} else if (slopeDeg >= 15) {
ruggedCount++;
spatialFeatures.push({
type: 'Feature',
properties: {
layerType: 'slope-sector',
category: 'severe-slow-go',
color: '#f97316',
title: `انحدار جبلي وعر (SEVERE SLOW-GO) [${slopeDeg}°]`,
slope: slopeDeg,
elevation: cellElevAvg
},
geometry: {
type: 'Polygon',
coordinates: [[p1, p2, p3, p4, p1]]
}
});
} else if (slopeDeg >= 7) {
rollingCount++;
spatialFeatures.push({
type: 'Feature',
properties: {
layerType: 'slope-sector',
category: 'slow-go',
color: '#eab308',
title: `تضاريس متموجة (SLOW-GO) [${slopeDeg}°]`,
slope: slopeDeg,
elevation: cellElevAvg
},
geometry: {
type: 'Polygon',
coordinates: [[p1, p2, p3, p4, p1]]
}
});
} else {
flatCount++;
spatialFeatures.push({
type: 'Feature',
properties: {
layerType: 'slope-sector',
category: 'go',
color: '#22c55e',
title: `ممر حركة منبسط (GO) [${slopeDeg}°]`,
slope: slopeDeg,
elevation: cellElevAvg
},
geometry: {
type: 'Polygon',
coordinates: [[p1, p2, p3, p4, p1]]
}
});
}
const p1 = [gridCoords[r * gridSize + c].lng, gridCoords[r * gridSize + c].lat];
const p2 = [gridCoords[r * gridSize + (c + 1)].lng, gridCoords[r * gridSize + (c + 1)].lat];
const p3 = [gridCoords[(r + 1) * gridSize + (c + 1)].lng, gridCoords[(r + 1) * gridSize + (c + 1)].lat];
const p4 = [gridCoords[(r + 1) * gridSize + c].lng, gridCoords[(r + 1) * gridSize + c].lat];
spatialFeatures.push({
type: 'Feature',
properties: {
layerType: 'slope-sector',
category: cat,
color: catColor,
title: catLabel,
slope: slopeDeg,
elevation: Math.round((z00 + z01 + z10 + z11) / 4)
},
geometry: {
type: 'Polygon',
coordinates: [[p1, p2, p3, p4, p1]]
}
});
}
}
// Slope stats
// 5. Statistics of Slope Distribution
const totalCells = Math.max(1, slopeValues.length);
const flatPct = Math.round((flatCount / totalCells) * 100);
const modPct = Math.round((rollingCount / totalCells) * 100);
const ruggedPct = Math.round((ruggedCount / totalCells) * 100);
const cliffPct = Math.max(0, 100 - flatPct - modPct - ruggedPct);
const avgSlope = slopeValues.length > 0 ? Math.round((slopeValues.reduce((a, b) => a + b, 0) / slopeValues.length) * 10) / 10 : 2.5;
const maxSlope = slopeValues.length > 0 ? Math.round(Math.max(...slopeValues) * 10) / 10 : 5.0;
const flatCount = slopeValues.filter(s => s < 5).length;
const modCount = slopeValues.filter(s => s >= 5 && s <= 12).length;
const steepCount = slopeValues.filter(s => s > 12).length;
const totalCells = Math.max(1, slopeValues.length);
const flatPct = Math.round((flatCount / totalCells) * 100);
const modPct = Math.round((modCount / totalCells) * 100);
const steepPct = Math.max(0, 100 - flatPct - modPct);
// 4. Trace the Natural Valley / Drainage Line (Wadi) using DEM lowest cells
const valleyPoints: [number, number][] = [];
// 6. Trace Drainage Defiles & Natural Wadis (Lowland Thalweg Corridors)
const valleySegments: [number, number][] = [];
for (let r = 0; r < gridSize; r++) {
let minRowElev = 9999;
let minColIdx = 0;
@@ -804,29 +949,29 @@ export async function calculateTerrainStudy(
}
const pt = gridCoords[r * gridSize + minColIdx];
const dist = calculateDistance(centerLat, centerLng, pt.lat, pt.lng);
if (dist <= radiusMeters * 1.05) {
valleyPoints.push([pt.lng, pt.lat]);
if (dist <= radiusMeters * 1.02) {
valleySegments.push([pt.lng, pt.lat]);
}
}
if (valleyPoints.length >= 2) {
if (valleySegments.length >= 3) {
spatialFeatures.push({
type: 'Feature',
properties: {
layerType: 'wadi',
category: 'natural-obstacle',
title: `🌊 مجرى وادٍ رئيسي ومصرف سيل طبيعي (${minElev}م)`,
title: `🌊 بطن وادٍ ومصرف سيل طبيعي (${minElev}م)`,
color: '#06b6d4'
},
geometry: {
type: 'LineString',
coordinates: valleyPoints
coordinates: valleySegments
}
});
}
// 5. Detect and Draw Dominant Mountain Ridge (Connecting High Points)
const ridgePoints: [number, number][] = [];
// 7. Trace Dominant Mountain Ridges (Key Terrain Crest Lines)
const ridgeSegments: [number, number][] = [];
for (let r = 0; r < gridSize; r++) {
let maxRowElev = -9999;
let maxColIdx = 0;
@@ -838,28 +983,28 @@ export async function calculateTerrainStudy(
}
const pt = gridCoords[r * gridSize + maxColIdx];
const dist = calculateDistance(centerLat, centerLng, pt.lat, pt.lng);
if (dist <= radiusMeters * 1.05 && maxRowElev > minElev + relief * 0.6) {
ridgePoints.push([pt.lng, pt.lat]);
if (dist <= radiusMeters * 1.02 && maxRowElev >= minElev + relief * 0.5) {
ridgeSegments.push([pt.lng, pt.lat]);
}
}
if (ridgePoints.length >= 2) {
if (ridgeSegments.length >= 3) {
spatialFeatures.push({
type: 'Feature',
properties: {
layerType: 'ridge',
category: 'key-terrain',
title: `⛰️ سلسلة تلال حاكمة (Dominant Ridge - ${maxElev}م)`,
title: `⛰️ سلسلة تلال حاكمة (Dominant Crest - ${maxElev}م)`,
color: '#38bdf8'
},
geometry: {
type: 'LineString',
coordinates: ridgePoints
coordinates: ridgeSegments
}
});
}
// 6. Real Regional Sector Identification
// 8. Regional Sector Intelligence Context
let sectorName = 'قطاع عمليات ميداني';
let terrainClassificationAr = 'هضاب وتلال متوسطة الوعورة';
let terrainClassification = 'Rolling Hills & Ridges';
@@ -869,7 +1014,6 @@ export async function calculateTerrainStudy(
let soilType = 'تربة طينية صخرية جافة';
let trafficability = 'جيدة للمركبات المدولبة والمجنزرة مع الحذر في مجاري السيول';
// Geolocation Specific Context (Zarqa / Amman / Irbid / Mafraq / Dead Sea / South)
if (centerLat >= 32.05 && centerLat <= 32.20 && centerLng >= 36.00 && centerLng <= 36.18) {
sectorName = 'قاطع الزرقاء والسخنة / حوض سيل الزرقاء';
terrainClassificationAr = 'حوض وادي صدعي واسع تحيط به تلال وهضاب حاكمة ومجرى سيل مائي';
@@ -878,16 +1022,16 @@ export async function calculateTerrainStudy(
urbanDensityAr = 'كثافة عالية (بلدات وضواحي ممتدة)';
settlements = ['بلدة السخنة', 'خربة قنتول', 'ضاحية البستان', 'أحياء الهاشمية', 'مدينة الزرقاء'];
soilType = 'طمي رسوبي في بطن الوادي وصخور كلسية على الأكتاف';
trafficability = 'ممتازة على شبكة الطرق وبطون الأودية المنبسطة (GO) مع صعوبة صعود الجروف الصخرية';
trafficability = 'ممتازة على شبكة الطرق وبطون الأودية المنبسطة (GO) مع وجود قواطع صخرية على الأكتاف';
} else if (centerLat > 32.3 && centerLng < 36.0) {
sectorName = 'قاطع الشمال / حوض اليرموك وإربد';
terrainClassificationAr = 'مرتفعات جبلية تتخللها أودية سحيقة وخوانق مائية';
terrainClassification = 'Highland Plateaus with Deep Gorges';
terrainClassificationAr = 'مرتفعات جبلية تتخللها أودية سحيقة وخوانق مائية وجروف صخرية';
terrainClassification = 'Highland Plateaus with Deep Gorges & Rock Cliffs';
urbanDensity = 'MEDIUM';
urbanDensityAr = 'كثافة متوسطة (قرى وضواحي ممتدة)';
settlements = ['بلدات وضواحي إربد', 'قرى حوض اليرموك', 'مزارع ومحميات وادي الشلالة'];
soilType = 'تربة زراعية خصبة وصخور كلسية';
trafficability = 'صعبة على حواف الأودية - تتطلب محاور طرق رئيسية';
soilType = 'تربة زراعية خصبة وصخور كلسية صلبة';
trafficability = 'مقيدة بالجروف الصخرية الحادة - تتطلب محاور طرق معبدة';
} else if (centerLat >= 31.85 && centerLat <= 32.05 && centerLng >= 35.8 && centerLng <= 36.02) {
sectorName = 'قاطع الوسط / إقليم العاصمة عمان';
terrainClassificationAr = 'هضاب وكتل جبلية حضرية مكتظة بالعمران';
@@ -908,26 +1052,35 @@ export async function calculateTerrainStudy(
trafficability = 'حركة حرة مفتوحة للدروع والآليات الصحراوية (Cross-Country GO)';
} else if (centerLat < 31.8 && centerLng < 35.7) {
sectorName = 'قاطع الغور / المنخفض الصدعي والبحر الميت';
terrainClassificationAr = 'أخفـض نقطة على سطح الأرض (جروف حادة ومنحدرات قاسية)';
terrainClassification = 'Rift Valley Escarpment (-400m MSL)';
terrainClassificationAr = 'أخفـض نقطة على سطح الأرض (جروف صخرية وقواطع حادة)';
terrainClassification = 'Rift Valley Escarpment (-400m MSL) with Rock Cliffs';
urbanDensity = 'LOW';
urbanDensityAr = 'كثافة منخفضة (مزارع الغور والمنتجعات)';
settlements = ['مزارع الأغوار الجنوبية', 'مجمعات الفنادق ومصانع البوتاس', 'قرى غور الصافي'];
soilType = 'طمي نهري وسبخات ملحية وصخور رسوبية';
trafficability = 'حذرة جداً بجانب السبخات - ممتازة على طريق البحر الميت الرئيسي';
soilType = 'طمي نهري وسبخات ملحية وصخور رسوبية قاسية';
trafficability = 'جروف صخرية مانعة (NO-GO) على الحواف الغربية والشرقية';
}
// Mobility status calculation
// 9. Mobility Assessment
let mobilityStatus: 'GO' | 'SLOW-GO' | 'NO-GO' = 'GO';
let mobilityStatusAr = 'حركة حرة ممتازة للآليات والدروع (Cross-Country GO)';
if (steepPct > 30 || avgSlope > 12) {
if (cliffPct > 12 || ruggedPct > 25 || avgSlope > 14) {
mobilityStatus = 'NO-GO';
mobilityStatusAr = 'مانع تضاريسي - حركة مقيدة وشديدة الصعوبة (NO-GO)';
} else if (steepPct > 10 || modPct > 35 || avgSlope > 6) {
mobilityStatusAr = 'مانع تضاريسي - قواطع صخرية وانحدارات حادة تعيق المناورة (NO-GO)';
} else if (cliffPct > 3 || ruggedPct > 10 || modPct > 30 || avgSlope > 6) {
mobilityStatus = 'SLOW-GO';
mobilityStatusAr = 'حركة بطيئة تتطلب مسارب وتجهيزاً هندسياً (SLOW-GO)';
mobilityStatusAr = 'تضاريس متموجة مقيدة تتطلب مسارب وتجهيزاً هندسياً (SLOW-GO)';
}
// Sort and pick top distinct cliffs
const topCliffs = detectedCliffs
.sort((a, b) => b.dropMeters - a.dropMeters || b.slopeDegrees - a.slopeDegrees)
.slice(0, 8);
const cliffSummaryText = topCliffs.length > 0
? `تم رصد ${detectedCliffs.length} قاطعاً وجرفاً صخرياً حاداً (أقصى سقوط ${topCliffs[0].dropMeters}م بميل ${topCliffs[0].slopeDegrees}°). تشكل هذه القواطع موانع طبيعية مانعة لحركة الآليات والدروع.`
: `القطاع خالٍ من الجروف الصخرية العمودية، والميول العامة تتراوح بين منبسطة ومتموجة معتدلة (أقصى انحدار ${maxSlope}°).`;
return {
centerLat,
centerLng,
@@ -937,14 +1090,17 @@ export async function calculateTerrainStudy(
centerElevation: Math.round(centerElev),
minElevation: Math.round(minElev),
maxElevation: Math.round(maxElev),
reliefMeters: Math.round(relief),
reliefMeters: relief,
avgSlopeDegrees: avgSlope,
maxSlopeDegrees: maxSlope,
slopeDistribution: {
flatPercentage: flatPct,
moderatePercentage: modPct,
steepPercentage: steepPct
steepPercentage: ruggedPct,
cliffPercentage: cliffPct
},
cliffsCount: detectedCliffs.length,
cliffs: topCliffs,
terrainClassification,
terrainClassificationAr,
mobilityStatus,
@@ -956,32 +1112,32 @@ export async function calculateTerrainStudy(
features: spatialFeatures
},
naturalObstacles: [
{
name: 'القواطع والجروف الصخرية الطبيعية (Cliffs & Rock Escarpments)',
type: 'موانع وقواطع صخرية حادة',
description: cliffSummaryText,
impact: topCliffs.length > 0
? 'إغلاق محاور الالتفاف الجانبي وإجبار القوات المهاجمة على استخدام ممرات إجبارية (Choke Points)'
: 'حرية مناورة واسعة بدون عوائق صخرية عمودية'
},
{
name: 'مجاري الوديان والسيول الطبيعية (Drainage Defiles)',
type: 'مصارف مائية وأودية',
description: `بطن الوادي عند منسوب ${minElev}م يشكل مصرفاً مائياً رئيسياً، يوفر ممراً محجوباً ولكن قد يعيق المناورة عند هطول الأمطار.`,
impact: 'محور تسلل مناسب للمشاة ومقيد للآليات الثقيلة عند تشكل الأوحال'
description: `بطن الوادي عند منسوب ${minElev}م يشكل مصرفاً مائياً ومحور حركة منخفضاً، يوفر ممراً محجوباً ولكن قد يعيق المناورة عند هطول الأمطار.`,
impact: 'محور تسلل مناسب للمشاة ومقيد للآليات الثقيلة عند تشكل الأوحال والسيول'
},
{
name: 'التلال والقمم الحاكمة (Dominant Ridges)',
name: 'التلال والقمم الحاكمة (Dominant Ridges & Crests)',
type: 'أرض مسيطرة (Key Terrain)',
description: `المرتفع الحاكم بارتفاع ${maxElev}م يشرف بشكل كامل على بطن الوادي ومحاور التحرك على مسافة ${radiusMeters / 1000} كم.`,
impact: 'موقع استراتيجي لتمركز أسلحة الإسناد ونقاط الملاحظة والاستطلاع'
},
{
name: 'المنحدرات التضاريسية المحيطة',
type: 'انحدار طبوغرافي',
description: steepPct > 0
? `يوجد ${steepPct}% من مساحة القطاع ذات انحدار وعر (>12°) تشكل موانع طبيعية تعيق صعود الآليات.`
: `القطاع يتسم بانحدارات لطيفة إلى متوسطة (أقصى انحدار ${maxSlope}°)، ولا توجد جروف شاهقة مانعة لحركة الدروع.`,
impact: steepPct > 0 ? 'إجبار القوات المهاجمة على استخدام الممرات المعبدة' : 'حرية مناورة واسعة للدروع في مختلف الاتجاهات'
description: `المرتفع الحاكم بارتفاع ${maxElev}م يشرف بشكل كامل على بطن الوادي ومحاور التحرك على مسافة ${radiusMeters / 1000} كم بفارق تضاريسي ${relief}م.`,
impact: 'موقع استراتيجي لتمركز أسلحة الإسناد ونقاط الملاحظة والاستطلاع والسيطرة النارية'
}
],
manMadeObstacles: [
{
name: 'محاور الطرق الرئيسية وخطوط الإمداد (MSR)',
type: 'بنية تحتية للمواصلات',
description: 'الطرق المعبدة المارة بالوادي تتيح تدفقاً سريعاً للأرتال وإعادة التزود اللوجستي.',
description: 'الطرق المعبدة المارة بالقطاع تتيح تدفقاً سريعاً للأرتال وإعادة التزود اللوجستي.',
tacticalNote: 'وجوب إعداد خطط قطع وغلق هندسي سريع للتحكم بحركة العدو'
},
{
@@ -1011,17 +1167,20 @@ export async function calculateTerrainStudy(
airObservationExposure: urbanDensity === 'HIGH' ? 'مختلطة (المباني توفر إخفاء جزئياً)' : 'عالية للرصد الجوي والمسيرات نهاراً'
},
oakocAssessment: {
obstacles: `القطاع يتضمن مجرى وادٍ رئيسي عند منسوب ${minElev}م مع انحدارات تصل إلى ${maxSlope}°، مما يشكل محاور حركة طبيعية محددة.`,
avenuesOfApproach: 'محور الاقتراب الرئيسي يتبع بطن الوادي وشبكة الطرق الرئيسية، مع إمكانية الالتفاف عبر الهضاب المنبسطة.',
obstacles: `القطاع يضم ${detectedCliffs.length > 0 ? `${detectedCliffs.length} قاطعاً وجرفاً صخرياً و` : ''}مجرى وادٍ رئيسي عند منسوب ${minElev}م، مع انحدارات تصل إلى ${maxSlope}°، مما يشكل محاور حركة طبيعية محددة.`,
avenuesOfApproach: 'محور الاقتراب الرئيسي يتبع بطن الوادي وشبكة الطرق الرئيسية المنبسطة، مع تفادي الجروف الصخرية الحادة.',
keyTerrain: `القمة الحاكمة بارتفاع ${maxElev}م توفر ميزة كشف وهيمنة نارية حاسمة على كامل القطاع.`,
observationAndFieldsOfFire: `حقول الرماية مكشوفة ومثالية بنسبة ${flatPct}% في الأراضي المنبسطة، ومحجوبة داخل بطون الأودية.`,
coverAndConcealment: 'تتوفر السواتر التضاريسية في المنحدرات الخلفية (Reverse Slopes) وداخل التجمعات العمرانية.'
observationAndFieldsOfFire: `حقول الرماية مكشوفة ومثالية بنسبة ${flatPct}% في الأراضي المنبسطة، ومحجوبة داخل بطون الأودية وخلف الجروف.`,
coverAndConcealment: 'تتوفر السواتر التضاريسية في المنحدرات الخلفية (Reverse Slopes) وأسفل الجروف الصخرية وداخل الكتل العمرانية.'
},
tacticalRecommendations: [
`نشر مرصد استطلاع رئيسي ورادار كشف أرضي على المرتفع الحاكم (${maxElev}م) لتأمين إنذار مبكر ومراقبة بطن الوادي.`,
`تجهيز نقاط كمائن وموانع هندسية وحقول ألغام موجهة في مخانق بطن الوادي (${minElev}م) لصد أي تقدم مدرع.`,
`نشر مرصد استطلاع رئيسي ورادار كشف أرضي على المرتفع الحاكم (${maxElev}م) لتأمين إنذار مبكر ومراقبة القطاع.`,
detectedCliffs.length > 0
? `استغلال القواطع والجروف الصخرية المكتشفة كحواجز طبيعية موجهة لحصر تقدم العدو داخل المخانق المستهدفة بنيران الأسلحة الثقيلة.`
: `تجهيز نقاط كمائن وموانع هندسية وحقول ألغام موجهة في مخانق بطن الوادي (${minElev}م) لصد أي تقدم مدرع.`,
`تأمين الطرق الرئيسية والمحاور الحيوية بنقاط سيطرة وحراسة ثابتة لمنع عمليات الالتفاف السريعة.`,
`استغلال المناطق المنبسطة (${flatPct}%) لإنشاء مهابط المروحيات (HLZ) ومناطق الإخلاء الطبي والتجمع اللوجستي.`
]
};
}