- controller: @Query() params arrive as strings in NestJS - now explicitly
parseFloat() all numeric params (lat, lng, radius) before passing to service.
Also validates against NaN before hitting PostGIS.
- controller: reverse geocode now validates and throws 400 on invalid lat/lng.
- service: searchPlaces now normalizes all results to include:
- location: { lat, lng } nested object (frontend was crashing on place.location.lat)
- distance_km: pre-computed string field (frontend was reading undefined distance_km)
- latitude/longitude as actual floats (not decimal strings from DB)
- frontend (App.tsx): fixed map.flyTo() to read place.latitude/place.longitude
instead of the non-existent place.location.lat/lng.
- frontend (App.tsx): fixed search result click handler same way.
- frontend (App.tsx): fixed distance display to compute from res.distance (meters).
- entity: added missing source column to BasePlace entity.
350 lines
14 KiB
TypeScript
350 lines
14 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import MapComponent from './components/MapComponent';
|
|
import { Navigation, Compass, Activity, BarChart3, MapPin } from 'lucide-react';
|
|
import { decodePolyline } from './utils/polyline';
|
|
|
|
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 [stats, setStats] = useState<any>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [show3D, setShow3D] = useState(false);
|
|
const [showPOIs, setShowPOIs] = useState(true);
|
|
|
|
// 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');
|
|
|
|
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: '🇪🇬' },
|
|
];
|
|
|
|
const handleRegionSwitch = (region: any) => {
|
|
setCurrentRegion(region.name);
|
|
if (map) {
|
|
map.flyTo({
|
|
center: region.center,
|
|
zoom: region.zoom,
|
|
essential: true,
|
|
duration: 3000
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleMapClick = (lat: number, lng: number) => {
|
|
setNewPlace({ lat, lng });
|
|
};
|
|
|
|
const handleSearch = async () => {
|
|
if (searchQuery.length < 3) return;
|
|
setLoading(true);
|
|
try {
|
|
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
|
|
|
let queryUrl = `${apiUrl}/geocoding/search?q=${searchQuery}&radius=20000`;
|
|
|
|
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' }
|
|
});
|
|
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 });
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Search failed", e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
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';
|
|
const response = await fetch(`${apiUrl}/map-refinement/summary`);
|
|
if (!response.ok) throw new Error('Stats fetch failed');
|
|
const data = await response.json();
|
|
setStats(data);
|
|
} catch (error) {
|
|
console.warn("Stats fetch failed, using fallback UI");
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchStats();
|
|
const interval = setInterval(fetchStats, 60000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
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 calculateRoute = async () => {
|
|
setLoading(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 data = await response.json();
|
|
|
|
if (data.points && map) {
|
|
const coords = typeof data.points === 'string' ? decodePolyline(data.points) : data.points;
|
|
setRouteData({ ...data, points: coords });
|
|
|
|
if (map.getSource('route')) {
|
|
map.getSource('route').setData({
|
|
type: 'Feature',
|
|
properties: {},
|
|
geometry: { type: 'LineString', coordinates: coords }
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error calculating route:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
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>
|
|
|
|
<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 #3b82f6' : '1px solid var(--glass-border)',
|
|
background: currentRegion === r.name ? 'rgba(59, 130, 246, 0.1)' : '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>
|
|
|
|
<div className="input-group">
|
|
<label><MapPin size={14} style={{ marginRight: 5 }} /> Search / البحث</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>
|
|
</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' }}>
|
|
{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' }}
|
|
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' }}>
|
|
{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)' }}
|
|
onClick={() => setShowResults(false)}
|
|
>
|
|
Clear Results / مسح
|
|
</button>
|
|
</div>
|
|
)}
|
|
{showResults && searchResults.length === 0 && (
|
|
<div style={{ marginTop: '10px', fontSize: '0.8rem', color: '#ef4444' }}>No results found near you.</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="input-group">
|
|
<label><Navigation size={14} style={{ marginRight: 5 }} /> Origin / نقطة الانطلاق</label>
|
|
<input type="text" placeholder="Amman, Jordan" defaultValue="31.9539, 35.9106" />
|
|
</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" />
|
|
</div>
|
|
|
|
<button className="btn" onClick={calculateRoute} disabled={loading}>
|
|
{loading ? 'Calculating...' : 'Calculate Route / حساب المسار'}
|
|
</button>
|
|
|
|
<hr style={{ border: 'none', borderTop: '1px solid var(--glass-border)', margin: '10px 0' }} />
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
|
|
<h3>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 / مباني 3D
|
|
</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>
|
|
|
|
<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}
|
|
/>
|
|
|
|
{/* 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>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
|
|
<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;
|