feat(tactical): add tactical line of sight API, elevation engine, dynamic step resolution, weather module and executive showcase

This commit is contained in:
Hamza-Ayed
2026-08-17 19:25:39 +03:00
parent 03cdc6fe9c
commit a0a5a09135
29 changed files with 3896 additions and 190 deletions
+363
View File
@@ -0,0 +1,363 @@
/**
* 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
*/
async function sampleElevationAt(lat: number, lng: number): Promise<number> {
const zoom = 12;
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}`;
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
}
return getApproximateElevation(lat, lng);
}
/**
* Topographic estimation model for Jordan terrain when tiles are offline
*/
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;
}
// Northern Highlands (Ajloun / Jerash / Salt)
if (lat >= 32.1 && lng < 36.0) {
return 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;
}
// Southern Highlands (Karak / Tafilah / Shobak / Petra)
if (lat < 31.5 && lat > 30.0 && lng < 35.7) {
return 1100 + Math.sin(lat * 30) * 350;
}
// Eastern Desert (Badia)
return 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,
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_API_KEY;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const res = await fetch(`${apiUrl}/tactical/line-of-sight?observerLat=${startLat}&observerLng=${startLng}&targetLat=${endLat}&targetLng=${endLng}&observerHeight=${obsHeight}&targetHeight=${tgtHeight}&samples=${samples}`, {
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.rayElevationMeters,
isVisible: p.isVisible,
isTargetRayBlocked: p.isTargetRayBlocked,
clearance: p.clearanceMeters
})),
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,
elevation: data.highestObstacle.groundElevationMeters,
lat: data.highestObstacle.lat,
lng: data.highestObstacle.lng,
excessHeight: data.highestObstacle.excessHeightMeters
} : null,
deadGroundPercentage: data.summary.deadGroundPercentage,
angleDegrees: data.summary.verticalAngleDegrees,
angleMils: data.summary.verticalAngleMilsNato,
azimuthDegrees: data.summary.azimuthDegrees
};
}
} catch {
// API not available or timed out, fall back to local DEM tile 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 parallel
const elevations = await Promise.all(
sampleCoords.map((coord) => sampleElevationAt(coord.lat, coord.lng))
);
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);
let isDirectlyVisible = true;
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[] = [];
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);
// 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) {
isDirectlyVisible = false;
isTargetRayBlocked = true;
const excess = elev - rayHeight;
if (excess > maxObstacleExcess) {
maxObstacleExcess = excess;
highestObstacle = {
distance: Math.round(d),
elevation: elev,
lat: sampleCoords[i].lat,
lng: sampleCoords[i].lng,
excessHeight: Math.round(excess * 10) / 10,
};
}
}
}
// Check visibility from observer's eye (Viewshed / Shadowing along profile)
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: elev,
rayHeight: Math.round(rayHeight * 10) / 10,
isVisible,
isTargetRayBlocked,
clearance: Math.round(clearance * 10) / 10,
});
}
// 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 {
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,
};
}
+36
View File
@@ -0,0 +1,36 @@
export function getWeatherDescription(code: number): { icon: string, description_ar: string, description_en: string } {
switch (code) {
case 0: return { icon: '☀️', description_ar: 'صافي', description_en: 'Clear' };
case 1: return { icon: '🌤️', description_ar: 'صافي غالباً', description_en: 'Mainly clear' };
case 2: return { icon: '⛅', description_ar: 'غائم جزئياً', description_en: 'Partly cloudy' };
case 3: return { icon: '☁️', description_ar: 'غائم', description_en: 'Overcast' };
case 45:
case 48: return { icon: '🌫️', description_ar: 'ضباب', description_en: 'Fog' };
case 51:
case 53:
case 55: return { icon: '🌦️', description_ar: 'رذاذ', description_en: 'Drizzle' };
case 61: return { icon: '🌧️', description_ar: 'أمطار خفيفة', description_en: 'Light rain' };
case 63: return { icon: '🌧️', description_ar: 'أمطار متوسطة', description_en: 'Moderate rain' };
case 65: return { icon: '🌧️', description_ar: 'أمطار غزيرة', description_en: 'Heavy rain' };
case 71:
case 73:
case 75: return { icon: '🌨️', description_ar: 'ثلوج', description_en: 'Snow' };
case 80:
case 81:
case 82: return { icon: '🌧️', description_ar: 'زخات مطرية', description_en: 'Rain showers' };
case 95: return { icon: '⛈️', description_ar: 'عاصفة رعدية', description_en: 'Thunderstorm' };
case 96:
case 99: return { icon: '⛈️', description_ar: 'عاصفة رعدية مع بَرَد', description_en: 'Thunderstorm with hail' };
default: return { icon: '🌡️', description_ar: 'غير معروف', description_en: 'Unknown' };
}
}
export function getTemperatureColor(temp: number): string {
if (temp <= 0) return '#0047AB';
if (temp <= 10) return '#4169E1';
if (temp <= 20) return '#32CD32';
if (temp <= 30) return '#FFD700';
if (temp <= 40) return '#FF8C00';
if (temp <= 45) return '#FF4500';
return '#DC143C';
}