diff --git a/apps/api/src/common/guards/admin.guard.ts b/apps/api/src/common/guards/admin.guard.ts index 71263aa..f4c14b4 100644 --- a/apps/api/src/common/guards/admin.guard.ts +++ b/apps/api/src/common/guards/admin.guard.ts @@ -11,7 +11,7 @@ export class AdminGuard implements CanActivate { throw new UnauthorizedException('Tenant not found in request'); } - if (tenant.plan !== TenantPlan.ENTERPRISE && tenant.role !== TenantRole.ADMIN) { + if (tenant.plan !== TenantPlan.ENTERPRISE && tenant.role !== 'ADMIN') { throw new ForbiddenException('Admin access required. Your tenant must have an ENTERPRISE plan or ADMIN clearance.'); } diff --git a/apps/web/src/components/MapComponent.tsx b/apps/web/src/components/MapComponent.tsx index 10f9f83..10533f3 100644 --- a/apps/web/src/components/MapComponent.tsx +++ b/apps/web/src/components/MapComponent.tsx @@ -198,7 +198,7 @@ const MapComponent: React.FC = ({ id: 'los-line', type: 'line', source: 'los-source', - filter: ['==', '$type', 'LineString'], + filter: ['==', ['geometry-type'], 'LineString'], layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#f59e0b', @@ -211,7 +211,7 @@ const MapComponent: React.FC = ({ id: 'los-points', type: 'circle', source: 'los-source', - filter: ['==', '$type', 'Point'], + filter: ['==', ['geometry-type'], 'Point'], paint: { 'circle-radius': 8, 'circle-color': [ @@ -230,7 +230,7 @@ const MapComponent: React.FC = ({ id: 'los-labels', type: 'symbol', source: 'los-source', - filter: ['==', '$type', 'Point'], + filter: ['==', ['geometry-type'], 'Point'], layout: { 'text-field': ['get', 'label'], 'text-size': 12, diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 171e275..1fa85b9 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react' import ReactDOM from 'react-dom/client' -import maplibregl from 'maplibre-gl' +import maplibregl from './utils/maplibreWorker' import App from './App.tsx' import CompareView from './pages/CompareView' import IntelligenceDashboard from './pages/IntelligenceDashboard' diff --git a/apps/web/src/pages/TacticalDefenseView.tsx b/apps/web/src/pages/TacticalDefenseView.tsx index 54f861d..bc6e9e8 100644 --- a/apps/web/src/pages/TacticalDefenseView.tsx +++ b/apps/web/src/pages/TacticalDefenseView.tsx @@ -2,19 +2,19 @@ import React, { useState, useEffect, useRef } from 'react'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { getSharedDemSource } from '../utils/demSource'; -import { - Shield, - Crosshair, - Eye, - Radio, - Mountain, - AlertTriangle, - CheckCircle2, - XCircle, - Layers, - Trash2, - Navigation, - Flame, +import { + Shield, + Crosshair, + Eye, + Radio, + Mountain, + AlertTriangle, + CheckCircle2, + XCircle, + Layers, + Trash2, + Navigation, + Flame, Activity, Plus, Play, @@ -43,14 +43,14 @@ import { Zap, Share2 } from 'lucide-react'; -import { - calculateLineOfSight, - calculateRadialViewshed, +import { + calculateLineOfSight, + calculateRadialViewshed, calculateMinefieldAnalysis, calculateTerrainStudy, calculateRealIPBOverlays, IPBOverlayFeatureSet, - LineOfSightResult, + LineOfSightResult, Viewshed360Result, MinefieldAnalysisResult, TerrainStudyResult, @@ -117,8 +117,8 @@ export const TacticalDefenseView: React.FC = () => { // Tactical License & Access Clearance State const [authKey, setAuthKey] = useState(() => { - return new URLSearchParams(window.location.search).get('key') || - new URLSearchParams(window.location.search).get('api_key') || + return new URLSearchParams(window.location.search).get('key') || + new URLSearchParams(window.location.search).get('api_key') || sessionStorage.getItem('tactical_api_key') || ''; }); const [authStatus, setAuthStatus] = useState<'validating' | 'authorized' | 'unauthorized'>('validating'); @@ -176,6 +176,8 @@ export const TacticalDefenseView: React.FC = () => { }; const [mode, setMode] = useState('terrain'); + const [mapLoaded, setMapLoaded] = useState(false); + const mapLoadedRef = useRef(false); const [cursorPos, setCursorPos] = useState({ lat: 31.95, lng: 35.93, elev: 850 }); const [activePlacement, setActivePlacement] = useState(null); @@ -364,6 +366,38 @@ export const TacticalDefenseView: React.FC = () => { }); }; + const bringTacticalLayersToFront = (m: maplibregl.Map | null) => { + if (!m) return; + const tacticalLayerOrder = [ + 'tactical-terrain-fill', + 'tactical-terrain-line', + 'tactical-isochrone-fill', + 'tactical-isochrone-line', + 'tactical-viewshed-fill', + 'tactical-viewshed-outline', + 'tactical-terrain-spatial-poly-fill', + 'tactical-terrain-spatial-poly-line', + 'tactical-terrain-spatial-lines', + 'tactical-terrain-spatial-points', + 'tactical-terrain-spatial-labels', + 'tactical-ipb-fill', + 'tactical-ipb-line', + 'tactical-ipb-symbol', + 'tactical-los-line', + 'tactical-artillery-line', + 'tactical-minefield-line', + 'tactical-symbols-pts', + 'tactical-symbols-lbls' + ]; + tacticalLayerOrder.forEach((layerId) => { + if (m.getLayer(layerId)) { + // try { + // m.moveLayer(layerId); + // } catch {} + } + }); + }; + const handlePositionFoundByResection = (lat: number, lng: number, observations: ResectionObservation[]) => { setResectionPosition([lat, lng]); if (map.current) { @@ -410,11 +444,10 @@ export const TacticalDefenseView: React.FC = () => { id: 'resection-lines', type: 'line', source: 'resection-source', - filter: ['==', '$type', 'LineString'], + filter: ['==', ['geometry-type'], 'LineString'], paint: { 'line-color': '#00f0ff', - 'line-width': 3, - 'line-dasharray': [3, 2] + 'line-width': 3 } }); @@ -422,7 +455,7 @@ export const TacticalDefenseView: React.FC = () => { id: 'resection-points', type: 'circle', source: 'resection-source', - filter: ['==', '$type', 'Point'], + filter: ['==', ['geometry-type'], 'Point'], paint: { 'circle-radius': 9, 'circle-color': '#22c55e', @@ -450,7 +483,7 @@ export const TacticalDefenseView: React.FC = () => { id: fillLayerId, type: 'fill', source: sourceId, - filter: ['==', '$type', 'Polygon'], + filter: ['==', ['geometry-type'], 'Polygon'], paint: { 'fill-color': '#6366f1', 'fill-opacity': 0.45 @@ -462,7 +495,7 @@ export const TacticalDefenseView: React.FC = () => { id: lineLayerId, type: 'line', source: sourceId, - filter: ['any', ['==', '$type', 'LineString'], ['==', '$type', 'Polygon']], + filter: ['any', ['==', ['geometry-type'], 'LineString'], ['==', ['geometry-type'], 'Polygon']], paint: { 'line-color': '#38bdf8', 'line-width': 2.5 @@ -474,7 +507,7 @@ export const TacticalDefenseView: React.FC = () => { id: circleLayerId, type: 'circle', source: sourceId, - filter: ['==', '$type', 'Point'], + filter: ['==', ['geometry-type'], 'Point'], paint: { 'circle-radius': 7, 'circle-color': '#e11d48', @@ -679,7 +712,7 @@ export const TacticalDefenseView: React.FC = () => { const initialMap = new maplibregl.Map({ container: mapContainer.current, - style: '/tactical-style.json', + style: '/style.json', center: [35.9106, 31.9539], zoom: 11, pitch: 35, @@ -829,30 +862,46 @@ export const TacticalDefenseView: React.FC = () => { }); initialMap.on('load', () => { + const safeAddSource = (id: string, source: any) => { + if (!initialMap.getSource(id)) { + initialMap.addSource(id, source); + } + }; + + const safeAddLayer = (layer: any, beforeId?: string) => { + if (!initialMap.getLayer(layer.id)) { + if (beforeId && initialMap.getLayer(beforeId)) { + initialMap.addLayer(layer, beforeId); + } else { + initialMap.addLayer(layer); + } + } + }; + // 0. Terrain Study Circle & Spatial Layers - initialMap.addSource('tactical-terrain-src', { + safeAddSource('tactical-terrain-src', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-terrain-fill', type: 'fill', source: 'tactical-terrain-src', paint: { 'fill-color': '#6366f1', - 'fill-opacity': 0.08 + 'fill-opacity': 0.18 } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-terrain-line', type: 'line', source: 'tactical-terrain-src', paint: { - 'line-color': '#818cf8', - 'line-width': 2.5, - 'line-dasharray': [3, 2] + 'line-color': '#4f46e5', + 'line-width': 3.5, + 'line-opacity': 0.95 } }); @@ -1010,187 +1059,185 @@ export const TacticalDefenseView: React.FC = () => { } // Rich Spatial Feature Layers for Terrain Study (Slope, Wadis, Roads, Urban, Hazards) - initialMap.addSource('tactical-terrain-spatial-src', { + safeAddSource('tactical-terrain-spatial-src', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-terrain-spatial-poly-fill', type: 'fill', source: 'tactical-terrain-spatial-src', - filter: ['==', '$type', 'Polygon'], + filter: ['==', ['geometry-type'], 'Polygon'], paint: { 'fill-color': ['get', 'color'], 'fill-opacity': [ 'match', ['get', 'layerType'], - 'cliff', 0.38, - 'urban', 0.20, - 'hazard', 0.24, - 'slope-sector', 0.14, - 0.15 + 'cliff', 0.45, + 'urban', 0.30, + 'hazard', 0.35, + 'slope-sector', 0.22, + 0.20 ] } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-terrain-spatial-poly-line', type: 'line', source: 'tactical-terrain-spatial-src', - filter: ['==', '$type', 'Polygon'], + filter: ['==', ['geometry-type'], 'Polygon'], paint: { 'line-color': [ 'match', ['get', 'layerType'], 'cliff', '#ef4444', - 'rgba(255, 255, 255, 0.10)' + 'rgba(255, 255, 255, 0.4)' ], 'line-width': [ 'match', ['get', 'layerType'], - 'cliff', 1.8, - 0.4 + 'cliff', 2.5, + 1.0 ] } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-terrain-spatial-lines', type: 'line', source: 'tactical-terrain-spatial-src', - filter: ['==', '$type', 'LineString'], + filter: ['==', ['geometry-type'], 'LineString'], paint: { 'line-color': ['get', 'color'], 'line-width': [ 'match', ['get', 'layerType'], - 'cliff', 3.5, - 'road', 4.0, - 'wadi', 3.5, - 'ridge', 2.5, - 2.5 + 'cliff', 4.0, + 'road', 4.5, + 'wadi', 4.5, + 'ridge', 3.5, + 3.0 ] } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-terrain-spatial-points', type: 'circle', source: 'tactical-terrain-spatial-src', - filter: ['==', '$type', 'Point'], + filter: ['==', ['geometry-type'], 'Point'], paint: { - 'circle-radius': 5, + 'circle-radius': 7, 'circle-color': ['coalesce', ['get', 'color'], '#38bdf8'], - 'circle-stroke-width': 2, + 'circle-stroke-width': 2.5, 'circle-stroke-color': '#ffffff' } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-terrain-spatial-labels', type: 'symbol', source: 'tactical-terrain-spatial-src', - filter: ['all', ['==', '$type', 'Point'], ['has', 'title']], + filter: ['all', ['==', ['geometry-type'], 'Point'], ['has', 'title']], layout: { 'text-field': ['get', 'title'], 'text-size': 12, - 'text-font': ['Noto Sans Bold', 'Open Sans Bold'], + 'text-font': ['Noto Sans Regular'], 'text-anchor': 'bottom', - 'text-offset': [0, -0.6], + 'text-offset': [0, -0.8], 'symbol-placement': 'point', - 'text-allow-overlap': false + 'text-allow-overlap': true }, paint: { 'text-color': '#ffffff', 'text-halo-color': '#0f172a', - 'text-halo-width': 2.5 + 'text-halo-width': 3 } }); // 1. Line of Sight Layer - initialMap.addSource('tactical-los-src', { + safeAddSource('tactical-los-src', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-los-line', type: 'line', source: 'tactical-los-src', - filter: ['==', '$type', 'LineString'], + filter: ['==', ['geometry-type'], 'LineString'], paint: { 'line-color': ['case', ['==', ['get', 'blocked'], true], '#ef4444', '#22c55e'], - 'line-width': 4, - 'line-dasharray': [2, 1] + 'line-width': 5 } }); // 2. Viewshed 360 Polygon Layer - initialMap.addSource('tactical-viewshed-src', { + safeAddSource('tactical-viewshed-src', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-viewshed-fill', type: 'fill', source: 'tactical-viewshed-src', paint: { - 'fill-color': '#22c55e', - 'fill-opacity': 0.25 + 'fill-color': '#ff0000', + 'fill-opacity': 0.80 } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-viewshed-outline', type: 'line', source: 'tactical-viewshed-src', paint: { - 'line-color': '#4ade80', - 'line-width': 2, - 'line-dasharray': [3, 2] + 'line-color': '#ffff00', + 'line-width': 8.0, + 'line-opacity': 1.0 } }); // 3. Artillery Trajectory Layer - initialMap.addSource('tactical-artillery-src', { + safeAddSource('tactical-artillery-src', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-artillery-line', type: 'line', source: 'tactical-artillery-src', paint: { 'line-color': '#f97316', - 'line-width': 4 + 'line-width': 5 } }); // 4. Minefield Barrier Layer - initialMap.addSource('tactical-minefield-src', { + safeAddSource('tactical-minefield-src', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-minefield-line', type: 'line', source: 'tactical-minefield-src', paint: { 'line-color': '#dc2626', - 'line-width': 6, - 'line-dasharray': [1, 1] + 'line-width': 6 } }); // 5. Tactical Symbols Layer - initialMap.addSource('tactical-symbols-src', { + safeAddSource('tactical-symbols-src', { type: 'geojson', - data: { - type: 'FeatureCollection', + data: { + type: 'FeatureCollection', features: DEFAULT_SYMBOLS.map(s => ({ type: 'Feature', properties: { type: s.type, name: s.name }, @@ -1199,7 +1246,7 @@ export const TacticalDefenseView: React.FC = () => { } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-symbols-pts', type: 'circle', source: 'tactical-symbols-src', @@ -1222,7 +1269,7 @@ export const TacticalDefenseView: React.FC = () => { } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-symbols-lbls', type: 'symbol', source: 'tactical-symbols-src', @@ -1241,56 +1288,56 @@ export const TacticalDefenseView: React.FC = () => { }); // 6. Isochrone Reachability Polygons Layer (خارطة زمن الاستجابة والوصول) - initialMap.addSource('tactical-isochrone-src', { + safeAddSource('tactical-isochrone-src', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-isochrone-fill', type: 'fill', source: 'tactical-isochrone-src', paint: { 'fill-color': ['coalesce', ['get', 'color'], '#22c55e'], - 'fill-opacity': 0.32 + 'fill-opacity': 0.45 } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-isochrone-line', type: 'line', source: 'tactical-isochrone-src', paint: { 'line-color': ['coalesce', ['get', 'color'], '#22c55e'], - 'line-width': 2.5, + 'line-width': 3.5, 'line-opacity': 0.95 } }); // 7. Digital IPB Overlays Layer (منظومة الشفافات التكتيكية الرقمية لإعداد ساحة المعركة) - initialMap.addSource('tactical-ipb-src', { + safeAddSource('tactical-ipb-src', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-ipb-fill', type: 'fill', source: 'tactical-ipb-src', - filter: ['==', '$type', 'Polygon'], + filter: ['==', ['geometry-type'], 'Polygon'], paint: { 'fill-color': ['coalesce', ['get', 'color'], '#38bdf8'], 'fill-opacity': ['coalesce', ['get', 'opacity'], 0.65] } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-ipb-line', type: 'line', source: 'tactical-ipb-src', filter: [ 'any', - ['==', '$type', 'LineString'], + ['==', ['geometry-type'], 'LineString'], ['==', ['get', 'layer'], 'layer_10'], ['==', ['get', 'layer'], 'layer_7'], ['==', ['get', 'layer'], 'layer_4'] @@ -1308,7 +1355,7 @@ export const TacticalDefenseView: React.FC = () => { } }); - initialMap.addLayer({ + safeAddLayer({ id: 'tactical-ipb-symbol', type: 'symbol', source: 'tactical-ipb-src', @@ -1334,6 +1381,11 @@ export const TacticalDefenseView: React.FC = () => { 'text-halo-width': 2.5 } }); + + bringTacticalLayersToFront(initialMap); + mapLoadedRef.current = true; + setMapLoaded(true); + (window as any).tacticalMap = initialMap; }); return () => { @@ -1344,41 +1396,47 @@ export const TacticalDefenseView: React.FC = () => { }; }, []); + // Draw sector boundary circle immediately on map + const drawSectorCircle = (center: [number, number], radius: number) => { + if (!map.current || !map.current.getSource('tactical-terrain-src')) return; + const points = 64; + const coords: [number, number][] = []; + const R_earth = 6371000; + for (let i = 0; i <= points; i++) { + const angle = (i / points) * 2 * Math.PI; + const dLat = (radius / R_earth) * (180 / Math.PI) * Math.cos(angle); + const dLng = (radius / (R_earth * Math.cos((center[0] * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(angle); + coords.push([Number((center[1] + dLng).toFixed(6)), Number((center[0] + dLat).toFixed(6))]); + } + coords.push(coords[0]); + + (map.current.getSource('tactical-terrain-src') as maplibregl.GeoJSONSource).setData({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [coords] + } + } + ] + }); + bringTacticalLayersToFront(map.current); + }; + // Run Terrain Tactical Study const runTerrainStudy = () => { if (!terrainCenter) return; + // Draw boundary circle immediately so feedback is instant + drawSectorCircle(terrainCenter, terrainRadius); setTerrainLoading(true); calculateTerrainStudy(terrainCenter[0], terrainCenter[1], terrainRadius) .then((res) => { setTerrainResult(res); setTerrainLoading(false); - - // 1. Draw sector boundary polygon on map - if (map.current && map.current.getSource('tactical-terrain-src')) { - const points = 48; - const coords: [number, number][] = []; - const R_earth = 6371000; - for (let i = 0; i <= points; i++) { - const angle = (i / points) * 2 * Math.PI; - const dLat = (terrainRadius / R_earth) * (180 / Math.PI) * Math.cos(angle); - const dLng = (terrainRadius / (R_earth * Math.cos((terrainCenter[0] * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(angle); - coords.push([terrainCenter[1] + dLng, terrainCenter[0] + dLat]); - } - - (map.current.getSource('tactical-terrain-src') as maplibregl.GeoJSONSource).setData({ - type: 'FeatureCollection', - features: [ - { - type: 'Feature', - properties: {}, - geometry: { - type: 'Polygon', - coordinates: [coords] - } - } - ] - }); - } + drawSectorCircle(terrainCenter, terrainRadius); }) .catch(() => setTerrainLoading(false)); }; @@ -1396,7 +1454,7 @@ export const TacticalDefenseView: React.FC = () => { // Sync Spatial GeoJSON Features with Active Layer Toggles useEffect(() => { if (!map.current || !map.current.getSource('tactical-terrain-spatial-src')) return; - if (!terrainResult?.spatialGeoJson || (mode !== 'terrain' && mode !== 'ipb')) { + if (!terrainResult?.spatialGeoJson) { (map.current.getSource('tactical-terrain-spatial-src') as maplibregl.GeoJSONSource).setData({ type: 'FeatureCollection', features: [] @@ -1418,7 +1476,8 @@ export const TacticalDefenseView: React.FC = () => { type: 'FeatureCollection', features: filtered }); - }, [terrainResult, showPeaksAndValleys, showSlopeSectors, showNaturalObstacles, showRoadCorridors, showUrbanZones, showHazardousGround, mode]); + bringTacticalLayersToFront(map.current); + }, [terrainResult, showPeaksAndValleys, showSlopeSectors, showNaturalObstacles, showRoadCorridors, showUrbanZones, showHazardousGround]); // 1. Auto-run real mathematical IPB calculation when center or radius changes or when entering IPB mode useEffect(() => { @@ -1453,9 +1512,10 @@ export const TacticalDefenseView: React.FC = () => { // Auto-run initial terrain study on mount & coordinates change useEffect(() => { runTerrainStudy(); - }, [terrainCenter, terrainRadius]); + }, [terrainCenter, terrainRadius, mapLoaded]); const ensureViewshedSourceAndLayer = (m: maplibregl.Map) => { + if (!m) return; if (!m.getSource('tactical-viewshed-src')) { m.addSource('tactical-viewshed-src', { type: 'geojson', @@ -1468,8 +1528,8 @@ export const TacticalDefenseView: React.FC = () => { type: 'fill', source: 'tactical-viewshed-src', paint: { - 'fill-color': '#22c55e', - 'fill-opacity': 0.35 + 'fill-color': '#ff0000', + 'fill-opacity': 0.80 } }); } @@ -1479,15 +1539,17 @@ export const TacticalDefenseView: React.FC = () => { type: 'line', source: 'tactical-viewshed-src', paint: { - 'line-color': '#16a34a', - 'line-width': 3, - 'line-dasharray': [3, 2] + 'line-color': '#ffff00', + 'line-width': 8.0, + 'line-opacity': 1.0 } }); } + bringTacticalLayersToFront(m); }; const ensureLosSourceAndLayer = (m: maplibregl.Map) => { + if (!m) return; if (!m.getSource('tactical-los-src')) { m.addSource('tactical-los-src', { type: 'geojson', @@ -1499,17 +1561,18 @@ export const TacticalDefenseView: React.FC = () => { id: 'tactical-los-line', type: 'line', source: 'tactical-los-src', - filter: ['==', '$type', 'LineString'], + filter: ['==', ['geometry-type'], 'LineString'], paint: { 'line-color': ['case', ['==', ['get', 'blocked'], true], '#ef4444', '#22c55e'], - 'line-width': 4, - 'line-dasharray': [2, 1] + 'line-width': 5 } }); } + bringTacticalLayersToFront(m); }; const ensureArtillerySourceAndLayer = (m: maplibregl.Map) => { + if (!m) return; if (!m.getSource('tactical-artillery-src')) { m.addSource('tactical-artillery-src', { type: 'geojson', @@ -1523,13 +1586,14 @@ export const TacticalDefenseView: React.FC = () => { source: 'tactical-artillery-src', paint: { 'line-color': '#f97316', - 'line-width': 4 + 'line-width': 5 } }); } }; const ensureMinefieldSourceAndLayer = (m: maplibregl.Map) => { + if (!m) return; if (!m.getSource('tactical-minefield-src')) { m.addSource('tactical-minefield-src', { type: 'geojson', @@ -1543,8 +1607,7 @@ export const TacticalDefenseView: React.FC = () => { source: 'tactical-minefield-src', paint: { 'line-color': '#dc2626', - 'line-width': 6, - 'line-dasharray': [1, 1] + 'line-width': 6 } }); } @@ -1600,11 +1663,12 @@ export const TacticalDefenseView: React.FC = () => { type: 'FeatureCollection', features: feats }); + bringTacticalLayersToFront(map.current); } } }) .catch(() => setLosLoading(false)); - }, [losPointA, losPointB, losObsHeight, losTgtHeight]); + }, [losPointA, losPointB, losObsHeight, losTgtHeight, mapLoaded]); // Update Minefield Analysis & Map Layer useEffect(() => { @@ -1646,11 +1710,12 @@ export const TacticalDefenseView: React.FC = () => { } ] }); + bringTacticalLayersToFront(map.current); } } }) .catch(() => setMineLoading(false)); - }, [mineStart, mineEnd, mineDensity]); + }, [mineStart, mineEnd, mineDensity, mapLoaded]); // Update Artillery Trajectory useEffect(() => { @@ -1669,31 +1734,101 @@ export const TacticalDefenseView: React.FC = () => { return; } runArtilleryMission(); - }, [gunPos, targetPos, caliber, muzzleVel]); + }, [gunPos, targetPos, caliber, muzzleVel, mapLoaded]); // Update 360 Viewshed const runViewshed360 = () => { if (!viewshedCenter) return; setViewshedLoading(true); + console.log('[VIEWSHED-DEBUG] Starting calculation for center:', viewshedCenter, 'height:', viewshedHeight, 'radius:', viewshedRadius); calculateRadialViewshed(viewshedCenter[0], viewshedCenter[1], viewshedHeight, viewshedRadius, 36) .then((res) => { setViewshedResult(res); setViewshedLoading(false); + console.log('[VIEWSHED-DEBUG] API response received. polygonGeoJson:', !!res.polygonGeoJson, 'visibleAreaKm2:', res.visibleAreaKm2, 'visiblePercentage:', res.visiblePercentage); + + if (res.polygonGeoJson) { + const geom = (res.polygonGeoJson as any).geometry; + console.log('[VIEWSHED-DEBUG] Polygon geometry type:', geom?.type, 'coordinates rings:', geom?.coordinates?.length, 'ring[0] points:', geom?.coordinates?.[0]?.length); + if (geom?.coordinates?.[0]?.[0]) { + console.log('[VIEWSHED-DEBUG] First coord:', JSON.stringify(geom.coordinates[0][0]), 'Last coord:', JSON.stringify(geom.coordinates[0][geom.coordinates[0].length - 1])); + console.log('[VIEWSHED-DEBUG] Full polygon JSON (first 500 chars):', JSON.stringify(res.polygonGeoJson).substring(0, 500)); + } + // Store on window for console inspection + (window as any).__viewshedPolygon = res.polygonGeoJson; + } if (map.current) { + console.log('[VIEWSHED-DEBUG] map.current exists. isStyleLoaded:', map.current.isStyleLoaded(), 'loaded:', map.current.loaded()); + console.log('[VIEWSHED-DEBUG] Before ensure - src exists:', !!map.current.getSource('tactical-viewshed-src'), 'fill exists:', !!map.current.getLayer('tactical-viewshed-fill'), 'outline exists:', !!map.current.getLayer('tactical-viewshed-outline')); + ensureViewshedSourceAndLayer(map.current); + + console.log('[VIEWSHED-DEBUG] After ensure - src exists:', !!map.current.getSource('tactical-viewshed-src'), 'fill exists:', !!map.current.getLayer('tactical-viewshed-fill'), 'outline exists:', !!map.current.getLayer('tactical-viewshed-outline')); + const src = map.current.getSource('tactical-viewshed-src') as maplibregl.GeoJSONSource; if (src && res.polygonGeoJson) { - src.setData({ - type: 'FeatureCollection', + const fc = { + type: 'FeatureCollection' as const, features: [res.polygonGeoJson] - }); + }; + console.log('[VIEWSHED-DEBUG] Calling src.setData with FeatureCollection, features count:', fc.features.length); + src.setData(fc); + + if (map.current.getLayer('tactical-viewshed-fill')) { + map.current.setLayoutProperty('tactical-viewshed-fill', 'visibility', 'visible'); + map.current.setPaintProperty('tactical-viewshed-fill', 'fill-color', '#ff0000'); + map.current.setPaintProperty('tactical-viewshed-fill', 'fill-opacity', 0.80); + console.log('[VIEWSHED-DEBUG] Set tactical-viewshed-fill visible, RED, opacity 0.80'); + } + if (map.current.getLayer('tactical-viewshed-outline')) { + map.current.setLayoutProperty('tactical-viewshed-outline', 'visibility', 'visible'); + console.log('[VIEWSHED-DEBUG] Set tactical-viewshed-outline visible'); + } + bringTacticalLayersToFront(map.current); + + // Log full layer order for debugging + const allLayerIds = map.current.getStyle().layers.map((l: any) => l.id); + const vsIdx = allLayerIds.indexOf('tactical-viewshed-fill'); + console.log('[VIEWSHED-DEBUG] Layer order - viewshed-fill at index:', vsIdx, '/', allLayerIds.length, 'last 10 layers:', allLayerIds.slice(-10).join(', ')); + + try { + const ring = (res.polygonGeoJson.geometry as any)?.coordinates?.[0]; + if (ring && ring.length > 2) { + const lngs = ring.map((c: number[]) => c[0]); + const lats = ring.map((c: number[]) => c[1]); + const bounds: [[number, number], [number, number]] = [ + [Math.min(...lngs), Math.min(...lats)], + [Math.max(...lngs), Math.max(...lats)] + ]; + console.log('[VIEWSHED-DEBUG] fitBounds SW:', JSON.stringify(bounds[0]), 'NE:', JSON.stringify(bounds[1])); + map.current.fitBounds(bounds, { padding: 80, maxZoom: 14 }); + } else { + console.warn('[VIEWSHED-DEBUG] Ring too short for fitBounds:', ring?.length); + } + } catch (e) { + console.error('[VIEWSHED-DEBUG] fitBounds error:', e); + } + } else { + console.warn('[VIEWSHED-DEBUG] Missing src or polygonGeoJson. src:', !!src, 'polygonGeoJson:', !!res.polygonGeoJson); } + } else { + console.warn('[VIEWSHED-DEBUG] map.current is null'); } }) - .catch(() => setViewshedLoading(false)); + .catch((err) => { + console.error('[VIEWSHED-DEBUG] Calculation error:', err); + setViewshedLoading(false); + }); }; + // Auto-run Viewshed when entering viewshed mode or center changes + useEffect(() => { + if (mode === 'viewshed' && viewshedCenter && mapLoaded) { + runViewshed360(); + } + }, [mode, viewshedCenter, viewshedHeight, viewshedRadius, mapLoaded]); + // Run Artillery Fire Mission const runArtilleryMission = async () => { if (!gunPos || !targetPos) return; @@ -1717,18 +1852,23 @@ export const TacticalDefenseView: React.FC = () => { 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 } - } - ] - }); + if (map.current) { + ensureArtillerySourceAndLayer(map.current); + const src = map.current.getSource('tactical-artillery-src') as maplibregl.GeoJSONSource; + if (src) { + const lineCoords = (data.trajectoryPoints || []).map((p: any) => [p.lng, p.lat]); + src.setData({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: {}, + geometry: { type: 'LineString', coordinates: lineCoords } + } + ] + }); + bringTacticalLayersToFront(map.current); + } } } catch (e) { console.error('Artillery calculation error:', e); @@ -1816,7 +1956,25 @@ export const TacticalDefenseView: React.FC = () => { 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 { + bringTacticalLayersToFront(map.current); + + try { + const allCoords: [number, number][] = []; + isochroneData.featureCollection.features.forEach((feat: any) => { + if (feat.geometry?.coordinates?.[0]) { + allCoords.push(...feat.geometry.coordinates[0]); + } + }); + if (allCoords.length > 0) { + const lngs = allCoords.map((c: [number, number]) => c[0]); + const lats = allCoords.map((c: [number, number]) => c[1]); + map.current.fitBounds( + [[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]], + { padding: 80, maxZoom: 13.5 } + ); + } + } catch {} + } else if (mode !== 'isochrone') { (map.current.getSource('tactical-isochrone-src') as maplibregl.GeoJSONSource).setData({ type: 'FeatureCollection', features: [] @@ -1824,6 +1982,13 @@ export const TacticalDefenseView: React.FC = () => { } }, [mode, isochroneData]); + // Auto-run Isochrone calculation when entering mode or center/profile/times change + useEffect(() => { + if (mode === 'isochrone' && isochroneCenter && mapLoaded) { + runIsochroneCalculation(isochroneCenter, isochroneProfile, isochroneTimes); + } + }, [mode, isochroneCenter, isochroneProfile, isochroneTimes, mapLoaded]); + // Run Isochrone Calculation const runIsochroneCalculation = async (center = isochroneCenter, profile = isochroneProfile, times = isochroneTimes) => { if (!center) return; @@ -2210,7 +2375,7 @@ ${terrainResult.tacticalRecommendations.map((r, i) => `${i + 1}. ${r}`).join('\n {/* Main Tactical Workspace */}
- + {/* Left Side Tactical Control Deck */}