feat(tactical): add tactical line of sight API, elevation engine, dynamic step resolution, weather module and executive showcase
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user