feat(tactical): add tactical line of sight API, elevation engine, dynamic step resolution, weather module and executive showcase
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user