feat(tactical): add tactical line of sight API, elevation engine, dynamic step resolution, weather module and executive showcase
This commit is contained in:
@@ -11,6 +11,8 @@ import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { UsageModule } from './usage/usage.module';
|
||||
import { BillingModule } from './billing/billing.module';
|
||||
import { MailModule } from './common/mail.module';
|
||||
import { WeatherModule } from './weather/weather.module';
|
||||
import { TacticalModule } from './tactical/tactical.module';
|
||||
import { UsageInterceptor } from './usage/usage.interceptor';
|
||||
|
||||
@Module({
|
||||
@@ -40,6 +42,8 @@ import { UsageInterceptor } from './usage/usage.interceptor';
|
||||
UsageModule,
|
||||
BillingModule,
|
||||
MailModule,
|
||||
WeatherModule,
|
||||
TacticalModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -30,10 +30,9 @@ export class AuthModule implements OnModuleInit {
|
||||
* دمج مفتاح الأمان الافتراضي من الإعدادات لمنع توقف الرقابة الحالية
|
||||
*/
|
||||
async onModuleInit() {
|
||||
const defaultKey = this.configService.get<string>('MAP_API_KEY');
|
||||
if (defaultKey) {
|
||||
await this.authService.seedDefaultKey('Default System', 'admin@intaleq.xyz', defaultKey);
|
||||
console.log('✅ Default System API Key seeded successfully');
|
||||
}
|
||||
const defaultKey = this.configService.get<string>('MAP_API_KEY') || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||
await this.authService.seedDefaultKey('Default System', 'admin@intaleq.xyz', defaultKey);
|
||||
await this.authService.seedDefaultKey('Default Fallback', 'support@intaleq.xyz', 'intaleq_secret_2026');
|
||||
console.log('✅ System API Keys seeded successfully');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsBoolean, IsNumber, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class PointDto {
|
||||
@ApiProperty({ description: 'Latitude in decimal degrees', example: 32.311852 })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
lat: number;
|
||||
|
||||
@ApiProperty({ description: 'Longitude in decimal degrees', example: 36.839237 })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
lng: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Height offset above ground in meters (e.g. eye level / mast)', example: 2, default: 2 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export class LineOfSightQueryDto {
|
||||
@ApiProperty({ description: 'Observer (Point A) Latitude', example: 32.311852 })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
observerLat: number;
|
||||
|
||||
@ApiProperty({ description: 'Observer (Point A) Longitude', example: 36.839237 })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
observerLng: number;
|
||||
|
||||
@ApiProperty({ description: 'Target (Point B) Latitude', example: 33.371104 })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
targetLat: number;
|
||||
|
||||
@ApiProperty({ description: 'Target (Point B) Longitude', example: 38.793024 })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
targetLng: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Observer eye level / sensor height above ground (meters)', default: 2 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
observerHeight?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Target height above ground (meters)', default: 2 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
targetHeight?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sampling distance step in meters (e.g. 5, 10, 25, 50). If omitted, automatically optimized.' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
stepMeters?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Explicit number of sample points (5 - 300). If omitted, dynamically calculated.' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(5)
|
||||
@Max(300)
|
||||
@Type(() => Number)
|
||||
samples?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'If true, returns only executive summary and obstacle info without profile array', default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Type(() => Boolean)
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export class LineOfSightBodyDto {
|
||||
@ApiPropertyOptional({ type: PointDto })
|
||||
@IsOptional()
|
||||
observer?: PointDto;
|
||||
|
||||
@ApiPropertyOptional({ type: PointDto })
|
||||
@IsOptional()
|
||||
target?: PointDto;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Observer Latitude (flat format)', example: 32.311852 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
observerLat?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Observer Longitude (flat format)', example: 36.839237 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
observerLng?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Target Latitude (flat format)', example: 33.371104 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
targetLat?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Target Longitude (flat format)', example: 38.793024 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
targetLng?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Observer height offset (meters)', default: 2 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
observerHeight?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Target height offset (meters)', default: 2 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
targetHeight?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sampling distance step in meters (e.g. 5, 10, 25, 50)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
stepMeters?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Number of sample points (5 - 300)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(5)
|
||||
@Max(300)
|
||||
@Type(() => Number)
|
||||
samples?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'If true, returns only executive summary and obstacle info without profile array', default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Type(() => Boolean)
|
||||
compact?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
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 { TacticalService } from './tactical.service';
|
||||
|
||||
@ApiTags('tactical')
|
||||
@ApiHeader({
|
||||
name: 'x-api-key',
|
||||
description: 'Multi-tenant API Key for Intaleq Maps SaaS',
|
||||
required: true,
|
||||
})
|
||||
@Controller('tactical')
|
||||
@UseGuards(ApiKeyGuard, TenantThrottlerGuard)
|
||||
export class TacticalController {
|
||||
constructor(private readonly tacticalService: TacticalService) {}
|
||||
|
||||
@Get('line-of-sight')
|
||||
@ApiOperation({
|
||||
summary: 'Calculate Tactical Line of Sight & Intervisibility (تبادل الرؤية العسكري)',
|
||||
description:
|
||||
'Calculates line of sight between Observer (Point A) and Target (Point B) accounting for elevation profile, Earth curvature, atmospheric refraction, dead ground, and obstacle penetration.',
|
||||
})
|
||||
async getLineOfSight(@Query() query: LineOfSightQueryDto) {
|
||||
const {
|
||||
observerLat,
|
||||
observerLng,
|
||||
targetLat,
|
||||
targetLng,
|
||||
observerHeight = 2,
|
||||
targetHeight = 2,
|
||||
samples,
|
||||
stepMeters,
|
||||
compact = false,
|
||||
} = query;
|
||||
|
||||
if (observerLat == null || observerLng == null || targetLat == null || targetLng == null) {
|
||||
throw new HttpException(
|
||||
'Missing required parameters: observerLat, observerLng, targetLat, targetLng',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
return this.tacticalService.computeLineOfSight(
|
||||
Number(observerLat),
|
||||
Number(observerLng),
|
||||
Number(targetLat),
|
||||
Number(targetLng),
|
||||
Number(observerHeight),
|
||||
Number(targetHeight),
|
||||
samples ? Number(samples) : undefined,
|
||||
stepMeters ? Number(stepMeters) : undefined,
|
||||
Boolean(compact),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('los')
|
||||
@ApiOperation({ summary: 'Alias for line-of-sight GET' })
|
||||
async getLosAlias(@Query() query: LineOfSightQueryDto) {
|
||||
return this.getLineOfSight(query);
|
||||
}
|
||||
|
||||
@Post('line-of-sight')
|
||||
@ApiOperation({
|
||||
summary: 'Calculate Tactical Line of Sight via POST JSON body',
|
||||
description: 'Accepts structured observer and target objects or flat coordinate keys.',
|
||||
})
|
||||
async postLineOfSight(@Body() body: LineOfSightBodyDto) {
|
||||
const obsLat = body.observer?.lat ?? body.observerLat;
|
||||
const obsLng = body.observer?.lng ?? body.observerLng;
|
||||
const obsHeight = body.observer?.height ?? body.observerHeight ?? 2;
|
||||
|
||||
const tgtLat = body.target?.lat ?? body.targetLat;
|
||||
const tgtLng = body.target?.lng ?? body.targetLng;
|
||||
const tgtHeight = body.target?.height ?? body.targetHeight ?? 2;
|
||||
|
||||
const samples = body.samples;
|
||||
const stepMeters = body.stepMeters;
|
||||
const compact = body.compact ?? false;
|
||||
|
||||
if (obsLat == null || obsLng == null || tgtLat == null || tgtLng == null) {
|
||||
throw new HttpException(
|
||||
'Missing required parameters: observer coordinates (lat, lng) and target coordinates (lat, lng)',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
return this.tacticalService.computeLineOfSight(
|
||||
Number(obsLat),
|
||||
Number(obsLng),
|
||||
Number(tgtLat),
|
||||
Number(tgtLng),
|
||||
Number(obsHeight),
|
||||
Number(tgtHeight),
|
||||
samples ? Number(samples) : undefined,
|
||||
stepMeters ? Number(stepMeters) : undefined,
|
||||
Boolean(compact),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('los')
|
||||
@ApiOperation({ summary: 'Alias for line-of-sight POST' })
|
||||
async postLosAlias(@Body() body: LineOfSightBodyDto) {
|
||||
return this.postLineOfSight(body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TacticalController } from './tactical.controller';
|
||||
import { TacticalService } from './tactical.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [TacticalController],
|
||||
providers: [TacticalService],
|
||||
exports: [TacticalService],
|
||||
})
|
||||
export class TacticalModule {}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import axios from 'axios';
|
||||
|
||||
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 ObstacleInfo {
|
||||
distanceMeters: number;
|
||||
distanceKm: number;
|
||||
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[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TacticalService {
|
||||
private readonly logger = new Logger(TacticalService.name);
|
||||
|
||||
// In-memory cache for elevation sampling
|
||||
private readonly elevationCache = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Calculates Haversine geodesic distance in meters
|
||||
*/
|
||||
calculateDistance(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);
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1 * (Math.PI / 180)) * Math.cos(lat2 * (Math.PI / 180)) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates Forward Azimuth (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);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
if (lng < 35.6 && lat < 32.2 && lat > 31.0) {
|
||||
const distFromRift = Math.abs(lng - 35.5);
|
||||
return Math.round(-400 + distFromRift * 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);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
// Southern Highlands
|
||||
if (lat < 31.5 && lat > 30.0 && lng < 35.7) {
|
||||
return Math.round(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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { WeatherService } from './weather.service';
|
||||
|
||||
@ApiTags('weather')
|
||||
@Controller('weather')
|
||||
export class WeatherController {
|
||||
constructor(private readonly weatherService: WeatherService) {}
|
||||
|
||||
@Get('grid')
|
||||
@ApiOperation({ summary: 'Get weather temperature grid (Jordan, Syria, Egypt)' })
|
||||
async getWeatherGrid() {
|
||||
return this.weatherService.getWeatherGrid();
|
||||
}
|
||||
|
||||
@Get('cities')
|
||||
@ApiOperation({ summary: 'Get current weather and 7-day forecast for cities' })
|
||||
@ApiQuery({ name: 'region', required: false, description: 'Region/Country filter (Jordan, Syria, Egypt)' })
|
||||
async getCitiesWeather(@Query('region') region?: string) {
|
||||
return this.weatherService.getAllCitiesWeather(region);
|
||||
}
|
||||
|
||||
@Get('alerts')
|
||||
@ApiOperation({ summary: 'Get weather alerts and warnings' })
|
||||
@ApiQuery({ name: 'region', required: false, description: 'Region/Country filter (Jordan, Syria, Egypt)' })
|
||||
async getWeatherAlerts(@Query('region') region?: string) {
|
||||
return this.weatherService.getAlerts(region);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WeatherController } from './weather.controller';
|
||||
import { WeatherService } from './weather.service';
|
||||
import { RedisModule } from '../common/redis.module';
|
||||
|
||||
@Module({
|
||||
imports: [RedisModule],
|
||||
controllers: [WeatherController],
|
||||
providers: [WeatherService],
|
||||
exports: [WeatherService],
|
||||
})
|
||||
export class WeatherModule {}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { RedisService } from '../common/redis.service';
|
||||
import axios from 'axios';
|
||||
|
||||
// Cities data for Jordan, Syria, and Egypt / بيانات مدن الأردن وسوريا ومصر
|
||||
export interface CityDef {
|
||||
name: string;
|
||||
name_ar: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
country: 'Jordan' | 'Syria' | 'Egypt';
|
||||
}
|
||||
|
||||
export const REGIONAL_CITIES: CityDef[] = [
|
||||
// Jordan / الأردن
|
||||
{ name: 'Amman', name_ar: 'عمان', lat: 31.95, lng: 35.93, country: 'Jordan' },
|
||||
{ name: 'Irbid', name_ar: 'إربد', lat: 32.55, lng: 35.85, country: 'Jordan' },
|
||||
{ name: 'Zarqa', name_ar: 'الزرقاء', lat: 32.06, lng: 36.10, country: 'Jordan' },
|
||||
{ name: 'Aqaba', name_ar: 'العقبة', lat: 29.53, lng: 35.00, country: 'Jordan' },
|
||||
{ name: 'Karak', name_ar: 'الكرك', lat: 31.18, lng: 35.70, country: 'Jordan' },
|
||||
{ name: 'Madaba', name_ar: 'مادبا', lat: 31.72, lng: 35.79, country: 'Jordan' },
|
||||
{ name: 'Jerash', name_ar: 'جرش', lat: 32.28, lng: 35.90, country: 'Jordan' },
|
||||
{ name: 'Ajloun', name_ar: 'عجلون', lat: 32.33, lng: 35.75, country: 'Jordan' },
|
||||
{ name: 'Salt', name_ar: 'السلط', lat: 32.04, lng: 35.73, country: 'Jordan' },
|
||||
{ name: "Ma'an", name_ar: 'معان', lat: 30.19, lng: 35.73, country: 'Jordan' },
|
||||
{ name: 'Tafilah', name_ar: 'الطفيلة', lat: 30.84, lng: 35.60, country: 'Jordan' },
|
||||
{ name: 'Mafraq', name_ar: 'المفرق', lat: 32.34, lng: 36.21, country: 'Jordan' },
|
||||
|
||||
// Syria / سوريا
|
||||
{ name: 'Damascus', name_ar: 'دمشق', lat: 33.5138, lng: 36.2765, country: 'Syria' },
|
||||
{ name: 'Aleppo', name_ar: 'حلب', lat: 36.2021, lng: 37.1343, country: 'Syria' },
|
||||
{ name: 'Homs', name_ar: 'حمص', lat: 34.7324, lng: 36.7137, country: 'Syria' },
|
||||
{ name: 'Latakia', name_ar: 'اللاذقية', lat: 35.5317, lng: 35.7917, country: 'Syria' },
|
||||
{ name: 'Hama', name_ar: 'حماة', lat: 35.1318, lng: 36.7578, country: 'Syria' },
|
||||
{ name: 'Tartus', name_ar: 'طرطوس', lat: 34.8890, lng: 35.8866, country: 'Syria' },
|
||||
{ name: 'Deir ez-Zor', name_ar: 'دير الزور', lat: 35.3370, lng: 40.1444, country: 'Syria' },
|
||||
{ name: 'Raqqa', name_ar: 'الرقة', lat: 35.9594, lng: 39.0089, country: 'Syria' },
|
||||
{ name: 'Daraa', name_ar: 'درعا', lat: 32.6184, lng: 36.1023, country: 'Syria' },
|
||||
{ name: 'As-Suwayda', name_ar: 'السويداء', lat: 32.7090, lng: 36.5695, country: 'Syria' },
|
||||
{ name: 'Idlib', name_ar: 'إدلب', lat: 35.9306, lng: 36.6339, country: 'Syria' },
|
||||
{ name: 'Qamishli', name_ar: 'القامشلي', lat: 37.0522, lng: 41.2228, country: 'Syria' },
|
||||
|
||||
// Egypt / مصر
|
||||
{ name: 'Cairo', name_ar: 'القاهرة', lat: 30.0444, lng: 31.2357, country: 'Egypt' },
|
||||
{ name: 'Alexandria', name_ar: 'الإسكندرية', lat: 31.2001, lng: 29.9187, country: 'Egypt' },
|
||||
{ name: 'Giza', name_ar: 'الجيزة', lat: 30.0131, lng: 31.2089, country: 'Egypt' },
|
||||
{ name: 'Port Said', name_ar: 'بورسعيد', lat: 31.2653, lng: 32.3019, country: 'Egypt' },
|
||||
{ name: 'Suez', name_ar: 'السويس', lat: 29.9668, lng: 32.5498, country: 'Egypt' },
|
||||
{ name: 'Luxor', name_ar: 'الأقصر', lat: 25.6872, lng: 32.6396, country: 'Egypt' },
|
||||
{ name: 'Aswan', name_ar: 'أسوان', lat: 24.0889, lng: 32.8998, country: 'Egypt' },
|
||||
{ name: 'Sharm El Sheikh', name_ar: 'شرم الشيخ', lat: 27.9158, lng: 34.3299, country: 'Egypt' },
|
||||
{ name: 'Hurghada', name_ar: 'الغردقة', lat: 27.2579, lng: 33.8116, country: 'Egypt' },
|
||||
{ name: 'Mansoura', name_ar: 'المنصورة', lat: 31.0409, lng: 31.3785, country: 'Egypt' },
|
||||
{ name: 'Tanta', name_ar: 'طنطا', lat: 30.7865, lng: 31.0004, country: 'Egypt' },
|
||||
{ name: 'Ismailia', name_ar: 'الإسماعيلية', lat: 30.5965, lng: 32.2715, country: 'Egypt' },
|
||||
{ name: 'Marsa Matruh', name_ar: 'مرسى مطروح', lat: 31.3543, lng: 27.2373, country: 'Egypt' },
|
||||
{ name: 'Asyut', name_ar: 'أسيوط', lat: 27.1783, lng: 31.1859, country: 'Egypt' }
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class WeatherService {
|
||||
private readonly logger = new Logger(WeatherService.name);
|
||||
|
||||
constructor(private readonly redisService: RedisService) {}
|
||||
|
||||
/**
|
||||
* Get weather grid / الحصول على شبكة الطقس
|
||||
*/
|
||||
async getWeatherGrid() {
|
||||
const cacheKey = 'weather:grid:v3';
|
||||
const cached = await this.redisService.get<any>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const lats: number[] = [];
|
||||
const lngs: number[] = [];
|
||||
|
||||
for (let lat = 24.0; lat <= 37.0; lat += 0.8) {
|
||||
for (let lng = 26.0; lng <= 42.0; lng += 0.8) {
|
||||
lats.push(Number(lat.toFixed(1)));
|
||||
lngs.push(Number(lng.toFixed(1)));
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.get('https://api.open-meteo.com/v1/forecast', {
|
||||
params: {
|
||||
latitude: lats.join(','),
|
||||
longitude: lngs.join(','),
|
||||
current: 'temperature_2m,weather_code,wind_speed_10m,wind_direction_10m',
|
||||
timezone: 'auto'
|
||||
}
|
||||
});
|
||||
|
||||
const results = Array.isArray(response.data) ? response.data : [response.data];
|
||||
const features = results.map((result: any, index: number) => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [lngs[index], lats[index]]
|
||||
},
|
||||
properties: {
|
||||
temperature: result.current?.temperature_2m,
|
||||
weatherCode: result.current?.weather_code,
|
||||
windSpeed: result.current?.wind_speed_10m,
|
||||
windDirection: result.current?.wind_direction_10m
|
||||
}
|
||||
}));
|
||||
|
||||
const geoJson = { type: 'FeatureCollection', features };
|
||||
await this.redisService.set(cacheKey, JSON.stringify(geoJson), 900);
|
||||
return geoJson;
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching weather grid', error);
|
||||
return { type: 'FeatureCollection', features: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weather for all regional cities / الحصول على الطقس لكافة المدن الإقليمية في الأردن وسوريا ومصر
|
||||
*/
|
||||
async getAllCitiesWeather(region?: string) {
|
||||
// Return all regional cities by default so the entire map is populated across countries
|
||||
let targetCities = REGIONAL_CITIES;
|
||||
|
||||
const normRegion = region ? region.trim().toLowerCase() : '';
|
||||
if (normRegion === 'jordan' || normRegion === 'الأردن') {
|
||||
// If filtered specifically
|
||||
// targetCities = REGIONAL_CITIES.filter(c => c.country === 'Jordan');
|
||||
}
|
||||
|
||||
const cacheKey = `weather:cities:v3:all_combined`;
|
||||
const cached = await this.redisService.get<any>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const lats = targetCities.map(c => c.lat).join(',');
|
||||
const lngs = targetCities.map(c => c.lng).join(',');
|
||||
|
||||
const response = await axios.get('https://api.open-meteo.com/v1/forecast', {
|
||||
params: {
|
||||
latitude: lats,
|
||||
longitude: lngs,
|
||||
current: 'temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m,wind_direction_10m,cloud_cover,precipitation,is_day',
|
||||
daily: 'weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_sum,precipitation_probability_max,wind_speed_10m_max,wind_direction_10m_dominant',
|
||||
forecast_days: 7,
|
||||
timezone: 'auto'
|
||||
}
|
||||
});
|
||||
|
||||
const results = Array.isArray(response.data) ? response.data : [response.data];
|
||||
|
||||
const citiesWeather = targetCities.map((city, index) => {
|
||||
const weatherData = results[index] || {};
|
||||
|
||||
const daily: Array<{
|
||||
date: any;
|
||||
weatherCode: any;
|
||||
tempMax: any;
|
||||
tempMin: any;
|
||||
precipSum: any;
|
||||
precipProb: any;
|
||||
windMax: any;
|
||||
windDir: any;
|
||||
}> = [];
|
||||
|
||||
if (weatherData.daily && weatherData.daily.time) {
|
||||
for (let i = 0; i < weatherData.daily.time.length; i++) {
|
||||
daily.push({
|
||||
date: weatherData.daily.time[i],
|
||||
weatherCode: weatherData.daily.weather_code?.[i] ?? 0,
|
||||
tempMax: weatherData.daily.temperature_2m_max?.[i] ?? 25,
|
||||
tempMin: weatherData.daily.temperature_2m_min?.[i] ?? 15,
|
||||
precipSum: weatherData.daily.precipitation_sum?.[i] ?? 0,
|
||||
precipProb: weatherData.daily.precipitation_probability_max?.[i] ?? 0,
|
||||
windMax: weatherData.daily.wind_speed_10m_max?.[i] ?? 10,
|
||||
windDir: weatherData.daily.wind_direction_10m_dominant?.[i] ?? 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: city.name,
|
||||
name_ar: city.name_ar,
|
||||
lat: city.lat,
|
||||
lng: city.lng,
|
||||
country: city.country,
|
||||
current: {
|
||||
temp: weatherData.current?.temperature_2m ?? 22,
|
||||
feelsLike: weatherData.current?.apparent_temperature ?? 22,
|
||||
humidity: weatherData.current?.relative_humidity_2m ?? 50,
|
||||
weatherCode: weatherData.current?.weather_code ?? 0,
|
||||
windSpeed: weatherData.current?.wind_speed_10m ?? 12,
|
||||
windDirection: weatherData.current?.wind_direction_10m ?? 0,
|
||||
cloudCover: weatherData.current?.cloud_cover ?? 0,
|
||||
precipitation: weatherData.current?.precipitation ?? 0,
|
||||
isDay: weatherData.current?.is_day ?? 1
|
||||
},
|
||||
daily
|
||||
};
|
||||
});
|
||||
|
||||
await this.redisService.set(cacheKey, JSON.stringify(citiesWeather), 900);
|
||||
return citiesWeather;
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching regional cities weather', error);
|
||||
// Fallback response with base data
|
||||
return targetCities.map(c => ({
|
||||
name: c.name,
|
||||
name_ar: c.name_ar,
|
||||
lat: c.lat,
|
||||
lng: c.lng,
|
||||
country: c.country,
|
||||
current: {
|
||||
temp: 24,
|
||||
feelsLike: 24,
|
||||
humidity: 45,
|
||||
weatherCode: 0,
|
||||
windSpeed: 15,
|
||||
windDirection: 270,
|
||||
cloudCover: 10,
|
||||
precipitation: 0,
|
||||
isDay: 1
|
||||
},
|
||||
daily: []
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weather alerts / الحصول على تحذيرات الطقس
|
||||
*/
|
||||
async getAlerts(region?: string) {
|
||||
const cacheKey = `weather:alerts:v3:all`;
|
||||
const cached = await this.redisService.get<any[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const citiesWeather = await this.getAllCitiesWeather(region);
|
||||
const alerts: Array<{
|
||||
city: string;
|
||||
city_ar: string;
|
||||
type: string;
|
||||
message_ar: string;
|
||||
severity: string;
|
||||
temperature: number;
|
||||
windSpeed: number;
|
||||
}> = [];
|
||||
|
||||
for (const city of citiesWeather) {
|
||||
const { temp, windSpeed, precipitation } = city.current;
|
||||
|
||||
if (temp > 40) {
|
||||
alerts.push({ city: city.name, city_ar: city.name_ar, type: 'Extreme Heat', message_ar: 'حرارة شديدة', severity: 'danger', temperature: temp, windSpeed });
|
||||
} else if (temp > 36) {
|
||||
alerts.push({ city: city.name, city_ar: city.name_ar, type: 'High Heat', message_ar: 'حرارة مرتفعة', severity: 'warning', temperature: temp, windSpeed });
|
||||
}
|
||||
|
||||
if (temp < 4) {
|
||||
alerts.push({ city: city.name, city_ar: city.name_ar, type: 'Cold / Frost', message_ar: 'طقس بارد / صقيع', severity: 'warning', temperature: temp, windSpeed });
|
||||
}
|
||||
|
||||
if (windSpeed > 45) {
|
||||
alerts.push({ city: city.name, city_ar: city.name_ar, type: 'Strong Winds', message_ar: 'رياح نشطة قوية', severity: 'warning', temperature: temp, windSpeed });
|
||||
}
|
||||
|
||||
if (precipitation > 5) {
|
||||
alerts.push({ city: city.name, city_ar: city.name_ar, type: 'Rain', message_ar: 'هطول أمطار', severity: 'info', temperature: temp, windSpeed });
|
||||
}
|
||||
}
|
||||
|
||||
await this.redisService.set(cacheKey, JSON.stringify(alerts), 900);
|
||||
return alerts;
|
||||
} catch (error) {
|
||||
this.logger.error('Error generating weather alerts', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user