859 lines
36 KiB
TypeScript
859 lines
36 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import MapComponent from './components/MapComponent';
|
|
import { Navigation, Compass, Activity, BarChart3, MapPin, Eye, Shield, Gauge, Clock, AlertTriangle } from 'lucide-react';
|
|
import { decodePolyline } from './utils/polyline';
|
|
import WeatherPanel from './components/WeatherPanel';
|
|
import { LineOfSightTool } from './components/LineOfSightTool';
|
|
import { LandmarkCard, HeritageLandmarkData } from './components/LandmarkCard';
|
|
import { SubmitContributionModal } from './components/SubmitContributionModal';
|
|
import { GuidesModerationPanel } from './components/GuidesModerationPanel';
|
|
|
|
const DEFAULT_API_KEY = (import.meta as any).env.VITE_API_KEY || localStorage.getItem('intaleq_api_key') || '';
|
|
|
|
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[]>([]);
|
|
|
|
// Tourism & Heritage State
|
|
const [tourismMode, setTourismMode] = useState(false);
|
|
const [heritageLandmarks, setHeritageLandmarks] = useState<HeritageLandmarkData[]>([]);
|
|
const [selectedLandmark, setSelectedLandmark] = useState<HeritageLandmarkData | null>(null);
|
|
const [showContributeModal, setShowContributeModal] = useState(false);
|
|
const [contributeTargetLandmark, setContributeTargetLandmark] = useState<HeritageLandmarkData | null>(null);
|
|
const [showGuidesPanel, setShowGuidesPanel] = useState(false);
|
|
|
|
// Geocoding State
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
|
const [showResults, setShowResults] = useState(false);
|
|
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 fetchHeritageLandmarks = async () => {
|
|
try {
|
|
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
|
const res = await fetch(`${apiUrl}/v1/heritage/landmarks`, {
|
|
headers: { 'x-api-key': DEFAULT_API_KEY }
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setHeritageLandmarks(Array.isArray(data) ? data : []);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch heritage landmarks', e);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (tourismMode) {
|
|
fetchHeritageLandmarks();
|
|
} else {
|
|
setHeritageLandmarks([]);
|
|
setSelectedLandmark(null);
|
|
}
|
|
}, [tourismMode]);
|
|
|
|
const handleNavigateToGate = (lat: number, lng: number, name: string) => {
|
|
setDestText(`${lat.toFixed(5)}, ${lng.toFixed(5)}`);
|
|
if (map) {
|
|
const center = map.getCenter();
|
|
setOriginText(`${center.lat.toFixed(5)}, ${center.lng.toFixed(5)}`);
|
|
}
|
|
setTimeout(() => {
|
|
calculateRoute();
|
|
}, 150);
|
|
};
|
|
|
|
const regions = [
|
|
{
|
|
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: 'Iraq',
|
|
name_ar: 'العراق',
|
|
center: [44.3661, 33.3152],
|
|
zoom: 11,
|
|
flag: '🇮🇶',
|
|
defaultOrigin: '33.3152, 44.3661', // Baghdad
|
|
defaultDest: '32.0000, 44.4000' // Karbala / Babil
|
|
},
|
|
{
|
|
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: 2500
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleSearch = async () => {
|
|
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=${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': DEFAULT_API_KEY }
|
|
});
|
|
const data = await response.json();
|
|
let results = data.results || [];
|
|
|
|
// If no local results within 50km, fall back to nationwide/global search
|
|
if (results.length === 0) {
|
|
const fallbackRes = await fetch(`${apiUrl}/geocoding/search?q=${encodeURIComponent(searchQuery)}`, {
|
|
headers: { 'x-api-key': DEFAULT_API_KEY }
|
|
});
|
|
const fallbackData = await fallbackRes.json();
|
|
if (fallbackData.results && fallbackData.results.length > 0) {
|
|
results = fallbackData.results;
|
|
}
|
|
}
|
|
|
|
setSearchResults(results);
|
|
setShowResults(true);
|
|
|
|
if (results && results.length > 0 && map) {
|
|
const place = results[0];
|
|
const placeLng = parseFloat(place.longitude);
|
|
const placeLat = parseFloat(place.latitude);
|
|
if (!isNaN(placeLat) && !isNaN(placeLng)) {
|
|
map.flyTo({ center: [placeLng, placeLat], zoom: 15, duration: 2000 });
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Search failed", e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchStats = async () => {
|
|
try {
|
|
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
|
const response = await fetch(`${apiUrl}/map-refinement/roads/summary`);
|
|
if (!response.ok) throw new Error('Stats fetch failed');
|
|
const data = await response.json();
|
|
setStats(data);
|
|
} catch (error) {
|
|
console.warn("Stats fetch failed");
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchStats();
|
|
const interval = setInterval(fetchStats, 60000);
|
|
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', () => {
|
|
const center = initializedMap.getCenter();
|
|
setDebug({
|
|
zoom: initializedMap.getZoom().toFixed(2),
|
|
center: [center.lng.toFixed(4), center.lat.toFixed(4)],
|
|
bounds: initializedMap.getBounds().toString()
|
|
});
|
|
});
|
|
};
|
|
|
|
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 () => {
|
|
setRouteError('');
|
|
const originCoords = parseCoordinates(originText);
|
|
const destCoords = parseCoordinates(destText);
|
|
|
|
if (!originCoords || !destCoords) {
|
|
setRouteError('Please enter valid coordinates format: lat, lng');
|
|
return;
|
|
}
|
|
|
|
setRouteLoading(true);
|
|
try {
|
|
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
|
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 && data.points) {
|
|
const coords = typeof data.points === 'string' ? decodePolyline(data.points) : data.points;
|
|
setRouteData({ ...data, points: coords });
|
|
|
|
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 {
|
|
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' }}>Intaleq Mobility & Maps Cloud</p>
|
|
|
|
{/* Siro Tourism & Heritage Mode Toggle */}
|
|
<div style={{ display: 'flex', gap: '8px', margin: '14px 0 6px' }}>
|
|
<button
|
|
onClick={() => setTourismMode(!tourismMode)}
|
|
style={{
|
|
flex: 1.2,
|
|
padding: '10px 8px',
|
|
borderRadius: '10px',
|
|
border: tourismMode ? '1.5px solid #d4af37' : '1px solid var(--glass-border)',
|
|
background: tourismMode
|
|
? 'linear-gradient(135deg, rgba(181, 146, 71, 0.3) 0%, rgba(212, 175, 55, 0.15) 100%)'
|
|
: 'rgba(255,255,255,0.04)',
|
|
color: tourismMode ? '#fef08a' : '#e2e8f0',
|
|
fontWeight: 800,
|
|
fontSize: '0.78rem',
|
|
cursor: 'pointer',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: '6px',
|
|
transition: 'all 0.2s',
|
|
boxShadow: tourismMode ? '0 4px 14px rgba(212, 175, 55, 0.25)' : 'none',
|
|
}}
|
|
>
|
|
<span>🏛️</span>
|
|
<span>{tourismMode ? 'الخريطة السياحية (مفعلة)' : 'النمط السياحي والآثار'}</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setShowGuidesPanel(true)}
|
|
style={{
|
|
flex: 0.8,
|
|
padding: '10px 8px',
|
|
borderRadius: '10px',
|
|
border: '1px solid var(--glass-border)',
|
|
background: 'rgba(255,255,255,0.04)',
|
|
color: '#38bdf8',
|
|
fontWeight: 700,
|
|
fontSize: '0.78rem',
|
|
cursor: 'pointer',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: '4px',
|
|
}}
|
|
title="مجتمع المرشدين وطابور المراجعة"
|
|
>
|
|
<span>🌟</span>
|
|
<span>المرشدين</span>
|
|
</button>
|
|
</div>
|
|
|
|
{tourismMode && (
|
|
<div style={{
|
|
background: 'rgba(181, 146, 71, 0.1)',
|
|
border: '1px solid rgba(181, 146, 71, 0.3)',
|
|
borderRadius: '10px',
|
|
padding: '8px 10px',
|
|
fontSize: '0.75rem',
|
|
color: '#fef08a',
|
|
marginBottom: '10px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
}}>
|
|
<span>✨ معالم التراث والآثار ({heritageLandmarks.length})</span>
|
|
<span style={{ fontSize: '0.7rem', color: '#cbd5e1' }}>انقر لأي معلم لمشاهدة البوابات</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Region Selector */}
|
|
<div className="region-selector" style={{ display: 'flex', gap: '8px', margin: '15px 0' }}>
|
|
{regions.map(r => (
|
|
<button
|
|
key={r.name}
|
|
onClick={() => handleRegionSwitch(r)}
|
|
className={`region-btn ${currentRegion === r.name ? 'active' : ''}`}
|
|
style={{
|
|
flex: 1,
|
|
padding: '8px 4px',
|
|
borderRadius: '8px',
|
|
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',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
gap: '4px',
|
|
transition: 'all 0.2s'
|
|
}}
|
|
>
|
|
<span style={{ fontSize: '1.2rem' }}>{r.flag}</span>
|
|
<span>{r.name_ar}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Search Bar */}
|
|
<div className="input-group">
|
|
<label><MapPin size={14} style={{ marginRight: 5 }} /> Search Places / البحث في الأماكن</label>
|
|
<div style={{ display: 'flex', gap: '5px' }}>
|
|
<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(15, 23, 42, 0.8)', borderRadius: '8px', border: '1px solid var(--glass-border)' }}>
|
|
{searchResults.map((res) => (
|
|
<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, 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: '6px', fontSize: '0.75rem', background: 'transparent', border: 'none', color: '#94a3b8', cursor: 'pointer' }}
|
|
onClick={() => setShowResults(false)}
|
|
>
|
|
Clear Results / إغلاق
|
|
</button>
|
|
</div>
|
|
)}
|
|
{showResults && searchResults.length === 0 && (
|
|
<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="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="lat, lng"
|
|
value={destText}
|
|
onChange={e => setDestText(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<button className="btn" onClick={calculateRoute} disabled={routeLoading} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '8px' }}>
|
|
<Navigation size={16} />
|
|
{routeLoading ? 'Calculating...' : 'Calculate Route / حساب المسار'}
|
|
</button>
|
|
|
|
{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: '14px', borderRadius: '10px', background: 'rgba(15, 23, 42, 0.75)', border: '1px solid rgba(56, 189, 248, 0.3)' }}>
|
|
<h4 style={{ margin: '0 0 10px 0', fontSize: '0.92rem', color: '#38bdf8', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<span>{routeData.routeName || 'تفاصيل المسار'}</span>
|
|
<span style={{ fontSize: '0.75rem', background: 'rgba(56, 189, 248, 0.15)', color: '#38bdf8', padding: '2px 8px', borderRadius: 999 }}>
|
|
{routeData.tags ? routeData.tags[0] : 'FASTEST'}
|
|
</span>
|
|
</h4>
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '8px' }}>
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', color: '#e2e8f0' }}>
|
|
<Gauge size={14} color="#38bdf8" />
|
|
{(Number(routeData.distance || 0) / 1000).toFixed(1)} كم
|
|
</span>
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', color: '#4ade80' }}>
|
|
<Clock size={14} color="#22c55e" />
|
|
{Math.round(Number(routeData.duration || 0) / 60)} دقيقة
|
|
</span>
|
|
</div>
|
|
|
|
{/* Elevation & Slope Summary */}
|
|
{routeData.elevationSummary && (
|
|
<div style={{ marginTop: 10, paddingTop: 8, borderTop: '1px solid rgba(255,255,255,0.08)', fontSize: '0.78rem' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#94a3b8', marginBottom: 4 }}>
|
|
<span>📈 إجمالي الصعود: <strong style={{ color: '#4ade80' }}>+{routeData.elevationSummary.totalAscentMeters}م</strong></span>
|
|
<span>📉 إجمالي الهبوط: <strong style={{ color: '#38bdf8' }}>-{routeData.elevationSummary.totalDescentMeters}م</strong></span>
|
|
</div>
|
|
{(routeData.elevationSummary.maxInclinePercent > 0 || routeData.elevationSummary.maxDeclinePercent < 0) && (
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#94a3b8' }}>
|
|
<span>أقصى صعود: <strong style={{ color: '#fbbf24' }}>+{routeData.elevationSummary.maxInclinePercent}%</strong></span>
|
|
<span>أقصى انحدار: <strong style={{ color: '#f87171' }}>{routeData.elevationSummary.maxDeclinePercent}%</strong></span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Fuel & Eco Energy Impact */}
|
|
{routeData.ecoMetrics && (
|
|
<div style={{ marginTop: 10, paddingTop: 8, borderTop: '1px solid rgba(255,255,255,0.08)', fontSize: '0.78rem' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
|
<span style={{ color: '#38bdf8', fontWeight: 600 }}>🌿 مؤشر الطاقة واستهلاك الوقود:</span>
|
|
<span style={{ fontSize: '0.72rem', padding: '2px 6px', borderRadius: 4, background: routeData.ecoMetrics.ecoScore >= 80 ? 'rgba(34, 197, 94, 0.2)' : 'rgba(245, 158, 11, 0.2)', color: routeData.ecoMetrics.ecoScore >= 80 ? '#4ade80' : '#fbbf24' }}>
|
|
{routeData.ecoMetrics.ecoBadge} ({routeData.ecoMetrics.ecoScore}/100)
|
|
</span>
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#94a3b8', marginBottom: 4 }}>
|
|
<span>⛽ وقود مقدر: <strong style={{ color: '#f8fafc' }}>{routeData.ecoMetrics.estimatedGasolineLiters} لتر</strong></span>
|
|
<span>💰 تكلفة تقديرية: <strong style={{ color: '#4ade80' }}>{routeData.ecoMetrics.estimatedCostJOD} د.أ</strong></span>
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#94a3b8', marginBottom: 4 }}>
|
|
<span>⚡ استهلاك EV: <strong style={{ color: '#38bdf8' }}>{routeData.ecoMetrics.estimatedEvKWh} kWh</strong></span>
|
|
<span>🌱 انبعاثات الكربون: <strong style={{ color: '#94a3b8' }}>{routeData.ecoMetrics.co2Kg} كغم</strong></span>
|
|
</div>
|
|
{routeData.ecoMetrics.mechanicalAdvice && (
|
|
<div style={{ marginTop: 6, padding: '5px 8px', borderRadius: 6, background: 'rgba(56, 189, 248, 0.1)', color: '#bae6fd', fontSize: '0.74rem' }}>
|
|
💡 {routeData.ecoMetrics.mechanicalAdvice}
|
|
</div>
|
|
)}
|
|
{routeData.ecoMetrics.pricingBulletin && (
|
|
<div style={{ fontSize: '0.68rem', color: '#64748b', marginTop: 5, display: 'flex', justifyContent: 'space-between' }}>
|
|
<span>📅 تسعيرة {routeData.ecoMetrics.pricingBulletin.effectiveMonth}</span>
|
|
<span>بنزين 90: {routeData.ecoMetrics.pricingBulletin.gasoline90JOD} د.أ/لتر</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Steep Slope Warnings Alert */}
|
|
{routeData.elevationSummary?.steepWarnings && routeData.elevationSummary.steepWarnings.length > 0 && (
|
|
<div style={{
|
|
marginTop: 10,
|
|
padding: '8px 10px',
|
|
background: 'rgba(239, 68, 68, 0.15)',
|
|
border: '1px solid rgba(239, 68, 68, 0.35)',
|
|
borderRadius: 8,
|
|
fontSize: '0.75rem',
|
|
color: '#fca5a5'
|
|
}}>
|
|
<div style={{ fontWeight: 700, display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
|
<AlertTriangle size={13} color="#ef4444" />
|
|
<span>تحذير تضاريسي للمركبات الثقيلة:</span>
|
|
</div>
|
|
{routeData.elevationSummary.steepWarnings.slice(0, 2).map((w: any, idx: number) => (
|
|
<div key={idx} style={{ marginTop: 2 }}>
|
|
• {w.street ? `شارع ${w.street}: ` : ''}{w.slopePercent > 0 ? `صعود حاد (+${w.slopePercent}%)` : `منحدر جبلي شديد (${w.slopePercent}%)`}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Turn by turn expandable instructions */}
|
|
{routeData.instructions && routeData.instructions.length > 0 && (
|
|
<details style={{ marginTop: 10, fontSize: '0.75rem', color: '#cbd5e1' }}>
|
|
<summary style={{ cursor: 'pointer', color: '#38bdf8', fontWeight: 600, padding: '4px 0' }}>
|
|
عرض خطوات المسار بالتفصيل ({routeData.instructions.length} خطوة)
|
|
</summary>
|
|
<div style={{ maxHeight: 180, overflowY: 'auto', marginTop: 6, paddingRight: 4, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
{routeData.instructions.map((inst: any, idx: number) => (
|
|
<div key={idx} style={{
|
|
padding: '4px 6px',
|
|
borderRadius: 4,
|
|
background: inst.slopeWarning ? 'rgba(245, 158, 11, 0.12)' : 'rgba(255,255,255,0.03)',
|
|
borderRight: inst.slopeWarning ? '3px solid #f59e0b' : '1px solid transparent'
|
|
}}>
|
|
<div style={{ fontWeight: inst.slopeWarning ? 700 : 400, color: inst.slopeWarning ? '#fbbf24' : '#e2e8f0' }}>
|
|
{idx + 1}. {inst.text}
|
|
</div>
|
|
<div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>
|
|
{inst.distance ? `${Math.round(inst.distance)}م` : ''} {inst.slopePercent ? `| انحدار: ${inst.slopePercent}%` : ''}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</details>
|
|
)}
|
|
</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)} />
|
|
🏢 3D Buildings / مباني ثلاثية الأبعاد
|
|
</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={showPOIs} onChange={(e) => setShowPOIs(e.target.checked)} />
|
|
📍 Show POIs / المعالم والأنشطة
|
|
</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={showAdminBoundaries} onChange={(e) => setShowAdminBoundaries(e.target.checked)} />
|
|
🏛️ Admin Boundaries / الحدود الإدارية
|
|
</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={showTerrain} onChange={(e) => setShowTerrain(e.target.checked)} />
|
|
🏔️ Hillshading & Relief / تضاريس وظلال جبلية
|
|
</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={showContours} onChange={(e) => setShowContours(e.target.checked)} />
|
|
〰️ Contour Lines / خطوط الكنتور (الارتفاعات)
|
|
</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>
|
|
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
|
<Activity size={18} color="#22c55e" />
|
|
<span>Live Drivers: 12</span>
|
|
</div>
|
|
<button className="btn" style={{ background: '#334155' }}>
|
|
Spawn Simulated Drivers
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="map-container">
|
|
<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}
|
|
tourismMode={tourismMode}
|
|
heritageLandmarks={heritageLandmarks}
|
|
selectedLandmark={selectedLandmark}
|
|
onSelectLandmark={setSelectedLandmark}
|
|
/>
|
|
|
|
{/* Interactive Landmark Card */}
|
|
{selectedLandmark && (
|
|
<LandmarkCard
|
|
landmark={selectedLandmark}
|
|
onClose={() => setSelectedLandmark(null)}
|
|
onNavigateToGate={handleNavigateToGate}
|
|
onOpenContribute={(lm) => {
|
|
setContributeTargetLandmark(lm);
|
|
setShowContributeModal(true);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Field Contributor Submission Modal */}
|
|
{showContributeModal && (
|
|
<SubmitContributionModal
|
|
landmark={contributeTargetLandmark}
|
|
mapCenter={map ? map.getCenter() : undefined}
|
|
apiKey={DEFAULT_API_KEY}
|
|
onClose={() => {
|
|
setShowContributeModal(false);
|
|
setContributeTargetLandmark(null);
|
|
}}
|
|
onSuccess={() => {
|
|
fetchHeritageLandmarks();
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Community Guides & Moderation Slide-Over */}
|
|
{showGuidesPanel && (
|
|
<GuidesModerationPanel
|
|
apiKey={DEFAULT_API_KEY}
|
|
onClose={() => setShowGuidesPanel(false)}
|
|
onContributionApproved={() => {
|
|
fetchHeritageLandmarks();
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Tactical Line of Sight Tool Overlay */}
|
|
<LineOfSightTool
|
|
active={showLOS}
|
|
pointA={losPointA}
|
|
pointB={losPointB}
|
|
onClose={handleCloseLOS}
|
|
onClear={handleClearLOS}
|
|
onSwap={() => {
|
|
const temp = losPointA;
|
|
setLosPointA(losPointB);
|
|
setLosPointB(temp);
|
|
}}
|
|
/>
|
|
|
|
{stats && stats.telemetry && (
|
|
<div className="stats-panel glass-morphism">
|
|
<h4><BarChart3 size={16} style={{ verticalAlign: 'middle', marginRight: '8px' }} /> Map Insights / التحليل</h4>
|
|
<div className="stat-item">
|
|
<span className="stat-label">Total GPS Points / إجمالي النقاط</span>
|
|
<span className="stat-value">{stats.telemetry?.totalPoints?.toLocaleString()}</span>
|
|
</div>
|
|
<div className="stat-item">
|
|
<span className="stat-label">Last 24h / آخر 24 ساعة</span>
|
|
<span className="stat-badge">+{stats.telemetry?.last24h?.toLocaleString()}</span>
|
|
</div>
|
|
<div className="stat-item">
|
|
<span className="stat-label">Updated Roads / شوارع محدثة</span>
|
|
<span className="stat-value">{stats.roadSegments?.analyzed}</span>
|
|
</div>
|
|
<div className="stat-item">
|
|
<span className="stat-label">Candidate Roads / شـوارع مرشحة</span>
|
|
<span className="stat-value">{stats.candidateRoads?.total}</span>
|
|
</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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|