From a39dfe1aaad8ac4ff353ae8d5a2f24cbd61db150 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Wed, 15 Jul 2026 14:58:48 +0300 Subject: [PATCH] feat: implement side-by-side synchronized map comparison tool and infrastructure scripts for connectivity and data updates --- apps/api/src/maps/candidate-road.entity.ts | 15 + apps/api/src/maps/maps.service.ts | 69 ++- apps/web/src/App.tsx | 2 +- apps/web/src/main.tsx | 47 +- apps/web/src/pages/CompareView.tsx | 173 ++++++ apps/web/src/pages/IntelligenceDashboard.tsx | 572 ++++++++++-------- infrastructure/scripts/apply-delta.sh | 115 ++++ .../scripts/check-node-connectivity.sh | 94 +++ infrastructure/scripts/update-data.sh | 119 ++-- style-dark.json | 61 ++ style.json | 61 ++ 11 files changed, 1025 insertions(+), 303 deletions(-) create mode 100644 apps/web/src/pages/CompareView.tsx create mode 100644 infrastructure/scripts/apply-delta.sh create mode 100755 infrastructure/scripts/check-node-connectivity.sh diff --git a/apps/api/src/maps/candidate-road.entity.ts b/apps/api/src/maps/candidate-road.entity.ts index 7adf734..aac5894 100644 --- a/apps/api/src/maps/candidate-road.entity.ts +++ b/apps/api/src/maps/candidate-road.entity.ts @@ -52,6 +52,21 @@ export class CandidateRoad { @Column({ default: 'pending' }) status: string; + // Where this candidate came from: 'telemetry' (driver traces) or 'overture' (map diff) + // مصدر الاقتراح: من تتبع السائقين أو من مقارنة بيانات Overture + @Column({ default: 'telemetry' }) + source: string; + + // Road name, when known (Overture provides these; telemetry candidates are unnamed) + // اسم الطريق إن وُجد (يأتي من Overture) + @Column({ type: 'varchar', length: 255, nullable: true }) + name: string; + + // OSM highway class carried from Overture, applied on approval + // تصنيف الطريق المنقول من Overture ويُطبَّق عند الموافقة + @Column({ type: 'varchar', length: 32, nullable: true }) + highway: string; + @CreateDateColumn() discoveredAt: Date; diff --git a/apps/api/src/maps/maps.service.ts b/apps/api/src/maps/maps.service.ts index df86fb8..44308f3 100644 --- a/apps/api/src/maps/maps.service.ts +++ b/apps/api/src/maps/maps.service.ts @@ -17,11 +17,13 @@ export class MapsService { @InjectRepository(RoadSegmentStat) private roadStatRepo: Repository, private trafficGrid: TrafficGridService, - private geocodingService: GeocodingService + private geocodingService: GeocodingService, + private dataSource: DataSource, ) { this.graphHopperUrl = this.configService.get('GRAPH_HOPPER_URL', 'http://routing:8080'); } + async getRoute(waypoints: [number, number][], profile: string = 'car', steps: boolean = false, locale: string = 'en', alternatives: boolean = false) { if (waypoints.length < 2) { throw new HttpException('At least two waypoints are required', HttpStatus.BAD_REQUEST); @@ -63,6 +65,53 @@ export class MapsService { instructions: steps, }; + // ── Closure-Aware Routing ───────────────────────────────────────────── + // Roads the telemetry analyzer flagged as closed are handed to GraphHopper + // as custom-model "areas" so the router avoids them. Two bugs from the first + // version are fixed here: + // 1. Areas MUST be polygons. road_segment_stats.geometry is a LineString, + // so we buffer it (~15 m) into a polygon in SQL before sending. + // 2. Each rule must reference its area by the exact id the area declares + // (in_). The old code called indexOf() on a different array and + // always produced `in_custom_area-1` (a non-existent area) — GraphHopper + // then rejected the request with 400, taking down ALL routing. + // closureCount lets the send step below retry without closures if GH still + // refuses the custom model, so a bad closure can never break routing. + let closureCount = 0; + try { + const closedSegments = await this.dataSource.query(` + SELECT ST_AsGeoJSON( + ST_Transform(ST_Buffer(ST_Transform(geometry::geometry, 3857), 15), 4326), 6 + ) AS geojson + FROM road_segment_stats + WHERE "isClosed" = true AND geometry IS NOT NULL + LIMIT 50 + `); + + const features = closedSegments + .map((s: any, i: number) => { + try { + return { type: 'Feature', id: `closed_${i}`, geometry: JSON.parse(s.geojson), properties: {} }; + } catch { return null; } + }) + .filter(Boolean); + + if (features.length > 0) { + payload['custom_model'] = { + priority: features.map((f: any) => ({ if: `in_${f.id}`, multiply_by: '0' })), + areas: { type: 'FeatureCollection', features }, + }; + payload['ch.disable'] = true; // request-time custom models require CH disabled + closureCount = features.length; + console.log(`🚧 Routing: avoiding ${closureCount} closed segment(s).`); + } + } catch (closureError) { + // Non-fatal — routing continues normally without closure avoidance. + console.warn('⚠️ Could not load road closures for routing:', closureError.message); + } + // ───────────────────────────────────────────────────────────────────── + + // GraphHopper ONLY supports alternative routes if there are exactly 2 points (Start and End) if (alternatives && waypoints.length === 2) { payload.algorithm = 'alternative_route'; @@ -73,7 +122,23 @@ export class MapsService { } console.log(`Routing Request: ${waypoints.length} points via ${profile} on ${this.graphHopperUrl} | Steps: ${steps} | Locale: ${locale}`); - const response = await axios.post(`${this.graphHopperUrl}/route`, payload, { timeout: 10000 }); + let response: any; + try { + response = await axios.post(`${this.graphHopperUrl}/route`, payload, { timeout: 10000 }); + } catch (routeErr) { + // Safety net: if the closure-aware custom model was rejected, retry once + // WITHOUT it so an unsupported/bad closure can never take down all routing. + if (closureCount > 0 && payload['custom_model']) { + const detail = routeErr.response ? JSON.stringify(routeErr.response.data) : routeErr.message; + console.warn(`⚠️ Closure-aware routing failed (${detail}). Retrying without closures...`); + delete payload['custom_model']; + // Keep ch.disable only if the alternative-route block below still needs it. + if (!(alternatives && waypoints.length === 2)) delete payload['ch.disable']; + response = await axios.post(`${this.graphHopperUrl}/route`, payload, { timeout: 10000 }); + } else { + throw routeErr; + } + } console.log('Routing SUCCESS'); const paths = response.data.paths; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 2a3d5ae..8ac1630 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -106,7 +106,7 @@ function App() { const fetchStats = async () => { try { const apiUrl = (import.meta as any).env.VITE_API_URL || '/api'; - const response = await fetch(`${apiUrl}/map-refinement/summary`); + 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); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 3d7150d..ca40ac7 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,10 +1,53 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' import ReactDOM from 'react-dom/client' import App from './App.tsx' +import CompareView from './pages/CompareView' +import IntelligenceDashboard from './pages/IntelligenceDashboard' import './index.css' +// Lightweight hash router (no dependency). '#compare' and '#review' are additive +// views; anything else falls back to the existing map app, untouched. +const NAV = [ + { hash: '#map', label: 'Map' }, + { hash: '#review', label: 'Review' }, + { hash: '#compare', label: 'Compare' }, +] + +function ViewNav({ hash }: { hash: string }) { + const isMap = hash !== '#compare' && hash !== '#review' + return ( +
+ {NAV.map(n => { + const active = n.hash === '#map' ? isMap : hash === n.hash + return ( + + {n.label} + + ) + })} +
+ ) +} + +function Root() { + const [hash, setHash] = useState(window.location.hash) + useEffect(() => { + const on = () => setHash(window.location.hash) + window.addEventListener('hashchange', on) + return () => window.removeEventListener('hashchange', on) + }, []) + const view = hash === '#compare' ? : hash === '#review' ? : + return ( + <> + + {view} + + ) +} + ReactDOM.createRoot(document.getElementById('root')!).render( - + , ) diff --git a/apps/web/src/pages/CompareView.tsx b/apps/web/src/pages/CompareView.tsx new file mode 100644 index 0000000..fad12dd --- /dev/null +++ b/apps/web/src/pages/CompareView.tsx @@ -0,0 +1,173 @@ +import React, { useEffect, useRef, useState } from 'react'; +import maplibregl from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; + +/** + * CompareView — side-by-side, pan/zoom-synced map comparison. + * + * Left = our map (the production Martin style). + * Right = a legally-usable reference: Esri World Imagery (satellite) / Esri Streets + * / OSM standard. Google, Bing and Apple tiles are NOT embedded here — their + * terms forbid unofficial tile use in a product — so instead there are buttons + * that open THEIR official sites at the exact same coordinates in a new tab, + * which is the legal way to eyeball against them. + * + * Purpose: visually confirm whether a street/place exists before adding or approving it. + */ + +const TILES = (import.meta as any).env.VITE_TILES_URL || 'https://tiles.intaleqapp.com'; + +// "Our map" style, built inline from the public Martin vector tiles (same endpoints +// the main app uses — no API key needed). Roads-focused for clean street comparison, +// with our approved_roads drawn green. approved_roads tiles 404 until the table exists; +// MapLibre degrades gracefully (just no green lines yet). +const ourStyle = (): any => ({ + version: 8, + glyphs: 'https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf', + sources: { + osm_lines: { type: 'vector', tiles: [`${TILES}/planet_osm_line/{z}/{x}/{y}`], maxzoom: 14 }, + osm_polys: { type: 'vector', tiles: [`${TILES}/planet_osm_polygon/{z}/{x}/{y}`], maxzoom: 14 }, + approved: { type: 'vector', tiles: [`${TILES}/approved_roads/{z}/{x}/{y}`], minzoom: 8, maxzoom: 18 }, + }, + layers: [ + { id: 'bg', type: 'background', paint: { 'background-color': '#eef0f2' } }, + { id: 'water', type: 'fill', source: 'osm_polys', 'source-layer': 'planet_osm_polygon', filter: ['in', 'natural', 'water', 'bay'], paint: { 'fill-color': '#a3ccff' } }, + { id: 'roads-casing', type: 'line', source: 'osm_lines', 'source-layer': 'planet_osm_line', filter: ['has', 'highway'], paint: { 'line-color': '#c8ccd4', 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1.2, 16, 10] } }, + { id: 'roads-core', type: 'line', source: 'osm_lines', 'source-layer': 'planet_osm_line', filter: ['has', 'highway'], paint: { 'line-color': '#ffffff', 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 0.6, 16, 7] } }, + { id: 'approved', type: 'line', source: 'approved', 'source-layer': 'approved_roads', paint: { 'line-color': '#22c55e', 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1.5, 16, 8] } }, + ], +}); + +type RefKey = 'esri-sat' | 'esri-streets' | 'osm'; +const REFS: Record = { + 'esri-sat': { label: '🛰️ Esri Satellite', tiles: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri, Maxar', maxzoom: 19 }, + 'esri-streets': { label: '🗺️ Esri Streets', tiles: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri', maxzoom: 19 }, + 'osm': { label: '🧭 OSM Standard', tiles: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap', maxzoom: 19 }, +}; + +const rasterStyle = (ref: { tiles: string; attribution: string; maxzoom: number }): any => ({ + version: 8, + sources: { ref: { type: 'raster', tiles: [ref.tiles], tileSize: 256, attribution: ref.attribution, maxzoom: ref.maxzoom } }, + layers: [{ id: 'ref', type: 'raster', source: 'ref' }], +}); + +const CompareView: React.FC = () => { + const leftDiv = useRef(null); + const rightDiv = useRef(null); + const leftMap = useRef(null); + const rightMap = useRef(null); + const syncing = useRef(false); + + const [refKey, setRefKey] = useState('esri-sat'); + const [center, setCenter] = useState<{ lng: number; lat: number; zoom: number }>({ lng: 35.91, lat: 31.95, zoom: 15 }); + + // Create both maps once. + useEffect(() => { + if (!leftDiv.current || !rightDiv.current || leftMap.current) return; + + const start: [number, number] = [35.91, 31.95]; + const startZoom = 15; + + const lMap = new maplibregl.Map({ container: leftDiv.current, style: ourStyle(), center: start, zoom: startZoom, attributionControl: false }); + const rMap = new maplibregl.Map({ container: rightDiv.current, style: rasterStyle(REFS['esri-sat']), center: start, zoom: startZoom, attributionControl: false }); + lMap.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-left'); + rMap.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right'); + + // Keep the two views locked together. The guard stops the move→jumpTo→move loop. + const link = (from: maplibregl.Map, to: maplibregl.Map) => { + if (syncing.current) return; + syncing.current = true; + to.jumpTo({ center: from.getCenter(), zoom: from.getZoom(), bearing: from.getBearing(), pitch: from.getPitch() }); + syncing.current = false; + const c = from.getCenter(); + setCenter({ lng: c.lng, lat: c.lat, zoom: from.getZoom() }); + }; + lMap.on('move', () => link(lMap, rMap)); + rMap.on('move', () => link(rMap, lMap)); + + leftMap.current = lMap; + rightMap.current = rMap; + return () => { lMap.remove(); rMap.remove(); leftMap.current = null; rightMap.current = null; }; + }, []); + + // Swap the reference basemap without losing the synced position. + useEffect(() => { + const m = rightMap.current; + if (!m) return; + const c = m.getCenter(); const z = m.getZoom(); const b = m.getBearing(); const p = m.getPitch(); + m.setStyle(rasterStyle(REFS[refKey])); + m.once('styledata', () => m.jumpTo({ center: c, zoom: z, bearing: b, pitch: p })); + }, [refKey]); + + const fmt = (n: number) => n.toFixed(6); + const z = Math.round(center.zoom); + const googleUrl = `https://www.google.com/maps/@${center.lat},${center.lng},${z}z/data=!3m1!1e3`; // 1e3 = satellite + const bingUrl = `https://www.bing.com/maps?cp=${center.lat}~${center.lng}&lvl=${z}&style=h`; // h = aerial+labels + const josmUrl = `https://www.openstreetmap.org/#map=${z}/${fmt(center.lat)}/${fmt(center.lng)}`; + + 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' }; + + return ( +
+ {/* Toolbar */} +
+ ← Map + Compare + Our map ⟷ reference — synced + +
+ {(Object.keys(REFS) as RefKey[]).map(k => ( + + ))} +
+ + +
+ + {/* Maps */} +
+
+
+ + +
+
+
+ + +
+
+ + {/* Coordinate readout */} +
+ 📍 center: {fmt(center.lat)}, {fmt(center.lng)} + zoom: {center.zoom.toFixed(1)} + Center the crosshair on a street, then compare both sides / open Google. +
+
+ ); +}; + +const Badge: React.FC<{ text: string }> = ({ text }) => ( +
+ {text} +
+); + +const Crosshair: React.FC = () => ( +
+
+
+
+
+
+); + +export default CompareView; diff --git a/apps/web/src/pages/IntelligenceDashboard.tsx b/apps/web/src/pages/IntelligenceDashboard.tsx index 926b607..2f0d349 100644 --- a/apps/web/src/pages/IntelligenceDashboard.tsx +++ b/apps/web/src/pages/IntelligenceDashboard.tsx @@ -1,314 +1,362 @@ -import React, { useState, useEffect } from 'react'; -import { - Activity, - Map as MapIcon, - Settings, - ShieldAlert, - CheckCircle2, - XCircle, - RefreshCw, - Search, - LayoutDashboard, - ExternalLink, - ChevronRight -} from 'lucide-react'; +import React, { useState, useEffect, useRef } from 'react'; +import maplibregl from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import { Activity, Map as MapIcon, Settings, ShieldAlert, CheckCircle2, XCircle, RefreshCw, LayoutDashboard, Eye, Layers, Navigation } from 'lucide-react'; + +interface CandidateRoad { id: string; uniqueDriverCount: number; totalPoints: number; lengthMeters: number; confidence: number; status: string; source?: string; name?: string; highway?: string; geometry?: any; } +interface Closure { segmentId: string; sampleCount: number; lastUpdated: string; } + +const ESRI = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; +const MARTIN = (import.meta as any).env.VITE_TILE_SERVER_URL || 'http://localhost:3202'; +const API = (import.meta as any).env.VITE_API_URL || '/api'; +const cc = (c: number) => c >= 0.8 ? '#22c55e' : c >= 0.6 ? '#eab308' : '#f97316'; const IntelligenceDashboard: React.FC = () => { const [stats, setStats] = useState(null); - const [candidates, setCandidates] = useState([]); - const [closures, setClosures] = useState([]); + const [cands, setCands] = useState([]); + const [closes, setCloses] = useState([]); const [loading, setLoading] = useState(false); - const [activeTab, setActiveTab] = useState<'summary' | 'candidates' | 'closures'>('summary'); - const [message, setMessage] = useState(''); + const [tab, setTab] = useState<'candidates' | 'closures' | 'summary'>('candidates'); + const [sel, setSel] = useState(null); + const [msg, setMsg] = useState(''); + const [sat, setSat] = useState(true); + const [traces, setTraces] = useState(true); + const mapRef = useRef(null); + const mapDiv = useRef(null); + const popupRef = useRef(null); + const candsRef = useRef([]); + const closesRef = useRef([]); - const API_BASE = (import.meta as any).env.VITE_API_URL || '/api'; + // Push the latest candidates/closures onto the map as GeoJSON. These tables use + // a `geography` column, which Martin's auto-publish does not serve as vector + // tiles — so the review layers are fed straight from the API instead. + const pushMapData = () => { + const m = mapRef.current; + if (!m || !m.isStyleLoaded()) return; + (m.getSource('cands') as maplibregl.GeoJSONSource)?.setData({ + type: 'FeatureCollection', + features: candsRef.current.filter(c => c.geometry).map(c => ({ + type: 'Feature' as const, + geometry: c.geometry, + properties: { id: c.id, status: c.status, source: c.source || 'telemetry', name: c.name || '', confidence: c.confidence, lengthMeters: c.lengthMeters, uniqueDriverCount: c.uniqueDriverCount }, + })), + }); + (m.getSource('closures') as maplibregl.GeoJSONSource)?.setData({ + type: 'FeatureCollection', + features: (closesRef.current as any[]).filter(c => c.geometry).map(c => ({ + type: 'Feature' as const, + geometry: c.geometry, + properties: { segmentId: c.segmentId }, + })), + }); + }; - const fetchData = async () => { + const load = async () => { setLoading(true); try { - const [summaryRes, candRes, closeRes] = await Promise.all([ - fetch(`${API_BASE}/map-refinement/summary`), - fetch(`${API_BASE}/map-refinement/candidates?status=pending`), - fetch(`${API_BASE}/map-refinement/closures`) + const [sr, cr, clr] = await Promise.all([ + fetch(`${API}/map-refinement/roads/summary`), + fetch(`${API}/map-refinement/roads/candidates?status=pending`), + fetch(`${API}/map-refinement/roads/closures`), ]); - - const summary = await summaryRes.json(); - const cand = await candRes.json(); - const close = await closeRes.json(); - - setStats(summary); - setCandidates(cand || []); - setClosures(close || []); - } catch (error) { - console.error("Dashboard fetch failed", error); - } finally { - setLoading(false); - } + setStats(await sr.json()); + const cJson = (await cr.json()) || []; + const clJson = (await clr.json()) || []; + setCands(cJson); + setCloses(clJson); + candsRef.current = cJson; + closesRef.current = clJson; + pushMapData(); + } catch (e) { console.error(e); } + setLoading(false); }; + useEffect(() => { load(); const iv = setInterval(load, 60000); return () => clearInterval(iv); }, []); + useEffect(() => { - fetchData(); - const interval = setInterval(fetchData, 30000); - return () => clearInterval(interval); + if (!mapDiv.current || mapRef.current) return; + const m = new maplibregl.Map({ + container: mapDiv.current, + style: { + version: 8, + glyphs: 'https://fonts.openmaptiles.org/{fontstack}/{range}.pbf', + sources: { + sat: { type: 'raster', tiles: [ESRI], tileSize: 256, maxzoom: 19 }, + roads: { type: 'vector', tiles: [`${MARTIN}/planet_osm_line/{z}/{x}/{y}`], minzoom: 8, maxzoom: 16 }, + // candidate_roads / road_segment_stats are `geography` tables that Martin + // does not auto-publish — these are fed from the API via pushMapData(). + cands: { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any, + closures: { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any, + hl: { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any, + }, + layers: [ + { id: 'sat', type: 'raster', source: 'sat', layout: { visibility: 'visible' } }, + { id: 'osm', type: 'line', source: 'roads', 'source-layer': 'planet_osm_line', filter: ['has', 'highway'], paint: { 'line-color': '#6b7280', 'line-width': 1.2, 'line-opacity': 0.6 } }, + { id: 'cl', type: 'line', source: 'closures', paint: { 'line-color': '#ef4444', 'line-width': 4, 'line-dasharray': [3, 2] } }, + { 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] } }, + { id: 'cp-overture', type: 'line', source: 'cands', + filter: ['all', ['==', ['get', 'status'], 'pending'], ['==', ['get', 'source'], 'overture']] as any, + paint: { 'line-color': '#a855f7', 'line-width': 4, 'line-dasharray': [2, 2] } }, + { id: 'ca', type: 'line', source: 'cands', filter: ['==', ['get', 'status'], 'approved'] as any, paint: { 'line-color': '#22c55e', 'line-width': 3.5 } }, + { id: 'hl', type: 'line', source: 'hl', paint: { 'line-color': '#60a5fa', 'line-width': 6, 'line-blur': 1 } }, + ] as any, + }, + center: [35.93, 31.96], zoom: 10, + }); + m.addControl(new maplibregl.NavigationControl(), 'top-right'); + m.on('load', () => pushMapData()); // draw any data that arrived before the map was ready + const onCandidateClick = (e: any) => { + const p = e.features?.[0]?.properties as any; if (!p) return; + setSel(p.id); + const isOvt = p.source === 'overture'; + const title = isOvt ? '🗺️ Overture road (missing)' : '🛣️ Driver-traced road'; + const nameLine = p.name ? `${p.name}
` : ''; + const evidence = isOvt + ? `${p.uniqueDriverCount ?? 0} trace pts corroborate` + : `${p.uniqueDriverCount} drivers`; + if (popupRef.current) popupRef.current.remove(); + popupRef.current = new maplibregl.Popup({ offset: 12 }).setLngLat(e.lngLat) + .setHTML(`
${nameLine}${title}
Conf: ${Math.round(p.confidence * 100)}%
${Math.round(p.lengthMeters)}m | ${evidence}
`) + .addTo(m); + }; + ['cp', 'cp-overture'].forEach(layer => { + m.on('click', layer, onCandidateClick); + m.on('mouseenter', layer, () => { m.getCanvas().style.cursor = 'pointer'; }); + m.on('mouseleave', layer, () => { m.getCanvas().style.cursor = ''; }); + }); + mapRef.current = m; + return () => { m.remove(); mapRef.current = null; }; }, []); - const runAnalysis = async () => { - setMessage('Brain starting deep analysis... 🧠'); - try { - await fetch(`${API_BASE}/telemetry/process-intelligence?days=2`, { method: 'POST' }); - setMessage('Analysis initiated successfully. Check Telegram for report.'); - } catch (error) { - setMessage('Analysis failed to start.'); + useEffect(() => { const m = mapRef.current; if (m && m.isStyleLoaded()) m.setLayoutProperty('sat', 'visibility', sat ? 'visible' : 'none'); }, [sat]); + useEffect(() => { const m = mapRef.current; if (m && m.isStyleLoaded()) m.setLayoutProperty('osm', 'visibility', traces ? 'visible' : 'none'); }, [traces]); + + const fly = (c: CandidateRoad) => { + const m = mapRef.current; if (!m || !c.geometry) return; + setSel(c.id); + (m.getSource('hl') as maplibregl.GeoJSONSource)?.setData({ type: 'FeatureCollection', features: [{ type: 'Feature', geometry: c.geometry, properties: {} }] }); + const coords: [number, number][] = c.geometry.coordinates || []; + if (coords.length > 0) { + const b = coords.reduce((b, p) => b.extend(p as maplibregl.LngLatLike), new maplibregl.LngLatBounds(coords[0], coords[0])); + m.fitBounds(b, { padding: 120, maxZoom: 17, duration: 800 }); } - setTimeout(() => setMessage(''), 5000); }; - const handleCandidate = async (id: string, action: 'approve' | 'reject') => { + const act = async (id: string, a: 'approve' | 'reject') => { try { - await fetch(`${API_BASE}/map-refinement/candidates/${id}/${action}`, { method: 'PATCH' }); - setMessage(`Road ${action}d successfully. 🛣️`); - fetchData(); - } catch (error) { - setMessage(`Failed to ${action} road.`); - } - setTimeout(() => setMessage(''), 3000); + const res = await fetch(`${API}/map-refinement/roads/candidates/${id}/${a}`, { method: 'PATCH' }); + if (a === 'approve') { + const d = await res.json().catch(() => ({} as any)); + setMsg(d.connected ? '✅ Approved & wired into the network — routes after next rebuild.' + : d.snapped ? '✅ Approved & snapped — endpoints not auto-connected (check DB middle tables).' + : '✅ Approved — drawn on the map; routing after rebuild.'); + } else { + setMsg('🚫 Rejected.'); + } + setSel(null); + (mapRef.current?.getSource('hl') as maplibregl.GeoJSONSource)?.setData({ type: 'FeatureCollection', features: [] }); + load(); + } catch { setMsg('Action failed.'); } + setTimeout(() => setMsg(''), 5000); + }; + + const runAI = async () => { + setMsg('🧠 Deep analysis started...'); + try { await fetch(`${API}/telemetry/process-intelligence?days=2`, { method: 'POST' }); setMsg('Running. Check Telegram.'); } + catch { setMsg('Analysis failed.'); } + setTimeout(() => setMsg(''), 6000); + }; + + const runOverture = async () => { + setMsg('🗺️ Comparing our map against Overture...'); + try { + const res = await fetch(`${API}/map-refinement/roads/discover-overture-gaps`, { method: 'POST' }); + const data = await res.json(); + setMsg(`🗺️ Overture diff: ${data.candidatesFound ?? 0} missing road(s) added for review.`); + load(); + } catch { setMsg('Overture comparison failed.'); } + setTimeout(() => setMsg(''), 6000); }; return ( -
- {/* Header */} -
-
-

-
- -
- Map AI Intelligence -

-

Intaleq SaaS Monitoring & Enrichment Platform

-
+
-
- -
-
+ {/* ── SIDEBAR ── */} +
- {/* Stats Quick View */} -
- {[ - { label: 'Telemetry Points', value: stats?.telemetry?.total || 0, icon: , color: '#818cf8' }, - { label: 'Analyzed Segments', value: stats?.roads?.analyzed || 0, icon: , color: '#60a5fa' }, - { label: 'Road Candidates', value: candidates.length, icon: , color: '#fbbf24' }, - { label: 'Active Closures', value: closures.length, icon: , color: '#f87171' }, - ].map((stat, i) => ( -
-
{stat.icon}
+
+
+
- {stat.label} - {stat.value.toLocaleString()} +
Map Intelligence
+
Intaleq SaaS v2 · Closure-Aware Routing
- ))} -
+ +
- {/* Main Content Area */} -
- {/* Navigation Tabs */} - +
-
- {activeTab === 'candidates' && ( -
-
-

Detected Road Candidates / طرق مرشحة

-
- - -
-
+
- {candidates.length === 0 ? ( -
- -

No pending road candidates found. Everything is synced. ✅

-
- ) : ( -
- {candidates.map((c) => ( -
-
-
0.8 ? '#22c55e' : '#eab308', - borderRadius: '4px' - }} /> -
-
- New Segment {c.id.slice(-4)} - - {Math.round(c.lengthMeters)}m - -
-
- {c.uniqueDriverCount} drivers • {c.totalPoints} points • Confidence: {Math.round(c.confidence * 100)}% -
-
-
-
- - -
+ {tab === 'candidates' && ( +
+ {cands.length === 0 + ?
+ +

No pending candidates ✅

+
+ : cands.map(c => ( +
fly(c)} style={{ background: sel === c.id ? 'rgba(99,102,241,0.15)' : '#1e293b', border: `1px solid ${sel === c.id ? '#6366f1' : '#334155'}`, borderRadius: '12px', padding: '0.9rem', cursor: 'pointer', transition: 'all 0.15s' }}> +
+
+ {c.name || `Segment …${c.id.slice(-6)}`} + {c.source === 'overture' ? '🗺️ Overture' : '📡 Traces'} + {Math.round(c.lengthMeters)}m
- ))} -
- )} +
+ {c.source === 'overture' + ? <>{c.highway || 'road'} · {c.totalPoints} trace pts · {Math.round(c.confidence * 100)}% confidence + : <>{c.uniqueDriverCount} drivers · {c.totalPoints} pts · {Math.round(c.confidence * 100)}% confidence} +
+
+ {[ + { l: 'View', i: , f: (e: React.MouseEvent) => { e.stopPropagation(); fly(c); }, bg: 'rgba(99,102,241,0.1)', b: '#6366f130', col: '#818cf8' }, + { l: 'Approve', i: , f: (e: React.MouseEvent) => { e.stopPropagation(); act(c.id, 'approve'); }, bg: 'rgba(34,197,94,0.1)', b: '#22c55e30', col: '#4ade80' }, + { l: 'Reject', i: , f: (e: React.MouseEvent) => { e.stopPropagation(); act(c.id, 'reject'); }, bg: 'rgba(239,68,68,0.1)', b: '#ef444430', col: '#f87171' }, + ].map(({ l, i, f, bg, b, col }) => ( + + ))} +
+
+ ))}
)} - {activeTab === 'summary' && stats && ( -
-
-

Intelligence Quality Report

-
- {/* Quality Metrics */} - {[ - { label: 'Data Density', value: 'High Accuracy', desc: 'Clusters are well-defined in Damascus & Amman', color: '#22c55e' }, - { label: 'Map Freshness', value: 'Last 24h', desc: 'Telemetry analysis window is fully processed', color: '#6366f1' }, - { label: 'Overpass Sync', value: 'Available', desc: 'Overture enrichment ready to pull labels', color: '#3b82f6' } - ].map((item, i) => ( -
-
-
{item.label}
-
{item.desc}
-
-
{item.value}
-
- ))} -
-
-
-

Action Log

-
- {stats.history?.map((entry: any, i: number) => ( -
- - {entry} + {tab === 'closures' && ( +
+ {closes.length === 0 + ?
+ +

No active closures 🚧

+
+ : closes.map(cl => ( +
+
+
+ Closed Road
- )) || [
System initialized. Waiting for analysis...
]} -
-
+
…{String(cl.segmentId).slice(-8)} · {cl.sampleCount} historical samples
+
+ ))}
)} - {activeTab === 'closures' && ( -
- -

No active road closures detected in current telemetry. 🚧

- -
+ {tab === 'summary' && ( +
+ {[ + { l: 'Data Density', v: 'High', d: 'Clusters in Amman & Damascus', c: '#22c55e' }, + { l: 'Routing Closures', v: `${closes.length} blocked`, d: 'isClosed → GraphHopper custom_model', c: '#f87171' }, + { l: 'Overture Sync', v: 'Ready', d: 'SQL diff for automated gap detection', c: '#3b82f6' }, + { l: 'Delta Pipeline', v: 'Active', d: 'Approved roads survive 10-day updates', c: '#6366f1' }, + ].map((it, i) => ( +
+
+
{it.l}
+
{it.d}
+
+
{it.v}
+
+ ))} +
)}
- {/* Persistence Message */} - {message && ( -
- {message} + {/* ── MAP PANE ── */} +
+
+ + {/* Legend */} +
+ {[ + { c: '#6b7280', l: 'OSM Roads', d: false }, + { c: '#eab308', l: 'Traced Candidate', d: true }, + { c: '#a855f7', l: 'Overture (missing)', d: true }, + { c: '#22c55e', l: 'Approved', d: false }, + { c: '#ef4444', l: 'Road Closure', d: true }, + { c: '#60a5fa', l: 'Selected', d: false }, + ].map(({ c, l, d }) => ( +
+ + {l} +
+ ))} +
+ + {loading && ( +
+ Refreshing… +
+ )} +
+ + {msg && ( +
+ {msg}
)}