Files
maps-saas/apps/web/src/components/MapComponent.tsx
T

760 lines
25 KiB
TypeScript

import React, { useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { attachIconLoader } from '../utils/mapIcons';
import { getSharedDemSource } from '../utils/demSource';
const MapComponent: React.FC<MapProps> = ({
onMapLoad,
onMapClick,
show3D = false,
showPOIs = true,
showTerrain = false,
showContours = false,
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);
// 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
if (map.current.getLayer('3d-buildings')) {
map.current.setLayoutProperty('3d-buildings', 'visibility', show3D ? 'visible' : 'none');
}
// 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');
}
});
// Toggle Hillshading / Terrain
if (map.current.getLayer('hillshading')) {
map.current.setLayoutProperty('hillshading', 'visibility', showTerrain ? 'visible' : 'none');
}
if (map.current.getLayer('terrain-hillshade')) {
map.current.setLayoutProperty('terrain-hillshade', 'visibility', showTerrain ? 'visible' : 'none');
}
// Toggle Contours
const contourLayers = ['contour-lines-minor', 'contour-lines-major', 'contour-labels'];
contourLayers.forEach(layerId => {
if (map.current!.getLayer(layerId)) {
map.current!.setLayoutProperty(layerId, 'visibility', showContours ? 'visible' : 'none');
}
});
// Toggle Admin Boundaries
const adminLayers = [
'admin-boundary-national',
'admin-boundary-governorate-poly',
'admin-boundary-governorate',
'admin-boundary-district-poly',
'admin-boundary-district'
];
adminLayers.forEach(layerId => {
if (map.current!.getLayer(layerId)) {
map.current!.setLayoutProperty(layerId, 'visibility', showAdminBoundaries ? 'visible' : 'none');
}
});
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 || '';
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();
// 2. Fetch RainViewer Animated Cloud & Rain Radar
let radarTimer: any = null;
const fetchRadar = async () => {
try {
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json');
const data = await res.json();
const host = data.host || 'https://tilecache.rainviewer.com';
const frames = data.radar?.past || [];
if (frames.length > 0 && map.current && map.current.isStyleLoaded()) {
let currentIdx = frames.length - 1;
const getTileUrl = (i: number) => `${host}${frames[i].path}`;
if (!map.current.getSource('weather-radar-source')) {
map.current.addSource('weather-radar-source', {
type: 'raster',
tiles: [getTileUrl(currentIdx)],
tileSize: 256
});
map.current.addLayer({
id: 'weather-radar-layer',
type: 'raster',
source: 'weather-radar-source',
paint: {
'raster-opacity': 0.72,
'raster-fade-duration': 250
}
}, map.current.getLayer('weather-city-circles') ? 'weather-city-circles' : undefined);
} else {
map.current.setLayoutProperty('weather-radar-layer', 'visibility', 'visible');
}
// Animate through radar frames every 1200ms
if (frames.length > 1) {
radarTimer = setInterval(() => {
if (!map.current || !map.current.getSource('weather-radar-source')) return;
currentIdx = (currentIdx + 1) % frames.length;
const src = map.current.getSource('weather-radar-source') as any;
if (src && src.setTiles) {
src.setTiles([getTileUrl(currentIdx)]);
}
}, 1200);
}
}
} catch (err) {
console.warn('Radar fetch failed', err);
}
};
fetchRadar();
return () => {
if (radarTimer) clearInterval(radarTimer);
};
} else {
const weatherLayers = ['weather-city-circles', 'weather-city-icons', 'weather-wind-arrows', 'weather-wind-speed', 'weather-radar-layer'];
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;
if (!mapContainer.current) return;
// Load RTL Text Plugin for correct Arabic rendering
if (maplibregl.getRTLTextPluginStatus() === 'unavailable') {
maplibregl.setRTLTextPlugin(
'/rtl-plugin.js',
true
);
}
try {
const initialMap = new maplibregl.Map({
container: mapContainer.current,
style: '/style.json',
center: [35.9106, 31.9539],
zoom: 12,
attributionControl: false
});
map.current = initialMap;
attachIconLoader(initialMap);
// Request User Location
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
(position) => {
if (initialMap) {
initialMap.flyTo({
center: [position.coords.longitude, position.coords.latitude],
zoom: 14,
essential: true
});
}
},
(error) => console.warn('Geolocation denied or failed', error),
{ timeout: 5000 }
);
}
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
}
});
}
// 3D Elevation Hillshade Relief (315° North-West Illumination)
if (!initialMap.getSource('terrain-dem')) {
initialMap.addSource('terrain-dem', {
type: 'raster-dem',
tiles: ['https://tiles.intaleqapp.com/raster_dem/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 14,
});
initialMap.addLayer({
id: 'terrain-hillshade',
type: 'hillshade',
source: 'terrain-dem',
layout: {
visibility: showTerrain ? 'visible' : 'none',
},
paint: {
'hillshade-illumination-direction': 315,
'hillshade-illumination-anchor': 'viewport',
'hillshade-shadow-color': '#3b1c06',
'hillshade-highlight-color': '#ffffff',
'hillshade-accent-color': '#9a3412',
'hillshade-exaggeration': 0.65,
},
});
}
const demSource = getSharedDemSource();
const contourUrl = demSource.contourProtocolUrl({
thresholds: {
10: [100, 500],
11: [50, 250],
12: [25, 100],
13: [20, 100],
14: [10, 50],
15: [10, 50],
16: [10, 50],
17: [10, 50],
18: [10, 50],
},
elevationKey: 'ele',
levelKey: 'level',
contourLayer: 'contours',
});
if (!initialMap.getSource('contour-source')) {
initialMap.addSource('contour-source', {
type: 'vector',
tiles: [contourUrl],
maxzoom: 18,
});
initialMap.addLayer({
id: 'contour-lines-minor',
type: 'line',
source: 'contour-source',
'source-layer': 'contours',
minzoom: 10,
layout: {
visibility: showContours ? 'visible' : 'none',
},
filter: ['==', ['get', 'level'], 0],
paint: {
'line-color': '#a86324',
'line-width': 1.1,
'line-opacity': 0.9,
},
});
initialMap.addLayer({
id: 'contour-lines-major',
type: 'line',
source: 'contour-source',
'source-layer': 'contours',
minzoom: 9,
layout: {
visibility: showContours ? 'visible' : 'none',
},
filter: ['>', ['get', 'level'], 0],
paint: {
'line-color': '#703800',
'line-width': 2.0,
'line-opacity': 1.0,
},
});
initialMap.addLayer({
id: 'contour-labels',
type: 'symbol',
source: 'contour-source',
'source-layer': 'contours',
minzoom: 12,
layout: {
visibility: showContours ? 'visible' : 'none',
'symbol-placement': 'line',
'text-field': ['concat', ['to-string', ['get', 'ele']], ' m'],
'text-size': 10,
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
},
paint: {
'text-color': '#5c2d00',
'text-halo-color': 'rgba(255, 255, 255, 0.95)',
'text-halo-width': 2,
},
});
// Hydro-Flattening Water Mask over Contour Layers
if (!initialMap.getLayer('water-mask-hydroflat-map')) {
initialMap.addLayer({
id: 'water-mask-hydroflat-map',
type: 'fill',
source: 'local-osm-polygons',
'source-layer': 'planet_osm_polygon',
filter: [
'any',
['==', 'natural', 'water'],
['==', 'water', 'reservoir'],
['==', 'water', 'dam'],
['==', 'water', 'lake'],
['==', 'water', 'pond'],
['==', 'waterway', 'riverbank'],
['==', 'landuse', 'reservoir'],
['==', 'landuse', 'basin']
],
paint: {
'fill-color': '#90C3D4',
'fill-opacity': 1.0
}
});
}
// Military Mountain Peak Spot Heights (✕ [ele]m)
if (!initialMap.getLayer('tactical-peaks-spot-heights')) {
initialMap.addLayer({
id: 'tactical-peaks-spot-heights',
type: 'symbol',
source: 'local-osm-points',
'source-layer': 'planet_osm_point',
minzoom: 9,
filter: [
'any',
['in', 'natural', 'peak', 'volcano', 'ridge', 'hill', 'cliff'],
['in', 'place', 'isolated_dwelling', 'locality']
],
layout: {
'text-field': [
'case',
['has', 'ele'],
[
'concat',
'✕ ',
['to-string', ['get', 'ele']],
'م\n',
['coalesce', ['get', 'name:ar'], ['get', 'name'], '']
],
[
'concat',
'✕\n',
['coalesce', ['get', 'name:ar'], ['get', 'name'], 'مرتفع']
]
],
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
'text-size': [
'interpolate',
['linear'],
['zoom'],
9, 9,
11, 10,
13, 11.5,
15, 13,
17, 15
],
'text-anchor': 'center',
'text-justify': 'center',
'text-padding': 8,
'text-allow-overlap': false
},
paint: {
'text-color': '#7F1D1D',
'text-halo-color': 'rgba(255,255,255,0.95)',
'text-halo-width': 2.2
}
});
}
}
onMapLoad(map.current!);
});
initialMap.on('click', (e) => {
if (onMapClickRef.current) {
onMapClickRef.current(e.lngLat.lat, e.lngLat.lng);
}
});
initialMap.on('error', (e) => {
console.error("MapComponent: Map Error:", e);
});
} catch (err) {
console.error("MapComponent: Fatal Initialization Error:", err);
}
return () => {
if (map.current) {
map.current.remove();
map.current = null;
}
};
}, []);
return (
<div ref={mapContainer} style={{ width: '100%', height: '100%', position: 'relative' }}>
{/* Intaleq Premium Branding Watermark */}
<div
style={{
position: 'absolute',
bottom: '10px',
left: '10px',
zIndex: 10,
display: 'flex',
alignItems: 'center',
background: 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'blur(12px) saturate(180%)',
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
padding: '6px 12px',
borderRadius: '10px',
pointerEvents: 'none',
userSelect: 'none',
gap: '8px',
boxShadow: '0 4px 16px rgba(0,0,0,0.08)',
border: '1px solid rgba(255,255,255,0.35)'
}}
>
<img
src="/intaleq-logo.png"
alt="Intaleq"
style={{ height: '18px', filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.1))' }}
/>
<span
style={{
fontSize: '10px',
fontWeight: 800,
color: '#c0a048',
letterSpacing: '0.8px',
textTransform: 'uppercase'
}}
>
Powered by Intaleq
</span>
</div>
</div>
);
};
export default MapComponent;