From 9247499f5013c6302243e81232ea8788b0157185 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Tue, 18 Aug 2026 09:47:04 +0300 Subject: [PATCH] fix(tactical): sync complete tactical service with LOS and artillery methods --- apps/api/src/tactical/tactical.service.ts | 337 ++++++++++++++++++---- 1 file changed, 276 insertions(+), 61 deletions(-) diff --git a/apps/api/src/tactical/tactical.service.ts b/apps/api/src/tactical/tactical.service.ts index c60380d..80489c6 100644 --- a/apps/api/src/tactical/tactical.service.ts +++ b/apps/api/src/tactical/tactical.service.ts @@ -1,41 +1,261 @@ import { Injectable, Logger } from '@nestjs/common'; import { RedisService } from '../common/redis.service'; +import { ArtilleryMissionRequestDto, TacticalSymbolDto } from './dto/tactical.dto'; -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 TacticalSymbol { - id: string; - type: 'friendly' | 'hostile' | 'neutral' | 'unknown' | 'radar' | 'artillery' | 'minefield' | 'checkpoint' | 'hlz' | 'op'; - name: string; - name_ar: string; +export interface LosPoint { + index: number; + distanceMeters: number; lat: number; lng: number; - elevation?: number; - notes?: string; - timeAdded: string; + groundElevationMeters: number; + earthCurvatureSagittaMeters: number; + apparentElevationMeters: number; + sightRayElevationMeters: number; + marginMeters: number; + isVisible: boolean; + isDeadGround: boolean; + horizonAngleMils: number; +} + +export interface LineOfSightResponse { + isDirectlyVisible: boolean; + status: 'CLEAR_LINE_OF_SIGHT' | 'OBSTRUCTED'; + statusAr: string; + summary: { + totalDistanceMeters: number; + totalDistanceKm: number; + azimuthDegrees: number; + verticalAngleDegrees: number; + verticalAngleMilsNato: number; + verticalAngleMilsSoviet: number; + observerGroundElevationMeters: number; + observerTotalElevationMeters: number; + targetGroundElevationMeters: number; + targetTotalElevationMeters: number; + minElevationMeters: number; + maxElevationMeters: number; + deadGroundPercentage: number; + samplePointsCount: number; + stepMeters?: number; + }; + highestObstacle: { + distanceMeters: number; + lat: number; + lng: number; + elevationMeters: number; + sightRayElevationMeters: number; + penetrationMeters: number; + } | null; + observer: { + lat: number; + lng: number; + heightOffsetMeters: number; + groundElevationMeters: number; + totalElevationMeters: number; + }; + target: { + lat: number; + lng: number; + heightOffsetMeters: number; + groundElevationMeters: number; + totalElevationMeters: number; + }; + profile?: LosPoint[]; } @Injectable() export class TacticalService { private readonly logger = new Logger(TacticalService.name); - constructor(private readonly redisService: RedisService) { } + constructor(private readonly redisService: RedisService) {} + + /** + * Tactical Line of Sight (LOS) & Intervisibility Engine + */ + async computeLineOfSight( + observerLat: number, + observerLng: number, + targetLat: number, + targetLng: number, + observerHeight: number = 2, + targetHeight: number = 2, + samplesCount?: number, + stepMeters?: number, + compact: boolean = false, + ): Promise { + const totalDistanceMeters = this.haversineDistance( + observerLat, + observerLng, + targetLat, + targetLng, + ); + + let effectiveStep = stepMeters; + let effectiveSamples = samplesCount; + + if (effectiveStep != null && effectiveStep > 0) { + effectiveSamples = Math.max(5, Math.min(300, Math.round(totalDistanceMeters / effectiveStep) + 1)); + } else if (effectiveSamples != null && effectiveSamples >= 5) { + effectiveSamples = Math.min(300, effectiveSamples); + effectiveStep = totalDistanceMeters / (effectiveSamples - 1); + } else { + if (totalDistanceMeters <= 500) { + effectiveStep = 5; + } else if (totalDistanceMeters <= 2000) { + effectiveStep = 10; + } else if (totalDistanceMeters <= 10000) { + effectiveStep = 25; + } else if (totalDistanceMeters <= 30000) { + effectiveStep = 50; + } else { + effectiveStep = 100; + } + effectiveSamples = Math.max(10, Math.min(200, Math.round(totalDistanceMeters / effectiveStep) + 1)); + } + + const azimuthDegrees = this.calculateBearing(observerLat, observerLng, targetLat, targetLng); + + const observerGround = this.estimateElevation(observerLat, observerLng); + const targetGround = this.estimateElevation(targetLat, targetLng); + + const observerTotal = observerGround + observerHeight; + const targetTotal = targetGround + targetHeight; + + const R_earth = 6371000; + const k_refraction = 0.13; + const effectiveRadius = R_earth / (1 - k_refraction); + + const points: LosPoint[] = []; + let isDirectlyVisible = true; + let highestObstacle: LineOfSightResponse['highestObstacle'] = null; + let maxPenetration = 0; + + let minElev = Math.min(observerGround, targetGround); + let maxElev = Math.max(observerGround, targetGround); + + let maxHorizonAngle = -Infinity; + let deadGroundCount = 0; + + for (let i = 0; i < effectiveSamples; i++) { + const fraction = effectiveSamples === 1 ? 0 : i / (effectiveSamples - 1); + const dMeters = totalDistanceMeters * fraction; + + const pLat = observerLat + (targetLat - observerLat) * fraction; + const pLng = observerLng + (targetLng - observerLng) * fraction; + + const gElev = this.estimateElevation(pLat, pLng); + minElev = Math.min(minElev, gElev); + maxElev = Math.max(maxElev, gElev); + + const d1 = dMeters; + const d2 = totalDistanceMeters - dMeters; + const sagitta = (d1 * d2) / (2 * effectiveRadius); + + const apparentElev = gElev + sagitta; + const rayElev = observerTotal + (targetTotal - observerTotal) * fraction; + + const margin = rayElev - apparentElev; + const isPointVisible = i === 0 || i === effectiveSamples - 1 || margin >= 0; + + if (!isPointVisible) { + isDirectlyVisible = false; + const penetration = apparentElev - rayElev; + if (penetration > maxPenetration) { + maxPenetration = penetration; + highestObstacle = { + distanceMeters: Math.round(dMeters), + lat: Number(pLat.toFixed(6)), + lng: Number(pLng.toFixed(6)), + elevationMeters: Math.round(gElev), + sightRayElevationMeters: Math.round(rayElev), + penetrationMeters: Math.round(penetration * 10) / 10, + }; + } + } + + const angleFromObserver = dMeters > 0 ? (apparentElev - observerTotal) / dMeters : 0; + const angleMils = angleFromObserver * (6400 / (2 * Math.PI)); + + let isDeadGround = false; + if (i > 0) { + if (angleFromObserver < maxHorizonAngle) { + isDeadGround = true; + deadGroundCount++; + } else { + maxHorizonAngle = angleFromObserver; + } + } + + points.push({ + index: i, + distanceMeters: Math.round(dMeters), + lat: Number(pLat.toFixed(6)), + lng: Number(pLng.toFixed(6)), + groundElevationMeters: Math.round(gElev), + earthCurvatureSagittaMeters: Math.round(sagitta * 10) / 10, + apparentElevationMeters: Math.round(apparentElev * 10) / 10, + sightRayElevationMeters: Math.round(rayElev * 10) / 10, + marginMeters: Math.round(margin * 10) / 10, + isVisible: isPointVisible, + isDeadGround, + horizonAngleMils: Math.round(angleMils * 10) / 10, + }); + } + + const elevDiff = targetTotal - observerTotal; + const verticalAngleRad = totalDistanceMeters > 0 ? Math.atan2(elevDiff, totalDistanceMeters) : 0; + const verticalAngleDegrees = (verticalAngleRad * 180) / Math.PI; + const verticalAngleMilsNato = (verticalAngleRad * 6400) / (2 * Math.PI); + const verticalAngleMilsSoviet = (verticalAngleRad * 6000) / (2 * Math.PI); + + const deadGroundPercentage = effectiveSamples > 1 + ? Math.round((deadGroundCount / (effectiveSamples - 1)) * 100) + : 0; + + return { + isDirectlyVisible, + status: isDirectlyVisible ? 'CLEAR_LINE_OF_SIGHT' : 'OBSTRUCTED', + statusAr: isDirectlyVisible ? 'رؤية مباشرة مكشوفة (Clear LOS)' : 'خط الرؤية محجوب بتضاريس عائقة (Obstructed)', + summary: { + totalDistanceMeters: Math.round(totalDistanceMeters), + totalDistanceKm: Math.round((totalDistanceMeters / 1000) * 100) / 100, + azimuthDegrees: Math.round(azimuthDegrees * 10) / 10, + verticalAngleDegrees: Math.round(verticalAngleDegrees * 100) / 100, + verticalAngleMilsNato: Math.round(verticalAngleMilsNato * 10) / 10, + verticalAngleMilsSoviet: Math.round(verticalAngleMilsSoviet * 10) / 10, + observerGroundElevationMeters: Math.round(observerGround), + observerTotalElevationMeters: Math.round(observerTotal), + targetGroundElevationMeters: Math.round(targetGround), + targetTotalElevationMeters: Math.round(targetTotal), + minElevationMeters: Math.round(minElev), + maxElevationMeters: Math.round(maxElev), + deadGroundPercentage, + samplePointsCount: effectiveSamples, + stepMeters: effectiveStep ? Math.round(effectiveStep * 10) / 10 : undefined, + }, + highestObstacle, + observer: { + lat: observerLat, + lng: observerLng, + heightOffsetMeters: observerHeight, + groundElevationMeters: Math.round(observerGround), + totalElevationMeters: Math.round(observerTotal), + }, + target: { + lat: targetLat, + lng: targetLng, + heightOffsetMeters: targetHeight, + groundElevationMeters: Math.round(targetGround), + totalElevationMeters: Math.round(targetTotal), + }, + ...(compact ? {} : { profile: points }), + }; + } /** * Calculates ballistic artillery trajectory and checks terrain crest clearance - * حساب مسار القذيفة البالستي القوسي وفحص أمان مرورها فوق القمم التضاريسية */ - async calculateArtilleryFireMission(dto: ArtilleryMissionRequest) { + async calculateArtilleryFireMission(dto: ArtilleryMissionRequestDto) { const { gunLat, gunLng, @@ -43,15 +263,13 @@ export class TacticalService { targetLng, gunElevationOffset = 2, targetElevationOffset = 0, - muzzleVelocity = 827, // 155mm Howitzer standard muzzle velocity - caliber = '155mm Howitzer' + muzzleVelocity = 827, + 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); @@ -59,42 +277,34 @@ export class TacticalService { 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; + const maxRange = Math.pow(v0, 2) / g; + const 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 lowAngleMils = lowAngleDeg * (6400 / 360); const highAngleDeg = (highAngleRad * 180) / Math.PI; - const highAngleMils = (highAngleDeg * (6400 / 360)); + const highAngleMils = highAngleDeg * (6400 / 360); - // Time of Flight (TOF) = x / (v0 * cos(theta)) const timeOfFlightSeconds = distanceMeters / (v0 * Math.cos(lowAngleRad)); + const apexHeightMeters = gunTotalElev + Math.pow(v0 * Math.sin(lowAngleRad), 2) / (2 * g); - // 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; @@ -106,9 +316,8 @@ export class TacticalService { 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 y = v0 * Math.sin(lowAngleRad) * t - 0.5 * g * Math.pow(t, 2); const projectileAlt = gunTotalElev + y; const terrainElev = this.estimateElevation(lat, lng); @@ -123,7 +332,7 @@ export class TacticalService { projectileAltMeters: Math.round(projectileAlt), deficitMeters: Math.round(Math.abs(clearance)), lat, - lng + lng, }; } } @@ -134,7 +343,7 @@ export class TacticalService { lng, terrainElevation: Math.round(terrainElev), projectileAltitude: Math.round(projectileAlt), - clearanceMeters: Math.round(clearance) + clearanceMeters: Math.round(clearance), }); } @@ -160,21 +369,20 @@ export class TacticalService { }, hasCrestClearance, criticalObstacle, - trajectoryPoints + 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 + const samples = 16; 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 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); @@ -183,10 +391,13 @@ export class TacticalService { 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 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; @@ -200,7 +411,7 @@ export class TacticalService { suitability: isSuitable ? 'EXCELLENT' : slopeDeg <= 12 ? 'MARGINAL' : 'UNSUITABLE', suitability_ar: isSuitable ? 'ممتاز (مستوي وخالي من العوائق)' : slopeDeg <= 12 ? 'مقبول بحذر' : 'غير صالح (شديد الانحدار)', maxRotorDiameterMeters: isSuitable ? 25 : 15, - windApproachBearingDeg: Math.round(Math.random() * 360) + windApproachBearingDeg: Math.round(Math.random() * 360), }); } @@ -209,26 +420,26 @@ export class TacticalService { centerLng: lng, searchRadiusMeters: radiusMeters, totalAssessed: candidates.length, - suitableCount: candidates.filter(c => c.suitability === 'EXCELLENT').length, - zones: candidates + suitableCount: candidates.filter((c) => c.suitability === 'EXCELLENT').length, + zones: candidates, }; } /** * Save tactical symbols scenario */ - async saveScenario(name: string, symbols: TacticalSymbol[]) { + async saveScenario(name: string, symbols: TacticalSymbolDto[]) { const key = `tactical:scenario:${name}`; - await this.redisService.set(key, JSON.stringify(symbols), 86400 * 7); // 7 days + await this.redisService.set(key, JSON.stringify(symbols), 86400 * 7); return { success: true, count: symbols.length, name }; } /** * Get saved tactical symbols scenario */ - async getScenario(name: string): Promise { + async getScenario(name: string): Promise { const key = `tactical:scenario:${name}`; - const data = await this.redisService.get(key); + const data = await this.redisService.get(key); return data || []; } @@ -239,8 +450,10 @@ export class TacticalService { 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); + Math.cos(lat1 * (Math.PI / 180)) * + Math.cos(lat2 * (Math.PI / 180)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } @@ -249,8 +462,10 @@ export class TacticalService { 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); - return (Math.atan2(y, x) * (180 / Math.PI) + 360) % 360; + const x = + Math.cos(phi1) * Math.sin(phi2) - + Math.sin(phi1) * Math.cos(phi2) * Math.cos(deltaLambda); + return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360; } private estimateElevation(lat: number, lng: number): number {