@@ -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 ;
}
}