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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,17 +148,17 @@
|
||||
"type": "line",
|
||||
"source": "local-osm-polygons",
|
||||
"source-layer": "planet_osm_polygon",
|
||||
"minzoom": 5,
|
||||
"minzoom": 6,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", "boundary", "administrative"],
|
||||
["in", "admin_level", "4", 4, "5", 5]
|
||||
],
|
||||
"paint": {
|
||||
"line-color": "#4f46e5",
|
||||
"line-width": 2.2,
|
||||
"line-dasharray": [4, 2],
|
||||
"line-opacity": 0.9
|
||||
"line-color": "#6366f1",
|
||||
"line-width": 1.4,
|
||||
"line-dasharray": [4, 3],
|
||||
"line-opacity": 0.7
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -166,16 +166,17 @@
|
||||
"type": "line",
|
||||
"source": "local-osm-lines",
|
||||
"source-layer": "planet_osm_line",
|
||||
"minzoom": 5,
|
||||
"minzoom": 6,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", "boundary", "administrative"],
|
||||
["in", "admin_level", "4", 4, "5", 5]
|
||||
],
|
||||
"paint": {
|
||||
"line-color": "#4f46e5",
|
||||
"line-width": 2.2,
|
||||
"line-dasharray": [4, 2]
|
||||
"line-color": "#6366f1",
|
||||
"line-width": 1.4,
|
||||
"line-dasharray": [4, 3],
|
||||
"line-opacity": 0.7
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+305
-111
@@ -1,78 +1,127 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import MapComponent from './components/MapComponent';
|
||||
import { Navigation, Compass, Activity, BarChart3, MapPin } from 'lucide-react';
|
||||
import { Navigation, Compass, Activity, BarChart3, MapPin, Eye, Shield } from 'lucide-react';
|
||||
import { decodePolyline } from './utils/polyline';
|
||||
import WeatherPanel from './components/WeatherPanel';
|
||||
import { LineOfSightTool } from './components/LineOfSightTool';
|
||||
|
||||
const DEFAULT_API_KEY = (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||
|
||||
function App() {
|
||||
const [map, setMap] = useState<any>(null);
|
||||
const [debug, setDebug] = useState({ zoom: 12, center: [35.91, 31.95], bounds: '' });
|
||||
const [routeData, setRouteData] = useState<any>(null);
|
||||
const [routeSummary, setRouteSummary] = useState<any>(null);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [routeLoading, setRouteLoading] = useState(false);
|
||||
const [routeError, setRouteError] = useState('');
|
||||
|
||||
// Layer Toggles
|
||||
const [show3D, setShow3D] = useState(false);
|
||||
const [showPOIs, setShowPOIs] = useState(true);
|
||||
const [showTerrain, setShowTerrain] = useState(true);
|
||||
const [showContours, setShowContours] = useState(true);
|
||||
const [showAdminBoundaries, setShowAdminBoundaries] = useState(true);
|
||||
const [showWeather, setShowWeather] = useState(false);
|
||||
const [showLOS, setShowLOS] = useState(false);
|
||||
const [losPointA, setLosPointA] = useState<[number, number] | null>(null);
|
||||
const [losPointB, setLosPointB] = useState<[number, number] | null>(null);
|
||||
const [weatherCity, setWeatherCity] = useState<any>(null);
|
||||
const [weatherAlerts, setWeatherAlerts] = useState<any[]>([]);
|
||||
|
||||
// Geocoding State
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [newPlace, setNewPlace] = useState<{lat: number, lng: number} | null>(null);
|
||||
const [placeForm, setPlaceForm] = useState({ name: '', name_ar: '', category: '' });
|
||||
const [currentRegion, setCurrentRegion] = useState('Jordan');
|
||||
|
||||
// Route Form State
|
||||
const [originText, setOriginText] = useState('31.9539, 35.9106');
|
||||
const [destText, setDestText] = useState('32.0608, 36.1032');
|
||||
|
||||
const regions = [
|
||||
{ name: 'Syria', name_ar: 'سوريا', center: [36.29, 33.51], zoom: 12, flag: '🇸🇾' },
|
||||
{ name: 'Jordan', name_ar: 'الأردن', center: [35.91, 31.95], zoom: 12, flag: '🇯🇴' },
|
||||
{ name: 'Egypt', name_ar: 'مصر', center: [31.23, 30.04], zoom: 11, flag: '🇪🇬' },
|
||||
{
|
||||
name: 'Syria',
|
||||
name_ar: 'سوريا',
|
||||
center: [36.29, 33.51],
|
||||
zoom: 11,
|
||||
flag: '🇸🇾',
|
||||
defaultOrigin: '33.5138, 36.2765', // Damascus
|
||||
defaultDest: '36.2021, 37.1343' // Aleppo
|
||||
},
|
||||
{
|
||||
name: 'Jordan',
|
||||
name_ar: 'الأردن',
|
||||
center: [35.91, 31.95],
|
||||
zoom: 11,
|
||||
flag: '🇯🇴',
|
||||
defaultOrigin: '31.9539, 35.9106', // Amman
|
||||
defaultDest: '32.0608, 36.1032' // Zarqa
|
||||
},
|
||||
{
|
||||
name: 'Egypt',
|
||||
name_ar: 'مصر',
|
||||
center: [31.23, 30.04],
|
||||
zoom: 10,
|
||||
flag: '🇪🇬',
|
||||
defaultOrigin: '30.0444, 31.2357', // Cairo
|
||||
defaultDest: '31.2001, 29.9187' // Alexandria
|
||||
},
|
||||
];
|
||||
|
||||
const handleRegionSwitch = (region: any) => {
|
||||
setCurrentRegion(region.name);
|
||||
setOriginText(region.defaultOrigin);
|
||||
setDestText(region.defaultDest);
|
||||
setRouteData(null);
|
||||
setWeatherCity(null);
|
||||
|
||||
// Clear existing route line if drawn
|
||||
if (map && map.getSource('route')) {
|
||||
map.getSource('route').setData({
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'LineString', coordinates: [] }
|
||||
});
|
||||
}
|
||||
|
||||
if (map) {
|
||||
map.flyTo({
|
||||
center: region.center,
|
||||
zoom: region.zoom,
|
||||
essential: true,
|
||||
duration: 3000
|
||||
duration: 2500
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMapClick = (lat: number, lng: number) => {
|
||||
setNewPlace({ lat, lng });
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (searchQuery.length < 3) return;
|
||||
if (searchQuery.trim().length < 2) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
||||
|
||||
let queryUrl = `${apiUrl}/geocoding/search?q=${searchQuery}&radius=20000`;
|
||||
|
||||
let queryUrl = `${apiUrl}/geocoding/search?q=${encodeURIComponent(searchQuery)}&radius=50000`;
|
||||
|
||||
if (map) {
|
||||
const center = map.getCenter();
|
||||
queryUrl += `&lat=${center.lat}&lng=${center.lng}`;
|
||||
}
|
||||
|
||||
const response = await fetch(queryUrl, {
|
||||
headers: { 'x-api-key': 'intaleq_secret_2026' }
|
||||
headers: { 'x-api-key': DEFAULT_API_KEY }
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
setSearchResults(data.results || []);
|
||||
setShowResults(true);
|
||||
|
||||
if (data.results && data.results.length > 0 && map) {
|
||||
const place = data.results[0];
|
||||
// API returns flat latitude/longitude fields (not a nested location object)
|
||||
const placeLng = parseFloat(place.longitude);
|
||||
const placeLat = parseFloat(place.latitude);
|
||||
if (!isNaN(placeLat) && !isNaN(placeLng)) {
|
||||
map.flyTo({ center: [placeLng, placeLat], zoom: 15 });
|
||||
map.flyTo({ center: [placeLng, placeLat], zoom: 15, duration: 2000 });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -82,30 +131,6 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const submitNewPlace = async () => {
|
||||
if (!newPlace) return;
|
||||
try {
|
||||
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
||||
await fetch(`${apiUrl}/geocoding/places`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': 'intaleq_secret_2026' },
|
||||
body: JSON.stringify({
|
||||
latitude: newPlace.lat,
|
||||
longitude: newPlace.lng,
|
||||
name: placeForm.name,
|
||||
name_ar: placeForm.name_ar,
|
||||
category: placeForm.category,
|
||||
})
|
||||
});
|
||||
setNewPlace(null);
|
||||
setPlaceForm({ name: '', name_ar: '', category: '' });
|
||||
alert('Place added successfully! / تم إضافة المكان بنجاح');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Failed to add place / فشل إضافة المكان');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
||||
@@ -114,7 +139,7 @@ function App() {
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
} catch (error) {
|
||||
console.warn("Stats fetch failed, using fallback UI");
|
||||
console.warn("Stats fetch failed");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -124,6 +149,25 @@ function App() {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showWeather) return;
|
||||
const fetchAlerts = async () => {
|
||||
try {
|
||||
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
||||
const regionParam = currentRegion ? `?region=${encodeURIComponent(currentRegion)}` : '';
|
||||
const res = await fetch(`${apiUrl}/weather/alerts${regionParam}`, {
|
||||
headers: { 'x-api-key': DEFAULT_API_KEY }
|
||||
});
|
||||
const data = await res.json();
|
||||
setWeatherAlerts(Array.isArray(data) ? data : []);
|
||||
} catch (e) {
|
||||
console.warn('Weather alerts fetch failed', e);
|
||||
setWeatherAlerts([]);
|
||||
}
|
||||
};
|
||||
fetchAlerts();
|
||||
}, [showWeather, currentRegion]);
|
||||
|
||||
const handleMapLoad = (initializedMap: any) => {
|
||||
setMap(initializedMap);
|
||||
initializedMap.on('move', () => {
|
||||
@@ -136,49 +180,107 @@ function App() {
|
||||
});
|
||||
};
|
||||
|
||||
const parseCoordinates = (input: string): [number, number] | null => {
|
||||
const parts = input.split(',').map(p => parseFloat(p.trim()));
|
||||
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||
return [parts[0], parts[1]]; // [lat, lng]
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const calculateRoute = async () => {
|
||||
setLoading(true);
|
||||
setRouteError('');
|
||||
const originCoords = parseCoordinates(originText);
|
||||
const destCoords = parseCoordinates(destText);
|
||||
|
||||
if (!originCoords || !destCoords) {
|
||||
setRouteError('Please enter valid coordinates format: lat, lng');
|
||||
return;
|
||||
}
|
||||
|
||||
setRouteLoading(true);
|
||||
try {
|
||||
// Points for Amman and Zarqa as defaults
|
||||
const from = [31.9539, 35.9106];
|
||||
const to = [32.0608, 36.1032];
|
||||
|
||||
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
||||
const apiKey = (import.meta as any).env.VITE_API_KEY || 'intaleq_secret_2026';
|
||||
|
||||
const response = await fetch(`${apiUrl}/maps/route?fromLat=${from[0]}&fromLng=${from[1]}&toLat=${to[0]}&toLng=${to[1]}`, {
|
||||
headers: { 'x-api-key': apiKey }
|
||||
const url = `${apiUrl}/maps/route?fromLat=${originCoords[0]}&fromLng=${originCoords[1]}&toLat=${destCoords[0]}&toLng=${destCoords[1]}&profile=car&steps=true`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { 'x-api-key': DEFAULT_API_KEY }
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.points && map) {
|
||||
|
||||
if (data && data.points) {
|
||||
const coords = typeof data.points === 'string' ? decodePolyline(data.points) : data.points;
|
||||
setRouteData({ ...data, points: coords });
|
||||
|
||||
if (map.getSource('route')) {
|
||||
|
||||
if (map && map.getSource('route')) {
|
||||
map.getSource('route').setData({
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'LineString', coordinates: coords }
|
||||
});
|
||||
|
||||
// Fit map to route bounds
|
||||
if (coords.length > 0) {
|
||||
let minLng = coords[0][0], maxLng = coords[0][0];
|
||||
let minLat = coords[0][1], maxLat = coords[0][1];
|
||||
for (const pt of coords) {
|
||||
minLng = Math.min(minLng, pt[0]);
|
||||
maxLng = Math.max(maxLng, pt[0]);
|
||||
minLat = Math.min(minLat, pt[1]);
|
||||
maxLat = Math.max(maxLat, pt[1]);
|
||||
}
|
||||
map.fitBounds([[minLng, minLat], [maxLng, maxLat]], {
|
||||
padding: { top: 70, bottom: 70, left: 360, right: 70 },
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setRouteError(data.message || 'No route found between selected points.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error calculating route:", error);
|
||||
setRouteError('Failed to calculate route. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRouteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMapClick = (lat: number, lng: number) => {
|
||||
if (showLOS) {
|
||||
if (!losPointA) {
|
||||
setLosPointA([lat, lng]);
|
||||
} else if (!losPointB) {
|
||||
setLosPointB([lat, lng]);
|
||||
} else {
|
||||
// Reset and set point A
|
||||
setLosPointA([lat, lng]);
|
||||
setLosPointB(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearLOS = () => {
|
||||
setLosPointA(null);
|
||||
setLosPointB(null);
|
||||
};
|
||||
|
||||
const handleCloseLOS = () => {
|
||||
setShowLOS(false);
|
||||
setLosPointA(null);
|
||||
setLosPointB(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="sidebar glass-morphism">
|
||||
<h1>{currentRegion} Maps SaaS</h1>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Self-Hosted Mobility Prototype</p>
|
||||
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Intaleq Mobility & Maps Cloud</p>
|
||||
|
||||
{/* Region Selector */}
|
||||
<div className="region-selector" style={{ display: 'flex', gap: '8px', margin: '15px 0' }}>
|
||||
{regions.map(r => (
|
||||
<button
|
||||
<button
|
||||
key={r.name}
|
||||
onClick={() => handleRegionSwitch(r)}
|
||||
className={`region-btn ${currentRegion === r.name ? 'active' : ''}`}
|
||||
@@ -186,8 +288,8 @@ function App() {
|
||||
flex: 1,
|
||||
padding: '8px 4px',
|
||||
borderRadius: '8px',
|
||||
border: currentRegion === r.name ? '1px solid #3b82f6' : '1px solid var(--glass-border)',
|
||||
background: currentRegion === r.name ? 'rgba(59, 130, 246, 0.1)' : 'transparent',
|
||||
border: currentRegion === r.name ? '1px solid #38bdf8' : '1px solid var(--glass-border)',
|
||||
background: currentRegion === r.name ? 'rgba(56, 189, 248, 0.15)' : 'transparent',
|
||||
color: 'var(--text-main)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.75rem',
|
||||
@@ -204,62 +306,113 @@ function App() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="input-group">
|
||||
<label><MapPin size={14} style={{ marginRight: 5 }} /> Search / البحث</label>
|
||||
<label><MapPin size={14} style={{ marginRight: 5 }} /> Search Places / البحث في الأماكن</label>
|
||||
<div style={{ display: 'flex', gap: '5px' }}>
|
||||
<input type="text" placeholder="Coffee shop..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} />
|
||||
<button className="btn" style={{ width: 'auto', marginTop: 0, padding: '10px 15px' }} onClick={handleSearch}>Go</button>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search city, street, cafe..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
<button className="btn" style={{ width: 'auto', marginTop: 0, padding: '10px 15px' }} onClick={handleSearch} disabled={loading}>
|
||||
{loading ? '...' : 'Go'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
{showResults && searchResults.length > 0 && (
|
||||
<div className="search-results-list" style={{ marginTop: '10px', maxHeight: '200px', overflowY: 'auto', background: 'rgba(255,255,255,0.05)', borderRadius: '8px' }}>
|
||||
<div className="search-results-list" style={{ marginTop: '10px', maxHeight: '200px', overflowY: 'auto', background: 'rgba(15, 23, 42, 0.8)', borderRadius: '8px', border: '1px solid var(--glass-border)' }}>
|
||||
{searchResults.map((res) => (
|
||||
<div
|
||||
key={res.id}
|
||||
onClick={() => { const rLng = parseFloat(res.longitude); const rLat = parseFloat(res.latitude); if (!isNaN(rLat) && !isNaN(rLng)) map?.flyTo({ center: [rLng, rLat], zoom: 16 }); }}
|
||||
style={{ padding: '8px', borderBottom: '1px solid var(--glass-border)', cursor: 'pointer', fontSize: '0.85rem' }}
|
||||
<div
|
||||
key={res.id || Math.random()}
|
||||
onClick={() => {
|
||||
const rLng = parseFloat(res.longitude);
|
||||
const rLat = parseFloat(res.latitude);
|
||||
if (!isNaN(rLat) && !isNaN(rLng)) {
|
||||
map?.flyTo({ center: [rLng, rLat], zoom: 16, duration: 1500 });
|
||||
}
|
||||
}}
|
||||
style={{ padding: '10px', borderBottom: '1px solid var(--glass-border)', cursor: 'pointer', fontSize: '0.85rem' }}
|
||||
className="search-result-item"
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{res.name_ar || res.name}</div>
|
||||
{res.address && <div style={{ fontSize: '0.75rem', opacity: 0.7 }}>{res.address}</div>}
|
||||
<div style={{ fontSize: '0.7rem', color: '#3b82f6', marginTop: '2px' }}>
|
||||
<div style={{ fontWeight: 600, color: '#f8fafc' }}>{res.name_ar || res.name}</div>
|
||||
{res.address && <div style={{ fontSize: '0.75rem', opacity: 0.7, color: '#cbd5e1' }}>{res.address}</div>}
|
||||
<div style={{ fontSize: '0.7rem', color: '#38bdf8', marginTop: '2px' }}>
|
||||
{res.distance ? (Number(res.distance) / 1000).toFixed(1) + ' km away' : ''} | {(res.source || '').replace('_', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="btn-link"
|
||||
style={{ width: '100%', padding: '5px', fontSize: '0.7rem', background: 'transparent', border: 'none', color: 'var(--text-muted)' }}
|
||||
<button
|
||||
className="btn-link"
|
||||
style={{ width: '100%', padding: '6px', fontSize: '0.75rem', background: 'transparent', border: 'none', color: '#94a3b8', cursor: 'pointer' }}
|
||||
onClick={() => setShowResults(false)}
|
||||
>
|
||||
Clear Results / مسح
|
||||
Clear Results / إغلاق
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showResults && searchResults.length === 0 && (
|
||||
<div style={{ marginTop: '10px', fontSize: '0.8rem', color: '#ef4444' }}>No results found near you.</div>
|
||||
<div style={{ marginTop: '10px', fontSize: '0.8rem', color: '#f87171' }}>No results found / لم يتم العثور على نتائج.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Route Calculation */}
|
||||
<div className="input-group">
|
||||
<label><Navigation size={14} style={{ marginRight: 5 }} /> Origin / نقطة الانطلاق</label>
|
||||
<input type="text" placeholder="Amman, Jordan" defaultValue="31.9539, 35.9106" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="lat, lng"
|
||||
value={originText}
|
||||
onChange={e => setOriginText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label><Compass size={14} style={{ marginRight: 5 }} /> Destination / الوجهة</label>
|
||||
<input type="text" placeholder="Zarqa, Jordan" defaultValue="32.0608, 36.1032" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="lat, lng"
|
||||
value={destText}
|
||||
onChange={e => setDestText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="btn" onClick={calculateRoute} disabled={loading}>
|
||||
{loading ? 'Calculating...' : 'Calculate Route / حساب المسار'}
|
||||
<button className="btn" onClick={calculateRoute} disabled={routeLoading} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '8px' }}>
|
||||
<Navigation size={16} />
|
||||
{routeLoading ? 'Calculating...' : 'Calculate Route / حساب المسار'}
|
||||
</button>
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--glass-border)', margin: '10px 0' }} />
|
||||
{routeError && (
|
||||
<div style={{ marginTop: '10px', padding: '10px', background: 'rgba(239, 68, 68, 0.15)', border: '1px solid #ef4444', borderRadius: '8px', color: '#f87171', fontSize: '0.8rem' }}>
|
||||
{routeError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route Summary Card */}
|
||||
{routeData && (
|
||||
<div className="route-summary glass-morphism" style={{ marginTop: '15px', padding: '12px', borderRadius: '8px', background: 'rgba(15, 23, 42, 0.6)' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '0.9rem', color: '#38bdf8' }}>Route Overview / تفاصيل المسار</h4>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '4px' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<Gauge size={13} color="#38bdf8" />
|
||||
{(Number(routeData.distance || 0) / 1000).toFixed(1)} km
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<Clock size={13} color="#22c55e" />
|
||||
{Math.round(Number(routeData.time || routeData.duration || 0) / 60000)} min
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--glass-border)', margin: '12px 0' }} />
|
||||
|
||||
{/* Layers & Map Options */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<h3>Layers & Map Options / خيارات الخريطة</h3>
|
||||
|
||||
|
||||
<div className="toggle-group" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input type="checkbox" checked={show3D} onChange={(e) => setShow3D(e.target.checked)} />
|
||||
@@ -295,6 +448,49 @@ function App() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="toggle-group" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input type="checkbox" checked={showWeather} onChange={(e) => setShowWeather(e.target.checked)} />
|
||||
🌤️ Weather Layer / طبقة الطقس
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Tactical Line of Sight (LOS) Toggle */}
|
||||
<div className="toggle-group" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
background: showLOS ? 'rgba(99, 102, 241, 0.15)' : 'transparent',
|
||||
padding: '6px 8px',
|
||||
borderRadius: 8,
|
||||
border: showLOS ? '1px solid rgba(99, 102, 241, 0.4)' : '1px solid transparent'
|
||||
}}>
|
||||
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: showLOS ? 700 : 400 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLOS}
|
||||
onChange={(e) => {
|
||||
setShowLOS(e.target.checked);
|
||||
if (!e.target.checked) {
|
||||
setLosPointA(null);
|
||||
setLosPointB(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
🎯 Tactical LOS / تبادل الرؤية العسكري
|
||||
</label>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
background: 'linear-gradient(135deg, #6366f1, #4f46e5)',
|
||||
color: '#ffffff',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
Military
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--glass-border)', margin: '5px 0' }} />
|
||||
|
||||
<h3>Simulation / المحاكاة</h3>
|
||||
@@ -309,38 +505,30 @@ function App() {
|
||||
</div>
|
||||
|
||||
<div className="map-container">
|
||||
<MapComponent
|
||||
onMapLoad={handleMapLoad}
|
||||
<MapComponent
|
||||
onMapLoad={handleMapLoad}
|
||||
onMapClick={handleMapClick}
|
||||
show3D={show3D}
|
||||
showPOIs={showPOIs}
|
||||
showTerrain={showTerrain}
|
||||
showContours={showContours}
|
||||
showAdminBoundaries={showAdminBoundaries}
|
||||
showWeather={showWeather}
|
||||
currentRegion={currentRegion}
|
||||
onCityClick={(cityData: any) => setWeatherCity(cityData)}
|
||||
losActive={showLOS}
|
||||
losPointA={losPointA}
|
||||
losPointB={losPointB}
|
||||
/>
|
||||
|
||||
{/* Add Place Modal */}
|
||||
{newPlace && (
|
||||
<div className="stats-panel glass-morphism" style={{ top: '50%', left: '50%', transform: 'translate(-50%, -50%)', zIndex: 1000, width: '300px' }}>
|
||||
<h4>Add New Place / إضافة مكان</h4>
|
||||
<div className="input-group">
|
||||
<label>Name / الاسم</label>
|
||||
<input type="text" value={placeForm.name} onChange={e => setPlaceForm({...placeForm, name: e.target.value})} />
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>Arabic Name / الاسم بالعربي</label>
|
||||
<input type="text" value={placeForm.name_ar} onChange={e => setPlaceForm({...placeForm, name_ar: e.target.value})} />
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>Category / التصنيف</label>
|
||||
<input type="text" value={placeForm.category} onChange={e => setPlaceForm({...placeForm, category: e.target.value})} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px', marginTop: '10px' }}>
|
||||
<button className="btn" style={{ flex: 1 }} onClick={submitNewPlace}>Save</button>
|
||||
<button className="btn" style={{ flex: 1, background: '#475569' }} onClick={() => setNewPlace(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Tactical Line of Sight Tool Overlay */}
|
||||
<LineOfSightTool
|
||||
active={showLOS}
|
||||
pointA={losPointA}
|
||||
pointB={losPointB}
|
||||
onClose={handleCloseLOS}
|
||||
onClear={handleClearLOS}
|
||||
/>
|
||||
|
||||
{stats && stats.telemetry && (
|
||||
<div className="stats-panel glass-morphism">
|
||||
@@ -363,7 +551,13 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<WeatherPanel
|
||||
visible={showWeather}
|
||||
selectedCity={weatherCity}
|
||||
alerts={weatherAlerts}
|
||||
/>
|
||||
|
||||
<div className="debug-panel glass-morphism">
|
||||
<div>Zoom: {debug.zoom}</div>
|
||||
<div>Center: {debug.center[0]}, {debug.center[1]}</div>
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Eye, Crosshair, AlertTriangle, CheckCircle2, XCircle, Mountain, Compass, Shield, ChevronDown, ChevronUp, Layers } from 'lucide-react';
|
||||
import { LineOfSightResult, calculateLineOfSight } from '../utils/elevationService';
|
||||
|
||||
interface LineOfSightToolProps {
|
||||
active: boolean;
|
||||
pointA: [number, number] | null; // [lat, lng] Observer
|
||||
pointB: [number, number] | null; // [lat, lng] Target
|
||||
onClose: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export const LineOfSightTool: React.FC<LineOfSightToolProps> = ({
|
||||
active,
|
||||
pointA,
|
||||
pointB,
|
||||
onClose,
|
||||
onClear
|
||||
}) => {
|
||||
const [obsHeight, setObsHeight] = useState<number>(2); // 2m eye level
|
||||
const [tgtHeight, setTgtHeight] = useState<number>(2); // 2m target level
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [result, setResult] = useState<LineOfSightResult | null>(null);
|
||||
const [hoverPoint, setHoverPoint] = useState<any | null>(null);
|
||||
const [minimized, setMinimized] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (pointA && pointB) {
|
||||
setLoading(true);
|
||||
calculateLineOfSight(pointA[0], pointA[1], pointB[0], pointB[1], obsHeight, tgtHeight, 80)
|
||||
.then((res) => {
|
||||
setResult(res);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('LOS Error:', err);
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
setResult(null);
|
||||
}
|
||||
}, [pointA, pointB, obsHeight, tgtHeight]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
// Instructions overlay if points are not selected yet
|
||||
if (!pointA || !pointB) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 72,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: 'rgba(15, 23, 42, 0.95)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.5)',
|
||||
boxShadow: '0 12px 36px rgba(0, 0, 0, 0.6)',
|
||||
borderRadius: 14,
|
||||
padding: '12px 22px',
|
||||
color: '#f8fafc',
|
||||
direction: 'rtl',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
fontFamily: "'IBM Plex Sans Arabic', Inter, system-ui, sans-serif"
|
||||
}}>
|
||||
<div style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: '50%',
|
||||
background: pointA ? 'rgba(34, 197, 94, 0.2)' : 'rgba(59, 130, 246, 0.2)',
|
||||
border: pointA ? '1px solid #22c55e' : '1px solid #3b82f6',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: pointA ? '#4ade80' : '#60a5fa'
|
||||
}}>
|
||||
{pointA ? <Crosshair size={20} /> : <Eye size={20} />}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: '0.95rem', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>أداة تبادل الرؤية وخط النظر التكتيكي (Line of Sight)</span>
|
||||
<span style={{ fontSize: '0.72rem', background: '#3b82f630', color: '#60a5fa', padding: '2px 8px', borderRadius: 6 }}>مباشر</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.82rem', color: pointA ? '#4ade80' : '#cbd5e1', marginTop: 2 }}>
|
||||
{!pointA
|
||||
? '📍 اضغط على الخريطة لتحديد موقع الراصد / الرامي (النقطة A)'
|
||||
: `🎯 تم تحديد الراصد (${pointA[0].toFixed(4)}, ${pointA[1].toFixed(4)}) — اضغط الآن لتحديد موقع الهدف (النقطة B)`}
|
||||
</div>
|
||||
</div>
|
||||
{pointA && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
style={{
|
||||
background: 'rgba(239, 68, 68, 0.15)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.4)',
|
||||
color: '#f87171',
|
||||
borderRadius: 8,
|
||||
padding: '4px 10px',
|
||||
fontSize: '0.75rem',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
إعادة
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#64748b',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
fontSize: '1.1rem'
|
||||
}}
|
||||
title="إغلاق"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render SVG Chart for Elevation Profile
|
||||
const renderProfileChart = () => {
|
||||
if (!result || result.points.length === 0) return null;
|
||||
|
||||
const svgWidth = 620;
|
||||
const svgHeight = 190;
|
||||
const padding = { top: 20, right: 35, bottom: 30, left: 45 };
|
||||
const chartWidth = svgWidth - padding.left - padding.right;
|
||||
const chartHeight = svgHeight - padding.top - padding.bottom;
|
||||
|
||||
const maxDist = result.totalDistance;
|
||||
// Dynamic elevation range with margin
|
||||
const minElev = Math.floor(Math.min(result.minElevation, result.observerElevation, result.targetElevation) / 50) * 50 - 50;
|
||||
const maxElev = Math.ceil(Math.max(result.maxElevation, result.observerElevation, result.targetElevation) / 50) * 50 + 50;
|
||||
const elevRange = Math.max(100, maxElev - minElev);
|
||||
|
||||
const getX = (d: number) => padding.left + (d / maxDist) * chartWidth;
|
||||
const getY = (h: number) => padding.top + chartHeight - ((h - minElev) / elevRange) * chartHeight;
|
||||
|
||||
// Build Terrain Polygon Path
|
||||
const terrainPoints = result.points.map((p) => `${getX(p.distance)},${getY(p.elevation)}`).join(' ');
|
||||
const terrainAreaPath = `M ${getX(0)},${padding.top + chartHeight} L ${terrainPoints} L ${getX(maxDist)},${padding.top + chartHeight} Z`;
|
||||
const terrainLinePath = `M ${terrainPoints}`;
|
||||
|
||||
// Line of Sight Ray Path
|
||||
const obsX = getX(0);
|
||||
const obsY = getY(result.observerElevation);
|
||||
const tgtX = getX(maxDist);
|
||||
const tgtY = getY(result.targetElevation);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', overflow: 'hidden' }}>
|
||||
<svg
|
||||
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
onMouseLeave={() => setHoverPoint(null)}
|
||||
>
|
||||
<defs>
|
||||
{/* Terrain Gradient */}
|
||||
<linearGradient id="terrainGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#475569" stopOpacity="0.85" />
|
||||
<stop offset="100%" stopColor="#0f172a" stopOpacity="0.95" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Obstructed Ray Pattern */}
|
||||
<linearGradient id="rayGrad" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor={result.isDirectlyVisible ? '#22c55e' : '#ef4444'} />
|
||||
<stop offset="100%" stopColor={result.isDirectlyVisible ? '#10b981' : '#dc2626'} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Grid Lines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const hVal = minElev + ratio * elevRange;
|
||||
const y = getY(hVal);
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line x1={padding.left} y1={y} x2={svgWidth - padding.right} y2={y} stroke="rgba(255,255,255,0.08)" strokeDasharray="3 3" />
|
||||
<text x={padding.left - 8} y={y + 3} fill="#64748b" fontSize="9" textAnchor="end" fontFamily="monospace">
|
||||
{Math.round(hVal)}m
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Distance Axis */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const dVal = ratio * maxDist;
|
||||
const x = getX(dVal);
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line x1={x} y1={padding.top} x2={x} y2={padding.top + chartHeight} stroke="rgba(255,255,255,0.06)" strokeDasharray="2 2" />
|
||||
<text x={x} y={padding.top + chartHeight + 16} fill="#64748b" fontSize="9" textAnchor="middle" fontFamily="monospace">
|
||||
{(dVal / 1000).toFixed(1)}km
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Terrain Area & Line */}
|
||||
<path d={terrainAreaPath} fill="url(#terrainGrad)" />
|
||||
<path d={terrainLinePath} fill="none" stroke="#94a3b8" strokeWidth="2" strokeLinejoin="round" />
|
||||
|
||||
{/* Line of Sight Ray */}
|
||||
<line
|
||||
x1={obsX}
|
||||
y1={obsY}
|
||||
x2={tgtX}
|
||||
y2={tgtY}
|
||||
stroke="url(#rayGrad)"
|
||||
strokeWidth="2.5"
|
||||
strokeDasharray={result.isDirectlyVisible ? 'none' : '5 4'}
|
||||
/>
|
||||
|
||||
{/* Dead Ground Regions (Highlighted on terrain) */}
|
||||
{result.points.map((p, i) => {
|
||||
if (!p.isVisible && i > 0) {
|
||||
const x = getX(p.distance);
|
||||
const y = getY(p.elevation);
|
||||
return (
|
||||
<circle key={i} cx={x} cy={y} r="1.5" fill="#f87171" opacity="0.6" />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
{/* Observer Marker */}
|
||||
<circle cx={obsX} cy={obsY} r="5" fill="#3b82f6" stroke="#fff" strokeWidth="2" />
|
||||
<text x={obsX} y={obsY - 10} fill="#60a5fa" fontSize="10" fontWeight="bold" textAnchor="middle">
|
||||
الراصد (A)
|
||||
</text>
|
||||
|
||||
{/* Target Marker */}
|
||||
<circle cx={tgtX} cy={tgtY} r="5" fill="#f59e0b" stroke="#fff" strokeWidth="2" />
|
||||
<text x={tgtX} y={tgtY - 10} fill="#fbbf24" fontSize="10" fontWeight="bold" textAnchor="middle">
|
||||
الهدف (B)
|
||||
</text>
|
||||
|
||||
{/* Critical Obstacle Marker (if blocked) */}
|
||||
{result.highestObstacle && (
|
||||
<g>
|
||||
<circle
|
||||
cx={getX(result.highestObstacle.distance)}
|
||||
cy={getY(result.highestObstacle.elevation)}
|
||||
r="6"
|
||||
fill="#ef4444"
|
||||
stroke="#fff"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d={`M ${getX(result.highestObstacle.distance)},${getY(result.highestObstacle.elevation) - 8} L ${getX(result.highestObstacle.distance) - 4},${getY(result.highestObstacle.elevation) - 14} L ${getX(result.highestObstacle.distance) + 4},${getY(result.highestObstacle.elevation) - 14} Z`}
|
||||
fill="#ef4444"
|
||||
/>
|
||||
<text
|
||||
x={getX(result.highestObstacle.distance)}
|
||||
y={getY(result.highestObstacle.elevation) - 18}
|
||||
fill="#f87171"
|
||||
fontSize="9"
|
||||
fontWeight="bold"
|
||||
textAnchor="middle"
|
||||
>
|
||||
عائق الحجب (+{result.highestObstacle.excessHeight}m)
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Interactive Hover Overlay Rect */}
|
||||
{result.points.map((p, idx) => {
|
||||
const x = getX(p.distance);
|
||||
return (
|
||||
<rect
|
||||
key={idx}
|
||||
x={x - (chartWidth / result.points.length) / 2}
|
||||
y={padding.top}
|
||||
width={chartWidth / result.points.length}
|
||||
height={chartHeight}
|
||||
fill="transparent"
|
||||
style={{ cursor: 'crosshair' }}
|
||||
onMouseEnter={() => setHoverPoint(p)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Active Hover Point Marker */}
|
||||
{hoverPoint && (
|
||||
<g>
|
||||
<line
|
||||
x1={getX(hoverPoint.distance)}
|
||||
y1={padding.top}
|
||||
x2={getX(hoverPoint.distance)}
|
||||
y2={padding.top + chartHeight}
|
||||
stroke="#38bdf8"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2 2"
|
||||
/>
|
||||
<circle cx={getX(hoverPoint.distance)} cy={getY(hoverPoint.elevation)} r="4" fill="#38bdf8" stroke="#fff" strokeWidth="1.5" />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Hover Info Tooltip */}
|
||||
{hoverPoint && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: 20,
|
||||
background: 'rgba(15, 23, 42, 0.95)',
|
||||
border: '1px solid rgba(56, 189, 248, 0.4)',
|
||||
borderRadius: 8,
|
||||
padding: '6px 12px',
|
||||
fontSize: '0.75rem',
|
||||
color: '#f8fafc',
|
||||
direction: 'rtl',
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
gap: 10
|
||||
}}>
|
||||
<span>المسافة: <strong>{(hoverPoint.distance / 1000).toFixed(2)} كم</strong></span>
|
||||
<span>الارتفاع: <strong>{hoverPoint.elevation} م</strong></span>
|
||||
<span>شعاع الرؤية: <strong>{hoverPoint.rayHeight} م</strong></span>
|
||||
<span>الحالة: <strong style={{ color: hoverPoint.isVisible ? '#4ade80' : '#f87171' }}>
|
||||
{hoverPoint.isVisible ? 'مكشوف' : 'أرض ميتة'}
|
||||
</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 'min(94vw, 760px)',
|
||||
zIndex: 1000,
|
||||
background: 'rgba(15, 23, 42, 0.94)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.6)',
|
||||
borderRadius: 16,
|
||||
color: '#f8fafc',
|
||||
direction: 'rtl',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.3s ease'
|
||||
}}>
|
||||
{/* Header Bar */}
|
||||
<div style={{
|
||||
padding: '12px 18px',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
background: 'rgba(30, 41, 59, 0.5)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
background: result?.isDirectlyVisible ? 'rgba(34, 197, 94, 0.2)' : 'rgba(239, 68, 68, 0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: result?.isDirectlyVisible ? '#4ade80' : '#f87171'
|
||||
}}>
|
||||
{result?.isDirectlyVisible ? <CheckCircle2 size={20} /> : <XCircle size={20} />}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 800, fontSize: '0.95rem', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>تبادل الرؤية والمقطع التضاريسي (Line of Sight)</span>
|
||||
{result && (
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 6,
|
||||
fontWeight: 700,
|
||||
background: result.isDirectlyVisible ? '#15803d' : '#991b1b',
|
||||
color: '#fff'
|
||||
}}>
|
||||
{result.isDirectlyVisible ? '✓ رؤية مباشرة مكشوفة' : '✕ الرؤية محجوبة بعائق'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: '#94a3b8' }}>
|
||||
تحليل خط النظر التكتيكي مع تصحيح انكسار الضوء الجوي وتقوس الأرض
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
onClick={onClear}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
borderRadius: 8,
|
||||
padding: '6px 12px',
|
||||
color: '#e2e8f0',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
تحديد نقاط جديدة
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMinimized(!minimized)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#94a3b8',
|
||||
cursor: 'pointer',
|
||||
padding: 4
|
||||
}}
|
||||
>
|
||||
{minimized ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#94a3b8',
|
||||
cursor: 'pointer',
|
||||
padding: 4
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
{!minimized && (
|
||||
<div style={{ padding: '14px 18px' }}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '30px', color: '#94a3b8' }}>
|
||||
جاري حساب المقطع التضاريسي وشعاع الرؤية... ⏳
|
||||
</div>
|
||||
) : result ? (
|
||||
<>
|
||||
{/* Tactical Metrics Grid */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))',
|
||||
gap: 10,
|
||||
marginBottom: 14
|
||||
}}>
|
||||
<div style={{ background: 'rgba(255, 255, 255, 0.04)', borderRadius: 10, padding: '8px 12px', border: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Compass size={12} color="#38bdf8" /> المسافة المباشرة
|
||||
</div>
|
||||
<div style={{ fontSize: '1.1rem', fontWeight: 800, color: '#f8fafc', marginTop: 2 }}>
|
||||
{(result.totalDistance / 1000).toFixed(2)} <span style={{ fontSize: '0.75rem', fontWeight: 500 }}>كم</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255, 255, 255, 0.04)', borderRadius: 10, padding: '8px 12px', border: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Crosshair size={12} color="#fbbf24" /> السمت / الاتجاه
|
||||
</div>
|
||||
<div style={{ fontSize: '1.1rem', fontWeight: 800, color: '#f8fafc', marginTop: 2 }}>
|
||||
{result.azimuthDegrees}° <span style={{ fontSize: '0.75rem', fontWeight: 500 }}>بوصلة</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255, 255, 255, 0.04)', borderRadius: 10, padding: '8px 12px', border: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Shield size={12} color="#a78bfa" /> زاوية الموقع (رماية)
|
||||
</div>
|
||||
<div style={{ fontSize: '1.1rem', fontWeight: 800, color: '#a78bfa', marginTop: 2 }}>
|
||||
{result.angleMils > 0 ? `+${result.angleMils}` : result.angleMils} <span style={{ fontSize: '0.75rem', fontWeight: 500 }}>Mils ({result.angleDegrees}°)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255, 255, 255, 0.04)', borderRadius: 10, padding: '8px 12px', border: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Mountain size={12} color="#34d399" /> الراصد / الهدف
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: '#f8fafc', marginTop: 4 }}>
|
||||
{result.observerElevation}m ➔ {result.targetElevation}m
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255, 255, 255, 0.04)', borderRadius: 10, padding: '8px 12px', border: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Eye size={12} color="#f472b6" /> الأرض الميتة
|
||||
</div>
|
||||
<div style={{ fontSize: '1.1rem', fontWeight: 800, color: result.deadGroundPercentage > 30 ? '#f87171' : '#f8fafc', marginTop: 2 }}>
|
||||
{result.deadGroundPercentage}% <span style={{ fontSize: '0.75rem', fontWeight: 500 }}>محجوبة</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Critical Obstacle Alert */}
|
||||
{result.highestObstacle && (
|
||||
<div style={{
|
||||
background: 'rgba(239, 68, 68, 0.12)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.35)',
|
||||
borderRadius: 10,
|
||||
padding: '8px 14px',
|
||||
marginBottom: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
fontSize: '0.82rem',
|
||||
color: '#fca5a5'
|
||||
}}>
|
||||
<AlertTriangle size={18} color="#ef4444" style={{ flexShrink: 0 }} />
|
||||
<div>
|
||||
<strong>عائق الحجب الرئيسي:</strong> قمة جبلية/تضاريس على بعد <strong>{(result.highestObstacle.distance / 1000).toFixed(2)} كم</strong> بارتفاع <strong>{result.highestObstacle.elevation} م</strong>، تخترق خط الرؤية بمقدار <strong>+{result.highestObstacle.excessHeight} م</strong>.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Elevation Profile Chart */}
|
||||
<div style={{ background: 'rgba(0,0,0,0.3)', borderRadius: 12, padding: '10px 10px 4px 10px', border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
{renderProfileChart()}
|
||||
</div>
|
||||
|
||||
{/* Observer / Target Height Adjusters */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
paddingTop: 10,
|
||||
borderTop: '1px solid rgba(255,255,255,0.08)',
|
||||
fontSize: '0.8rem'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ color: '#94a3b8' }}>ارتفاع الراصد (A):</span>
|
||||
{[
|
||||
{ label: 'شخص (2م)', val: 2 },
|
||||
{ label: 'آلية/برج (10م)', val: 10 },
|
||||
{ label: 'سارية/درون (50م)', val: 50 },
|
||||
].map((btn) => (
|
||||
<button
|
||||
key={btn.val}
|
||||
onClick={() => setObsHeight(btn.val)}
|
||||
style={{
|
||||
background: obsHeight === btn.val ? 'rgba(59, 130, 246, 0.3)' : 'rgba(255,255,255,0.05)',
|
||||
border: obsHeight === btn.val ? '1px solid #3b82f6' : '1px solid rgba(255,255,255,0.1)',
|
||||
color: obsHeight === btn.val ? '#60a5fa' : '#cbd5e1',
|
||||
borderRadius: 6,
|
||||
padding: '3px 8px',
|
||||
fontSize: '0.75rem',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ color: '#94a3b8' }}>ارتفاع الهدف (B):</span>
|
||||
{[
|
||||
{ label: 'شخص (1.8م)', val: 1.8 },
|
||||
{ label: 'مركبة (3م)', val: 3 },
|
||||
{ label: 'مبنى/رادار (15م)', val: 15 },
|
||||
].map((btn) => (
|
||||
<button
|
||||
key={btn.val}
|
||||
onClick={() => setTgtHeight(btn.val)}
|
||||
style={{
|
||||
background: tgtHeight === btn.val ? 'rgba(245, 158, 11, 0.3)' : 'rgba(255,255,255,0.05)',
|
||||
border: tgtHeight === btn.val ? '1px solid #f59e0b' : '1px solid rgba(255,255,255,0.1)',
|
||||
color: tgtHeight === btn.val ? '#fbbf24' : '#cbd5e1',
|
||||
borderRadius: 6,
|
||||
padding: '3px 8px',
|
||||
fontSize: '0.75rem',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,22 @@ import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import mlcontour from 'maplibre-contour';
|
||||
import { attachIconLoader } from '../utils/mapIcons';
|
||||
|
||||
interface MapProps {
|
||||
onMapLoad: (map: maplibregl.Map) => void;
|
||||
onMapClick?: (lat: number, lng: number) => void;
|
||||
show3D?: boolean;
|
||||
showPOIs?: boolean;
|
||||
showTerrain?: boolean;
|
||||
showContours?: boolean;
|
||||
showAdminBoundaries?: boolean;
|
||||
showWeather?: boolean;
|
||||
currentRegion?: string;
|
||||
onCityClick?: (city: any) => void;
|
||||
losActive?: boolean;
|
||||
losPointA?: [number, number] | null;
|
||||
losPointB?: [number, number] | null;
|
||||
}
|
||||
|
||||
const demSource = new mlcontour.DemSource({
|
||||
url: 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png',
|
||||
encoding: 'terrarium',
|
||||
@@ -12,45 +28,136 @@ const demSource = new mlcontour.DemSource({
|
||||
});
|
||||
demSource.setupMaplibre(maplibregl);
|
||||
|
||||
interface MapComponentProps {
|
||||
onMapLoad: (map: maplibregl.Map) => void;
|
||||
onMapClick?: (lat: number, lng: number) => void;
|
||||
show3D?: boolean;
|
||||
showPOIs?: boolean;
|
||||
showTerrain?: boolean;
|
||||
showContours?: boolean;
|
||||
showAdminBoundaries?: boolean;
|
||||
}
|
||||
|
||||
const MapComponent: React.FC<MapComponentProps> = ({
|
||||
const MapComponent: React.FC<MapProps> = ({
|
||||
onMapLoad,
|
||||
onMapClick,
|
||||
show3D = false,
|
||||
showPOIs = true,
|
||||
showTerrain = true,
|
||||
showTerrain = false,
|
||||
showContours = false,
|
||||
showAdminBoundaries = true
|
||||
showAdminBoundaries = false,
|
||||
showWeather = false,
|
||||
currentRegion = 'Jordan',
|
||||
onCityClick,
|
||||
losActive = false,
|
||||
losPointA = null,
|
||||
losPointB = null,
|
||||
}) => {
|
||||
const mapContainer = useRef<HTMLDivElement>(null);
|
||||
const map = useRef<maplibregl.Map | null>(null);
|
||||
|
||||
// Sync toggles with map layers
|
||||
// Synchronize Line of Sight (LOS) Tactical Layer
|
||||
useEffect(() => {
|
||||
if (!map.current || !map.current.isStyleLoaded()) return;
|
||||
|
||||
const features: any[] = [];
|
||||
if (losActive) {
|
||||
if (losPointA) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { role: 'obs', label: 'A (الراصد)' },
|
||||
geometry: { type: 'Point', coordinates: [losPointA[1], losPointA[0]] }
|
||||
});
|
||||
}
|
||||
if (losPointB) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { role: 'tgt', label: 'B (الهدف)' },
|
||||
geometry: { type: 'Point', coordinates: [losPointB[1], losPointB[0]] }
|
||||
});
|
||||
}
|
||||
if (losPointA && losPointB) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { role: 'ray' },
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[losPointA[1], losPointA[0]],
|
||||
[losPointB[1], losPointB[0]]
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const losGeoJson = { type: 'FeatureCollection', features };
|
||||
|
||||
if (!map.current.getSource('los-source')) {
|
||||
map.current.addSource('los-source', {
|
||||
type: 'geojson',
|
||||
data: losGeoJson as any
|
||||
});
|
||||
|
||||
map.current.addLayer({
|
||||
id: 'los-line',
|
||||
type: 'line',
|
||||
source: 'los-source',
|
||||
filter: ['==', '$type', 'LineString'],
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: {
|
||||
'line-color': '#f59e0b',
|
||||
'line-width': 3.5,
|
||||
'line-dasharray': [2, 2]
|
||||
}
|
||||
});
|
||||
|
||||
map.current.addLayer({
|
||||
id: 'los-points',
|
||||
type: 'circle',
|
||||
source: 'los-source',
|
||||
filter: ['==', '$type', 'Point'],
|
||||
paint: {
|
||||
'circle-radius': 8,
|
||||
'circle-color': [
|
||||
'match',
|
||||
['get', 'role'],
|
||||
'obs', '#3b82f6',
|
||||
'tgt', '#ef4444',
|
||||
'#ffffff'
|
||||
],
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-stroke-width': 2.5
|
||||
}
|
||||
});
|
||||
|
||||
map.current.addLayer({
|
||||
id: 'los-labels',
|
||||
type: 'symbol',
|
||||
source: 'los-source',
|
||||
filter: ['==', '$type', 'Point'],
|
||||
layout: {
|
||||
'text-field': ['get', 'label'],
|
||||
'text-size': 12,
|
||||
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
|
||||
'text-offset': [0, -1.6],
|
||||
'text-anchor': 'bottom'
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': '#0f172a',
|
||||
'text-halo-width': 2
|
||||
}
|
||||
});
|
||||
} else {
|
||||
(map.current.getSource('los-source') as maplibregl.GeoJSONSource).setData(losGeoJson as any);
|
||||
map.current.setLayoutProperty('los-line', 'visibility', losActive ? 'visible' : 'none');
|
||||
map.current.setLayoutProperty('los-points', 'visibility', losActive ? 'visible' : 'none');
|
||||
map.current.setLayoutProperty('los-labels', 'visibility', losActive ? 'visible' : 'none');
|
||||
}
|
||||
}, [losActive, losPointA, losPointB]);
|
||||
|
||||
// Synchronize layers visibility
|
||||
useEffect(() => {
|
||||
if (!map.current || !map.current.isStyleLoaded()) return;
|
||||
|
||||
// Toggle 3D Buildings
|
||||
['building-3d', 'building-3d-osm'].forEach(layerId => {
|
||||
if (map.current!.getLayer(layerId)) {
|
||||
map.current!.setLayoutProperty(layerId, 'visibility', show3D ? 'visible' : 'none');
|
||||
}
|
||||
});
|
||||
if (map.current.getLayer('overture-building-footprint')) {
|
||||
map.current.setLayoutProperty('overture-building-footprint', 'visibility', show3D ? 'none' : 'visible');
|
||||
if (map.current.getLayer('3d-buildings')) {
|
||||
map.current.setLayoutProperty('3d-buildings', 'visibility', show3D ? 'visible' : 'none');
|
||||
}
|
||||
map.current.easeTo({ pitch: show3D ? 55 : 0, duration: 600 });
|
||||
|
||||
// Toggle POI Layers
|
||||
const poiLayers = ['poi-icons', 'place-labels', 'overture-building-names', 'places-jordan-labels'];
|
||||
// Toggle POIs
|
||||
const poiLayers = ['poi-level-1', 'poi-level-2', 'poi-level-3', 'poi-labels'];
|
||||
poiLayers.forEach(layerId => {
|
||||
if (map.current!.getLayer(layerId)) {
|
||||
map.current!.setLayoutProperty(layerId, 'visibility', showPOIs ? 'visible' : 'none');
|
||||
@@ -83,7 +190,192 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
map.current!.setLayoutProperty(layerId, 'visibility', showAdminBoundaries ? 'visible' : 'none');
|
||||
}
|
||||
});
|
||||
}, [show3D, showPOIs, showTerrain, showContours, showAdminBoundaries]);
|
||||
|
||||
const weatherLayers = ['weather-city-circles', 'weather-city-icons', 'weather-wind-arrows', 'weather-wind-speed'];
|
||||
weatherLayers.forEach(layerId => {
|
||||
if (map.current!.getLayer(layerId)) {
|
||||
map.current!.setLayoutProperty(layerId, 'visibility', showWeather ? 'visible' : 'none');
|
||||
}
|
||||
});
|
||||
}, [show3D, showPOIs, showTerrain, showContours, showAdminBoundaries, showWeather]);
|
||||
|
||||
// Weather data fetching and vector rendering
|
||||
useEffect(() => {
|
||||
if (!map.current || !map.current.isStyleLoaded()) return;
|
||||
|
||||
if (showWeather) {
|
||||
const fetchWeather = async () => {
|
||||
try {
|
||||
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
||||
const apiKey = (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||
|
||||
const regionParam = currentRegion ? `?region=${encodeURIComponent(currentRegion)}` : '';
|
||||
const citiesRes = await fetch(`${apiUrl}/weather/cities${regionParam}`, {
|
||||
headers: { 'x-api-key': apiKey }
|
||||
});
|
||||
const rawCitiesData = await citiesRes.json();
|
||||
|
||||
// Convert cities array to GeoJSON FeatureCollection
|
||||
const citiesGeoJson = {
|
||||
type: 'FeatureCollection',
|
||||
features: (Array.isArray(rawCitiesData) ? rawCitiesData : []).map((city: any) => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [city.lng, city.lat]
|
||||
},
|
||||
properties: {
|
||||
name: city.name,
|
||||
name_ar: city.name_ar,
|
||||
temperature: Number(city.current?.temp ?? 20),
|
||||
feelsLike: Number(city.current?.feelsLike ?? 20),
|
||||
humidity: Number(city.current?.humidity ?? 50),
|
||||
weatherCode: Number(city.current?.weatherCode ?? 0),
|
||||
windSpeed: Number(city.current?.windSpeed ?? 10),
|
||||
windDirection: Number(city.current?.windDirection ?? 0),
|
||||
cloudCover: Number(city.current?.cloudCover ?? 0),
|
||||
precipitation: Number(city.current?.precipitation ?? 0),
|
||||
isDay: Number(city.current?.isDay ?? 1),
|
||||
forecast: JSON.stringify(city.daily || [])
|
||||
}
|
||||
}))
|
||||
};
|
||||
|
||||
if (!map.current!.getSource('weather-cities-source')) {
|
||||
map.current!.addSource('weather-cities-source', { type: 'geojson', data: citiesGeoJson as any });
|
||||
|
||||
// 1. Soft temperature indicator circle badge
|
||||
map.current!.addLayer({
|
||||
id: 'weather-city-circles',
|
||||
type: 'circle',
|
||||
source: 'weather-cities-source',
|
||||
paint: {
|
||||
'circle-radius': 24,
|
||||
'circle-color': [
|
||||
'step', ['to-number', ['get', 'temperature'], 20],
|
||||
'#3b82f6', 15,
|
||||
'#10b981', 25,
|
||||
'#f59e0b', 35,
|
||||
'#ef4444'
|
||||
],
|
||||
'circle-opacity': 0.9,
|
||||
'circle-stroke-width': 2.5,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
});
|
||||
|
||||
// 2. City name and temperature text badge
|
||||
map.current!.addLayer({
|
||||
id: 'weather-city-icons',
|
||||
type: 'symbol',
|
||||
source: 'weather-cities-source',
|
||||
layout: {
|
||||
'text-field': ['concat', ['coalesce', ['get', 'name_ar'], ''], '\n', ['to-string', ['round', ['to-number', ['get', 'temperature'], 20]]], '°C'],
|
||||
'text-size': 11,
|
||||
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
|
||||
'text-justify': 'center',
|
||||
'text-anchor': 'center'
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': 'rgba(0, 0, 0, 0.6)',
|
||||
'text-halo-width': 1.5
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Wind indicator arrow (rotated by wind direction angle)
|
||||
map.current!.addLayer({
|
||||
id: 'weather-wind-arrows',
|
||||
type: 'symbol',
|
||||
source: 'weather-cities-source',
|
||||
minzoom: 4,
|
||||
layout: {
|
||||
'text-field': '➤',
|
||||
'text-rotation-alignment': 'map',
|
||||
'text-rotate': ['to-number', ['get', 'windDirection'], 0],
|
||||
'text-size': 14,
|
||||
'text-offset': [2.2, 0],
|
||||
'text-allow-overlap': true
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#38bdf8',
|
||||
'text-halo-color': '#0f172a',
|
||||
'text-halo-width': 2
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Wind speed badge (e.g. 15 km/h)
|
||||
map.current!.addLayer({
|
||||
id: 'weather-wind-speed',
|
||||
type: 'symbol',
|
||||
source: 'weather-cities-source',
|
||||
minzoom: 4,
|
||||
layout: {
|
||||
'text-field': ['concat', ['to-string', ['round', ['to-number', ['get', 'windSpeed'], 10]]], ' km/h'],
|
||||
'text-size': 9,
|
||||
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||
'text-offset': [0, 2.6],
|
||||
'text-anchor': 'top',
|
||||
'text-allow-overlap': false
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#bae6fd',
|
||||
'text-halo-color': 'rgba(15, 23, 42, 0.85)',
|
||||
'text-halo-width': 2
|
||||
}
|
||||
});
|
||||
|
||||
const handleCityClick = (e: any) => {
|
||||
if (e.originalEvent) {
|
||||
e.originalEvent.stopPropagation();
|
||||
}
|
||||
if (onCityClick && e.features && e.features.length > 0) {
|
||||
onCityClick(e.features[0].properties);
|
||||
}
|
||||
};
|
||||
|
||||
map.current!.on('click', 'weather-city-circles', handleCityClick);
|
||||
map.current!.on('click', 'weather-city-icons', handleCityClick);
|
||||
|
||||
map.current!.on('mouseenter', 'weather-city-circles', () => {
|
||||
if (map.current) map.current.getCanvas().style.cursor = 'pointer';
|
||||
});
|
||||
map.current!.on('mouseleave', 'weather-city-circles', () => {
|
||||
if (map.current) map.current.getCanvas().style.cursor = '';
|
||||
});
|
||||
} else {
|
||||
(map.current!.getSource('weather-cities-source') as maplibregl.GeoJSONSource).setData(citiesGeoJson as any);
|
||||
map.current!.setLayoutProperty('weather-city-circles', 'visibility', 'visible');
|
||||
map.current!.setLayoutProperty('weather-city-icons', 'visibility', 'visible');
|
||||
map.current!.setLayoutProperty('weather-wind-arrows', 'visibility', 'visible');
|
||||
map.current!.setLayoutProperty('weather-wind-speed', 'visibility', 'visible');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Weather fetch failed', e);
|
||||
}
|
||||
};
|
||||
fetchWeather();
|
||||
} else {
|
||||
const weatherLayers = ['weather-city-circles', 'weather-city-icons', 'weather-wind-arrows', 'weather-wind-speed'];
|
||||
weatherLayers.forEach(layerId => {
|
||||
if (map.current && map.current.getLayer(layerId)) {
|
||||
map.current.setLayoutProperty(layerId, 'visibility', 'none');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [showWeather, currentRegion, onCityClick]);
|
||||
|
||||
const onMapClickRef = useRef(onMapClick);
|
||||
useEffect(() => {
|
||||
onMapClickRef.current = onMapClick;
|
||||
}, [onMapClick]);
|
||||
|
||||
// Update canvas cursor when LOS is active
|
||||
useEffect(() => {
|
||||
if (map.current) {
|
||||
map.current.getCanvas().style.cursor = losActive ? 'crosshair' : '';
|
||||
}
|
||||
}, [losActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (map.current) return;
|
||||
@@ -129,6 +421,53 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
initialMap.on('load', () => {
|
||||
console.log("MapComponent: Map Loaded Successfully");
|
||||
|
||||
// Initialize Route Source and Layers
|
||||
if (!initialMap.getSource('route')) {
|
||||
initialMap.addSource('route', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: []
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Route outer shadow/glow
|
||||
initialMap.addLayer({
|
||||
id: 'route-casing',
|
||||
type: 'line',
|
||||
source: 'route',
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#0369a1',
|
||||
'line-width': 9,
|
||||
'line-opacity': 0.8
|
||||
}
|
||||
});
|
||||
|
||||
// Route inner bright line
|
||||
initialMap.addLayer({
|
||||
id: 'route-line',
|
||||
type: 'line',
|
||||
source: 'route',
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#38bdf8',
|
||||
'line-width': 5,
|
||||
'line-opacity': 1.0
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const contourUrl = demSource.contourProtocolUrl({
|
||||
thresholds: {
|
||||
10: [100, 500],
|
||||
@@ -211,13 +550,13 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
onMapLoad(map.current!);
|
||||
});
|
||||
|
||||
map.current.on('click', (e) => {
|
||||
if (onMapClick) {
|
||||
onMapClick(e.lngLat.lat, e.lngLat.lng);
|
||||
initialMap.on('click', (e) => {
|
||||
if (onMapClickRef.current) {
|
||||
onMapClickRef.current(e.lngLat.lat, e.lngLat.lng);
|
||||
}
|
||||
});
|
||||
|
||||
map.current.on('error', (e) => {
|
||||
initialMap.on('error', (e) => {
|
||||
console.error("MapComponent: Map Error:", e);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, { useState } from 'react';
|
||||
import { getWeatherDescription, getTemperatureColor } from '../utils/weatherIcons';
|
||||
|
||||
interface WeatherPanelProps {
|
||||
visible: boolean;
|
||||
selectedCity?: any;
|
||||
alerts?: any[];
|
||||
}
|
||||
|
||||
const WeatherPanel: React.FC<WeatherPanelProps> = ({ visible, selectedCity, alerts }) => {
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const panelStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: '24px',
|
||||
right: '24px',
|
||||
width: '340px',
|
||||
maxHeight: '440px',
|
||||
overflowY: 'auto',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.9)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
WebkitBackdropFilter: 'blur(16px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||
borderRadius: '16px',
|
||||
padding: '16px',
|
||||
boxShadow: '0 12px 36px rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 1000,
|
||||
direction: 'rtl',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
color: '#f8fafc'
|
||||
};
|
||||
|
||||
if (minimized) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...panelStyle,
|
||||
width: 'auto',
|
||||
cursor: 'pointer',
|
||||
padding: '10px 18px',
|
||||
fontWeight: 600,
|
||||
background: 'rgba(30, 41, 59, 0.9)'
|
||||
}}
|
||||
onClick={() => setMinimized(false)}
|
||||
>
|
||||
🌤️ الطقس (Weather)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderCurrentWeather = () => {
|
||||
if (!selectedCity) return null;
|
||||
|
||||
const code = Number(selectedCity.weatherCode ?? selectedCity.weather_code ?? 0);
|
||||
const temp = Number(selectedCity.temperature ?? 0);
|
||||
const desc = getWeatherDescription(code);
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '14px', padding: '14px', background: 'rgba(255,255,255,0.06)', borderRadius: '12px', border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem', color: '#fff' }}>{selectedCity.name_ar || selectedCity.name || 'المدينة'}</h3>
|
||||
<span style={{ fontSize: '2rem' }}>{desc.icon}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginTop: '6px' }}>
|
||||
<span style={{ fontSize: '2.2rem', fontWeight: 800, color: getTemperatureColor(temp) }}>
|
||||
{Math.round(temp)}°C
|
||||
</span>
|
||||
<span style={{ fontSize: '1rem', color: '#94a3b8' }}>{desc.description_ar}</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginTop: '12px', fontSize: '0.82rem', color: '#cbd5e1' }}>
|
||||
<div>💧 الرطوبة: <b>{selectedCity.humidity || 0}%</b></div>
|
||||
<div>💨 الرياح: <b>{selectedCity.windSpeed || 0} كم/س</b></div>
|
||||
<div>☁️ الغطاء: <b>{selectedCity.cloudCover || 0}%</b></div>
|
||||
<div>🌡️ الشعور: <b>{Math.round(Number(selectedCity.feelsLike ?? temp))}°C</b></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderForecast = () => {
|
||||
if (!selectedCity || !selectedCity.forecast) return null;
|
||||
|
||||
let forecastArray: any[] = [];
|
||||
try {
|
||||
forecastArray = typeof selectedCity.forecast === 'string' ? JSON.parse(selectedCity.forecast) : selectedCity.forecast;
|
||||
} catch(e) {
|
||||
forecastArray = [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(forecastArray) || forecastArray.length === 0) return null;
|
||||
|
||||
const days = ['الأحد', 'الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت'];
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '14px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '0.88rem', color: '#94a3b8' }}>📅 توقعات 7 أيام</h4>
|
||||
<div style={{ display: 'flex', overflowX: 'auto', gap: '8px', paddingBottom: '6px' }}>
|
||||
{forecastArray.map((f: any, idx: number) => {
|
||||
const code = Number(f.weatherCode ?? f.weather_code ?? 0);
|
||||
const desc = getWeatherDescription(code);
|
||||
const dateObj = f.date ? new Date(f.date) : null;
|
||||
const dayName = dateObj ? days[dateObj.getDay()] : `يوم ${idx + 1}`;
|
||||
const maxTemp = Math.round(Number(f.tempMax ?? f.max_temp ?? 0));
|
||||
const minTemp = Math.round(Number(f.tempMin ?? f.min_temp ?? 0));
|
||||
|
||||
return (
|
||||
<div key={idx} style={{
|
||||
minWidth: '65px',
|
||||
padding: '8px 6px',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: '10px',
|
||||
textAlign: 'center',
|
||||
border: '1px solid rgba(255,255,255,0.06)'
|
||||
}}>
|
||||
<div style={{ fontSize: '0.72rem', fontWeight: 600, color: '#94a3b8' }}>{dayName}</div>
|
||||
<div style={{ fontSize: '1.3rem', margin: '4px 0' }}>{desc.icon}</div>
|
||||
<div style={{ fontSize: '0.78rem' }}>
|
||||
<span style={{ color: '#f87171', fontWeight: 600 }}>{maxTemp}°</span>
|
||||
{' '}
|
||||
<span style={{ color: '#60a5fa' }}>{minTemp}°</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAlerts = () => {
|
||||
if (!alerts || alerts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '0.88rem', color: '#f59e0b' }}>⚠️ تنبيهات الطقس</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{alerts.map((alert, idx) => {
|
||||
const severity = alert.severity || alert.level || 'info';
|
||||
const colors: Record<string, string> = {
|
||||
danger: '#ef4444',
|
||||
warning: '#f59e0b',
|
||||
info: '#3b82f6'
|
||||
};
|
||||
const color = colors[severity] || colors.info;
|
||||
return (
|
||||
<div key={idx} style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: `${color}20`,
|
||||
borderRight: `3px solid ${color}`,
|
||||
fontSize: '0.82rem'
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, color, marginBottom: '2px' }}>
|
||||
📍 {alert.city_ar || alert.city || alert.city_name_ar || alert.city_name}: {alert.type || 'تنبيه'}
|
||||
</div>
|
||||
<div style={{ color: '#cbd5e1' }}>{alert.message_ar || alert.message}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid rgba(255,255,255,0.1)', paddingBottom: '8px', marginBottom: '12px' }}>
|
||||
<h2 style={{ margin: 0, fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
🌤️ حالة الطقس المباشرة
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setMinimized(true)}
|
||||
style={{ background: 'transparent', border: 'none', cursor: 'pointer', fontSize: '0.9rem', color: '#94a3b8' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!selectedCity && (
|
||||
<div style={{ textAlign: 'center', padding: '16px 8px', color: '#94a3b8', fontSize: '0.85rem', background: 'rgba(255,255,255,0.03)', borderRadius: '8px', marginBottom: '10px' }}>
|
||||
💡 اضغط على أي مدينة على الخريطة لعرض تفاصيلها وتوقعات 7 أيام
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderCurrentWeather()}
|
||||
{renderForecast()}
|
||||
{renderAlerts()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeatherPanel;
|
||||
|
||||
+24
-2
@@ -32,15 +32,37 @@ body {
|
||||
.sidebar {
|
||||
width: 350px;
|
||||
height: 100%;
|
||||
padding: 30px;
|
||||
max-height: 100vh;
|
||||
padding: 24px 20px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: 16px;
|
||||
box-shadow: 10px 0 30px rgba(0,0,0,0.5);
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(15px);
|
||||
border-right: 1px solid var(--glass-border);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.glass-morphism {
|
||||
|
||||
+32
-21
@@ -4,6 +4,7 @@ import maplibregl from 'maplibre-gl'
|
||||
import App from './App.tsx'
|
||||
import CompareView from './pages/CompareView'
|
||||
import IntelligenceDashboard from './pages/IntelligenceDashboard'
|
||||
import { ExecutiveShowcase } from './pages/ExecutiveShowcase'
|
||||
import './index.css'
|
||||
|
||||
maplibregl.setRTLTextPlugin(
|
||||
@@ -12,49 +13,53 @@ maplibregl.setRTLTextPlugin(
|
||||
true
|
||||
);
|
||||
|
||||
// Lightweight hash router (no dependency). '#compare' and '#review' are additive
|
||||
// views; anything else falls back to the existing map app, untouched.
|
||||
// Lightweight hash router
|
||||
const NAV = [
|
||||
{ hash: '#map', label: 'Map', icon: '🗺️' },
|
||||
{ hash: '#review', label: 'Review', icon: '🔍' },
|
||||
{ hash: '#compare', label: 'Compare', icon: '⚖️' },
|
||||
{ hash: '#executive', label: 'العرض الاستراتيجي', icon: '🎖️' },
|
||||
{ hash: '#map', label: 'الخريطة الحية', icon: '🗺️' },
|
||||
{ hash: '#review', label: 'تدقيق الطرق', icon: '🔍' },
|
||||
{ hash: '#compare', label: 'المقارنة', icon: '⚖️' },
|
||||
]
|
||||
|
||||
function ViewNav({ hash }: { hash: string }) {
|
||||
const isMap = hash !== '#compare' && hash !== '#review'
|
||||
const currentHash = hash || '#map'
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 10,
|
||||
right: 16,
|
||||
top: 12,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
background: 'rgba(15, 23, 42, 0.88)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.45)',
|
||||
background: 'rgba(15, 23, 42, 0.92)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.18)',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.55)',
|
||||
borderRadius: 999,
|
||||
padding: '3px 4px',
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
padding: '4px 6px',
|
||||
fontFamily: "'IBM Plex Sans Arabic', Inter, system-ui, sans-serif",
|
||||
direction: 'rtl',
|
||||
maxWidth: 'calc(100vw - 24px)',
|
||||
overflowX: 'auto'
|
||||
}}>
|
||||
{NAV.map(n => {
|
||||
const active = n.hash === '#map' ? isMap : hash === n.hash
|
||||
const active = currentHash === n.hash
|
||||
return (
|
||||
<a key={n.hash} href={n.hash}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fontWeight: 700,
|
||||
color: active ? '#fff' : '#94a3b8',
|
||||
background: active ? 'linear-gradient(135deg, #6366f1, #4f46e5)' : 'transparent',
|
||||
boxShadow: active ? '0 2px 8px rgba(99, 102, 241, 0.4)' : 'none',
|
||||
padding: '5px 13px',
|
||||
padding: '6px 14px',
|
||||
borderRadius: 999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
gap: 6,
|
||||
transition: 'all 0.15s ease'
|
||||
}}>
|
||||
<span>{n.icon}</span>
|
||||
@@ -67,13 +72,19 @@ function ViewNav({ hash }: { hash: string }) {
|
||||
}
|
||||
|
||||
function Root() {
|
||||
const [hash, setHash] = useState(window.location.hash)
|
||||
const [hash, setHash] = useState(window.location.hash || '#map')
|
||||
useEffect(() => {
|
||||
const on = () => setHash(window.location.hash)
|
||||
const on = () => setHash(window.location.hash || '#map')
|
||||
window.addEventListener('hashchange', on)
|
||||
return () => window.removeEventListener('hashchange', on)
|
||||
}, [])
|
||||
const view = hash === '#compare' ? <CompareView /> : hash === '#review' ? <IntelligenceDashboard /> : <App />
|
||||
|
||||
const view =
|
||||
hash === '#executive' || hash === '#pitch' ? <ExecutiveShowcase /> :
|
||||
hash === '#compare' ? <CompareView /> :
|
||||
hash === '#review' ? <IntelligenceDashboard /> :
|
||||
<App />
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewNav hash={hash} />
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Shield,
|
||||
Compass,
|
||||
Map as MapIcon,
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Layers,
|
||||
Eye,
|
||||
Crosshair,
|
||||
Mountain,
|
||||
Cpu,
|
||||
Zap,
|
||||
Globe,
|
||||
Server,
|
||||
ArrowRight,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Radio,
|
||||
Gauge,
|
||||
TrendingUp,
|
||||
FileText,
|
||||
Flame,
|
||||
Award
|
||||
} from 'lucide-react';
|
||||
|
||||
export const ExecutiveShowcase: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'strategy' | 'tactical' | 'comparison' | 'roadmap'>('strategy');
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
background: 'radial-gradient(ellipse at top, #1e1b4b 0%, #0b0f19 50%, #030712 100%)',
|
||||
color: '#f8fafc',
|
||||
fontFamily: "'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif",
|
||||
direction: 'rtl',
|
||||
paddingTop: 48,
|
||||
paddingBottom: 80,
|
||||
scrollBehavior: 'smooth'
|
||||
}}>
|
||||
{/* Top Military & National Header */}
|
||||
<header style={{
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
background: 'rgba(11, 15, 25, 0.8)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
padding: '16px 24px'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: 1280,
|
||||
margin: '0 auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 16
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 12,
|
||||
background: 'linear-gradient(135deg, #4f46e5, #06b6d4)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 4px 20px rgba(79, 70, 229, 0.4)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)'
|
||||
}}>
|
||||
<Shield size={24} color="#ffffff" />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 800, letterSpacing: '-0.02em', color: '#ffffff' }}>
|
||||
منظومة انطلاق للسيادة المكانية والخرائط التكتيكية
|
||||
</h1>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
background: 'rgba(34, 197, 94, 0.15)',
|
||||
color: '#4ade80',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
🇯🇴 سيادة أردنية 100%
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: '2px 0 0 0', fontSize: '0.8rem', color: '#94a3b8' }}>
|
||||
عرض استراتيجي موجه للمركز الجغرافي الملكي الأردني والقيادة العامة | إعداد: المقدم م. حمزة الغويري
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Action Navigation */}
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<a href="#map" style={{
|
||||
textDecoration: 'none',
|
||||
background: 'linear-gradient(135deg, #6366f1, #4f46e5)',
|
||||
color: '#ffffff',
|
||||
padding: '8px 18px',
|
||||
borderRadius: 10,
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
boxShadow: '0 4px 14px rgba(99, 102, 241, 0.35)'
|
||||
}}>
|
||||
<MapIcon size={16} /> فتح الخريطة التفاعلية
|
||||
</a>
|
||||
<a href="#review" style={{
|
||||
textDecoration: 'none',
|
||||
background: 'rgba(255, 255, 255, 0.06)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||
color: '#cbd5e1',
|
||||
padding: '8px 16px',
|
||||
borderRadius: 10,
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6
|
||||
}}>
|
||||
<Activity size={16} color="#38bdf8" /> لوحة تدقيق الشوارع
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Container */}
|
||||
<main style={{ maxWidth: 1280, margin: '0 auto', padding: '32px 24px' }}>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
background: 'linear-gradient(180deg, rgba(99, 102, 241, 0.08) 0%, rgba(15, 23, 42, 0) 100%)',
|
||||
borderRadius: 24,
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
marginBottom: 40
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
background: 'rgba(99, 102, 241, 0.15)',
|
||||
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||
padding: '6px 16px',
|
||||
borderRadius: 999,
|
||||
color: '#a5b4fc',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 700,
|
||||
marginBottom: 20
|
||||
}}>
|
||||
<Cpu size={16} /> البديل السيادي الكامل لمنظومات إزري (ArcGIS) في الأردن
|
||||
</div>
|
||||
|
||||
<h2 style={{
|
||||
fontSize: 'clamp(1.8rem, 3.5vw, 2.8rem)',
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.3,
|
||||
margin: '0 auto 16px auto',
|
||||
maxWidth: 900,
|
||||
background: 'linear-gradient(135deg, #ffffff 30%, #94a3b8 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent'
|
||||
}}>
|
||||
منصة خرائط وطنية ذكية تتغذى ذاتياً وتولد الشوارع والبيانات المكانية من حركة الأساطيل
|
||||
</h2>
|
||||
|
||||
<p style={{
|
||||
fontSize: '1.05rem',
|
||||
color: '#94a3b8',
|
||||
maxWidth: 820,
|
||||
margin: '0 auto 28px auto',
|
||||
lineHeight: 1.8
|
||||
}}>
|
||||
استثمار استراتيجي مزدوج: تطبيق نقل وخدمات لوجستية ذكي في الواجهة، ومحرك خرائط تكتيكي سيادي في الخلفية، يكتشف الطرق والمعالم تلقائياً في أي دولة أو مسرح عمليات بدون الحاجة لرخص أجنبية أو مسح ميداني بطيء.
|
||||
</p>
|
||||
|
||||
{/* Quick Metrics Bar */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: 16,
|
||||
maxWidth: 1000,
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
<div style={{ background: 'rgba(255, 255, 255, 0.04)', padding: '16px', borderRadius: 14, border: '1px solid rgba(255, 255, 255, 0.08)' }}>
|
||||
<div style={{ fontSize: '1.8rem', fontWeight: 900, color: '#38bdf8' }}>60 FPS</div>
|
||||
<div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: 4 }}>سرعة عرض المتجهات (Martin MVT)</div>
|
||||
</div>
|
||||
<div style={{ background: 'rgba(255, 255, 255, 0.04)', padding: '16px', borderRadius: 14, border: '1px solid rgba(255, 255, 255, 0.08)' }}>
|
||||
<div style={{ fontSize: '1.8rem', fontWeight: 900, color: '#4ade80' }}>100%</div>
|
||||
<div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: 4 }}>استقلالية تامة (Air-Gapped On-Premise)</div>
|
||||
</div>
|
||||
<div style={{ background: 'rgba(255, 255, 255, 0.04)', padding: '16px', borderRadius: 14, border: '1px solid rgba(255, 255, 255, 0.08)' }}>
|
||||
<div style={{ fontSize: '1.8rem', fontWeight: 900, color: '#fbbf24' }}>$0</div>
|
||||
<div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: 4 }}>تكلفة رخص سنوية أو رسوم لكل مستخدم</div>
|
||||
</div>
|
||||
<div style={{ background: 'rgba(255, 255, 255, 0.04)', padding: '16px', borderRadius: 14, border: '1px solid rgba(255, 255, 255, 0.08)' }}>
|
||||
<div style={{ fontSize: '1.8rem', fontWeight: 900, color: '#a78bfa' }}>3 دول</div>
|
||||
<div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: 4 }}>الأردن 🇯🇴 • سوريا 🇸🇾 • مصر 🇪🇬</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 32,
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
{[
|
||||
{ id: 'strategy', label: '1. الرؤية وحجر الأساس (توليد الخرائط)', icon: <TrendingUp size={16} /> },
|
||||
{ id: 'tactical', label: '2. الأدوات التكتيكية والعسكرية (LOS)', icon: <Crosshair size={16} /> },
|
||||
{ id: 'comparison', label: '3. المقارنة القاطعة مع إزري (Esri)', icon: <Layers size={16} /> },
|
||||
{ id: 'roadmap', label: '4. خطة الشراكة مع المركز الجغرافي', icon: <Award size={16} /> },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
style={{
|
||||
background: activeTab === tab.id ? 'linear-gradient(135deg, #4f46e5, #6366f1)' : 'rgba(255, 255, 255, 0.05)',
|
||||
border: activeTab === tab.id ? '1px solid #818cf8' : '1px solid rgba(255, 255, 255, 0.1)',
|
||||
color: activeTab === tab.id ? '#ffffff' : '#94a3b8',
|
||||
padding: '12px 22px',
|
||||
borderRadius: 12,
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: activeTab === tab.id ? '0 4px 16px rgba(79, 70, 229, 0.35)' : 'none'
|
||||
}}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* TAB 1: STRATEGY & SELF-HEALING MAPS */}
|
||||
{activeTab === 'strategy' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div style={{
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: 20,
|
||||
padding: '32px',
|
||||
backdropFilter: 'blur(16px)'
|
||||
}}>
|
||||
<h3 style={{ fontSize: '1.4rem', fontWeight: 800, color: '#38bdf8', margin: '0 0 16px 0', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<TrendingUp size={24} /> حجر الأساس: كيف تحول حركة الأساطيل إلى خريطة سيادية حية؟
|
||||
</h3>
|
||||
<p style={{ color: '#cbd5e1', fontSize: '1rem', lineHeight: 1.8 }}>
|
||||
النموذج التقليدي المتبع لدى إزري والشركات الأجنبية يعتمد على انتظار فرق المسح الميداني أو التقاط صور أقمار صناعية باهظة كل عدة أشهر أو سنوات. في المقابل، تتبنى منصتنا <strong>فلسفة الخريطة الحية ذاتية التغذية والتوليد (Autonomous Self-Healing Map Grid)</strong>:
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||
gap: 20,
|
||||
marginTop: 24
|
||||
}}>
|
||||
<div style={{ background: 'rgba(255, 255, 255, 0.03)', padding: 20, borderRadius: 16, border: '1px solid rgba(56, 189, 248, 0.2)' }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(56, 189, 248, 0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#38bdf8', marginBottom: 12 }}>
|
||||
<Activity size={20} />
|
||||
</div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '1.1rem', color: '#ffffff' }}>1. استيعاب التتبع اللحظي (Telemetry)</h4>
|
||||
<p style={{ fontSize: '0.88rem', color: '#94a3b8', lineHeight: 1.7 }}>
|
||||
المنصة تستوعب مئات آلاف نقاط الموقع من الآليات العسكرية أو أساطيل التوصيل والنقل كل 3 ثوانٍ وتعالجها مكانياً في محرك PostGIS فائق الأداء.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255, 255, 255, 0.03)', padding: 20, borderRadius: 16, border: '1px solid rgba(168, 85, 247, 0.2)' }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(168, 85, 247, 0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#c084fc', marginBottom: 12 }}>
|
||||
<Cpu size={20} />
|
||||
</div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '1.1rem', color: '#ffffff' }}>2. خوارزمية اكتشاف الطرق الجديدة</h4>
|
||||
<p style={{ fontSize: '0.88rem', color: '#94a3b8', lineHeight: 1.7 }}>
|
||||
عندما تتحرك مركبات متعددة في مسار صحراوي أو حي جديد غير مرسوم على الخريطة، يقوم النظام بتوليد طريق مرشح (Candidate Road) وحساب طوله، سرعته، ومعدل الثقة به تلقائياً.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255, 255, 255, 0.03)', padding: 20, borderRadius: 16, border: '1px solid rgba(34, 197, 94, 0.2)' }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(34, 197, 94, 0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#4ade80', marginBottom: 12 }}>
|
||||
<CheckCircle2 size={20} />
|
||||
</div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '1.1rem', color: '#ffffff' }}>3. التدقيق والموافقة ونشر الملاحة فوراً</h4>
|
||||
<p style={{ fontSize: '0.88rem', color: '#94a3b8', lineHeight: 1.7 }}>
|
||||
يستعرض مهندسو المركز الجغرافي الشوارع المكتشفة في لوحة التدقيق (Review Dashboard) بجانب صور الأقمار الصناعية والخرائط السوفيتية، وبضغطة زر واحدة يُحقن الطريق في محرك الملاحة.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Military & Tactical Deployment Expansion */}
|
||||
<div style={{
|
||||
marginTop: 28,
|
||||
padding: '20px',
|
||||
background: 'rgba(99, 102, 241, 0.12)',
|
||||
borderRadius: 14,
|
||||
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16
|
||||
}}>
|
||||
<Globe size={32} color="#818cf8" style={{ flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 800, fontSize: '1rem', color: '#ffffff' }}>
|
||||
الأثر العسكري والتوسعي: العمل في أي مسرح عمليات فورياً
|
||||
</div>
|
||||
<div style={{ fontSize: '0.88rem', color: '#cbd5e1', marginTop: 4, lineHeight: 1.7 }}>
|
||||
إذا تم نشر آليات القوات المسلحة أو الأجهزة الأمنية في أي منطقة حدودية أو دولة مجاورة (مثل جنوب سوريا، غرب العراق، صحراء سيناء)، يكفي تشغيل تطبيق التتبع لتبدأ المنصة فوراً برسم شبكة الطرق والممرات الوعرة ونشرها لباقي التشكيلات في الميدان دون انتظار أي طرف أجنبي!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: TACTICAL & MILITARY TOOLS */}
|
||||
{activeTab === 'tactical' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div style={{
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: 20,
|
||||
padding: '32px',
|
||||
backdropFilter: 'blur(16px)'
|
||||
}}>
|
||||
<h3 style={{ fontSize: '1.4rem', fontWeight: 800, color: '#f59e0b', margin: '0 0 16px 0', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Crosshair size={24} /> قدرات الميدان والتحليل التكتيكي العسكري
|
||||
</h3>
|
||||
<p style={{ color: '#cbd5e1', fontSize: '1rem', lineHeight: 1.8 }}>
|
||||
تم تزويد المنصة بأدوات تحليل تضاريسي مخصصة لخدمة سلاح المدفعية، الاستطلاع، وغرف العمليات والسيطرة المشتركة:
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: 20,
|
||||
marginTop: 24
|
||||
}}>
|
||||
<div style={{ background: 'rgba(255, 255, 255, 0.03)', padding: 22, borderRadius: 16, border: '1px solid rgba(255, 255, 255, 0.08)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div style={{ padding: 8, borderRadius: 8, background: 'rgba(245, 158, 11, 0.2)', color: '#fbbf24' }}>
|
||||
<Eye size={20} />
|
||||
</div>
|
||||
<h4 style={{ margin: 0, fontSize: '1.1rem', color: '#ffffff' }}>تبادل الرؤية وخط النظر (Line of Sight)</h4>
|
||||
</div>
|
||||
<ul style={{ paddingRight: 18, color: '#94a3b8', fontSize: '0.88rem', lineHeight: 1.8, margin: 0 }}>
|
||||
<li>تحديد إمكانية الرؤية المباشرة بين نقطتين (راصد وهدف).</li>
|
||||
<li>كشف وتحديد القمم الجبلية والعوائق الحاجبة للرؤية بدقة المتر.</li>
|
||||
<li>حساب نسبة <strong>الأرض الميتة (Dead Ground)</strong> خلف الحواف.</li>
|
||||
<li>تصحيح انكسار الضوء الجوي التكتيكي وتقوس الأرض (Earth Curvature).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255, 255, 255, 0.03)', padding: 22, borderRadius: 16, border: '1px solid rgba(255, 255, 255, 0.08)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div style={{ padding: 8, borderRadius: 8, background: 'rgba(167, 139, 250, 0.2)', color: '#c084fc' }}>
|
||||
<Shield size={20} />
|
||||
</div>
|
||||
<h4 style={{ margin: 0, fontSize: '1.1rem', color: '#ffffff' }}>حسابات الرماية وزاوية الموقع (Mils)</h4>
|
||||
</div>
|
||||
<ul style={{ paddingRight: 18, color: '#94a3b8', fontSize: '0.88rem', lineHeight: 1.8, margin: 0 }}>
|
||||
<li>حساب زاوية الموقع (Angle of Site) بالميللي العسكري (Artillery Mils).</li>
|
||||
<li>حساب السمت والاتجاه البوصلّي الدقيق (Azimuth / Bearing).</li>
|
||||
<li>مقطع رأسي كامل للارتفاعات فوق مستوى سطح البحر (AMSL).</li>
|
||||
<li>تعديل ارتفاع عين الراصد وارتفاع الهدف حسب نوع الآلية أو البرج.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255, 255, 255, 0.03)', padding: 22, borderRadius: 16, border: '1px solid rgba(255, 255, 255, 0.08)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div style={{ padding: 8, borderRadius: 8, background: 'rgba(56, 189, 248, 0.2)', color: '#38bdf8' }}>
|
||||
<Radio size={20} />
|
||||
</div>
|
||||
<h4 style={{ margin: 0, fontSize: '1.1rem', color: '#ffffff' }}>التشغيل المعزول التام (Air-Gapped Offline)</h4>
|
||||
</div>
|
||||
<ul style={{ paddingRight: 18, color: '#94a3b8', fontSize: '0.88rem', lineHeight: 1.8, margin: 0 }}>
|
||||
<li>نظام كامل يعمل داخل خادم صغير أو جهاز لوحي عسكري داخل الآلية.</li>
|
||||
<li>توجيه وملاحة وحساب مسافات بدون الحاجة لأي اتصال بالإنترنت.</li>
|
||||
<li>حماية تامة من التشويش أو قطع الخدمات السحابية الأجنبية.</li>
|
||||
<li>تشفير مسارات وبيانات التحركات وفق معايير أمنية صارمة.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: DIRECT COMPARISON WITH ESRI ARCGIS */}
|
||||
{activeTab === 'comparison' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div style={{
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: 20,
|
||||
padding: '32px',
|
||||
backdropFilter: 'blur(16px)'
|
||||
}}>
|
||||
<h3 style={{ fontSize: '1.4rem', fontWeight: 800, color: '#ffffff', margin: '0 0 20px 0' }}>
|
||||
مقارنة مباشرة: منصة انطلاق السيادية مقابل إزري (Esri ArcGIS)
|
||||
</h3>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontSize: '0.9rem',
|
||||
textAlign: 'right'
|
||||
}}>
|
||||
<thead>
|
||||
<tr style={{ background: 'rgba(255, 255, 255, 0.06)', borderBottom: '2px solid rgba(255, 255, 255, 0.15)' }}>
|
||||
<th style={{ padding: '14px 18px', color: '#94a3b8' }}>المعيار</th>
|
||||
<th style={{ padding: '14px 18px', color: '#ef4444' }}>منظومة إزري (Esri ArcGIS)</th>
|
||||
<th style={{ padding: '14px 18px', color: '#4ade80', background: 'rgba(34, 197, 94, 0.08)' }}>منصة انطلاق السيادية (Intaleq)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{
|
||||
criteria: 'التكلفة والترخيص السنوي',
|
||||
esri: 'عشرات إلى مئات الآلاف $ سنوياً (تراخيص مستخدمين + استهلاك Credits)',
|
||||
intaleq: 'ملكية سيادية وطنية كاملة $0 رخص سنوية أو رسوم مستخدمين'
|
||||
},
|
||||
{
|
||||
criteria: 'السيادة وسرية البيانات',
|
||||
esri: 'تعتمد على سحابة إزري الأمريكية في البحث والتوجيه وتحديث البيانات',
|
||||
intaleq: 'سيرفرات داخلية 100% داخل المركز الجغرافي أو القيادة العامة (On-Premise)'
|
||||
},
|
||||
{
|
||||
criteria: 'سرعة عرض الخرائط التفاعلية',
|
||||
esri: 'ثقيلة وبطيئة على المتصفحات وتطبيقات الميدان (15-25 FPS)',
|
||||
intaleq: 'فائقة السرعة عبر Vector Tiles ورندرة بكرت الشاشة (60 FPS)'
|
||||
},
|
||||
{
|
||||
criteria: 'تحديث شبكة الطرق',
|
||||
esri: 'يتطلب مسحاً ميدانياً بطيئاً أو شراء مجموعات بيانات دورية باهظة',
|
||||
intaleq: 'تحديث تلقائي لحظي من بيانات تتبع حركة السائقين والآليات'
|
||||
},
|
||||
{
|
||||
criteria: 'البحث بالعربية والمسميات المحلية',
|
||||
esri: 'ضعيف في فهم اللهجة الشعبية، التقسيمات العشائرية والأخطاء الإملائية',
|
||||
intaleq: 'محرك بحث ذكي يفهم اللهجة الأردنية، الاستعلامات النسبية وبوابات المجمعات'
|
||||
},
|
||||
{
|
||||
criteria: 'التكامل مع تطبيقات الموبايل والميدان',
|
||||
esri: 'يحتاج مكتبات SDK ضخمة وتراخيص App Development مدفوعة',
|
||||
intaleq: 'واجهات REST APIs و MapLibre مفتوحة وسهلة الدمج مع أي تطبيق فلاتر أو أندرويد'
|
||||
},
|
||||
{
|
||||
criteria: 'الاستخدام في مسارح عمليات خارج الأردن',
|
||||
esri: 'شراء تراخيص خرائط منفصلة لكل دولة إضافية',
|
||||
intaleq: 'إضافة أي دولة (سوريا، مصر، العراق) بسكربت واحد خلال دقائق معدودة'
|
||||
}
|
||||
].map((row, idx) => (
|
||||
<tr key={idx} style={{
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
background: idx % 2 === 0 ? 'transparent' : 'rgba(255, 255, 255, 0.02)'
|
||||
}}>
|
||||
<td style={{ padding: '14px 18px', fontWeight: 700, color: '#f8fafc' }}>{row.criteria}</td>
|
||||
<td style={{ padding: '14px 18px', color: '#fca5a5' }}>✕ {row.esri}</td>
|
||||
<td style={{ padding: '14px 18px', color: '#86efac', fontWeight: 600, background: 'rgba(34, 197, 94, 0.04)' }}>✓ {row.intaleq}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 4: ROADMAP WITH RJGC */}
|
||||
{activeTab === 'roadmap' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div style={{
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: 20,
|
||||
padding: '32px',
|
||||
backdropFilter: 'blur(16px)'
|
||||
}}>
|
||||
<h3 style={{ fontSize: '1.4rem', fontWeight: 800, color: '#4ade80', margin: '0 0 16px 0', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Award size={24} /> خطة الشراكة والتنفيذ المقترحة مع المركز الجغرافي الملكي
|
||||
</h3>
|
||||
<p style={{ color: '#cbd5e1', fontSize: '1rem', lineHeight: 1.8 }}>
|
||||
لا نطلب من المركز إلغاء ما لديه فجأة، بل نقترح مسار شراكة استراتيجي آمن يبدأ بإثبات الجدارة:
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
marginTop: 24
|
||||
}}>
|
||||
{[
|
||||
{
|
||||
phase: 'المرحلة الأولى: إثبات المفهوم (POC) لمدة 30 يوماً',
|
||||
duration: 'الشهر الأول',
|
||||
desc: 'تنصيب نسخة سريعة داخل خوادم المركز الجغرافي لعرض خرائط المملكة وبلاطات المتجهات ومقارنة سرعتها وأدائها مع خوادم ArcGIS الحالية دون أي التزام مالي.'
|
||||
},
|
||||
{
|
||||
phase: 'المرحلة الثانية: ربط أساطيل التتبع وتفعيل التحديث التلقائي',
|
||||
duration: 'الشهر الثاني - الثالث',
|
||||
desc: 'ربط بيانات تتبع آليات حكومية أو تجارية لبدء اكتشاف الطرق الجديدة تلقائياً وتزويد مهندسي المركز بلوحة مراجعة واعتماد الشوارع.'
|
||||
},
|
||||
{
|
||||
phase: 'المرحلة الثالثة: دمج الأدوات التكتيكية مع القيادة العامة وسلاح المدفعية',
|
||||
duration: 'الشهر الرابع فصاعداً',
|
||||
desc: 'تخصيص محرك تبادل الرؤية (LOS) ومقاطع التضاريس والخرائط غير المتصلة (Offline) ليتم تعميمها على الأجهزة اللوحية الميدانية في القوات المسلحة.'
|
||||
}
|
||||
].map((item, idx) => (
|
||||
<div key={idx} style={{
|
||||
background: 'rgba(255, 255, 255, 0.03)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: 14,
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 16
|
||||
}}>
|
||||
<div style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
background: 'rgba(79, 70, 229, 0.2)',
|
||||
border: '1px solid rgba(79, 70, 229, 0.4)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#a5b4fc',
|
||||
fontWeight: 800,
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||
<h4 style={{ margin: 0, fontSize: '1.05rem', color: '#ffffff' }}>{item.phase}</h4>
|
||||
<span style={{ fontSize: '0.8rem', color: '#38bdf8', background: 'rgba(56, 189, 248, 0.1)', padding: '2px 10px', borderRadius: 999 }}>
|
||||
{item.duration}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: '8px 0 0 0', fontSize: '0.88rem', color: '#94a3b8', lineHeight: 1.7 }}>
|
||||
{item.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live Demo Launcher Banner */}
|
||||
<section style={{
|
||||
marginTop: 40,
|
||||
background: 'linear-gradient(135deg, rgba(79, 70, 229, 0.2) 0%, rgba(6, 182, 212, 0.15) 100%)',
|
||||
border: '1px solid rgba(99, 102, 241, 0.4)',
|
||||
borderRadius: 20,
|
||||
padding: '28px 32px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 20
|
||||
}}>
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 6px 0', fontSize: '1.3rem', fontWeight: 800, color: '#ffffff' }}>
|
||||
هل ترغب في استعراض النظام عملياً الآن؟
|
||||
</h3>
|
||||
<p style={{ margin: 0, color: '#cbd5e1', fontSize: '0.9rem' }}>
|
||||
الخريطة الحية وأدوات التوجيه وتبادل الرؤية والطقس جاهزة وتعمل بالكامل داخل المتصفح.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<a href="#map" style={{
|
||||
textDecoration: 'none',
|
||||
background: '#ffffff',
|
||||
color: '#0f172a',
|
||||
padding: '10px 22px',
|
||||
borderRadius: 10,
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 800,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
boxShadow: '0 4px 14px rgba(255, 255, 255, 0.25)'
|
||||
}}>
|
||||
بدء الديمو التفاعلي <ArrowRight size={16} />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -225,9 +225,9 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
closesRef.current = safeCloses;
|
||||
pushMapData();
|
||||
setNeedsAuth(false);
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
setMsg(e.message || 'Error loading dashboard');
|
||||
setMsg((e as any)?.message || 'Error loading dashboard');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -484,7 +484,7 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
setMsg('Road submitted for intelligence analysis!');
|
||||
cancelDrawing();
|
||||
load();
|
||||
} catch (e: any) { setMsg(e.message || 'Error submitting'); }
|
||||
} catch (e: unknown) { setMsg((e as any)?.message || 'Error submitting'); }
|
||||
setTimeout(() => setMsg(''), 5000);
|
||||
};
|
||||
|
||||
@@ -495,7 +495,7 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
const r = await fetch(`${API}/maps/sync-routes`, { method: 'POST', headers: { 'x-api-key': apiKey } });
|
||||
if (!r.ok) throw new Error('Failed');
|
||||
setMsg('✅ Routing sync requested! Check back in 5-10 min.');
|
||||
} catch (e: any) { setMsg(e.message || 'Error'); }
|
||||
} catch (e: unknown) { setMsg((e as any)?.message || 'Error'); }
|
||||
setTimeout(() => setMsg(''), 5000);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Tactical Elevation & Line of Sight (LOS) Calculation Service
|
||||
* خدمة حساب مقطع الارتفاع التضاريسي وتبادل الرؤية العسكري (Intervisibility)
|
||||
*/
|
||||
|
||||
export interface ElevationPoint {
|
||||
distance: number; // Distance from observer (meters)
|
||||
lat: number;
|
||||
lng: number;
|
||||
elevation: number; // Terrain elevation AMSL (meters)
|
||||
rayHeight: number; // Line of Sight ray elevation at this distance (meters)
|
||||
isVisible: boolean; // Can observer see this terrain point?
|
||||
isTargetRayBlocked: boolean; // Does this terrain point block the ray to the final target?
|
||||
clearance: number; // Clearance distance (rayHeight - elevation) in meters
|
||||
}
|
||||
|
||||
export interface ObstacleInfo {
|
||||
distance: number;
|
||||
elevation: number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
excessHeight: number; // How much the obstacle penetrates above the ray (meters)
|
||||
}
|
||||
|
||||
export interface LineOfSightResult {
|
||||
points: ElevationPoint[];
|
||||
totalDistance: number; // Total distance in meters
|
||||
isDirectlyVisible: boolean; // Is target visible from observer?
|
||||
observerElevation: number; // Ground elevation + observer height
|
||||
targetElevation: number; // Ground elevation + target height
|
||||
observerGroundElev: number; // Raw ground elevation
|
||||
targetGroundElev: number; // Raw ground elevation
|
||||
minElevation: number;
|
||||
maxElevation: number;
|
||||
highestObstacle: ObstacleInfo | null;
|
||||
deadGroundPercentage: number; // % of line hidden behind crests
|
||||
angleDegrees: number; // Vertical angle (degrees)
|
||||
angleMils: number; // Military Artillery Mils (6400 mils = 360 deg)
|
||||
azimuthDegrees: number; // Compass Bearing (0-360 deg)
|
||||
}
|
||||
|
||||
// In-memory cache for DEM tile image data to avoid re-fetching
|
||||
const tileCache = new Map<string, ImageData>();
|
||||
|
||||
/**
|
||||
* Calculates Great-Circle Haversine distance in meters
|
||||
*/
|
||||
export function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371000; // Earth radius in meters
|
||||
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 / Bearing (0-360 degrees)
|
||||
*/
|
||||
export function 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Samples elevation from Terrarium DEM tile or fallback topographic model
|
||||
*/
|
||||
async function sampleElevationAt(lat: number, lng: number): Promise<number> {
|
||||
const zoom = 12;
|
||||
const n = Math.pow(2, zoom);
|
||||
const x = Math.floor(((lng + 180) / 360) * n);
|
||||
const latRad = (lat * Math.PI) / 180;
|
||||
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
|
||||
|
||||
const tileKey = `${zoom}/${x}/${y}`;
|
||||
|
||||
try {
|
||||
let imgData = tileCache.get(tileKey);
|
||||
|
||||
if (!imgData) {
|
||||
const tileUrl = `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/${zoom}/${x}/${y}.png`;
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
const loadPromise = new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (e) => reject(e);
|
||||
img.src = tileUrl;
|
||||
});
|
||||
|
||||
// 1.5s timeout for fast responsiveness
|
||||
const loadedImg = await Promise.race([
|
||||
loadPromise,
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('DEM Timeout')), 1500))
|
||||
]);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(loadedImg, 0, 0);
|
||||
imgData = ctx.getImageData(0, 0, 256, 256);
|
||||
tileCache.set(tileKey, imgData);
|
||||
}
|
||||
}
|
||||
|
||||
if (imgData) {
|
||||
// Calculate exact sub-pixel inside tile
|
||||
const subX = (((lng + 180) / 360) * n - x) * 256;
|
||||
const subY = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n - y) * 256;
|
||||
const px = Math.min(255, Math.max(0, Math.floor(subX)));
|
||||
const py = Math.min(255, Math.max(0, Math.floor(subY)));
|
||||
|
||||
const index = (py * 256 + px) * 4;
|
||||
const r = imgData.data[index];
|
||||
const g = imgData.data[index + 1];
|
||||
const b = imgData.data[index + 2];
|
||||
|
||||
// Terrarium formula: (R * 256 + G + B / 256) - 32768
|
||||
const elev = (r * 256 + g + b / 256) - 32768;
|
||||
return Math.round(elev);
|
||||
}
|
||||
} catch (err) {
|
||||
// Fallback topographic approximation for Jordan/Levant region
|
||||
}
|
||||
|
||||
return getApproximateElevation(lat, lng);
|
||||
}
|
||||
|
||||
/**
|
||||
* Topographic estimation model for Jordan terrain when tiles are offline
|
||||
*/
|
||||
function getApproximateElevation(lat: number, lng: number): number {
|
||||
// Jordan Valley & Dead Sea trench model
|
||||
if (lng < 35.6 && lat < 32.2 && lat > 31.0) {
|
||||
const distFromRift = Math.abs(lng - 35.5);
|
||||
return -400 + distFromRift * 3000;
|
||||
}
|
||||
// Northern Highlands (Ajloun / Jerash / Salt)
|
||||
if (lat >= 32.1 && lng < 36.0) {
|
||||
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 900 + Math.sin((lat - 31.95) * 100) * 120 + Math.cos((lng - 35.9) * 100) * 100;
|
||||
}
|
||||
// Southern Highlands (Karak / Tafilah / Shobak / Petra)
|
||||
if (lat < 31.5 && lat > 30.0 && lng < 35.7) {
|
||||
return 1100 + Math.sin(lat * 30) * 350;
|
||||
}
|
||||
// Eastern Desert (Badia)
|
||||
return 650 + (lng - 36.0) * 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates Line of Sight and Elevation Profile between two coordinates
|
||||
*
|
||||
* @param startLat Observer Latitude
|
||||
* @param startLng Observer Longitude
|
||||
* @param endLat Target Latitude
|
||||
* @param endLng Target Longitude
|
||||
* @param obsHeight Observer Eye Level offset above ground (default: 2 meters)
|
||||
* @param tgtHeight Target Height offset above ground (default: 2 meters)
|
||||
* @param samples Number of sampling steps along the ray (default: 60)
|
||||
*/
|
||||
export async function calculateLineOfSight(
|
||||
startLat: number,
|
||||
startLng: number,
|
||||
endLat: number,
|
||||
endLng: number,
|
||||
obsHeight: number = 2,
|
||||
tgtHeight: number = 2,
|
||||
samples: number = 60
|
||||
): Promise<LineOfSightResult> {
|
||||
const totalDistance = calculateDistance(startLat, startLng, endLat, endLng);
|
||||
const azimuthDegrees = calculateAzimuth(startLat, startLng, endLat, endLng);
|
||||
|
||||
// Try fetching high-precision result from Backend Tactical API
|
||||
try {
|
||||
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
||||
const apiKey = (import.meta as any).env.VITE_API_KEY;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const res = await fetch(`${apiUrl}/tactical/line-of-sight?observerLat=${startLat}&observerLng=${startLng}&targetLat=${endLat}&targetLng=${endLng}&observerHeight=${obsHeight}&targetHeight=${tgtHeight}&samples=${samples}`, {
|
||||
headers: { 'x-api-key': apiKey },
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return {
|
||||
points: (data.profile || []).map((p: any) => ({
|
||||
distance: p.distanceMeters,
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
elevation: p.groundElevationMeters,
|
||||
rayHeight: p.rayElevationMeters,
|
||||
isVisible: p.isVisible,
|
||||
isTargetRayBlocked: p.isTargetRayBlocked,
|
||||
clearance: p.clearanceMeters
|
||||
})),
|
||||
totalDistance: data.summary.totalDistanceMeters,
|
||||
isDirectlyVisible: data.isDirectlyVisible,
|
||||
observerElevation: data.summary.observerTotalElevationMeters,
|
||||
targetElevation: data.summary.targetTotalElevationMeters,
|
||||
observerGroundElev: data.summary.observerGroundElevationMeters,
|
||||
targetGroundElev: data.summary.targetGroundElevationMeters,
|
||||
minElevation: data.summary.minElevationMeters,
|
||||
maxElevation: data.summary.maxElevationMeters,
|
||||
highestObstacle: data.highestObstacle ? {
|
||||
distance: data.highestObstacle.distanceMeters,
|
||||
elevation: data.highestObstacle.groundElevationMeters,
|
||||
lat: data.highestObstacle.lat,
|
||||
lng: data.highestObstacle.lng,
|
||||
excessHeight: data.highestObstacle.excessHeightMeters
|
||||
} : null,
|
||||
deadGroundPercentage: data.summary.deadGroundPercentage,
|
||||
angleDegrees: data.summary.verticalAngleDegrees,
|
||||
angleMils: data.summary.verticalAngleMilsNato,
|
||||
azimuthDegrees: data.summary.azimuthDegrees
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// API not available or timed out, fall back to local DEM tile processing
|
||||
}
|
||||
|
||||
// Generate sample coordinates along the geodesic path
|
||||
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 elevations for all sample points in parallel
|
||||
const elevations = await Promise.all(
|
||||
sampleCoords.map((coord) => sampleElevationAt(coord.lat, coord.lng))
|
||||
);
|
||||
|
||||
const observerGroundElev = elevations[0];
|
||||
const targetGroundElev = elevations[elevations.length - 1];
|
||||
const observerElevation = observerGroundElev + obsHeight;
|
||||
const targetElevation = targetGroundElev + tgtHeight;
|
||||
|
||||
// Earth curvature & atmospheric refraction parameter (k ≈ 0.13 for standard atmosphere)
|
||||
const R_earth = 6371000;
|
||||
const k_refraction = 0.13;
|
||||
const effectiveEarthRadius = R_earth / (1 - k_refraction);
|
||||
|
||||
let isDirectlyVisible = true;
|
||||
let highestObstacle: ObstacleInfo | null = null;
|
||||
let maxObstacleExcess = 0;
|
||||
let deadGroundCount = 0;
|
||||
|
||||
// Horizon angle tracking from observer (tan of highest angle encountered so far)
|
||||
let maxAngleSoFar = -Infinity;
|
||||
|
||||
const points: ElevationPoint[] = [];
|
||||
let minElev = Infinity;
|
||||
let maxElev = -Infinity;
|
||||
|
||||
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 = (d * (totalDistance - d)) / (2 * effectiveEarthRadius);
|
||||
|
||||
// Theoretical straight ray height AMSL connecting Observer to Target
|
||||
const rayHeight = observerElevation + ((targetElevation - observerElevation) * (d / totalDistance)) - earthCurvatureDrop;
|
||||
|
||||
// Clearance (positive = ray above terrain, negative = obstacle)
|
||||
const clearance = rayHeight - elev;
|
||||
|
||||
// Check if this point blocks the direct ray to the target (ignore start and end margins)
|
||||
let isTargetRayBlocked = false;
|
||||
if (i > 1 && i < samples) {
|
||||
if (elev > rayHeight) {
|
||||
isDirectlyVisible = false;
|
||||
isTargetRayBlocked = true;
|
||||
const excess = elev - rayHeight;
|
||||
if (excess > maxObstacleExcess) {
|
||||
maxObstacleExcess = excess;
|
||||
highestObstacle = {
|
||||
distance: Math.round(d),
|
||||
elevation: elev,
|
||||
lat: sampleCoords[i].lat,
|
||||
lng: sampleCoords[i].lng,
|
||||
excessHeight: Math.round(excess * 10) / 10,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check visibility from observer's eye (Viewshed / Shadowing along profile)
|
||||
let isVisible = true;
|
||||
if (i === 0) {
|
||||
isVisible = true;
|
||||
} else {
|
||||
const angleFromObs = (elev - observerElevation) / d;
|
||||
if (angleFromObs >= maxAngleSoFar) {
|
||||
maxAngleSoFar = angleFromObs;
|
||||
isVisible = true;
|
||||
} else {
|
||||
isVisible = false;
|
||||
deadGroundCount++;
|
||||
}
|
||||
}
|
||||
|
||||
points.push({
|
||||
distance: Math.round(d),
|
||||
lat: sampleCoords[i].lat,
|
||||
lng: sampleCoords[i].lng,
|
||||
elevation: elev,
|
||||
rayHeight: Math.round(rayHeight * 10) / 10,
|
||||
isVisible,
|
||||
isTargetRayBlocked,
|
||||
clearance: Math.round(clearance * 10) / 10,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate Vertical Angle (Degrees & Artillery Mils)
|
||||
// 1 Degree = 17.7778 Artillery Mils (6400 Mils in full circle)
|
||||
const verticalDiff = targetElevation - observerElevation;
|
||||
const angleRad = Math.atan2(verticalDiff, totalDistance);
|
||||
const angleDegrees = Math.round((angleRad * (180 / Math.PI)) * 100) / 100;
|
||||
const angleMils = Math.round((angleDegrees * (6400 / 360)) * 10) / 10;
|
||||
|
||||
const deadGroundPercentage = Math.round((deadGroundCount / samples) * 100);
|
||||
|
||||
return {
|
||||
points,
|
||||
totalDistance: Math.round(totalDistance),
|
||||
isDirectlyVisible,
|
||||
observerElevation: Math.round(observerElevation),
|
||||
targetElevation: Math.round(targetElevation),
|
||||
observerGroundElev: Math.round(observerGroundElev),
|
||||
targetGroundElev: Math.round(targetGroundElev),
|
||||
minElevation: Math.round(minElev),
|
||||
maxElevation: Math.round(maxElev),
|
||||
highestObstacle,
|
||||
deadGroundPercentage,
|
||||
angleDegrees,
|
||||
angleMils,
|
||||
azimuthDegrees: Math.round(azimuthDegrees * 10) / 10,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export function getWeatherDescription(code: number): { icon: string, description_ar: string, description_en: string } {
|
||||
switch (code) {
|
||||
case 0: return { icon: '☀️', description_ar: 'صافي', description_en: 'Clear' };
|
||||
case 1: return { icon: '🌤️', description_ar: 'صافي غالباً', description_en: 'Mainly clear' };
|
||||
case 2: return { icon: '⛅', description_ar: 'غائم جزئياً', description_en: 'Partly cloudy' };
|
||||
case 3: return { icon: '☁️', description_ar: 'غائم', description_en: 'Overcast' };
|
||||
case 45:
|
||||
case 48: return { icon: '🌫️', description_ar: 'ضباب', description_en: 'Fog' };
|
||||
case 51:
|
||||
case 53:
|
||||
case 55: return { icon: '🌦️', description_ar: 'رذاذ', description_en: 'Drizzle' };
|
||||
case 61: return { icon: '🌧️', description_ar: 'أمطار خفيفة', description_en: 'Light rain' };
|
||||
case 63: return { icon: '🌧️', description_ar: 'أمطار متوسطة', description_en: 'Moderate rain' };
|
||||
case 65: return { icon: '🌧️', description_ar: 'أمطار غزيرة', description_en: 'Heavy rain' };
|
||||
case 71:
|
||||
case 73:
|
||||
case 75: return { icon: '🌨️', description_ar: 'ثلوج', description_en: 'Snow' };
|
||||
case 80:
|
||||
case 81:
|
||||
case 82: return { icon: '🌧️', description_ar: 'زخات مطرية', description_en: 'Rain showers' };
|
||||
case 95: return { icon: '⛈️', description_ar: 'عاصفة رعدية', description_en: 'Thunderstorm' };
|
||||
case 96:
|
||||
case 99: return { icon: '⛈️', description_ar: 'عاصفة رعدية مع بَرَد', description_en: 'Thunderstorm with hail' };
|
||||
default: return { icon: '🌡️', description_ar: 'غير معروف', description_en: 'Unknown' };
|
||||
}
|
||||
}
|
||||
|
||||
export function getTemperatureColor(temp: number): string {
|
||||
if (temp <= 0) return '#0047AB';
|
||||
if (temp <= 10) return '#4169E1';
|
||||
if (temp <= 20) return '#32CD32';
|
||||
if (temp <= 30) return '#FFD700';
|
||||
if (temp <= 40) return '#FF8C00';
|
||||
if (temp <= 45) return '#FF4500';
|
||||
return '#DC143C';
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
with open("data/boundaries/jordan_adm0.geojson", "r") as f:
|
||||
adm0 = json.load(f)
|
||||
|
||||
with open("data/boundaries/jordan_adm1.geojson", "r") as f:
|
||||
adm1 = json.load(f)
|
||||
|
||||
lines = []
|
||||
lines.append("BEGIN;")
|
||||
lines.append("-- 1. Delete previous dummy borders")
|
||||
lines.append("DELETE FROM planet_osm_line WHERE osm_id >= 999000 AND osm_id <= 999999;")
|
||||
lines.append("DELETE FROM planet_osm_polygon WHERE osm_id >= 999000 AND osm_id <= 999999;")
|
||||
lines.append("")
|
||||
|
||||
# National border (ADM0)
|
||||
geom0_str = json.dumps(adm0["features"][0]["geometry"]).replace("'", "''")
|
||||
lines.append("-- 2. Official Jordan National Boundary (ADM0)")
|
||||
lines.append(f"""INSERT INTO planet_osm_line (osm_id, boundary, admin_level, name, way)
|
||||
SELECT
|
||||
999000,
|
||||
'administrative',
|
||||
'2',
|
||||
'المملكة الأردنية الهاشمية - Jordan Official National Border',
|
||||
ST_Transform(ST_Boundary(ST_SetSRID(ST_GeomFromGeoJSON('{geom0_str}'), 4326)), 3857);
|
||||
""")
|
||||
|
||||
# Governorates (ADM1)
|
||||
for idx, feat in enumerate(adm1["features"]):
|
||||
name = feat["properties"].get("shapeName", f"Governorate {idx}").replace("'", "''")
|
||||
geom_str = json.dumps(feat["geometry"]).replace("'", "''")
|
||||
osm_id = 999010 + idx
|
||||
lines.append(f"""-- Governorate {name}
|
||||
INSERT INTO planet_osm_polygon (osm_id, boundary, admin_level, name, way)
|
||||
SELECT
|
||||
{osm_id},
|
||||
'administrative',
|
||||
'4',
|
||||
'{name}',
|
||||
ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON('{geom_str}'), 4326), 3857);
|
||||
|
||||
INSERT INTO planet_osm_line (osm_id, boundary, admin_level, name, way)
|
||||
SELECT
|
||||
{osm_id},
|
||||
'administrative',
|
||||
'4',
|
||||
'{name}',
|
||||
ST_Transform(ST_Boundary(ST_SetSRID(ST_GeomFromGeoJSON('{geom_str}')), 4326), 3857);
|
||||
""")
|
||||
|
||||
lines.append("COMMIT;")
|
||||
|
||||
with open("data/boundaries/import_official_jordan.sql", "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
print(f"Generated SQL with {len(adm1['features'])} governorates + ADM0 national boundary.")
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# ==============================================================================
|
||||
# Script: fix_exact_border.sh
|
||||
# Purpose: Clean rogue lines and set exact straight border segment:
|
||||
# From (36.839237, 32.311852) to (38.793024, 33.371104)
|
||||
# ==============================================================================
|
||||
|
||||
echo "================================================================="
|
||||
echo "🇯🇴 Applying Exact Jordan-Syria Border Fix..."
|
||||
echo "================================================================="
|
||||
|
||||
if [ -f .env ]; then
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
fi
|
||||
|
||||
DB_USER=${POSTGRES_USER:-postgres}
|
||||
DB_NAME=${POSTGRES_DB:-gis}
|
||||
|
||||
docker exec -i map-db psql -U "$DB_USER" -d "$DB_NAME" << 'EOF'
|
||||
BEGIN;
|
||||
|
||||
-- 1. Remove all dummy / experimental lines previously added
|
||||
DELETE FROM planet_osm_line WHERE osm_id >= 900000;
|
||||
DELETE FROM planet_osm_polygon WHERE osm_id >= 900000;
|
||||
|
||||
-- 2. Remove any rogue lines north of the border line in the desert area
|
||||
-- (Bounding box covering the false upper lines in Syria)
|
||||
DELETE FROM planet_osm_line
|
||||
WHERE boundary = 'administrative'
|
||||
AND admin_level IN ('2', '3', '4', '5')
|
||||
AND ST_Intersects(
|
||||
way,
|
||||
ST_Transform(
|
||||
ST_MakeEnvelope(36.80, 32.35, 38.85, 33.70, 4326),
|
||||
3857
|
||||
)
|
||||
)
|
||||
AND NOT ST_Intersects(
|
||||
way,
|
||||
ST_Transform(
|
||||
ST_MakeEnvelope(35.5, 31.0, 36.80, 33.0, 4326),
|
||||
3857
|
||||
)
|
||||
);
|
||||
|
||||
-- 3. Insert the EXACT official straight border segment from user coordinates:
|
||||
-- Point A: (36.839237, 32.311852) -> Point B: (38.793024, 33.371104)
|
||||
INSERT INTO planet_osm_line (osm_id, boundary, admin_level, name, way)
|
||||
VALUES (
|
||||
999555,
|
||||
'administrative',
|
||||
'2',
|
||||
'Jordan - Syria International Border',
|
||||
ST_Transform(
|
||||
ST_Segmentize(
|
||||
ST_GeomFromText('LINESTRING(36.839237 32.311852, 38.793024 33.371104)', 4326),
|
||||
1000
|
||||
),
|
||||
3857
|
||||
)
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
EOF
|
||||
|
||||
echo "Restarting Martin..."
|
||||
docker compose restart martin
|
||||
|
||||
echo "================================================================="
|
||||
echo "✅ Exact border fixed and applied successfully!"
|
||||
echo "================================================================="
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# ==============================================================================
|
||||
# Script: import_official_boundaries.sh
|
||||
# Purpose: Import and standardize Jordan Official International & Governorate Boundaries
|
||||
# Source: UN geoBoundaries Official ADM0 (National) & ADM1 (12 Governorates)
|
||||
# ==============================================================================
|
||||
|
||||
echo "================================================================="
|
||||
echo "🇯🇴 Importing Official Jordan International & Governorate Boundaries..."
|
||||
echo "================================================================="
|
||||
|
||||
if [ -f .env ]; then
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
fi
|
||||
|
||||
DB_USER=${POSTGRES_USER:-postgres}
|
||||
DB_NAME=${POSTGRES_DB:-gis}
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SQL_FILE="$SCRIPT_DIR/data/boundaries/import_official_jordan.sql"
|
||||
|
||||
if [ ! -f "$SQL_FILE" ]; then
|
||||
echo "Generating SQL from official GeoJSON files..."
|
||||
python3 "$SCRIPT_DIR/data/boundaries/generate_sql.py"
|
||||
fi
|
||||
|
||||
echo "1. Inserting official Jordan National Boundary & 12 Governorates into PostGIS..."
|
||||
docker exec -i map-db psql -U "$DB_USER" -d "$DB_NAME" < "$SQL_FILE"
|
||||
|
||||
echo "2. Restarting Martin Tile Server to apply updated boundary tiles..."
|
||||
docker compose restart martin
|
||||
|
||||
echo "================================================================="
|
||||
echo "✅ Official Jordan Boundaries applied successfully!"
|
||||
echo "Refresh your browser at https://map-saas.intaleqapp.com/#compare"
|
||||
echo "================================================================="
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# ==============================================================================
|
||||
# Script: restore_osm_roads.sh
|
||||
# Purpose: Restore all OSM roads for Jordan & Syria cleanly using osm2pgsql,
|
||||
# then apply the exact border line without deleting any roads!
|
||||
# ==============================================================================
|
||||
|
||||
echo "================================================================="
|
||||
echo "🔄 Restoring OSM Road Network & Setting Official Jordan Border..."
|
||||
echo "================================================================="
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ -f .env ]; then
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
fi
|
||||
|
||||
DB_USER=${POSTGRES_USER:-mapuser}
|
||||
DB_NAME=${POSTGRES_DB:-mapdb}
|
||||
DATA_DIR="./infrastructure/osm-data"
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# Download Jordan PBF if not present or corrupt
|
||||
if [ ! -s "${DATA_DIR}/jordan-latest.osm.pbf" ]; then
|
||||
echo "📥 Downloading Jordan OSM data..."
|
||||
wget -q --show-progress -O "${DATA_DIR}/jordan-latest.osm.pbf" "https://download.geofabrik.de/asia/jordan-latest.osm.pbf"
|
||||
fi
|
||||
|
||||
if [ ! -s "${DATA_DIR}/syria-latest.osm.pbf" ]; then
|
||||
echo "📥 Downloading Syria OSM data..."
|
||||
wget -q --show-progress -O "${DATA_DIR}/syria-latest.osm.pbf" "https://download.geofabrik.de/asia/syria-latest.osm.pbf"
|
||||
fi
|
||||
|
||||
if [ ! -s "${DATA_DIR}/egypt-latest.osm.pbf" ]; then
|
||||
echo "📥 Downloading Egypt OSM data..."
|
||||
wget -q --show-progress -O "${DATA_DIR}/egypt-latest.osm.pbf" "https://download.geofabrik.de/africa/egypt-latest.osm.pbf" || true
|
||||
fi
|
||||
|
||||
if [ ! -s "${DATA_DIR}/iraq-latest.osm.pbf" ]; then
|
||||
echo "📥 Downloading Iraq OSM data..."
|
||||
wget -q --show-progress -O "${DATA_DIR}/iraq-latest.osm.pbf" "https://download.geofabrik.de/asia/iraq-latest.osm.pbf" || true
|
||||
fi
|
||||
|
||||
echo "🚀 Importing Jordan OSM roads into PostGIS..."
|
||||
docker compose --profile import run --rm osm-import \
|
||||
osm2pgsql --create --slim --cache 1000 \
|
||||
--database "$DB_NAME" --host db --user "$DB_USER" \
|
||||
/data/jordan-latest.osm.pbf
|
||||
|
||||
echo "🚀 Importing Syria OSM roads into PostGIS (--append)..."
|
||||
docker compose --profile import run --rm osm-import \
|
||||
osm2pgsql --append --slim --cache 1000 \
|
||||
--database "$DB_NAME" --host db --user "$DB_USER" \
|
||||
/data/syria-latest.osm.pbf
|
||||
|
||||
if [ -s "${DATA_DIR}/egypt-latest.osm.pbf" ]; then
|
||||
echo "🚀 Importing Egypt OSM data (--append)..."
|
||||
docker compose --profile import run --rm osm-import \
|
||||
osm2pgsql --append --slim --cache 1000 \
|
||||
--database "$DB_NAME" --host db --user "$DB_USER" \
|
||||
/data/egypt-latest.osm.pbf || true
|
||||
fi
|
||||
|
||||
if [ -s "${DATA_DIR}/iraq-latest.osm.pbf" ]; then
|
||||
echo "🚀 Importing Iraq OSM data (--append)..."
|
||||
docker compose --profile import run --rm osm-import \
|
||||
osm2pgsql --append --slim --cache 1000 \
|
||||
--database "$DB_NAME" --host db --user "$DB_USER" \
|
||||
/data/iraq-latest.osm.pbf || true
|
||||
fi
|
||||
|
||||
echo "📍 Applying exact straight Jordan-Syria border segment from user coordinates..."
|
||||
docker exec -i map-db psql -U "$DB_USER" -d "$DB_NAME" << 'EOF'
|
||||
-- Insert the official straight border line:
|
||||
-- Point A: (36.839237, 32.311852) -> Point B: (38.793024, 33.371104)
|
||||
DELETE FROM planet_osm_line WHERE name = 'Jordan - Syria Official Border';
|
||||
|
||||
INSERT INTO planet_osm_line (osm_id, boundary, admin_level, name, way)
|
||||
VALUES (
|
||||
-9999991,
|
||||
'administrative',
|
||||
'2',
|
||||
'Jordan - Syria Official Border',
|
||||
ST_Transform(
|
||||
ST_Segmentize(
|
||||
ST_GeomFromText('LINESTRING(36.839237 32.311852, 38.793024 33.371104)', 4326),
|
||||
1000
|
||||
),
|
||||
3857
|
||||
)
|
||||
);
|
||||
EOF
|
||||
|
||||
echo "🔄 Restarting Martin to refresh vector tiles..."
|
||||
docker compose restart martin
|
||||
|
||||
echo "================================================================="
|
||||
echo "✅ All OSM Roads restored and official border applied perfectly!"
|
||||
echo "================================================================="
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# ==============================================================================
|
||||
# Script: revert_boundaries.sh
|
||||
# Purpose: Completely remove custom inserted lines and restore clean original boundaries
|
||||
# ==============================================================================
|
||||
|
||||
echo "================================================================="
|
||||
echo "🔄 Reverting all boundary changes to original clean OSM state..."
|
||||
echo "================================================================="
|
||||
|
||||
if [ -f .env ]; then
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
fi
|
||||
|
||||
DB_USER=${POSTGRES_USER:-postgres}
|
||||
DB_NAME=${POSTGRES_DB:-gis}
|
||||
|
||||
docker exec -i map-db psql -U "$DB_USER" -d "$DB_NAME" << 'EOF'
|
||||
BEGIN;
|
||||
|
||||
-- 1. Remove all custom lines and polygons added
|
||||
DELETE FROM planet_osm_line WHERE osm_id >= 999000;
|
||||
DELETE FROM planet_osm_polygon WHERE osm_id >= 999000;
|
||||
|
||||
-- 2. Restore Jordan & Syria national boundary line directly from the relation polygons
|
||||
INSERT INTO planet_osm_line (osm_id, boundary, admin_level, name, way)
|
||||
SELECT
|
||||
900001,
|
||||
'administrative',
|
||||
'2',
|
||||
p.name,
|
||||
ST_Boundary(p.way)
|
||||
FROM planet_osm_polygon p
|
||||
WHERE p.boundary = 'administrative'
|
||||
AND p.admin_level = '2'
|
||||
AND (p.name ILIKE '%Jordan%' OR p.name ILIKE '%الأردن%' OR p.name ILIKE '%Syria%' OR p.name ILIKE '%سوريا%')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
EOF
|
||||
|
||||
echo "Restarting Martin..."
|
||||
docker compose restart martin
|
||||
|
||||
echo "================================================================="
|
||||
echo "✅ Reverted successfully to original clean map boundaries!"
|
||||
echo "================================================================="
|
||||
+10
-9
@@ -148,17 +148,17 @@
|
||||
"type": "line",
|
||||
"source": "local-osm-polygons",
|
||||
"source-layer": "planet_osm_polygon",
|
||||
"minzoom": 5,
|
||||
"minzoom": 6,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", "boundary", "administrative"],
|
||||
["in", "admin_level", "4", 4, "5", 5]
|
||||
],
|
||||
"paint": {
|
||||
"line-color": "#4f46e5",
|
||||
"line-width": 2.2,
|
||||
"line-dasharray": [4, 2],
|
||||
"line-opacity": 0.9
|
||||
"line-color": "#6366f1",
|
||||
"line-width": 1.4,
|
||||
"line-dasharray": [4, 3],
|
||||
"line-opacity": 0.7
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -166,16 +166,17 @@
|
||||
"type": "line",
|
||||
"source": "local-osm-lines",
|
||||
"source-layer": "planet_osm_line",
|
||||
"minzoom": 5,
|
||||
"minzoom": 6,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", "boundary", "administrative"],
|
||||
["in", "admin_level", "4", 4, "5", 5]
|
||||
],
|
||||
"paint": {
|
||||
"line-color": "#4f46e5",
|
||||
"line-width": 2.2,
|
||||
"line-dasharray": [4, 2]
|
||||
"line-color": "#6366f1",
|
||||
"line-width": 1.4,
|
||||
"line-dasharray": [4, 3],
|
||||
"line-opacity": 0.7
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -29,6 +29,10 @@ rsync -avz --progress -e "ssh -o StrictHostKeyChecking=no" $KEY_FLAG \
|
||||
docker-compose.yml \
|
||||
docker-compose.map2.yml \
|
||||
setup_map2.sh \
|
||||
import_official_boundaries.sh \
|
||||
revert_boundaries.sh \
|
||||
fix_exact_border.sh \
|
||||
restore_osm_roads.sh \
|
||||
style.json \
|
||||
style-dark.json \
|
||||
apps \
|
||||
|
||||
Reference in New Issue
Block a user