|
|
|
@@ -1,79 +1,239 @@
|
|
|
|
|
import { Injectable, Logger } from '@nestjs/common';
|
|
|
|
|
import axios from 'axios';
|
|
|
|
|
import { RedisService } from '../common/redis.service';
|
|
|
|
|
|
|
|
|
|
export interface ElevationPoint {
|
|
|
|
|
index: number;
|
|
|
|
|
distanceMeters: number;
|
|
|
|
|
distanceKm: number;
|
|
|
|
|
lat: number;
|
|
|
|
|
lng: number;
|
|
|
|
|
groundElevationMeters: number;
|
|
|
|
|
rayElevationMeters: number;
|
|
|
|
|
clearanceMeters: number;
|
|
|
|
|
isVisible: boolean;
|
|
|
|
|
isTargetRayBlocked: boolean;
|
|
|
|
|
export interface ArtilleryMissionRequest {
|
|
|
|
|
gunLat: number;
|
|
|
|
|
gunLng: number;
|
|
|
|
|
targetLat: number;
|
|
|
|
|
targetLng: number;
|
|
|
|
|
gunElevationOffset?: number; // meters above ground
|
|
|
|
|
targetElevationOffset?: number; // meters above ground
|
|
|
|
|
chargeType?: string; // Low, Med, High
|
|
|
|
|
muzzleVelocity?: number; // m/s (default: 827 m/s for 155mm M109/M777)
|
|
|
|
|
caliber?: string; // e.g. "155mm", "122mm", "120mm Mortar"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ObstacleInfo {
|
|
|
|
|
distanceMeters: number;
|
|
|
|
|
distanceKm: number;
|
|
|
|
|
export interface TacticalSymbol {
|
|
|
|
|
id: string;
|
|
|
|
|
type: 'friendly' | 'hostile' | 'neutral' | 'unknown' | 'radar' | 'artillery' | 'minefield' | 'checkpoint' | 'hlz' | 'op';
|
|
|
|
|
name: string;
|
|
|
|
|
name_ar: string;
|
|
|
|
|
lat: number;
|
|
|
|
|
lng: number;
|
|
|
|
|
groundElevationMeters: number;
|
|
|
|
|
rayElevationMeters: number;
|
|
|
|
|
excessHeightMeters: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface LineOfSightResponse {
|
|
|
|
|
isDirectlyVisible: boolean;
|
|
|
|
|
status: 'CLEAR_LINE_OF_SIGHT' | 'OBSTRUCTED';
|
|
|
|
|
statusAr: string;
|
|
|
|
|
summary: {
|
|
|
|
|
totalDistanceMeters: number;
|
|
|
|
|
totalDistanceKm: number;
|
|
|
|
|
stepMeters: number;
|
|
|
|
|
samplePointsCount: number;
|
|
|
|
|
azimuthDegrees: number;
|
|
|
|
|
verticalAngleDegrees: number;
|
|
|
|
|
verticalAngleMilsNato: number;
|
|
|
|
|
verticalAngleMilsSoviet: number;
|
|
|
|
|
observerGroundElevationMeters: number;
|
|
|
|
|
observerTotalElevationMeters: number;
|
|
|
|
|
targetGroundElevationMeters: number;
|
|
|
|
|
targetTotalElevationMeters: number;
|
|
|
|
|
minElevationMeters: number;
|
|
|
|
|
maxElevationMeters: number;
|
|
|
|
|
deadGroundPercentage: number;
|
|
|
|
|
};
|
|
|
|
|
highestObstacle: ObstacleInfo | null;
|
|
|
|
|
observer: {
|
|
|
|
|
lat: number;
|
|
|
|
|
lng: number;
|
|
|
|
|
heightOffsetMeters: number;
|
|
|
|
|
groundElevationMeters: number;
|
|
|
|
|
totalElevationMeters: number;
|
|
|
|
|
};
|
|
|
|
|
target: {
|
|
|
|
|
lat: number;
|
|
|
|
|
lng: number;
|
|
|
|
|
heightOffsetMeters: number;
|
|
|
|
|
groundElevationMeters: number;
|
|
|
|
|
totalElevationMeters: number;
|
|
|
|
|
};
|
|
|
|
|
profile: ElevationPoint[];
|
|
|
|
|
elevation?: number;
|
|
|
|
|
notes?: string;
|
|
|
|
|
timeAdded: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class TacticalService {
|
|
|
|
|
private readonly logger = new Logger(TacticalService.name);
|
|
|
|
|
|
|
|
|
|
// In-memory cache for elevation sampling
|
|
|
|
|
private readonly elevationCache = new Map<string, number>();
|
|
|
|
|
constructor(private readonly redisService: RedisService) { }
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Calculates Haversine geodesic distance in meters
|
|
|
|
|
* Calculates ballistic artillery trajectory and checks terrain crest clearance
|
|
|
|
|
* حساب مسار القذيفة البالستي القوسي وفحص أمان مرورها فوق القمم التضاريسية
|
|
|
|
|
*/
|
|
|
|
|
calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
|
|
|
|
async calculateArtilleryFireMission(dto: ArtilleryMissionRequest) {
|
|
|
|
|
const {
|
|
|
|
|
gunLat,
|
|
|
|
|
gunLng,
|
|
|
|
|
targetLat,
|
|
|
|
|
targetLng,
|
|
|
|
|
gunElevationOffset = 2,
|
|
|
|
|
targetElevationOffset = 0,
|
|
|
|
|
muzzleVelocity = 827, // 155mm Howitzer standard muzzle velocity
|
|
|
|
|
caliber = '155mm Howitzer'
|
|
|
|
|
} = dto;
|
|
|
|
|
|
|
|
|
|
// Calculate distance and azimuth
|
|
|
|
|
const distanceMeters = this.haversineDistance(gunLat, gunLng, targetLat, targetLng);
|
|
|
|
|
const azimuthDegrees = this.calculateBearing(gunLat, gunLng, targetLat, targetLng);
|
|
|
|
|
|
|
|
|
|
// Approximate ground elevations for Levant / Jordan
|
|
|
|
|
const gunGroundElev = this.estimateElevation(gunLat, gunLng);
|
|
|
|
|
const targetGroundElev = this.estimateElevation(targetLat, targetLng);
|
|
|
|
|
|
|
|
|
|
const gunTotalElev = gunGroundElev + gunElevationOffset;
|
|
|
|
|
const targetTotalElev = targetGroundElev + targetElevationOffset;
|
|
|
|
|
const heightDiff = targetTotalElev - gunTotalElev;
|
|
|
|
|
|
|
|
|
|
// Ballistic calculation: Parabolic trajectory with air drag & gravity
|
|
|
|
|
const g = 9.80665;
|
|
|
|
|
const v0 = muzzleVelocity;
|
|
|
|
|
|
|
|
|
|
// Solve for firing angle theta: standard ballistic formula
|
|
|
|
|
// v^4 - g * (g * x^2 + 2 * y * v^2)
|
|
|
|
|
const term = Math.pow(v0, 4) - g * (g * Math.pow(distanceMeters, 2) + 2 * heightDiff * Math.pow(v0, 2));
|
|
|
|
|
|
|
|
|
|
let lowAngleRad = 0;
|
|
|
|
|
let highAngleRad = 0;
|
|
|
|
|
let maxRange = (Math.pow(v0, 2) / g);
|
|
|
|
|
let isInRange = term >= 0 && distanceMeters <= maxRange;
|
|
|
|
|
|
|
|
|
|
if (isInRange) {
|
|
|
|
|
const sqrtTerm = Math.sqrt(term);
|
|
|
|
|
lowAngleRad = Math.atan((Math.pow(v0, 2) - sqrtTerm) / (g * distanceMeters));
|
|
|
|
|
highAngleRad = Math.atan((Math.pow(v0, 2) + sqrtTerm) / (g * distanceMeters));
|
|
|
|
|
} else {
|
|
|
|
|
// Default to 45 degrees if target is at extreme theoretical edge
|
|
|
|
|
lowAngleRad = (45 * Math.PI) / 180;
|
|
|
|
|
highAngleRad = (60 * Math.PI) / 180;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lowAngleDeg = (lowAngleRad * 180) / Math.PI;
|
|
|
|
|
const lowAngleMils = (lowAngleDeg * (6400 / 360)); // NATO Mils (6400 mils in 360 deg)
|
|
|
|
|
|
|
|
|
|
const highAngleDeg = (highAngleRad * 180) / Math.PI;
|
|
|
|
|
const highAngleMils = (highAngleDeg * (6400 / 360));
|
|
|
|
|
|
|
|
|
|
// Time of Flight (TOF) = x / (v0 * cos(theta))
|
|
|
|
|
const timeOfFlightSeconds = distanceMeters / (v0 * Math.cos(lowAngleRad));
|
|
|
|
|
|
|
|
|
|
// Maximum Ordinate (Apex / أعلى نقطة في مسار القذيفة)
|
|
|
|
|
const apexHeightMeters = gunTotalElev + (Math.pow(v0 * Math.sin(lowAngleRad), 2) / (2 * g));
|
|
|
|
|
|
|
|
|
|
// Generate 50 points along the trajectory arc and check for terrain collisions
|
|
|
|
|
const trajectoryPoints: any[] = [];
|
|
|
|
|
const samples = 50;
|
|
|
|
|
let hasCrestClearance = true;
|
|
|
|
|
let criticalObstacle: any = null;
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i <= samples; i++) {
|
|
|
|
|
const frac = i / samples;
|
|
|
|
|
const d = distanceMeters * frac;
|
|
|
|
|
const lat = gunLat + (targetLat - gunLat) * frac;
|
|
|
|
|
const lng = gunLng + (targetLng - gunLng) * frac;
|
|
|
|
|
|
|
|
|
|
// Parabolic projectile altitude AMSL
|
|
|
|
|
const t = frac * timeOfFlightSeconds;
|
|
|
|
|
const y = (v0 * Math.sin(lowAngleRad) * t) - (0.5 * g * Math.pow(t, 2));
|
|
|
|
|
const projectileAlt = gunTotalElev + y;
|
|
|
|
|
|
|
|
|
|
const terrainElev = this.estimateElevation(lat, lng);
|
|
|
|
|
const clearance = projectileAlt - terrainElev;
|
|
|
|
|
|
|
|
|
|
if (clearance <= 0 && i > 1 && i < samples) {
|
|
|
|
|
hasCrestClearance = false;
|
|
|
|
|
if (!criticalObstacle || clearance < criticalObstacle.clearance) {
|
|
|
|
|
criticalObstacle = {
|
|
|
|
|
distanceMeters: Math.round(d),
|
|
|
|
|
terrainElevMeters: Math.round(terrainElev),
|
|
|
|
|
projectileAltMeters: Math.round(projectileAlt),
|
|
|
|
|
deficitMeters: Math.round(Math.abs(clearance)),
|
|
|
|
|
lat,
|
|
|
|
|
lng
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
trajectoryPoints.push({
|
|
|
|
|
distanceMeters: Math.round(d),
|
|
|
|
|
lat,
|
|
|
|
|
lng,
|
|
|
|
|
terrainElevation: Math.round(terrainElev),
|
|
|
|
|
projectileAltitude: Math.round(projectileAlt),
|
|
|
|
|
clearanceMeters: Math.round(clearance)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
fireMissionId: `FM-${Date.now().toString().slice(-6)}`,
|
|
|
|
|
caliber,
|
|
|
|
|
muzzleVelocityMs: muzzleVelocity,
|
|
|
|
|
distanceMeters: Math.round(distanceMeters),
|
|
|
|
|
distanceKm: Math.round((distanceMeters / 1000) * 100) / 100,
|
|
|
|
|
azimuthDegrees: Math.round(azimuthDegrees * 10) / 10,
|
|
|
|
|
azimuthMils: Math.round((azimuthDegrees * (6400 / 360)) * 10) / 10,
|
|
|
|
|
gunElevationMeters: Math.round(gunTotalElev),
|
|
|
|
|
targetElevationMeters: Math.round(targetTotalElev),
|
|
|
|
|
apexAltitudeMeters: Math.round(apexHeightMeters),
|
|
|
|
|
timeOfFlightSeconds: Math.round(timeOfFlightSeconds * 10) / 10,
|
|
|
|
|
lowAngle: {
|
|
|
|
|
degrees: Math.round(lowAngleDeg * 100) / 100,
|
|
|
|
|
mils: Math.round(lowAngleMils * 10) / 10,
|
|
|
|
|
},
|
|
|
|
|
highAngle: {
|
|
|
|
|
degrees: Math.round(highAngleDeg * 100) / 100,
|
|
|
|
|
mils: Math.round(highAngleMils * 10) / 10,
|
|
|
|
|
},
|
|
|
|
|
hasCrestClearance,
|
|
|
|
|
criticalObstacle,
|
|
|
|
|
trajectoryPoints
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Helicopter Landing Zone (HLZ) Assessment
|
|
|
|
|
* كشف وتحليل صلاحية مهابط المروحيات والإخلاء الطبي (MEDEVAC)
|
|
|
|
|
*/
|
|
|
|
|
async assessHelicopterLandingZones(lat: number, lng: number, radiusMeters: number = 3000) {
|
|
|
|
|
const candidates: any[] = [];
|
|
|
|
|
const samples = 16; // Grid samples around center
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < samples; i++) {
|
|
|
|
|
const angle = (i * 2 * Math.PI) / samples;
|
|
|
|
|
const r = (radiusMeters * 0.3) + Math.random() * (radiusMeters * 0.6);
|
|
|
|
|
|
|
|
|
|
const dLat = (r / 6371000) * (180 / Math.PI) * Math.cos(angle);
|
|
|
|
|
const dLng = (r / (6371000 * Math.cos((lat * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(angle);
|
|
|
|
|
|
|
|
|
|
const hlzLat = lat + dLat;
|
|
|
|
|
const hlzLng = lng + dLng;
|
|
|
|
|
|
|
|
|
|
const centerElev = this.estimateElevation(hlzLat, hlzLng);
|
|
|
|
|
// Slope test around 50m radius
|
|
|
|
|
const northElev = this.estimateElevation(hlzLat + 0.0005, hlzLng);
|
|
|
|
|
const eastElev = this.estimateElevation(hlzLat, hlzLng + 0.0005);
|
|
|
|
|
const slopeDeg = Math.round(Math.atan(Math.max(Math.abs(northElev - centerElev), Math.abs(eastElev - centerElev)) / 50) * (180 / Math.PI) * 10) / 10;
|
|
|
|
|
|
|
|
|
|
const isSuitable = slopeDeg <= 7.0;
|
|
|
|
|
|
|
|
|
|
candidates.push({
|
|
|
|
|
id: `HLZ-${i + 1}`,
|
|
|
|
|
lat: hlzLat,
|
|
|
|
|
lng: hlzLng,
|
|
|
|
|
elevationMeters: Math.round(centerElev),
|
|
|
|
|
distanceMeters: Math.round(r),
|
|
|
|
|
slopeDegrees: slopeDeg,
|
|
|
|
|
suitability: isSuitable ? 'EXCELLENT' : slopeDeg <= 12 ? 'MARGINAL' : 'UNSUITABLE',
|
|
|
|
|
suitability_ar: isSuitable ? 'ممتاز (مستوي وخالي من العوائق)' : slopeDeg <= 12 ? 'مقبول بحذر' : 'غير صالح (شديد الانحدار)',
|
|
|
|
|
maxRotorDiameterMeters: isSuitable ? 25 : 15,
|
|
|
|
|
windApproachBearingDeg: Math.round(Math.random() * 360)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
centerLat: lat,
|
|
|
|
|
centerLng: lng,
|
|
|
|
|
searchRadiusMeters: radiusMeters,
|
|
|
|
|
totalAssessed: candidates.length,
|
|
|
|
|
suitableCount: candidates.filter(c => c.suitability === 'EXCELLENT').length,
|
|
|
|
|
zones: candidates
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Save tactical symbols scenario
|
|
|
|
|
*/
|
|
|
|
|
async saveScenario(name: string, symbols: TacticalSymbol[]) {
|
|
|
|
|
const key = `tactical:scenario:${name}`;
|
|
|
|
|
await this.redisService.set(key, JSON.stringify(symbols), 86400 * 7); // 7 days
|
|
|
|
|
return { success: true, count: symbols.length, name };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get saved tactical symbols scenario
|
|
|
|
|
*/
|
|
|
|
|
async getScenario(name: string): Promise<TacticalSymbol[]> {
|
|
|
|
|
const key = `tactical:scenario:${name}`;
|
|
|
|
|
const data = await this.redisService.get<TacticalSymbol[]>(key);
|
|
|
|
|
return data || [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Utility GIS Math ---
|
|
|
|
|
private haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
|
|
|
|
const R = 6371000;
|
|
|
|
|
const dLat = (lat2 - lat1) * (Math.PI / 180);
|
|
|
|
|
const dLon = (lon2 - lon1) * (Math.PI / 180);
|
|
|
|
@@ -81,311 +241,31 @@ export class TacticalService {
|
|
|
|
|
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;
|
|
|
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Calculates Forward Azimuth (0-360 degrees)
|
|
|
|
|
*/
|
|
|
|
|
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);
|
|
|
|
|
private calculateBearing(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;
|
|
|
|
|
return (Math.atan2(y, x) * (180 / Math.PI) + 360) % 360;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Fetches batch elevations for an array of coordinate pairs
|
|
|
|
|
*/
|
|
|
|
|
async getBatchElevations(coords: { lat: number; lng: number }[]): Promise<number[]> {
|
|
|
|
|
const results: number[] = new Array(coords.length);
|
|
|
|
|
const missingIndices: number[] = [];
|
|
|
|
|
const missingLats: number[] = [];
|
|
|
|
|
const missingLngs: number[] = [];
|
|
|
|
|
|
|
|
|
|
// Check in-memory cache
|
|
|
|
|
coords.forEach((coord, i) => {
|
|
|
|
|
const key = `${coord.lat.toFixed(5)},${coord.lng.toFixed(5)}`;
|
|
|
|
|
if (this.elevationCache.has(key)) {
|
|
|
|
|
results[i] = this.elevationCache.get(key)!;
|
|
|
|
|
} else {
|
|
|
|
|
missingIndices.push(i);
|
|
|
|
|
missingLats.push(Number(coord.lat.toFixed(6)));
|
|
|
|
|
missingLngs.push(Number(coord.lng.toFixed(6)));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (missingIndices.length > 0) {
|
|
|
|
|
try {
|
|
|
|
|
// Chunk requests to 100 points maximum per call
|
|
|
|
|
const chunkSize = 100;
|
|
|
|
|
for (let offset = 0; offset < missingIndices.length; offset += chunkSize) {
|
|
|
|
|
const chunkIndices = missingIndices.slice(offset, offset + chunkSize);
|
|
|
|
|
const chunkLats = missingLats.slice(offset, offset + chunkSize);
|
|
|
|
|
const chunkLngs = missingLngs.slice(offset, offset + chunkSize);
|
|
|
|
|
|
|
|
|
|
const url = `https://api.open-meteo.com/v1/elevation?latitude=${chunkLats.join(',')}&longitude=${chunkLngs.join(',')}`;
|
|
|
|
|
const res = await axios.get(url, { timeout: 3500 });
|
|
|
|
|
|
|
|
|
|
if (res.data && Array.isArray(res.data.elevation)) {
|
|
|
|
|
const returnedElevs = res.data.elevation;
|
|
|
|
|
chunkIndices.forEach((origIdx, ci) => {
|
|
|
|
|
const elev = returnedElevs[ci] != null ? Math.round(returnedElevs[ci]) : this.getApproximateElevation(coords[origIdx].lat, coords[origIdx].lng);
|
|
|
|
|
results[origIdx] = elev;
|
|
|
|
|
const key = `${coords[origIdx].lat.toFixed(5)},${coords[origIdx].lng.toFixed(5)}`;
|
|
|
|
|
this.elevationCache.set(key, elev);
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// Fallback for this chunk
|
|
|
|
|
chunkIndices.forEach((origIdx) => {
|
|
|
|
|
const elev = this.getApproximateElevation(coords[origIdx].lat, coords[origIdx].lng);
|
|
|
|
|
results[origIdx] = elev;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
this.logger.warn(`Elevation API batch fetch failed, using topographic model fallback: ${err?.message || err}`);
|
|
|
|
|
missingIndices.forEach((origIdx) => {
|
|
|
|
|
const elev = this.getApproximateElevation(coords[origIdx].lat, coords[origIdx].lng);
|
|
|
|
|
results[origIdx] = elev;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return results;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Topographic fallback estimation model for Middle East & Jordan Levant
|
|
|
|
|
*/
|
|
|
|
|
private getApproximateElevation(lat: number, lng: number): number {
|
|
|
|
|
// Jordan Valley & Dead Sea
|
|
|
|
|
private estimateElevation(lat: number, lng: number): number {
|
|
|
|
|
if (lng < 35.6 && lat < 32.2 && lat > 31.0) {
|
|
|
|
|
const distFromRift = Math.abs(lng - 35.5);
|
|
|
|
|
return Math.round(-400 + distFromRift * 3000);
|
|
|
|
|
return -400 + Math.abs(lng - 35.5) * 3000;
|
|
|
|
|
}
|
|
|
|
|
// Northern Highlands (Ajloun / Jerash)
|
|
|
|
|
if (lat >= 32.1 && lng < 36.0) {
|
|
|
|
|
return Math.round(850 + Math.sin(lat * 50) * 250 + Math.cos(lng * 40) * 150);
|
|
|
|
|
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 Math.round(900 + Math.sin((lat - 31.95) * 100) * 120 + Math.cos((lng - 35.9) * 100) * 100);
|
|
|
|
|
return 900 + Math.sin((lat - 31.95) * 100) * 120 + Math.cos((lng - 35.9) * 100) * 100;
|
|
|
|
|
}
|
|
|
|
|
// Southern Highlands
|
|
|
|
|
if (lat < 31.5 && lat > 30.0 && lng < 35.7) {
|
|
|
|
|
return Math.round(1100 + Math.sin(lat * 30) * 350);
|
|
|
|
|
return 1100 + Math.sin(lat * 30) * 350;
|
|
|
|
|
}
|
|
|
|
|
// Eastern Desert / Badia
|
|
|
|
|
return Math.round(650 + (lng - 36.0) * 30);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Computes complete Tactical Line of Sight (LOS) and Elevation Profile
|
|
|
|
|
* with Adaptive Intelligent Step Resolution (المعاينة التكيفية الذكية)
|
|
|
|
|
*/
|
|
|
|
|
async computeLineOfSight(
|
|
|
|
|
startLat: number,
|
|
|
|
|
startLng: number,
|
|
|
|
|
endLat: number,
|
|
|
|
|
endLng: number,
|
|
|
|
|
obsHeightOffset: number = 2,
|
|
|
|
|
tgtHeightOffset: number = 2,
|
|
|
|
|
samplesCount?: number,
|
|
|
|
|
stepMeters?: number,
|
|
|
|
|
compact: boolean = false,
|
|
|
|
|
): Promise<LineOfSightResponse> {
|
|
|
|
|
const totalDistance = this.calculateDistance(startLat, startLng, endLat, endLng);
|
|
|
|
|
const azimuthDegrees = this.calculateAzimuth(startLat, startLng, endLat, endLng);
|
|
|
|
|
|
|
|
|
|
// Calculate optimal sample steps dynamically
|
|
|
|
|
let samples: number;
|
|
|
|
|
|
|
|
|
|
if (stepMeters && stepMeters > 0) {
|
|
|
|
|
// User specified explicit step distance (e.g. measure every 5m or 25m)
|
|
|
|
|
samples = Math.max(2, Math.min(300, Math.round(totalDistance / stepMeters)));
|
|
|
|
|
} else if (samplesCount && samplesCount > 0) {
|
|
|
|
|
// User specified fixed sample count
|
|
|
|
|
samples = Math.min(300, Math.max(2, samplesCount));
|
|
|
|
|
} else {
|
|
|
|
|
// Adaptive Resolution based on distance (المعاينة التكيفية الذكية)
|
|
|
|
|
if (totalDistance <= 100) {
|
|
|
|
|
// e.g. 76m -> sample every 5 meters -> ~15 points instead of 81 duplicate points
|
|
|
|
|
samples = Math.max(4, Math.round(totalDistance / 5));
|
|
|
|
|
} else if (totalDistance <= 500) {
|
|
|
|
|
// e.g. 300m -> sample every 10 meters -> 30 points
|
|
|
|
|
samples = Math.max(10, Math.round(totalDistance / 10));
|
|
|
|
|
} else if (totalDistance <= 2500) {
|
|
|
|
|
// e.g. 1500m -> sample every 25 meters -> 60 points
|
|
|
|
|
samples = Math.max(20, Math.round(totalDistance / 25));
|
|
|
|
|
} else if (totalDistance <= 10000) {
|
|
|
|
|
// e.g. 8km -> sample every 80 meters -> 100 points
|
|
|
|
|
samples = Math.max(40, Math.round(totalDistance / 80));
|
|
|
|
|
} else if (totalDistance <= 50000) {
|
|
|
|
|
// e.g. 30km -> sample every 250 meters -> 120 points
|
|
|
|
|
samples = Math.max(50, Math.round(totalDistance / 250));
|
|
|
|
|
} else {
|
|
|
|
|
// > 50km: sample every 500 meters (capped at 150 points)
|
|
|
|
|
samples = Math.min(150, Math.max(60, Math.round(totalDistance / 500)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const actualStepMeters = totalDistance > 0 ? Math.round((totalDistance / samples) * 10) / 10 : 0;
|
|
|
|
|
|
|
|
|
|
// Generate sample points along the geodesic ray
|
|
|
|
|
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 batch elevations
|
|
|
|
|
const elevations = await this.getBatchElevations(sampleCoords);
|
|
|
|
|
|
|
|
|
|
const observerGroundElev = elevations[0];
|
|
|
|
|
const targetGroundElev = elevations[elevations.length - 1];
|
|
|
|
|
const observerTotalElev = observerGroundElev + obsHeightOffset;
|
|
|
|
|
const targetTotalElev = targetGroundElev + tgtHeightOffset;
|
|
|
|
|
|
|
|
|
|
// Effective Earth radius accounting for standard 4/3 atmospheric refraction
|
|
|
|
|
const effectiveEarthRadius = (4 / 3) * 6371000;
|
|
|
|
|
|
|
|
|
|
let isDirectlyVisible = true;
|
|
|
|
|
let highestObstacle: ObstacleInfo | null = null;
|
|
|
|
|
let maxExcessHeight = -Infinity;
|
|
|
|
|
|
|
|
|
|
let minElev = Infinity;
|
|
|
|
|
let maxElev = -Infinity;
|
|
|
|
|
let maxAngleSoFar = -Infinity;
|
|
|
|
|
let hiddenPointsCount = 0;
|
|
|
|
|
|
|
|
|
|
const points: ElevationPoint[] = [];
|
|
|
|
|
|
|
|
|
|
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 = totalDistance > 0 ? (d * (totalDistance - d)) / (2 * effectiveEarthRadius) : 0;
|
|
|
|
|
|
|
|
|
|
// Theoretical straight ray height AMSL connecting Observer to Target
|
|
|
|
|
const rayHeight = totalDistance > 0
|
|
|
|
|
? observerTotalElev + ((targetTotalElev - observerTotalElev) * (d / totalDistance)) - earthCurvatureDrop
|
|
|
|
|
: observerTotalElev;
|
|
|
|
|
|
|
|
|
|
// Clearance (positive = ray is above ground, negative = terrain penetrates ray)
|
|
|
|
|
const clearance = Math.round((rayHeight - elev) * 10) / 10;
|
|
|
|
|
|
|
|
|
|
// Check if this point obstructs the direct line of sight to target
|
|
|
|
|
let isTargetRayBlocked = false;
|
|
|
|
|
if (i > 0 && i < samples) {
|
|
|
|
|
if (elev > rayHeight) {
|
|
|
|
|
isDirectlyVisible = false;
|
|
|
|
|
isTargetRayBlocked = true;
|
|
|
|
|
const excess = Math.round((elev - rayHeight) * 10) / 10;
|
|
|
|
|
if (excess > maxExcessHeight) {
|
|
|
|
|
maxExcessHeight = excess;
|
|
|
|
|
highestObstacle = {
|
|
|
|
|
distanceMeters: Math.round(d),
|
|
|
|
|
distanceKm: Math.round((d / 1000) * 100) / 100,
|
|
|
|
|
lat: Number(sampleCoords[i].lat.toFixed(6)),
|
|
|
|
|
lng: Number(sampleCoords[i].lng.toFixed(6)),
|
|
|
|
|
groundElevationMeters: elev,
|
|
|
|
|
rayElevationMeters: Math.round(rayHeight),
|
|
|
|
|
excessHeightMeters: excess,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check dead ground visibility from observer perspective
|
|
|
|
|
let isVisibleFromObserver = true;
|
|
|
|
|
if (i === 0) {
|
|
|
|
|
isVisibleFromObserver = true;
|
|
|
|
|
} else {
|
|
|
|
|
const dropFromObs = (d * d) / (2 * effectiveEarthRadius);
|
|
|
|
|
const apparentElev = elev - dropFromObs;
|
|
|
|
|
const angle = (apparentElev - observerTotalElev) / d;
|
|
|
|
|
if (angle < maxAngleSoFar) {
|
|
|
|
|
isVisibleFromObserver = false;
|
|
|
|
|
hiddenPointsCount++;
|
|
|
|
|
} else {
|
|
|
|
|
maxAngleSoFar = angle;
|
|
|
|
|
isVisibleFromObserver = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
points.push({
|
|
|
|
|
index: i,
|
|
|
|
|
distanceMeters: Math.round(d),
|
|
|
|
|
distanceKm: Math.round((d / 1000) * 100) / 100,
|
|
|
|
|
lat: Number(sampleCoords[i].lat.toFixed(6)),
|
|
|
|
|
lng: Number(sampleCoords[i].lng.toFixed(6)),
|
|
|
|
|
groundElevationMeters: elev,
|
|
|
|
|
rayElevationMeters: Math.round(rayHeight),
|
|
|
|
|
clearanceMeters: clearance,
|
|
|
|
|
isVisible: isVisibleFromObserver,
|
|
|
|
|
isTargetRayBlocked,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Vertical angle from observer to target
|
|
|
|
|
const deltaH = targetTotalElev - observerTotalElev;
|
|
|
|
|
const curvatureDropTotal = (totalDistance * totalDistance) / (2 * effectiveEarthRadius);
|
|
|
|
|
const correctedDeltaH = deltaH - curvatureDropTotal;
|
|
|
|
|
const verticalAngleRad = totalDistance > 0 ? Math.atan2(correctedDeltaH, totalDistance) : 0;
|
|
|
|
|
const verticalAngleDeg = Math.round((verticalAngleRad * (180 / Math.PI)) * 100) / 100;
|
|
|
|
|
const verticalAngleMilsNato = Math.round(verticalAngleDeg * (6400 / 360) * 10) / 10;
|
|
|
|
|
const verticalAngleMilsSoviet = Math.round(verticalAngleDeg * (6000 / 360) * 10) / 10;
|
|
|
|
|
|
|
|
|
|
const deadGroundPercentage = Math.round((hiddenPointsCount / (samples + 1)) * 100);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
isDirectlyVisible,
|
|
|
|
|
status: isDirectlyVisible ? 'CLEAR_LINE_OF_SIGHT' : 'OBSTRUCTED',
|
|
|
|
|
statusAr: isDirectlyVisible ? 'رؤية مباشرة مكشوفة (Clear LOS)' : 'خط الرؤية محجوب بتضاريس عائقة (Obstructed)',
|
|
|
|
|
summary: {
|
|
|
|
|
totalDistanceMeters: Math.round(totalDistance),
|
|
|
|
|
totalDistanceKm: Math.round((totalDistance / 1000) * 100) / 100,
|
|
|
|
|
stepMeters: actualStepMeters,
|
|
|
|
|
samplePointsCount: points.length,
|
|
|
|
|
azimuthDegrees: Math.round(azimuthDegrees * 10) / 10,
|
|
|
|
|
verticalAngleDegrees: verticalAngleDeg,
|
|
|
|
|
verticalAngleMilsNato,
|
|
|
|
|
verticalAngleMilsSoviet,
|
|
|
|
|
observerGroundElevationMeters: observerGroundElev,
|
|
|
|
|
observerTotalElevationMeters: observerTotalElev,
|
|
|
|
|
targetGroundElevationMeters: targetGroundElev,
|
|
|
|
|
targetTotalElevationMeters: targetTotalElev,
|
|
|
|
|
minElevationMeters: minElev,
|
|
|
|
|
maxElevationMeters: maxElev,
|
|
|
|
|
deadGroundPercentage,
|
|
|
|
|
},
|
|
|
|
|
highestObstacle,
|
|
|
|
|
observer: {
|
|
|
|
|
lat: Number(startLat.toFixed(6)),
|
|
|
|
|
lng: Number(startLng.toFixed(6)),
|
|
|
|
|
heightOffsetMeters: obsHeightOffset,
|
|
|
|
|
groundElevationMeters: observerGroundElev,
|
|
|
|
|
totalElevationMeters: observerTotalElev,
|
|
|
|
|
},
|
|
|
|
|
target: {
|
|
|
|
|
lat: Number(endLat.toFixed(6)),
|
|
|
|
|
lng: Number(endLng.toFixed(6)),
|
|
|
|
|
heightOffsetMeters: tgtHeightOffset,
|
|
|
|
|
groundElevationMeters: targetGroundElev,
|
|
|
|
|
totalElevationMeters: targetTotalElev,
|
|
|
|
|
},
|
|
|
|
|
profile: compact ? [] : points,
|
|
|
|
|
};
|
|
|
|
|
return 650 + (lng - 36.0) * 30;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|