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