diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 574b148..4caff88 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -108,7 +108,8 @@ export class AuthService { tenant = await this.tenantRepository.save({ name, email, - isActive: true + isActive: true, + plan: TenantPlan.ENTERPRISE, }); } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index f05d0b5..0b8ce0d 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -19,9 +19,11 @@ async function bootstrap() { // Apply standard HTTP security headers app.use(helmet()); - // 1. Modern Security Headers & Permissive CORS for Production Dashboard + // 1. Strict Security Headers & Restricted CORS app.enableCors({ - origin: true, // Reflect request origin + origin: process.env.ALLOWED_ORIGINS + ? process.env.ALLOWED_ORIGINS.split(',') + : ['http://localhost:3204', 'http://localhost:5173', 'https://map-saas.intaleqapp.com'], methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', credentials: true, }); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index ca40ac7..ad60575 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,10 +1,17 @@ import React, { useEffect, useState } from 'react' import ReactDOM from 'react-dom/client' +import maplibregl from 'maplibre-gl' import App from './App.tsx' import CompareView from './pages/CompareView' import IntelligenceDashboard from './pages/IntelligenceDashboard' import './index.css' +maplibregl.setRTLTextPlugin( + 'https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.min.js', + (err) => { if (err) console.error(err); }, + true +); + // Lightweight hash router (no dependency). '#compare' and '#review' are additive // views; anything else falls back to the existing map app, untouched. const NAV = [ diff --git a/apps/web/src/pages/CompareView.tsx b/apps/web/src/pages/CompareView.tsx index fad12dd..0a15757 100644 --- a/apps/web/src/pages/CompareView.tsx +++ b/apps/web/src/pages/CompareView.tsx @@ -17,29 +17,9 @@ import 'maplibre-gl/dist/maplibre-gl.css'; 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'; +type RefKey = 'google-sat' | 'esri-sat' | 'esri-streets' | 'osm'; const REFS: Record = { + 'google-sat': { label: '🛰️ Google Satellite', tiles: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', attribution: '© Google', maxzoom: 20 }, '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 }, @@ -68,11 +48,16 @@ const CompareView: React.FC = () => { 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 lMap = new maplibregl.Map({ container: leftDiv.current, style: TILES + '/style.json', 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'); + lMap.on('load', () => { + lMap.addSource('approved', { type: 'vector', tiles: [`${TILES}/approved_roads/{z}/{x}/{y}`], minzoom: 8, maxzoom: 18 }); + lMap.addLayer({ id: 'approved', type: 'line', source: 'approved', 'source-layer': 'approved_roads', paint: { 'line-color': '#22c55e', 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1.5, 16, 8] } }); + }); + // 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; @@ -101,10 +86,6 @@ const CompareView: React.FC = () => { 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 ( @@ -123,12 +104,6 @@ const CompareView: React.FC = () => { ))} - -
- Google ↗ - Bing ↗ - OSM ↗ -
{/* Maps */} diff --git a/apps/web/src/pages/IntelligenceDashboard.tsx b/apps/web/src/pages/IntelligenceDashboard.tsx index 2f0d349..f716009 100644 --- a/apps/web/src/pages/IntelligenceDashboard.tsx +++ b/apps/web/src/pages/IntelligenceDashboard.tsx @@ -5,33 +5,73 @@ import { Activity, Map as MapIcon, Settings, ShieldAlert, CheckCircle2, XCircle, 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; } +type RefKey = 'google-sat' | 'esri-sat' | 'esri-streets' | 'osm'; +const REFS: Record = { + 'google-sat': { label: '🛰️ Google Satellite', tiles: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', attribution: '© Google', maxzoom: 20 }, + '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 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 rasterStyle = (ref: any): any => ({ + version: 8, + sources: { ref: { type: 'raster', tiles: [ref.tiles], tileSize: 256, maxzoom: ref.maxzoom } }, + layers: [{ id: 'ref', type: 'raster', source: 'ref' }], +}); + const IntelligenceDashboard: React.FC = () => { const [stats, setStats] = useState(null); const [cands, setCands] = useState([]); const [closes, setCloses] = useState([]); - const [loading, setLoading] = useState(false); - 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 [tab, setTab] = useState<'summary' | 'candidates' | 'closures'>('candidates'); + const [loading, setLoading] = useState(true); + const [apiKey, setApiKey] = useState(sessionStorage.getItem('map_admin_key') || ''); + const [needsAuth, setNeedsAuth] = useState(!sessionStorage.getItem('map_admin_key')); + const [sel, setSel] = useState(null); + + // Right Map State + const [refKey, setRefKey] = useState('google-sat'); + + const leftMapRef = useRef(null); + const rightMapRef = useRef(null); + const leftDiv = useRef(null); + const rightDiv = useRef(null); + const syncing = useRef(false); + const popupRef = useRef(null); const candsRef = useRef([]); const closesRef = useRef([]); - // 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. + // Drawing state + const [drawing, setDrawing] = useState(false); + const drawingRef = useRef(false); + const pointsRef = useRef<[number, number][]>([]); + + useEffect(() => { + drawingRef.current = drawing; + if (leftMapRef.current) leftMapRef.current.getCanvas().style.cursor = drawing ? 'crosshair' : ''; + if (rightMapRef.current) rightMapRef.current.getCanvas().style.cursor = drawing ? 'crosshair' : ''; + }, [drawing]); + + const redrawDrawing = () => { + const lm = leftMapRef.current; + const rm = rightMapRef.current; + const pts = pointsRef.current; + const feats: any[] = []; + pts.forEach(p => feats.push({ type: 'Feature', geometry: { type: 'Point', coordinates: p } })); + if (pts.length > 1) feats.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: pts } }); + const col = { type: 'FeatureCollection', features: feats }; + if (lm && lm.isStyleLoaded()) (lm.getSource('draw') as maplibregl.GeoJSONSource)?.setData(col as any); + if (rm && rm.isStyleLoaded()) (rm.getSource('draw') as maplibregl.GeoJSONSource)?.setData(col as any); + }; + const pushMapData = () => { - const m = mapRef.current; + const m = leftMapRef.current; if (!m || !m.isStyleLoaded()) return; (m.getSource('cands') as maplibregl.GeoJSONSource)?.setData({ type: 'FeatureCollection', @@ -43,7 +83,7 @@ const IntelligenceDashboard: React.FC = () => { }); (m.getSource('closures') as maplibregl.GeoJSONSource)?.setData({ type: 'FeatureCollection', - features: (closesRef.current as any[]).filter(c => c.geometry).map(c => ({ + features: closesRef.current.filter((c: any) => c.geometry).map((c: any) => ({ type: 'Feature' as const, geometry: c.geometry, properties: { segmentId: c.segmentId }, @@ -52,89 +92,164 @@ const IntelligenceDashboard: React.FC = () => { }; const load = async () => { + if (!apiKey) { + setNeedsAuth(true); + setLoading(false); + return; + } setLoading(true); + const opts = { headers: { 'x-api-key': apiKey } }; try { 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`), + fetch(`${API}/map-refinement/roads/summary`, opts), + fetch(`${API}/map-refinement/roads/candidates`, opts), + fetch(`${API}/map-refinement/roads/closures`, opts), ]); - setStats(await sr.json()); - const cJson = (await cr.json()) || []; - const clJson = (await clr.json()) || []; - setCands(cJson); - setCloses(clJson); - candsRef.current = cJson; - closesRef.current = clJson; + + if (!sr.ok || !cr.ok || !clr.ok) { + if (sr.status === 401 || sr.status === 403) { + setNeedsAuth(true); + sessionStorage.removeItem('map_admin_key'); + setApiKey(''); + throw new Error('Invalid or unauthorized API Key'); + } + throw new Error('Failed to load data from server'); + } + + const sJson = await sr.json(); + const cJson = await cr.json(); + const clJson = await clr.json(); + + const safeCands = Array.isArray(cJson) ? cJson : []; + const safeCloses = Array.isArray(clJson) ? clJson : []; + + setStats(sJson); + setCands(safeCands); + setCloses(safeCloses); + candsRef.current = safeCands; + closesRef.current = safeCloses; pushMapData(); - } catch (e) { console.error(e); } + setNeedsAuth(false); + } catch (e: any) { + console.error(e); + setMsg(e.message || 'Error loading dashboard'); + } setLoading(false); }; - useEffect(() => { load(); const iv = setInterval(load, 60000); return () => clearInterval(iv); }, []); - useEffect(() => { - 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, + if (apiKey) load(); + const iv = setInterval(() => { if (apiKey) load(); }, 60000); + return () => clearInterval(iv); + }, [apiKey]); + + // Initialize both maps + useEffect(() => { + if (!leftDiv.current || !rightDiv.current || leftMapRef.current) return; + + const start: [number, number] = [35.93, 31.96]; + const startZoom = 10; + + const lMap = new maplibregl.Map({ + container: leftDiv.current, + style: '/style.json', + center: start, zoom: startZoom, attributionControl: false }); - m.addControl(new maplibregl.NavigationControl(), 'top-right'); - m.on('load', () => pushMapData()); // draw any data that arrived before the map was ready + + const rMap = new maplibregl.Map({ + container: rightDiv.current, + style: rasterStyle(REFS[refKey]), + center: start, zoom: startZoom, attributionControl: false + }); + + lMap.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-left'); + + // Sync logic + 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; + }; + lMap.on('move', () => link(lMap, rMap)); + rMap.on('move', () => link(rMap, lMap)); + + lMap.on('load', () => { + lMap.addSource('cands', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any); + lMap.addSource('closures', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any); + lMap.addSource('hl', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any); + lMap.addSource('draw', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any); + + lMap.addLayer({ id: 'cl', type: 'line', source: 'closures', paint: { 'line-color': '#ef4444', 'line-width': 4, 'line-dasharray': [3, 2] } }); + lMap.addLayer({ id: 'cp', type: 'line', source: 'cands', filter: ['all', ['==', ['get', 'status'], 'pending'], ['!=', ['get', 'source'], 'overture']] as any, paint: { 'line-color': ['interpolate', ['linear'], ['get', 'confidence'], 0, '#f97316', 0.6, '#eab308', 0.8, '#22c55e'] as any, 'line-width': 4, 'line-dasharray': [4, 3] } }); + lMap.addLayer({ 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] } }); + lMap.addLayer({ id: 'ca', type: 'line', source: 'cands', filter: ['==', ['get', 'status'], 'approved'] as any, paint: { 'line-color': '#22c55e', 'line-width': 3.5 } }); + lMap.addLayer({ id: 'hl', type: 'line', source: 'hl', paint: { 'line-color': '#60a5fa', 'line-width': 6, 'line-blur': 1 } }); + lMap.addLayer({ id: 'draw-line', type: 'line', source: 'draw', paint: { 'line-color': '#3b82f6', 'line-width': 4, 'line-dasharray': [2, 2] } }); + lMap.addLayer({ id: 'draw-pts', type: 'circle', source: 'draw', paint: { 'circle-radius': 5, 'circle-color': '#ffffff', 'circle-stroke-width': 2, 'circle-stroke-color': '#3b82f6' } }); + + pushMapData(); + }); + + rMap.on('load', () => { + rMap.addSource('draw', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any); + rMap.addLayer({ id: 'draw-line', type: 'line', source: 'draw', paint: { 'line-color': '#3b82f6', 'line-width': 4, 'line-dasharray': [2, 2] } }); + rMap.addLayer({ id: 'draw-pts', type: 'circle', source: 'draw', paint: { 'circle-radius': 5, 'circle-color': '#ffffff', 'circle-stroke-width': 2, 'circle-stroke-color': '#3b82f6' } }); + }); + + const onClickDraw = (e: any) => { + if (!drawingRef.current) return; + pointsRef.current.push([e.lngLat.lng, e.lngLat.lat]); + redrawDrawing(); + }; + + lMap.on('click', onClickDraw); + rMap.on('click', onClickDraw); + 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`; + 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); + .addTo(lMap); }; + ['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 = ''; }); + lMap.on('click', layer, onCandidateClick); + lMap.on('mouseenter', layer, () => { lMap.getCanvas().style.cursor = 'pointer'; }); + lMap.on('mouseleave', layer, () => { lMap.getCanvas().style.cursor = ''; }); }); - mapRef.current = m; - return () => { m.remove(); mapRef.current = null; }; + + leftMapRef.current = lMap; + rightMapRef.current = rMap; + + return () => { lMap.remove(); rMap.remove(); leftMapRef.current = null; rightMapRef.current = null; }; }, []); - 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]); + // Update right map when reference layer changes + useEffect(() => { + const m = rightMapRef.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 }); + if (!m.getSource('draw')) { + m.addSource('draw', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any); + m.addLayer({ id: 'draw-line', type: 'line', source: 'draw', paint: { 'line-color': '#3b82f6', 'line-width': 4, 'line-dasharray': [2, 2] } }); + m.addLayer({ id: 'draw-pts', type: 'circle', source: 'draw', paint: { 'circle-radius': 5, 'circle-color': '#ffffff', 'circle-stroke-width': 2, 'circle-stroke-color': '#3b82f6' } }); + redrawDrawing(); + } + }); + }, [refKey]); const fly = (c: CandidateRoad) => { - const m = mapRef.current; if (!m || !c.geometry) return; + const m = leftMapRef.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 || []; @@ -146,17 +261,18 @@ const IntelligenceDashboard: React.FC = () => { const act = async (id: string, a: 'approve' | 'reject') => { try { - const res = await fetch(`${API}/map-refinement/roads/candidates/${id}/${a}`, { method: 'PATCH' }); + const res = await fetch(`${API}/map-refinement/roads/candidates/${id}/${a}`, { + method: 'PATCH', + headers: { 'x-api-key': apiKey } + }); 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.'); + setMsg(d.connected ? '✅ Approved & wired into the network.' : '✅ Approved — drawn on the map.'); } else { setMsg('🚫 Rejected.'); } setSel(null); - (mapRef.current?.getSource('hl') as maplibregl.GeoJSONSource)?.setData({ type: 'FeatureCollection', features: [] }); + (leftMapRef.current?.getSource('hl') as maplibregl.GeoJSONSource)?.setData({ type: 'FeatureCollection', features: [] }); load(); } catch { setMsg('Action failed.'); } setTimeout(() => setMsg(''), 5000); @@ -164,34 +280,88 @@ const IntelligenceDashboard: React.FC = () => { 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.'); } + try { + await fetch(`${API}/telemetry/process-intelligence?days=2`, { method: 'POST', headers: { 'x-api-key': apiKey } }); + 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 res = await fetch(`${API}/map-refinement/roads/discover-overture-gaps`, { method: 'POST', headers: { 'x-api-key': apiKey } }); const data = await res.json(); - setMsg(`🗺️ Overture diff: ${data.candidatesFound ?? 0} missing road(s) added for review.`); + setMsg(`🗺️ Overture diff: ${data.candidatesFound ?? 0} missing road(s) added.`); load(); } catch { setMsg('Overture comparison failed.'); } setTimeout(() => setMsg(''), 6000); }; + const submitDrawing = async () => { + if (pointsRef.current.length < 2) return setMsg('Draw at least 2 points!'); + + const roadName = window.prompt('Enter a name for this new road:', 'Unnamed Road'); + if (roadName === null) return; // User cancelled + + try { + const geojson = { type: 'LineString', coordinates: pointsRef.current }; + const res = await fetch(`${API}/map-refinement/roads/candidates/manual`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey }, + body: JSON.stringify({ geojson, name: roadName }), + }); + if (!res.ok) throw new Error('Failed to submit road'); + setMsg('Road submitted for intelligence analysis!'); + cancelDrawing(); + load(); + } catch (e: any) { setMsg(e.message || 'Error submitting'); } + setTimeout(() => setMsg(''), 5000); + }; + + const cancelDrawing = () => { pointsRef.current = []; redrawDrawing(); setDrawing(false); }; + + if (needsAuth) { + return ( +
+
+
+
+

Admin Access Required

+
+

Please enter your Enterprise API key to access the Intelligence Dashboard.

+ { + if (e.key === 'Enter') { + const val = e.currentTarget.value; + if (val) { + sessionStorage.setItem('map_admin_key', val); + setApiKey(val); + } + } + }} + style={{ width: '100%', padding: '12px', background: '#0f172a', border: '1px solid #3b82f6', borderRadius: '8px', color: 'white', outline: 'none', marginBottom: '1rem', boxSizing: 'border-box' }} + /> + {msg &&
{msg}
} +
+
+ ); + } + return (
{/* ── SIDEBAR ── */} -
+
Map Intelligence
-
Intaleq SaaS v2 · Closure-Aware Routing
+
Intaleq SaaS v2 · Split Compare
-
- +
-
- - -
+ {drawing && ( +
+
+ Click on either map to trace the street.
+
+ + +
+
+
+ )}
{[{ id: 'candidates', l: `Candidates (${cands.length})` }, { id: 'closures', l: `Closures (${closes.length})` }, { id: 'summary', l: 'Summary' }].map(t => ( @@ -240,14 +416,13 @@ const IntelligenceDashboard: React.FC = () => {
- {tab === 'candidates' && (
{cands.length === 0 ?
- -

No pending candidates ✅

-
+ +

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' }}>
@@ -263,9 +438,9 @@ const IntelligenceDashboard: React.FC = () => {
{[ - { 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' }, + { 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 }) => ( + ))}
- )} +
+ + {/* Crosshairs */} +
+
+
+
+
+
+
+
+
{msg && ( @@ -351,7 +534,8 @@ const IntelligenceDashboard: React.FC = () => {
)} -