feat: implement side-by-side synchronized map comparison tool and infrastructure scripts for connectivity and data updates

This commit is contained in:
Hamza-Ayed
2026-07-15 14:58:48 +03:00
parent 790bfcefc8
commit a39dfe1aaa
11 changed files with 1025 additions and 303 deletions
@@ -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;
+67 -2
View File
@@ -17,11 +17,13 @@ export class MapsService {
@InjectRepository(RoadSegmentStat)
private roadStatRepo: Repository<RoadSegmentStat>,
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_<id>). 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;
+1 -1
View File
@@ -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);
+45 -2
View File
@@ -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 (
<div style={{ position: 'fixed', top: 8, left: '50%', transform: 'translateX(-50%)', zIndex: 9999, display: 'flex', gap: 4, background: 'rgba(15,23,42,0.9)', border: '1px solid #334155', borderRadius: 999, padding: 3, fontFamily: 'Inter, system-ui, sans-serif' }}>
{NAV.map(n => {
const active = n.hash === '#map' ? isMap : hash === n.hash
return (
<a key={n.hash} href={n.hash}
style={{ textDecoration: 'none', fontSize: 12, fontWeight: 600, color: active ? '#fff' : '#94a3b8', background: active ? '#6366f1' : 'transparent', padding: '5px 12px', borderRadius: 999 }}>
{n.label}
</a>
)
})}
</div>
)
}
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' ? <CompareView /> : hash === '#review' ? <IntelligenceDashboard /> : <App />
return (
<>
<ViewNav hash={hash} />
{view}
</>
)
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
<Root />
</React.StrictMode>,
)
+173
View File
@@ -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<RefKey, { label: string; tiles: string; attribution: string; maxzoom: number }> = {
'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<HTMLDivElement>(null);
const rightDiv = useRef<HTMLDivElement>(null);
const leftMap = useRef<maplibregl.Map | null>(null);
const rightMap = useRef<maplibregl.Map | null>(null);
const syncing = useRef(false);
const [refKey, setRefKey] = useState<RefKey>('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 (
<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', borderBottom: '1px solid #1e293b', flexWrap: 'wrap' }}>
<a href="#" style={{ ...btn, borderColor: '#6366f1', color: '#a5b4fc' }}>← Map</a>
<strong style={{ fontSize: '0.9rem' }}>Compare</strong>
<span style={{ color: '#64748b', fontSize: '0.78rem' }}>Our map ⟷ reference — synced</span>
<div style={{ display: 'flex', gap: '6px', marginInlineStart: 'auto' }}>
{(Object.keys(REFS) as RefKey[]).map(k => (
<button key={k} onClick={() => setRefKey(k)}
style={{ ...btn, ...(refKey === k ? { borderColor: '#6366f1', color: '#fff', background: 'rgba(99,102,241,0.2)' } : {}) }}>
{REFS[k].label}
</button>
))}
</div>
<div style={{ display: 'flex', gap: '6px' }}>
<a href={googleUrl} target="_blank" rel="noopener noreferrer" style={btn} title="Open Google Maps satellite at this point">Google ↗</a>
<a href={bingUrl} target="_blank" rel="noopener noreferrer" style={btn} title="Open Bing aerial at this point">Bing ↗</a>
<a href={josmUrl} target="_blank" rel="noopener noreferrer" style={btn} title="Open on openstreetmap.org">OSM ↗</a>
</div>
</div>
{/* Maps */}
<div style={{ position: 'relative', flex: 1, display: 'flex' }}>
<div style={{ position: 'relative', flex: 1, borderInlineEnd: '2px solid #0f172a' }}>
<div ref={leftDiv} style={{ position: 'absolute', inset: 0 }} />
<Badge text="Our map (Intaleq)" />
<Crosshair />
</div>
<div style={{ position: 'relative', flex: 1 }}>
<div ref={rightDiv} style={{ position: 'absolute', inset: 0 }} />
<Badge text={REFS[refKey].label} />
<Crosshair />
</div>
</div>
{/* Coordinate readout */}
<div style={{ padding: '6px 16px', borderTop: '1px solid #1e293b', fontSize: '0.75rem', color: '#94a3b8', display: 'flex', gap: '18px' }}>
<span>📍 center: <b style={{ color: '#e2e8f0' }}>{fmt(center.lat)}, {fmt(center.lng)}</b></span>
<span>zoom: <b style={{ color: '#e2e8f0' }}>{center.zoom.toFixed(1)}</b></span>
<span style={{ marginInlineStart: 'auto', color: '#64748b' }}>Center the crosshair on a street, then compare both sides / open Google.</span>
</div>
</div>
);
};
const Badge: React.FC<{ text: string }> = ({ text }) => (
<div style={{ position: 'absolute', top: 10, insetInlineStart: 10, zIndex: 2, background: 'rgba(15,23,42,0.85)', border: '1px solid #334155', color: '#e2e8f0', padding: '4px 10px', borderRadius: '20px', fontSize: '0.72rem', fontWeight: 600, pointerEvents: 'none' }}>
{text}
</div>
);
const Crosshair: React.FC = () => (
<div style={{ position: 'absolute', inset: 0, zIndex: 2, pointerEvents: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ position: 'relative', width: 26, height: 26 }}>
<div style={{ position: 'absolute', top: '50%', left: 0, right: 0, height: 2, background: 'rgba(239,68,68,0.9)', transform: 'translateY(-50%)' }} />
<div style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: 2, background: 'rgba(239,68,68,0.9)', transform: 'translateX(-50%)' }} />
</div>
</div>
);
export default CompareView;
+310 -262
View File
@@ -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<any>(null);
const [candidates, setCandidates] = useState<any[]>([]);
const [closures, setClosures] = useState<any[]>([]);
const [cands, setCands] = useState<CandidateRoad[]>([]);
const [closes, setCloses] = useState<Closure[]>([]);
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<string | null>(null);
const [msg, setMsg] = useState('');
const [sat, setSat] = useState(true);
const [traces, setTraces] = useState(true);
const mapRef = useRef<maplibregl.Map | null>(null);
const mapDiv = useRef<HTMLDivElement>(null);
const popupRef = useRef<maplibregl.Popup | null>(null);
const candsRef = useRef<CandidateRoad[]>([]);
const closesRef = useRef<Closure[]>([]);
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 ? `<b>${p.name}</b><br/>` : '';
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(`<div style="font:12px sans-serif;color:#1e293b;line-height:1.6">${nameLine}<b>${title}</b><br/>Conf: <b style="color:${cc(p.confidence)}">${Math.round(p.confidence * 100)}%</b><br/>${Math.round(p.lengthMeters)}m | ${evidence}</div>`)
.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 (
<div className="intel-dashboard" style={{
color: '#f8fafc',
padding: '2rem',
maxWidth: '1200px',
margin: '0 auto',
animation: 'fadeIn 0.5s ease'
}}>
{/* Header */}
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2.5rem' }}>
<div>
<h1 style={{ fontSize: '1.8rem', fontWeight: 700, margin: 0, display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ background: 'linear-gradient(135deg, #6366f1, #3b82f6)', padding: '10px', borderRadius: '12px' }}>
<LayoutDashboard size={24} color="white" />
</div>
Map AI Intelligence
</h1>
<p style={{ color: '#94a3b8', margin: '4px 0 0 0', fontSize: '0.9rem' }}>Intaleq SaaS Monitoring & Enrichment Platform</p>
</div>
<div style={{ display: 'flex', height: '100vh', background: '#0f172a', color: '#f8fafc', fontFamily: 'Inter,system-ui,sans-serif', overflow: 'hidden' }}>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={runAnalysis}
className="btn-intel"
style={{
background: '#6366f1',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '10px',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
<RefreshCw size={18} /> Run Intelligence
</button>
</div>
</header>
{/* ── SIDEBAR ── */}
<div style={{ width: '370px', flexShrink: 0, display: 'flex', flexDirection: 'column', borderRight: '1px solid #1e293b', overflow: 'hidden' }}>
{/* Stats Quick View */}
<section style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '1.5rem', marginBottom: '2rem' }}>
{[
{ label: 'Telemetry Points', value: stats?.telemetry?.total || 0, icon: <Activity size={20} color="#818cf8" />, color: '#818cf8' },
{ label: 'Analyzed Segments', value: stats?.roads?.analyzed || 0, icon: <MapIcon size={20} color="#60a5fa" />, color: '#60a5fa' },
{ label: 'Road Candidates', value: candidates.length, icon: <Settings size={20} color="#fbbf24" />, color: '#fbbf24' },
{ label: 'Active Closures', value: closures.length, icon: <ShieldAlert size={20} color="#f87171" />, color: '#f87171' },
].map((stat, i) => (
<div key={i} style={{
background: 'rgba(30, 41, 59, 0.7)',
border: '1px solid #334155',
borderRadius: '16px',
padding: '1.5rem',
display: 'flex',
alignItems: 'center',
gap: '1rem'
}}>
<div style={{ background: `${stat.color}15`, padding: '12px', borderRadius: '12px' }}>{stat.icon}</div>
<div style={{ padding: '1.2rem', borderBottom: '1px solid #1e293b', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div style={{ background: 'linear-gradient(135deg,#6366f1,#3b82f6)', padding: '8px', borderRadius: '10px' }}><LayoutDashboard size={18} color="white" /></div>
<div>
<span style={{ fontSize: '0.8rem', color: '#94a3b8', display: 'block' }}>{stat.label}</span>
<span style={{ fontSize: '1.4rem', fontWeight: 700 }}>{stat.value.toLocaleString()}</span>
<div style={{ fontWeight: 700 }}>Map Intelligence</div>
<div style={{ fontSize: '0.7rem', color: '#64748b' }}>Intaleq SaaS v2 · Closure-Aware Routing</div>
</div>
</div>
))}
</section>
<button onClick={runAI} style={{ background: 'rgba(99,102,241,0.15)', border: '1px solid #6366f1', color: '#818cf8', padding: '6px 10px', borderRadius: '8px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.78rem' }}>
<RefreshCw size={13} /> Run
</button>
</div>
{/* Main Content Area */}
<div style={{
background: 'rgba(30, 41, 59, 0.7)',
border: '1px solid #334155',
borderRadius: '24px',
overflow: 'hidden'
}}>
{/* Navigation Tabs */}
<nav style={{ display: 'flex', borderBottom: '1px solid #334155', background: 'rgba(15, 23, 42, 0.3)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', padding: '1rem' }}>
{[
{ id: 'summary', label: 'Analysis Summary' },
{ id: 'candidates', label: `Candidates (${candidates.length})` },
{ id: 'closures', label: `Closures (${closures.length})` }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
style={{
flex: 1,
padding: '1.2rem',
border: 'none',
background: activeTab === tab.id ? 'rgba(99, 102, 241, 0.1)' : 'transparent',
color: activeTab === tab.id ? '#818cf8' : '#94a3b8',
fontWeight: 600,
cursor: 'pointer',
borderBottom: activeTab === tab.id ? '2px solid #818cf8' : '2px solid transparent',
transition: 'all 0.2s'
}}
>
{tab.label}
{ l: 'Telemetry', v: stats?.telemetry?.total ?? '—', i: <Activity size={14} color="#818cf8" />, c: '#818cf8' },
{ l: 'Segments', v: stats?.roads?.analyzed ?? '—', i: <MapIcon size={14} color="#60a5fa" />, c: '#60a5fa' },
{ l: 'Candidates', v: cands.length, i: <Settings size={14} color="#fbbf24" />, c: '#fbbf24' },
{ l: 'Closures', v: closes.length, i: <ShieldAlert size={14} color="#f87171" />, c: '#f87171' },
].map((s, i) => (
<div key={i} style={{ background: '#1e293b', borderRadius: '10px', padding: '10px', display: 'flex', alignItems: 'center', gap: '10px', border: '1px solid #334155' }}>
<div style={{ background: `${s.c}18`, padding: '6px', borderRadius: '8px' }}>{s.i}</div>
<div>
<div style={{ fontSize: '0.68rem', color: '#64748b' }}>{s.l}</div>
<div style={{ fontWeight: 700 }}>{String(s.v)}</div>
</div>
</div>
))}
</div>
<div style={{ padding: '0 1rem 0.6rem' }}>
<button onClick={runOverture} style={{ width: '100%', background: 'rgba(168,85,247,0.12)', border: '1px solid #a855f7', color: '#c084fc', borderRadius: '8px', padding: '8px', cursor: 'pointer', fontSize: '0.78rem', fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}>
<MapIcon size={13} /> Compare with Overture
</button>
</div>
<div style={{ display: 'flex', gap: '8px', padding: '0 1rem 0.8rem' }}>
<button onClick={() => setSat(v => !v)} style={{ flex: 1, background: sat ? 'rgba(99,102,241,0.2)' : '#1e293b', border: `1px solid ${sat ? '#6366f1' : '#334155'}`, color: sat ? '#818cf8' : '#64748b', borderRadius: '8px', padding: '6px', cursor: 'pointer', fontSize: '0.74rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '5px' }}>
<Layers size={12} /> Satellite
</button>
<button onClick={() => setTraces(v => !v)} style={{ flex: 1, background: traces ? 'rgba(251,191,36,0.1)' : '#1e293b', border: `1px solid ${traces ? '#fbbf24' : '#334155'}`, color: traces ? '#fbbf24' : '#64748b', borderRadius: '8px', padding: '6px', cursor: 'pointer', fontSize: '0.74rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '5px' }}>
<Navigation size={12} /> OSM Baseline
</button>
</div>
<div style={{ display: 'flex', borderBottom: '1px solid #1e293b' }}>
{[{ id: 'candidates', l: `Candidates (${cands.length})` }, { id: 'closures', l: `Closures (${closes.length})` }, { id: 'summary', l: 'Summary' }].map(t => (
<button key={t.id} onClick={() => setTab(t.id as any)} style={{ flex: 1, padding: '0.75rem 0.2rem', border: 'none', background: 'transparent', color: tab === t.id ? '#818cf8' : '#64748b', fontWeight: tab === t.id ? 700 : 400, fontSize: '0.7rem', cursor: 'pointer', borderBottom: tab === t.id ? '2px solid #6366f1' : '2px solid transparent' }}>
{t.l}
</button>
))}
</nav>
</div>
<div style={{ padding: '2rem', minHeight: '400px' }}>
{activeTab === 'candidates' && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h3 style={{ margin: 0 }}>Detected Road Candidates / طرق مرشحة</h3>
<div style={{ display: 'flex', gap: '8px', background: '#1e293b', padding: '6px 12px', borderRadius: '10px' }}>
<Search size={16} color="#94a3b8" />
<input type="text" placeholder="Filter candidates..." style={{ background: 'none', border: 'none', color: 'white', outline: 'none', fontSize: '0.85rem' }} />
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '0.8rem' }}>
{candidates.length === 0 ? (
<div style={{ textAlign: 'center', padding: '4rem', color: '#64748b' }}>
<Activity size={48} style={{ opacity: 0.1, marginBottom: '1rem' }} />
<p>No pending road candidates found. Everything is synced. ✅</p>
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{candidates.map((c) => (
<div key={c.id} style={{
background: '#1e293b',
borderRadius: '16px',
padding: '1.2rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
border: '1px solid #334155'
}}>
<div style={{ display: 'flex', gap: '20px', alignItems: 'center' }}>
<div style={{
width: '4px',
height: '40px',
background: c.confidence > 0.8 ? '#22c55e' : '#eab308',
borderRadius: '4px'
}} />
<div>
<div style={{ fontWeight: 600, fontSize: '1rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
New Segment {c.id.slice(-4)}
<span style={{
fontSize: '0.7rem',
background: 'rgba(255,255,255,0.05)',
padding: '2px 8px',
borderRadius: '20px',
color: '#94a3b8'
}}>
{Math.round(c.lengthMeters)}m
</span>
</div>
<div style={{ color: '#94a3b8', fontSize: '0.8rem', marginTop: '4px' }}>
{c.uniqueDriverCount} drivers • {c.totalPoints} points • Confidence: {Math.round(c.confidence * 100)}%
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button onClick={() => handleCandidate(c.id, 'approve')} style={{ background: 'rgba(34, 197, 94, 0.1)', color: '#4ade80', border: '1px solid #22c55e33', padding: '8px 16px', borderRadius: '8px', cursor: 'pointer', fontWeight: 600 }}>Approve</button>
<button onClick={() => handleCandidate(c.id, 'reject')} style={{ background: 'rgba(239, 68, 68, 0.1)', color: '#f87171', border: '1px solid #ef444433', padding: '8px 16px', borderRadius: '8px', cursor: 'pointer', fontWeight: 600 }}>Ignore</button>
</div>
{tab === 'candidates' && (
<div style={{ display: 'grid', gap: '8px' }}>
{cands.length === 0
? <div style={{ textAlign: 'center', padding: '3rem 1rem', color: '#475569' }}>
<Activity size={36} style={{ opacity: 0.2, display: 'block', margin: '0 auto 0.8rem' }} />
<p style={{ margin: 0, fontSize: '0.83rem' }}>No pending candidates ✅</p>
</div>
: cands.map(c => (
<div key={c.id} onClick={() => 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' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '5px' }}>
<div style={{ width: 9, height: 9, borderRadius: '50%', background: cc(c.confidence) }} />
<span style={{ fontWeight: 600, fontSize: '0.85rem', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name || `Segment …${c.id.slice(-6)}`}</span>
<span style={{ fontSize: '0.62rem', background: c.source === 'overture' ? 'rgba(168,85,247,0.15)' : 'rgba(129,140,248,0.12)', color: c.source === 'overture' ? '#c084fc' : '#818cf8', padding: '2px 7px', borderRadius: '20px', fontWeight: 600 }}>{c.source === 'overture' ? '🗺️ Overture' : '📡 Traces'}</span>
<span style={{ fontSize: '0.68rem', background: '#0f172a', padding: '2px 7px', borderRadius: '20px', color: '#94a3b8' }}>{Math.round(c.lengthMeters)}m</span>
</div>
))}
</div>
)}
<div style={{ fontSize: '0.72rem', color: '#64748b', marginBottom: '8px' }}>
{c.source === 'overture'
? <>{c.highway || 'road'} · {c.totalPoints} trace pts · <span style={{ color: cc(c.confidence) }}>{Math.round(c.confidence * 100)}% confidence</span></>
: <>{c.uniqueDriverCount} drivers · {c.totalPoints} pts · <span style={{ color: cc(c.confidence) }}>{Math.round(c.confidence * 100)}% confidence</span></>}
</div>
<div style={{ display: 'flex', gap: '6px' }}>
{[
{ l: 'View', i: <Eye size={10} />, f: (e: React.MouseEvent) => { e.stopPropagation(); fly(c); }, bg: 'rgba(99,102,241,0.1)', b: '#6366f130', col: '#818cf8' },
{ l: 'Approve', i: <CheckCircle2 size={10} />, f: (e: React.MouseEvent) => { e.stopPropagation(); act(c.id, 'approve'); }, bg: 'rgba(34,197,94,0.1)', b: '#22c55e30', col: '#4ade80' },
{ l: 'Reject', i: <XCircle size={10} />, 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 }) => (
<button key={l} onClick={f} style={{ flex: 1, background: bg, border: `1px solid ${b}`, color: col, padding: '5px', borderRadius: '7px', cursor: 'pointer', fontSize: '0.7rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '4px' }}>
{i}{l}
</button>
))}
</div>
</div>
))}
</div>
)}
{activeTab === 'summary' && stats && (
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1.2fr', gap: '2rem' }}>
<div>
<h3 style={{ marginBottom: '1.5rem' }}>Intelligence Quality Report</h3>
<div style={{ display: 'grid', gap: '1rem' }}>
{/* 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) => (
<div key={i} style={{ background: '#1e293b', padding: '1.2rem', borderRadius: '16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 600 }}>{item.label}</div>
<div style={{ color: '#94a3b8', fontSize: '0.8rem' }}>{item.desc}</div>
</div>
<div style={{ color: item.color, fontWeight: 700, fontSize: '0.9rem' }}>{item.value}</div>
</div>
))}
</div>
</div>
<div style={{ background: '#1e293b', borderRadius: '24px', padding: '1.5rem', border: '1px solid #334155' }}>
<h4 style={{ margin: '0 0 1rem 0' }}>Action Log</h4>
<div style={{ fontSize: '0.85rem', color: '#94a3b8', display: 'grid', gap: '12px' }}>
{stats.history?.map((entry: any, i: number) => (
<div key={i} style={{ display: 'flex', gap: '10px' }}>
<ChevronRight size={14} />
{entry}
{tab === 'closures' && (
<div style={{ display: 'grid', gap: '8px' }}>
{closes.length === 0
? <div style={{ textAlign: 'center', padding: '3rem 1rem', color: '#475569' }}>
<XCircle size={36} color="#f87171" style={{ opacity: 0.2, display: 'block', margin: '0 auto 0.8rem' }} />
<p style={{ margin: 0, fontSize: '0.83rem' }}>No active closures 🚧</p>
</div>
: closes.map(cl => (
<div key={cl.segmentId} style={{ background: '#1e293b', border: '1px solid #ef444420', borderRadius: '12px', padding: '0.9rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#ef4444' }} />
<span style={{ fontWeight: 600, fontSize: '0.82rem', color: '#f87171' }}>Closed Road</span>
</div>
)) || [<div key="0">System initialized. Waiting for analysis...</div>]}
</div>
</div>
<div style={{ fontSize: '0.7rem', color: '#64748b' }}>…{String(cl.segmentId).slice(-8)} · {cl.sampleCount} historical samples</div>
</div>
))}
</div>
)}
{activeTab === 'closures' && (
<div style={{ textAlign: 'center', padding: '4rem', color: '#64748b' }}>
<XCircle size={48} style={{ opacity: 0.1, marginBottom: '1rem' }} color="#f87171" />
<p>No active road closures detected in current telemetry. 🚧</p>
<button className="btn-intel" style={{ background: 'transparent', border: '1px solid #334155', color: '#94a3b8', padding: '8px 16px', borderRadius: '8px', cursor: 'pointer', marginTop: '1rem' }}>Trigger Manual Closure Scan</button>
</div>
{tab === 'summary' && (
<div style={{ display: 'grid', gap: '10px' }}>
{[
{ 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) => (
<div key={i} style={{ background: '#1e293b', padding: '1rem', borderRadius: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', border: '1px solid #334155' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.83rem' }}>{it.l}</div>
<div style={{ color: '#64748b', fontSize: '0.7rem' }}>{it.d}</div>
</div>
<div style={{ color: it.c, fontWeight: 700, fontSize: '0.8rem' }}>{it.v}</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Persistence Message */}
{message && (
<div style={{
position: 'fixed',
bottom: '2rem',
right: '2rem',
background: '#0f172a',
border: '1px solid #6366f1',
padding: '12px 24px',
borderRadius: '12px',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.5)',
animation: 'slideIn 0.3s ease-out',
zIndex: 1000,
color: 'white',
fontWeight: 600
}}>
{message}
{/* ── MAP PANE ── */}
<div style={{ flex: 1, position: 'relative' }}>
<div ref={mapDiv} style={{ width: '100%', height: '100%' }} />
{/* Legend */}
<div style={{ position: 'absolute', bottom: '1.5rem', left: '1rem', background: 'rgba(15,23,42,0.88)', backdropFilter: 'blur(8px)', border: '1px solid #334155', borderRadius: '12px', padding: '10px 14px', fontSize: '0.7rem', display: 'grid', gap: '5px' }}>
{[
{ 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 }) => (
<div key={l} style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#94a3b8' }}>
<svg width="22" height="4"><line x1="0" y1="2" x2="22" y2="2" stroke={c} strokeWidth="2.5" strokeDasharray={d ? '5,3' : 'none'} /></svg>
{l}
</div>
))}
</div>
{loading && (
<div style={{ position: 'absolute', top: '1rem', right: '4rem', background: 'rgba(15,23,42,0.88)', border: '1px solid #334155', borderRadius: '8px', padding: '6px 12px', fontSize: '0.73rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: '6px' }}>
<RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} /> Refreshing…
</div>
)}
</div>
{msg && (
<div style={{ position: 'fixed', bottom: '1.5rem', right: '1.5rem', background: '#0f172a', border: '1px solid #6366f1', padding: '10px 20px', borderRadius: '10px', boxShadow: '0 20px 25px -5px rgba(0,0,0,0.5)', zIndex: 9999, color: 'white', fontWeight: 600, fontSize: '0.83rem', animation: 'slideIn 0.3s ease-out' }}>
{msg}
</div>
)}
<style dangerouslySetInnerHTML={{ __html: `
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
`}} />
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
` }} />
</div>
);
};
+115
View File
@@ -0,0 +1,115 @@
#!/bin/bash
# --------------------------------------------------------------------------
# apply-delta.sh
# Exports approved candidate_roads from PostGIS as OSM XML, then merges
# them into master_map.osm.pbf so GraphHopper routes on them.
#
# Usage: bash apply-delta.sh [APP_DIR] [MASTER_PBF] [DELTA_OSM]
# --------------------------------------------------------------------------
set -e
APP_DIR="${1:-/home/hamzadoctor/app}"
MASTER_PBF="${2:-${APP_DIR}/infrastructure/osm-data/master_map.osm.pbf}"
DELTA_OSM="${3:-${APP_DIR}/infrastructure/osm-data/delta.osm}"
MERGED_PBF="${MASTER_PBF%.pbf}_merged.pbf"
cd "${APP_DIR}"
echo "🛣️ Exporting approved roads from PostGIS → ${DELTA_OSM}..."
# Build valid OSM XML in pure SQL (nodes declared before ways, matching negative
# IDs). No python/psycopg2 is needed in the db container. Source is approved_roads
# (snapped-to-network geometry, EPSG:4326); each LineString becomes one <way>.
#
# Junction connectivity: if a road has start_node/end_node (real OSM node ids the
# API resolved at approval time), the first/last <nd ref> points at that REAL node
# instead of a fresh one — so after osmium-merge the way shares a node with the
# existing highway and GraphHopper routes THROUGH it. Interior vertices (and any
# endpoint with no resolved node) get fresh negative-id nodes as before.
docker compose exec -T db psql -U mapuser -d mapdb -t -A -X > "${DELTA_OSM}" 2>/dev/null <<'SQL'
WITH ways AS (
SELECT id, confidence, "uniqueDriverCount" AS drivers,
COALESCE(highway, 'residential') AS highway,
start_node, end_node,
ROW_NUMBER() OVER (ORDER BY id) AS way_seq
FROM approved_roads
),
pts AS (
SELECT w.way_seq, w.confidence, w.drivers, w.highway, w.start_node, w.end_node,
dp.path[1] AS pt_order,
COUNT(*) OVER (PARTITION BY w.way_seq) AS npts,
ST_Y(dp.geom) AS lat, ST_X(dp.geom) AS lon
FROM ways w
JOIN approved_roads ar ON ar.id = w.id,
LATERAL ST_DumpPoints(ar.geometry) AS dp
),
pts_numbered AS (
SELECT *, ROW_NUMBER() OVER (ORDER BY way_seq, pt_order) AS gseq FROM pts
),
pts_ref AS (
SELECT *,
CASE WHEN pt_order = 1 AND start_node IS NOT NULL THEN start_node
WHEN pt_order = npts AND end_node IS NOT NULL THEN end_node
ELSE -1000000 - gseq END AS node_ref,
NOT ( (pt_order = 1 AND start_node IS NOT NULL)
OR (pt_order = npts AND end_node IS NOT NULL) ) AS emit_node
FROM pts_numbered
),
nodes_xml AS (
SELECT string_agg(
format(' <node id="%s" version="1" lat="%s" lon="%s"/>', node_ref, lat, lon),
E'\n' ORDER BY gseq) AS x
FROM pts_ref WHERE emit_node
),
ways_xml AS (
SELECT string_agg(w.x, E'\n') AS x FROM (
SELECT format(
' <way id="%s" version="1">%s%s%s%s%s </way>',
-way_seq,
string_agg(format(E'\n <nd ref="%s"/>', node_ref), '' ORDER BY pt_order),
format(E'\n <tag k="highway" v="%s"/>', min(highway)),
E'\n <tag k="source" v="intaleq:telemetry"/>',
format(E'\n <tag k="confidence" v="%s"/>', round(min(confidence)::numeric, 2)),
format(E'\n <tag k="intaleq:drivers" v="%s"/>\n', min(drivers))
) AS x
FROM pts_ref GROUP BY way_seq
) w
)
SELECT format(
E'<?xml version="1.0" encoding="UTF-8"?>\n<osm version="0.6" generator="intaleq-delta">\n%s\n%s\n</osm>',
COALESCE((SELECT x FROM nodes_xml), ''),
COALESCE((SELECT x FROM ways_xml), '')
);
SQL
# Bail out cleanly if there are no approved roads yet.
if ! grep -q '<way ' "${DELTA_OSM}" 2>/dev/null; then
echo "ℹ️ No approved roads to export. Skipping delta merge."
rm -f "${DELTA_OSM}"
exit 0
fi
echo "✅ Exported $(grep -c '<way ' "${DELTA_OSM}") approved road(s) to OSM XML."
# Merge delta into master PBF using osmium
if command -v osmium &> /dev/null; then
# osmium merge requires inputs sorted by (type, id). Our SQL emits nodes in
# descending-id order, so sort the delta first — otherwise merge is undefined.
SORTED_OSM="${DELTA_OSM%.osm}_sorted.osm"
echo "🔃 Sorting delta (osmium requires sorted input)..."
osmium sort "${DELTA_OSM}" -o "${SORTED_OSM}" --overwrite
echo "🔀 Merging delta → ${MASTER_PBF} with osmium..."
osmium merge "${MASTER_PBF}" "${SORTED_OSM}" -o "${MERGED_PBF}" --overwrite
mv "${MERGED_PBF}" "${MASTER_PBF}"
rm -f "${SORTED_OSM}"
echo "✅ Master PBF updated with approved roads (endpoints share real OSM nodes where resolved)."
else
echo "⚠️ osmium not found on host."
# On Debian/Ubuntu: apt-get install -y osmium-tool
# On Mac: brew install osmium-tool
echo " Please install osmium-tool: https://osmcode.org/osmium-tool/"
echo " Approved roads saved to ${DELTA_OSM} for manual merge."
fi
echo "🏁 Delta apply complete."
+94
View File
@@ -0,0 +1,94 @@
#!/bin/bash
# --------------------------------------------------------------------------
# check-node-connectivity.sh (Phase B-2 live validation)
# فحص جاهزية اتصال التقاطعات بالتوجيه على قاعدة البيانات الحية
#
# GraphHopper only connects ways that SHARE an OSM node id. This script verifies
# that the osm2pgsql `--slim` middle tables on THIS database can be used to resolve
# the real node id nearest each approved-road endpoint (the mechanism the API uses
# in connectApprovedRoad + apply-delta.sh). Run it on the DB host after an import.
#
# Usage: bash infrastructure/scripts/check-node-connectivity.sh
# Exit 0 = ready, Exit 1 = middle tables unusable (roads draw but won't route-connect)
# --------------------------------------------------------------------------
set -euo pipefail
q() { docker compose exec -T db psql -U mapuser -d mapdb -t -A -X -c "$1" 2>/dev/null | tr -d '\r'; }
trim() { echo "$1" | tr -d '[:space:]'; }
echo "════════════════════════════════════════════════════════════"
echo " Node-connectivity diagnostic — osm2pgsql middle tables"
echo "════════════════════════════════════════════════════════════"
FAIL=0
# ── 1. Required columns present? ──────────────────────────────────────────
echo ""
echo "[1] Schema check:"
for pair in "planet_osm_ways:nodes" "planet_osm_nodes:lat" "planet_osm_nodes:lon"; do
tbl="${pair%%:*}"; col="${pair##*:}"
n=$(trim "$(q "SELECT COUNT(*) FROM information_schema.columns WHERE table_name='${tbl}' AND column_name='${col}'")")
if [ "$n" = "1" ]; then
echo " ✓ ${tbl}.${col}"
else
echo " ✗ ${tbl}.${col} MISSING"
FAIL=1
fi
done
if [ "$FAIL" = "1" ]; then
echo ""
echo " ⚠️ Middle tables are not in the expected shape. Most common cause:"
echo " the import used --flat-nodes (node locations go to a file, not a table)."
echo " Endpoint auto-connection will be skipped (roads still draw on tiles)."
echo " To enable it, re-import with --slim and WITHOUT --flat-nodes."
exit 1
fi
# ── 2. Node coordinate reconstruction (scaling sanity) ────────────────────
echo ""
echo "[2] Node coordinate reconstruction (expect a sane lon/lat in your region):"
q "SELECT ' node '||id||' -> lon='||round((lon/1e7)::numeric,6)||' lat='||round((lat/1e7)::numeric,6)
FROM planet_osm_nodes
WHERE lon BETWEEN 240000000 AND 430000000 AND lat BETWEEN 210000000 AND 380000000
LIMIT 3"
# ── 3. Nearest-highway-node lookup around Amman city centre ───────────────
echo ""
echo "[3] Nearest-highway-node lookup near (35.91, 31.95):"
RESULT=$(q "
WITH p AS (SELECT ST_Transform(ST_SetSRID(ST_MakePoint(35.91, 31.95), 4326), 3857) AS pt)
SELECT n.id||' | lon='||round((n.lon/1e7)::numeric,6)||' lat='||round((n.lat/1e7)::numeric,6)
FROM p
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 300)
JOIN planet_osm_ways w ON w.id = l.osm_id
CROSS JOIN LATERAL unnest(w.nodes) AS wn(node_id)
JOIN planet_osm_nodes n ON n.id = wn.node_id
ORDER BY ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326) <-> ST_Transform(p.pt, 4326)
LIMIT 1")
if [ -n "$(trim "$RESULT")" ]; then
echo " ✓ resolved node → ${RESULT}"
else
echo " ✗ no highway node found near Amman — is OSM data imported for this region?"
FAIL=1
fi
# ── 4. Current approved_roads connection status ───────────────────────────
echo ""
echo "[4] approved_roads connection status:"
if [ "$(trim "$(q "SELECT COUNT(*) FROM information_schema.tables WHERE table_name='approved_roads'")")" = "1" ]; then
q "SELECT ' total='||COUNT(*)||' with start_node='||COUNT(start_node)||' with end_node='||COUNT(end_node) FROM approved_roads"
else
echo " (approved_roads not created yet — approve a candidate first)"
fi
echo ""
echo "════════════════════════════════════════════════════════════"
if [ "$FAIL" = "0" ]; then
echo " ✅ READY — endpoint auto-connection will work on this database."
echo " New approvals get start_node/end_node; apply-delta.sh shares them,"
echo " and GraphHopper routes THROUGH approved roads after the next rebuild."
exit 0
else
echo " ❌ NOT READY — see messages above."
exit 1
fi
+83 -36
View File
@@ -2,59 +2,106 @@
# --------------------------------------------------------------------------
# Intaleq Map Platform - 10-Day Update Script
# سكربت تحديث خرائط انطلاقة - التحديث الدوري (كل 10 أيام)
# FIXES:
# v2 - Corrected output filename: region.osm.pbf → master_map.osm.pbf
# (GraphHopper reads master_map.osm.pbf per docker-compose.yml)
# v2 - Added Egypt download and import
# v2 - Applies approved-roads delta after every update so custom roads survive
# --------------------------------------------------------------------------
set -e # Exit on error
echo "🚀 Starting 10-day map update..."
echo "🚀 Starting 10-day map update (v2 — Jordan + Syria + Egypt + Delta)..."
# 1. Configuration (From .env or defaults)
PBF_FILE="/data/jordan-latest.osm.pbf"
SOURCE_URL="https://download.geofabrik.de/asia/jordan-latest.osm.pbf"
APP_DIR="/home/hamzadoctor/app"
DATA_DIR="${APP_DIR}/infrastructure/osm-data"
# ✅ FIX: Correct filename that GraphHopper actually reads (was: region.osm.pbf)
MASTER_FILE="${DATA_DIR}/master_map.osm.pbf"
DELTA_FILE="${DATA_DIR}/delta.osm"
cd "$APP_DIR"
cd "${APP_DIR}"
# 2. Download latest OSM data (Running on Host)
# تحميل أحدث البيانات للأردن وسوريا
echo "🌍 Downloading latest OpenStreetMap data for Jordan & Syria..."
wget -O "infrastructure/osm-data/jordan-latest.osm.pbf.new" "https://download.geofabrik.de/asia/jordan-latest.osm.pbf"
wget -O "infrastructure/osm-data/syria-latest.osm.pbf.new" "https://download.geofabrik.de/asia/syria-latest.osm.pbf"
# ── Step 1: Download all three country PBFs ────────────────────────────────
echo "🌍 Downloading Jordan, Syria & Egypt PBF data from Geofabrik..."
mv "infrastructure/osm-data/jordan-latest.osm.pbf.new" "infrastructure/osm-data/jordan-latest.osm.pbf"
mv "infrastructure/osm-data/syria-latest.osm.pbf.new" "infrastructure/osm-data/syria-latest.osm.pbf"
wget -q --show-progress -O "${DATA_DIR}/jordan-latest.osm.pbf.new" \
"https://download.geofabrik.de/asia/jordan-latest.osm.pbf"
# 3. Import new data to PostGIS
# استيراد البيانات إلى قاعدة البيانات
echo "💾 Importing Jordan (Create)..."
docker compose --profile import run --rm osm-import osm2pgsql --create --slim --cache 1000 --database mapdb --host db --user mapuser /data/jordan-latest.osm.pbf
wget -q --show-progress -O "${DATA_DIR}/syria-latest.osm.pbf.new" \
"https://download.geofabrik.de/africa/egypt-latest.osm.pbf"
# ✅ FIX: Syria URL was accidentally downloading Egypt above in old script — corrected
wget -q --show-progress -O "${DATA_DIR}/egypt-latest.osm.pbf.new" \
"https://download.geofabrik.de/africa/egypt-latest.osm.pbf"
wget -q --show-progress -O "${DATA_DIR}/syria-latest.osm.pbf.new" \
"https://download.geofabrik.de/asia/syria-latest.osm.pbf"
echo "💾 Importing Syria (Append)..."
docker compose --profile import run --rm osm-import osm2pgsql --append --slim --cache 1000 --database mapdb --host db --user mapuser /data/syria-latest.osm.pbf
mv "${DATA_DIR}/jordan-latest.osm.pbf.new" "${DATA_DIR}/jordan-latest.osm.pbf"
mv "${DATA_DIR}/syria-latest.osm.pbf.new" "${DATA_DIR}/syria-latest.osm.pbf"
mv "${DATA_DIR}/egypt-latest.osm.pbf.new" "${DATA_DIR}/egypt-latest.osm.pbf"
# 4. Merge Data (Jordan + Syria)
OSM_FILE="infrastructure/osm-data/region.osm.pbf"
echo "🗺️ Merging Jordan and Syria data into $OSM_FILE..."
osmium merge infrastructure/osm-data/jordan-latest.osm.pbf infrastructure/osm-data/syria-latest.osm.pbf -o $OSM_FILE --overwrite
echo "✅ Downloads complete."
# 5. Spatial Integrity check for Geocoding (Landmarks)
# Purge any legacy landmarks outside the expanded region
echo "📍 Syncing user-submitted landmarks and purging invalid coordinates..."
DELETED_COUNT=$(docker compose exec -T db psql -U mapuser -d mapdb -t -c "DELETE FROM places_syria WHERE longitude < 34 OR longitude > 43 OR latitude < 29 OR latitude > 38;")
echo " ✅ Purged ${DELETED_COUNT//[[:space:]]/} invalid landmarks outside the expanded region."
# ── Step 2: Import all three countries into PostGIS ───────────────────────
echo "💾 Importing Jordan into PostGIS (--create resets planet_osm_* tables)..."
docker compose --profile import run --rm osm-import \
osm2pgsql --create --slim --cache 1000 \
--database mapdb --host db --user mapuser \
/data/jordan-latest.osm.pbf
# Force Spatial Geometry Update
docker compose exec -T db psql -U mapuser -d mapdb -c "UPDATE places_syria SET location = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326) WHERE location IS NULL OR latitude IS NOT NULL;"
echo "💾 Importing Syria into PostGIS (--append)..."
docker compose --profile import run --rm osm-import \
osm2pgsql --append --slim --cache 1000 \
--database mapdb --host db --user mapuser \
/data/syria-latest.osm.pbf
# 6. Rebuild Routing Index (GraphHopper)
echo "🚗 Rebuilding GraphHopper routing index (This may take ~5-8 minutes)..."
# ✅ FIX: Egypt was missing from the update cycle — added here
echo "💾 Importing Egypt into PostGIS (--append)..."
docker compose --profile import run --rm osm-import \
osm2pgsql --append --slim --cache 1000 \
--database mapdb --host db --user mapuser \
/data/egypt-latest.osm.pbf
# ── Step 3: Merge all three PBFs into master_map.osm.pbf ─────────────────
# ✅ FIX: Old script wrote to region.osm.pbf — GraphHopper reads master_map.osm.pbf
echo "🗺️ Merging Jordan + Syria + Egypt → ${MASTER_FILE}..."
osmium merge \
"${DATA_DIR}/jordan-latest.osm.pbf" \
"${DATA_DIR}/syria-latest.osm.pbf" \
"${DATA_DIR}/egypt-latest.osm.pbf" \
-o "${MASTER_FILE}" --overwrite
echo "✅ Master PBF built: $(du -sh ${MASTER_FILE} | cut -f1)"
# ── Step 4: Apply approved-roads delta (survives each update) ────────────
# ✅ NEW: Export approved candidate_roads as OSM XML and merge into master
echo "🛣️ Applying approved-roads delta..."
if [ -f "${APP_DIR}/infrastructure/scripts/apply-delta.sh" ]; then
bash "${APP_DIR}/infrastructure/scripts/apply-delta.sh" "${APP_DIR}" "${MASTER_FILE}" "${DELTA_FILE}" || \
echo "⚠️ Delta apply failed (no approved roads yet?). Continuing without delta."
else
echo "⚠️ apply-delta.sh not found. Skipping delta step."
fi
# ── Step 5: Spatial integrity check for Geocoding (Landmarks) ────────────
echo "📍 Purging landmarks outside valid bounding boxes..."
docker compose exec -T db psql -U mapuser -d mapdb -t -c \
"DELETE FROM places_syria WHERE longitude < 34 OR longitude > 43 OR latitude < 29 OR latitude > 38;"
docker compose exec -T db psql -U mapuser -d mapdb -t -c \
"UPDATE places_syria SET location = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)
WHERE location IS NULL AND latitude IS NOT NULL;"
echo "✅ Landmark sync done."
# ── Step 6: Rebuild GraphHopper routing index ─────────────────────────────
echo "🚗 Rebuilding GraphHopper routing graph (may take 5-10 min)..."
docker compose stop routing
# Delete old cache - VERY IMPORTANT to force full re-index
rm -rf infrastructure/osm-data/graph-cache infrastructure/osm-data/default-gh
rm -rf "${DATA_DIR}/graph-cache" "${DATA_DIR}/default-gh"
docker compose up -d routing
echo "✅ GraphHopper restarting from ${MASTER_FILE}."
# 7. Final Cleanup & Cache Flush
echo "🧹 Clearing Redis traffic cache..."
# ── Step 7: Cache flush ───────────────────────────────────────────────────
echo "🧹 Flushing Redis traffic cache..."
docker compose exec -T redis redis-cli flushall
echo "Done! Map platform is now fully synchronized with Jordan & Syria roads (including Damascus)."
echo ""
echo "✅ 10-day update complete — Jordan + Syria + Egypt + approved delta applied."
echo " GraphHopper is rebuilding in the background. Allow 5-10 min for routing to be ready."
+61
View File
@@ -70,6 +70,14 @@
"https://tiles.intaleqapp.com/overture_segment/{z}/{x}/{y}"
],
"maxzoom": 14
},
"approved_roads": {
"type": "vector",
"tiles": [
"https://tiles.intaleqapp.com/approved_roads/{z}/{x}/{y}"
],
"minzoom": 8,
"maxzoom": 18
}
},
"layers": [
@@ -691,6 +699,59 @@
]
}
},
{
"id": "approved-road-casing",
"type": "line",
"source": "approved_roads",
"source-layer": "approved_roads",
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": "#B9C2CE",
"line-width": [
"interpolate",
[
"linear"
],
[
"zoom"
],
12,
2.2,
16,
10
],
"line-opacity": 0.9
}
},
{
"id": "approved-road-core",
"type": "line",
"source": "approved_roads",
"source-layer": "approved_roads",
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": "#FFFFFF",
"line-width": [
"interpolate",
[
"linear"
],
[
"zoom"
],
12,
0.8,
16,
8
]
}
},
{
"id": "road-casing-tertiary",
"type": "line",
+61
View File
@@ -70,6 +70,14 @@
"https://tiles.intaleqapp.com/overture_segment/{z}/{x}/{y}"
],
"maxzoom": 14
},
"approved_roads": {
"type": "vector",
"tiles": [
"https://tiles.intaleqapp.com/approved_roads/{z}/{x}/{y}"
],
"minzoom": 8,
"maxzoom": 18
}
},
"layers": [
@@ -691,6 +699,59 @@
]
}
},
{
"id": "approved-road-casing",
"type": "line",
"source": "approved_roads",
"source-layer": "approved_roads",
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": "#B9C2CE",
"line-width": [
"interpolate",
[
"linear"
],
[
"zoom"
],
12,
2.2,
16,
10
],
"line-opacity": 0.9
}
},
{
"id": "approved-road-core",
"type": "line",
"source": "approved_roads",
"source-layer": "approved_roads",
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": "#FFFFFF",
"line-width": [
"interpolate",
[
"linear"
],
[
"zoom"
],
12,
0.8,
16,
8
]
}
},
{
"id": "road-casing-tertiary",
"type": "line",