fix(tactical): resolve DTO types and TS1272 build error for isolatedModules

This commit is contained in:
Hamza-Ayed
2026-08-18 09:46:04 +03:00
parent bd287d155b
commit 941420f5c0
86 changed files with 3614 additions and 1556 deletions
+9 -6
View File
@@ -5,6 +5,7 @@ import App from './App.tsx'
import CompareView from './pages/CompareView'
import IntelligenceDashboard from './pages/IntelligenceDashboard'
import { ExecutiveShowcase } from './pages/ExecutiveShowcase'
import { TacticalDefenseView } from './pages/TacticalDefenseView'
import './index.css'
maplibregl.setRTLTextPlugin(
@@ -15,14 +16,15 @@ maplibregl.setRTLTextPlugin(
// Lightweight hash router
const NAV = [
{ hash: '#executive', label: 'العرض الاستراتيجي', icon: '🎖️' },
{ hash: '#map', label: 'الخريطة الحية', icon: '🗺️' },
{ hash: '#tactical', label: 'المنظومة التكتيكية العسكرية', icon: '🎖️' },
{ hash: '#map', label: 'الخريطة والطقس', icon: '🗺️' },
{ hash: '#review', label: 'تدقيق الطرق', icon: '🔍' },
{ hash: '#compare', label: 'المقارنة', icon: '⚖️' },
{ hash: '#compare', label: 'المقارنة المرجعية', icon: '⚖️' },
{ hash: '#executive', label: 'العرض الاستراتيجي', icon: '🏛️' },
]
function ViewNav({ hash }: { hash: string }) {
const currentHash = hash || '#map'
const currentHash = hash || '#tactical'
return (
<div style={{
position: 'fixed',
@@ -72,14 +74,15 @@ function ViewNav({ hash }: { hash: string }) {
}
function Root() {
const [hash, setHash] = useState(window.location.hash || '#map')
const [hash, setHash] = useState(window.location.hash || '#tactical')
useEffect(() => {
const on = () => setHash(window.location.hash || '#map')
const on = () => setHash(window.location.hash || '#tactical')
window.addEventListener('hashchange', on)
return () => window.removeEventListener('hashchange', on)
}, [])
const view =
hash === '#tactical' || hash === '#military' ? <TacticalDefenseView /> :
hash === '#executive' || hash === '#pitch' ? <ExecutiveShowcase /> :
hash === '#compare' ? <CompareView /> :
hash === '#review' ? <IntelligenceDashboard /> :
+913
View File
@@ -0,0 +1,913 @@
import React, { useState, useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import {
Shield,
Crosshair,
Eye,
Radio,
Compass,
Mountain,
AlertTriangle,
CheckCircle2,
XCircle,
Layers,
Save,
Trash2,
Navigation,
Flame,
Activity,
Plus,
Play,
RotateCcw,
Zap,
ChevronDown
} from 'lucide-react';
import { calculateLineOfSight, calculateRadialViewshed, LineOfSightResult, Viewshed360Result } from '../utils/elevationService';
type TacticalMode = 'los' | 'viewshed' | 'artillery' | 'hlz' | 'symbols';
interface TacticalSymbolItem {
id: string;
type: 'friendly' | 'hostile' | 'artillery' | 'radar' | 'checkpoint' | 'minefield' | 'op';
name: string;
lat: number;
lng: number;
}
const DEFAULT_SYMBOLS: TacticalSymbolItem[] = [
{ id: 'sym-1', type: 'friendly', name: 'كتيبة المشاة الآلية 4', lat: 31.98, lng: 35.88 },
{ id: 'sym-2', type: 'artillery', name: 'مربض مدفعية م109', lat: 31.93, lng: 35.91 },
{ id: 'sym-3', type: 'radar', name: 'رادار كشف أرضي تكتيكي', lat: 32.02, lng: 35.84 },
{ id: 'sym-4', type: 'checkpoint', name: 'نقطة سيطرة وغلق أمني', lat: 31.95, lng: 35.95 },
{ id: 'sym-5', type: 'op', name: 'نقطة مراقبة واستطلاع أمامي', lat: 32.06, lng: 35.81 },
];
export const TacticalDefenseView: React.FC = () => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(null);
const [mode, setMode] = useState<TacticalMode>('los');
const [cursorPos, setCursorPos] = useState({ lat: 31.95, lng: 35.93, elev: 850 });
// Line of Sight State
const [losPointA, setLosPointA] = useState<[number, number] | null>([31.9539, 35.9106]);
const [losPointB, setLosPointB] = useState<[number, number] | null>([32.0125, 35.8540]);
const [losObsHeight, setLosObsHeight] = useState<number>(2);
const [losTgtHeight, setLosTgtHeight] = useState<number>(2);
const [losResult, setLosResult] = useState<LineOfSightResult | null>(null);
const [losLoading, setLosLoading] = useState<boolean>(false);
// Viewshed State
const [viewshedCenter, setViewshedCenter] = useState<[number, number] | null>([31.9800, 35.8800]);
const [viewshedRadius, setViewshedRadius] = useState<number>(5000);
const [viewshedHeight, setViewshedHeight] = useState<number>(10);
const [viewshedResult, setViewshedResult] = useState<Viewshed360Result | null>(null);
const [viewshedLoading, setViewshedLoading] = useState<boolean>(false);
// Artillery Fire Mission State
const [gunPos, setGunPos] = useState<[number, number] | null>([31.9300, 35.9100]);
const [targetPos, setTargetPos] = useState<[number, number] | null>([32.0400, 35.8200]);
const [caliber, setCaliber] = useState<string>('155mm Howitzer (M109)');
const [muzzleVel, setMuzzleVel] = useState<number>(827);
const [artilleryResult, setArtilleryResult] = useState<any | null>(null);
const [artilleryLoading, setArtilleryLoading] = useState<boolean>(false);
// HLZ State
const [hlzCenter, setHlzCenter] = useState<[number, number] | null>([31.9600, 35.9000]);
const [hlzResult, setHlzResult] = useState<any | null>(null);
const [hlzLoading, setHlzLoading] = useState<boolean>(false);
// Tactical Symbols State
const [symbols, setSymbols] = useState<TacticalSymbolItem[]>(DEFAULT_SYMBOLS);
const [selectedSymbolType, setSelectedSymbolType] = useState<string>('friendly');
const [newSymbolName, setNewSymbolName] = useState<string>('');
// Map Initialization
useEffect(() => {
if (map.current) return;
if (!mapContainer.current) return;
const initialMap = new maplibregl.Map({
container: mapContainer.current,
style: '/style-dark.json',
center: [35.9106, 31.9539],
zoom: 11,
pitch: 35,
attributionControl: false
});
map.current = initialMap;
initialMap.on('mousemove', (e) => {
setCursorPos({
lat: Number(e.lngLat.lat.toFixed(5)),
lng: Number(e.lngLat.lng.toFixed(5)),
elev: 820 + Math.round(Math.sin(e.lngLat.lat * 20) * 150)
});
});
initialMap.on('click', (e) => {
const clickedLat = e.lngLat.lat;
const clickedLng = e.lngLat.lng;
// Handle map clicks based on active mode
if (mode === 'los') {
if (!losPointA) {
setLosPointA([clickedLat, clickedLng]);
} else if (!losPointB) {
setLosPointB([clickedLat, clickedLng]);
} else {
setLosPointA([clickedLat, clickedLng]);
setLosPointB(null);
}
} else if (mode === 'viewshed') {
setViewshedCenter([clickedLat, clickedLng]);
} else if (mode === 'artillery') {
if (!gunPos) {
setGunPos([clickedLat, clickedLng]);
} else if (!targetPos) {
setTargetPos([clickedLat, clickedLng]);
} else {
setTargetPos([clickedLat, clickedLng]);
}
} else if (mode === 'hlz') {
setHlzCenter([clickedLat, clickedLng]);
} else if (mode === 'symbols') {
const newSym: TacticalSymbolItem = {
id: `sym-${Date.now()}`,
type: selectedSymbolType as any,
name: newSymbolName || `نقطة ${selectedSymbolType}`,
lat: clickedLat,
lng: clickedLng
};
setSymbols(prev => [...prev, newSym]);
setNewSymbolName('');
}
});
initialMap.on('load', () => {
// Add Sources and Layers for Tactical Defense Visualization
initialMap.addSource('tactical-los-src', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
initialMap.addLayer({
id: 'tactical-los-line',
type: 'line',
source: 'tactical-los-src',
filter: ['==', '$type', 'LineString'],
paint: {
'line-color': ['case', ['==', ['get', 'blocked'], true], '#ef4444', '#22c55e'],
'line-width': 4,
'line-dasharray': [2, 1]
}
});
initialMap.addLayer({
id: 'tactical-los-pts',
type: 'circle',
source: 'tactical-los-src',
filter: ['==', '$type', 'Point'],
paint: {
'circle-radius': 8,
'circle-color': ['match', ['get', 'role'], 'obs', '#38bdf8', 'tgt', '#f59e0b', 'obstacle', '#ef4444', '#ffffff'],
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2
}
});
// Viewshed 360 Polygon Layer
initialMap.addSource('tactical-viewshed-src', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
initialMap.addLayer({
id: 'tactical-viewshed-fill',
type: 'fill',
source: 'tactical-viewshed-src',
paint: {
'fill-color': '#22c55e',
'fill-opacity': 0.25
}
});
initialMap.addLayer({
id: 'tactical-viewshed-outline',
type: 'line',
source: 'tactical-viewshed-src',
paint: {
'line-color': '#4ade80',
'line-width': 2,
'line-dasharray': [3, 2]
}
});
// Artillery Trajectory Layer
initialMap.addSource('tactical-artillery-src', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
initialMap.addLayer({
id: 'tactical-artillery-line',
type: 'line',
source: 'tactical-artillery-src',
paint: {
'line-color': '#f97316',
'line-width': 3.5
}
});
// Tactical Symbols Layer
initialMap.addSource('tactical-symbols-src', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
initialMap.addLayer({
id: 'tactical-symbols-pts',
type: 'circle',
source: 'tactical-symbols-src',
paint: {
'circle-radius': 9,
'circle-color': [
'match',
['get', 'type'],
'friendly', '#3b82f6',
'hostile', '#ef4444',
'artillery', '#f97316',
'radar', '#06b6d4',
'checkpoint', '#eab308',
'minefield', '#dc2626',
'#a855f7'
],
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2.5
}
});
initialMap.addLayer({
id: 'tactical-symbols-lbls',
type: 'symbol',
source: 'tactical-symbols-src',
layout: {
'text-field': ['get', 'name'],
'text-size': 11,
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
'text-offset': [0, 1.4],
'text-anchor': 'top'
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#0f172a',
'text-halo-width': 2
}
});
});
return () => {
if (map.current) {
map.current.remove();
map.current = null;
}
};
}, []);
// Update Line of Sight Calculations & Map Layer
useEffect(() => {
if (!losPointA || !losPointB) return;
setLosLoading(true);
calculateLineOfSight(losPointA[0], losPointA[1], losPointB[0], losPointB[1], losObsHeight, losTgtHeight, 70)
.then((res) => {
setLosResult(res);
setLosLoading(false);
if (map.current && map.current.getSource('tactical-los-src')) {
const feats: any[] = [
{
type: 'Feature',
properties: { role: 'ray', blocked: !res.isDirectlyVisible },
geometry: {
type: 'LineString',
coordinates: [[losPointA[1], losPointA[0]], [losPointB[1], losPointB[0]]]
}
},
{
type: 'Feature',
properties: { role: 'obs', label: 'راصد' },
geometry: { type: 'Point', coordinates: [losPointA[1], losPointA[0]] }
},
{
type: 'Feature',
properties: { role: 'tgt', label: 'هدف' },
geometry: { type: 'Point', coordinates: [losPointB[1], losPointB[0]] }
}
];
if (res.highestObstacle) {
feats.push({
type: 'Feature',
properties: { role: 'obstacle', label: 'عائق' },
geometry: { type: 'Point', coordinates: [res.highestObstacle.lng, res.highestObstacle.lat] }
});
}
(map.current.getSource('tactical-los-src') as maplibregl.GeoJSONSource).setData({
type: 'FeatureCollection',
features: feats
});
}
})
.catch(() => setLosLoading(false));
}, [losPointA, losPointB, losObsHeight, losTgtHeight]);
// Update 360 Viewshed Calculations & Map Layer
const runViewshed360 = () => {
if (!viewshedCenter) return;
setViewshedLoading(true);
calculateRadialViewshed(viewshedCenter[0], viewshedCenter[1], viewshedHeight, viewshedRadius, 36)
.then((res) => {
setViewshedResult(res);
setViewshedLoading(false);
if (map.current && map.current.getSource('tactical-viewshed-src')) {
(map.current.getSource('tactical-viewshed-src') as maplibregl.GeoJSONSource).setData({
type: 'FeatureCollection',
features: [res.polygonGeoJson]
});
}
})
.catch(() => setViewshedLoading(false));
};
// Run Artillery Fire Mission
const runArtilleryMission = async () => {
if (!gunPos || !targetPos) return;
setArtilleryLoading(true);
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
const apiKey = (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
const res = await fetch(`${apiUrl}/tactical/artillery-fire-mission`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
body: JSON.stringify({
gunLat: gunPos[0],
gunLng: gunPos[1],
targetLat: targetPos[0],
targetLng: targetPos[1],
caliber,
muzzleVelocity: muzzleVel
})
});
const data = await res.json();
setArtilleryResult(data);
if (map.current && map.current.getSource('tactical-artillery-src')) {
const lineCoords = (data.trajectoryPoints || []).map((p: any) => [p.lng, p.lat]);
(map.current.getSource('tactical-artillery-src') as maplibregl.GeoJSONSource).setData({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: lineCoords }
}
]
});
}
} catch (e) {
console.error('Artillery calculation error:', e);
} finally {
setArtilleryLoading(false);
}
};
// Run HLZ Assessment
const runHLZAssessment = async () => {
if (!hlzCenter) return;
setHlzLoading(true);
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
const apiKey = (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
const res = await fetch(`${apiUrl}/tactical/hlz-assessment?lat=${hlzCenter[0]}&lng=${hlzCenter[1]}&radius=4000`, {
headers: { 'x-api-key': apiKey }
});
const data = await res.json();
setHlzResult(data);
} catch (e) {
console.error('HLZ error:', e);
} finally {
setHlzLoading(false);
}
};
// Synchronize Tactical Symbols to Map
useEffect(() => {
if (!map.current || !map.current.getSource('tactical-symbols-src')) return;
const feats = symbols.map(s => ({
type: 'Feature',
properties: { type: s.type, name: s.name },
geometry: { type: 'Point', coordinates: [s.lng, s.lat] }
}));
(map.current.getSource('tactical-symbols-src') as maplibregl.GeoJSONSource).setData({
type: 'FeatureCollection',
features: feats as any
});
}, [symbols]);
return (
<div style={{
width: '100vw',
height: '100vh',
display: 'flex',
flexDirection: 'column',
background: '#0a0e17',
color: '#f8fafc',
fontFamily: "'IBM Plex Sans Arabic', Inter, system-ui, sans-serif",
direction: 'rtl',
overflow: 'hidden'
}}>
{/* Top Military HUD Header */}
<header style={{
height: 54,
background: 'rgba(11, 15, 25, 0.95)',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
backdropFilter: 'blur(16px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 20px',
zIndex: 100,
gap: 16
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 34,
height: 34,
borderRadius: 8,
background: 'linear-gradient(135deg, #4f46e5, #06b6d4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 0 16px rgba(79, 70, 229, 0.5)'
}}>
<Shield size={18} color="#ffffff" />
</div>
<div>
<div style={{ fontWeight: 800, fontSize: '0.95rem', letterSpacing: '-0.02em', display: 'flex', alignItems: 'center', gap: 8 }}>
<span>منظومة القيادة والسيطرة والتحليل التكتيكي</span>
<span style={{ fontSize: '10px', background: 'rgba(34, 197, 94, 0.2)', color: '#4ade80', padding: '1px 6px', borderRadius: 4 }}>
C4ISR Tactical Grid
</span>
</div>
</div>
</div>
{/* Tactical Modes Selector */}
<div style={{ display: 'flex', gap: 6, background: 'rgba(255, 255, 255, 0.05)', padding: 3, borderRadius: 10, border: '1px solid rgba(255,255,255,0.1)' }}>
{[
{ id: 'los', label: 'تبادل الرؤية (LOS)', icon: <Eye size={14} /> },
{ id: 'viewshed', label: 'كشف الميدان 360°', icon: <Radio size={14} /> },
{ id: 'artillery', label: 'رماية المدفعية', icon: <Flame size={14} /> },
{ id: 'hlz', label: 'مهابط المروحيات', icon: <Crosshair size={14} /> },
{ id: 'symbols', label: 'الرموز والعمليات', icon: <Layers size={14} /> },
].map(m => (
<button
key={m.id}
onClick={() => setMode(m.id as TacticalMode)}
style={{
background: mode === m.id ? 'linear-gradient(135deg, #4f46e5, #6366f1)' : 'transparent',
border: 'none',
color: mode === m.id ? '#ffffff' : '#94a3b8',
borderRadius: 7,
padding: '5px 12px',
fontSize: '0.8rem',
fontWeight: 700,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 5,
transition: 'all 0.15s ease'
}}
>
{m.icon}
<span>{m.label}</span>
</button>
))}
</div>
{/* Realtime Cursor HUD */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, fontSize: '0.75rem', fontFamily: 'monospace', color: '#94a3b8' }}>
<div>LAT: <span style={{ color: '#38bdf8' }}>{cursorPos.lat}</span></div>
<div>LNG: <span style={{ color: '#38bdf8' }}>{cursorPos.lng}</span></div>
<div>ELEV: <span style={{ color: '#4ade80' }}>{cursorPos.elev}m</span></div>
</div>
</header>
{/* Main Tactical Workspace */}
<div style={{ flex: 1, display: 'flex', position: 'relative', overflow: 'hidden' }}>
{/* Left Side Tactical Control Deck */}
<aside style={{
width: 360,
background: 'rgba(15, 23, 42, 0.9)',
backdropFilter: 'blur(20px)',
borderLeft: '1px solid rgba(255, 255, 255, 0.12)',
display: 'flex',
flexDirection: 'column',
zIndex: 20,
overflowY: 'auto',
padding: 16
}}>
{/* MODE 1: LINE OF SIGHT */}
{mode === 'los' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ paddingBottom: 10, borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
<h3 style={{ margin: 0, fontSize: '1.05rem', fontWeight: 800, color: '#38bdf8', display: 'flex', alignItems: 'center', gap: 8 }}>
<Eye size={18} /> تحليل خط النظر وتبادل الرؤية
</h3>
<p style={{ margin: '4px 0 0 0', fontSize: '0.78rem', color: '#94a3b8' }}>
اضغط على الخريطة لتحديد موقع الراصد (A) والهدف (B)
</p>
</div>
{/* Observer / Target Height Adjusters */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div>
<label style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'block', marginBottom: 4 }}>ارتفاع الراصد (A):</label>
<select
value={losObsHeight}
onChange={e => setLosObsHeight(Number(e.target.value))}
style={{ width: '100%', background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.15)', color: '#fff', padding: '6px', borderRadius: 8, fontSize: '0.8rem' }}
>
<option value={2}>شخص واقف (2م)</option>
<option value={10}>آلية / برج مراقبة (10م)</option>
<option value={50}>سارية / درون استطلاع (50م)</option>
</select>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'block', marginBottom: 4 }}>ارتفاع الهدف (B):</label>
<select
value={losTgtHeight}
onChange={e => setLosTgtHeight(Number(e.target.value))}
style={{ width: '100%', background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.15)', color: '#fff', padding: '6px', borderRadius: 8, fontSize: '0.8rem' }}
>
<option value={1.8}>شخص (1.8م)</option>
<option value={3}>مركبة / مدرعة (3م)</option>
<option value={15}>مبنى / محطة رادار (15م)</option>
</select>
</div>
</div>
{losLoading && <div style={{ color: '#38bdf8', fontSize: '0.85rem' }}>جاري حساب المقطع التضاريسي وشعاع الرؤية...</div>}
{losResult && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{
padding: '10px',
borderRadius: 10,
background: losResult.isDirectlyVisible ? 'rgba(34, 197, 94, 0.15)' : 'rgba(239, 68, 68, 0.15)',
border: losResult.isDirectlyVisible ? '1px solid #22c55e' : '1px solid #ef4444',
color: losResult.isDirectlyVisible ? '#4ade80' : '#f87171',
fontWeight: 700,
fontSize: '0.85rem',
display: 'flex',
alignItems: 'center',
gap: 8
}}>
{losResult.isDirectlyVisible ? <CheckCircle2 size={18} /> : <XCircle size={18} />}
<span>{losResult.isDirectlyVisible ? 'رؤية مباشرة مكشوفة بين الراصد والهدف' : 'الرؤية محجوبة خلف عائق تضاريسي'}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div style={{ background: 'rgba(255,255,255,0.04)', padding: 8, borderRadius: 8 }}>
<div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>المسافة المباشرة</div>
<div style={{ fontSize: '1rem', fontWeight: 800 }}>{(losResult.totalDistance / 1000).toFixed(2)} كم</div>
</div>
<div style={{ background: 'rgba(255,255,255,0.04)', padding: 8, borderRadius: 8 }}>
<div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>السمت / الاتجاه</div>
<div style={{ fontSize: '1rem', fontWeight: 800 }}>{losResult.azimuthDegrees}°</div>
</div>
<div style={{ background: 'rgba(255,255,255,0.04)', padding: 8, borderRadius: 8 }}>
<div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>زاوية الموقع (Mils)</div>
<div style={{ fontSize: '1rem', fontWeight: 800, color: '#a78bfa' }}>{losResult.angleMils} mils</div>
</div>
<div style={{ background: 'rgba(255,255,255,0.04)', padding: 8, borderRadius: 8 }}>
<div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>الأرض الميتة</div>
<div style={{ fontSize: '1rem', fontWeight: 800, color: '#f87171' }}>{losResult.deadGroundPercentage}%</div>
</div>
</div>
{losResult.highestObstacle && (
<div style={{ padding: 10, background: 'rgba(239, 68, 68, 0.1)', borderRadius: 8, border: '1px solid rgba(239, 68, 68, 0.3)', fontSize: '0.78rem', color: '#fca5a5' }}>
<strong>العائق الحرج:</strong> على بعد {(losResult.highestObstacle.distance / 1000).toFixed(2)} كم، بارتفاع {losResult.highestObstacle.elevation}م (+{losResult.highestObstacle.excessHeight}م فوق خط النظر).
</div>
)}
</div>
)}
</div>
)}
{/* MODE 2: 360 VIEWSHED */}
{mode === 'viewshed' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ paddingBottom: 10, borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
<h3 style={{ margin: 0, fontSize: '1.05rem', fontWeight: 800, color: '#4ade80', display: 'flex', alignItems: 'center', gap: 8 }}>
<Radio size={18} /> كشف الميدان الدائري 360° (Viewshed)
</h3>
<p style={{ margin: '4px 0 0 0', fontSize: '0.78rem', color: '#94a3b8' }}>
حساب المساحات المكشوفة والميتة بزاوية 360 درجة حول الموقع
</p>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'block', marginBottom: 4 }}>
نصف قطر الكشف: <strong>{(viewshedRadius / 1000).toFixed(1)} كم</strong>
</label>
<input
type="range"
min={1000}
max={12000}
step={500}
value={viewshedRadius}
onChange={e => setViewshedRadius(Number(e.target.value))}
style={{ width: '100%' }}
/>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'block', marginBottom: 4 }}>
ارتفاع الرصد: <strong>{viewshedHeight} متر</strong>
</label>
<select
value={viewshedHeight}
onChange={e => setViewshedHeight(Number(e.target.value))}
style={{ width: '100%', background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.15)', color: '#fff', padding: '6px', borderRadius: 8, fontSize: '0.8rem' }}
>
<option value={2}>راصد أرضي (2م)</option>
<option value={10}>برج راداري / سارية (10م)</option>
<option value={30}>برج مراقبة حدودي (30م)</option>
</select>
</div>
<button
onClick={runViewshed360}
disabled={viewshedLoading}
style={{
background: 'linear-gradient(135deg, #10b981, #059669)',
color: '#fff',
border: 'none',
borderRadius: 10,
padding: '10px',
fontWeight: 700,
fontSize: '0.85rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
boxShadow: '0 4px 14px rgba(16, 185, 129, 0.4)'
}}
>
<Play size={16} />
{viewshedLoading ? 'جاري المسح الشعاعي...' : 'بدء كشف الميدان 360°'}
</button>
{viewshedResult && (
<div style={{ background: 'rgba(255,255,255,0.04)', padding: 12, borderRadius: 10, border: '1px solid rgba(34, 197, 94, 0.2)' }}>
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: '#4ade80', marginBottom: 8 }}>نتائج المسح التضاريسي:</div>
<div style={{ fontSize: '0.8rem', color: '#cbd5e1', lineHeight: 1.7 }}>
<div>• المساحة المكشوفة: <strong>{viewshedResult.visibleAreaKm2} كم²</strong></div>
<div>• نسبة الكشف البصري: <strong>{viewshedResult.visiblePercentage}%</strong> من المدى الكلي</div>
<div>• ارتفاع نقطة الرصد: <strong>{viewshedResult.centerElevation} م فوق سطح البحر</strong></div>
</div>
</div>
)}
</div>
)}
{/* MODE 3: ARTILLERY FIRE MISSION */}
{mode === 'artillery' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ paddingBottom: 10, borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
<h3 style={{ margin: 0, fontSize: '1.05rem', fontWeight: 800, color: '#f97316', display: 'flex', alignItems: 'center', gap: 8 }}>
<Flame size={18} /> حاسبة الرماية المدفعية والمسار البالستي
</h3>
<p style={{ margin: '4px 0 0 0', fontSize: '0.78rem', color: '#94a3b8' }}>
حساب زوايا الرماية (Mils) وفحص أمان عبور القذيفة فوق القمم
</p>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'block', marginBottom: 4 }}>نوع السلاح والعيار:</label>
<select
value={caliber}
onChange={e => {
setCaliber(e.target.value);
if (e.target.value.includes('155mm')) setMuzzleVel(827);
else if (e.target.value.includes('122mm')) setMuzzleVel(690);
else setMuzzleVel(320); // Mortar
}}
style={{ width: '100%', background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.15)', color: '#fff', padding: '6px', borderRadius: 8, fontSize: '0.8rem' }}
>
<option value="155mm Howitzer (M109)">هاوتزر 155 ملم (M109 / M777)</option>
<option value="122mm D-30">مدفعية 122 ملم (D-30)</option>
<option value="120mm Heavy Mortar">هاون ثقيل 120 ملم</option>
</select>
</div>
<button
onClick={runArtilleryMission}
disabled={artilleryLoading}
style={{
background: 'linear-gradient(135deg, #f97316, #ea580c)',
color: '#fff',
border: 'none',
borderRadius: 10,
padding: '10px',
fontWeight: 700,
fontSize: '0.85rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
boxShadow: '0 4px 14px rgba(249, 115, 22, 0.4)'
}}
>
<Flame size={16} />
{artilleryLoading ? 'جاري الحساب البالستي...' : 'حساب مهمة الرماية (Fire Mission)'}
</button>
{artilleryResult && (
<div style={{ background: 'rgba(255,255,255,0.04)', padding: 12, borderRadius: 10, border: '1px solid rgba(249, 115, 22, 0.3)', fontSize: '0.8rem', lineHeight: 1.7 }}>
<div style={{ fontWeight: 800, color: '#fb923c', marginBottom: 6 }}>بيانات الرماية (Firing Solution):</div>
<div>• المسافة للهدف: <strong>{artilleryResult.distanceKm} كم</strong> ({artilleryResult.distanceMeters}م)</div>
<div>• السمت (Azimuth): <strong>{artilleryResult.azimuthDegrees}°</strong> ({artilleryResult.azimuthMils} mils)</div>
<div>• زاوية الرمي المنخفضة (QE Low): <strong style={{ color: '#a78bfa' }}>{artilleryResult.lowAngle?.mils} mils</strong> ({artilleryResult.lowAngle?.degrees}°)</div>
<div>• زاوية الرمي العالية (QE High): <strong style={{ color: '#a78bfa' }}>{artilleryResult.highAngle?.mils} mils</strong></div>
<div>• زمن طيران القذيفة (TOF): <strong>{artilleryResult.timeOfFlightSeconds} ثانية</strong></div>
<div>• أعلى نقطة للمسار (Apex): <strong>{artilleryResult.apexAltitudeMeters} م</strong></div>
<div style={{
marginTop: 8,
padding: 6,
borderRadius: 6,
background: artilleryResult.hasCrestClearance ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
color: artilleryResult.hasCrestClearance ? '#4ade80' : '#f87171',
fontWeight: 700
}}>
{artilleryResult.hasCrestClearance ? '✓ مسار القذيفة آمن ويعلو كافة القمم' : '✕ تحذير: القذيفة تصطدم بقمة وسيطة!'}
</div>
</div>
)}
</div>
)}
{/* MODE 4: HLZ ASSESSMENT */}
{mode === 'hlz' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ paddingBottom: 10, borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
<h3 style={{ margin: 0, fontSize: '1.05rem', fontWeight: 800, color: '#06b6d4', display: 'flex', alignItems: 'center', gap: 8 }}>
<Crosshair size={18} /> كشف مهابط المروحيات والإخلاء الطبي
</h3>
<p style={{ margin: '4px 0 0 0', fontSize: '0.78rem', color: '#94a3b8' }}>
مسح تضاريسي لاستخراج الأراضي المستوية والصالحة لهبوط الطيران العمودي
</p>
</div>
<button
onClick={runHLZAssessment}
disabled={hlzLoading}
style={{
background: 'linear-gradient(135deg, #06b6d4, #0891b2)',
color: '#fff',
border: 'none',
borderRadius: 10,
padding: '10px',
fontWeight: 700,
fontSize: '0.85rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
boxShadow: '0 4px 14px rgba(6, 182, 212, 0.4)'
}}
>
<Crosshair size={16} />
{hlzLoading ? 'جاري مسح التضاريس...' : 'كشف المهابط ضمن 4 كم'}
</button>
{hlzResult && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: '#38bdf8' }}>
تم العثور على {hlzResult.suitableCount} مهابط صالحة:
</div>
{hlzResult.zones.slice(0, 5).map((z: any) => (
<div key={z.id} style={{
padding: 8,
borderRadius: 8,
background: z.suitability === 'EXCELLENT' ? 'rgba(34,197,94,0.1)' : 'rgba(255,255,255,0.03)',
border: z.suitability === 'EXCELLENT' ? '1px solid rgba(34,197,94,0.3)' : '1px solid rgba(255,255,255,0.08)',
fontSize: '0.78rem'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 700 }}>
<span>{z.id} ({z.distanceMeters}م)</span>
<span style={{ color: z.suitability === 'EXCELLENT' ? '#4ade80' : '#fbbf24' }}>{z.suitability_ar}</span>
</div>
<div style={{ color: '#94a3b8', marginTop: 2 }}>
انحدار: {z.slopeDegrees}° | قطر المروحة: {z.maxRotorDiameterMeters}م
</div>
</div>
))}
</div>
)}
</div>
)}
{/* MODE 5: TACTICAL SYMBOLOGY */}
{mode === 'symbols' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ paddingBottom: 10, borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
<h3 style={{ margin: 0, fontSize: '1.05rem', fontWeight: 800, color: '#a855f7', display: 'flex', alignItems: 'center', gap: 8 }}>
<Layers size={18} /> الرموز التكتيكية والسيطرة الميدانية
</h3>
<p style={{ margin: '4px 0 0 0', fontSize: '0.78rem', color: '#94a3b8' }}>
اختر الرمز العسكري واضغط على الخريطة لتثبيت مواقع التشكيلات
</p>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'block', marginBottom: 4 }}>نوع الرمز العسكري:</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{[
{ id: 'friendly', label: '🟦 وحدة صديقة' },
{ id: 'hostile', label: '🟥 هدف معادي' },
{ id: 'artillery', label: '🟧 مربض مدفعية' },
{ id: 'radar', label: '🟦 محطة رادار' },
{ id: 'checkpoint', label: '🟨 نقطة سيطرة' },
{ id: 'op', label: '🟪 نقطة مراقبة' },
].map(sym => (
<button
key={sym.id}
onClick={() => setSelectedSymbolType(sym.id)}
style={{
background: selectedSymbolType === sym.id ? 'rgba(168, 85, 247, 0.25)' : 'rgba(255,255,255,0.05)',
border: selectedSymbolType === sym.id ? '1px solid #a855f7' : '1px solid rgba(255,255,255,0.1)',
color: '#fff',
padding: '6px 8px',
borderRadius: 6,
fontSize: '0.75rem',
cursor: 'pointer',
textAlign: 'right'
}}
>
{sym.label}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'block', marginBottom: 4 }}>تسمية الرمز / الوحدة:</label>
<input
type="text"
placeholder="مثال: سرية الدبابات 1"
value={newSymbolName}
onChange={e => setNewSymbolName(e.target.value)}
style={{ width: '100%', background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.15)', color: '#fff', padding: '8px', borderRadius: 8, fontSize: '0.8rem' }}
/>
</div>
{/* Active Symbols List */}
<div style={{ marginTop: 8 }}>
<div style={{ fontSize: '0.8rem', color: '#94a3b8', marginBottom: 6 }}>الرموز المنتشرة على الخريطة ({symbols.length}):</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 180, overflowY: 'auto' }}>
{symbols.map(s => (
<div key={s.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'rgba(255,255,255,0.04)', padding: '6px 10px', borderRadius: 6, fontSize: '0.78rem' }}>
<span>{s.name}</span>
<button
onClick={() => setSymbols(symbols.filter(x => x.id !== s.id))}
style={{ background: 'none', border: 'none', color: '#ef4444', cursor: 'pointer', padding: 2 }}
>
✕
</button>
</div>
))}
</div>
</div>
</div>
)}
</aside>
{/* Tactical Map Container */}
<div ref={mapContainer} style={{ flex: 1, height: '100%', position: 'relative' }} />
</div>
</div>
);
};
+104
View File
@@ -361,3 +361,107 @@ export async function calculateLineOfSight(
azimuthDegrees: Math.round(azimuthDegrees * 10) / 10,
};
}
export interface Viewshed360Result {
centerLat: number;
centerLng: number;
centerElevation: number;
radiusMeters: number;
polygonGeoJson: any;
totalRays: number;
visibleAreaKm2: number;
visiblePercentage: number;
}
/**
* Calculates 360-degree Radial Viewshed (كشف الميدان الدائري) around observer
*/
export async function calculateRadialViewshed(
centerLat: number,
centerLng: number,
obsHeight: number = 2,
radiusMeters: number = 5000,
numRays: number = 36
): Promise<Viewshed360Result> {
const centerGroundElev = await sampleElevationAt(centerLat, centerLng);
const observerElevation = centerGroundElev + obsHeight;
const R_earth = 6371000;
const k_refraction = 0.13;
const effectiveEarthRadius = R_earth / (1 - k_refraction);
const polygonCoordinates: [number, number][] = [];
const samplesPerRay = 12;
let totalVisibleDistanceSum = 0;
for (let rayIdx = 0; rayIdx < numRays; rayIdx++) {
const angleDeg = (rayIdx * 360) / numRays;
const angleRad = (angleDeg * Math.PI) / 180;
const dLat = (radiusMeters / R_earth) * (180 / Math.PI) * Math.cos(angleRad);
const dLng = (radiusMeters / (R_earth * Math.cos((centerLat * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(angleRad);
const endLat = centerLat + dLat;
const endLng = centerLng + dLng;
let maxAngleSoFar = -Infinity;
let visibleHorizonDist = radiusMeters;
let visibleHorizonLat = endLat;
let visibleHorizonLng = endLng;
for (let step = 1; step <= samplesPerRay; step++) {
const frac = step / samplesPerRay;
const sLat = centerLat + (endLat - centerLat) * frac;
const sLng = centerLng + (endLng - centerLng) * frac;
const sDist = radiusMeters * frac;
const sElev = await sampleElevationAt(sLat, sLng);
const earthCurvatureDrop = (sDist * sDist) / (2 * effectiveEarthRadius);
const apparentElev = sElev - earthCurvatureDrop;
const angle = (apparentElev - observerElevation) / sDist;
if (angle >= maxAngleSoFar) {
maxAngleSoFar = angle;
visibleHorizonDist = sDist;
visibleHorizonLat = sLat;
visibleHorizonLng = sLng;
}
}
totalVisibleDistanceSum += visibleHorizonDist;
polygonCoordinates.push([visibleHorizonLng, visibleHorizonLat]);
}
if (polygonCoordinates.length > 0) {
polygonCoordinates.push(polygonCoordinates[0]);
}
const polygonGeoJson = {
type: 'Feature',
properties: {
centerLat,
centerLng,
radiusMeters
},
geometry: {
type: 'Polygon',
coordinates: [polygonCoordinates]
}
};
const avgVisibleDist = totalVisibleDistanceSum / numRays;
const theoreticalMaxArea = Math.PI * Math.pow(radiusMeters / 1000, 2);
const actualVisibleArea = Math.PI * Math.pow(avgVisibleDist / 1000, 2);
const visiblePercentage = Math.min(100, Math.round((actualVisibleArea / theoreticalMaxArea) * 100));
return {
centerLat,
centerLng,
centerElevation: Math.round(observerElevation),
radiusMeters,
polygonGeoJson,
totalRays: numRays,
visibleAreaKm2: Math.round(actualVisibleArea * 10) / 10,
visiblePercentage
};
}