|
|
@@ -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 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; }
|
|
|
|
interface Closure { segmentId: string; sampleCount: number; lastUpdated: string; }
|
|
|
|
|
|
|
|
type RefKey = 'google-sat' | 'esri-sat' | 'esri-streets' | 'osm';
|
|
|
|
|
|
|
|
const REFS: Record<RefKey, { label: string; tiles: string; attribution: string; maxzoom: number }> = {
|
|
|
|
|
|
|
|
'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 API = (import.meta as any).env.VITE_API_URL || '/api';
|
|
|
|
|
|
|
|
|
|
|
|
const cc = (c: number) => c >= 0.8 ? '#22c55e' : c >= 0.6 ? '#eab308' : '#f97316';
|
|
|
|
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 IntelligenceDashboard: React.FC = () => {
|
|
|
|
const [stats, setStats] = useState<any>(null);
|
|
|
|
const [stats, setStats] = useState<any>(null);
|
|
|
|
const [cands, setCands] = useState<CandidateRoad[]>([]);
|
|
|
|
const [cands, setCands] = useState<CandidateRoad[]>([]);
|
|
|
|
const [closes, setCloses] = useState<Closure[]>([]);
|
|
|
|
const [closes, setCloses] = useState<Closure[]>([]);
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
|
|
const [tab, setTab] = useState<'candidates' | 'closures' | 'summary'>('candidates');
|
|
|
|
|
|
|
|
const [sel, setSel] = useState<string | null>(null);
|
|
|
|
|
|
|
|
const [msg, setMsg] = useState('');
|
|
|
|
const [msg, setMsg] = useState('');
|
|
|
|
const [sat, setSat] = useState(true);
|
|
|
|
const [tab, setTab] = useState<'summary' | 'candidates' | 'closures'>('candidates');
|
|
|
|
const [traces, setTraces] = useState(true);
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
|
|
|
const [apiKey, setApiKey] = useState(sessionStorage.getItem('map_admin_key') || '');
|
|
|
|
const mapDiv = useRef<HTMLDivElement>(null);
|
|
|
|
const [needsAuth, setNeedsAuth] = useState(!sessionStorage.getItem('map_admin_key'));
|
|
|
|
|
|
|
|
const [sel, setSel] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Right Map State
|
|
|
|
|
|
|
|
const [refKey, setRefKey] = useState<keyof typeof REFS>('google-sat');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const leftMapRef = useRef<maplibregl.Map | null>(null);
|
|
|
|
|
|
|
|
const rightMapRef = useRef<maplibregl.Map | null>(null);
|
|
|
|
|
|
|
|
const leftDiv = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
const rightDiv = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
const syncing = useRef(false);
|
|
|
|
|
|
|
|
|
|
|
|
const popupRef = useRef<maplibregl.Popup | null>(null);
|
|
|
|
const popupRef = useRef<maplibregl.Popup | null>(null);
|
|
|
|
const candsRef = useRef<CandidateRoad[]>([]);
|
|
|
|
const candsRef = useRef<CandidateRoad[]>([]);
|
|
|
|
const closesRef = useRef<Closure[]>([]);
|
|
|
|
const closesRef = useRef<Closure[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
// Push the latest candidates/closures onto the map as GeoJSON. These tables use
|
|
|
|
// Drawing state
|
|
|
|
// a `geography` column, which Martin's auto-publish does not serve as vector
|
|
|
|
const [drawing, setDrawing] = useState(false);
|
|
|
|
// tiles — so the review layers are fed straight from the API instead.
|
|
|
|
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 pushMapData = () => {
|
|
|
|
const m = mapRef.current;
|
|
|
|
const m = leftMapRef.current;
|
|
|
|
if (!m || !m.isStyleLoaded()) return;
|
|
|
|
if (!m || !m.isStyleLoaded()) return;
|
|
|
|
(m.getSource('cands') as maplibregl.GeoJSONSource)?.setData({
|
|
|
|
(m.getSource('cands') as maplibregl.GeoJSONSource)?.setData({
|
|
|
|
type: 'FeatureCollection',
|
|
|
|
type: 'FeatureCollection',
|
|
|
@@ -43,7 +83,7 @@ const IntelligenceDashboard: React.FC = () => {
|
|
|
|
});
|
|
|
|
});
|
|
|
|
(m.getSource('closures') as maplibregl.GeoJSONSource)?.setData({
|
|
|
|
(m.getSource('closures') as maplibregl.GeoJSONSource)?.setData({
|
|
|
|
type: 'FeatureCollection',
|
|
|
|
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,
|
|
|
|
type: 'Feature' as const,
|
|
|
|
geometry: c.geometry,
|
|
|
|
geometry: c.geometry,
|
|
|
|
properties: { segmentId: c.segmentId },
|
|
|
|
properties: { segmentId: c.segmentId },
|
|
|
@@ -52,89 +92,164 @@ const IntelligenceDashboard: React.FC = () => {
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const load = async () => {
|
|
|
|
const load = async () => {
|
|
|
|
|
|
|
|
if (!apiKey) {
|
|
|
|
|
|
|
|
setNeedsAuth(true);
|
|
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
setLoading(true);
|
|
|
|
|
|
|
|
const opts = { headers: { 'x-api-key': apiKey } };
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
const [sr, cr, clr] = await Promise.all([
|
|
|
|
const [sr, cr, clr] = await Promise.all([
|
|
|
|
fetch(`${API}/map-refinement/roads/summary`),
|
|
|
|
fetch(`${API}/map-refinement/roads/summary`, opts),
|
|
|
|
fetch(`${API}/map-refinement/roads/candidates?status=pending`),
|
|
|
|
fetch(`${API}/map-refinement/roads/candidates`, opts),
|
|
|
|
fetch(`${API}/map-refinement/roads/closures`),
|
|
|
|
fetch(`${API}/map-refinement/roads/closures`, opts),
|
|
|
|
]);
|
|
|
|
]);
|
|
|
|
setStats(await sr.json());
|
|
|
|
|
|
|
|
const cJson = (await cr.json()) || [];
|
|
|
|
if (!sr.ok || !cr.ok || !clr.ok) {
|
|
|
|
const clJson = (await clr.json()) || [];
|
|
|
|
if (sr.status === 401 || sr.status === 403) {
|
|
|
|
setCands(cJson);
|
|
|
|
setNeedsAuth(true);
|
|
|
|
setCloses(clJson);
|
|
|
|
sessionStorage.removeItem('map_admin_key');
|
|
|
|
candsRef.current = cJson;
|
|
|
|
setApiKey('');
|
|
|
|
closesRef.current = clJson;
|
|
|
|
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();
|
|
|
|
pushMapData();
|
|
|
|
} catch (e) { console.error(e); }
|
|
|
|
setNeedsAuth(false);
|
|
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
|
|
setMsg(e.message || 'Error loading dashboard');
|
|
|
|
|
|
|
|
}
|
|
|
|
setLoading(false);
|
|
|
|
setLoading(false);
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { load(); const iv = setInterval(load, 60000); return () => clearInterval(iv); }, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
useEffect(() => {
|
|
|
|
if (!mapDiv.current || mapRef.current) return;
|
|
|
|
if (apiKey) load();
|
|
|
|
const m = new maplibregl.Map({
|
|
|
|
const iv = setInterval(() => { if (apiKey) load(); }, 60000);
|
|
|
|
container: mapDiv.current,
|
|
|
|
return () => clearInterval(iv);
|
|
|
|
style: {
|
|
|
|
}, [apiKey]);
|
|
|
|
version: 8,
|
|
|
|
|
|
|
|
glyphs: 'https://fonts.openmaptiles.org/{fontstack}/{range}.pbf',
|
|
|
|
// Initialize both maps
|
|
|
|
sources: {
|
|
|
|
useEffect(() => {
|
|
|
|
sat: { type: 'raster', tiles: [ESRI], tileSize: 256, maxzoom: 19 },
|
|
|
|
if (!leftDiv.current || !rightDiv.current || leftMapRef.current) return;
|
|
|
|
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
|
|
|
|
const start: [number, number] = [35.93, 31.96];
|
|
|
|
// does not auto-publish — these are fed from the API via pushMapData().
|
|
|
|
const startZoom = 10;
|
|
|
|
cands: { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any,
|
|
|
|
|
|
|
|
closures: { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any,
|
|
|
|
const lMap = new maplibregl.Map({
|
|
|
|
hl: { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any,
|
|
|
|
container: leftDiv.current,
|
|
|
|
},
|
|
|
|
style: '/style.json',
|
|
|
|
layers: [
|
|
|
|
center: start, zoom: startZoom, attributionControl: false
|
|
|
|
{ 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 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 onCandidateClick = (e: any) => {
|
|
|
|
const p = e.features?.[0]?.properties as any; if (!p) return;
|
|
|
|
const p = e.features?.[0]?.properties as any; if (!p) return;
|
|
|
|
setSel(p.id);
|
|
|
|
setSel(p.id);
|
|
|
|
const isOvt = p.source === 'overture';
|
|
|
|
const isOvt = p.source === 'overture';
|
|
|
|
const title = isOvt ? '🗺️ Overture road (missing)' : '🛣️ Driver-traced road';
|
|
|
|
const title = isOvt ? '🗺️ Overture road (missing)' : '🛣️ Driver-traced road';
|
|
|
|
const nameLine = p.name ? `<b>${p.name}</b><br/>` : '';
|
|
|
|
const nameLine = p.name ? `<b>${p.name}</b><br/>` : '';
|
|
|
|
const evidence = isOvt
|
|
|
|
const evidence = isOvt ? `${p.uniqueDriverCount ?? 0} trace pts corroborate` : `${p.uniqueDriverCount} drivers`;
|
|
|
|
? `${p.uniqueDriverCount ?? 0} trace pts corroborate`
|
|
|
|
|
|
|
|
: `${p.uniqueDriverCount} drivers`;
|
|
|
|
|
|
|
|
if (popupRef.current) popupRef.current.remove();
|
|
|
|
if (popupRef.current) popupRef.current.remove();
|
|
|
|
popupRef.current = new maplibregl.Popup({ offset: 12 }).setLngLat(e.lngLat)
|
|
|
|
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>`)
|
|
|
|
.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);
|
|
|
|
.addTo(lMap);
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
['cp', 'cp-overture'].forEach(layer => {
|
|
|
|
['cp', 'cp-overture'].forEach(layer => {
|
|
|
|
m.on('click', layer, onCandidateClick);
|
|
|
|
lMap.on('click', layer, onCandidateClick);
|
|
|
|
m.on('mouseenter', layer, () => { m.getCanvas().style.cursor = 'pointer'; });
|
|
|
|
lMap.on('mouseenter', layer, () => { lMap.getCanvas().style.cursor = 'pointer'; });
|
|
|
|
m.on('mouseleave', layer, () => { m.getCanvas().style.cursor = ''; });
|
|
|
|
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]);
|
|
|
|
// Update right map when reference layer changes
|
|
|
|
useEffect(() => { const m = mapRef.current; if (m && m.isStyleLoaded()) m.setLayoutProperty('osm', 'visibility', traces ? 'visible' : 'none'); }, [traces]);
|
|
|
|
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 fly = (c: CandidateRoad) => {
|
|
|
|
const m = mapRef.current; if (!m || !c.geometry) return;
|
|
|
|
const m = leftMapRef.current; if (!m || !c.geometry) return;
|
|
|
|
setSel(c.id);
|
|
|
|
setSel(c.id);
|
|
|
|
(m.getSource('hl') as maplibregl.GeoJSONSource)?.setData({ type: 'FeatureCollection', features: [{ type: 'Feature', geometry: c.geometry, properties: {} }] });
|
|
|
|
(m.getSource('hl') as maplibregl.GeoJSONSource)?.setData({ type: 'FeatureCollection', features: [{ type: 'Feature', geometry: c.geometry, properties: {} }] });
|
|
|
|
const coords: [number, number][] = c.geometry.coordinates || [];
|
|
|
|
const coords: [number, number][] = c.geometry.coordinates || [];
|
|
|
@@ -146,17 +261,18 @@ const IntelligenceDashboard: React.FC = () => {
|
|
|
|
|
|
|
|
|
|
|
|
const act = async (id: string, a: 'approve' | 'reject') => {
|
|
|
|
const act = async (id: string, a: 'approve' | 'reject') => {
|
|
|
|
try {
|
|
|
|
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') {
|
|
|
|
if (a === 'approve') {
|
|
|
|
const d = await res.json().catch(() => ({} as any));
|
|
|
|
const d = await res.json().catch(() => ({} as any));
|
|
|
|
setMsg(d.connected ? '✅ Approved & wired into the network — routes after next rebuild.'
|
|
|
|
setMsg(d.connected ? '✅ Approved & wired into the network.' : '✅ Approved — drawn on the map.');
|
|
|
|
: d.snapped ? '✅ Approved & snapped — endpoints not auto-connected (check DB middle tables).'
|
|
|
|
|
|
|
|
: '✅ Approved — drawn on the map; routing after rebuild.');
|
|
|
|
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
setMsg('🚫 Rejected.');
|
|
|
|
setMsg('🚫 Rejected.');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
setSel(null);
|
|
|
|
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();
|
|
|
|
load();
|
|
|
|
} catch { setMsg('Action failed.'); }
|
|
|
|
} catch { setMsg('Action failed.'); }
|
|
|
|
setTimeout(() => setMsg(''), 5000);
|
|
|
|
setTimeout(() => setMsg(''), 5000);
|
|
|
@@ -164,34 +280,88 @@ const IntelligenceDashboard: React.FC = () => {
|
|
|
|
|
|
|
|
|
|
|
|
const runAI = async () => {
|
|
|
|
const runAI = async () => {
|
|
|
|
setMsg('🧠 Deep analysis started...');
|
|
|
|
setMsg('🧠 Deep analysis started...');
|
|
|
|
try { await fetch(`${API}/telemetry/process-intelligence?days=2`, { method: 'POST' }); setMsg('Running. Check Telegram.'); }
|
|
|
|
try {
|
|
|
|
catch { setMsg('Analysis failed.'); }
|
|
|
|
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);
|
|
|
|
setTimeout(() => setMsg(''), 6000);
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const runOverture = async () => {
|
|
|
|
const runOverture = async () => {
|
|
|
|
setMsg('🗺️ Comparing our map against Overture...');
|
|
|
|
setMsg('🗺️ Comparing our map against Overture...');
|
|
|
|
try {
|
|
|
|
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();
|
|
|
|
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();
|
|
|
|
load();
|
|
|
|
} catch { setMsg('Overture comparison failed.'); }
|
|
|
|
} catch { setMsg('Overture comparison failed.'); }
|
|
|
|
setTimeout(() => setMsg(''), 6000);
|
|
|
|
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 (
|
|
|
|
|
|
|
|
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#0f172a', color: 'white', fontFamily: 'system-ui' }}>
|
|
|
|
|
|
|
|
<div style={{ background: '#1e293b', padding: '2rem', borderRadius: '12px', border: '1px solid #334155', width: '100%', maxWidth: '400px' }}>
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '1.5rem' }}>
|
|
|
|
|
|
|
|
<div style={{ background: '#3b82f6', borderRadius: '8px', display: 'flex', padding: '8px' }}><ShieldAlert size={24} color="white" /></div>
|
|
|
|
|
|
|
|
<h2 style={{ margin: 0, fontSize: '1.2rem' }}>Admin Access Required</h2>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<p style={{ color: '#94a3b8', fontSize: '0.9rem', marginBottom: '1rem' }}>Please enter your Enterprise API key to access the Intelligence Dashboard.</p>
|
|
|
|
|
|
|
|
<input
|
|
|
|
|
|
|
|
type="password"
|
|
|
|
|
|
|
|
placeholder="API Key (e.g. intaleq_secret_...)"
|
|
|
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
|
|
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 && <div style={{ color: '#ef4444', fontSize: '0.85rem', marginBottom: '1rem' }}>{msg}</div>}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
return (
|
|
|
|
<div style={{ display: 'flex', height: '100vh', background: '#0f172a', color: '#f8fafc', fontFamily: 'Inter,system-ui,sans-serif', overflow: 'hidden' }}>
|
|
|
|
<div style={{ display: 'flex', height: '100vh', background: '#0f172a', color: '#f8fafc', fontFamily: 'Inter,system-ui,sans-serif', overflow: 'hidden' }}>
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── SIDEBAR ── */}
|
|
|
|
{/* ── SIDEBAR ── */}
|
|
|
|
<div style={{ width: '370px', flexShrink: 0, display: 'flex', flexDirection: 'column', borderRight: '1px solid #1e293b', overflow: 'hidden' }}>
|
|
|
|
<div style={{ width: '370px', flexShrink: 0, display: 'flex', flexDirection: 'column', borderRight: '1px solid #1e293b', overflow: 'hidden', zIndex: 10 }}>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ padding: '1.2rem', borderBottom: '1px solid #1e293b', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
|
|
<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={{ 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 style={{ background: 'linear-gradient(135deg,#6366f1,#3b82f6)', padding: '8px', borderRadius: '10px' }}><LayoutDashboard size={18} color="white" /></div>
|
|
|
|
<div>
|
|
|
|
<div>
|
|
|
|
<div style={{ fontWeight: 700 }}>Map Intelligence</div>
|
|
|
|
<div style={{ fontWeight: 700 }}>Map Intelligence</div>
|
|
|
|
<div style={{ fontSize: '0.7rem', color: '#64748b' }}>Intaleq SaaS v2 · Closure-Aware Routing</div>
|
|
|
|
<div style={{ fontSize: '0.7rem', color: '#64748b' }}>Intaleq SaaS v2 · Split Compare</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<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' }}>
|
|
|
|
<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' }}>
|
|
|
@@ -216,20 +386,26 @@ const IntelligenceDashboard: React.FC = () => {
|
|
|
|
))}
|
|
|
|
))}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ padding: '0 1rem 0.6rem' }}>
|
|
|
|
<div style={{ padding: '0 1rem 0.6rem', display: 'flex', gap: '8px' }}>
|
|
|
|
<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' }}>
|
|
|
|
<button onClick={runOverture} style={{ flex: 1, 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
|
|
|
|
<MapIcon size={13} /> Compare with Overture
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
|
|
|
|
<button onClick={() => setDrawing(d => !d)} style={{ flex: 1, background: drawing ? 'rgba(239,68,68,0.12)' : 'rgba(59,130,246,0.12)', border: `1px solid ${drawing ? '#ef4444' : '#3b82f6'}`, color: drawing ? '#f87171' : '#60a5fa', borderRadius: '8px', padding: '8px', cursor: 'pointer', fontSize: '0.78rem', fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}>
|
|
|
|
|
|
|
|
{drawing ? 'Cancel Drawing' : 'Draw Missing Road'}
|
|
|
|
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', gap: '8px', padding: '0 1rem 0.8rem' }}>
|
|
|
|
{drawing && (
|
|
|
|
<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' }}>
|
|
|
|
<div style={{ padding: '0 1rem 0.6rem' }}>
|
|
|
|
<Layers size={12} /> Satellite
|
|
|
|
<div style={{ background: '#1e3a8a30', border: '1px solid #3b82f6', padding: '10px', borderRadius: '8px', fontSize: '0.75rem', color: '#bfdbfe' }}>
|
|
|
|
</button>
|
|
|
|
Click on <b>either map</b> to trace the street.<br />
|
|
|
|
<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' }}>
|
|
|
|
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
|
|
|
<Navigation size={12} /> OSM Baseline
|
|
|
|
<button onClick={submitDrawing} style={{ flex: 1, background: '#3b82f6', color: 'white', border: 'none', padding: '6px', borderRadius: '6px', cursor: 'pointer', fontWeight: 600 }}>Submit Road</button>
|
|
|
|
</button>
|
|
|
|
<button onClick={() => { pointsRef.current = []; redrawDrawing(); }} style={{ flex: 1, background: 'transparent', color: '#94a3b8', border: '1px solid #334155', padding: '6px', borderRadius: '6px', cursor: 'pointer', fontWeight: 600 }}>Clear</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', borderBottom: '1px solid #1e293b' }}>
|
|
|
|
<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 => (
|
|
|
|
{[{ id: 'candidates', l: `Candidates (${cands.length})` }, { id: 'closures', l: `Closures (${closes.length})` }, { id: 'summary', l: 'Summary' }].map(t => (
|
|
|
@@ -240,7 +416,6 @@ const IntelligenceDashboard: React.FC = () => {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ flex: 1, overflowY: 'auto', padding: '0.8rem' }}>
|
|
|
|
<div style={{ flex: 1, overflowY: 'auto', padding: '0.8rem' }}>
|
|
|
|
|
|
|
|
|
|
|
|
{tab === 'candidates' && (
|
|
|
|
{tab === 'candidates' && (
|
|
|
|
<div style={{ display: 'grid', gap: '8px' }}>
|
|
|
|
<div style={{ display: 'grid', gap: '8px' }}>
|
|
|
|
{cands.length === 0
|
|
|
|
{cands.length === 0
|
|
|
@@ -317,32 +492,40 @@ const IntelligenceDashboard: React.FC = () => {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── MAP PANE ── */}
|
|
|
|
{/* ── MAPS CONTAINER (Split Screen) ── */}
|
|
|
|
<div style={{ flex: 1, position: 'relative' }}>
|
|
|
|
<div style={{ flex: 1, display: 'flex', position: 'relative' }}>
|
|
|
|
<div ref={mapDiv} style={{ width: '100%', height: '100%' }} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* Legend */}
|
|
|
|
{/* Left Map: Our Data */}
|
|
|
|
<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' }}>
|
|
|
|
<div style={{ flex: 1, position: 'relative', borderRight: '2px solid #0f172a' }}>
|
|
|
|
{[
|
|
|
|
<div ref={leftDiv} style={{ width: '100%', height: '100%' }} />
|
|
|
|
{ c: '#6b7280', l: 'OSM Roads', d: false },
|
|
|
|
<div style={{ position: 'absolute', top: 10, left: 10, zIndex: 2, background: 'rgba(15,23,42,0.85)', border: '1px solid #334155', padding: '4px 10px', borderRadius: '20px', fontSize: '0.72rem', fontWeight: 600, pointerEvents: 'none' }}>
|
|
|
|
{ c: '#eab308', l: 'Traced Candidate', d: true },
|
|
|
|
Our Map (Intaleq)
|
|
|
|
{ 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>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* Right Map: Reference */}
|
|
|
|
|
|
|
|
<div style={{ flex: 1, position: 'relative' }}>
|
|
|
|
|
|
|
|
<div ref={rightDiv} style={{ width: '100%', height: '100%' }} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ position: 'absolute', top: 10, left: 10, zIndex: 2, display: 'flex', gap: '6px' }}>
|
|
|
|
|
|
|
|
{(Object.keys(REFS) as Array<keyof typeof REFS>).map(k => (
|
|
|
|
|
|
|
|
<button key={k} onClick={() => setRefKey(k)} style={{ background: refKey === k ? 'rgba(99,102,241,0.9)' : 'rgba(15,23,42,0.85)', border: '1px solid #334155', color: refKey === k ? 'white' : '#e2e8f0', padding: '4px 10px', borderRadius: '20px', fontSize: '0.72rem', fontWeight: 600, cursor: 'pointer' }}>
|
|
|
|
|
|
|
|
{REFS[k].label}
|
|
|
|
|
|
|
|
</button>
|
|
|
|
))}
|
|
|
|
))}
|
|
|
|
</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>
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Crosshairs */}
|
|
|
|
|
|
|
|
<div style={{ position: 'absolute', left: '25%', top: '50%', width: 26, height: 26, transform: 'translate(-50%, -50%)', pointerEvents: 'none', zIndex: 20 }}>
|
|
|
|
|
|
|
|
<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 style={{ position: 'absolute', left: '75%', top: '50%', width: 26, height: 26, transform: 'translate(-50%, -50%)', pointerEvents: 'none', zIndex: 20 }}>
|
|
|
|
|
|
|
|
<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>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{msg && (
|
|
|
|
{msg && (
|
|
|
@@ -351,7 +534,8 @@ const IntelligenceDashboard: React.FC = () => {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<style dangerouslySetInnerHTML={{ __html: `
|
|
|
|
<style dangerouslySetInnerHTML={{
|
|
|
|
|
|
|
|
__html: `
|
|
|
|
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
|
|
|
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
|
|
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
|
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
|
|
::-webkit-scrollbar { width: 4px; }
|
|
|
|
::-webkit-scrollbar { width: 4px; }
|
|
|
|