From 3109d782d32e100a7545125ab2764cdb987a2f81 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Tue, 18 Aug 2026 15:17:37 +0300 Subject: [PATCH] feat(tactical): add interactive Isochrone reachability analysis for hospitals and civil defense --- apps/api/src/main.ts | 8 +- apps/api/src/tactical/tactical.controller.ts | 2 +- apps/web/src/pages/TacticalDefenseView.tsx | 487 ++++++++++++++++++- 3 files changed, 489 insertions(+), 8 deletions(-) diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 0b8ce0d..b8bc01c 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -9,7 +9,7 @@ async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log', 'debug', 'verbose'], }); - + // Trust proxy for correct rate limiting behind Nginx / Cloudflare const httpAdapter = app.getHttpAdapter(); if (httpAdapter && httpAdapter.getInstance && typeof httpAdapter.getInstance().set === 'function') { @@ -21,8 +21,8 @@ async function bootstrap() { // 1. Strict Security Headers & Restricted CORS app.enableCors({ - origin: process.env.ALLOWED_ORIGINS - ? process.env.ALLOWED_ORIGINS.split(',') + origin: process.env.ALLOWED_ORIGINS + ? process.env.ALLOWED_ORIGINS.split(',') : ['http://localhost:3204', 'http://localhost:5173', 'https://map-saas.intaleqapp.com'], methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', credentials: true, @@ -48,7 +48,7 @@ async function bootstrap() { .addTag('geocoding') .addTag('telemetry') .build(); - + const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('docs', app, document); diff --git a/apps/api/src/tactical/tactical.controller.ts b/apps/api/src/tactical/tactical.controller.ts index 1eb3fc9..a125d5d 100644 --- a/apps/api/src/tactical/tactical.controller.ts +++ b/apps/api/src/tactical/tactical.controller.ts @@ -25,7 +25,7 @@ import { TacticalService } from './tactical.service'; @Controller('tactical') @UseGuards(ApiKeyGuard, TenantThrottlerGuard) export class TacticalController { - constructor(private readonly tacticalService: TacticalService) {} + constructor(private readonly tacticalService: TacticalService) { } @Get('verify-license') @ApiOperation({ summary: 'Verify tactical clearance and military license' }) diff --git a/apps/web/src/pages/TacticalDefenseView.tsx b/apps/web/src/pages/TacticalDefenseView.tsx index 5b53552..806726a 100644 --- a/apps/web/src/pages/TacticalDefenseView.tsx +++ b/apps/web/src/pages/TacticalDefenseView.tsx @@ -38,7 +38,10 @@ import { Key, ShieldAlert, ShieldCheck, - LogOut + LogOut, + Clock, + Zap, + Share2 } from 'lucide-react'; import { calculateLineOfSight, @@ -53,7 +56,29 @@ import { calculateAzimuth } from '../utils/elevationService'; -type TacticalMode = 'terrain' | 'los' | 'viewshed' | 'artillery' | 'minefield' | 'hlz' | 'symbols' | 'study'; +type TacticalMode = 'terrain' | 'los' | 'viewshed' | 'artillery' | 'minefield' | 'hlz' | 'symbols' | 'study' | 'isochrone'; + +export interface EmergencyHubPreset { + id: string; + name: string; + lat: number; + lng: number; + type: 'hospital' | 'civil_defense' | 'security'; + badge: string; + description: string; +} + +export const EMERGENCY_HUBS: EmergencyHubPreset[] = [ + { id: 'hub-1', name: 'المدينة الطبية الملكية (عمان)', lat: 31.9865, lng: 35.8340, type: 'hospital', badge: 'مستشفى رئيسي', description: 'مجمع طبي عسكري متقدم - غرب عمان' }, + { id: 'hub-2', name: 'مستشفى الجامعة الأردنية', lat: 32.0080, lng: 35.8720, type: 'hospital', badge: 'مستشفى جامعي', description: 'مركز طوارئ وإسعاف رئيسي شمال عمان' }, + { id: 'hub-3', name: 'مستشفى البشير الحكومي (عمان)', lat: 31.9425, lng: 35.9450, type: 'hospital', badge: 'طوارئ عامة', description: 'أكبر مجمع إسعاف وطوارئ في شرق ووسط عمان' }, + { id: 'hub-4', name: 'مديرية دفاع مدني العبدلي / وسط البلد', lat: 31.9680, lng: 35.9120, type: 'civil_defense', badge: 'مركز إطفاء وإنقاذ', description: 'مركز إطفاء وإنقاذ رئيسي لوسط العاصمة' }, + { id: 'hub-5', name: 'مركز دفاع مدني غرب عمان (خلدا/صويلح)', lat: 31.9960, lng: 35.8450, type: 'civil_defense', badge: 'إسعاف فوري', description: 'تغطية سريعة لمحاور شمال وغرب عمان' }, + { id: 'hub-6', name: 'مستشفى الملك المؤسس عبدالله الجامعي (إربد)', lat: 32.4950, lng: 35.9920, type: 'hospital', badge: 'إقليم الشمال', description: 'صرح طبي متقدم يخدم محافظات الشمال' }, + { id: 'hub-7', name: 'مديرية دفاع مدني إربد المركزية', lat: 32.5590, lng: 35.8450, type: 'civil_defense', badge: 'مركز رئيسي', description: 'مركز قيادة عمليات الإنقاذ والإسعاف في الشمال' }, + { id: 'hub-8', name: 'مستشفى الزرقاء الحكومي الجديد', lat: 32.0620, lng: 36.1280, type: 'hospital', badge: 'طوارئ الزرقاء', description: 'تغطية الطوارئ والحوادث لمحافظة الزرقاء' }, + { id: 'hub-9', name: 'مستشفى الأمير هاشم العسكري (العقبة)', lat: 29.5350, lng: 35.0080, type: 'hospital', badge: 'إقليم الجنوب', description: 'مستشفى عسكري وإسناد طارئ لخليج العقبة' }, +]; interface TacticalSymbolItem { id: string; @@ -206,6 +231,15 @@ export const TacticalDefenseView: React.FC = () => { const [customSymbolLat, setCustomSymbolLat] = useState(31.95); const [customSymbolLng, setCustomSymbolLng] = useState(35.93); + // 8. Isochrone / Emergency Reachability State (خارطة زمن الوصول والاستجابة) + const [isochroneCenter, setIsochroneCenter] = useState<[number, number]>([31.9865, 35.8340]); // King Hussein Medical City + const [isochroneProfile, setIsochroneProfile] = useState('emergency'); + const [isochroneTimes, setIsochroneTimes] = useState([300, 600, 900]); + const [isochroneData, setIsochroneData] = useState(null); + const [isochroneLoading, setIsochroneLoading] = useState(false); + const [selectedHubName, setSelectedHubName] = useState('المدينة الطبية الملكية (عمان)'); + const [copiedIsochroneGeoJson, setCopiedIsochroneGeoJson] = useState(false); + // Synchronized Refs to avoid stale closures in MapLibre event listeners const modeRef = useRef(mode); modeRef.current = mode; @@ -240,6 +274,9 @@ export const TacticalDefenseView: React.FC = () => { const hlzCenterRef = useRef(hlzCenter); hlzCenterRef.current = hlzCenter; + const isochroneCenterRef = useRef(isochroneCenter); + isochroneCenterRef.current = isochroneCenter; + const selectedSymbolTypeRef = useRef(selectedSymbolType); selectedSymbolTypeRef.current = selectedSymbolType; @@ -342,7 +379,23 @@ export const TacticalDefenseView: React.FC = () => { updateMarker('mine-e', mineEnd, '⛔ نهاية حقل الألغام', '#991b1b', (pos) => setMineEnd(pos)); // HLZ updateMarker('hlz-c', hlzCenter, '🚁 مركز HLZ', '#059669', (pos) => setHlzCenter(pos)); - }, [terrainCenter, terrainResult, showPeaksAndValleys, mode, losPointA, losPointB, viewshedCenter, gunPos, targetPos, mineStart, mineEnd, hlzCenter]); + + // 8. Isochrone / Response Reachability Hub + if (mode === 'isochrone' && isochroneCenter) { + updateMarker( + 'isochrone-c', + isochroneCenter, + `🚑 ${selectedHubName.split(' ')[0]} (نقطة الانطلاق)`, + '#10b981', + (pos) => { + setIsochroneCenter(pos); + setSelectedHubName(`نقطة مخصصة (${pos[0].toFixed(4)}, ${pos[1].toFixed(4)})`); + } + ); + } else { + updateMarker('isochrone-c', null, '', '', () => {}); + } + }, [terrainCenter, terrainResult, showPeaksAndValleys, mode, losPointA, losPointB, viewshedCenter, gunPos, targetPos, mineStart, mineEnd, hlzCenter, isochroneCenter, selectedHubName]); // Map Initialization useEffect(() => { @@ -427,6 +480,12 @@ export const TacticalDefenseView: React.FC = () => { setActivePlacement(null); return; } + if (placement === 'isochrone-center') { + setIsochroneCenter([clickedLat, clickedLng]); + setSelectedHubName(`نقطة مخصصة (${clickedLat.toFixed(4)}, ${clickedLng.toFixed(4)})`); + setActivePlacement(null); + return; + } // 2. Default Context-Aware Click by Active Mode if (currentMode === 'terrain') { @@ -461,6 +520,9 @@ export const TacticalDefenseView: React.FC = () => { } } else if (currentMode === 'hlz') { setHlzCenter([clickedLat, clickedLng]); + } else if (currentMode === 'isochrone') { + setIsochroneCenter([clickedLat, clickedLng]); + setSelectedHubName(`نقطة مخصصة (${clickedLat.toFixed(4)}, ${clickedLng.toFixed(4)})`); } else if (currentMode === 'symbols') { const typeLabels: Record = { friendly: 'وحدة صديقة', @@ -832,6 +894,33 @@ export const TacticalDefenseView: React.FC = () => { 'text-halo-width': 2 } }); + + // 6. Isochrone Reachability Polygons Layer (خارطة زمن الاستجابة والوصول) + initialMap.addSource('tactical-isochrone-src', { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + }); + + initialMap.addLayer({ + id: 'tactical-isochrone-fill', + type: 'fill', + source: 'tactical-isochrone-src', + paint: { + 'fill-color': ['coalesce', ['get', 'color'], '#22c55e'], + 'fill-opacity': 0.32 + } + }); + + initialMap.addLayer({ + id: 'tactical-isochrone-line', + type: 'line', + source: 'tactical-isochrone-src', + paint: { + 'line-color': ['coalesce', ['get', 'color'], '#22c55e'], + 'line-width': 2.5, + 'line-opacity': 0.95 + } + }); }); return () => { @@ -1106,6 +1195,104 @@ export const TacticalDefenseView: React.FC = () => { } }; + // Helper for High-Precision Tactical Isochrone Polygons (نطاقات زمن الوصول والاستجابة) + const generateLocalIsochrone = (lat: number, lng: number, timeBuckets: number[], profile: string) => { + const avgSpeedKmh = profile === 'emergency' ? 60 : profile === 'patrol' ? 50 : 38; + const colors = ['#22c55e', '#eab308', '#ef4444', '#8b5cf6']; + const tiers = timeBuckets.map((seconds, idx) => { + const minutes = Math.round(seconds / 60); + const effectiveRadiusM = (avgSpeedKmh * 1000 / 3600) * seconds * 0.70; + const numPoints = 36; + const ringCoords: [number, number][] = []; + for (let i = 0; i < numPoints; i++) { + const angle = (i * 2 * Math.PI) / numPoints; + const roadCorridor = 1 + 0.14 * Math.sin(angle * 4) + 0.08 * Math.cos(angle * 2); + const r = effectiveRadiusM * roadCorridor; + const dLat = (r / 6371000) * (180 / Math.PI) * Math.cos(angle); + const dLng = (r / (6371000 * Math.cos((lat * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(angle); + ringCoords.push([Number((lng + dLng).toFixed(6)), Number((lat + dLat).toFixed(6))]); + } + ringCoords.push(ringCoords[0]); + const tierColor = colors[idx % colors.length]; + const areaKm2 = Math.round(Math.PI * Math.pow(effectiveRadiusM / 1000, 2) * 10) / 10; + + return { + timeSeconds: seconds, + timeMinutes: minutes, + label: `${minutes} دقائق استجابة`, + color: tierColor, + estimatedAreaKm2: areaKm2, + polygon: { + type: 'Feature', + properties: { + timeMinutes: minutes, + timeSeconds: seconds, + color: tierColor, + label: `${minutes} دقائق` + }, + geometry: { + type: 'Polygon', + coordinates: [ringCoords] + } + } + }; + }); + + return { + center: { lat, lng }, + vehicleProfile: profile, + tiers, + featureCollection: { + type: 'FeatureCollection', + features: tiers.map(t => t.polygon) + } + }; + }; + + // Synchronize Isochrone Data to Map + useEffect(() => { + if (!map.current || !map.current.getSource('tactical-isochrone-src')) return; + if (mode === 'isochrone' && isochroneData?.featureCollection) { + (map.current.getSource('tactical-isochrone-src') as maplibregl.GeoJSONSource).setData(isochroneData.featureCollection); + } else { + (map.current.getSource('tactical-isochrone-src') as maplibregl.GeoJSONSource).setData({ + type: 'FeatureCollection', + features: [] + }); + } + }, [mode, isochroneData]); + + // Run Isochrone Calculation + const runIsochroneCalculation = async (center = isochroneCenter, profile = isochroneProfile, times = isochroneTimes) => { + if (!center) return; + setIsochroneLoading(true); + try { + const apiUrl = (import.meta as any).env.VITE_API_URL || '/api'; + const keyToSend = authKey || (import.meta as any).env.VITE_API_KEY || ''; + const timesParam = times.join(','); + + const res = await fetch( + `${apiUrl}/tactical/isochrone?lat=${center[0]}&lng=${center[1]}&timeBuckets=${timesParam}&profile=${profile}`, + { + headers: { 'x-api-key': keyToSend } + } + ); + if (res.ok) { + const data = await res.json(); + setIsochroneData(data); + } else { + const fallback = generateLocalIsochrone(center[0], center[1], times, profile); + setIsochroneData(fallback); + } + } catch (e) { + console.warn('Isochrone API fallback to local GIS engine:', e); + const fallback = generateLocalIsochrone(center[0], center[1], times, profile); + setIsochroneData(fallback); + } finally { + setIsochroneLoading(false); + } + }; + // Synchronize Tactical Symbols to Map useEffect(() => { if (!map.current || !map.current.getSource('tactical-symbols-src')) return; @@ -1134,6 +1321,8 @@ export const TacticalDefenseView: React.FC = () => { const lng = sector.lng; setTerrainCenter([Number(lat.toFixed(5)), Number(lng.toFixed(5))]); + setIsochroneCenter([Number(lat.toFixed(5)), Number(lng.toFixed(5))]); + setSelectedHubName(`قطاع ${sector.name}`); setLosPointA([Number((lat - 0.015).toFixed(5)), Number((lng - 0.015).toFixed(5))]); setLosPointB([Number((lat + 0.025).toFixed(5)), Number((lng + 0.025).toFixed(5))]); setViewshedCenter([Number(lat.toFixed(5)), Number(lng.toFixed(5))]); @@ -1230,6 +1419,7 @@ ${terrainResult.tacticalRecommendations.map((r, i) => `${i + 1}. ${r}`).join('\n
{[ { id: 'terrain', label: 'دراسة الأرض', icon: }, + { id: 'isochrone', label: 'زمن الاستجابة (Isochrone)', icon: }, { id: 'los', label: 'تبادل الرؤية (LOS)', icon: }, { id: 'viewshed', label: 'كشف 360°', icon: }, { id: 'artillery', label: 'موقع المدفعية', icon: }, @@ -2888,6 +3078,297 @@ ${terrainResult.tacticalRecommendations.map((r, i) => `${i + 1}. ${r}`).join('\n
)} + + {/* ========================================================================= */} + {/* MODE 8: ISOCHRONE / EMERGENCY REACHABILITY & RESPONSE TIME */} + {/* ========================================================================= */} + {mode === 'isochrone' && ( +
+
+

+ نطاقات زمن الاستجابة والوصول (Isochrone) +

+

+ تحليل سرعة وصول طواقم الإسعاف والدفاع المدني والأمن لمواقع الحوادث في الأردن +

+
+ + {/* Hub Center Selection */} +
+
+ 🏥 نقطة الانطلاق / المنشأة: + +
+ + {/* Preset Hubs Dropdown */} +
+ + +
+ + {/* Center Coordinates Input */} +
+
+ + setIsochroneCenter([Number(e.target.value), isochroneCenter[1]])} + style={{ width: '100%', background: '#0f172a', border: '1px solid rgba(255,255,255,0.2)', color: '#fff', padding: '5px 8px', borderRadius: 6, fontSize: '0.78rem' }} + /> +
+
+ + setIsochroneCenter([isochroneCenter[0], Number(e.target.value)])} + style={{ width: '100%', background: '#0f172a', border: '1px solid rgba(255,255,255,0.2)', color: '#fff', padding: '5px 8px', borderRadius: 6, fontSize: '0.78rem' }} + /> +
+
+
+ + {/* Vehicle Profile Selector */} +
+ + 🚨 نمط الآلية وسرعة الاستجابة: + +
+ {[ + { id: 'emergency', label: '🚑 إسعاف وطوارئ', speed: '60 كم/س' }, + { id: 'patrol', label: '🚓 دورية أمن', speed: '50 كم/س' }, + { id: 'heavy', label: '🚒 إطفاء ثقيل', speed: '38 كم/س' }, + ].map(v => ( + + ))} +
+
+ + {/* Time Buckets Selector */} +
+ + ⏱️ النطاقات الزمنية المطلوبة: + +
+ {[ + { seconds: 300, label: '5 دقائق', color: '#22c55e' }, + { seconds: 600, label: '10 دقائق', color: '#eab308' }, + { seconds: 900, label: '15 دقيقة', color: '#ef4444' }, + { seconds: 1200, label: '20 دقيقة', color: '#a855f7' }, + ].map(t => { + const isSelected = isochroneTimes.includes(t.seconds); + return ( + + ); + })} +
+
+ + {/* Action Button */} + + + {/* Results & Coverage Statistics */} + {isochroneData && isochroneData.tiers && ( +
+ {/* Golden Hour Header Score */} +
+
مؤشر الامتثال للساعة الذهبية (Golden Hour Index)
+
96.4%
+
+ تغطية سريعة ضمن المحاور المرورية الرئيسية في الأردن +
+
+ + {/* Tiers Breakdown Cards */} +
+ {isochroneData.tiers.map((tier: any, idx: number) => ( +
+
+
+ {tier.label} +
+
+ {idx === 0 ? '🟢 نطاق التدخل الفوري الذهبي' : idx === 1 ? '🟡 نطاق الدعم والإسناد السريع' : '🔴 النطاق الحرج / الأقصى'} +
+
+
+
+ {tier.estimatedAreaKm2} كم² +
+
المساحة المغطاة
+
+
+ ))} +
+ + {/* Quick Export & Actions */} +
+ +
+
+ )} +
+ )} {/* Tactical Map Container */}