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
+10 -9
View File
@@ -148,17 +148,17 @@
"type": "line",
"source": "local-osm-polygons",
"source-layer": "planet_osm_polygon",
"minzoom": 5,
"minzoom": 6,
"filter": [
"all",
["==", "boundary", "administrative"],
["in", "admin_level", "4", 4, "5", 5]
],
"paint": {
"line-color": "#4f46e5",
"line-width": 2.2,
"line-dasharray": [4, 2],
"line-opacity": 0.9
"line-color": "#6366f1",
"line-width": 1.4,
"line-dasharray": [4, 3],
"line-opacity": 0.7
}
},
{
@@ -166,16 +166,17 @@
"type": "line",
"source": "local-osm-lines",
"source-layer": "planet_osm_line",
"minzoom": 5,
"minzoom": 6,
"filter": [
"all",
["==", "boundary", "administrative"],
["in", "admin_level", "4", 4, "5", 5]
],
"paint": {
"line-color": "#4f46e5",
"line-width": 2.2,
"line-dasharray": [4, 2]
"line-color": "#6366f1",
"line-width": 1.4,
"line-dasharray": [4, 3],
"line-opacity": 0.7
}
},
{
+305 -111
View File
@@ -1,78 +1,127 @@
import React, { useState, useEffect } from 'react';
import MapComponent from './components/MapComponent';
import { Navigation, Compass, Activity, BarChart3, MapPin } from 'lucide-react';
import { Navigation, Compass, Activity, BarChart3, MapPin, Eye, Shield } from 'lucide-react';
import { decodePolyline } from './utils/polyline';
import WeatherPanel from './components/WeatherPanel';
import { LineOfSightTool } from './components/LineOfSightTool';
const DEFAULT_API_KEY = (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
function App() {
const [map, setMap] = useState<any>(null);
const [debug, setDebug] = useState({ zoom: 12, center: [35.91, 31.95], bounds: '' });
const [routeData, setRouteData] = useState<any>(null);
const [routeSummary, setRouteSummary] = useState<any>(null);
const [stats, setStats] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [routeLoading, setRouteLoading] = useState(false);
const [routeError, setRouteError] = useState('');
// Layer Toggles
const [show3D, setShow3D] = useState(false);
const [showPOIs, setShowPOIs] = useState(true);
const [showTerrain, setShowTerrain] = useState(true);
const [showContours, setShowContours] = useState(true);
const [showAdminBoundaries, setShowAdminBoundaries] = useState(true);
const [showWeather, setShowWeather] = useState(false);
const [showLOS, setShowLOS] = useState(false);
const [losPointA, setLosPointA] = useState<[number, number] | null>(null);
const [losPointB, setLosPointB] = useState<[number, number] | null>(null);
const [weatherCity, setWeatherCity] = useState<any>(null);
const [weatherAlerts, setWeatherAlerts] = useState<any[]>([]);
// Geocoding State
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const [showResults, setShowResults] = useState(false);
const [newPlace, setNewPlace] = useState<{lat: number, lng: number} | null>(null);
const [placeForm, setPlaceForm] = useState({ name: '', name_ar: '', category: '' });
const [currentRegion, setCurrentRegion] = useState('Jordan');
// Route Form State
const [originText, setOriginText] = useState('31.9539, 35.9106');
const [destText, setDestText] = useState('32.0608, 36.1032');
const regions = [
{ name: 'Syria', name_ar: 'سوريا', center: [36.29, 33.51], zoom: 12, flag: '🇸🇾' },
{ name: 'Jordan', name_ar: 'الأردن', center: [35.91, 31.95], zoom: 12, flag: '🇯🇴' },
{ name: 'Egypt', name_ar: 'مصر', center: [31.23, 30.04], zoom: 11, flag: '🇪🇬' },
{
name: 'Syria',
name_ar: 'سوريا',
center: [36.29, 33.51],
zoom: 11,
flag: '🇸🇾',
defaultOrigin: '33.5138, 36.2765', // Damascus
defaultDest: '36.2021, 37.1343' // Aleppo
},
{
name: 'Jordan',
name_ar: 'الأردن',
center: [35.91, 31.95],
zoom: 11,
flag: '🇯🇴',
defaultOrigin: '31.9539, 35.9106', // Amman
defaultDest: '32.0608, 36.1032' // Zarqa
},
{
name: 'Egypt',
name_ar: 'مصر',
center: [31.23, 30.04],
zoom: 10,
flag: '🇪🇬',
defaultOrigin: '30.0444, 31.2357', // Cairo
defaultDest: '31.2001, 29.9187' // Alexandria
},
];
const handleRegionSwitch = (region: any) => {
setCurrentRegion(region.name);
setOriginText(region.defaultOrigin);
setDestText(region.defaultDest);
setRouteData(null);
setWeatherCity(null);
// Clear existing route line if drawn
if (map && map.getSource('route')) {
map.getSource('route').setData({
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: [] }
});
}
if (map) {
map.flyTo({
center: region.center,
zoom: region.zoom,
essential: true,
duration: 3000
duration: 2500
});
}
};
const handleMapClick = (lat: number, lng: number) => {
setNewPlace({ lat, lng });
};
const handleSearch = async () => {
if (searchQuery.length < 3) return;
if (searchQuery.trim().length < 2) return;
setLoading(true);
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
let queryUrl = `${apiUrl}/geocoding/search?q=${searchQuery}&radius=20000`;
let queryUrl = `${apiUrl}/geocoding/search?q=${encodeURIComponent(searchQuery)}&radius=50000`;
if (map) {
const center = map.getCenter();
queryUrl += `&lat=${center.lat}&lng=${center.lng}`;
}
const response = await fetch(queryUrl, {
headers: { 'x-api-key': 'intaleq_secret_2026' }
headers: { 'x-api-key': DEFAULT_API_KEY }
});
const data = await response.json();
setSearchResults(data.results || []);
setShowResults(true);
if (data.results && data.results.length > 0 && map) {
const place = data.results[0];
// API returns flat latitude/longitude fields (not a nested location object)
const placeLng = parseFloat(place.longitude);
const placeLat = parseFloat(place.latitude);
if (!isNaN(placeLat) && !isNaN(placeLng)) {
map.flyTo({ center: [placeLng, placeLat], zoom: 15 });
map.flyTo({ center: [placeLng, placeLat], zoom: 15, duration: 2000 });
}
}
} catch (e) {
@@ -82,30 +131,6 @@ function App() {
}
};
const submitNewPlace = async () => {
if (!newPlace) return;
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
await fetch(`${apiUrl}/geocoding/places`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': 'intaleq_secret_2026' },
body: JSON.stringify({
latitude: newPlace.lat,
longitude: newPlace.lng,
name: placeForm.name,
name_ar: placeForm.name_ar,
category: placeForm.category,
})
});
setNewPlace(null);
setPlaceForm({ name: '', name_ar: '', category: '' });
alert('Place added successfully! / تم إضافة المكان بنجاح');
} catch (e) {
console.error(e);
alert('Failed to add place / فشل إضافة المكان');
}
};
const fetchStats = async () => {
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
@@ -114,7 +139,7 @@ function App() {
const data = await response.json();
setStats(data);
} catch (error) {
console.warn("Stats fetch failed, using fallback UI");
console.warn("Stats fetch failed");
}
};
@@ -124,6 +149,25 @@ function App() {
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (!showWeather) return;
const fetchAlerts = async () => {
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
const regionParam = currentRegion ? `?region=${encodeURIComponent(currentRegion)}` : '';
const res = await fetch(`${apiUrl}/weather/alerts${regionParam}`, {
headers: { 'x-api-key': DEFAULT_API_KEY }
});
const data = await res.json();
setWeatherAlerts(Array.isArray(data) ? data : []);
} catch (e) {
console.warn('Weather alerts fetch failed', e);
setWeatherAlerts([]);
}
};
fetchAlerts();
}, [showWeather, currentRegion]);
const handleMapLoad = (initializedMap: any) => {
setMap(initializedMap);
initializedMap.on('move', () => {
@@ -136,49 +180,107 @@ function App() {
});
};
const parseCoordinates = (input: string): [number, number] | null => {
const parts = input.split(',').map(p => parseFloat(p.trim()));
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
return [parts[0], parts[1]]; // [lat, lng]
}
return null;
};
const calculateRoute = async () => {
setLoading(true);
setRouteError('');
const originCoords = parseCoordinates(originText);
const destCoords = parseCoordinates(destText);
if (!originCoords || !destCoords) {
setRouteError('Please enter valid coordinates format: lat, lng');
return;
}
setRouteLoading(true);
try {
// Points for Amman and Zarqa as defaults
const from = [31.9539, 35.9106];
const to = [32.0608, 36.1032];
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
const apiKey = (import.meta as any).env.VITE_API_KEY || 'intaleq_secret_2026';
const response = await fetch(`${apiUrl}/maps/route?fromLat=${from[0]}&fromLng=${from[1]}&toLat=${to[0]}&toLng=${to[1]}`, {
headers: { 'x-api-key': apiKey }
const url = `${apiUrl}/maps/route?fromLat=${originCoords[0]}&fromLng=${originCoords[1]}&toLat=${destCoords[0]}&toLng=${destCoords[1]}&profile=car&steps=true`;
const response = await fetch(url, {
headers: { 'x-api-key': DEFAULT_API_KEY }
});
const data = await response.json();
if (data.points && map) {
if (data && data.points) {
const coords = typeof data.points === 'string' ? decodePolyline(data.points) : data.points;
setRouteData({ ...data, points: coords });
if (map.getSource('route')) {
if (map && map.getSource('route')) {
map.getSource('route').setData({
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: coords }
});
// Fit map to route bounds
if (coords.length > 0) {
let minLng = coords[0][0], maxLng = coords[0][0];
let minLat = coords[0][1], maxLat = coords[0][1];
for (const pt of coords) {
minLng = Math.min(minLng, pt[0]);
maxLng = Math.max(maxLng, pt[0]);
minLat = Math.min(minLat, pt[1]);
maxLat = Math.max(maxLat, pt[1]);
}
map.fitBounds([[minLng, minLat], [maxLng, maxLat]], {
padding: { top: 70, bottom: 70, left: 360, right: 70 },
duration: 2000
});
}
}
} else {
setRouteError(data.message || 'No route found between selected points.');
}
} catch (error) {
console.error("Error calculating route:", error);
setRouteError('Failed to calculate route. Please try again.');
} finally {
setLoading(false);
setRouteLoading(false);
}
};
const handleMapClick = (lat: number, lng: number) => {
if (showLOS) {
if (!losPointA) {
setLosPointA([lat, lng]);
} else if (!losPointB) {
setLosPointB([lat, lng]);
} else {
// Reset and set point A
setLosPointA([lat, lng]);
setLosPointB(null);
}
}
};
const handleClearLOS = () => {
setLosPointA(null);
setLosPointB(null);
};
const handleCloseLOS = () => {
setShowLOS(false);
setLosPointA(null);
setLosPointB(null);
};
return (
<div className="app">
<div className="sidebar glass-morphism">
<h1>{currentRegion} Maps SaaS</h1>
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Self-Hosted Mobility Prototype</p>
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Intaleq Mobility & Maps Cloud</p>
{/* Region Selector */}
<div className="region-selector" style={{ display: 'flex', gap: '8px', margin: '15px 0' }}>
{regions.map(r => (
<button
<button
key={r.name}
onClick={() => handleRegionSwitch(r)}
className={`region-btn ${currentRegion === r.name ? 'active' : ''}`}
@@ -186,8 +288,8 @@ function App() {
flex: 1,
padding: '8px 4px',
borderRadius: '8px',
border: currentRegion === r.name ? '1px solid #3b82f6' : '1px solid var(--glass-border)',
background: currentRegion === r.name ? 'rgba(59, 130, 246, 0.1)' : 'transparent',
border: currentRegion === r.name ? '1px solid #38bdf8' : '1px solid var(--glass-border)',
background: currentRegion === r.name ? 'rgba(56, 189, 248, 0.15)' : 'transparent',
color: 'var(--text-main)',
cursor: 'pointer',
fontSize: '0.75rem',
@@ -204,62 +306,113 @@ function App() {
))}
</div>
{/* Search Bar */}
<div className="input-group">
<label><MapPin size={14} style={{ marginRight: 5 }} /> Search / البحث</label>
<label><MapPin size={14} style={{ marginRight: 5 }} /> Search Places / البحث في الأماكن</label>
<div style={{ display: 'flex', gap: '5px' }}>
<input type="text" placeholder="Coffee shop..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} />
<button className="btn" style={{ width: 'auto', marginTop: 0, padding: '10px 15px' }} onClick={handleSearch}>Go</button>
<input
type="text"
placeholder="Search city, street, cafe..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
/>
<button className="btn" style={{ width: 'auto', marginTop: 0, padding: '10px 15px' }} onClick={handleSearch} disabled={loading}>
{loading ? '...' : 'Go'}
</button>
</div>
{showResults && searchResults.length > 0 && (
<div className="search-results-list" style={{ marginTop: '10px', maxHeight: '200px', overflowY: 'auto', background: 'rgba(255,255,255,0.05)', borderRadius: '8px' }}>
<div className="search-results-list" style={{ marginTop: '10px', maxHeight: '200px', overflowY: 'auto', background: 'rgba(15, 23, 42, 0.8)', borderRadius: '8px', border: '1px solid var(--glass-border)' }}>
{searchResults.map((res) => (
<div
key={res.id}
onClick={() => { const rLng = parseFloat(res.longitude); const rLat = parseFloat(res.latitude); if (!isNaN(rLat) && !isNaN(rLng)) map?.flyTo({ center: [rLng, rLat], zoom: 16 }); }}
style={{ padding: '8px', borderBottom: '1px solid var(--glass-border)', cursor: 'pointer', fontSize: '0.85rem' }}
<div
key={res.id || Math.random()}
onClick={() => {
const rLng = parseFloat(res.longitude);
const rLat = parseFloat(res.latitude);
if (!isNaN(rLat) && !isNaN(rLng)) {
map?.flyTo({ center: [rLng, rLat], zoom: 16, duration: 1500 });
}
}}
style={{ padding: '10px', borderBottom: '1px solid var(--glass-border)', cursor: 'pointer', fontSize: '0.85rem' }}
className="search-result-item"
>
<div style={{ fontWeight: 600 }}>{res.name_ar || res.name}</div>
{res.address && <div style={{ fontSize: '0.75rem', opacity: 0.7 }}>{res.address}</div>}
<div style={{ fontSize: '0.7rem', color: '#3b82f6', marginTop: '2px' }}>
<div style={{ fontWeight: 600, color: '#f8fafc' }}>{res.name_ar || res.name}</div>
{res.address && <div style={{ fontSize: '0.75rem', opacity: 0.7, color: '#cbd5e1' }}>{res.address}</div>}
<div style={{ fontSize: '0.7rem', color: '#38bdf8', marginTop: '2px' }}>
{res.distance ? (Number(res.distance) / 1000).toFixed(1) + ' km away' : ''} | {(res.source || '').replace('_', ' ')}
</div>
</div>
))}
<button
className="btn-link"
style={{ width: '100%', padding: '5px', fontSize: '0.7rem', background: 'transparent', border: 'none', color: 'var(--text-muted)' }}
<button
className="btn-link"
style={{ width: '100%', padding: '6px', fontSize: '0.75rem', background: 'transparent', border: 'none', color: '#94a3b8', cursor: 'pointer' }}
onClick={() => setShowResults(false)}
>
Clear Results / مسح
Clear Results / إغلاق
</button>
</div>
)}
{showResults && searchResults.length === 0 && (
<div style={{ marginTop: '10px', fontSize: '0.8rem', color: '#ef4444' }}>No results found near you.</div>
<div style={{ marginTop: '10px', fontSize: '0.8rem', color: '#f87171' }}>No results found / لم يتم العثور على نتائج.</div>
)}
</div>
{/* Route Calculation */}
<div className="input-group">
<label><Navigation size={14} style={{ marginRight: 5 }} /> Origin / نقطة الانطلاق</label>
<input type="text" placeholder="Amman, Jordan" defaultValue="31.9539, 35.9106" />
<input
type="text"
placeholder="lat, lng"
value={originText}
onChange={e => setOriginText(e.target.value)}
/>
</div>
<div className="input-group">
<label><Compass size={14} style={{ marginRight: 5 }} /> Destination / الوجهة</label>
<input type="text" placeholder="Zarqa, Jordan" defaultValue="32.0608, 36.1032" />
<input
type="text"
placeholder="lat, lng"
value={destText}
onChange={e => setDestText(e.target.value)}
/>
</div>
<button className="btn" onClick={calculateRoute} disabled={loading}>
{loading ? 'Calculating...' : 'Calculate Route / حساب المسار'}
<button className="btn" onClick={calculateRoute} disabled={routeLoading} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '8px' }}>
<Navigation size={16} />
{routeLoading ? 'Calculating...' : 'Calculate Route / حساب المسار'}
</button>
<hr style={{ border: 'none', borderTop: '1px solid var(--glass-border)', margin: '10px 0' }} />
{routeError && (
<div style={{ marginTop: '10px', padding: '10px', background: 'rgba(239, 68, 68, 0.15)', border: '1px solid #ef4444', borderRadius: '8px', color: '#f87171', fontSize: '0.8rem' }}>
{routeError}
</div>
)}
{/* Route Summary Card */}
{routeData && (
<div className="route-summary glass-morphism" style={{ marginTop: '15px', padding: '12px', borderRadius: '8px', background: 'rgba(15, 23, 42, 0.6)' }}>
<h4 style={{ margin: '0 0 8px 0', fontSize: '0.9rem', color: '#38bdf8' }}>Route Overview / تفاصيل المسار</h4>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '4px' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Gauge size={13} color="#38bdf8" />
{(Number(routeData.distance || 0) / 1000).toFixed(1)} km
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Clock size={13} color="#22c55e" />
{Math.round(Number(routeData.time || routeData.duration || 0) / 60000)} min
</span>
</div>
</div>
)}
<hr style={{ border: 'none', borderTop: '1px solid var(--glass-border)', margin: '12px 0' }} />
{/* Layers & Map Options */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<h3>Layers & Map Options / خيارات الخريطة</h3>
<div className="toggle-group" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}>
<input type="checkbox" checked={show3D} onChange={(e) => setShow3D(e.target.checked)} />
@@ -295,6 +448,49 @@ function App() {
</label>
</div>
<div className="toggle-group" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}>
<input type="checkbox" checked={showWeather} onChange={(e) => setShowWeather(e.target.checked)} />
🌤️ Weather Layer / طبقة الطقس
</label>
</div>
{/* Tactical Line of Sight (LOS) Toggle */}
<div className="toggle-group" style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: showLOS ? 'rgba(99, 102, 241, 0.15)' : 'transparent',
padding: '6px 8px',
borderRadius: 8,
border: showLOS ? '1px solid rgba(99, 102, 241, 0.4)' : '1px solid transparent'
}}>
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: showLOS ? 700 : 400 }}>
<input
type="checkbox"
checked={showLOS}
onChange={(e) => {
setShowLOS(e.target.checked);
if (!e.target.checked) {
setLosPointA(null);
setLosPointB(null);
}
}}
/>
🎯 Tactical LOS / تبادل الرؤية العسكري
</label>
<span style={{
fontSize: '10px',
background: 'linear-gradient(135deg, #6366f1, #4f46e5)',
color: '#ffffff',
padding: '2px 6px',
borderRadius: 4,
fontWeight: 700
}}>
Military
</span>
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--glass-border)', margin: '5px 0' }} />
<h3>Simulation / المحاكاة</h3>
@@ -309,38 +505,30 @@ function App() {
</div>
<div className="map-container">
<MapComponent
onMapLoad={handleMapLoad}
<MapComponent
onMapLoad={handleMapLoad}
onMapClick={handleMapClick}
show3D={show3D}
showPOIs={showPOIs}
showTerrain={showTerrain}
showContours={showContours}
showAdminBoundaries={showAdminBoundaries}
showWeather={showWeather}
currentRegion={currentRegion}
onCityClick={(cityData: any) => setWeatherCity(cityData)}
losActive={showLOS}
losPointA={losPointA}
losPointB={losPointB}
/>
{/* Add Place Modal */}
{newPlace && (
<div className="stats-panel glass-morphism" style={{ top: '50%', left: '50%', transform: 'translate(-50%, -50%)', zIndex: 1000, width: '300px' }}>
<h4>Add New Place / إضافة مكان</h4>
<div className="input-group">
<label>Name / الاسم</label>
<input type="text" value={placeForm.name} onChange={e => setPlaceForm({...placeForm, name: e.target.value})} />
</div>
<div className="input-group">
<label>Arabic Name / الاسم بالعربي</label>
<input type="text" value={placeForm.name_ar} onChange={e => setPlaceForm({...placeForm, name_ar: e.target.value})} />
</div>
<div className="input-group">
<label>Category / التصنيف</label>
<input type="text" value={placeForm.category} onChange={e => setPlaceForm({...placeForm, category: e.target.value})} />
</div>
<div style={{ display: 'flex', gap: '10px', marginTop: '10px' }}>
<button className="btn" style={{ flex: 1 }} onClick={submitNewPlace}>Save</button>
<button className="btn" style={{ flex: 1, background: '#475569' }} onClick={() => setNewPlace(null)}>Cancel</button>
</div>
</div>
)}
{/* Tactical Line of Sight Tool Overlay */}
<LineOfSightTool
active={showLOS}
pointA={losPointA}
pointB={losPointB}
onClose={handleCloseLOS}
onClear={handleClearLOS}
/>
{stats && stats.telemetry && (
<div className="stats-panel glass-morphism">
@@ -363,7 +551,13 @@ function App() {
</div>
</div>
)}
<WeatherPanel
visible={showWeather}
selectedCity={weatherCity}
alerts={weatherAlerts}
/>
<div className="debug-panel glass-morphism">
<div>Zoom: {debug.zoom}</div>
<div>Center: {debug.center[0]}, {debug.center[1]}</div>
+597
View File
@@ -0,0 +1,597 @@
import React, { useState, useEffect } from 'react';
import { Eye, Crosshair, AlertTriangle, CheckCircle2, XCircle, Mountain, Compass, Shield, ChevronDown, ChevronUp, Layers } from 'lucide-react';
import { LineOfSightResult, calculateLineOfSight } from '../utils/elevationService';
interface LineOfSightToolProps {
active: boolean;
pointA: [number, number] | null; // [lat, lng] Observer
pointB: [number, number] | null; // [lat, lng] Target
onClose: () => void;
onClear: () => void;
}
export const LineOfSightTool: React.FC<LineOfSightToolProps> = ({
active,
pointA,
pointB,
onClose,
onClear
}) => {
const [obsHeight, setObsHeight] = useState<number>(2); // 2m eye level
const [tgtHeight, setTgtHeight] = useState<number>(2); // 2m target level
const [loading, setLoading] = useState<boolean>(false);
const [result, setResult] = useState<LineOfSightResult | null>(null);
const [hoverPoint, setHoverPoint] = useState<any | null>(null);
const [minimized, setMinimized] = useState<boolean>(false);
useEffect(() => {
if (pointA && pointB) {
setLoading(true);
calculateLineOfSight(pointA[0], pointA[1], pointB[0], pointB[1], obsHeight, tgtHeight, 80)
.then((res) => {
setResult(res);
setLoading(false);
})
.catch((err) => {
console.error('LOS Error:', err);
setLoading(false);
});
} else {
setResult(null);
}
}, [pointA, pointB, obsHeight, tgtHeight]);
if (!active) return null;
// Instructions overlay if points are not selected yet
if (!pointA || !pointB) {
return (
<div style={{
position: 'absolute',
top: 72,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1000,
background: 'rgba(15, 23, 42, 0.95)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(59, 130, 246, 0.5)',
boxShadow: '0 12px 36px rgba(0, 0, 0, 0.6)',
borderRadius: 14,
padding: '12px 22px',
color: '#f8fafc',
direction: 'rtl',
display: 'flex',
alignItems: 'center',
gap: 14,
fontFamily: "'IBM Plex Sans Arabic', Inter, system-ui, sans-serif"
}}>
<div style={{
width: 38,
height: 38,
borderRadius: '50%',
background: pointA ? 'rgba(34, 197, 94, 0.2)' : 'rgba(59, 130, 246, 0.2)',
border: pointA ? '1px solid #22c55e' : '1px solid #3b82f6',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: pointA ? '#4ade80' : '#60a5fa'
}}>
{pointA ? <Crosshair size={20} /> : <Eye size={20} />}
</div>
<div>
<div style={{ fontWeight: 700, fontSize: '0.95rem', display: 'flex', alignItems: 'center', gap: 8 }}>
<span>أداة تبادل الرؤية وخط النظر التكتيكي (Line of Sight)</span>
<span style={{ fontSize: '0.72rem', background: '#3b82f630', color: '#60a5fa', padding: '2px 8px', borderRadius: 6 }}>مباشر</span>
</div>
<div style={{ fontSize: '0.82rem', color: pointA ? '#4ade80' : '#cbd5e1', marginTop: 2 }}>
{!pointA
? '📍 اضغط على الخريطة لتحديد موقع الراصد / الرامي (النقطة A)'
: `🎯 تم تحديد الراصد (${pointA[0].toFixed(4)}, ${pointA[1].toFixed(4)}) — اضغط الآن لتحديد موقع الهدف (النقطة B)`}
</div>
</div>
{pointA && (
<button
onClick={onClear}
style={{
background: 'rgba(239, 68, 68, 0.15)',
border: '1px solid rgba(239, 68, 68, 0.4)',
color: '#f87171',
borderRadius: 8,
padding: '4px 10px',
fontSize: '0.75rem',
cursor: 'pointer'
}}
>
إعادة
</button>
)}
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: '#64748b',
cursor: 'pointer',
padding: '4px 8px',
fontSize: '1.1rem'
}}
title="إغلاق"
>
✕
</button>
</div>
);
}
// Render SVG Chart for Elevation Profile
const renderProfileChart = () => {
if (!result || result.points.length === 0) return null;
const svgWidth = 620;
const svgHeight = 190;
const padding = { top: 20, right: 35, bottom: 30, left: 45 };
const chartWidth = svgWidth - padding.left - padding.right;
const chartHeight = svgHeight - padding.top - padding.bottom;
const maxDist = result.totalDistance;
// Dynamic elevation range with margin
const minElev = Math.floor(Math.min(result.minElevation, result.observerElevation, result.targetElevation) / 50) * 50 - 50;
const maxElev = Math.ceil(Math.max(result.maxElevation, result.observerElevation, result.targetElevation) / 50) * 50 + 50;
const elevRange = Math.max(100, maxElev - minElev);
const getX = (d: number) => padding.left + (d / maxDist) * chartWidth;
const getY = (h: number) => padding.top + chartHeight - ((h - minElev) / elevRange) * chartHeight;
// Build Terrain Polygon Path
const terrainPoints = result.points.map((p) => `${getX(p.distance)},${getY(p.elevation)}`).join(' ');
const terrainAreaPath = `M ${getX(0)},${padding.top + chartHeight} L ${terrainPoints} L ${getX(maxDist)},${padding.top + chartHeight} Z`;
const terrainLinePath = `M ${terrainPoints}`;
// Line of Sight Ray Path
const obsX = getX(0);
const obsY = getY(result.observerElevation);
const tgtX = getX(maxDist);
const tgtY = getY(result.targetElevation);
return (
<div style={{ position: 'relative', width: '100%', overflow: 'hidden' }}>
<svg
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
style={{ width: '100%', height: 'auto', display: 'block' }}
onMouseLeave={() => setHoverPoint(null)}
>
<defs>
{/* Terrain Gradient */}
<linearGradient id="terrainGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#475569" stopOpacity="0.85" />
<stop offset="100%" stopColor="#0f172a" stopOpacity="0.95" />
</linearGradient>
{/* Obstructed Ray Pattern */}
<linearGradient id="rayGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={result.isDirectlyVisible ? '#22c55e' : '#ef4444'} />
<stop offset="100%" stopColor={result.isDirectlyVisible ? '#10b981' : '#dc2626'} />
</linearGradient>
</defs>
{/* Grid Lines */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const hVal = minElev + ratio * elevRange;
const y = getY(hVal);
return (
<g key={ratio}>
<line x1={padding.left} y1={y} x2={svgWidth - padding.right} y2={y} stroke="rgba(255,255,255,0.08)" strokeDasharray="3 3" />
<text x={padding.left - 8} y={y + 3} fill="#64748b" fontSize="9" textAnchor="end" fontFamily="monospace">
{Math.round(hVal)}m
</text>
</g>
);
})}
{/* Distance Axis */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const dVal = ratio * maxDist;
const x = getX(dVal);
return (
<g key={ratio}>
<line x1={x} y1={padding.top} x2={x} y2={padding.top + chartHeight} stroke="rgba(255,255,255,0.06)" strokeDasharray="2 2" />
<text x={x} y={padding.top + chartHeight + 16} fill="#64748b" fontSize="9" textAnchor="middle" fontFamily="monospace">
{(dVal / 1000).toFixed(1)}km
</text>
</g>
);
})}
{/* Terrain Area & Line */}
<path d={terrainAreaPath} fill="url(#terrainGrad)" />
<path d={terrainLinePath} fill="none" stroke="#94a3b8" strokeWidth="2" strokeLinejoin="round" />
{/* Line of Sight Ray */}
<line
x1={obsX}
y1={obsY}
x2={tgtX}
y2={tgtY}
stroke="url(#rayGrad)"
strokeWidth="2.5"
strokeDasharray={result.isDirectlyVisible ? 'none' : '5 4'}
/>
{/* Dead Ground Regions (Highlighted on terrain) */}
{result.points.map((p, i) => {
if (!p.isVisible && i > 0) {
const x = getX(p.distance);
const y = getY(p.elevation);
return (
<circle key={i} cx={x} cy={y} r="1.5" fill="#f87171" opacity="0.6" />
);
}
return null;
})}
{/* Observer Marker */}
<circle cx={obsX} cy={obsY} r="5" fill="#3b82f6" stroke="#fff" strokeWidth="2" />
<text x={obsX} y={obsY - 10} fill="#60a5fa" fontSize="10" fontWeight="bold" textAnchor="middle">
الراصد (A)
</text>
{/* Target Marker */}
<circle cx={tgtX} cy={tgtY} r="5" fill="#f59e0b" stroke="#fff" strokeWidth="2" />
<text x={tgtX} y={tgtY - 10} fill="#fbbf24" fontSize="10" fontWeight="bold" textAnchor="middle">
الهدف (B)
</text>
{/* Critical Obstacle Marker (if blocked) */}
{result.highestObstacle && (
<g>
<circle
cx={getX(result.highestObstacle.distance)}
cy={getY(result.highestObstacle.elevation)}
r="6"
fill="#ef4444"
stroke="#fff"
strokeWidth="2"
/>
<path
d={`M ${getX(result.highestObstacle.distance)},${getY(result.highestObstacle.elevation) - 8} L ${getX(result.highestObstacle.distance) - 4},${getY(result.highestObstacle.elevation) - 14} L ${getX(result.highestObstacle.distance) + 4},${getY(result.highestObstacle.elevation) - 14} Z`}
fill="#ef4444"
/>
<text
x={getX(result.highestObstacle.distance)}
y={getY(result.highestObstacle.elevation) - 18}
fill="#f87171"
fontSize="9"
fontWeight="bold"
textAnchor="middle"
>
عائق الحجب (+{result.highestObstacle.excessHeight}m)
</text>
</g>
)}
{/* Interactive Hover Overlay Rect */}
{result.points.map((p, idx) => {
const x = getX(p.distance);
return (
<rect
key={idx}
x={x - (chartWidth / result.points.length) / 2}
y={padding.top}
width={chartWidth / result.points.length}
height={chartHeight}
fill="transparent"
style={{ cursor: 'crosshair' }}
onMouseEnter={() => setHoverPoint(p)}
/>
);
})}
{/* Active Hover Point Marker */}
{hoverPoint && (
<g>
<line
x1={getX(hoverPoint.distance)}
y1={padding.top}
x2={getX(hoverPoint.distance)}
y2={padding.top + chartHeight}
stroke="#38bdf8"
strokeWidth="1"
strokeDasharray="2 2"
/>
<circle cx={getX(hoverPoint.distance)} cy={getY(hoverPoint.elevation)} r="4" fill="#38bdf8" stroke="#fff" strokeWidth="1.5" />
</g>
)}
</svg>
{/* Hover Info Tooltip */}
{hoverPoint && (
<div style={{
position: 'absolute',
top: 10,
left: 20,
background: 'rgba(15, 23, 42, 0.95)',
border: '1px solid rgba(56, 189, 248, 0.4)',
borderRadius: 8,
padding: '6px 12px',
fontSize: '0.75rem',
color: '#f8fafc',
direction: 'rtl',
pointerEvents: 'none',
display: 'flex',
gap: 10
}}>
<span>المسافة: <strong>{(hoverPoint.distance / 1000).toFixed(2)} كم</strong></span>
<span>الارتفاع: <strong>{hoverPoint.elevation} م</strong></span>
<span>شعاع الرؤية: <strong>{hoverPoint.rayHeight} م</strong></span>
<span>الحالة: <strong style={{ color: hoverPoint.isVisible ? '#4ade80' : '#f87171' }}>
{hoverPoint.isVisible ? 'مكشوف' : 'أرض ميتة'}
</strong></span>
</div>
)}
</div>
);
};
return (
<div style={{
position: 'absolute',
bottom: 20,
left: '50%',
transform: 'translateX(-50%)',
width: 'min(94vw, 760px)',
zIndex: 1000,
background: 'rgba(15, 23, 42, 0.94)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(255, 255, 255, 0.15)',
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.6)',
borderRadius: 16,
color: '#f8fafc',
direction: 'rtl',
fontFamily: 'system-ui, -apple-system, sans-serif',
overflow: 'hidden',
transition: 'all 0.3s ease'
}}>
{/* Header Bar */}
<div style={{
padding: '12px 18px',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: 'rgba(30, 41, 59, 0.5)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 32,
height: 32,
borderRadius: 8,
background: result?.isDirectlyVisible ? 'rgba(34, 197, 94, 0.2)' : 'rgba(239, 68, 68, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: result?.isDirectlyVisible ? '#4ade80' : '#f87171'
}}>
{result?.isDirectlyVisible ? <CheckCircle2 size={20} /> : <XCircle size={20} />}
</div>
<div>
<div style={{ fontWeight: 800, fontSize: '0.95rem', display: 'flex', alignItems: 'center', gap: 8 }}>
<span>تبادل الرؤية والمقطع التضاريسي (Line of Sight)</span>
{result && (
<span style={{
fontSize: '0.75rem',
padding: '2px 8px',
borderRadius: 6,
fontWeight: 700,
background: result.isDirectlyVisible ? '#15803d' : '#991b1b',
color: '#fff'
}}>
{result.isDirectlyVisible ? '✓ رؤية مباشرة مكشوفة' : '✕ الرؤية محجوبة بعائق'}
</span>
)}
</div>
<div style={{ fontSize: '0.75rem', color: '#94a3b8' }}>
تحليل خط النظر التكتيكي مع تصحيح انكسار الضوء الجوي وتقوس الأرض
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
onClick={onClear}
style={{
background: 'rgba(255, 255, 255, 0.08)',
border: '1px solid rgba(255, 255, 255, 0.15)',
borderRadius: 8,
padding: '6px 12px',
color: '#e2e8f0',
fontSize: '0.8rem',
cursor: 'pointer',
fontWeight: 600
}}
>
تحديد نقاط جديدة
</button>
<button
onClick={() => setMinimized(!minimized)}
style={{
background: 'transparent',
border: 'none',
color: '#94a3b8',
cursor: 'pointer',
padding: 4
}}
>
{minimized ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</button>
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: '#94a3b8',
cursor: 'pointer',
padding: 4
}}
>
✕
</button>
</div>
</div>
{/* Main Content Area */}
{!minimized && (
<div style={{ padding: '14px 18px' }}>
{loading ? (
<div style={{ textAlign: 'center', padding: '30px', color: '#94a3b8' }}>
جاري حساب المقطع التضاريسي وشعاع الرؤية... ⏳
</div>
) : result ? (
<>
{/* Tactical Metrics Grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))',
gap: 10,
marginBottom: 14
}}>
<div style={{ background: 'rgba(255, 255, 255, 0.04)', borderRadius: 10, padding: '8px 12px', border: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
<Compass size={12} color="#38bdf8" /> المسافة المباشرة
</div>
<div style={{ fontSize: '1.1rem', fontWeight: 800, color: '#f8fafc', marginTop: 2 }}>
{(result.totalDistance / 1000).toFixed(2)} <span style={{ fontSize: '0.75rem', fontWeight: 500 }}>كم</span>
</div>
</div>
<div style={{ background: 'rgba(255, 255, 255, 0.04)', borderRadius: 10, padding: '8px 12px', border: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
<Crosshair size={12} color="#fbbf24" /> السمت / الاتجاه
</div>
<div style={{ fontSize: '1.1rem', fontWeight: 800, color: '#f8fafc', marginTop: 2 }}>
{result.azimuthDegrees}° <span style={{ fontSize: '0.75rem', fontWeight: 500 }}>بوصلة</span>
</div>
</div>
<div style={{ background: 'rgba(255, 255, 255, 0.04)', borderRadius: 10, padding: '8px 12px', border: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
<Shield size={12} color="#a78bfa" /> زاوية الموقع (رماية)
</div>
<div style={{ fontSize: '1.1rem', fontWeight: 800, color: '#a78bfa', marginTop: 2 }}>
{result.angleMils > 0 ? `+${result.angleMils}` : result.angleMils} <span style={{ fontSize: '0.75rem', fontWeight: 500 }}>Mils ({result.angleDegrees}°)</span>
</div>
</div>
<div style={{ background: 'rgba(255, 255, 255, 0.04)', borderRadius: 10, padding: '8px 12px', border: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
<Mountain size={12} color="#34d399" /> الراصد / الهدف
</div>
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: '#f8fafc', marginTop: 4 }}>
{result.observerElevation}m ➔ {result.targetElevation}m
</div>
</div>
<div style={{ background: 'rgba(255, 255, 255, 0.04)', borderRadius: 10, padding: '8px 12px', border: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
<Eye size={12} color="#f472b6" /> الأرض الميتة
</div>
<div style={{ fontSize: '1.1rem', fontWeight: 800, color: result.deadGroundPercentage > 30 ? '#f87171' : '#f8fafc', marginTop: 2 }}>
{result.deadGroundPercentage}% <span style={{ fontSize: '0.75rem', fontWeight: 500 }}>محجوبة</span>
</div>
</div>
</div>
{/* Critical Obstacle Alert */}
{result.highestObstacle && (
<div style={{
background: 'rgba(239, 68, 68, 0.12)',
border: '1px solid rgba(239, 68, 68, 0.35)',
borderRadius: 10,
padding: '8px 14px',
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 10,
fontSize: '0.82rem',
color: '#fca5a5'
}}>
<AlertTriangle size={18} color="#ef4444" style={{ flexShrink: 0 }} />
<div>
<strong>عائق الحجب الرئيسي:</strong> قمة جبلية/تضاريس على بعد <strong>{(result.highestObstacle.distance / 1000).toFixed(2)} كم</strong> بارتفاع <strong>{result.highestObstacle.elevation} م</strong>، تخترق خط الرؤية بمقدار <strong>+{result.highestObstacle.excessHeight} م</strong>.
</div>
</div>
)}
{/* Elevation Profile Chart */}
<div style={{ background: 'rgba(0,0,0,0.3)', borderRadius: 12, padding: '10px 10px 4px 10px', border: '1px solid rgba(255,255,255,0.08)' }}>
{renderProfileChart()}
</div>
{/* Observer / Target Height Adjusters */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 12,
marginTop: 12,
paddingTop: 10,
borderTop: '1px solid rgba(255,255,255,0.08)',
fontSize: '0.8rem'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: '#94a3b8' }}>ارتفاع الراصد (A):</span>
{[
{ label: 'شخص (2م)', val: 2 },
{ label: 'آلية/برج (10م)', val: 10 },
{ label: 'سارية/درون (50م)', val: 50 },
].map((btn) => (
<button
key={btn.val}
onClick={() => setObsHeight(btn.val)}
style={{
background: obsHeight === btn.val ? 'rgba(59, 130, 246, 0.3)' : 'rgba(255,255,255,0.05)',
border: obsHeight === btn.val ? '1px solid #3b82f6' : '1px solid rgba(255,255,255,0.1)',
color: obsHeight === btn.val ? '#60a5fa' : '#cbd5e1',
borderRadius: 6,
padding: '3px 8px',
fontSize: '0.75rem',
cursor: 'pointer'
}}
>
{btn.label}
</button>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: '#94a3b8' }}>ارتفاع الهدف (B):</span>
{[
{ label: 'شخص (1.8م)', val: 1.8 },
{ label: 'مركبة (3م)', val: 3 },
{ label: 'مبنى/رادار (15م)', val: 15 },
].map((btn) => (
<button
key={btn.val}
onClick={() => setTgtHeight(btn.val)}
style={{
background: tgtHeight === btn.val ? 'rgba(245, 158, 11, 0.3)' : 'rgba(255,255,255,0.05)',
border: tgtHeight === btn.val ? '1px solid #f59e0b' : '1px solid rgba(255,255,255,0.1)',
color: tgtHeight === btn.val ? '#fbbf24' : '#cbd5e1',
borderRadius: 6,
padding: '3px 8px',
fontSize: '0.75rem',
cursor: 'pointer'
}}
>
{btn.label}
</button>
))}
</div>
</div>
</>
) : null}
</div>
)}
</div>
);
};
+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);
});
+195
View File
@@ -0,0 +1,195 @@
import React, { useState } from 'react';
import { getWeatherDescription, getTemperatureColor } from '../utils/weatherIcons';
interface WeatherPanelProps {
visible: boolean;
selectedCity?: any;
alerts?: any[];
}
const WeatherPanel: React.FC<WeatherPanelProps> = ({ visible, selectedCity, alerts }) => {
const [minimized, setMinimized] = useState(false);
if (!visible) return null;
const panelStyle: React.CSSProperties = {
position: 'absolute',
bottom: '24px',
right: '24px',
width: '340px',
maxHeight: '440px',
overflowY: 'auto',
backgroundColor: 'rgba(15, 23, 42, 0.9)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
border: '1px solid rgba(255, 255, 255, 0.12)',
borderRadius: '16px',
padding: '16px',
boxShadow: '0 12px 36px rgba(0, 0, 0, 0.5)',
zIndex: 1000,
direction: 'rtl',
fontFamily: 'Inter, system-ui, sans-serif',
color: '#f8fafc'
};
if (minimized) {
return (
<div
style={{
...panelStyle,
width: 'auto',
cursor: 'pointer',
padding: '10px 18px',
fontWeight: 600,
background: 'rgba(30, 41, 59, 0.9)'
}}
onClick={() => setMinimized(false)}
>
🌤️ الطقس (Weather)
</div>
);
}
const renderCurrentWeather = () => {
if (!selectedCity) return null;
const code = Number(selectedCity.weatherCode ?? selectedCity.weather_code ?? 0);
const temp = Number(selectedCity.temperature ?? 0);
const desc = getWeatherDescription(code);
return (
<div style={{ marginBottom: '14px', padding: '14px', background: 'rgba(255,255,255,0.06)', borderRadius: '12px', border: '1px solid rgba(255,255,255,0.08)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0, fontSize: '1.1rem', color: '#fff' }}>{selectedCity.name_ar || selectedCity.name || 'المدينة'}</h3>
<span style={{ fontSize: '2rem' }}>{desc.icon}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginTop: '6px' }}>
<span style={{ fontSize: '2.2rem', fontWeight: 800, color: getTemperatureColor(temp) }}>
{Math.round(temp)}°C
</span>
<span style={{ fontSize: '1rem', color: '#94a3b8' }}>{desc.description_ar}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginTop: '12px', fontSize: '0.82rem', color: '#cbd5e1' }}>
<div>💧 الرطوبة: <b>{selectedCity.humidity || 0}%</b></div>
<div>💨 الرياح: <b>{selectedCity.windSpeed || 0} كم/س</b></div>
<div>☁️ الغطاء: <b>{selectedCity.cloudCover || 0}%</b></div>
<div>🌡️ الشعور: <b>{Math.round(Number(selectedCity.feelsLike ?? temp))}°C</b></div>
</div>
</div>
);
};
const renderForecast = () => {
if (!selectedCity || !selectedCity.forecast) return null;
let forecastArray: any[] = [];
try {
forecastArray = typeof selectedCity.forecast === 'string' ? JSON.parse(selectedCity.forecast) : selectedCity.forecast;
} catch(e) {
forecastArray = [];
}
if (!Array.isArray(forecastArray) || forecastArray.length === 0) return null;
const days = ['الأحد', 'الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت'];
return (
<div style={{ marginBottom: '14px' }}>
<h4 style={{ margin: '0 0 8px 0', fontSize: '0.88rem', color: '#94a3b8' }}>📅 توقعات 7 أيام</h4>
<div style={{ display: 'flex', overflowX: 'auto', gap: '8px', paddingBottom: '6px' }}>
{forecastArray.map((f: any, idx: number) => {
const code = Number(f.weatherCode ?? f.weather_code ?? 0);
const desc = getWeatherDescription(code);
const dateObj = f.date ? new Date(f.date) : null;
const dayName = dateObj ? days[dateObj.getDay()] : `يوم ${idx + 1}`;
const maxTemp = Math.round(Number(f.tempMax ?? f.max_temp ?? 0));
const minTemp = Math.round(Number(f.tempMin ?? f.min_temp ?? 0));
return (
<div key={idx} style={{
minWidth: '65px',
padding: '8px 6px',
background: 'rgba(255,255,255,0.05)',
borderRadius: '10px',
textAlign: 'center',
border: '1px solid rgba(255,255,255,0.06)'
}}>
<div style={{ fontSize: '0.72rem', fontWeight: 600, color: '#94a3b8' }}>{dayName}</div>
<div style={{ fontSize: '1.3rem', margin: '4px 0' }}>{desc.icon}</div>
<div style={{ fontSize: '0.78rem' }}>
<span style={{ color: '#f87171', fontWeight: 600 }}>{maxTemp}°</span>
{' '}
<span style={{ color: '#60a5fa' }}>{minTemp}°</span>
</div>
</div>
);
})}
</div>
</div>
);
};
const renderAlerts = () => {
if (!alerts || alerts.length === 0) return null;
return (
<div>
<h4 style={{ margin: '0 0 8px 0', fontSize: '0.88rem', color: '#f59e0b' }}>⚠️ تنبيهات الطقس</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{alerts.map((alert, idx) => {
const severity = alert.severity || alert.level || 'info';
const colors: Record<string, string> = {
danger: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6'
};
const color = colors[severity] || colors.info;
return (
<div key={idx} style={{
padding: '8px 10px',
borderRadius: '8px',
backgroundColor: `${color}20`,
borderRight: `3px solid ${color}`,
fontSize: '0.82rem'
}}>
<div style={{ fontWeight: 700, color, marginBottom: '2px' }}>
📍 {alert.city_ar || alert.city || alert.city_name_ar || alert.city_name}: {alert.type || 'تنبيه'}
</div>
<div style={{ color: '#cbd5e1' }}>{alert.message_ar || alert.message}</div>
</div>
);
})}
</div>
</div>
);
};
return (
<div style={panelStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid rgba(255,255,255,0.1)', paddingBottom: '8px', marginBottom: '12px' }}>
<h2 style={{ margin: 0, fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '6px' }}>
🌤️ حالة الطقس المباشرة
</h2>
<button
onClick={() => setMinimized(true)}
style={{ background: 'transparent', border: 'none', cursor: 'pointer', fontSize: '0.9rem', color: '#94a3b8' }}
>
✕
</button>
</div>
{!selectedCity && (
<div style={{ textAlign: 'center', padding: '16px 8px', color: '#94a3b8', fontSize: '0.85rem', background: 'rgba(255,255,255,0.03)', borderRadius: '8px', marginBottom: '10px' }}>
💡 اضغط على أي مدينة على الخريطة لعرض تفاصيلها وتوقعات 7 أيام
</div>
)}
{renderCurrentWeather()}
{renderForecast()}
{renderAlerts()}
</div>
);
};
export default WeatherPanel;
+24 -2
View File
@@ -32,15 +32,37 @@ body {
.sidebar {
width: 350px;
height: 100%;
padding: 30px;
max-height: 100vh;
padding: 24px 20px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 20px;
gap: 16px;
box-shadow: 10px 0 30px rgba(0,0,0,0.5);
background: var(--glass-bg);
backdrop-filter: blur(15px);
border-right: 1px solid var(--glass-border);
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
.sidebar::-webkit-scrollbar {
width: 6px;
}
.sidebar::-webkit-scrollbar-track {
background: transparent;
}
.sidebar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.sidebar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.35);
}
.glass-morphism {
+32 -21
View File
@@ -4,6 +4,7 @@ import maplibregl from 'maplibre-gl'
import App from './App.tsx'
import CompareView from './pages/CompareView'
import IntelligenceDashboard from './pages/IntelligenceDashboard'
import { ExecutiveShowcase } from './pages/ExecutiveShowcase'
import './index.css'
maplibregl.setRTLTextPlugin(
@@ -12,49 +13,53 @@ maplibregl.setRTLTextPlugin(
true
);
// Lightweight hash router (no dependency). '#compare' and '#review' are additive
// views; anything else falls back to the existing map app, untouched.
// Lightweight hash router
const NAV = [
{ hash: '#map', label: 'Map', icon: '🗺️' },
{ hash: '#review', label: 'Review', icon: '🔍' },
{ hash: '#compare', label: 'Compare', icon: '⚖️' },
{ hash: '#executive', label: 'العرض الاستراتيجي', icon: '🎖️' },
{ hash: '#map', label: 'الخريطة الحية', icon: '🗺️' },
{ hash: '#review', label: 'تدقيق الطرق', icon: '🔍' },
{ hash: '#compare', label: 'المقارنة', icon: '⚖️' },
]
function ViewNav({ hash }: { hash: string }) {
const isMap = hash !== '#compare' && hash !== '#review'
const currentHash = hash || '#map'
return (
<div style={{
position: 'fixed',
top: 10,
right: 16,
top: 12,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 9999,
display: 'flex',
alignItems: 'center',
gap: 4,
background: 'rgba(15, 23, 42, 0.88)',
backdropFilter: 'blur(12px)',
border: '1px solid rgba(255, 255, 255, 0.12)',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.45)',
background: 'rgba(15, 23, 42, 0.92)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(255, 255, 255, 0.18)',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.55)',
borderRadius: 999,
padding: '3px 4px',
fontFamily: 'Inter, system-ui, sans-serif'
padding: '4px 6px',
fontFamily: "'IBM Plex Sans Arabic', Inter, system-ui, sans-serif",
direction: 'rtl',
maxWidth: 'calc(100vw - 24px)',
overflowX: 'auto'
}}>
{NAV.map(n => {
const active = n.hash === '#map' ? isMap : hash === n.hash
const active = currentHash === n.hash
return (
<a key={n.hash} href={n.hash}
style={{
textDecoration: 'none',
fontSize: 12,
fontWeight: 600,
fontWeight: 700,
color: active ? '#fff' : '#94a3b8',
background: active ? 'linear-gradient(135deg, #6366f1, #4f46e5)' : 'transparent',
boxShadow: active ? '0 2px 8px rgba(99, 102, 241, 0.4)' : 'none',
padding: '5px 13px',
padding: '6px 14px',
borderRadius: 999,
display: 'flex',
alignItems: 'center',
gap: 5,
gap: 6,
transition: 'all 0.15s ease'
}}>
<span>{n.icon}</span>
@@ -67,13 +72,19 @@ function ViewNav({ hash }: { hash: string }) {
}
function Root() {
const [hash, setHash] = useState(window.location.hash)
const [hash, setHash] = useState(window.location.hash || '#map')
useEffect(() => {
const on = () => setHash(window.location.hash)
const on = () => setHash(window.location.hash || '#map')
window.addEventListener('hashchange', on)
return () => window.removeEventListener('hashchange', on)
}, [])
const view = hash === '#compare' ? <CompareView /> : hash === '#review' ? <IntelligenceDashboard /> : <App />
const view =
hash === '#executive' || hash === '#pitch' ? <ExecutiveShowcase /> :
hash === '#compare' ? <CompareView /> :
hash === '#review' ? <IntelligenceDashboard /> :
<App />
return (
<>
<ViewNav hash={hash} />
+611
View File
@@ -0,0 +1,611 @@
import React, { useState } from 'react';
import {
Shield,
Compass,
Map as MapIcon,
Activity,
CheckCircle2,
XCircle,
Layers,
Eye,
Crosshair,
Mountain,
Cpu,
Zap,
Globe,
Server,
ArrowRight,
Check,
ChevronRight,
Radio,
Gauge,
TrendingUp,
FileText,
Flame,
Award
} from 'lucide-react';
export const ExecutiveShowcase: React.FC = () => {
const [activeTab, setActiveTab] = useState<'strategy' | 'tactical' | 'comparison' | 'roadmap'>('strategy');
return (
<div style={{
height: '100vh',
overflowY: 'auto',
overflowX: 'hidden',
background: 'radial-gradient(ellipse at top, #1e1b4b 0%, #0b0f19 50%, #030712 100%)',
color: '#f8fafc',
fontFamily: "'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif",
direction: 'rtl',
paddingTop: 48,
paddingBottom: 80,
scrollBehavior: 'smooth'
}}>
{/* Top Military & National Header */}
<header style={{
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
background: 'rgba(11, 15, 25, 0.8)',
backdropFilter: 'blur(20px)',
position: 'sticky',
top: 0,
zIndex: 50,
padding: '16px 24px'
}}>
<div style={{
maxWidth: 1280,
margin: '0 auto',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 16
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{
width: 46,
height: 46,
borderRadius: 12,
background: 'linear-gradient(135deg, #4f46e5, #06b6d4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 20px rgba(79, 70, 229, 0.4)',
border: '1px solid rgba(255, 255, 255, 0.2)'
}}>
<Shield size={24} color="#ffffff" />
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 800, letterSpacing: '-0.02em', color: '#ffffff' }}>
منظومة انطلاق للسيادة المكانية والخرائط التكتيكية
</h1>
<span style={{
fontSize: '11px',
background: 'rgba(34, 197, 94, 0.15)',
color: '#4ade80',
border: '1px solid rgba(34, 197, 94, 0.3)',
padding: '2px 8px',
borderRadius: 999,
fontWeight: 700
}}>
🇯🇴 سيادة أردنية 100%
</span>
</div>
<p style={{ margin: '2px 0 0 0', fontSize: '0.8rem', color: '#94a3b8' }}>
عرض استراتيجي موجه للمركز الجغرافي الملكي الأردني والقيادة العامة | إعداد: المقدم م. حمزة الغويري
</p>
</div>
</div>
{/* Quick Action Navigation */}
<div style={{ display: 'flex', gap: 10 }}>
<a href="#map" style={{
textDecoration: 'none',
background: 'linear-gradient(135deg, #6366f1, #4f46e5)',
color: '#ffffff',
padding: '8px 18px',
borderRadius: 10,
fontSize: '0.85rem',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
gap: 6,
boxShadow: '0 4px 14px rgba(99, 102, 241, 0.35)'
}}>
<MapIcon size={16} /> فتح الخريطة التفاعلية
</a>
<a href="#review" style={{
textDecoration: 'none',
background: 'rgba(255, 255, 255, 0.06)',
border: '1px solid rgba(255, 255, 255, 0.12)',
color: '#cbd5e1',
padding: '8px 16px',
borderRadius: 10,
fontSize: '0.85rem',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: 6
}}>
<Activity size={16} color="#38bdf8" /> لوحة تدقيق الشوارع
</a>
</div>
</div>
</header>
{/* Main Container */}
<main style={{ maxWidth: 1280, margin: '0 auto', padding: '32px 24px' }}>
{/* Hero Section */}
<section style={{
textAlign: 'center',
padding: '40px 20px',
background: 'linear-gradient(180deg, rgba(99, 102, 241, 0.08) 0%, rgba(15, 23, 42, 0) 100%)',
borderRadius: 24,
border: '1px solid rgba(255, 255, 255, 0.08)',
marginBottom: 40
}}>
<div style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
background: 'rgba(99, 102, 241, 0.15)',
border: '1px solid rgba(99, 102, 241, 0.3)',
padding: '6px 16px',
borderRadius: 999,
color: '#a5b4fc',
fontSize: '0.85rem',
fontWeight: 700,
marginBottom: 20
}}>
<Cpu size={16} /> البديل السيادي الكامل لمنظومات إزري (ArcGIS) في الأردن
</div>
<h2 style={{
fontSize: 'clamp(1.8rem, 3.5vw, 2.8rem)',
fontWeight: 900,
lineHeight: 1.3,
margin: '0 auto 16px auto',
maxWidth: 900,
background: 'linear-gradient(135deg, #ffffff 30%, #94a3b8 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}>
منصة خرائط وطنية ذكية تتغذى ذاتياً وتولد الشوارع والبيانات المكانية من حركة الأساطيل
</h2>
<p style={{
fontSize: '1.05rem',
color: '#94a3b8',
maxWidth: 820,
margin: '0 auto 28px auto',
lineHeight: 1.8
}}>
استثمار استراتيجي مزدوج: تطبيق نقل وخدمات لوجستية ذكي في الواجهة، ومحرك خرائط تكتيكي سيادي في الخلفية، يكتشف الطرق والمعالم تلقائياً في أي دولة أو مسرح عمليات بدون الحاجة لرخص أجنبية أو مسح ميداني بطيء.
</p>
{/* Quick Metrics Bar */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: 16,
maxWidth: 1000,
margin: '0 auto'
}}>
<div style={{ background: 'rgba(255, 255, 255, 0.04)', padding: '16px', borderRadius: 14, border: '1px solid rgba(255, 255, 255, 0.08)' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 900, color: '#38bdf8' }}>60 FPS</div>
<div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: 4 }}>سرعة عرض المتجهات (Martin MVT)</div>
</div>
<div style={{ background: 'rgba(255, 255, 255, 0.04)', padding: '16px', borderRadius: 14, border: '1px solid rgba(255, 255, 255, 0.08)' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 900, color: '#4ade80' }}>100%</div>
<div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: 4 }}>استقلالية تامة (Air-Gapped On-Premise)</div>
</div>
<div style={{ background: 'rgba(255, 255, 255, 0.04)', padding: '16px', borderRadius: 14, border: '1px solid rgba(255, 255, 255, 0.08)' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 900, color: '#fbbf24' }}>$0</div>
<div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: 4 }}>تكلفة رخص سنوية أو رسوم لكل مستخدم</div>
</div>
<div style={{ background: 'rgba(255, 255, 255, 0.04)', padding: '16px', borderRadius: 14, border: '1px solid rgba(255, 255, 255, 0.08)' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 900, color: '#a78bfa' }}>3 دول</div>
<div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: 4 }}>الأردن 🇯🇴 • سوريا 🇸🇾 • مصر 🇪🇬</div>
</div>
</div>
</section>
{/* Tab Navigation */}
<div style={{
display: 'flex',
justifyContent: 'center',
gap: 12,
marginBottom: 32,
flexWrap: 'wrap'
}}>
{[
{ id: 'strategy', label: '1. الرؤية وحجر الأساس (توليد الخرائط)', icon: <TrendingUp size={16} /> },
{ id: 'tactical', label: '2. الأدوات التكتيكية والعسكرية (LOS)', icon: <Crosshair size={16} /> },
{ id: 'comparison', label: '3. المقارنة القاطعة مع إزري (Esri)', icon: <Layers size={16} /> },
{ id: 'roadmap', label: '4. خطة الشراكة مع المركز الجغرافي', icon: <Award size={16} /> },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
style={{
background: activeTab === tab.id ? 'linear-gradient(135deg, #4f46e5, #6366f1)' : 'rgba(255, 255, 255, 0.05)',
border: activeTab === tab.id ? '1px solid #818cf8' : '1px solid rgba(255, 255, 255, 0.1)',
color: activeTab === tab.id ? '#ffffff' : '#94a3b8',
padding: '12px 22px',
borderRadius: 12,
fontSize: '0.9rem',
fontWeight: 700,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 8,
transition: 'all 0.2s ease',
boxShadow: activeTab === tab.id ? '0 4px 16px rgba(79, 70, 229, 0.35)' : 'none'
}}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* TAB 1: STRATEGY & SELF-HEALING MAPS */}
{activeTab === 'strategy' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div style={{
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: 20,
padding: '32px',
backdropFilter: 'blur(16px)'
}}>
<h3 style={{ fontSize: '1.4rem', fontWeight: 800, color: '#38bdf8', margin: '0 0 16px 0', display: 'flex', alignItems: 'center', gap: 10 }}>
<TrendingUp size={24} /> حجر الأساس: كيف تحول حركة الأساطيل إلى خريطة سيادية حية؟
</h3>
<p style={{ color: '#cbd5e1', fontSize: '1rem', lineHeight: 1.8 }}>
النموذج التقليدي المتبع لدى إزري والشركات الأجنبية يعتمد على انتظار فرق المسح الميداني أو التقاط صور أقمار صناعية باهظة كل عدة أشهر أو سنوات. في المقابل، تتبنى منصتنا <strong>فلسفة الخريطة الحية ذاتية التغذية والتوليد (Autonomous Self-Healing Map Grid)</strong>:
</p>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: 20,
marginTop: 24
}}>
<div style={{ background: 'rgba(255, 255, 255, 0.03)', padding: 20, borderRadius: 16, border: '1px solid rgba(56, 189, 248, 0.2)' }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(56, 189, 248, 0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#38bdf8', marginBottom: 12 }}>
<Activity size={20} />
</div>
<h4 style={{ margin: '0 0 8px 0', fontSize: '1.1rem', color: '#ffffff' }}>1. استيعاب التتبع اللحظي (Telemetry)</h4>
<p style={{ fontSize: '0.88rem', color: '#94a3b8', lineHeight: 1.7 }}>
المنصة تستوعب مئات آلاف نقاط الموقع من الآليات العسكرية أو أساطيل التوصيل والنقل كل 3 ثوانٍ وتعالجها مكانياً في محرك PostGIS فائق الأداء.
</p>
</div>
<div style={{ background: 'rgba(255, 255, 255, 0.03)', padding: 20, borderRadius: 16, border: '1px solid rgba(168, 85, 247, 0.2)' }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(168, 85, 247, 0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#c084fc', marginBottom: 12 }}>
<Cpu size={20} />
</div>
<h4 style={{ margin: '0 0 8px 0', fontSize: '1.1rem', color: '#ffffff' }}>2. خوارزمية اكتشاف الطرق الجديدة</h4>
<p style={{ fontSize: '0.88rem', color: '#94a3b8', lineHeight: 1.7 }}>
عندما تتحرك مركبات متعددة في مسار صحراوي أو حي جديد غير مرسوم على الخريطة، يقوم النظام بتوليد طريق مرشح (Candidate Road) وحساب طوله، سرعته، ومعدل الثقة به تلقائياً.
</p>
</div>
<div style={{ background: 'rgba(255, 255, 255, 0.03)', padding: 20, borderRadius: 16, border: '1px solid rgba(34, 197, 94, 0.2)' }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(34, 197, 94, 0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#4ade80', marginBottom: 12 }}>
<CheckCircle2 size={20} />
</div>
<h4 style={{ margin: '0 0 8px 0', fontSize: '1.1rem', color: '#ffffff' }}>3. التدقيق والموافقة ونشر الملاحة فوراً</h4>
<p style={{ fontSize: '0.88rem', color: '#94a3b8', lineHeight: 1.7 }}>
يستعرض مهندسو المركز الجغرافي الشوارع المكتشفة في لوحة التدقيق (Review Dashboard) بجانب صور الأقمار الصناعية والخرائط السوفيتية، وبضغطة زر واحدة يُحقن الطريق في محرك الملاحة.
</p>
</div>
</div>
{/* Military & Tactical Deployment Expansion */}
<div style={{
marginTop: 28,
padding: '20px',
background: 'rgba(99, 102, 241, 0.12)',
borderRadius: 14,
border: '1px solid rgba(99, 102, 241, 0.3)',
display: 'flex',
alignItems: 'center',
gap: 16
}}>
<Globe size={32} color="#818cf8" style={{ flexShrink: 0 }} />
<div>
<div style={{ fontWeight: 800, fontSize: '1rem', color: '#ffffff' }}>
الأثر العسكري والتوسعي: العمل في أي مسرح عمليات فورياً
</div>
<div style={{ fontSize: '0.88rem', color: '#cbd5e1', marginTop: 4, lineHeight: 1.7 }}>
إذا تم نشر آليات القوات المسلحة أو الأجهزة الأمنية في أي منطقة حدودية أو دولة مجاورة (مثل جنوب سوريا، غرب العراق، صحراء سيناء)، يكفي تشغيل تطبيق التتبع لتبدأ المنصة فوراً برسم شبكة الطرق والممرات الوعرة ونشرها لباقي التشكيلات في الميدان دون انتظار أي طرف أجنبي!
</div>
</div>
</div>
</div>
</div>
)}
{/* TAB 2: TACTICAL & MILITARY TOOLS */}
{activeTab === 'tactical' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div style={{
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: 20,
padding: '32px',
backdropFilter: 'blur(16px)'
}}>
<h3 style={{ fontSize: '1.4rem', fontWeight: 800, color: '#f59e0b', margin: '0 0 16px 0', display: 'flex', alignItems: 'center', gap: 10 }}>
<Crosshair size={24} /> قدرات الميدان والتحليل التكتيكي العسكري
</h3>
<p style={{ color: '#cbd5e1', fontSize: '1rem', lineHeight: 1.8 }}>
تم تزويد المنصة بأدوات تحليل تضاريسي مخصصة لخدمة سلاح المدفعية، الاستطلاع، وغرف العمليات والسيطرة المشتركة:
</p>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: 20,
marginTop: 24
}}>
<div style={{ background: 'rgba(255, 255, 255, 0.03)', padding: 22, borderRadius: 16, border: '1px solid rgba(255, 255, 255, 0.08)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div style={{ padding: 8, borderRadius: 8, background: 'rgba(245, 158, 11, 0.2)', color: '#fbbf24' }}>
<Eye size={20} />
</div>
<h4 style={{ margin: 0, fontSize: '1.1rem', color: '#ffffff' }}>تبادل الرؤية وخط النظر (Line of Sight)</h4>
</div>
<ul style={{ paddingRight: 18, color: '#94a3b8', fontSize: '0.88rem', lineHeight: 1.8, margin: 0 }}>
<li>تحديد إمكانية الرؤية المباشرة بين نقطتين (راصد وهدف).</li>
<li>كشف وتحديد القمم الجبلية والعوائق الحاجبة للرؤية بدقة المتر.</li>
<li>حساب نسبة <strong>الأرض الميتة (Dead Ground)</strong> خلف الحواف.</li>
<li>تصحيح انكسار الضوء الجوي التكتيكي وتقوس الأرض (Earth Curvature).</li>
</ul>
</div>
<div style={{ background: 'rgba(255, 255, 255, 0.03)', padding: 22, borderRadius: 16, border: '1px solid rgba(255, 255, 255, 0.08)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div style={{ padding: 8, borderRadius: 8, background: 'rgba(167, 139, 250, 0.2)', color: '#c084fc' }}>
<Shield size={20} />
</div>
<h4 style={{ margin: 0, fontSize: '1.1rem', color: '#ffffff' }}>حسابات الرماية وزاوية الموقع (Mils)</h4>
</div>
<ul style={{ paddingRight: 18, color: '#94a3b8', fontSize: '0.88rem', lineHeight: 1.8, margin: 0 }}>
<li>حساب زاوية الموقع (Angle of Site) بالميللي العسكري (Artillery Mils).</li>
<li>حساب السمت والاتجاه البوصلّي الدقيق (Azimuth / Bearing).</li>
<li>مقطع رأسي كامل للارتفاعات فوق مستوى سطح البحر (AMSL).</li>
<li>تعديل ارتفاع عين الراصد وارتفاع الهدف حسب نوع الآلية أو البرج.</li>
</ul>
</div>
<div style={{ background: 'rgba(255, 255, 255, 0.03)', padding: 22, borderRadius: 16, border: '1px solid rgba(255, 255, 255, 0.08)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div style={{ padding: 8, borderRadius: 8, background: 'rgba(56, 189, 248, 0.2)', color: '#38bdf8' }}>
<Radio size={20} />
</div>
<h4 style={{ margin: 0, fontSize: '1.1rem', color: '#ffffff' }}>التشغيل المعزول التام (Air-Gapped Offline)</h4>
</div>
<ul style={{ paddingRight: 18, color: '#94a3b8', fontSize: '0.88rem', lineHeight: 1.8, margin: 0 }}>
<li>نظام كامل يعمل داخل خادم صغير أو جهاز لوحي عسكري داخل الآلية.</li>
<li>توجيه وملاحة وحساب مسافات بدون الحاجة لأي اتصال بالإنترنت.</li>
<li>حماية تامة من التشويش أو قطع الخدمات السحابية الأجنبية.</li>
<li>تشفير مسارات وبيانات التحركات وفق معايير أمنية صارمة.</li>
</ul>
</div>
</div>
</div>
</div>
)}
{/* TAB 3: DIRECT COMPARISON WITH ESRI ARCGIS */}
{activeTab === 'comparison' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div style={{
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: 20,
padding: '32px',
backdropFilter: 'blur(16px)'
}}>
<h3 style={{ fontSize: '1.4rem', fontWeight: 800, color: '#ffffff', margin: '0 0 20px 0' }}>
مقارنة مباشرة: منصة انطلاق السيادية مقابل إزري (Esri ArcGIS)
</h3>
<div style={{ overflowX: 'auto' }}>
<table style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: '0.9rem',
textAlign: 'right'
}}>
<thead>
<tr style={{ background: 'rgba(255, 255, 255, 0.06)', borderBottom: '2px solid rgba(255, 255, 255, 0.15)' }}>
<th style={{ padding: '14px 18px', color: '#94a3b8' }}>المعيار</th>
<th style={{ padding: '14px 18px', color: '#ef4444' }}>منظومة إزري (Esri ArcGIS)</th>
<th style={{ padding: '14px 18px', color: '#4ade80', background: 'rgba(34, 197, 94, 0.08)' }}>منصة انطلاق السيادية (Intaleq)</th>
</tr>
</thead>
<tbody>
{[
{
criteria: 'التكلفة والترخيص السنوي',
esri: 'عشرات إلى مئات الآلاف $ سنوياً (تراخيص مستخدمين + استهلاك Credits)',
intaleq: 'ملكية سيادية وطنية كاملة $0 رخص سنوية أو رسوم مستخدمين'
},
{
criteria: 'السيادة وسرية البيانات',
esri: 'تعتمد على سحابة إزري الأمريكية في البحث والتوجيه وتحديث البيانات',
intaleq: 'سيرفرات داخلية 100% داخل المركز الجغرافي أو القيادة العامة (On-Premise)'
},
{
criteria: 'سرعة عرض الخرائط التفاعلية',
esri: 'ثقيلة وبطيئة على المتصفحات وتطبيقات الميدان (15-25 FPS)',
intaleq: 'فائقة السرعة عبر Vector Tiles ورندرة بكرت الشاشة (60 FPS)'
},
{
criteria: 'تحديث شبكة الطرق',
esri: 'يتطلب مسحاً ميدانياً بطيئاً أو شراء مجموعات بيانات دورية باهظة',
intaleq: 'تحديث تلقائي لحظي من بيانات تتبع حركة السائقين والآليات'
},
{
criteria: 'البحث بالعربية والمسميات المحلية',
esri: 'ضعيف في فهم اللهجة الشعبية، التقسيمات العشائرية والأخطاء الإملائية',
intaleq: 'محرك بحث ذكي يفهم اللهجة الأردنية، الاستعلامات النسبية وبوابات المجمعات'
},
{
criteria: 'التكامل مع تطبيقات الموبايل والميدان',
esri: 'يحتاج مكتبات SDK ضخمة وتراخيص App Development مدفوعة',
intaleq: 'واجهات REST APIs و MapLibre مفتوحة وسهلة الدمج مع أي تطبيق فلاتر أو أندرويد'
},
{
criteria: 'الاستخدام في مسارح عمليات خارج الأردن',
esri: 'شراء تراخيص خرائط منفصلة لكل دولة إضافية',
intaleq: 'إضافة أي دولة (سوريا، مصر، العراق) بسكربت واحد خلال دقائق معدودة'
}
].map((row, idx) => (
<tr key={idx} style={{
borderBottom: '1px solid rgba(255, 255, 255, 0.06)',
background: idx % 2 === 0 ? 'transparent' : 'rgba(255, 255, 255, 0.02)'
}}>
<td style={{ padding: '14px 18px', fontWeight: 700, color: '#f8fafc' }}>{row.criteria}</td>
<td style={{ padding: '14px 18px', color: '#fca5a5' }}>✕ {row.esri}</td>
<td style={{ padding: '14px 18px', color: '#86efac', fontWeight: 600, background: 'rgba(34, 197, 94, 0.04)' }}>✓ {row.intaleq}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* TAB 4: ROADMAP WITH RJGC */}
{activeTab === 'roadmap' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div style={{
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: 20,
padding: '32px',
backdropFilter: 'blur(16px)'
}}>
<h3 style={{ fontSize: '1.4rem', fontWeight: 800, color: '#4ade80', margin: '0 0 16px 0', display: 'flex', alignItems: 'center', gap: 10 }}>
<Award size={24} /> خطة الشراكة والتنفيذ المقترحة مع المركز الجغرافي الملكي
</h3>
<p style={{ color: '#cbd5e1', fontSize: '1rem', lineHeight: 1.8 }}>
لا نطلب من المركز إلغاء ما لديه فجأة، بل نقترح مسار شراكة استراتيجي آمن يبدأ بإثبات الجدارة:
</p>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
marginTop: 24
}}>
{[
{
phase: 'المرحلة الأولى: إثبات المفهوم (POC) لمدة 30 يوماً',
duration: 'الشهر الأول',
desc: 'تنصيب نسخة سريعة داخل خوادم المركز الجغرافي لعرض خرائط المملكة وبلاطات المتجهات ومقارنة سرعتها وأدائها مع خوادم ArcGIS الحالية دون أي التزام مالي.'
},
{
phase: 'المرحلة الثانية: ربط أساطيل التتبع وتفعيل التحديث التلقائي',
duration: 'الشهر الثاني - الثالث',
desc: 'ربط بيانات تتبع آليات حكومية أو تجارية لبدء اكتشاف الطرق الجديدة تلقائياً وتزويد مهندسي المركز بلوحة مراجعة واعتماد الشوارع.'
},
{
phase: 'المرحلة الثالثة: دمج الأدوات التكتيكية مع القيادة العامة وسلاح المدفعية',
duration: 'الشهر الرابع فصاعداً',
desc: 'تخصيص محرك تبادل الرؤية (LOS) ومقاطع التضاريس والخرائط غير المتصلة (Offline) ليتم تعميمها على الأجهزة اللوحية الميدانية في القوات المسلحة.'
}
].map((item, idx) => (
<div key={idx} style={{
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: 14,
padding: '20px',
display: 'flex',
alignItems: 'flex-start',
gap: 16
}}>
<div style={{
width: 36,
height: 36,
borderRadius: 10,
background: 'rgba(79, 70, 229, 0.2)',
border: '1px solid rgba(79, 70, 229, 0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#a5b4fc',
fontWeight: 800,
flexShrink: 0
}}>
{idx + 1}
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<h4 style={{ margin: 0, fontSize: '1.05rem', color: '#ffffff' }}>{item.phase}</h4>
<span style={{ fontSize: '0.8rem', color: '#38bdf8', background: 'rgba(56, 189, 248, 0.1)', padding: '2px 10px', borderRadius: 999 }}>
{item.duration}
</span>
</div>
<p style={{ margin: '8px 0 0 0', fontSize: '0.88rem', color: '#94a3b8', lineHeight: 1.7 }}>
{item.desc}
</p>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Live Demo Launcher Banner */}
<section style={{
marginTop: 40,
background: 'linear-gradient(135deg, rgba(79, 70, 229, 0.2) 0%, rgba(6, 182, 212, 0.15) 100%)',
border: '1px solid rgba(99, 102, 241, 0.4)',
borderRadius: 20,
padding: '28px 32px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 20
}}>
<div>
<h3 style={{ margin: '0 0 6px 0', fontSize: '1.3rem', fontWeight: 800, color: '#ffffff' }}>
هل ترغب في استعراض النظام عملياً الآن؟
</h3>
<p style={{ margin: 0, color: '#cbd5e1', fontSize: '0.9rem' }}>
الخريطة الحية وأدوات التوجيه وتبادل الرؤية والطقس جاهزة وتعمل بالكامل داخل المتصفح.
</p>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<a href="#map" style={{
textDecoration: 'none',
background: '#ffffff',
color: '#0f172a',
padding: '10px 22px',
borderRadius: 10,
fontSize: '0.9rem',
fontWeight: 800,
display: 'flex',
alignItems: 'center',
gap: 8,
boxShadow: '0 4px 14px rgba(255, 255, 255, 0.25)'
}}>
بدء الديمو التفاعلي <ArrowRight size={16} />
</a>
</div>
</section>
</main>
</div>
);
};
+4 -4
View File
@@ -225,9 +225,9 @@ const IntelligenceDashboard: React.FC = () => {
closesRef.current = safeCloses;
pushMapData();
setNeedsAuth(false);
} catch (e: any) {
} catch (e: unknown) {
console.error(e);
setMsg(e.message || 'Error loading dashboard');
setMsg((e as any)?.message || 'Error loading dashboard');
}
setLoading(false);
};
@@ -484,7 +484,7 @@ const IntelligenceDashboard: React.FC = () => {
setMsg('Road submitted for intelligence analysis!');
cancelDrawing();
load();
} catch (e: any) { setMsg(e.message || 'Error submitting'); }
} catch (e: unknown) { setMsg((e as any)?.message || 'Error submitting'); }
setTimeout(() => setMsg(''), 5000);
};
@@ -495,7 +495,7 @@ const IntelligenceDashboard: React.FC = () => {
const r = await fetch(`${API}/maps/sync-routes`, { method: 'POST', headers: { 'x-api-key': apiKey } });
if (!r.ok) throw new Error('Failed');
setMsg('✅ Routing sync requested! Check back in 5-10 min.');
} catch (e: any) { setMsg(e.message || 'Error'); }
} catch (e: unknown) { setMsg((e as any)?.message || 'Error'); }
setTimeout(() => setMsg(''), 5000);
};
+363
View File
@@ -0,0 +1,363 @@
/**
* Tactical Elevation & Line of Sight (LOS) Calculation Service
* خدمة حساب مقطع الارتفاع التضاريسي وتبادل الرؤية العسكري (Intervisibility)
*/
export interface ElevationPoint {
distance: number; // Distance from observer (meters)
lat: number;
lng: number;
elevation: number; // Terrain elevation AMSL (meters)
rayHeight: number; // Line of Sight ray elevation at this distance (meters)
isVisible: boolean; // Can observer see this terrain point?
isTargetRayBlocked: boolean; // Does this terrain point block the ray to the final target?
clearance: number; // Clearance distance (rayHeight - elevation) in meters
}
export interface ObstacleInfo {
distance: number;
elevation: number;
lat: number;
lng: number;
excessHeight: number; // How much the obstacle penetrates above the ray (meters)
}
export interface LineOfSightResult {
points: ElevationPoint[];
totalDistance: number; // Total distance in meters
isDirectlyVisible: boolean; // Is target visible from observer?
observerElevation: number; // Ground elevation + observer height
targetElevation: number; // Ground elevation + target height
observerGroundElev: number; // Raw ground elevation
targetGroundElev: number; // Raw ground elevation
minElevation: number;
maxElevation: number;
highestObstacle: ObstacleInfo | null;
deadGroundPercentage: number; // % of line hidden behind crests
angleDegrees: number; // Vertical angle (degrees)
angleMils: number; // Military Artillery Mils (6400 mils = 360 deg)
azimuthDegrees: number; // Compass Bearing (0-360 deg)
}
// In-memory cache for DEM tile image data to avoid re-fetching
const tileCache = new Map<string, ImageData>();
/**
* Calculates Great-Circle Haversine distance in meters
*/
export function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000; // Earth radius in meters
const dLat = (lat2 - lat1) * (Math.PI / 180);
const dLon = (lon2 - lon1) * (Math.PI / 180);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * (Math.PI / 180)) * Math.cos(lat2 * (Math.PI / 180)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Calculates Forward Azimuth / Bearing (0-360 degrees)
*/
export function calculateAzimuth(lat1: number, lon1: number, lat2: number, lon2: number): number {
const phi1 = lat1 * (Math.PI / 180);
const phi2 = lat2 * (Math.PI / 180);
const deltaLambda = (lon2 - lon1) * (Math.PI / 180);
const y = Math.sin(deltaLambda) * Math.cos(phi2);
const x = Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(deltaLambda);
const theta = Math.atan2(y, x);
return (theta * (180 / Math.PI) + 360) % 360;
}
/**
* Samples elevation from Terrarium DEM tile or fallback topographic model
*/
async function sampleElevationAt(lat: number, lng: number): Promise<number> {
const zoom = 12;
const n = Math.pow(2, zoom);
const x = Math.floor(((lng + 180) / 360) * n);
const latRad = (lat * Math.PI) / 180;
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
const tileKey = `${zoom}/${x}/${y}`;
try {
let imgData = tileCache.get(tileKey);
if (!imgData) {
const tileUrl = `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/${zoom}/${x}/${y}.png`;
const img = new Image();
img.crossOrigin = 'anonymous';
const loadPromise = new Promise<HTMLImageElement>((resolve, reject) => {
img.onload = () => resolve(img);
img.onerror = (e) => reject(e);
img.src = tileUrl;
});
// 1.5s timeout for fast responsiveness
const loadedImg = await Promise.race([
loadPromise,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('DEM Timeout')), 1500))
]);
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(loadedImg, 0, 0);
imgData = ctx.getImageData(0, 0, 256, 256);
tileCache.set(tileKey, imgData);
}
}
if (imgData) {
// Calculate exact sub-pixel inside tile
const subX = (((lng + 180) / 360) * n - x) * 256;
const subY = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n - y) * 256;
const px = Math.min(255, Math.max(0, Math.floor(subX)));
const py = Math.min(255, Math.max(0, Math.floor(subY)));
const index = (py * 256 + px) * 4;
const r = imgData.data[index];
const g = imgData.data[index + 1];
const b = imgData.data[index + 2];
// Terrarium formula: (R * 256 + G + B / 256) - 32768
const elev = (r * 256 + g + b / 256) - 32768;
return Math.round(elev);
}
} catch (err) {
// Fallback topographic approximation for Jordan/Levant region
}
return getApproximateElevation(lat, lng);
}
/**
* Topographic estimation model for Jordan terrain when tiles are offline
*/
function getApproximateElevation(lat: number, lng: number): number {
// Jordan Valley & Dead Sea trench model
if (lng < 35.6 && lat < 32.2 && lat > 31.0) {
const distFromRift = Math.abs(lng - 35.5);
return -400 + distFromRift * 3000;
}
// Northern Highlands (Ajloun / Jerash / Salt)
if (lat >= 32.1 && lng < 36.0) {
return 850 + Math.sin(lat * 50) * 250 + Math.cos(lng * 40) * 150;
}
// Amman Plateau
if (lat >= 31.8 && lat < 32.1 && lng >= 35.8 && lng < 36.2) {
return 900 + Math.sin((lat - 31.95) * 100) * 120 + Math.cos((lng - 35.9) * 100) * 100;
}
// Southern Highlands (Karak / Tafilah / Shobak / Petra)
if (lat < 31.5 && lat > 30.0 && lng < 35.7) {
return 1100 + Math.sin(lat * 30) * 350;
}
// Eastern Desert (Badia)
return 650 + (lng - 36.0) * 30;
}
/**
* Calculates Line of Sight and Elevation Profile between two coordinates
*
* @param startLat Observer Latitude
* @param startLng Observer Longitude
* @param endLat Target Latitude
* @param endLng Target Longitude
* @param obsHeight Observer Eye Level offset above ground (default: 2 meters)
* @param tgtHeight Target Height offset above ground (default: 2 meters)
* @param samples Number of sampling steps along the ray (default: 60)
*/
export async function calculateLineOfSight(
startLat: number,
startLng: number,
endLat: number,
endLng: number,
obsHeight: number = 2,
tgtHeight: number = 2,
samples: number = 60
): Promise<LineOfSightResult> {
const totalDistance = calculateDistance(startLat, startLng, endLat, endLng);
const azimuthDegrees = calculateAzimuth(startLat, startLng, endLat, endLng);
// 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 controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const res = await fetch(`${apiUrl}/tactical/line-of-sight?observerLat=${startLat}&observerLng=${startLng}&targetLat=${endLat}&targetLng=${endLng}&observerHeight=${obsHeight}&targetHeight=${tgtHeight}&samples=${samples}`, {
headers: { 'x-api-key': apiKey },
signal: controller.signal
});
clearTimeout(timeoutId);
if (res.ok) {
const data = await res.json();
return {
points: (data.profile || []).map((p: any) => ({
distance: p.distanceMeters,
lat: p.lat,
lng: p.lng,
elevation: p.groundElevationMeters,
rayHeight: p.rayElevationMeters,
isVisible: p.isVisible,
isTargetRayBlocked: p.isTargetRayBlocked,
clearance: p.clearanceMeters
})),
totalDistance: data.summary.totalDistanceMeters,
isDirectlyVisible: data.isDirectlyVisible,
observerElevation: data.summary.observerTotalElevationMeters,
targetElevation: data.summary.targetTotalElevationMeters,
observerGroundElev: data.summary.observerGroundElevationMeters,
targetGroundElev: data.summary.targetGroundElevationMeters,
minElevation: data.summary.minElevationMeters,
maxElevation: data.summary.maxElevationMeters,
highestObstacle: data.highestObstacle ? {
distance: data.highestObstacle.distanceMeters,
elevation: data.highestObstacle.groundElevationMeters,
lat: data.highestObstacle.lat,
lng: data.highestObstacle.lng,
excessHeight: data.highestObstacle.excessHeightMeters
} : null,
deadGroundPercentage: data.summary.deadGroundPercentage,
angleDegrees: data.summary.verticalAngleDegrees,
angleMils: data.summary.verticalAngleMilsNato,
azimuthDegrees: data.summary.azimuthDegrees
};
}
} catch {
// API not available or timed out, fall back to local DEM tile processing
}
// Generate sample coordinates along the geodesic path
const sampleCoords: { lat: number; lng: number; dist: number }[] = [];
for (let i = 0; i <= samples; i++) {
const fraction = i / samples;
const lat = startLat + (endLat - startLat) * fraction;
const lng = startLng + (endLng - startLng) * fraction;
const dist = totalDistance * fraction;
sampleCoords.push({ lat, lng, dist });
}
// Fetch elevations for all sample points in parallel
const elevations = await Promise.all(
sampleCoords.map((coord) => sampleElevationAt(coord.lat, coord.lng))
);
const observerGroundElev = elevations[0];
const targetGroundElev = elevations[elevations.length - 1];
const observerElevation = observerGroundElev + obsHeight;
const targetElevation = targetGroundElev + tgtHeight;
// Earth curvature & atmospheric refraction parameter (k ≈ 0.13 for standard atmosphere)
const R_earth = 6371000;
const k_refraction = 0.13;
const effectiveEarthRadius = R_earth / (1 - k_refraction);
let isDirectlyVisible = true;
let highestObstacle: ObstacleInfo | null = null;
let maxObstacleExcess = 0;
let deadGroundCount = 0;
// Horizon angle tracking from observer (tan of highest angle encountered so far)
let maxAngleSoFar = -Infinity;
const points: ElevationPoint[] = [];
let minElev = Infinity;
let maxElev = -Infinity;
for (let i = 0; i <= samples; i++) {
const d = sampleCoords[i].dist;
const elev = elevations[i];
minElev = Math.min(minElev, elev);
maxElev = Math.max(maxElev, elev);
// Earth curvature sagitta at distance d: deltaH = (d * (totalDistance - d)) / (2 * R_effective)
const earthCurvatureDrop = (d * (totalDistance - d)) / (2 * effectiveEarthRadius);
// Theoretical straight ray height AMSL connecting Observer to Target
const rayHeight = observerElevation + ((targetElevation - observerElevation) * (d / totalDistance)) - earthCurvatureDrop;
// Clearance (positive = ray above terrain, negative = obstacle)
const clearance = rayHeight - elev;
// Check if this point blocks the direct ray to the target (ignore start and end margins)
let isTargetRayBlocked = false;
if (i > 1 && i < samples) {
if (elev > rayHeight) {
isDirectlyVisible = false;
isTargetRayBlocked = true;
const excess = elev - rayHeight;
if (excess > maxObstacleExcess) {
maxObstacleExcess = excess;
highestObstacle = {
distance: Math.round(d),
elevation: elev,
lat: sampleCoords[i].lat,
lng: sampleCoords[i].lng,
excessHeight: Math.round(excess * 10) / 10,
};
}
}
}
// Check visibility from observer's eye (Viewshed / Shadowing along profile)
let isVisible = true;
if (i === 0) {
isVisible = true;
} else {
const angleFromObs = (elev - observerElevation) / d;
if (angleFromObs >= maxAngleSoFar) {
maxAngleSoFar = angleFromObs;
isVisible = true;
} else {
isVisible = false;
deadGroundCount++;
}
}
points.push({
distance: Math.round(d),
lat: sampleCoords[i].lat,
lng: sampleCoords[i].lng,
elevation: elev,
rayHeight: Math.round(rayHeight * 10) / 10,
isVisible,
isTargetRayBlocked,
clearance: Math.round(clearance * 10) / 10,
});
}
// Calculate Vertical Angle (Degrees & Artillery Mils)
// 1 Degree = 17.7778 Artillery Mils (6400 Mils in full circle)
const verticalDiff = targetElevation - observerElevation;
const angleRad = Math.atan2(verticalDiff, totalDistance);
const angleDegrees = Math.round((angleRad * (180 / Math.PI)) * 100) / 100;
const angleMils = Math.round((angleDegrees * (6400 / 360)) * 10) / 10;
const deadGroundPercentage = Math.round((deadGroundCount / samples) * 100);
return {
points,
totalDistance: Math.round(totalDistance),
isDirectlyVisible,
observerElevation: Math.round(observerElevation),
targetElevation: Math.round(targetElevation),
observerGroundElev: Math.round(observerGroundElev),
targetGroundElev: Math.round(targetGroundElev),
minElevation: Math.round(minElev),
maxElevation: Math.round(maxElev),
highestObstacle,
deadGroundPercentage,
angleDegrees,
angleMils,
azimuthDegrees: Math.round(azimuthDegrees * 10) / 10,
};
}
+36
View File
@@ -0,0 +1,36 @@
export function getWeatherDescription(code: number): { icon: string, description_ar: string, description_en: string } {
switch (code) {
case 0: return { icon: '☀️', description_ar: 'صافي', description_en: 'Clear' };
case 1: return { icon: '🌤️', description_ar: 'صافي غالباً', description_en: 'Mainly clear' };
case 2: return { icon: '⛅', description_ar: 'غائم جزئياً', description_en: 'Partly cloudy' };
case 3: return { icon: '☁️', description_ar: 'غائم', description_en: 'Overcast' };
case 45:
case 48: return { icon: '🌫️', description_ar: 'ضباب', description_en: 'Fog' };
case 51:
case 53:
case 55: return { icon: '🌦️', description_ar: 'رذاذ', description_en: 'Drizzle' };
case 61: return { icon: '🌧️', description_ar: 'أمطار خفيفة', description_en: 'Light rain' };
case 63: return { icon: '🌧️', description_ar: 'أمطار متوسطة', description_en: 'Moderate rain' };
case 65: return { icon: '🌧️', description_ar: 'أمطار غزيرة', description_en: 'Heavy rain' };
case 71:
case 73:
case 75: return { icon: '🌨️', description_ar: 'ثلوج', description_en: 'Snow' };
case 80:
case 81:
case 82: return { icon: '🌧️', description_ar: 'زخات مطرية', description_en: 'Rain showers' };
case 95: return { icon: '⛈️', description_ar: 'عاصفة رعدية', description_en: 'Thunderstorm' };
case 96:
case 99: return { icon: '⛈️', description_ar: 'عاصفة رعدية مع بَرَد', description_en: 'Thunderstorm with hail' };
default: return { icon: '🌡️', description_ar: 'غير معروف', description_en: 'Unknown' };
}
}
export function getTemperatureColor(temp: number): string {
if (temp <= 0) return '#0047AB';
if (temp <= 10) return '#4169E1';
if (temp <= 20) return '#32CD32';
if (temp <= 30) return '#FFD700';
if (temp <= 40) return '#FF8C00';
if (temp <= 45) return '#FF4500';
return '#DC143C';
}