stable: finalized GIS aesthetics and Syria/Jordan routing synchronization
This commit is contained in:
+188
-22
@@ -1,11 +1,101 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import MapComponent from './components/MapComponent';
|
||||
import { Navigation, Map as MapIcon, Database, Activity, Compass } from 'lucide-react';
|
||||
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 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];
|
||||
map.flyTo({ center: [place.location.lng, place.location.lat], 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);
|
||||
@@ -19,8 +109,6 @@ function App() {
|
||||
});
|
||||
};
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const calculateRoute = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -28,20 +116,23 @@ function App() {
|
||||
const from = [31.9539, 35.9106];
|
||||
const to = [32.0608, 36.1032];
|
||||
|
||||
const response = await fetch(`http://localhost:3200/maps/route?fromLat=${from[0]}&fromLng=${from[1]}&toLat=${to[0]}&toLng=${to[1]}`);
|
||||
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) {
|
||||
setRouteData(data);
|
||||
// Draw on 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: data.points
|
||||
}
|
||||
geometry: { type: 'LineString', coordinates: coords }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -52,16 +143,49 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const [show3D, setShow3D] = useState(false);
|
||||
const [showPOIs, setShowPOIs] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{/* Sidebar - لوحة التحكم */}
|
||||
<div className="sidebar glass-morphism">
|
||||
<h1>Jordan Maps SaaS</h1>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Self-Hosted Mobility Prototype</p>
|
||||
|
||||
<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={() => map?.flyTo({ center: [res.location.lng, res.location.lat], 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_km} 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" />
|
||||
@@ -72,16 +196,14 @@ function App() {
|
||||
<input type="text" placeholder="Zarqa, Jordan" defaultValue="32.0608, 36.1032" />
|
||||
</div>
|
||||
|
||||
<button className="btn" onClick={calculateRoute}>
|
||||
Calculate Route / حساب المسار
|
||||
<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' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0 }}>Options / خيارات</h3>
|
||||
</div>
|
||||
<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' }}>
|
||||
@@ -110,15 +232,59 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map - الخريطة */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Debug Panel - لوحة المعلومات */}
|
||||
<div className="debug-panel glass-morphism">
|
||||
<div>Zoom: {debug.zoom}</div>
|
||||
<div>Center: {debug.center[0]}, {debug.center[1]}</div>
|
||||
|
||||
@@ -3,12 +3,14 @@ import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
interface MapComponentProps {
|
||||
onMapLoad: (map: maplibregl.Map) => void;
|
||||
onMapClick?: (lat: number, lng: number) => void;
|
||||
show3D?: boolean;
|
||||
showPOIs?: boolean;
|
||||
}
|
||||
|
||||
const MapComponent: React.FC<MapComponentProps> = ({
|
||||
onMapLoad,
|
||||
onMapClick,
|
||||
show3D = false,
|
||||
showPOIs = true
|
||||
}) => {
|
||||
@@ -25,7 +27,7 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
}
|
||||
|
||||
// Toggle POI Layers
|
||||
const poiLayers = ['poi-circles', 'place-labels'];
|
||||
const poiLayers = ['poi-icons', 'place-labels'];
|
||||
poiLayers.forEach(layerId => {
|
||||
if (map.current!.getLayer(layerId)) {
|
||||
map.current!.setLayoutProperty(layerId, 'visibility', showPOIs ? 'visible' : 'none');
|
||||
@@ -45,6 +47,8 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const tilesUrl = (import.meta as any).env.VITE_TILES_URL || 'https://tiles.intaleqapp.com';
|
||||
|
||||
try {
|
||||
map.current = new maplibregl.Map({
|
||||
container: mapContainer.current,
|
||||
@@ -55,17 +59,17 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
sources: {
|
||||
'local-osm-polygons': {
|
||||
type: 'vector',
|
||||
tiles: ['http://localhost:3001/planet_osm_polygon/{z}/{x}/{y}'],
|
||||
tiles: [`${tilesUrl}/planet_osm_polygon/{z}/{x}/{y}`],
|
||||
maxzoom: 14
|
||||
},
|
||||
'local-osm-lines': {
|
||||
type: 'vector',
|
||||
tiles: ['http://localhost:3001/planet_osm_line/{z}/{x}/{y}'],
|
||||
tiles: [`${tilesUrl}/planet_osm_line/{z}/{x}/{y}`],
|
||||
maxzoom: 14
|
||||
},
|
||||
'local-osm-points': {
|
||||
type: 'vector',
|
||||
tiles: ['http://localhost:3001/planet_osm_point/{z}/{x}/{y}'],
|
||||
tiles: [`${tilesUrl}/planet_osm_point/{z}/{x}/{y}`],
|
||||
maxzoom: 14
|
||||
}
|
||||
},
|
||||
@@ -73,7 +77,7 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
{
|
||||
id: 'background',
|
||||
type: 'background',
|
||||
paint: { 'background-color': '#f2efea' }
|
||||
paint: { 'background-color': '#f8f9fa' }
|
||||
},
|
||||
{
|
||||
id: 'water-layer',
|
||||
@@ -81,17 +85,34 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
source: 'local-osm-polygons',
|
||||
'source-layer': 'planet_osm_polygon',
|
||||
filter: ['in', 'natural', 'water', 'lake', 'riverbank'],
|
||||
paint: { 'fill-color': '#aadaff' }
|
||||
paint: { 'fill-color': '#a3ccff' }
|
||||
},
|
||||
{
|
||||
id: 'park-layer',
|
||||
type: 'fill',
|
||||
source: 'local-osm-polygons',
|
||||
'source-layer': 'planet_osm_polygon',
|
||||
filter: ['in', 'leisure', 'park', 'garden', 'nature_reserve'],
|
||||
paint: { 'fill-color': '#c8facc' }
|
||||
filter: ['in', 'leisure', 'park', 'garden', 'nature_reserve', 'pitch'],
|
||||
paint: { 'fill-color': '#dcedc8' }
|
||||
},
|
||||
{
|
||||
id: 'landuse-layer',
|
||||
type: 'fill',
|
||||
source: 'local-osm-polygons',
|
||||
'source-layer': 'planet_osm_polygon',
|
||||
filter: ["in", "landuse", "residential", "commercial", "industrial", "cemetery"],
|
||||
paint: {
|
||||
"fill-color": [
|
||||
"match",
|
||||
["get", "landuse"],
|
||||
"residential", "#f1f3f4",
|
||||
"commercial", "#f8f9fa",
|
||||
"industrial", "#f1f3f4",
|
||||
"cemetery", "#dcedc8",
|
||||
"#f1f3f4"
|
||||
]
|
||||
}
|
||||
},
|
||||
// Building 3D - User toggleable
|
||||
{
|
||||
id: 'building-3d',
|
||||
type: 'fill-extrusion',
|
||||
@@ -103,67 +124,131 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
visibility: show3D ? 'visible' : 'none'
|
||||
},
|
||||
paint: {
|
||||
'fill-extrusion-color': '#d9d0c9',
|
||||
'fill-extrusion-height': 40,
|
||||
'fill-extrusion-color': '#e8eaed',
|
||||
'fill-extrusion-height': 20,
|
||||
'fill-extrusion-base': 0,
|
||||
'fill-extrusion-opacity': 0.8
|
||||
}
|
||||
},
|
||||
// ROAD BORDERS (Casing)
|
||||
{
|
||||
id: 'road-border-high',
|
||||
id: 'road-casing-minor',
|
||||
type: 'line',
|
||||
source: 'local-osm-lines',
|
||||
'source-layer': 'planet_osm_line',
|
||||
filter: ['in', 'highway', 'primary', 'secondary', 'tertiary', 'motorway'],
|
||||
filter: ['in', 'highway', 'residential', 'service', 'unclassified', 'living_street', 'pedestrian', 'path', 'track'],
|
||||
paint: {
|
||||
'line-color': '#ccc',
|
||||
'line-width': [
|
||||
'interpolate', ['exponential', 1.5], ['zoom'],
|
||||
12, 3,
|
||||
16, 12
|
||||
] as any
|
||||
}
|
||||
},
|
||||
// ROAD CORES
|
||||
{
|
||||
id: 'road-layer-low',
|
||||
type: 'line',
|
||||
source: 'local-osm-lines',
|
||||
'source-layer': 'planet_osm_line',
|
||||
filter: ['in', 'highway', 'residential', 'service', 'unclassified'],
|
||||
paint: { 'line-color': '#ffffff', 'line-width': 1.5 }
|
||||
},
|
||||
{
|
||||
id: 'road-layer-high',
|
||||
type: 'line',
|
||||
source: 'local-osm-lines',
|
||||
'source-layer': 'planet_osm_line',
|
||||
filter: ['in', 'highway', 'primary', 'secondary', 'tertiary'],
|
||||
paint: {
|
||||
'line-color': '#fff',
|
||||
'line-width': [
|
||||
'interpolate', ['exponential', 1.5], ['zoom'],
|
||||
12, 1.5,
|
||||
16, 8
|
||||
] as any
|
||||
'line-color': '#d4d4d4',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 13, 1, 16, 8] as any
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'road-layer-motorway',
|
||||
id: 'road-core-minor',
|
||||
type: 'line',
|
||||
source: 'local-osm-lines',
|
||||
'source-layer': 'planet_osm_line',
|
||||
filter: ['==', 'highway', 'motorway'],
|
||||
filter: ['in', 'highway', 'residential', 'service', 'unclassified', 'living_street', 'pedestrian', 'path', 'track'],
|
||||
paint: {
|
||||
'line-color': '#f7d499',
|
||||
'line-width': [
|
||||
'interpolate', ['exponential', 1.5], ['zoom'],
|
||||
12, 2.5,
|
||||
16, 10
|
||||
] as any
|
||||
'line-color': '#ffffff',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 13, 0.5, 16, 6] as any
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'road-casing-tertiary',
|
||||
type: 'line',
|
||||
source: 'local-osm-lines',
|
||||
'source-layer': 'planet_osm_line',
|
||||
filter: ['in', 'highway', 'tertiary', 'tertiary_link'],
|
||||
paint: {
|
||||
'line-color': '#e0e0e0',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 12, 1.5, 16, 12] as any
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'road-core-tertiary',
|
||||
type: 'line',
|
||||
source: 'local-osm-lines',
|
||||
'source-layer': 'planet_osm_line',
|
||||
filter: ['in', 'highway', 'tertiary', 'tertiary_link'],
|
||||
paint: {
|
||||
'line-color': '#ffffff',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 12, 1, 16, 9] as any
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'road-casing-secondary',
|
||||
type: 'line',
|
||||
source: 'local-osm-lines',
|
||||
'source-layer': 'planet_osm_line',
|
||||
filter: ['in', 'highway', 'secondary', 'secondary_link'],
|
||||
paint: {
|
||||
'line-color': '#cfd8dc',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 12, 2, 16, 14] as any
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'road-core-secondary',
|
||||
type: 'line',
|
||||
source: 'local-osm-lines',
|
||||
'source-layer': 'planet_osm_line',
|
||||
filter: ['in', 'highway', 'secondary', 'secondary_link'],
|
||||
paint: {
|
||||
'line-color': '#f1f5f9',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 12, 1.5, 16, 11] as any
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'road-casing-primary',
|
||||
type: 'line',
|
||||
source: 'local-osm-lines',
|
||||
'source-layer': 'planet_osm_line',
|
||||
filter: ['in', 'highway', 'primary', 'primary_link'],
|
||||
paint: {
|
||||
'line-color': '#facc15',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 12, 3, 16, 16] as any
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'road-core-primary',
|
||||
type: 'line',
|
||||
source: 'local-osm-lines',
|
||||
'source-layer': 'planet_osm_line',
|
||||
filter: ['in', 'highway', 'primary', 'primary_link'],
|
||||
paint: {
|
||||
'line-color': '#fefce8',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 12, 2, 16, 12] as any
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'road-casing-motorway-trunk',
|
||||
type: 'line',
|
||||
source: 'local-osm-lines',
|
||||
'source-layer': 'planet_osm_line',
|
||||
filter: ['in', 'highway', 'motorway', 'motorway_link', 'trunk', 'trunk_link'],
|
||||
paint: {
|
||||
'line-color': '#fb923c',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 12, 4, 16, 18] as any
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'road-core-motorway-trunk',
|
||||
type: 'line',
|
||||
source: 'local-osm-lines',
|
||||
'source-layer': 'planet_osm_line',
|
||||
filter: ['in', 'highway', 'motorway', 'motorway_link', 'trunk', 'trunk_link'],
|
||||
paint: {
|
||||
'line-color': '#ffedd5',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 12, 2.5, 16, 14] as any
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'route-line',
|
||||
type: 'line',
|
||||
source: 'local-osm-lines',
|
||||
'source-layer': 'planet_osm_line',
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#3b82f6', 'line-width': 6, 'line-opacity': 0.8 },
|
||||
filter: ['==', 'id', -1]
|
||||
},
|
||||
{
|
||||
id: 'road-labels',
|
||||
type: 'symbol',
|
||||
@@ -173,30 +258,19 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
layout: {
|
||||
'text-field': '{name}',
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
'text-size': 11,
|
||||
'text-size': 13,
|
||||
'symbol-placement': 'line',
|
||||
'text-letter-spacing': 0.05,
|
||||
'text-padding': 15,
|
||||
'text-allow-overlap': false, // Avoid "black cloud"
|
||||
'text-padding': 5,
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#444',
|
||||
'text-halo-color': '#fff',
|
||||
'text-halo-width': 1.5
|
||||
'text-color': '#3c4043',
|
||||
'text-halo-color': 'rgba(255, 255, 255, 0.8)',
|
||||
'text-halo-width': 2
|
||||
}
|
||||
},
|
||||
// ROUTE LAYER - Placeholder for navigation results
|
||||
{
|
||||
id: 'route-line',
|
||||
type: 'line',
|
||||
source: 'local-osm-lines', // Use a source that exists and can be updated
|
||||
'source-layer': 'planet_osm_line',
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#3b82f6', 'line-width': 6, 'line-opacity': 0.8 },
|
||||
filter: ['==', 'id', -1] // Initially hide
|
||||
},
|
||||
// POI ICONS - Professional categorization
|
||||
{
|
||||
id: 'poi-icons',
|
||||
type: 'symbol',
|
||||
@@ -208,8 +282,7 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
'icon-image': [
|
||||
'coalesce',
|
||||
[
|
||||
'match',
|
||||
['get', 'shop'],
|
||||
'match', ['get', 'shop'],
|
||||
'supermarket', 'grocery-15',
|
||||
'convenience', 'grocery-15',
|
||||
'pharmacy', 'pharmacy-15',
|
||||
@@ -217,8 +290,7 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
''
|
||||
],
|
||||
[
|
||||
'match',
|
||||
['get', 'amenity'],
|
||||
'match', ['get', 'amenity'],
|
||||
'restaurant', 'restaurant-15',
|
||||
'cafe', 'cafe-15',
|
||||
'bank', 'bank-15',
|
||||
@@ -226,34 +298,31 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
'grave_yard', 'cemetery-15',
|
||||
'school', 'school-15',
|
||||
'university', 'college-15',
|
||||
'place_of_worship', 'religious-christian-15', // Fallback for general
|
||||
'place_of_worship', 'religious-christian-15',
|
||||
'hospital', 'hospital-15',
|
||||
'fuel', 'fuel-15',
|
||||
''
|
||||
],
|
||||
[
|
||||
'match',
|
||||
['get', 'historic'],
|
||||
'match', ['get', 'historic'],
|
||||
'monument', 'monument-15',
|
||||
'ruins', 'attraction-15',
|
||||
'archaeological_site', 'attraction-15',
|
||||
''
|
||||
],
|
||||
[
|
||||
'match',
|
||||
['get', 'tourism'],
|
||||
'match', ['get', 'tourism'],
|
||||
'museum', 'museum-15',
|
||||
'hotel', 'hotel-15',
|
||||
'attraction', 'attraction-15',
|
||||
''
|
||||
],
|
||||
[
|
||||
'match',
|
||||
['get', 'landuse'],
|
||||
'match', ['get', 'landuse'],
|
||||
'cemetery', 'cemetery-15',
|
||||
''
|
||||
],
|
||||
'marker-15' // Ultimate fallback
|
||||
'marker-15'
|
||||
],
|
||||
'icon-size': 1.1,
|
||||
'icon-padding': 2,
|
||||
@@ -270,21 +339,19 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
type: 'symbol',
|
||||
source: 'local-osm-points',
|
||||
'source-layer': 'planet_osm_point',
|
||||
minzoom: 14, // Only show text at higher zoom to avoid clutter
|
||||
minzoom: 13,
|
||||
layout: {
|
||||
'text-field': '{name}',
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
'text-size': 12,
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 13, 12, 16, 16] as any,
|
||||
'text-offset': [0, 1.5],
|
||||
'text-anchor': 'top',
|
||||
'text-padding': 5,
|
||||
'text-allow-overlap': false,
|
||||
'visibility': showPOIs ? 'visible' : 'none'
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#111',
|
||||
'text-halo-color': '#fff',
|
||||
'text-halo-width': 1.5
|
||||
'text-color': '#222',
|
||||
'text-halo-color': 'rgba(255, 255, 255, 0.9)',
|
||||
'text-halo-width': 2
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -298,6 +365,12 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
onMapLoad(map.current!);
|
||||
});
|
||||
|
||||
map.current.on('click', (e) => {
|
||||
if (onMapClick) {
|
||||
onMapClick(e.lngLat.lat, e.lngLat.lng);
|
||||
}
|
||||
});
|
||||
|
||||
map.current.on('error', (e) => {
|
||||
console.error("MapComponent: Map Error:", e);
|
||||
});
|
||||
@@ -314,7 +387,44 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={mapContainer} style={{ width: '100%', height: '100%' }} />;
|
||||
return (
|
||||
<div ref={mapContainer} style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
{/* Intaleq Branding Watermark */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '10px',
|
||||
left: '10px',
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.7)',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
gap: '6px'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/intaleq-logo.png"
|
||||
alt="Intaleq"
|
||||
style={{ height: '16px', filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.1))' }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: '#3c4043',
|
||||
letterSpacing: '0.02em',
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
>
|
||||
Powered by Intaleq
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapComponent;
|
||||
|
||||
@@ -153,3 +153,55 @@ input:focus {
|
||||
font-size: 0.75rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.stats-panel {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 240px;
|
||||
padding: 15px;
|
||||
border-radius: 12px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
animation: slideIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(30px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.stats-panel h4 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Decodes an encoded Polyline string into an array of [lng, lat] coordinates.
|
||||
* @param str The encoded polyline string
|
||||
* @param precision The precision of the coordinates (default 5 for Google Polyline)
|
||||
* @returns Array of [lng, lat] coordinates
|
||||
*/
|
||||
export function decodePolyline(str: string, precision: number = 5): [number, number][] {
|
||||
let index = 0;
|
||||
let lat = 0;
|
||||
let lng = 0;
|
||||
const coordinates: [number, number][] = [];
|
||||
let shift = 0;
|
||||
let result = 0;
|
||||
let byte: number | null = null;
|
||||
let latitude_change: number;
|
||||
let longitude_change: number;
|
||||
const factor = Math.pow(10, precision);
|
||||
|
||||
while (index < str.length) {
|
||||
byte = null;
|
||||
shift = 0;
|
||||
result = 0;
|
||||
|
||||
do {
|
||||
byte = str.charCodeAt(index++) - 63;
|
||||
result |= (byte & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (byte >= 0x20);
|
||||
|
||||
latitude_change = ((result & 1) ? ~(result >> 1) : (result >> 1));
|
||||
lat += latitude_change;
|
||||
|
||||
shift = 0;
|
||||
result = 0;
|
||||
|
||||
do {
|
||||
byte = str.charCodeAt(index++) - 63;
|
||||
result |= (byte & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (byte >= 0x20);
|
||||
|
||||
longitude_change = ((result & 1) ? ~(result >> 1) : (result >> 1));
|
||||
lng += longitude_change;
|
||||
|
||||
coordinates.push([lng / factor, lat / factor]);
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
}
|
||||
Reference in New Issue
Block a user