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
+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>