Files
maps-saas/apps/web/src/utils/elevationService.ts
T

1935 lines
74 KiB
TypeScript

/**
* Tactical Elevation & Line of Sight (LOS) Calculation Service
* خدمة حساب مقطع الارتفاع التضاريسي وتبادل الرؤية العسكري (Intervisibility)
*/
export interface ElevationPoint {
distance: number; // Distance from observer (meters)
lat: number;
lng: number;
elevation: number; // Terrain elevation AMSL (meters)
rayHeight: number; // Line of Sight ray elevation at this distance (meters)
isVisible: boolean; // Can observer see this terrain point?
isTargetRayBlocked: boolean; // Does this terrain point block the ray to the final target?
clearance: number; // Clearance distance (rayHeight - elevation) in meters
}
export interface ObstacleInfo {
distance: number;
elevation: number;
lat: number;
lng: number;
excessHeight: number; // How much the obstacle penetrates above the ray (meters)
}
export interface LineOfSightResult {
points: ElevationPoint[];
totalDistance: number; // Total distance in meters
isDirectlyVisible: boolean; // Is target visible from observer?
observerElevation: number; // Ground elevation + observer height
targetElevation: number; // Ground elevation + target height
observerGroundElev: number; // Raw ground elevation
targetGroundElev: number; // Raw ground elevation
minElevation: number;
maxElevation: number;
highestObstacle: ObstacleInfo | null;
deadGroundPercentage: number; // % of line hidden behind crests
angleDegrees: number; // Vertical angle (degrees)
angleMils: number; // Military Artillery Mils (6400 mils = 360 deg)
azimuthDegrees: number; // Compass Bearing (0-360 deg)
}
// In-memory cache for DEM tile image data to avoid re-fetching
const tileCache = new Map<string, ImageData>();
/**
* Calculates Great-Circle Haversine distance in meters
*/
export function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000; // Earth radius in meters
const dLat = (lat2 - lat1) * (Math.PI / 180);
const dLon = (lon2 - lon1) * (Math.PI / 180);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * (Math.PI / 180)) * Math.cos(lat2 * (Math.PI / 180)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Calculates Forward Azimuth / Bearing (0-360 degrees)
*/
export function calculateAzimuth(lat1: number, lon1: number, lat2: number, lon2: number): number {
const phi1 = lat1 * (Math.PI / 180);
const phi2 = lat2 * (Math.PI / 180);
const deltaLambda = (lon2 - lon1) * (Math.PI / 180);
const y = Math.sin(deltaLambda) * Math.cos(phi2);
const x = Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(deltaLambda);
const theta = Math.atan2(y, x);
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;
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
const urlsToTry = [
`${apiUrl}/tactical/dem/${zoom}/${x}/${y}.png`,
`https://s3.amazonaws.com/elevation-tiles-prod/terrarium/${zoom}/${x}/${y}.png`
];
for (const tileUrl of urlsToTry) {
try {
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')), 4000))
]);
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 {
// Continue to next URL
}
}
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[]> {
// 1. Try sovereign backend API for zero-CORS 100% accurate satellite DEM
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2500);
const apiKey = localStorage.getItem('map_admin_key') || localStorage.getItem('intaleq_api_key') || (import.meta as any).env.VITE_ADMIN_API_KEY || (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
const res = await fetch(`${apiUrl}/tactical/elevations?key=${encodeURIComponent(apiKey)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
body: JSON.stringify({ coordinates: coords }),
signal: controller.signal
});
clearTimeout(timeoutId);
if (res.ok) {
const data = await res.json();
if (Array.isArray(data) && data.length === coords.length) {
return data;
}
}
} catch {
// Fall back to client tile decoding
}
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 = 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 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 imgData = await fetchTileImageData(zoom, x, y);
if (imgData) {
return Math.round(interpolateElevation(imgData, subX, subY));
}
return getApproximateElevation(lat, lng);
}
/**
* Topographic estimation model for Jordan terrain when tiles are offline
*/
function getApproximateElevation(lat: number, lng: number): number {
if (lng < 35.65 && lat < 32.5 && lat > 30.8) {
const riftCenter = 35.50;
const distFromRift = Math.abs(lng - riftCenter);
const riftElev = -420.0 + distFromRift * 3200.0;
if (distFromRift < 0.15) {
return Math.round(Math.max(-430.0, Math.min(1000.0, riftElev)));
}
}
const DEM_BENCHMARKS: [number, number, number][] = [
[32.0720, 36.0880, 610.0], // الزرقاء
[32.1150, 35.9550, 750.0], // بيرين
[32.1250, 36.1150, 580.0], // الهاشمية
[32.0250, 36.0350, 670.0], // الرصيفة
[31.9615, 35.9130, 740.0], // وسط البلد
[32.0220, 35.8450, 1060.0], // صويلح
[32.2780, 35.8950, 600.0], // جرش
[32.3250, 35.7350, 1150.0], // عجلون
[32.5450, 35.8550, 620.0], // إربد
[32.3560, 36.2590, 700.0], // المفرق
];
let num = 0.0;
let den = 0.0;
const latRad = lat * (Math.PI / 180.0);
for (const c of DEM_BENCHMARKS) {
const dLat = (lat - c[0]) * 111.0;
const dLng = (lng - c[1]) * 111.0 * Math.cos(latRad);
const distKm = Math.max(Math.sqrt(dLat * dLat + dLng * dLng), 0.15);
const w = 1.0 / Math.pow(distKm, 2.0);
num += w * c[2];
den += w;
}
return Math.round(den > 0 ? num / den : 620.0);
}
/**
* Calculates Line of Sight and Elevation Profile between two coordinates
*/
export async function calculateLineOfSight(
startLat: number,
startLng: number,
endLat: number,
endLng: number,
obsHeight: number = 2,
tgtHeight: number = 2,
samples: number = 60
): Promise<LineOfSightResult> {
const totalDistance = calculateDistance(startLat, startLng, endLat, endLng);
const azimuthDegrees = calculateAzimuth(startLat, startLng, endLat, endLng);
// Try fetching high-precision result from Backend Tactical API
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
const apiKey = (import.meta as any).env.VITE_ADMIN_API_KEY || (import.meta as any).env.VITE_API_KEY || localStorage.getItem('map_admin_key') || localStorage.getItem('intaleq_api_key') || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const res = await fetch(`${apiUrl}/tactical/line-of-sight?observerLat=${startLat}&observerLng=${startLng}&targetLat=${endLat}&targetLng=${endLng}&observerHeight=${obsHeight}&targetHeight=${tgtHeight}&samples=${samples}&key=${encodeURIComponent(apiKey)}`, {
headers: { 'x-api-key': apiKey },
signal: controller.signal
});
clearTimeout(timeoutId);
if (res.ok) {
const data = await res.json();
return {
points: (data.profile || []).map((p: any) => ({
distance: p.distanceMeters,
lat: p.lat,
lng: p.lng,
elevation: p.groundElevationMeters,
rayHeight: p.sightRayElevationMeters,
isVisible: p.isVisible,
isTargetRayBlocked: !p.isVisible && p.marginMeters < 0,
clearance: p.marginMeters
})),
totalDistance: data.summary.totalDistanceMeters,
isDirectlyVisible: data.isDirectlyVisible,
observerElevation: data.summary.observerTotalElevationMeters,
targetElevation: data.summary.targetTotalElevationMeters,
observerGroundElev: data.summary.observerGroundElevationMeters,
targetGroundElev: data.summary.targetGroundElevationMeters,
minElevation: data.summary.minElevationMeters,
maxElevation: data.summary.maxElevationMeters,
highestObstacle: data.highestObstacle ? {
distance: data.highestObstacle.distanceMeters ?? data.highestObstacle.distance,
elevation: data.highestObstacle.elevationMeters ?? data.highestObstacle.groundElevationMeters ?? data.highestObstacle.elevation,
lat: data.highestObstacle.lat,
lng: data.highestObstacle.lng,
excessHeight: data.highestObstacle.penetrationMeters ?? data.highestObstacle.excessHeightMeters ?? data.highestObstacle.excessHeight
} : null,
deadGroundPercentage: data.summary.deadGroundPercentage,
angleDegrees: data.summary.verticalAngleDegrees,
angleMils: data.summary.verticalAngleMilsNato,
azimuthDegrees: data.summary.azimuthDegrees
};
}
} catch {
// Fall back to local DEM processing
}
// Generate sample coordinates along the geodesic path
const sampleCoords: { lat: number; lng: number; dist: number }[] = [];
for (let i = 0; i <= samples; i++) {
const fraction = i / samples;
const lat = startLat + (endLat - startLat) * fraction;
const lng = startLng + (endLng - startLng) * fraction;
const dist = totalDistance * fraction;
sampleCoords.push({ lat, lng, dist });
}
// 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;
const R_earth = 6371000;
const k_refraction = 0.13;
const effectiveEarthRadius = R_earth / (1 - k_refraction);
let isDirectlyVisible = true;
let highestObstacle: ObstacleInfo | null = null;
let maxObstacleExcess = 0;
let deadGroundCount = 0;
let maxAngleSoFar = -Infinity;
const points: ElevationPoint[] = [];
let minElev = Infinity;
let maxElev = -Infinity;
for (let i = 0; i <= samples; i++) {
const d = sampleCoords[i].dist;
const elev = elevations[i];
minElev = Math.min(minElev, elev);
maxElev = Math.max(maxElev, elev);
const earthCurvatureDrop = (d * (totalDistance - d)) / (2 * effectiveEarthRadius);
const rayHeight = observerElevation + ((targetElevation - observerElevation) * (d / totalDistance)) - earthCurvatureDrop;
const clearance = rayHeight - elev;
let isTargetRayBlocked = false;
if (i > 1 && i < samples) {
if (elev > rayHeight) {
isDirectlyVisible = false;
isTargetRayBlocked = true;
const excess = elev - rayHeight;
if (excess > maxObstacleExcess) {
maxObstacleExcess = excess;
highestObstacle = {
distance: Math.round(d),
elevation: Math.round(elev),
lat: sampleCoords[i].lat,
lng: sampleCoords[i].lng,
excessHeight: Math.round(excess * 10) / 10,
};
}
}
}
let isVisible = true;
if (i === 0) {
isVisible = true;
} else {
const angleFromObs = (elev - observerElevation) / d;
if (angleFromObs >= maxAngleSoFar) {
maxAngleSoFar = angleFromObs;
isVisible = true;
} else {
isVisible = false;
deadGroundCount++;
}
}
points.push({
distance: Math.round(d),
lat: sampleCoords[i].lat,
lng: sampleCoords[i].lng,
elevation: Math.round(elev),
rayHeight: Math.round(rayHeight * 10) / 10,
isVisible,
isTargetRayBlocked,
clearance: Math.round(clearance * 10) / 10,
});
}
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 {
points,
totalDistance: Math.round(totalDistance),
isDirectlyVisible,
observerElevation: Math.round(observerElevation),
targetElevation: Math.round(targetElevation),
observerGroundElev: Math.round(observerGroundElev),
targetGroundElev: Math.round(targetGroundElev),
minElevation: Math.round(minElev),
maxElevation: Math.round(maxElev),
highestObstacle,
deadGroundPercentage,
angleDegrees,
angleMils,
azimuthDegrees: Math.round(azimuthDegrees * 10) / 10,
};
}
export interface Viewshed360Result {
centerLat: number;
centerLng: number;
centerElevation: number;
radiusMeters: number;
polygonGeoJson: any;
totalRays: number;
visibleAreaKm2: number;
visiblePercentage: number;
}
/**
* Calculates 360-degree Radial Viewshed around observer
*/
export async function calculateRadialViewshed(
centerLat: number,
centerLng: number,
obsHeight: number = 2,
radiusMeters: number = 5000,
numRays: number = 72
): Promise<Viewshed360Result> {
// 1. Try sovereign backend API for zero-CORS 100% accurate satellite DEM Viewshed
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3500);
const apiKey = (import.meta as any).env.VITE_ADMIN_API_KEY || (import.meta as any).env.VITE_API_KEY || localStorage.getItem('map_admin_key') || localStorage.getItem('intaleq_api_key') || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
const res = await fetch(`${apiUrl}/tactical/viewshed?lat=${centerLat}&lng=${centerLng}&height=${obsHeight}&radius=${radiusMeters}&rays=${numRays}&key=${encodeURIComponent(apiKey)}`, {
headers: { 'x-api-key': apiKey },
signal: controller.signal
});
clearTimeout(timeoutId);
if (res.ok) {
const data = await res.json();
return {
centerLat,
centerLng,
radiusMeters,
polygonGeoJson: data.polygon,
totalRays: numRays,
visibleAreaKm2: data.coveredAreaKm2,
visiblePercentage: data.coveragePercent
};
}
} catch {
// Fall back to client calculation
}
const centerGroundElev = await sampleElevationAt(centerLat, centerLng);
const observerElevation = centerGroundElev + obsHeight;
const R_earth = 6371000;
const k_refraction = 0.13;
const effectiveEarthRadius = R_earth / (1 - k_refraction);
const samplesPerRay = 20;
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;
const angleRad = (angleDeg * Math.PI) / 180;
const dLat = (radiusMeters / R_earth) * (180 / Math.PI) * Math.cos(angleRad);
const dLng = (radiusMeters / (R_earth * Math.cos((centerLat * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(angleRad);
const endLat = centerLat + dLat;
const endLng = centerLng + dLng;
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 });
}
}
// 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) / rc.sDist;
if (angle >= maxAngleSoFar) {
maxAngleSoFar = angle;
visibleHorizonDist = rc.sDist;
visibleHorizonLat = rc.sLat;
visibleHorizonLng = rc.sLng;
}
});
totalVisibleDistanceSum += visibleHorizonDist;
polygonCoordinates.push([visibleHorizonLng, visibleHorizonLat]);
}
if (polygonCoordinates.length > 0) {
polygonCoordinates.push(polygonCoordinates[0]);
}
const polygonGeoJson = {
type: 'Feature',
properties: {
centerLat,
centerLng,
radiusMeters
},
geometry: {
type: 'Polygon',
coordinates: [polygonCoordinates]
}
};
const avgVisibleDist = totalVisibleDistanceSum / numRays;
const theoreticalMaxArea = Math.PI * Math.pow(radiusMeters / 1000, 2);
const actualVisibleArea = Math.PI * Math.pow(avgVisibleDist / 1000, 2);
const visiblePercentage = Math.min(100, Math.round((actualVisibleArea / theoreticalMaxArea) * 100));
return {
centerLat,
centerLng,
centerElevation: Math.round(observerElevation),
radiusMeters,
polygonGeoJson,
totalRays: numRays,
visibleAreaKm2: Math.round(actualVisibleArea * 10) / 10,
visiblePercentage
};
}
export interface MinefieldAnalysisResult {
startLat: number;
startLng: number;
endLat: number;
endLng: number;
frontageMeters: number;
azimuthDegrees: number;
startElevation: number;
endElevation: number;
avgSlopeDegrees: number;
suitabilityScore: number; // 0-100%
suitabilityVerdict: string;
suitabilityVerdictAr: string;
isChokePoint: boolean;
quantities: {
antiTankMines: number;
antiPersonnelMines: number;
fuseSets: number;
warningSigns: number;
rowsCount: number;
depthMeters: number;
estimatedDeploymentHours: number;
};
}
/**
* Tactical Minefield Barrier & Sapper Engineering Analysis
*/
export async function calculateMinefieldAnalysis(
startLat: number,
startLng: number,
endLat: number,
endLng: number,
densityLevel: 'standard' | 'dense' | 'light' = 'standard'
): Promise<MinefieldAnalysisResult> {
const frontageMeters = calculateDistance(startLat, startLng, endLat, endLng);
const azimuthDegrees = calculateAzimuth(startLat, startLng, endLat, endLng);
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;
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;
const midElev = elevs[Math.floor(samples / 2)];
const isChokePoint = midElev < Math.min(startElev, endElev) - 10 || avgSlopeDegrees <= 10;
const densityFactor = densityLevel === 'dense' ? 1.5 : densityLevel === 'light' ? 0.6 : 1.0;
const atMinesPerMeter = 1.0 * densityFactor;
const apMinesPerMeter = 2.0 * densityFactor;
const antiTankMines = Math.round(frontageMeters * atMinesPerMeter);
const antiPersonnelMines = Math.round(frontageMeters * apMinesPerMeter);
const warningSigns = Math.max(4, Math.round(frontageMeters / 30));
const deploymentHours = Math.round(((frontageMeters / 150) * 2 * densityFactor) * 10) / 10;
let suitabilityScore = 85;
let suitabilityVerdict = 'Highly Effective Defensive Barrier';
let suitabilityVerdictAr = 'موضع دفاعي نموذجي يسد محور اقتراب الدروع بفعالية';
if (avgSlopeDegrees > 20) {
suitabilityScore = 55;
suitabilityVerdict = 'Steep Terrain: Armor movement already restricted by natural slope';
suitabilityVerdictAr = 'انحدار جبلي شديد: التضاريس تشكل مانعاً طبيعياً لحركة الدروع';
} else if (isChokePoint) {
suitabilityScore = 96;
suitabilityVerdict = 'Optimal Choke Point Defile Barrier';
suitabilityVerdictAr = 'ممر ومضيق إجباري مثالي (Choke Point) يمنع التفاف آليات العدو';
}
return {
startLat,
startLng,
endLat,
endLng,
frontageMeters: Math.round(frontageMeters),
azimuthDegrees: Math.round(azimuthDegrees * 10) / 10,
startElevation: Math.round(startElev),
endElevation: Math.round(endElev),
avgSlopeDegrees,
suitabilityScore,
suitabilityVerdict,
suitabilityVerdictAr,
isChokePoint,
quantities: {
antiTankMines,
antiPersonnelMines,
fuseSets: Math.round((antiTankMines + antiPersonnelMines) * 1.1),
warningSigns,
rowsCount: densityLevel === 'dense' ? 4 : 3,
depthMeters: densityLevel === 'dense' ? 75 : 50,
estimatedDeploymentHours: Math.max(1, deploymentHours)
}
};
}
export interface CliffFeature {
lat: number;
lng: number;
dropMeters: number;
slopeDegrees: number;
label: string;
tacticalImpact: string;
}
export interface TerrainStudyResult {
centerLat: number;
centerLng: number;
radiusMeters: number;
areaKm2: number;
sectorName: string;
centerElevation: number;
minElevation: number;
maxElevation: number;
reliefMeters: number;
avgSlopeDegrees: number;
maxSlopeDegrees: number;
slopeDistribution: {
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';
mobilityStatusAr: string;
highestPoint: {
lat: number;
lng: number;
elevation: number;
label: string;
};
lowestPoint: {
lat: number;
lng: number;
elevation: number;
label: string;
};
spatialGeoJson: {
type: 'FeatureCollection';
features: any[];
};
naturalObstacles: Array<{
name: string;
type: string;
description: string;
impact: string;
}>;
manMadeObstacles: Array<{
name: string;
type: string;
description: string;
tacticalNote: string;
}>;
urbanAndDemographics: {
density: 'HIGH' | 'MEDIUM' | 'LOW' | 'SPARSE';
densityAr: string;
settlements: string[];
moutComplexity: string;
collateralDamageRisk: string;
};
landCoverAndCover: {
soilType: string;
trafficability: string;
concealmentRating: string;
airObservationExposure: string;
};
oakocAssessment: {
obstacles: string;
avenuesOfApproach: string;
keyTerrain: string;
observationAndFieldsOfFire: string;
coverAndConcealment: string;
};
tacticalRecommendations: string[];
}
/**
* Military Tactical Terrain Intelligence & Environmental Study (OAKOC Doctrine)
* دراسة الأرض الشاملة عالية الدقة: حساب الميول الدقيقة، كشف القواطع والجروف الصخرية، وممرات الحركة
*/
export async function calculateTerrainStudy(
centerLat: number,
centerLng: number,
radiusMeters: number = 3000
): Promise<TerrainStudyResult> {
const centerElev = await sampleElevationAt(centerLat, centerLng);
const areaKm2 = Math.round(Math.PI * Math.pow(radiusMeters / 1000, 2) * 10) / 10;
// High-Density Sampling Matrix: 25x25 = 625 nodes (576 terrain cells)
const gridSize = 25;
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);
const minLat = centerLat - dLatTotal;
const maxLat = centerLat + dLatTotal;
const minLng = centerLng - dLngTotal;
const maxLng = centerLng + dLngTotal;
// 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(6));
for (let c = 0; c < gridSize; c++) {
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));
gridCoords.forEach((pt, idx) => {
elevationGrid[pt.r][pt.c] = sampleResults[idx];
});
// 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: 'أخفض نقطة' };
gridCoords.forEach((p, idx) => {
const el = sampleResults[idx];
const dist = calculateDistance(centerLat, centerLng, p.lat, p.lng);
if (dist <= radiusMeters * 1.05) {
if (el > maxElev) {
maxElev = el;
highestPoint = { lat: p.lat, lng: p.lng, elevation: Math.round(el), label: `🔺 أعلى قمة: ${Math.round(el)}م` };
}
if (el < minElev) {
minElev = el;
lowestPoint = { lat: p.lat, lng: p.lng, elevation: Math.round(el), label: `🔻 أخفض نقطة: ${Math.round(el)}م` };
}
}
});
const relief = Math.round(maxElev - minElev);
// 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);
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 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.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);
const slopeRad = Math.atan(Math.sqrt(dz_dx * dz_dx + dz_dy * dz_dy));
const slopeDeg = Math.round((slopeRad * (180 / Math.PI)) * 10) / 10;
slopeValues.push(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);
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° (< 12%)
// - Rolling (SLOW-GO): 7° - 15° (12% - 27%)
// - Rugged / Mountainous (SEVERE SLOW-GO): 15° - 28° (27% - 53%)
// - Natural Cliffs & Rock Escarpments (NO-GO): >= 28° (Physical vertical barrier)
const isCliff = slopeDeg >= 28;
if (isCliff) {
cliffCount++;
detectedCliffs.push({
lat: Number(cellCenterLat.toFixed(5)),
lng: Number(cellCenterLng.toFixed(5)),
dropMeters: localDrop,
slopeDegrees: slopeDeg,
label: `🧗‍♂️ جرف صخري حاد (${localDrop}م / ${slopeDeg}°)`,
tacticalImpact: 'مانع طبيعي قطعي - غير قابل لاجتياز الدروع والآليات، يفرض ممرات إجبارية'
});
// Add cliff barrier polygon without title label spam
spatialFeatures.push({
type: 'Feature',
properties: {
layerType: 'cliff',
category: 'natural-obstacle',
color: '#ef4444',
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',
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',
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',
slope: slopeDeg,
elevation: cellElevAvg
},
geometry: {
type: 'Polygon',
coordinates: [[p1, p2, p3, p4, p1]]
}
});
}
}
}
// Add Key Terrain POI Points (Only these will display text badges on the map)
spatialFeatures.push({
type: 'Feature',
properties: {
category: 'peak',
layerType: 'poi',
title: `▲ أعلى قمة: ${highestPoint.elevation}م`,
elevation: highestPoint.elevation,
color: '#22c55e'
},
geometry: {
type: 'Point',
coordinates: [highestPoint.lng, highestPoint.lat]
}
});
spatialFeatures.push({
type: 'Feature',
properties: {
category: 'valley',
layerType: 'poi',
title: `▼ أخفض نقطة: ${lowestPoint.elevation}م`,
elevation: lowestPoint.elevation,
color: '#06b6d4'
},
geometry: {
type: 'Point',
coordinates: [lowestPoint.lng, lowestPoint.lat]
}
});
// 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;
// 6. Scientific Hydrological D8 Flow Routing & Natural Wadi (Thalweg) Extraction
// Compute steepest descent neighbor for every grid cell
interface FlowTarget {
r: number;
c: number;
slope: number;
}
const flowTarget: (FlowTarget | null)[][] = Array(gridSize).fill(null).map(() => Array(gridSize).fill(null));
for (let r = 0; r < gridSize; r++) {
for (let c = 0; c < gridSize; c++) {
const zCurr = elevationGrid[r][c];
let maxDropSlope = 0;
let bestTarget: FlowTarget | null = null;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const nr = r + dr;
const nc = c + dc;
if (nr < 0 || nr >= gridSize || nc < 0 || nc >= gridSize) continue;
const zNeighbor = elevationGrid[nr][nc];
const distM = Math.hypot(dr * dyMeters, dc * dxMeters);
const drop = zCurr - zNeighbor;
const slope = drop / distM;
if (slope > maxDropSlope) {
maxDropSlope = slope;
bestTarget = { r: nr, c: nc, slope };
}
}
}
flowTarget[r][c] = bestTarget;
}
}
// Calculate Flow Accumulation (Upslope contributing area)
const flowAcc: number[][] = Array(gridSize).fill(1).map(() => Array(gridSize).fill(1));
// Sort cells from highest elevation to lowest
const sortedCells: Array<{ r: number; c: number; elev: number }> = [];
for (let r = 0; r < gridSize; r++) {
for (let c = 0; c < gridSize; c++) {
sortedCells.push({ r, c, elev: elevationGrid[r][c] });
}
}
sortedCells.sort((a, b) => b.elev - a.elev);
for (const cell of sortedCells) {
const target = flowTarget[cell.r][cell.c];
if (target) {
flowAcc[target.r][target.c] += flowAcc[cell.r][cell.c];
}
}
// Extract Thalweg Valley & Stream Channels
// Threshold for channel initiation: cells where accumulated flow exceeds minimum threshold
const minAccThreshold = Math.max(5, Math.floor(gridSize * 0.3));
const visitedStream = Array(gridSize).fill(false).map(() => Array(gridSize).fill(false));
// Find all channel heads (cells where flowAcc >= threshold, but none of upstream neighbors >= threshold)
const channelHeads: Array<{ r: number; c: number; acc: number }> = [];
for (let r = 1; r < gridSize - 1; r++) {
for (let c = 1; c < gridSize - 1; c++) {
if (flowAcc[r][c] >= minAccThreshold) {
// Check if any upstream neighbor that flows to (r, c) already had flowAcc >= minAccThreshold
let hasUpstreamThreshold = false;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const nr = r + dr;
const nc = c + dc;
if (nr >= 0 && nr < gridSize && nc >= 0 && nc < gridSize) {
const target = flowTarget[nr][nc];
if (target && target.r === r && target.c === c && flowAcc[nr][nc] >= minAccThreshold) {
hasUpstreamThreshold = true;
break;
}
}
}
if (hasUpstreamThreshold) break;
}
if (!hasUpstreamThreshold) {
channelHeads.push({ r, c, acc: flowAcc[r][c] });
}
}
}
}
// Trace streams downstream from each channel head
for (const head of channelHeads) {
const rawPath: [number, number][] = [];
const streamElevations: number[] = [];
let currR = head.r;
let currC = head.c;
let maxStreamAcc = head.acc;
let steps = 0;
while (currR >= 0 && currR < gridSize && currC >= 0 && currC < gridSize && steps < gridSize * 2) {
steps++;
const pt = gridCoords[currR * gridSize + currC];
const distFromCenter = calculateDistance(centerLat, centerLng, pt.lat, pt.lng);
if (distFromCenter <= radiusMeters * 1.05) {
rawPath.push([pt.lng, pt.lat]);
streamElevations.push(elevationGrid[currR][currC]);
if (flowAcc[currR][currC] > maxStreamAcc) {
maxStreamAcc = flowAcc[currR][currC];
}
}
visitedStream[currR][currC] = true;
const next = flowTarget[currR][currC];
if (!next) break; // Reached sink or local depression
if (visitedStream[next.r][next.c]) {
// Confluence with existing river channel
const confluencePt = gridCoords[next.r * gridSize + next.c];
rawPath.push([confluencePt.lng, confluencePt.lat]);
streamElevations.push(elevationGrid[next.r][next.c]);
break;
}
currR = next.r;
currC = next.c;
}
// Only keep streams that have at least 3 points and actual length
if (rawPath.length >= 3) {
// Smooth the stream line using moving average
const smoothedPath: [number, number][] = [];
for (let i = 0; i < rawPath.length; i++) {
if (i === 0 || i === rawPath.length - 1) {
smoothedPath.push(rawPath[i]);
} else {
const pPrev = rawPath[i - 1];
const pCurr = rawPath[i];
const pNext = rawPath[i + 1];
smoothedPath.push([
Number((pPrev[0] * 0.25 + pCurr[0] * 0.5 + pNext[0] * 0.25).toFixed(6)),
Number((pPrev[1] * 0.25 + pCurr[1] * 0.5 + pNext[1] * 0.25).toFixed(6))
]);
}
}
const streamMinZ = Math.min(...streamElevations);
const streamMaxZ = Math.max(...streamElevations);
spatialFeatures.push({
type: 'Feature',
properties: {
layerType: 'wadi',
category: 'natural-obstacle',
title: `🌊 مجرى وادٍ ومصرف سيل طبيعي (${Math.round(streamMinZ)}م - ${Math.round(streamMaxZ)}م)`,
flowAcc: maxStreamAcc,
color: '#0284c7',
width: 3.5
},
geometry: {
type: 'LineString',
coordinates: smoothedPath
}
});
}
}
// 8. Regional Sector Intelligence Context
let sectorName = 'قطاع عمليات ميداني';
let terrainClassificationAr = 'هضاب وتلال متوسطة الوعورة';
let terrainClassification = 'Rolling Hills & Ridges';
let urbanDensity: 'HIGH' | 'MEDIUM' | 'LOW' | 'SPARSE' = 'MEDIUM';
let urbanDensityAr = 'كثافة متوسطة';
let settlements: string[] = ['تجمعات سكنية وضواحي متصلة'];
let soilType = 'تربة طينية صخرية جافة';
let trafficability = 'جيدة للمركبات المدولبة والمجنزرة مع الحذر في مجاري السيول';
if (centerLat >= 32.05 && centerLat <= 32.20 && centerLng >= 36.00 && centerLng <= 36.18) {
sectorName = 'قاطع الزرقاء والسخنة / حوض سيل الزرقاء';
terrainClassificationAr = 'حوض وادي صدعي واسع تحيط به تلال وهضاب حاكمة ومجرى سيل مائي';
terrainClassification = 'River Valley Basin flanked by Ridge Plateaus';
urbanDensity = 'HIGH';
urbanDensityAr = 'كثافة عالية (بلدات وضواحي ممتدة)';
settlements = ['بلدة السخنة', 'خربة قنتول', 'ضاحية البستان', 'أحياء الهاشمية', 'مدينة الزرقاء'];
soilType = 'طمي رسوبي في بطن الوادي وصخور كلسية على الأكتاف';
trafficability = 'ممتازة على شبكة الطرق وبطون الأودية المنبسطة (GO) مع وجود قواطع صخرية على الأكتاف';
} else if (centerLat > 32.3 && centerLng < 36.0) {
sectorName = 'قاطع الشمال / حوض اليرموك وإربد';
terrainClassificationAr = 'مرتفعات جبلية تتخللها أودية سحيقة وخوانق مائية وجروف صخرية';
terrainClassification = 'Highland Plateaus with Deep Gorges & Rock Cliffs';
urbanDensity = 'MEDIUM';
urbanDensityAr = 'كثافة متوسطة (قرى وضواحي ممتدة)';
settlements = ['بلدات وضواحي إربد', 'قرى حوض اليرموك', 'مزارع ومحميات وادي الشلالة'];
soilType = 'تربة زراعية خصبة وصخور كلسية صلبة';
trafficability = 'مقيدة بالجروف الصخرية الحادة - تتطلب محاور طرق معبدة';
} else if (centerLat >= 31.85 && centerLat <= 32.05 && centerLng >= 35.8 && centerLng <= 36.02) {
sectorName = 'قاطع الوسط / إقليم العاصمة عمان';
terrainClassificationAr = 'هضاب وكتل جبلية حضرية مكتظة بالعمران';
terrainClassification = 'Urbanized Highland Terrain';
urbanDensity = 'HIGH';
urbanDensityAr = 'كثافة عالية (بيئة قتال مباني MOUT)';
settlements = ['أحياء ومجمعات العاصمة عمان', 'المناطق التجارية والتقاطعات المرورية', 'مناطق صناعية متقدمة'];
soilType = 'أرض معبدة وأرصفة إسمنتية مع تربة صخرية';
trafficability = 'ممتازة على شبكة الطرق ولكنها مقيدة بالمباني والاختناقات';
} else if (centerLng > 36.18) {
sectorName = 'قاطع البادية والشرق / المفرق والصفاوي';
terrainClassificationAr = 'سهول صحراوية مفتوحة وأراضي حرات بازلتية';
terrainClassification = 'Open Desert Steppe & Basalt Harrah';
urbanDensity = 'SPARSE';
urbanDensityAr = 'شبه خالية / تجمعات بدوية ومزارع متفرقة';
settlements = ['تجمعات ريفية بدوية', 'مواقع عسكرية ونقاط حدودية', 'مزارع صحراوية'];
soilType = 'رمال وحصى بازلتية صلبة';
trafficability = 'حركة حرة مفتوحة للدروع والآليات الصحراوية (Cross-Country GO)';
} else if (centerLat < 31.8 && centerLng < 35.7) {
sectorName = 'قاطع الغور / المنخفض الصدعي والبحر الميت';
terrainClassificationAr = 'أخفـض نقطة على سطح الأرض (جروف صخرية وقواطع حادة)';
terrainClassification = 'Rift Valley Escarpment (-400m MSL) with Rock Cliffs';
urbanDensity = 'LOW';
urbanDensityAr = 'كثافة منخفضة (مزارع الغور والمنتجعات)';
settlements = ['مزارع الأغوار الجنوبية', 'مجمعات الفنادق ومصانع البوتاس', 'قرى غور الصافي'];
soilType = 'طمي نهري وسبخات ملحية وصخور رسوبية قاسية';
trafficability = 'جروف صخرية مانعة (NO-GO) على الحواف الغربية والشرقية';
}
// 9. Mobility Assessment
let mobilityStatus: 'GO' | 'SLOW-GO' | 'NO-GO' = 'GO';
let mobilityStatusAr = 'حركة حرة ممتازة للآليات والدروع (Cross-Country GO)';
if (cliffPct > 12 || ruggedPct > 25 || avgSlope > 14) {
mobilityStatus = 'NO-GO';
mobilityStatusAr = 'مانع تضاريسي - قواطع صخرية وانحدارات حادة تعيق المناورة (NO-GO)';
} else if (cliffPct > 3 || ruggedPct > 10 || modPct > 30 || avgSlope > 6) {
mobilityStatus = '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,
radiusMeters,
areaKm2,
sectorName,
centerElevation: Math.round(centerElev),
minElevation: Math.round(minElev),
maxElevation: Math.round(maxElev),
reliefMeters: relief,
avgSlopeDegrees: avgSlope,
maxSlopeDegrees: maxSlope,
slopeDistribution: {
flatPercentage: flatPct,
moderatePercentage: modPct,
steepPercentage: ruggedPct,
cliffPercentage: cliffPct
},
cliffsCount: detectedCliffs.length,
cliffs: topCliffs,
terrainClassification,
terrainClassificationAr,
mobilityStatus,
mobilityStatusAr,
highestPoint,
lowestPoint,
spatialGeoJson: {
type: 'FeatureCollection',
features: spatialFeatures
},
naturalObstacles: [
{
name: 'القواطع والجروف الصخرية الطبيعية (Cliffs & Rock Escarpments)',
type: 'موانع وقواطع صخرية حادة',
description: cliffSummaryText,
impact: topCliffs.length > 0
? 'إغلاق محاور الالتفاف الجانبي وإجبار القوات المهاجمة على استخدام ممرات إجبارية (Choke Points)'
: 'حرية مناورة واسعة بدون عوائق صخرية عمودية'
},
{
name: 'مجاري الوديان والسيول الطبيعية (Drainage Defiles)',
type: 'مصارف مائية وأودية',
description: `بطن الوادي عند منسوب ${minElev}م يشكل مصرفاً مائياً ومحور حركة منخفضاً، يوفر ممراً محجوباً ولكن قد يعيق المناورة عند هطول الأمطار.`,
impact: 'محور تسلل مناسب للمشاة ومقيد للآليات الثقيلة عند تشكل الأوحال والسيول'
},
{
name: 'التلال والقمم الحاكمة (Dominant Ridges & Crests)',
type: 'أرض مسيطرة (Key Terrain)',
description: `المرتفع الحاكم بارتفاع ${maxElev}م يشرف بشكل كامل على بطن الوادي ومحاور التحرك على مسافة ${radiusMeters / 1000} كم بفارق تضاريسي ${relief}م.`,
impact: 'موقع استراتيجي لتمركز أسلحة الإسناد ونقاط الملاحظة والاستطلاع والسيطرة النارية'
}
],
manMadeObstacles: [
{
name: 'محاور الطرق الرئيسية وخطوط الإمداد (MSR)',
type: 'بنية تحتية للمواصلات',
description: 'الطرق المعبدة المارة بالقطاع تتيح تدفقاً سريعاً للأرتال وإعادة التزود اللوجستي.',
tacticalNote: 'وجوب إعداد خطط قطع وغلق هندسي سريع للتحكم بحركة العدو'
},
{
name: 'المنشآت والكتل العمرانية',
type: 'موانع مبنية وسواتر خرسانية',
description: `البلدات والتجمعات السكانية (${settlements.slice(0, 3).join('، ')}) توفر سواتر ممتازة ضد النيران والشظايا.`,
tacticalNote: 'تتطلب عمليات تطهير متقدمة وحماية للمدنيين'
},
{
name: 'أبراج وشبكات نقل الطاقة والاتصالات',
type: 'عوائق صناعية بارزة',
description: 'خطوط الضغط العالي والاتصالات تشكل خطراً على الطيران المنخفض ومواقع محتملة للتشويش.',
tacticalNote: 'تجنب تحديد مهابط الطيران بالقرب من مسارات خطوط الطاقة'
}
],
urbanAndDemographics: {
density: urbanDensity,
densityAr: urbanDensityAr,
settlements,
moutComplexity: urbanDensity === 'HIGH' ? 'بيئة قتال مباني حضرية متقدمة (MOUT)' : 'بيئة قتال ريفية مفتوحة تتخللها مزارع متفرقة',
collateralDamageRisk: urbanDensity === 'HIGH' ? 'عالية وتتطلب مراعاة قواعد الاشتباك الدقيقة' : 'منخفضة مع حماية الممتلكات المدنية'
},
landCoverAndCover: {
soilType,
trafficability,
concealmentRating: urbanDensity === 'HIGH' || relief > 100 ? 'ممتازة خلف التلال والكتل الخرسانية' : 'متوسطة إلى مكشوفة في الأراضي المفتوحة',
airObservationExposure: urbanDensity === 'HIGH' ? 'مختلطة (المباني توفر إخفاء جزئياً)' : 'عالية للرصد الجوي والمسيرات نهاراً'
},
oakocAssessment: {
obstacles: `القطاع يضم ${detectedCliffs.length > 0 ? `${detectedCliffs.length} قاطعاً وجرفاً صخرياً و` : ''}مجرى وادٍ رئيسي عند منسوب ${minElev}م، مع انحدارات تصل إلى ${maxSlope}°، مما يشكل محاور حركة طبيعية محددة.`,
avenuesOfApproach: 'محور الاقتراب الرئيسي يتبع بطن الوادي وشبكة الطرق الرئيسية المنبسطة، مع تفادي الجروف الصخرية الحادة.',
keyTerrain: `القمة الحاكمة بارتفاع ${maxElev}م توفر ميزة كشف وهيمنة نارية حاسمة على كامل القطاع.`,
observationAndFieldsOfFire: `حقول الرماية مكشوفة ومثالية بنسبة ${flatPct}% في الأراضي المنبسطة، ومحجوبة داخل بطون الأودية وخلف الجروف.`,
coverAndConcealment: 'تتوفر السواتر التضاريسية في المنحدرات الخلفية (Reverse Slopes) وأسفل الجروف الصخرية وداخل الكتل العمرانية.'
},
tacticalRecommendations: [
`نشر مرصد استطلاع رئيسي ورادار كشف أرضي على المرتفع الحاكم (${maxElev}م) لتأمين إنذار مبكر ومراقبة القطاع.`,
detectedCliffs.length > 0
? `استغلال القواطع والجروف الصخرية المكتشفة كحواجز طبيعية موجهة لحصر تقدم العدو داخل المخانق المستهدفة بنيران الأسلحة الثقيلة.`
: `تجهيز نقاط كمائن وموانع هندسية وحقول ألغام موجهة في مخانق بطن الوادي (${minElev}م) لصد أي تقدم مدرع.`,
`تأمين الطرق الرئيسية والمحاور الحيوية بنقاط سيطرة وحراسة ثابتة لمنع عمليات الالتفاف السريعة.`,
`استغلال المناطق المنبسطة (${flatPct}%) لإنشاء مهابط المروحيات (HLZ) ومناطق الإخلاء الطبي والتجمع اللوجستي.`
]
};
}
export interface IPBOverlayFeatureSet {
aoBoundary: any;
layers: {
layer_1: any[]; // Mountain Scarps & Steep Slopes (>20°)
layer_2: any[]; // Basalt / Rocky Boulder Fields & Cliffs
layer_3: any[]; // Urban Built-up Centers & Demographics
layer_4: any[]; // Wadis, Drainage Corridors & Waterways
layer_5: any[]; // Minefields & Man-made Choke Point Barriers
layer_6: any[]; // MCOO Combined Obstacle Grid (Green, Yellow, Red)
layer_7: any[]; // Avenues of Approach / Armor Mobility Corridors
layer_8: any[]; // Key Terrain Peaks (K) & Command Summits
layer_9: any[]; // Dead Ground & Viewshed Shadowed Zones
layer_10: any[]; // Threat SITTEMP, Kill Zones & Artillery Arcs
};
metrics: {
maxElev: number;
minElev: number;
relief: number;
maxSlope: number;
steepSlopePct: number;
ruggedPct: number;
unrestrictedPct: number;
restrictedPct: number;
severelyRestrictedPct: number;
cliffCount: number;
keyTerrainSummit: { lat: number; lng: number; elevation: number };
killZoneCenter: { lat: number; lng: number };
};
allFeatures: any[];
}
/**
* Real Mathematical IPB Digital Overlay Engine
* محرك استخبارات إعداد ساحة المعركة الرياضي عالي الدقة:
* يشتق الشفافات الـ 10 الحقيقية مباشرة من شبكة الارتفاعات الرقمية DEM، خطوط الكنتور، وحسابات الميول الدقيقة
*/
export async function calculateRealIPBOverlays(
centerLat: number,
centerLng: number,
radiusMeters: number = 6000
): Promise<IPBOverlayFeatureSet> {
const centerElev = await sampleElevationAt(centerLat, centerLng);
// Matrix sampling resolution (25x25 grid = 625 nodes)
const gridSize = 25;
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);
const minLat = centerLat - dLatTotal;
const maxLat = centerLat + dLatTotal;
const minLng = centerLng - dLngTotal;
const maxLng = centerLng + dLngTotal;
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(6));
for (let c = 0; c < gridSize; c++) {
const lng = Number((minLng + (c / (gridSize - 1)) * (maxLng - minLng)).toFixed(6));
gridCoords.push({ r, c, lat, lng });
}
}
const sampleResults = await sampleElevationsBatch(gridCoords, 13);
const elevationGrid: number[][] = Array(gridSize).fill(0).map(() => Array(gridSize).fill(0));
gridCoords.forEach((pt, idx) => {
elevationGrid[pt.r][pt.c] = sampleResults[idx];
});
// Calculate Extremes (Key Terrain Peak & Lowest Valley Point)
let maxElev = -9999;
let minElev = 9999;
let highestPoint = { lat: centerLat, lng: centerLng, elevation: centerElev };
let lowestPoint = { lat: centerLat, lng: centerLng, elevation: centerElev };
gridCoords.forEach((p, idx) => {
const el = sampleResults[idx];
const dist = calculateDistance(centerLat, centerLng, p.lat, p.lng);
if (dist <= radiusMeters * 1.05) {
if (el > maxElev) {
maxElev = el;
highestPoint = { lat: p.lat, lng: p.lng, elevation: Math.round(el) };
}
if (el < minElev) {
minElev = el;
lowestPoint = { lat: p.lat, lng: p.lng, elevation: Math.round(el) };
}
}
});
const relief = Math.round(maxElev - minElev);
// Initialize Layer Feature Buckets
const layer_1: any[] = []; // Mountains & Steep Slopes (>20°)
const layer_2: any[] = []; // Basalt / Rocky Escarpments
const layer_3: any[] = []; // Urban Demographics
const layer_4: any[] = []; // Wadis & Waterways
const layer_5: any[] = []; // Minefield Barriers
const layer_6: any[] = []; // MCOO Trafficability Grid
const layer_7: any[] = []; // Avenues of Approach
const layer_8: any[] = []; // Key Terrain
const layer_9: any[] = []; // Dead Space / Viewshed
const layer_10: any[] = []; // SITTEMP & Kill Zones
const dxMeters = (2 * radiusMeters) / (gridSize - 1);
const dyMeters = (2 * radiusMeters) / (gridSize - 1);
let flatCells = 0;
let slowGoCells = 0;
let severeCells = 0;
let cliffCount = 0;
let maxCalculatedSlope = 0;
// Track valley drainage nodes for Layer 4
const valleyNodes: Array<{ lat: number; lng: number; elevation: number; r: number; c: number }> = [];
// Track choke point cells for Layer 5
const chokePoints: Array<{ lat: number; lng: number; elevation: number }> = [];
for (let r = 0; r < gridSize - 1; r++) {
// Find local elevation minimum in this row for valley tracing
let rowMinElev = 9999;
let rowMinNode: any = null;
for (let c = 0; c < gridSize - 1; c++) {
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;
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);
if (cellElevAvg < rowMinElev) {
rowMinElev = cellElevAvg;
rowMinNode = { lat: cellCenterLat, lng: cellCenterLng, elevation: cellElevAvg, r, c };
}
// Central difference slope gradient
const dz_dx = ((z01 + z11) - (z00 + z10)) / (2 * dxMeters);
const dz_dy = ((z10 + z11) - (z00 + z01)) / (2 * dyMeters);
const slopeRad = Math.atan(Math.sqrt(dz_dx * dz_dx + dz_dy * dz_dy));
const slopeDeg = Math.round((slopeRad * (180 / Math.PI)) * 10) / 10;
if (slopeDeg > maxCalculatedSlope) maxCalculatedSlope = slopeDeg;
const localDrop = Math.round(Math.max(z00, z01, z10, z11) - Math.min(z00, z01, z10, z11));
const p1 = [p1Coord.lng, p1Coord.lat];
const p2 = [p2Coord.lng, p2Coord.lat];
const p3 = [p3Coord.lng, p3Coord.lat];
const p4 = [p4Coord.lng, p4Coord.lat];
const polyCoords = [[p1, p2, p3, p4, p1]];
// Classification
if (slopeDeg >= 20) {
severeCells++;
if (slopeDeg >= 28) cliffCount++;
// 1. Layer 1: Real Mountain Scarps & Steep Slope Polygon
layer_1.push({
type: 'Feature',
properties: {
layer: 'layer_1',
color: '#dc2626',
strokeColor: '#991b1b',
opacity: 0.78,
slope: slopeDeg,
elevation: cellElevAvg,
label: slopeDeg >= 28 ? `▲ جرف صخري حاد (${slopeDeg}° / ${cellElevAvg}م)` : `▲ انحدار شديد (${slopeDeg}°)`
},
geometry: { type: 'Polygon', coordinates: polyCoords }
});
// 2. Layer 2: Basalt / Rocky Escarpments & Mountain Crest Cliffs
if (slopeDeg >= 18 || localDrop >= 18) {
layer_2.push({
type: 'Feature',
properties: {
layer: 'layer_2',
color: '#b45309',
strokeColor: '#78350f',
opacity: 0.78,
drop: localDrop,
label: `■ مقطع صخري وعر (${localDrop}م هبوط / ${slopeDeg}° ميل)`
},
geometry: { type: 'Polygon', coordinates: polyCoords }
});
}
// 6. Layer 6: MCOO - Severely Restricted Red (Only on steep slopes)
layer_6.push({
type: 'Feature',
properties: {
layer: 'layer_6',
color: '#dc2626',
strokeColor: '#b91c1c',
opacity: 0.28
},
geometry: { type: 'Polygon', coordinates: polyCoords }
});
} else if (slopeDeg >= 9) {
slowGoCells++;
// 6. Layer 6: MCOO - Restricted Amber (Only on rugged slopes)
layer_6.push({
type: 'Feature',
properties: {
layer: 'layer_6',
color: '#d97706',
strokeColor: '#b45309',
opacity: 0.20
},
geometry: { type: 'Polygon', coordinates: polyCoords }
});
// Detect Choke Points where passable ground meets steep walls
if (c > 2 && c < gridSize - 3) {
const leftSlope = Math.abs(elevationGrid[r][c - 2] - z00) / (2 * dxMeters);
const rightSlope = Math.abs(elevationGrid[r][c + 2] - z01) / (2 * dxMeters);
if (leftSlope > 0.35 && rightSlope > 0.35) {
chokePoints.push({ lat: cellCenterLat, lng: cellCenterLng, elevation: cellElevAvg });
}
}
} else {
flatCells++;
// Unrestricted flat land remains unshaded and clean so the map shows naturally
}
}
if (rowMinNode) {
valleyNodes.push(rowMinNode);
}
}
const totalCells = Math.max(1, flatCells + slowGoCells + severeCells);
const unrestrictedPct = Math.round((flatCells / totalCells) * 100);
const restrictedPct = Math.round((slowGoCells / totalCells) * 100);
const severelyRestrictedPct = Math.round((severeCells / totalCells) * 100);
// 3. Layer 3: Urban Built-up Centers (Rendered directly by OSM/Overture style layers)
// 4. Layer 4: Real Valley Drainage Corridors & Wadis (مجاري السيول والأودية الحقيقية)
if (valleyNodes.length >= 3) {
const wadiLineCoords = valleyNodes.map(n => [n.lng, n.lat]);
layer_4.push({
type: 'Feature',
properties: {
layer: 'layer_4',
color: '#0284c7',
strokeColor: '#0369a1',
opacity: 0.85,
label: `≈≈ مجرى وادٍ وسيل رئيسي (${lowestPoint.elevation}م)`
},
geometry: { type: 'LineString', coordinates: wadiLineCoords }
});
}
// 5. Layer 5: Tactical Obstacles & Retaining Barriers (Populated if obstacles exist)
// 7. Layer 7: Real Approach Corridor & Mobility (المقترب التعبوي الرئيسي AA-1)
let approachLengthKm = Math.round((radiusMeters * 1.4 / 1000) * 10) / 10;
let approachDirection = 'الغرب نحو الشرق';
let approachAzimuth = 85;
let approachWidthM = Math.round(dxMeters * 3);
let approachCapacity = 'كتيبة مدرعة بنسق رتل';
if (valleyNodes.length >= 3) {
const approachCoords = valleyNodes.slice(0, Math.floor(valleyNodes.length * 0.8)).map(n => [n.lng, n.lat]);
// Calculate real length
let lenKm = 0;
for (let i = 0; i < approachCoords.length - 1; i++) {
const p1 = approachCoords[i];
const p2 = approachCoords[i + 1];
lenKm += Math.hypot((p2[1] - p1[1]) * 111.139, (p2[0] - p1[0]) * 111.139 * Math.cos(centerLat * Math.PI / 180));
}
if (lenKm > 0.5) approachLengthKm = Math.round(lenKm * 10) / 10;
// Calculate azimuth from start to end
const startP = approachCoords[0];
const endP = approachCoords[approachCoords.length - 1];
const dLngRad = (endP[0] - startP[0]) * (Math.PI / 180);
const lat1Rad = startP[1] * (Math.PI / 180);
const lat2Rad = endP[1] * (Math.PI / 180);
const y = Math.sin(dLngRad) * Math.cos(lat2Rad);
const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) - Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLngRad);
const azDeg = Math.round((Math.atan2(y, x) * 180 / Math.PI + 360) % 360);
approachAzimuth = azDeg;
if (azDeg >= 337.5 || azDeg < 22.5) approachDirection = 'الجنوب نحو الشمال';
else if (azDeg >= 22.5 && azDeg < 67.5) approachDirection = 'الجنوب الغربي نحو الشمال الشرقي';
else if (azDeg >= 67.5 && azDeg < 112.5) approachDirection = 'الغرب نحو الشرق';
else if (azDeg >= 112.5 && azDeg < 157.5) approachDirection = 'الشمال الغربي نحو الجنوب الشرقي';
else if (azDeg >= 157.5 && azDeg < 202.5) approachDirection = 'الشمال نحو الجنوب';
else if (azDeg >= 202.5 && azDeg < 247.5) approachDirection = 'الشمال الشرقي نحو الجنوب الغربي';
else if (azDeg >= 247.5 && azDeg < 292.5) approachDirection = 'الشرق نحو الغرب';
else approachDirection = 'الجنوب الشرقي نحو الشمال الغربي';
if (approachWidthM > 1200) approachCapacity = 'لواء مدرع بنسق كتائب';
else if (approachWidthM > 600) approachCapacity = 'كتيبة مدرعة بنسق رتل سرية';
else approachCapacity = 'سرية دبابات بنسق رتل واحد';
layer_7.push({
type: 'Feature',
properties: {
layer: 'layer_7',
color: '#2563eb',
strokeColor: '#1d4ed8',
opacity: 0.90,
label: `➔➔ المقترب التعبوي الرئيسي (AA-1: بطول ${approachLengthKm} كم / عرض ${approachWidthM}م / سعة ${approachCapacity})`
},
geometry: { type: 'LineString', coordinates: approachCoords }
});
}
// 8. Layer 8: Real Key Terrain (الأرض الحيوية والقمم الحاكمة الحقيقية)
layer_8.push({
type: 'Feature',
properties: {
layer: 'layer_8',
color: '#9333ea',
strokeColor: '#6b21a8',
opacity: 0.95,
label: `★ قمة حاكمة: ${highestPoint.elevation}م [Key Terrain]`
},
geometry: {
type: 'Point',
coordinates: [highestPoint.lng, highestPoint.lat]
}
});
// 9. Layer 9: Real DEM Line of Sight & Viewshed Shadows from Key Terrain Peak
// Soft, continuous terrain shadow masking without harsh black borders
const summitR = Math.min(gridSize - 1, Math.max(0, Math.round(((centerLat + dLatTotal - highestPoint.lat) / (2 * dLatTotal)) * (gridSize - 1))));
const summitC = Math.min(gridSize - 1, Math.max(0, Math.round(((highestPoint.lng - (centerLng - dLngTotal)) / (2 * dLngTotal)) * (gridSize - 1))));
const summitElev = highestPoint.elevation;
for (let r = 0; r < gridSize; r += 2) {
for (let c = 0; c < gridSize; c += 2) {
if (Math.abs(r - summitR) <= 1 && Math.abs(c - summitC) <= 1) continue;
const distCells = Math.hypot(r - summitR, c - summitC);
if (distCells === 0) continue;
const targetElev = elevationGrid[r][c];
const angleToTarget = (targetElev - summitElev) / distCells;
let isShadowed = false;
const steps = Math.floor(distCells);
for (let s = 1; s < steps; s++) {
const interR = Math.round(summitR + (r - summitR) * (s / distCells));
const interC = Math.round(summitC + (c - summitC) * (s / distCells));
if (interR >= 0 && interR < gridSize && interC >= 0 && interC < gridSize) {
const interElev = elevationGrid[interR][interC];
const angleToInter = (interElev - summitElev) / s;
if (angleToInter > angleToTarget + 0.8) {
isShadowed = true;
break;
}
}
}
if (isShadowed) {
const cellLat = centerLat + dLatTotal - (r / (gridSize - 1)) * (2 * dLatTotal);
const cellLng = centerLng - dLngTotal + (c / (gridSize - 1)) * (2 * dLngTotal);
const distM = Math.hypot(
(cellLat - centerLat) * (R_earth * Math.PI / 180),
(cellLng - centerLng) * (R_earth * Math.cos(centerLat * Math.PI / 180) * Math.PI / 180)
);
if (distM <= radiusMeters * 0.95) {
const hLat = (dLatTotal / (gridSize - 1)) * 1.05;
const hLng = (dLngTotal / (gridSize - 1)) * 1.05;
layer_9.push({
type: 'Feature',
properties: {
layer: 'layer_9',
color: '#475569',
strokeColor: '#334155',
opacity: 0.18
},
geometry: {
type: 'Polygon',
coordinates: [[
[cellLng - hLng, cellLat - hLat],
[cellLng + hLng, cellLat - hLat],
[cellLng + hLng, cellLat + hLat],
[cellLng - hLng, cellLat + hLat],
[cellLng - hLng, cellLat - hLat]
]]
}
});
}
}
}
}
// 10. Layer 10: SITTEMP & Engagement Area (منطقة التقتيل الرئيسية Kill Zone Alpha)
// Placed at the natural choke point near lowest valley within line of sight of key terrain
const killZoneLat = (highestPoint.lat + lowestPoint.lat * 2) / 3;
const killZoneLng = (highestPoint.lng + lowestPoint.lng * 2) / 3;
const kzR = (radiusMeters * 0.14 / R_earth) * (180 / Math.PI);
const kzRLng = kzR / Math.cos((killZoneLat * Math.PI) / 180);
const kzCoords: [number, number][] = [];
for (let i = 0; i <= 24; i++) {
const a = (i / 24) * 2 * Math.PI;
kzCoords.push([killZoneLng + kzRLng * Math.sin(a), killZoneLat + kzR * Math.cos(a)]);
}
// Direct Fire Line from Key Terrain Peak to Kill Zone Center
const fireLineCoords = [
[highestPoint.lng, highestPoint.lat],
[killZoneLng, killZoneLat]
];
const fireDistKm = Math.round(Math.hypot(
(killZoneLat - highestPoint.lat) * 111.139,
(killZoneLng - highestPoint.lng) * 111.139 * Math.cos(centerLat * Math.PI / 180)
) * 10) / 10;
const fireAzimuth = Math.round((Math.atan2(
(killZoneLng - highestPoint.lng) * Math.cos(centerLat * Math.PI / 180),
killZoneLat - highestPoint.lat
) * 180 / Math.PI + 360) % 360);
// Add Direct Fire Line
layer_10.push({
type: 'Feature',
properties: {
layer: 'layer_10',
color: '#dc2626',
strokeColor: '#991b1b',
opacity: 0.95,
label: `⚡ خط نار ورصد مباشر مسيطر (أزيموث ${fireAzimuth}° / مدى ${fireDistKm} كم)`
},
geometry: {
type: 'LineString',
coordinates: fireLineCoords
}
});
// Add Kill Zone Area
layer_10.push({
type: 'Feature',
properties: {
layer: 'layer_10',
color: '#ef4444',
strokeColor: '#b91c1c',
opacity: 0.75,
label: '⚔️ منطقة التقتيل الرئيسية (KILL ZONE ALPHA)'
},
geometry: {
type: 'Polygon',
coordinates: [kzCoords]
}
});
// AO Boundary Ring Feature
const aoCoords: [number, number][] = [];
for (let i = 0; i <= 48; i++) {
const a = (i / 48) * 2 * Math.PI;
aoCoords.push([centerLng + dLngTotal * Math.sin(a), centerLat + dLatTotal * Math.cos(a)]);
}
const aoBoundary = {
type: 'Feature',
properties: {
color: '#38bdf8',
strokeColor: '#0284c7',
opacity: 0.08,
label: `قاطع العمليات (${Math.round(radiusMeters / 1000)} كم / منسوب ${centerElev}م)`
},
geometry: {
type: 'Polygon',
coordinates: [aoCoords]
}
};
const allFeatures = [
aoBoundary,
...layer_1,
...layer_2,
...layer_3,
...layer_4,
...layer_5,
...layer_6,
...layer_7,
...layer_8,
...layer_9,
...layer_10
];
return {
aoBoundary,
layers: {
layer_1,
layer_2,
layer_3,
layer_4,
layer_5,
layer_6,
layer_7,
layer_8,
layer_9,
layer_10
},
metrics: {
maxElev,
minElev,
relief,
maxSlope: maxCalculatedSlope,
steepSlopePct: severelyRestrictedPct,
ruggedPct: restrictedPct,
unrestrictedPct,
restrictedPct,
severelyRestrictedPct,
cliffCount,
keyTerrainSummit: highestPoint,
killZoneCenter: { lat: killZoneLat, lng: killZoneLng },
approachLengthKm,
approachWidthM,
approachDirection,
approachAzimuth,
approachCapacity,
fireDistKm,
fireAzimuth
},
allFeatures
};
}