feat: implement geometry snapping logic for manual road insertion and approved roads topology

This commit is contained in:
Hamza-Ayed
2026-08-16 17:34:06 +03:00
parent b3ffa7c71d
commit 03cdc6fe9c
12 changed files with 1431 additions and 453 deletions
+31 -4
View File
@@ -11,6 +11,9 @@ function App() {
const [loading, setLoading] = useState(false);
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);
// Geocoding State
const [searchQuery, setSearchQuery] = useState('');
@@ -254,20 +257,41 @@ function App() {
<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 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 / مباني 3D
🏢 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 / عرض المعالم
📍 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>
@@ -290,6 +314,9 @@ function App() {
onMapClick={handleMapClick}
show3D={show3D}
showPOIs={showPOIs}
showTerrain={showTerrain}
showContours={showContours}
showAdminBoundaries={showAdminBoundaries}
/>
{/* Add Place Modal */}
+140 -344
View File
@@ -1,44 +1,89 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import mlcontour from 'maplibre-contour';
import { attachIconLoader } from '../utils/mapIcons';
const demSource = new mlcontour.DemSource({
url: 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png',
encoding: 'terrarium',
maxzoom: 14,
worker: true,
});
demSource.setupMaplibre(maplibregl);
interface MapComponentProps {
onMapLoad: (map: maplibregl.Map) => void;
onMapClick?: (lat: number, lng: number) => void;
show3D?: boolean;
showPOIs?: boolean;
showTerrain?: boolean;
showContours?: boolean;
showAdminBoundaries?: boolean;
}
const MapComponent: React.FC<MapComponentProps> = ({
onMapLoad,
onMapClick,
show3D = false,
showPOIs = true
showPOIs = true,
showTerrain = true,
showContours = false,
showAdminBoundaries = true
}) => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(null);
// Sync toggles with map layers
useEffect(() => {
if (!map.current) return;
if (!map.current || !map.current.isStyleLoaded()) return;
// Toggle 3D Buildings (now on Overture source)
if (map.current.getLayer('building-3d')) {
map.current.setLayoutProperty('building-3d', 'visibility', show3D ? 'visible' : 'none');
}
// Toggle flat Overture footprint (show when 3D is off)
// Toggle 3D Buildings
['building-3d', 'building-3d-osm'].forEach(layerId => {
if (map.current!.getLayer(layerId)) {
map.current!.setLayoutProperty(layerId, 'visibility', show3D ? 'visible' : 'none');
}
});
if (map.current.getLayer('overture-building-footprint')) {
map.current.setLayoutProperty('overture-building-footprint', 'visibility', show3D ? 'none' : 'visible');
}
map.current.easeTo({ pitch: show3D ? 55 : 0, duration: 600 });
// Toggle POI Layers
const poiLayers = ['poi-icons', 'place-labels', 'overture-building-names'];
const poiLayers = ['poi-icons', 'place-labels', 'overture-building-names', 'places-jordan-labels'];
poiLayers.forEach(layerId => {
if (map.current!.getLayer(layerId)) {
map.current!.setLayoutProperty(layerId, 'visibility', showPOIs ? 'visible' : 'none');
}
});
}, [show3D, showPOIs]);
// Toggle Hillshading / Terrain
if (map.current.getLayer('hillshading')) {
map.current.setLayoutProperty('hillshading', 'visibility', showTerrain ? 'visible' : 'none');
}
// Toggle Contours
const contourLayers = ['contour-lines-minor', 'contour-lines-major', 'contour-labels'];
contourLayers.forEach(layerId => {
if (map.current!.getLayer(layerId)) {
map.current!.setLayoutProperty(layerId, 'visibility', showContours ? 'visible' : 'none');
}
});
// Toggle Admin Boundaries
const adminLayers = [
'admin-boundary-national',
'admin-boundary-governorate-poly',
'admin-boundary-governorate',
'admin-boundary-district-poly',
'admin-boundary-district'
];
adminLayers.forEach(layerId => {
if (map.current!.getLayer(layerId)) {
map.current!.setLayoutProperty(layerId, 'visibility', showAdminBoundaries ? 'visible' : 'none');
}
});
}, [show3D, showPOIs, showTerrain, showContours, showAdminBoundaries]);
useEffect(() => {
if (map.current) return;
@@ -47,350 +92,21 @@ const MapComponent: React.FC<MapComponentProps> = ({
// Load RTL Text Plugin for correct Arabic rendering
if (maplibregl.getRTLTextPluginStatus() === 'unavailable') {
maplibregl.setRTLTextPlugin(
'/rtl-plugin.js', // Localized to bypass ORB
true // Lazy load
'/rtl-plugin.js',
true
);
}
const tilesUrl = (import.meta as any).env.VITE_TILES_URL || 'https://tiles.intaleqapp.com';
try {
const initialMap = new maplibregl.Map({
container: mapContainer.current,
style: {
version: 8,
glyphs: 'https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf',
sources: {
'local-osm-polygons': {
type: 'vector',
tiles: [`${tilesUrl}/planet_osm_polygon/{z}/{x}/{y}`],
maxzoom: 14
},
'local-osm-lines': {
type: 'vector',
tiles: [`${tilesUrl}/planet_osm_line/{z}/{x}/{y}`],
maxzoom: 14
},
'local-osm-points': {
type: 'vector',
tiles: [`${tilesUrl}/planet_osm_point/{z}/{x}/{y}`],
maxzoom: 14
},
'approved_roads': {
type: 'vector',
tiles: [`${tilesUrl}/approved_roads/{z}/{x}/{y}`],
minzoom: 8,
maxzoom: 18
}
},
layers: [
{
id: 'background',
type: 'background',
paint: { 'background-color': '#f8f9fa' }
},
{
id: 'water-layer',
type: 'fill',
source: 'local-osm-polygons',
'source-layer': 'planet_osm_polygon',
filter: ['in', 'natural', 'water', 'lake', 'riverbank'],
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', '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"
]
}
},
{
id: 'building-3d',
type: 'fill-extrusion',
source: 'local-osm-polygons',
'source-layer': 'planet_osm_polygon',
minzoom: 15,
filter: ['has', 'building'],
layout: {
visibility: show3D ? 'visible' : 'none'
},
paint: {
'fill-extrusion-color': '#e8eaed',
'fill-extrusion-height': 20,
'fill-extrusion-base': 0,
'fill-extrusion-opacity': 0.8
}
},
{
id: 'road-casing-minor',
type: 'line',
source: 'local-osm-lines',
'source-layer': 'planet_osm_line',
filter: ['in', 'highway', 'residential', 'service', 'unclassified', 'living_street', 'pedestrian', 'path', 'track'],
paint: {
'line-color': '#d4d4d4',
'line-width': ['interpolate', ['linear'], ['zoom'], 13, 1, 16, 8] as any
}
},
{
id: 'approved-casing',
type: 'line',
source: 'approved_roads',
'source-layer': 'approved_roads',
paint: {
'line-color': '#d4d4d4',
'line-width': ['interpolate', ['linear'], ['zoom'], 13, 1, 16, 8] as any
}
},
{
id: 'approved-core',
type: 'line',
source: 'approved_roads',
'source-layer': 'approved_roads',
paint: {
'line-color': '#ffffff',
'line-width': ['interpolate', ['linear'], ['zoom'], 13, 0.5, 16, 6] as any
}
},
{
id: 'road-core-minor',
type: 'line',
source: 'local-osm-lines',
'source-layer': 'planet_osm_line',
filter: ['in', 'highway', 'residential', 'service', 'unclassified', 'living_street', 'pedestrian', 'path', 'track'],
paint: {
'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',
source: 'local-osm-lines',
'source-layer': 'planet_osm_line',
minzoom: 15,
layout: {
'text-field': '{name}',
'text-font': ['Noto Sans Regular'],
'text-size': 13,
'symbol-placement': 'line',
'text-letter-spacing': 0.05,
'text-padding': 5,
'text-allow-overlap': false,
'text-ignore-placement': false
},
paint: {
'text-color': '#3c4043',
'text-halo-color': 'rgba(255, 255, 255, 0.8)',
'text-halo-width': 2
}
},
{
id: 'poi-icons',
type: 'symbol',
source: 'local-osm-points',
'source-layer': 'planet_osm_point',
minzoom: 13,
layout: {
'visibility': showPOIs ? 'visible' : 'none',
'icon-image': [
'coalesce',
[
'match', ['get', 'shop'],
'supermarket', 'grocery-15',
'convenience', 'grocery-15',
'pharmacy', 'pharmacy-15',
'clothes', 'clothing_store-15',
''
],
[
'match', ['get', 'amenity'],
'restaurant', 'restaurant-15',
'cafe', 'cafe-15',
'bank', 'bank-15',
'pharmacy', 'pharmacy-15',
'grave_yard', 'cemetery-15',
'school', 'school-15',
'university', 'college-15',
'place_of_worship', 'religious-christian-15',
'hospital', 'hospital-15',
'fuel', 'fuel-15',
''
],
[
'match', ['get', 'historic'],
'monument', 'monument-15',
'ruins', 'attraction-15',
'archaeological_site', 'attraction-15',
''
],
[
'match', ['get', 'tourism'],
'museum', 'museum-15',
'hotel', 'hotel-15',
'attraction', 'attraction-15',
''
],
[
'match', ['get', 'landuse'],
'cemetery', 'cemetery-15',
''
],
'marker-15'
],
'icon-size': 1.1,
'icon-padding': 2,
'icon-allow-overlap': false
},
paint: {
'icon-opacity': 1,
'icon-halo-color': '#ffffff',
'icon-halo-width': 1
}
},
{
id: 'place-labels',
type: 'symbol',
source: 'local-osm-points',
'source-layer': 'planet_osm_point',
minzoom: 13,
layout: {
'text-field': '{name}',
'text-font': ['Noto Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 13, 12, 16, 16] as any,
'text-offset': [0, 1.5],
'text-anchor': 'top',
'visibility': showPOIs ? 'visible' : 'none'
},
paint: {
'text-color': '#222',
'text-halo-color': 'rgba(255, 255, 255, 0.9)',
'text-halo-width': 2
}
}
]
},
style: '/style.json',
center: [35.9106, 31.9539],
zoom: 12,
attributionControl: false
});
map.current = initialMap;
// آيقونات الـ POI تُحمّل عند الطلب بدل sprite مستضاف
attachIconLoader(initialMap);
// Request User Location
@@ -412,6 +128,86 @@ const MapComponent: React.FC<MapComponentProps> = ({
initialMap.on('load', () => {
console.log("MapComponent: Map Loaded Successfully");
const contourUrl = demSource.contourProtocolUrl({
thresholds: {
10: [100, 500],
11: [50, 250],
12: [25, 100],
13: [20, 100],
14: [10, 50],
15: [10, 50],
16: [10, 50],
17: [10, 50],
18: [10, 50],
},
elevationKey: 'ele',
levelKey: 'level',
contourLayer: 'contours',
});
if (!initialMap.getSource('contour-source')) {
initialMap.addSource('contour-source', {
type: 'vector',
tiles: [contourUrl],
maxzoom: 18,
});
initialMap.addLayer({
id: 'contour-lines-minor',
type: 'line',
source: 'contour-source',
'source-layer': 'contours',
minzoom: 10,
layout: {
visibility: showContours ? 'visible' : 'none',
},
filter: ['==', ['get', 'level'], 0],
paint: {
'line-color': '#a86324',
'line-width': 1.1,
'line-opacity': 0.9,
},
});
initialMap.addLayer({
id: 'contour-lines-major',
type: 'line',
source: 'contour-source',
'source-layer': 'contours',
minzoom: 9,
layout: {
visibility: showContours ? 'visible' : 'none',
},
filter: ['>', ['get', 'level'], 0],
paint: {
'line-color': '#703800',
'line-width': 2.0,
'line-opacity': 1.0,
},
});
initialMap.addLayer({
id: 'contour-labels',
type: 'symbol',
source: 'contour-source',
'source-layer': 'contours',
minzoom: 12,
layout: {
visibility: showContours ? 'visible' : 'none',
'symbol-placement': 'line',
'text-field': ['concat', ['to-string', ['get', 'ele']], ' m'],
'text-size': 10,
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
},
paint: {
'text-color': '#5c2d00',
'text-halo-color': 'rgba(255, 255, 255, 0.95)',
'text-halo-width': 2,
},
});
}
onMapLoad(map.current!);
});
+139 -8
View File
@@ -1,8 +1,18 @@
import React, { useEffect, useRef, useState } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import mlcontour from 'maplibre-contour';
import { attachIconLoader } from '../utils/mapIcons';
// Initialize DEM source for on-the-fly global contours (Jordan, Syria, Iraq, Egypt, etc.)
const demSource = new mlcontour.DemSource({
url: 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png',
encoding: 'terrarium',
maxzoom: 14,
worker: true,
});
demSource.setupMaplibre(maplibregl);
/**
* CompareView — side-by-side, pan/zoom-synced map comparison.
*
@@ -91,9 +101,79 @@ const CompareView: React.FC = () => {
}
lMap.on('load', () => {
lMap.addSource('approved', { type: 'vector', tiles: [`${TILES}/approved_roads/{z}/{x}/{y}`], minzoom: 8, maxzoom: 18 });
lMap.addLayer({ id: 'approved-casing', type: 'line', source: 'approved', 'source-layer': 'approved_roads', paint: { 'line-color': '#d4d4d4', 'line-width': ['interpolate', ['linear'], ['zoom'], 13, 1, 16, 8] } });
lMap.addLayer({ id: 'approved', type: 'line', source: 'approved', 'source-layer': 'approved_roads', paint: { 'line-color': '#ffffff', 'line-width': ['interpolate', ['linear'], ['zoom'], 13, 0.5, 16, 6] } });
// Dynamic On-the-Fly Contours from Elevation DEM
const contourUrl = demSource.contourProtocolUrl({
thresholds: {
10: [100, 500],
11: [50, 250],
12: [25, 100],
13: [20, 100],
14: [10, 50],
15: [10, 50],
16: [10, 50],
17: [10, 50],
18: [10, 50],
},
elevationKey: 'ele',
levelKey: 'level',
contourLayer: 'contours',
});
if (!lMap.getSource('contour-source')) {
lMap.addSource('contour-source', {
type: 'vector',
tiles: [contourUrl],
maxzoom: 18,
});
lMap.addLayer({
id: 'contour-lines-minor',
type: 'line',
source: 'contour-source',
'source-layer': 'contours',
minzoom: 10,
filter: ['==', ['get', 'level'], 0],
paint: {
'line-color': '#a86324',
'line-width': 1.1,
'line-opacity': 0.9,
},
});
lMap.addLayer({
id: 'contour-lines-major',
type: 'line',
source: 'contour-source',
'source-layer': 'contours',
minzoom: 9,
filter: ['>', ['get', 'level'], 0],
paint: {
'line-color': '#703800',
'line-width': 2.0,
'line-opacity': 1.0,
},
});
lMap.addLayer({
id: 'contour-labels',
type: 'symbol',
source: 'contour-source',
'source-layer': 'contours',
minzoom: 12,
filter: ['>', ['get', 'level'], 0],
layout: {
'symbol-placement': 'line',
'text-field': ['concat', ['to-string', ['get', 'ele']], ' m'],
'text-size': 10,
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
},
paint: {
'text-color': '#5c2d00',
'text-halo-color': 'rgba(255, 255, 255, 0.95)',
'text-halo-width': 2,
},
});
}
});
// Keep the two views locked together. The guard stops the move→jumpTo→move loop.
@@ -122,18 +202,69 @@ const CompareView: React.FC = () => {
m.once('styledata', () => m.jumpTo({ center: c, zoom: z, bearing: b, pitch: p }));
}, [refKey]);
const [showTerrain, setShowTerrain] = useState(true);
const [showContours, setShowContours] = useState(true);
const [showAdmin, setShowAdmin] = useState(true);
const [show3D, setShow3D] = useState(false);
// Sync toggles with leftMap layers
useEffect(() => {
const m = leftMap.current;
if (!m) return;
// Wait if style is not loaded yet
const applyToggles = () => {
if (m.getLayer('hillshading')) {
m.setLayoutProperty('hillshading', 'visibility', showTerrain ? 'visible' : 'none');
}
['contour-lines-minor', 'contour-lines-major', 'contour-labels'].forEach(l => {
if (m.getLayer(l)) m.setLayoutProperty(l, 'visibility', showContours ? 'visible' : 'none');
});
['admin-boundary-national', 'admin-boundary-governorate-poly', 'admin-boundary-governorate', 'admin-boundary-district-poly', 'admin-boundary-district'].forEach(l => {
if (m.getLayer(l)) m.setLayoutProperty(l, 'visibility', showAdmin ? 'visible' : 'none');
});
['building-3d', 'building-3d-osm'].forEach(l => {
if (m.getLayer(l)) m.setLayoutProperty(l, 'visibility', show3D ? 'visible' : 'none');
});
// Tilt camera for 3D perspective
m.easeTo({ pitch: show3D ? 55 : 0, duration: 600 });
};
if (m.isStyleLoaded()) {
applyToggles();
} else {
m.once('styledata', applyToggles);
}
}, [showTerrain, showContours, showAdmin, show3D]);
const fmt = (n: number) => n.toFixed(6);
const z = Math.round(center.zoom);
const btn: React.CSSProperties = { background: '#1e293b', border: '1px solid #334155', color: '#cbd5e1', padding: '6px 10px', borderRadius: '8px', cursor: 'pointer', fontSize: '0.75rem', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: '5px' };
const btn: React.CSSProperties = { background: '#1e293b', border: '1px solid #334155', color: '#cbd5e1', padding: '5px 8px', borderRadius: '8px', cursor: 'pointer', fontSize: '0.72rem', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: '4px' };
const toggleBtn = (active: boolean): React.CSSProperties => ({
...btn,
borderColor: active ? '#10b981' : '#334155',
background: active ? 'rgba(16,185,129,0.2)' : '#1e293b',
color: active ? '#34d399' : '#94a3b8',
fontWeight: active ? 600 : 400
});
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', background: '#0f172a', color: '#e2e8f0', fontFamily: 'Inter, system-ui, sans-serif' }}>
{/* Toolbar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 16px', paddingRight: '280px', borderBottom: '1px solid #1e293b', background: '#0f172a' }}>
<strong style={{ fontSize: '0.9rem', whiteSpace: 'nowrap' }}>⚖️ Compare</strong>
<span style={{ color: '#64748b', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>Our map ⟷ reference — synced</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '8px 14px', paddingRight: '280px', borderBottom: '1px solid #1e293b', background: '#0f172a', flexWrap: 'wrap' }}>
<strong style={{ fontSize: '0.85rem', whiteSpace: 'nowrap' }}>⚖️ Compare</strong>
{/* Left Map Layer Toggles */}
<div style={{ display: 'flex', gap: '5px', alignItems: 'center', background: 'rgba(30,41,59,0.7)', padding: '2px 6px', borderRadius: '8px', border: '1px solid #334155' }}>
<span style={{ fontSize: '0.68rem', color: '#94a3b8' }}>Our Map:</span>
<button onClick={() => setShowTerrain(!showTerrain)} style={toggleBtn(showTerrain)}>🏔️ Relief</button>
<button onClick={() => setShowContours(!showContours)} style={toggleBtn(showContours)}>〰️ Contours</button>
<button onClick={() => setShowAdmin(!showAdmin)} style={toggleBtn(showAdmin)}>🏛️ Admin</button>
<button onClick={() => setShow3D(!show3D)} style={toggleBtn(show3D)}>🏢 3D</button>
</div>
<div style={{ display: 'flex', gap: '6px', marginInlineStart: 'auto', overflowX: 'auto', paddingBottom: '2px' }}>
<div style={{ display: 'flex', gap: '5px', marginInlineStart: 'auto', overflowX: 'auto', paddingBottom: '2px' }}>
{(Object.keys(REFS) as RefKey[]).map(k => (
<button key={k} onClick={() => setRefKey(k)}
style={{ ...btn, whiteSpace: 'nowrap', flexShrink: 0, ...(refKey === k ? { borderColor: '#6366f1', color: '#fff', background: 'rgba(99,102,241,0.35)', fontWeight: 600 } : {}) }}>
+142 -41
View File
@@ -51,6 +51,7 @@ const IntelligenceDashboard: React.FC = () => {
const [loading, setLoading] = useState(true);
const [apiKey, setApiKey] = useState(localStorage.getItem('map_admin_key') || '');
const [needsAuth, setNeedsAuth] = useState(!localStorage.getItem('map_admin_key'));
const [sel, setSel] = useState<string | null>(null);
// Right Map State
const [refKey, setRefKey] = useState<RefKey>('google-hybrid');
@@ -76,6 +77,70 @@ const IntelligenceDashboard: React.FC = () => {
if (rightMapRef.current) rightMapRef.current.getCanvas().style.cursor = drawing ? 'crosshair' : '';
}, [drawing]);
// Helper: Find closest point on line segment
const getClosestPointOnSegment = (p: [number, number], a: [number, number], b: [number, number]): [number, number] => {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
if (dx === 0 && dy === 0) return a;
const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy)));
return [a[0] + t * dx, a[1] + t * dy];
};
// Helper: Snap to nearest road feature on map
const getSnappedLngLat = (map: maplibregl.Map, point: { x: number; y: number }, rawLngLat: [number, number]): [number, number] => {
const radius = 28; // 28px magnet radius
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
[point.x - radius, point.y - radius],
[point.x + radius, point.y + radius]
];
try {
const features = map.queryRenderedFeatures(bbox).filter(f => {
const id = f.layer?.id || '';
return id.startsWith('road-') || id.startsWith('approved-') || id === 'cp' || id === 'ca' || id === 'cp-overture';
});
let bestPoint: [number, number] = rawLngLat;
let minDistancePx = radius;
for (const f of features) {
const geom = f.geometry;
if (!geom) continue;
const lines: [number, number][][] = geom.type === 'LineString'
? [geom.coordinates as [number, number][]]
: geom.type === 'MultiLineString'
? (geom.coordinates as [number, number][][])
: [];
for (const line of lines) {
for (let i = 0; i < line.length - 1; i++) {
const p1 = map.project(line[i] as any);
const p2 = map.project(line[i + 1] as any);
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) continue;
const t = Math.max(0, Math.min(1, ((point.x - p1.x) * dx + (point.y - p1.y) * dy) / lenSq));
const closePx = { x: p1.x + t * dx, y: p1.y + t * dy };
const distPx = Math.hypot(point.x - closePx.x, point.y - closePx.y);
if (distPx < minDistancePx) {
minDistancePx = distPx;
const unprojected = map.unproject(closePx);
bestPoint = [unprojected.lng, unprojected.lat];
}
}
}
}
return bestPoint;
} catch {
return rawLngLat;
}
};
const redrawDrawing = () => {
const lMap = leftMapRef.current; const rMap = rightMapRef.current;
if (!lMap || !rMap) return;
@@ -91,6 +156,15 @@ const IntelligenceDashboard: React.FC = () => {
(rMap.getSource('draw') as maplibregl.GeoJSONSource)?.setData(data as any);
};
const updateSnapGuide = (map: maplibregl.Map, coords: [number, number] | null) => {
const src = map.getSource('snap-guide') as maplibregl.GeoJSONSource;
if (!src) return;
src.setData({
type: 'FeatureCollection',
features: coords ? [{ type: 'Feature', geometry: { type: 'Point', coordinates: coords }, properties: {} }] : []
});
};
const pushMapData = () => {
const m = leftMapRef.current;
if (!m || !m.isStyleLoaded()) return;
@@ -209,14 +283,8 @@ const IntelligenceDashboard: React.FC = () => {
lMap.addSource('closures', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any);
lMap.addSource('hl', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any);
// approved_roads is served by Martin on the tile host — NOT the app host. An
// empty default made this resolve to /approved_roads on the app host (404), so
// approved roads never drew. Point it at the real Martin host.
const TILES = (import.meta as any).env.VITE_TILES_URL || 'https://tiles.intaleqapp.com';
lMap.addSource('approved', { type: 'vector', tiles: [`${TILES}/approved_roads/{z}/{x}/{y}`], minzoom: 8, maxzoom: 18 });
lMap.addLayer({ id: 'approved-casing', type: 'line', source: 'approved', 'source-layer': 'approved_roads', paint: { 'line-color': '#d4d4d4', 'line-width': ['interpolate', ['linear'], ['zoom'], 13, 1, 16, 8] } });
lMap.addLayer({ id: 'approved', type: 'line', source: 'approved', 'source-layer': 'approved_roads', paint: { 'line-color': '#ffffff', 'line-width': ['interpolate', ['linear'], ['zoom'], 13, 0.5, 16, 6] } });
lMap.addSource('draw', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any);
lMap.addSource('snap-guide', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any);
lMap.addLayer({ id: 'cl', type: 'line', source: 'closures', paint: { 'line-color': '#ef4444', 'line-width': 4, 'line-dasharray': [3, 2] } });
lMap.addLayer({ id: 'cp', type: 'line', source: 'cands', filter: ['all', ['==', ['get', 'status'], 'pending'], ['!=', ['get', 'source'], 'overture']] as any, paint: { 'line-color': ['interpolate', ['linear'], ['get', 'confidence'], 0, '#f97316', 0.6, '#eab308', 0.8, '#22c55e'] as any, 'line-width': 4, 'line-dasharray': [4, 3] } });
@@ -225,56 +293,78 @@ const IntelligenceDashboard: React.FC = () => {
lMap.addLayer({ id: 'hl', type: 'line', source: 'hl', paint: { 'line-color': '#60a5fa', 'line-width': 6, 'line-blur': 1 } });
lMap.addLayer({ id: 'draw-line', type: 'line', source: 'draw', paint: { 'line-color': '#3b82f6', 'line-width': 4, 'line-dasharray': [2, 2] } });
lMap.addLayer({ id: 'draw-pts', type: 'circle', source: 'draw', paint: { 'circle-radius': 5, 'circle-color': '#ffffff', 'circle-stroke-width': 2, 'circle-stroke-color': '#3b82f6' } });
lMap.addLayer({ id: 'snap-guide-pulse', type: 'circle', source: 'snap-guide', paint: { 'circle-radius': 8, 'circle-color': 'rgba(16, 185, 129, 0.4)', 'circle-stroke-width': 2, 'circle-stroke-color': '#10b981' } });
lMap.addLayer({ id: 'snap-guide-dot', type: 'circle', source: 'snap-guide', paint: { 'circle-radius': 4, 'circle-color': '#10b981' } });
const onCandidateClick = (e: any) => {
const p = e.features?.[0]?.properties as any; if (!p) return;
const l = e.features?.[0]?.layer?.id;
setSel(p.id);
const isOvt = p.source === 'overture';
const isAppr = l === 'ca' || p.status === 'approved';
const title = isAppr ? '✅ Approved Road' : isOvt ? '🗺️ Overture road (missing)' : '🛣️ Driver-traced road';
const nameLine = p.name ? `<b>${p.name}</b><br/>` : '';
const evidence = isOvt ? `${p.uniqueDriverCount ?? 0} trace pts corroborate` : `${p.uniqueDriverCount || 1} drivers`;
if (popupRef.current) popupRef.current.remove();
const popupDiv = document.createElement('div');
popupDiv.style.font = '12px sans-serif';
popupDiv.style.color = '#1e293b';
popupDiv.style.lineHeight = '1.6';
popupDiv.innerHTML = `${nameLine}<b>${title}</b><br/>Conf: <b style="color:${cc(p.confidence)}">${Math.round(p.confidence * 100)}%</b><br/>${Math.round(p.lengthMeters || 0)}m | ${evidence}
<br/><button id="btn-del-${p.id}" style="margin-top:8px;background:#ef4444;color:white;border:none;border-radius:4px;padding:4px 8px;cursor:pointer;">🗑️ Delete Road</button>`;
popupDiv.querySelector(`#btn-del-${p.id}`)?.addEventListener('click', () => {
deleteRoad(p.id, isAppr ? 'approved' : 'candidate');
popupRef.current?.remove();
});
popupRef.current = new maplibregl.Popup({ offset: 12 }).setLngLat(e.lngLat).setDOMContent(popupDiv).addTo(lMap);
};
['cp', 'cp-overture', 'ca'].forEach(layer => {
try {
lMap.on('click', layer, onCandidateClick);
lMap.on('mouseenter', layer, () => { lMap.getCanvas().style.cursor = 'pointer'; });
lMap.on('mouseleave', layer, () => { lMap.getCanvas().style.cursor = ''; });
} catch (err) {
console.warn(`Layer event registration skipped for ${layer}:`, err);
}
});
pushMapData();
});
rMap.on('load', () => {
rMap.addSource('draw', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any);
rMap.addSource('snap-guide', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any);
rMap.addLayer({ id: 'draw-line', type: 'line', source: 'draw', paint: { 'line-color': '#3b82f6', 'line-width': 4, 'line-dasharray': [2, 2] } });
rMap.addLayer({ id: 'draw-pts', type: 'circle', source: 'draw', paint: { 'circle-radius': 5, 'circle-color': '#ffffff', 'circle-stroke-width': 2, 'circle-stroke-color': '#3b82f6' } });
rMap.addLayer({ id: 'snap-guide-pulse', type: 'circle', source: 'snap-guide', paint: { 'circle-radius': 8, 'circle-color': 'rgba(16, 185, 129, 0.4)', 'circle-stroke-width': 2, 'circle-stroke-color': '#10b981' } });
rMap.addLayer({ id: 'snap-guide-dot', type: 'circle', source: 'snap-guide', paint: { 'circle-radius': 4, 'circle-color': '#10b981' } });
});
const onClickDraw = (e: any) => {
if (!drawingRef.current) return;
pointsRef.current.push([e.lngLat.lng, e.lngLat.lat]);
const targetMap = e.target as maplibregl.Map;
const snapped = getSnappedLngLat(targetMap, e.point, [e.lngLat.lng, e.lngLat.lat]);
pointsRef.current.push(snapped);
redrawDrawing();
updateSnapGuide(targetMap, snapped);
};
const onMouseMoveDraw = (e: any) => {
if (!drawingRef.current) return;
const targetMap = e.target as maplibregl.Map;
const snapped = getSnappedLngLat(targetMap, e.point, [e.lngLat.lng, e.lngLat.lat]);
const isSnapped = Math.hypot(snapped[0] - e.lngLat.lng, snapped[1] - e.lngLat.lat) > 0.000001;
updateSnapGuide(targetMap, isSnapped ? snapped : null);
};
lMap.on('click', onClickDraw);
rMap.on('click', onClickDraw);
const onCandidateClick = (e: any) => {
const p = e.features?.[0]?.properties as any; if (!p) return;
const l = e.features?.[0]?.layer?.id;
setSel(p.id);
const isOvt = p.source === 'overture';
const isAppr = l === 'approved';
const title = isAppr ? '✅ Approved Road' : isOvt ? '🗺️ Overture road (missing)' : '🛣️ Driver-traced road';
const nameLine = p.name ? `<b>${p.name}</b><br/>` : '';
const evidence = isOvt ? `${p.uniqueDriverCount ?? 0} trace pts corroborate` : `${p.uniqueDriverCount || 1} drivers`;
if (popupRef.current) popupRef.current.remove();
const popupDiv = document.createElement('div');
popupDiv.style.font = '12px sans-serif';
popupDiv.style.color = '#1e293b';
popupDiv.style.lineHeight = '1.6';
popupDiv.innerHTML = `${nameLine}<b>${title}</b><br/>Conf: <b style="color:${cc(p.confidence)}">${Math.round(p.confidence * 100)}%</b><br/>${Math.round(p.lengthMeters || 0)}m | ${evidence}
<br/><button id="btn-del-${p.id}" style="margin-top:8px;background:#ef4444;color:white;border:none;border-radius:4px;padding:4px 8px;cursor:pointer;">🗑️ Delete Road</button>`;
popupDiv.querySelector(`#btn-del-${p.id}`)?.addEventListener('click', () => {
deleteRoad(p.id, isAppr ? 'approved' : 'candidate');
popupRef.current?.remove();
});
popupRef.current = new maplibregl.Popup({ offset: 12 }).setLngLat(e.lngLat).setDOMContent(popupDiv).addTo(lMap);
};
['cp', 'cp-overture', 'approved'].forEach(layer => {
lMap.on('click', layer, onCandidateClick);
lMap.on('mouseenter', layer, () => { lMap.getCanvas().style.cursor = 'pointer'; });
lMap.on('mouseleave', layer, () => { lMap.getCanvas().style.cursor = ''; });
});
lMap.on('mousemove', onMouseMoveDraw);
rMap.on('mousemove', onMouseMoveDraw);
leftMapRef.current = lMap;
rightMapRef.current = rMap;
@@ -282,8 +372,13 @@ const IntelligenceDashboard: React.FC = () => {
return () => { lMap.remove(); rMap.remove(); leftMapRef.current = null; rightMapRef.current = null; };
}, []);
const mountedRef = useRef(false);
// Update right map when reference layer changes
useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true;
return;
}
const m = rightMapRef.current;
if (!m) return;
const c = m.getCenter(); const z = m.getZoom(); const b = m.getBearing(); const p = m.getPitch();
@@ -404,7 +499,13 @@ const IntelligenceDashboard: React.FC = () => {
setTimeout(() => setMsg(''), 5000);
};
const cancelDrawing = () => { pointsRef.current = []; redrawDrawing(); setDrawing(false); };
const cancelDrawing = () => {
pointsRef.current = [];
redrawDrawing();
if (leftMapRef.current) updateSnapGuide(leftMapRef.current, null);
if (rightMapRef.current) updateSnapGuide(rightMapRef.current, null);
setDrawing(false);
};
if (needsAuth) {
return (