fix(tactical): resolve DTO types and TS1272 build error for isolatedModules
This commit is contained in:
@@ -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 /> :
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user