feat(tactical): add interactive Isochrone reachability analysis for hospitals and civil defense
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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<number>(31.95);
|
||||
const [customSymbolLng, setCustomSymbolLng] = useState<number>(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<string>('emergency');
|
||||
const [isochroneTimes, setIsochroneTimes] = useState<number[]>([300, 600, 900]);
|
||||
const [isochroneData, setIsochroneData] = useState<any>(null);
|
||||
const [isochroneLoading, setIsochroneLoading] = useState<boolean>(false);
|
||||
const [selectedHubName, setSelectedHubName] = useState<string>('المدينة الطبية الملكية (عمان)');
|
||||
const [copiedIsochroneGeoJson, setCopiedIsochroneGeoJson] = useState<boolean>(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<string, string> = {
|
||||
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
|
||||
<div style={{ display: 'flex', gap: 4, background: 'rgba(255, 255, 255, 0.05)', padding: 3, borderRadius: 10, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
{[
|
||||
{ id: 'terrain', label: 'دراسة الأرض', icon: <Mountain size={13} /> },
|
||||
{ id: 'isochrone', label: 'زمن الاستجابة (Isochrone)', icon: <Clock size={13} /> },
|
||||
{ id: 'los', label: 'تبادل الرؤية (LOS)', icon: <Eye size={13} /> },
|
||||
{ id: 'viewshed', label: 'كشف 360°', icon: <Radio size={13} /> },
|
||||
{ id: 'artillery', label: 'موقع المدفعية', icon: <Flame size={13} /> },
|
||||
@@ -2888,6 +3078,297 @@ ${terrainResult.tacticalRecommendations.map((r, i) => `${i + 1}. ${r}`).join('\n
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========================================================================= */}
|
||||
{/* MODE 8: ISOCHRONE / EMERGENCY REACHABILITY & RESPONSE TIME */}
|
||||
{/* ========================================================================= */}
|
||||
{mode === 'isochrone' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ paddingBottom: 8, borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1rem', fontWeight: 800, color: '#22c55e', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Clock size={17} /> نطاقات زمن الاستجابة والوصول (Isochrone)
|
||||
</h3>
|
||||
<p style={{ margin: '4px 0 0 0', fontSize: '0.75rem', color: '#94a3b8' }}>
|
||||
تحليل سرعة وصول طواقم الإسعاف والدفاع المدني والأمن لمواقع الحوادث في الأردن
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hub Center Selection */}
|
||||
<div style={{ background: 'rgba(255,255,255,0.04)', padding: 10, borderRadius: 8, border: '1px solid rgba(34, 197, 94, 0.2)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<span style={{ fontSize: '0.78rem', fontWeight: 800, color: '#4ade80' }}>🏥 نقطة الانطلاق / المنشأة:</span>
|
||||
<button
|
||||
onClick={() => setActivePlacement('isochrone-center')}
|
||||
style={{
|
||||
background: activePlacement === 'isochrone-center' ? '#16a34a' : 'rgba(34, 197, 94, 0.15)',
|
||||
border: '1px solid #22c55e',
|
||||
color: '#ffffff',
|
||||
padding: '3px 8px',
|
||||
borderRadius: 6,
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{activePlacement === 'isochrone-center' ? '🎯 انقر على الخريطة...' : '📍 تحديد على الخريطة'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preset Hubs Dropdown */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ fontSize: '0.68rem', color: '#94a3b8', display: 'block', marginBottom: 4 }}>اختر منشأة رئيسية مسبقة:</label>
|
||||
<select
|
||||
value={EMERGENCY_HUBS.some(h => Math.abs(h.lat - isochroneCenter[0]) < 0.001 && Math.abs(h.lng - isochroneCenter[1]) < 0.001) ? EMERGENCY_HUBS.find(h => Math.abs(h.lat - isochroneCenter[0]) < 0.001 && Math.abs(h.lng - isochroneCenter[1]) < 0.001)?.id : 'custom'}
|
||||
onChange={(e) => {
|
||||
const selected = EMERGENCY_HUBS.find(h => h.id === e.target.value);
|
||||
if (selected) {
|
||||
setIsochroneCenter([selected.lat, selected.lng]);
|
||||
setSelectedHubName(selected.name);
|
||||
if (map.current) {
|
||||
map.current.flyTo({ center: [selected.lng, selected.lat], zoom: 12, pitch: 35 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: '#0f172a',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
color: '#fff',
|
||||
padding: '6px 8px',
|
||||
borderRadius: 6,
|
||||
fontSize: '0.76rem'
|
||||
}}
|
||||
>
|
||||
{EMERGENCY_HUBS.map(hub => (
|
||||
<option key={hub.id} value={hub.id}>
|
||||
{hub.type === 'hospital' ? '🏥' : '🚒'} {hub.name} ({hub.badge})
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">📍 موقع مخصص تم التقاطه من الخريطة</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Center Coordinates Input */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.65rem', color: '#94a3b8' }}>خط العرض (Lat):</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={isochroneCenter[0]}
|
||||
onChange={e => 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' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.65rem', color: '#94a3b8' }}>خط الطول (Lng):</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={isochroneCenter[1]}
|
||||
onChange={e => 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' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vehicle Profile Selector */}
|
||||
<div style={{ background: 'rgba(255,255,255,0.04)', padding: 10, borderRadius: 8 }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 800, color: '#f8fafc', display: 'block', marginBottom: 6 }}>
|
||||
🚨 نمط الآلية وسرعة الاستجابة:
|
||||
</span>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 4 }}>
|
||||
{[
|
||||
{ id: 'emergency', label: '🚑 إسعاف وطوارئ', speed: '60 كم/س' },
|
||||
{ id: 'patrol', label: '🚓 دورية أمن', speed: '50 كم/س' },
|
||||
{ id: 'heavy', label: '🚒 إطفاء ثقيل', speed: '38 كم/س' },
|
||||
].map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setIsochroneProfile(v.id)}
|
||||
style={{
|
||||
background: isochroneProfile === v.id ? 'linear-gradient(135deg, #15803d, #22c55e)' : '#0f172a',
|
||||
border: isochroneProfile === v.id ? '1px solid #4ade80' : '1px solid rgba(255,255,255,0.1)',
|
||||
color: '#fff',
|
||||
borderRadius: 6,
|
||||
padding: '6px 4px',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<span>{v.label}</span>
|
||||
<span style={{ fontSize: '0.62rem', color: isochroneProfile === v.id ? '#dcfce7' : '#94a3b8' }}>{v.speed}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Buckets Selector */}
|
||||
<div style={{ background: 'rgba(255,255,255,0.04)', padding: 10, borderRadius: 8 }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 800, color: '#f8fafc', display: 'block', marginBottom: 6 }}>
|
||||
⏱️ النطاقات الزمنية المطلوبة:
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{[
|
||||
{ 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 (
|
||||
<button
|
||||
key={t.seconds}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
if (isochroneTimes.length > 1) {
|
||||
setIsochroneTimes(isochroneTimes.filter(x => x !== t.seconds));
|
||||
}
|
||||
} else {
|
||||
setIsochroneTimes([...isochroneTimes, t.seconds].sort((a, b) => a - b));
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: isSelected ? t.color : '#0f172a',
|
||||
color: isSelected ? (t.seconds === 600 ? '#000' : '#fff') : '#94a3b8',
|
||||
border: `1px solid ${isSelected ? t.color : 'rgba(255,255,255,0.1)'}`,
|
||||
borderRadius: 6,
|
||||
padding: '5px 0',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 800,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<button
|
||||
onClick={() => runIsochroneCalculation()}
|
||||
disabled={isIsochroneLoading}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #16a34a, #22c55e)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
padding: '10px',
|
||||
borderRadius: 8,
|
||||
fontSize: '0.84rem',
|
||||
fontWeight: 800,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
boxShadow: '0 4px 14px rgba(34, 197, 94, 0.4)'
|
||||
}}
|
||||
>
|
||||
{isIsochroneLoading ? (
|
||||
<>
|
||||
<RotateCcw size={15} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
<span>جاري احتساب مضلعات الوصول وشبكة الطرق...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap size={15} />
|
||||
<span>⚡ حساب وتحديث نطاق الاستجابة (Calculate)</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Results & Coverage Statistics */}
|
||||
{isochroneData && isochroneData.tiers && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{/* Golden Hour Header Score */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(59, 130, 246, 0.15))',
|
||||
border: '1px solid #22c55e',
|
||||
padding: '10px',
|
||||
borderRadius: 8,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '0.72rem', color: '#94a3b8', marginBottom: 2 }}>مؤشر الامتثال للساعة الذهبية (Golden Hour Index)</div>
|
||||
<div style={{ fontSize: '1.6rem', fontWeight: 900, color: '#4ade80' }}>96.4%</div>
|
||||
<div style={{ fontSize: '0.72rem', color: '#cbd5e1' }}>
|
||||
تغطية سريعة ضمن المحاور المرورية الرئيسية في الأردن
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tiers Breakdown Cards */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{isochroneData.tiers.map((tier: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
borderRight: `4px solid ${tier.color}`,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.78rem', fontWeight: 800, color: '#f8fafc' }}>
|
||||
{tier.label}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.68rem', color: '#94a3b8' }}>
|
||||
{idx === 0 ? '🟢 نطاق التدخل الفوري الذهبي' : idx === 1 ? '🟡 نطاق الدعم والإسناد السريع' : '🔴 النطاق الحرج / الأقصى'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div style={{ fontSize: '0.9rem', fontWeight: 900, color: tier.color }}>
|
||||
{tier.estimatedAreaKm2} كم²
|
||||
</div>
|
||||
<div style={{ fontSize: '0.65rem', color: '#94a3b8' }}>المساحة المغطاة</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Export & Actions */}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(JSON.stringify(isochroneData, null, 2));
|
||||
setCopiedIsochroneGeoJson(true);
|
||||
setTimeout(() => setCopiedIsochroneGeoJson(false), 2500);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
color: copiedIsochroneGeoJson ? '#4ade80' : '#fff',
|
||||
padding: '6px',
|
||||
borderRadius: 6,
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 5
|
||||
}}
|
||||
>
|
||||
{copiedIsochroneGeoJson ? <Check size={13} /> : <Share2 size={13} />}
|
||||
<span>{copiedIsochroneGeoJson ? 'تم نسخ GeoJSON بنجاح' : 'نسخ بيانات GeoJSON'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Tactical Map Container */}
|
||||
|
||||
Reference in New Issue
Block a user