diff --git a/apps/web/src/pages/TacticalDefenseView.tsx b/apps/web/src/pages/TacticalDefenseView.tsx index c1e9249..728e1db 100644 --- a/apps/web/src/pages/TacticalDefenseView.tsx +++ b/apps/web/src/pages/TacticalDefenseView.tsx @@ -6,13 +6,11 @@ import { Crosshair, Eye, Radio, - Compass, Mountain, AlertTriangle, CheckCircle2, XCircle, Layers, - Save, Trash2, Navigation, Flame, @@ -20,15 +18,12 @@ import { Plus, Play, RotateCcw, - Zap, - ChevronDown, - FileText, + Award, + AlertOctagon, MapPin, Target, - Sliders, - Check, - Award, - AlertOctagon + LocateFixed, + Move } from 'lucide-react'; import { calculateLineOfSight, @@ -49,13 +44,12 @@ interface TacticalSymbolItem { name: string; lat: number; lng: number; - elev?: number; } const REGIONAL_SECTORS = [ { name: 'عمّان الوسطى', lat: 31.9539, lng: 35.9106, zoom: 12 }, - { name: 'إربد / اليرموك', lat: 32.5568, lng: 35.8469, zoom: 12 }, - { name: 'قاطع المفرق', lat: 32.3421, lng: 36.2081, zoom: 11 }, + { name: 'إربد / اليرموك (الشمال)', lat: 32.5568, lng: 35.8469, zoom: 12 }, + { name: 'قاطع المفرق (الشرق)', lat: 32.3421, lng: 36.2081, zoom: 11 }, { name: 'غور الأردن / البحر الميت', lat: 31.7500, lng: 35.5800, zoom: 12 }, { name: 'مرتفعات الكرك والشوبك', lat: 31.1853, lng: 35.7048, zoom: 11 }, { name: 'وادي رم الجنوبي', lat: 29.5700, lng: 35.4200, zoom: 12 }, @@ -116,6 +110,113 @@ export const TacticalDefenseView: React.FC = () => { const [symbols, setSymbols] = useState(DEFAULT_SYMBOLS); const [selectedSymbolType, setSelectedSymbolType] = useState('friendly'); const [newSymbolName, setNewSymbolName] = useState(''); + const [customSymbolLat, setCustomSymbolLat] = useState(31.95); + const [customSymbolLng, setCustomSymbolLng] = useState(35.93); + + // Synchronized Refs to avoid stale closures in MapLibre event listeners + const modeRef = useRef(mode); + modeRef.current = mode; + + const activePlacementRef = useRef(activePlacement); + activePlacementRef.current = activePlacement; + + const losPointARef = useRef(losPointA); + losPointARef.current = losPointA; + + const losPointBRef = useRef(losPointB); + losPointBRef.current = losPointB; + + const viewshedCenterRef = useRef(viewshedCenter); + viewshedCenterRef.current = viewshedCenter; + + const gunPosRef = useRef(gunPos); + gunPosRef.current = gunPos; + + const targetPosRef = useRef(targetPos); + targetPosRef.current = targetPos; + + const mineStartRef = useRef(mineStart); + mineStartRef.current = mineStart; + + const mineEndRef = useRef(mineEnd); + mineEndRef.current = mineEnd; + + const hlzCenterRef = useRef(hlzCenter); + hlzCenterRef.current = hlzCenter; + + const selectedSymbolTypeRef = useRef(selectedSymbolType); + selectedSymbolTypeRef.current = selectedSymbolType; + + const newSymbolNameRef = useRef(newSymbolName); + newSymbolNameRef.current = newSymbolName; + + const symbolsRef = useRef(symbols); + symbolsRef.current = symbols; + + // Draggable Markers Ref Store + const markersRef = useRef<{ [key: string]: maplibregl.Marker }>({}); + + // Helper to create or update draggable marker + const updateMarker = (id: string, coords: [number, number] | null, label: string, color: string, onDrag: (pos: [number, number]) => void) => { + if (!map.current) return; + if (!coords) { + if (markersRef.current[id]) { + markersRef.current[id].remove(); + delete markersRef.current[id]; + } + return; + } + + if (markersRef.current[id]) { + markersRef.current[id].setLngLat([coords[1], coords[0]]); + } else { + const el = document.createElement('div'); + el.className = 'tactical-marker'; + el.style.display = 'flex'; + el.style.alignItems = 'center'; + el.style.justifyContent = 'center'; + el.style.background = color; + el.style.color = '#ffffff'; + el.style.fontSize = '11px'; + el.style.fontWeight = 'bold'; + el.style.padding = '4px 8px'; + el.style.borderRadius = '20px'; + el.style.border = '2px solid #ffffff'; + el.style.boxShadow = '0 0 12px rgba(0,0,0,0.6)'; + el.style.cursor = 'grab'; + el.style.userSelect = 'none'; + el.innerText = label; + + const marker = new maplibregl.Marker({ element: el, draggable: true }) + .setLngLat([coords[1], coords[0]]) + .addTo(map.current); + + marker.on('dragend', () => { + const lngLat = marker.getLngLat(); + onDrag([Number(lngLat.lat.toFixed(5)), Number(lngLat.lng.toFixed(5))]); + }); + + markersRef.current[id] = marker; + } + }; + + // Sync Draggable Markers to Map + useEffect(() => { + if (!map.current) return; + // LOS + updateMarker('los-a', losPointA, '📍 راصد A', '#0284c7', (pos) => setLosPointA(pos)); + updateMarker('los-b', losPointB, '🎯 هدف B', '#d97706', (pos) => setLosPointB(pos)); + // Viewshed + updateMarker('viewshed-c', viewshedCenter, '📡 مركز الرصد', '#16a34a', (pos) => setViewshedCenter(pos)); + // Artillery + updateMarker('art-gun', gunPos, '💥 مربض المدفعية', '#ea580c', (pos) => setGunPos(pos)); + updateMarker('art-tgt', targetPos, '🎯 هدف مدفعية', '#dc2626', (pos) => setTargetPos(pos)); + // Minefield + updateMarker('mine-s', mineStart, '⛔ بداية حقل الألغام', '#b91c1c', (pos) => setMineStart(pos)); + updateMarker('mine-e', mineEnd, '⛔ نهاية حقل الألغام', '#991b1b', (pos) => setMineEnd(pos)); + // HLZ + updateMarker('hlz-c', hlzCenter, '🚁 مركز HLZ', '#059669', (pos) => setHlzCenter(pos)); + }, [losPointA, losPointB, viewshedCenter, gunPos, targetPos, mineStart, mineEnd, hlzCenter]); // Map Initialization useEffect(() => { @@ -146,84 +247,88 @@ export const TacticalDefenseView: React.FC = () => { }); }); + // Fresh Map Click Handler reading latest Refs initialMap.on('click', (e) => { const clickedLat = Number(e.lngLat.lat.toFixed(5)); const clickedLng = Number(e.lngLat.lng.toFixed(5)); - // Handle explicit placement modes - if (activePlacement === 'los-a') { + const placement = activePlacementRef.current; + const currentMode = modeRef.current; + + // 1. Explicit Active Placement from Buttons + if (placement === 'los-a') { setLosPointA([clickedLat, clickedLng]); setActivePlacement(null); return; } - if (activePlacement === 'los-b') { + if (placement === 'los-b') { setLosPointB([clickedLat, clickedLng]); setActivePlacement(null); return; } - if (activePlacement === 'gun') { - setGunPos([clickedLat, clickedLng]); - setActivePlacement(null); - return; - } - if (activePlacement === 'target') { - setTargetPos([clickedLat, clickedLng]); - setActivePlacement(null); - return; - } - if (activePlacement === 'mine-start') { - setMineStart([clickedLat, clickedLng]); - setActivePlacement(null); - return; - } - if (activePlacement === 'mine-end') { - setMineEnd([clickedLat, clickedLng]); - setActivePlacement(null); - return; - } - if (activePlacement === 'viewshed-center') { + if (placement === 'viewshed-center') { setViewshedCenter([clickedLat, clickedLng]); setActivePlacement(null); return; } - if (activePlacement === 'hlz-center') { + if (placement === 'gun') { + setGunPos([clickedLat, clickedLng]); + setActivePlacement(null); + return; + } + if (placement === 'target') { + setTargetPos([clickedLat, clickedLng]); + setActivePlacement(null); + return; + } + if (placement === 'mine-start') { + setMineStart([clickedLat, clickedLng]); + setActivePlacement(null); + return; + } + if (placement === 'mine-end') { + setMineEnd([clickedLat, clickedLng]); + setActivePlacement(null); + return; + } + if (placement === 'hlz-center') { setHlzCenter([clickedLat, clickedLng]); setActivePlacement(null); return; } - // Default click actions by active tab - if (mode === 'los') { - if (!losPointA) { + // 2. Default Context-Aware Click by Active Mode + if (currentMode === 'los') { + if (!losPointARef.current) { setLosPointA([clickedLat, clickedLng]); - } else if (!losPointB) { + } else if (!losPointBRef.current) { setLosPointB([clickedLat, clickedLng]); } else { setLosPointA([clickedLat, clickedLng]); setLosPointB(null); } - } else if (mode === 'viewshed') { + } else if (currentMode === 'viewshed') { setViewshedCenter([clickedLat, clickedLng]); - } else if (mode === 'artillery') { - if (!gunPos) { + } else if (currentMode === 'artillery') { + if (!gunPosRef.current) { setGunPos([clickedLat, clickedLng]); - } else if (!targetPos) { + } else if (!targetPosRef.current) { setTargetPos([clickedLat, clickedLng]); } else { setTargetPos([clickedLat, clickedLng]); } - } else if (mode === 'minefield') { - if (!mineStart) { + } else if (currentMode === 'minefield') { + if (!mineStartRef.current) { setMineStart([clickedLat, clickedLng]); - } else if (!mineEnd) { + } else if (!mineEndRef.current) { setMineEnd([clickedLat, clickedLng]); } else { setMineStart([clickedLat, clickedLng]); setMineEnd(null); } - } else if (mode === 'hlz') { + } else if (currentMode === 'hlz') { setHlzCenter([clickedLat, clickedLng]); - } else if (mode === 'symbols') { + } else if (currentMode === 'symbols') { const typeLabels: Record = { friendly: 'وحدة صديقة', hostile: 'هدف معادي', @@ -236,8 +341,8 @@ export const TacticalDefenseView: React.FC = () => { }; const newSym: TacticalSymbolItem = { id: `sym-${Date.now()}`, - type: selectedSymbolType as any, - name: newSymbolName || `${typeLabels[selectedSymbolType] || 'رمز'} ${symbols.length + 1}`, + type: selectedSymbolTypeRef.current as any, + name: newSymbolNameRef.current || `${typeLabels[selectedSymbolTypeRef.current] || 'رمز'} ${symbolsRef.current.length + 1}`, lat: clickedLat, lng: clickedLng }; @@ -265,19 +370,6 @@ export const TacticalDefenseView: React.FC = () => { } }); - 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 - } - }); - // 2. Viewshed 360 Polygon Layer initialMap.addSource('tactical-viewshed-src', { type: 'geojson', @@ -317,7 +409,7 @@ export const TacticalDefenseView: React.FC = () => { source: 'tactical-artillery-src', paint: { 'line-color': '#f97316', - 'line-width': 3.5 + 'line-width': 4 } }); @@ -403,7 +495,17 @@ export const TacticalDefenseView: React.FC = () => { // Update Line of Sight Calculations & Map Layer useEffect(() => { - if (!losPointA || !losPointB) return; + if (!losPointA || !losPointB) { + if (map.current && map.current.getSource('tactical-los-src')) { + (map.current.getSource('tactical-los-src') as maplibregl.GeoJSONSource).setData({ + type: 'FeatureCollection', + features: [] + }); + } + setLosResult(null); + return; + } + setLosLoading(true); calculateLineOfSight(losPointA[0], losPointA[1], losPointB[0], losPointB[1], losObsHeight, losTgtHeight, 70) .then((res) => { @@ -419,16 +521,6 @@ export const TacticalDefenseView: React.FC = () => { 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]] } } ]; @@ -449,9 +541,19 @@ export const TacticalDefenseView: React.FC = () => { .catch(() => setLosLoading(false)); }, [losPointA, losPointB, losObsHeight, losTgtHeight]); - // Update Minefield Analysis + // Update Minefield Analysis & Map Layer useEffect(() => { - if (!mineStart || !mineEnd) return; + if (!mineStart || !mineEnd) { + if (map.current && map.current.getSource('tactical-minefield-src')) { + (map.current.getSource('tactical-minefield-src') as maplibregl.GeoJSONSource).setData({ + type: 'FeatureCollection', + features: [] + }); + } + setMineResult(null); + return; + } + setMineLoading(true); calculateMinefieldAnalysis(mineStart[0], mineStart[1], mineEnd[0], mineEnd[1], mineDensity) .then((res) => { @@ -477,7 +579,22 @@ export const TacticalDefenseView: React.FC = () => { .catch(() => setMineLoading(false)); }, [mineStart, mineEnd, mineDensity]); - // Update 360 Viewshed Calculations & Map Layer + // Update Artillery Trajectory + useEffect(() => { + if (!gunPos || !targetPos) { + if (map.current && map.current.getSource('tactical-artillery-src')) { + (map.current.getSource('tactical-artillery-src') as maplibregl.GeoJSONSource).setData({ + type: 'FeatureCollection', + features: [] + }); + } + setArtilleryResult(null); + return; + } + runArtilleryMission(); + }, [gunPos, targetPos, caliber, muzzleVel]); + + // Update 360 Viewshed const runViewshed360 = () => { if (!viewshedCenter) return; setViewshedLoading(true); @@ -573,7 +690,7 @@ export const TacticalDefenseView: React.FC = () => { }); }, [symbols]); - // Jump to Sector + // Jump to Sector and Adapt Operational Points const flyToSector = (sector: typeof REGIONAL_SECTORS[0]) => { if (!map.current) return; map.current.flyTo({ @@ -582,6 +699,19 @@ export const TacticalDefenseView: React.FC = () => { pitch: 45, essential: true }); + + // Dynamically re-anchor operational points into this region so the user is not stuck with old coordinates + const lat = sector.lat; + const lng = sector.lng; + + 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))]); + setGunPos([Number((lat - 0.03).toFixed(5)), Number((lng - 0.02).toFixed(5))]); + setTargetPos([Number((lat + 0.04).toFixed(5)), Number((lng + 0.03).toFixed(5))]); + setMineStart([Number((lat + 0.01).toFixed(5)), Number((lng - 0.015).toFixed(5))]); + setMineEnd([Number((lat + 0.02).toFixed(5)), Number((lng + 0.015).toFixed(5))]); + setHlzCenter([Number(lat.toFixed(5)), Number(lng.toFixed(5))]); }; return ( @@ -682,9 +812,9 @@ export const TacticalDefenseView: React.FC = () => { {/* Quick Sector Presets Bar */}
{ key={idx} onClick={() => flyToSector(s)} style={{ - background: 'rgba(255,255,255,0.05)', - border: '1px solid rgba(255,255,255,0.1)', - color: '#cbd5e1', - padding: '2px 10px', + background: 'rgba(56, 189, 248, 0.1)', + border: '1px solid rgba(56, 189, 248, 0.25)', + color: '#38bdf8', + padding: '3px 10px', borderRadius: 6, fontSize: '0.72rem', + fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap', transition: 'all 0.15s ease' @@ -722,8 +853,8 @@ export const TacticalDefenseView: React.FC = () => { {/* Left Side Tactical Control Deck */}