From 043cd052b687722ece08e3861b35cb5b467d2688 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Thu, 24 Sep 2026 13:23:38 +0300 Subject: [PATCH] fix(tactical): resolve MapLibre line-dasharray crash and add self-healing layers for viewshed, LOS, and artillery --- apps/web/src/pages/TacticalDefenseView.tsx | 204 ++++++++++++++++----- apps/web/src/utils/elevationService.ts | 15 +- 2 files changed, 166 insertions(+), 53 deletions(-) diff --git a/apps/web/src/pages/TacticalDefenseView.tsx b/apps/web/src/pages/TacticalDefenseView.tsx index e771903..54f861d 100644 --- a/apps/web/src/pages/TacticalDefenseView.tsx +++ b/apps/web/src/pages/TacticalDefenseView.tsx @@ -1070,15 +1070,6 @@ export const TacticalDefenseView: React.FC = () => { 'wadi', 3.5, 'ridge', 2.5, 2.5 - ], - 'line-dasharray': [ - 'match', - ['get', 'layerType'], - 'cliff', ['literal', [2, 1]], - 'ridge', ['literal', [3, 1.5]], - 'road', ['literal', [1, 0]], - 'wadi', ['literal', [1, 0]], - ['literal', [1, 0]] ] } }); @@ -1464,14 +1455,113 @@ export const TacticalDefenseView: React.FC = () => { runTerrainStudy(); }, [terrainCenter, terrainRadius]); + const ensureViewshedSourceAndLayer = (m: maplibregl.Map) => { + if (!m.getSource('tactical-viewshed-src')) { + m.addSource('tactical-viewshed-src', { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + }); + } + if (!m.getLayer('tactical-viewshed-fill')) { + m.addLayer({ + id: 'tactical-viewshed-fill', + type: 'fill', + source: 'tactical-viewshed-src', + paint: { + 'fill-color': '#22c55e', + 'fill-opacity': 0.35 + } + }); + } + if (!m.getLayer('tactical-viewshed-outline')) { + m.addLayer({ + id: 'tactical-viewshed-outline', + type: 'line', + source: 'tactical-viewshed-src', + paint: { + 'line-color': '#16a34a', + 'line-width': 3, + 'line-dasharray': [3, 2] + } + }); + } + }; + + const ensureLosSourceAndLayer = (m: maplibregl.Map) => { + if (!m.getSource('tactical-los-src')) { + m.addSource('tactical-los-src', { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + }); + } + if (!m.getLayer('tactical-los-line')) { + m.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] + } + }); + } + }; + + const ensureArtillerySourceAndLayer = (m: maplibregl.Map) => { + if (!m.getSource('tactical-artillery-src')) { + m.addSource('tactical-artillery-src', { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + }); + } + if (!m.getLayer('tactical-artillery-line')) { + m.addLayer({ + id: 'tactical-artillery-line', + type: 'line', + source: 'tactical-artillery-src', + paint: { + 'line-color': '#f97316', + 'line-width': 4 + } + }); + } + }; + + const ensureMinefieldSourceAndLayer = (m: maplibregl.Map) => { + if (!m.getSource('tactical-minefield-src')) { + m.addSource('tactical-minefield-src', { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + }); + } + if (!m.getLayer('tactical-minefield-line')) { + m.addLayer({ + id: 'tactical-minefield-line', + type: 'line', + source: 'tactical-minefield-src', + paint: { + 'line-color': '#dc2626', + 'line-width': 6, + 'line-dasharray': [1, 1] + } + }); + } + }; + // Update Line of Sight Calculations & Map Layer useEffect(() => { 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: [] - }); + if (map.current) { + ensureLosSourceAndLayer(map.current); + const src = map.current.getSource('tactical-los-src') as maplibregl.GeoJSONSource; + if (src) { + src.setData({ + type: 'FeatureCollection', + features: [] + }); + } } setLosResult(null); return; @@ -1483,7 +1573,8 @@ export const TacticalDefenseView: React.FC = () => { setLosResult(res); setLosLoading(false); - if (map.current && map.current.getSource('tactical-los-src')) { + if (map.current) { + ensureLosSourceAndLayer(map.current); const feats: any[] = [ { type: 'Feature', @@ -1503,10 +1594,13 @@ export const TacticalDefenseView: React.FC = () => { }); } - (map.current.getSource('tactical-los-src') as maplibregl.GeoJSONSource).setData({ - type: 'FeatureCollection', - features: feats - }); + const src = map.current.getSource('tactical-los-src') as maplibregl.GeoJSONSource; + if (src) { + src.setData({ + type: 'FeatureCollection', + features: feats + }); + } } }) .catch(() => setLosLoading(false)); @@ -1515,11 +1609,15 @@ export const TacticalDefenseView: React.FC = () => { // Update Minefield Analysis & Map Layer useEffect(() => { 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: [] - }); + if (map.current) { + ensureMinefieldSourceAndLayer(map.current); + const src = map.current.getSource('tactical-minefield-src') as maplibregl.GeoJSONSource; + if (src) { + src.setData({ + type: 'FeatureCollection', + features: [] + }); + } } setMineResult(null); return; @@ -1531,20 +1629,24 @@ export const TacticalDefenseView: React.FC = () => { setMineResult(res); setMineLoading(false); - if (map.current && map.current.getSource('tactical-minefield-src')) { - (map.current.getSource('tactical-minefield-src') as maplibregl.GeoJSONSource).setData({ - type: 'FeatureCollection', - features: [ - { - type: 'Feature', - properties: {}, - geometry: { - type: 'LineString', - coordinates: [[mineStart[1], mineStart[0]], [mineEnd[1], mineEnd[0]]] + if (map.current) { + ensureMinefieldSourceAndLayer(map.current); + const src = map.current.getSource('tactical-minefield-src') as maplibregl.GeoJSONSource; + if (src) { + src.setData({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: {}, + geometry: { + type: 'LineString', + coordinates: [[mineStart[1], mineStart[0]], [mineEnd[1], mineEnd[0]]] + } } - } - ] - }); + ] + }); + } } }) .catch(() => setMineLoading(false)); @@ -1553,11 +1655,15 @@ export const TacticalDefenseView: React.FC = () => { // 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: [] - }); + if (map.current) { + ensureArtillerySourceAndLayer(map.current); + const src = map.current.getSource('tactical-artillery-src') as maplibregl.GeoJSONSource; + if (src) { + src.setData({ + type: 'FeatureCollection', + features: [] + }); + } } setArtilleryResult(null); return; @@ -1574,11 +1680,15 @@ export const TacticalDefenseView: React.FC = () => { 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] - }); + if (map.current) { + ensureViewshedSourceAndLayer(map.current); + const src = map.current.getSource('tactical-viewshed-src') as maplibregl.GeoJSONSource; + if (src && res.polygonGeoJson) { + src.setData({ + type: 'FeatureCollection', + features: [res.polygonGeoJson] + }); + } } }) .catch(() => setViewshedLoading(false)); diff --git a/apps/web/src/utils/elevationService.ts b/apps/web/src/utils/elevationService.ts index 4f73331..77a4c4b 100644 --- a/apps/web/src/utils/elevationService.ts +++ b/apps/web/src/utils/elevationService.ts @@ -167,9 +167,10 @@ export async function sampleElevationsBatch( const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 2500); - const res = await fetch(`${apiUrl}/tactical/elevations`, { + const apiKey = localStorage.getItem('map_admin_key') || localStorage.getItem('intaleq_api_key') || (import.meta as any).env.VITE_ADMIN_API_KEY || (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX'; + const res = await fetch(`${apiUrl}/tactical/elevations?key=${encodeURIComponent(apiKey)}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey }, body: JSON.stringify({ coordinates: coords }), signal: controller.signal }); @@ -303,13 +304,13 @@ export async function calculateLineOfSight( // Try fetching high-precision result from Backend Tactical API try { const apiUrl = (import.meta as any).env.VITE_API_URL || '/api'; - const apiKey = (import.meta as any).env.VITE_API_KEY; + const apiKey = (import.meta as any).env.VITE_ADMIN_API_KEY || (import.meta as any).env.VITE_API_KEY || localStorage.getItem('map_admin_key') || localStorage.getItem('intaleq_api_key') || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX'; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 8000); - const res = await fetch(`${apiUrl}/tactical/line-of-sight?observerLat=${startLat}&observerLng=${startLng}&targetLat=${endLat}&targetLng=${endLng}&observerHeight=${obsHeight}&targetHeight=${tgtHeight}&samples=${samples}`, { - headers: apiKey ? { 'x-api-key': apiKey } : {}, + const res = await fetch(`${apiUrl}/tactical/line-of-sight?observerLat=${startLat}&observerLng=${startLng}&targetLat=${endLat}&targetLng=${endLng}&observerHeight=${obsHeight}&targetHeight=${tgtHeight}&samples=${samples}&key=${encodeURIComponent(apiKey)}`, { + headers: { 'x-api-key': apiKey }, signal: controller.signal }); clearTimeout(timeoutId); @@ -490,7 +491,9 @@ export async function calculateRadialViewshed( const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3500); - const res = await fetch(`${apiUrl}/tactical/viewshed?lat=${centerLat}&lng=${centerLng}&height=${obsHeight}&radius=${radiusMeters}&rays=${numRays}`, { + const apiKey = (import.meta as any).env.VITE_ADMIN_API_KEY || (import.meta as any).env.VITE_API_KEY || localStorage.getItem('map_admin_key') || localStorage.getItem('intaleq_api_key') || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX'; + const res = await fetch(`${apiUrl}/tactical/viewshed?lat=${centerLat}&lng=${centerLng}&height=${obsHeight}&radius=${radiusMeters}&rays=${numRays}&key=${encodeURIComponent(apiKey)}`, { + headers: { 'x-api-key': apiKey }, signal: controller.signal }); clearTimeout(timeoutId);