fix(tactical): resolve DTO types and TS1272 build error for isolatedModules

This commit is contained in:
Hamza-Ayed
2026-08-18 09:46:04 +03:00
parent bd287d155b
commit 941420f5c0
86 changed files with 3614 additions and 1556 deletions
+106
View File
@@ -0,0 +1,106 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator';
export class ArtilleryMissionRequestDto {
@ApiProperty({ description: 'Gun / Battery Latitude', example: 31.9539 })
@IsNumber()
@Type(() => Number)
gunLat: number;
@ApiProperty({ description: 'Gun / Battery Longitude', example: 35.9106 })
@IsNumber()
@Type(() => Number)
gunLng: number;
@ApiProperty({ description: 'Target Latitude', example: 32.01 })
@IsNumber()
@Type(() => Number)
targetLat: number;
@ApiProperty({ description: 'Target Longitude', example: 35.87 })
@IsNumber()
@Type(() => Number)
targetLng: number;
@ApiPropertyOptional({ description: 'Gun elevation offset above ground in meters', default: 2 })
@IsOptional()
@IsNumber()
@Type(() => Number)
gunElevationOffset?: number;
@ApiPropertyOptional({ description: 'Target elevation offset above ground in meters', default: 0 })
@IsOptional()
@IsNumber()
@Type(() => Number)
targetElevationOffset?: number;
@ApiPropertyOptional({ description: 'Powder charge type (Low, Med, High)' })
@IsOptional()
@IsString()
chargeType?: string;
@ApiPropertyOptional({ description: 'Muzzle velocity in m/s (default: 827 m/s for 155mm Howitzer)', default: 827 })
@IsOptional()
@IsNumber()
@Type(() => Number)
muzzleVelocity?: number;
@ApiPropertyOptional({ description: 'Weapon Caliber description', default: '155mm Howitzer' })
@IsOptional()
@IsString()
caliber?: string;
}
export class TacticalSymbolDto {
@ApiProperty({ description: 'Symbol Unique ID' })
@IsString()
id: string;
@ApiProperty({ description: 'Symbol military type' })
@IsString()
type: string;
@ApiProperty({ description: 'Name in English' })
@IsString()
name: string;
@ApiProperty({ description: 'Name in Arabic' })
@IsString()
name_ar: string;
@ApiProperty({ description: 'Latitude' })
@IsNumber()
@Type(() => Number)
lat: number;
@ApiProperty({ description: 'Longitude' })
@IsNumber()
@Type(() => Number)
lng: number;
@ApiPropertyOptional({ description: 'Elevation' })
@IsOptional()
@IsNumber()
@Type(() => Number)
elevation?: number;
@ApiPropertyOptional({ description: 'Operational Notes' })
@IsOptional()
@IsString()
notes?: string;
@ApiProperty({ description: 'Timestamp added' })
@IsString()
timeAdded: string;
}
export class SaveScenarioDto {
@ApiProperty({ description: 'Scenario Name', example: 'default' })
@IsString()
name: string;
@ApiProperty({ description: 'List of tactical symbols', type: [TacticalSymbolDto] })
@IsArray()
symbols: TacticalSymbolDto[];
}
@@ -12,6 +12,7 @@ import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiKeyGuard } from '../common/guards/api-key.guard';
import { TenantThrottlerGuard } from '../common/guards/rate-limiter.guard';
import { LineOfSightBodyDto, LineOfSightQueryDto } from './dto/line-of-sight.dto';
import { ArtilleryMissionRequestDto, SaveScenarioDto } from './dto/tactical.dto';
import { TacticalService } from './tactical.service';
@ApiTags('tactical')
@@ -113,4 +114,43 @@ export class TacticalController {
async postLosAlias(@Body() body: LineOfSightBodyDto) {
return this.postLineOfSight(body);
}
@Post('artillery-fire-mission')
@ApiOperation({
summary: 'Calculate Artillery Fire Mission & Ballistic Trajectory / حساب رماية المدفعية ومسار القذيفة',
})
async calculateArtilleryFireMission(@Body() dto: ArtilleryMissionRequestDto) {
return this.tacticalService.calculateArtilleryFireMission(dto);
}
@Get('hlz-assessment')
@ApiOperation({
summary: 'Assess Helicopter Landing Zones (HLZ) & MEDEVAC / كشف صلاحية مهابط المروحيات والإخلاء الطبي',
})
async assessHLZ(
@Query('lat') lat: string,
@Query('lng') lng: string,
@Query('radius') radius?: string,
) {
const latNum = parseFloat(lat || '31.95');
const lngNum = parseFloat(lng || '35.93');
const radiusNum = parseFloat(radius || '3000');
return this.tacticalService.assessHelicopterLandingZones(latNum, lngNum, radiusNum);
}
@Post('scenarios')
@ApiOperation({
summary: 'Save tactical symbols scenario / حفظ سيناريو الرموز التكتيكية',
})
async saveScenario(@Body() body: SaveScenarioDto) {
return this.tacticalService.saveScenario(body.name || 'default', body.symbols || []);
}
@Get('scenarios')
@ApiOperation({
summary: 'Get saved tactical symbols scenario / استرجاع سيناريو الرموز التكتيكية',
})
async getScenario(@Query('name') name?: string) {
return this.tacticalService.getScenario(name || 'default');
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
import { Module } from '@nestjs/common';
import { TacticalController } from './tactical.controller';
import { TacticalService } from './tactical.service';
import { AuthModule } from '../auth/auth.module';
import { RedisModule } from '../common/redis.module';
@Module({
imports: [AuthModule],
imports: [RedisModule],
controllers: [TacticalController],
providers: [TacticalService],
exports: [TacticalService],
+233 -353
View File
@@ -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;
}
}