feat(tactical): add tactical line of sight API, elevation engine, dynamic step resolution, weather module and executive showcase

This commit is contained in:
Hamza-Ayed
2026-08-17 19:25:39 +03:00
parent 03cdc6fe9c
commit a0a5a09135
29 changed files with 3896 additions and 190 deletions
+368 -29
View File
@@ -4,6 +4,22 @@ import 'maplibre-gl/dist/maplibre-gl.css';
import mlcontour from 'maplibre-contour';
import { attachIconLoader } from '../utils/mapIcons';
interface MapProps {
onMapLoad: (map: maplibregl.Map) => void;
onMapClick?: (lat: number, lng: number) => void;
show3D?: boolean;
showPOIs?: boolean;
showTerrain?: boolean;
showContours?: boolean;
showAdminBoundaries?: boolean;
showWeather?: boolean;
currentRegion?: string;
onCityClick?: (city: any) => void;
losActive?: boolean;
losPointA?: [number, number] | null;
losPointB?: [number, number] | null;
}
const demSource = new mlcontour.DemSource({
url: 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png',
encoding: 'terrarium',
@@ -12,45 +28,136 @@ const demSource = new mlcontour.DemSource({
});
demSource.setupMaplibre(maplibregl);
interface MapComponentProps {
onMapLoad: (map: maplibregl.Map) => void;
onMapClick?: (lat: number, lng: number) => void;
show3D?: boolean;
showPOIs?: boolean;
showTerrain?: boolean;
showContours?: boolean;
showAdminBoundaries?: boolean;
}
const MapComponent: React.FC<MapComponentProps> = ({
const MapComponent: React.FC<MapProps> = ({
onMapLoad,
onMapClick,
show3D = false,
showPOIs = true,
showTerrain = true,
showTerrain = false,
showContours = false,
showAdminBoundaries = true
showAdminBoundaries = false,
showWeather = false,
currentRegion = 'Jordan',
onCityClick,
losActive = false,
losPointA = null,
losPointB = null,
}) => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(null);
// Sync toggles with map layers
// Synchronize Line of Sight (LOS) Tactical Layer
useEffect(() => {
if (!map.current || !map.current.isStyleLoaded()) return;
const features: any[] = [];
if (losActive) {
if (losPointA) {
features.push({
type: 'Feature',
properties: { role: 'obs', label: 'A (الراصد)' },
geometry: { type: 'Point', coordinates: [losPointA[1], losPointA[0]] }
});
}
if (losPointB) {
features.push({
type: 'Feature',
properties: { role: 'tgt', label: 'B (الهدف)' },
geometry: { type: 'Point', coordinates: [losPointB[1], losPointB[0]] }
});
}
if (losPointA && losPointB) {
features.push({
type: 'Feature',
properties: { role: 'ray' },
geometry: {
type: 'LineString',
coordinates: [
[losPointA[1], losPointA[0]],
[losPointB[1], losPointB[0]]
]
}
});
}
}
const losGeoJson = { type: 'FeatureCollection', features };
if (!map.current.getSource('los-source')) {
map.current.addSource('los-source', {
type: 'geojson',
data: losGeoJson as any
});
map.current.addLayer({
id: 'los-line',
type: 'line',
source: 'los-source',
filter: ['==', '$type', 'LineString'],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: {
'line-color': '#f59e0b',
'line-width': 3.5,
'line-dasharray': [2, 2]
}
});
map.current.addLayer({
id: 'los-points',
type: 'circle',
source: 'los-source',
filter: ['==', '$type', 'Point'],
paint: {
'circle-radius': 8,
'circle-color': [
'match',
['get', 'role'],
'obs', '#3b82f6',
'tgt', '#ef4444',
'#ffffff'
],
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2.5
}
});
map.current.addLayer({
id: 'los-labels',
type: 'symbol',
source: 'los-source',
filter: ['==', '$type', 'Point'],
layout: {
'text-field': ['get', 'label'],
'text-size': 12,
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
'text-offset': [0, -1.6],
'text-anchor': 'bottom'
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#0f172a',
'text-halo-width': 2
}
});
} else {
(map.current.getSource('los-source') as maplibregl.GeoJSONSource).setData(losGeoJson as any);
map.current.setLayoutProperty('los-line', 'visibility', losActive ? 'visible' : 'none');
map.current.setLayoutProperty('los-points', 'visibility', losActive ? 'visible' : 'none');
map.current.setLayoutProperty('los-labels', 'visibility', losActive ? 'visible' : 'none');
}
}, [losActive, losPointA, losPointB]);
// Synchronize layers visibility
useEffect(() => {
if (!map.current || !map.current.isStyleLoaded()) return;
// Toggle 3D Buildings
['building-3d', 'building-3d-osm'].forEach(layerId => {
if (map.current!.getLayer(layerId)) {
map.current!.setLayoutProperty(layerId, 'visibility', show3D ? 'visible' : 'none');
}
});
if (map.current.getLayer('overture-building-footprint')) {
map.current.setLayoutProperty('overture-building-footprint', 'visibility', show3D ? 'none' : 'visible');
if (map.current.getLayer('3d-buildings')) {
map.current.setLayoutProperty('3d-buildings', 'visibility', show3D ? 'visible' : 'none');
}
map.current.easeTo({ pitch: show3D ? 55 : 0, duration: 600 });
// Toggle POI Layers
const poiLayers = ['poi-icons', 'place-labels', 'overture-building-names', 'places-jordan-labels'];
// Toggle POIs
const poiLayers = ['poi-level-1', 'poi-level-2', 'poi-level-3', 'poi-labels'];
poiLayers.forEach(layerId => {
if (map.current!.getLayer(layerId)) {
map.current!.setLayoutProperty(layerId, 'visibility', showPOIs ? 'visible' : 'none');
@@ -83,7 +190,192 @@ const MapComponent: React.FC<MapComponentProps> = ({
map.current!.setLayoutProperty(layerId, 'visibility', showAdminBoundaries ? 'visible' : 'none');
}
});
}, [show3D, showPOIs, showTerrain, showContours, showAdminBoundaries]);
const weatherLayers = ['weather-city-circles', 'weather-city-icons', 'weather-wind-arrows', 'weather-wind-speed'];
weatherLayers.forEach(layerId => {
if (map.current!.getLayer(layerId)) {
map.current!.setLayoutProperty(layerId, 'visibility', showWeather ? 'visible' : 'none');
}
});
}, [show3D, showPOIs, showTerrain, showContours, showAdminBoundaries, showWeather]);
// Weather data fetching and vector rendering
useEffect(() => {
if (!map.current || !map.current.isStyleLoaded()) return;
if (showWeather) {
const fetchWeather = async () => {
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
const apiKey = (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
const regionParam = currentRegion ? `?region=${encodeURIComponent(currentRegion)}` : '';
const citiesRes = await fetch(`${apiUrl}/weather/cities${regionParam}`, {
headers: { 'x-api-key': apiKey }
});
const rawCitiesData = await citiesRes.json();
// Convert cities array to GeoJSON FeatureCollection
const citiesGeoJson = {
type: 'FeatureCollection',
features: (Array.isArray(rawCitiesData) ? rawCitiesData : []).map((city: any) => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [city.lng, city.lat]
},
properties: {
name: city.name,
name_ar: city.name_ar,
temperature: Number(city.current?.temp ?? 20),
feelsLike: Number(city.current?.feelsLike ?? 20),
humidity: Number(city.current?.humidity ?? 50),
weatherCode: Number(city.current?.weatherCode ?? 0),
windSpeed: Number(city.current?.windSpeed ?? 10),
windDirection: Number(city.current?.windDirection ?? 0),
cloudCover: Number(city.current?.cloudCover ?? 0),
precipitation: Number(city.current?.precipitation ?? 0),
isDay: Number(city.current?.isDay ?? 1),
forecast: JSON.stringify(city.daily || [])
}
}))
};
if (!map.current!.getSource('weather-cities-source')) {
map.current!.addSource('weather-cities-source', { type: 'geojson', data: citiesGeoJson as any });
// 1. Soft temperature indicator circle badge
map.current!.addLayer({
id: 'weather-city-circles',
type: 'circle',
source: 'weather-cities-source',
paint: {
'circle-radius': 24,
'circle-color': [
'step', ['to-number', ['get', 'temperature'], 20],
'#3b82f6', 15,
'#10b981', 25,
'#f59e0b', 35,
'#ef4444'
],
'circle-opacity': 0.9,
'circle-stroke-width': 2.5,
'circle-stroke-color': '#ffffff'
}
});
// 2. City name and temperature text badge
map.current!.addLayer({
id: 'weather-city-icons',
type: 'symbol',
source: 'weather-cities-source',
layout: {
'text-field': ['concat', ['coalesce', ['get', 'name_ar'], ''], '\n', ['to-string', ['round', ['to-number', ['get', 'temperature'], 20]]], '°C'],
'text-size': 11,
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
'text-justify': 'center',
'text-anchor': 'center'
},
paint: {
'text-color': '#ffffff',
'text-halo-color': 'rgba(0, 0, 0, 0.6)',
'text-halo-width': 1.5
}
});
// 3. Wind indicator arrow (rotated by wind direction angle)
map.current!.addLayer({
id: 'weather-wind-arrows',
type: 'symbol',
source: 'weather-cities-source',
minzoom: 4,
layout: {
'text-field': '➤',
'text-rotation-alignment': 'map',
'text-rotate': ['to-number', ['get', 'windDirection'], 0],
'text-size': 14,
'text-offset': [2.2, 0],
'text-allow-overlap': true
},
paint: {
'text-color': '#38bdf8',
'text-halo-color': '#0f172a',
'text-halo-width': 2
}
});
// 4. Wind speed badge (e.g. 15 km/h)
map.current!.addLayer({
id: 'weather-wind-speed',
type: 'symbol',
source: 'weather-cities-source',
minzoom: 4,
layout: {
'text-field': ['concat', ['to-string', ['round', ['to-number', ['get', 'windSpeed'], 10]]], ' km/h'],
'text-size': 9,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-offset': [0, 2.6],
'text-anchor': 'top',
'text-allow-overlap': false
},
paint: {
'text-color': '#bae6fd',
'text-halo-color': 'rgba(15, 23, 42, 0.85)',
'text-halo-width': 2
}
});
const handleCityClick = (e: any) => {
if (e.originalEvent) {
e.originalEvent.stopPropagation();
}
if (onCityClick && e.features && e.features.length > 0) {
onCityClick(e.features[0].properties);
}
};
map.current!.on('click', 'weather-city-circles', handleCityClick);
map.current!.on('click', 'weather-city-icons', handleCityClick);
map.current!.on('mouseenter', 'weather-city-circles', () => {
if (map.current) map.current.getCanvas().style.cursor = 'pointer';
});
map.current!.on('mouseleave', 'weather-city-circles', () => {
if (map.current) map.current.getCanvas().style.cursor = '';
});
} else {
(map.current!.getSource('weather-cities-source') as maplibregl.GeoJSONSource).setData(citiesGeoJson as any);
map.current!.setLayoutProperty('weather-city-circles', 'visibility', 'visible');
map.current!.setLayoutProperty('weather-city-icons', 'visibility', 'visible');
map.current!.setLayoutProperty('weather-wind-arrows', 'visibility', 'visible');
map.current!.setLayoutProperty('weather-wind-speed', 'visibility', 'visible');
}
} catch (e) {
console.warn('Weather fetch failed', e);
}
};
fetchWeather();
} else {
const weatherLayers = ['weather-city-circles', 'weather-city-icons', 'weather-wind-arrows', 'weather-wind-speed'];
weatherLayers.forEach(layerId => {
if (map.current && map.current.getLayer(layerId)) {
map.current.setLayoutProperty(layerId, 'visibility', 'none');
}
});
}
}, [showWeather, currentRegion, onCityClick]);
const onMapClickRef = useRef(onMapClick);
useEffect(() => {
onMapClickRef.current = onMapClick;
}, [onMapClick]);
// Update canvas cursor when LOS is active
useEffect(() => {
if (map.current) {
map.current.getCanvas().style.cursor = losActive ? 'crosshair' : '';
}
}, [losActive]);
useEffect(() => {
if (map.current) return;
@@ -129,6 +421,53 @@ const MapComponent: React.FC<MapComponentProps> = ({
initialMap.on('load', () => {
console.log("MapComponent: Map Loaded Successfully");
// Initialize Route Source and Layers
if (!initialMap.getSource('route')) {
initialMap.addSource('route', {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates: []
}
}
});
// Route outer shadow/glow
initialMap.addLayer({
id: 'route-casing',
type: 'line',
source: 'route',
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': '#0369a1',
'line-width': 9,
'line-opacity': 0.8
}
});
// Route inner bright line
initialMap.addLayer({
id: 'route-line',
type: 'line',
source: 'route',
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': '#38bdf8',
'line-width': 5,
'line-opacity': 1.0
}
});
}
const contourUrl = demSource.contourProtocolUrl({
thresholds: {
10: [100, 500],
@@ -211,13 +550,13 @@ const MapComponent: React.FC<MapComponentProps> = ({
onMapLoad(map.current!);
});
map.current.on('click', (e) => {
if (onMapClick) {
onMapClick(e.lngLat.lat, e.lngLat.lng);
initialMap.on('click', (e) => {
if (onMapClickRef.current) {
onMapClickRef.current(e.lngLat.lat, e.lngLat.lng);
}
});
map.current.on('error', (e) => {
initialMap.on('error', (e) => {
console.error("MapComponent: Map Error:", e);
});