feat: add automated crons, telemetry cleanup, RTL support, and manual road candidate tools

This commit is contained in:
Hamza-Ayed
2026-07-15 17:14:01 +03:00
parent 0bb609d5a2
commit 7a64be5833
6 changed files with 353 additions and 161 deletions
+2 -1
View File
@@ -108,7 +108,8 @@ export class AuthService {
tenant = await this.tenantRepository.save({
name,
email,
isActive: true
isActive: true,
plan: TenantPlan.ENTERPRISE,
});
}
+4 -2
View File
@@ -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,
});
+7
View File
@@ -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 = [
+8 -33
View File
@@ -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<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 },
@@ -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 = () => {
</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 */}
+298 -114
View File
@@ -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<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 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<any>(null);
const [cands, setCands] = useState<CandidateRoad[]>([]);
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 [sat, setSat] = useState(true);
const [traces, setTraces] = useState(true);
const mapRef = useRef<maplibregl.Map | null>(null);
const mapDiv = useRef<HTMLDivElement>(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<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 candsRef = useRef<CandidateRoad[]>([]);
const closesRef = useRef<Closure[]>([]);
// 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 ? `<b>${p.name}</b><br/>` : '';
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(`<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 => {
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 (
<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 (
<div style={{ display: 'flex', height: '100vh', background: '#0f172a', color: '#f8fafc', fontFamily: 'Inter,system-ui,sans-serif', overflow: 'hidden' }}>
{/* ── 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={{ 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>
<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>
<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 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' }}>
<div style={{ padding: '0 1rem 0.6rem', display: 'flex', gap: '8px' }}>
<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
</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 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>
{drawing && (
<div style={{ padding: '0 1rem 0.6rem' }}>
<div style={{ background: '#1e3a8a30', border: '1px solid #3b82f6', padding: '10px', borderRadius: '8px', fontSize: '0.75rem', color: '#bfdbfe' }}>
Click on <b>either map</b> to trace the street.<br />
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<button onClick={submitDrawing} style={{ flex: 1, background: '#3b82f6', color: 'white', border: 'none', padding: '6px', borderRadius: '6px', cursor: 'pointer', fontWeight: 600 }}>Submit Road</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 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 => (
@@ -240,7 +416,6 @@ const IntelligenceDashboard: React.FC = () => {
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '0.8rem' }}>
{tab === 'candidates' && (
<div style={{ display: 'grid', gap: '8px' }}>
{cands.length === 0
@@ -317,32 +492,40 @@ const IntelligenceDashboard: React.FC = () => {
</div>
</div>
{/* ── MAP PANE ── */}
<div style={{ flex: 1, position: 'relative' }}>
<div ref={mapDiv} style={{ width: '100%', height: '100%' }} />
{/* ── MAPS CONTAINER (Split Screen) ── */}
<div style={{ flex: 1, display: 'flex', position: 'relative' }}>
{/* 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}
{/* Left Map: Our Data */}
<div style={{ flex: 1, position: 'relative', borderRight: '2px solid #0f172a' }}>
<div ref={leftDiv} style={{ width: '100%', height: '100%' }} />
<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' }}>
Our Map (Intaleq)
</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>
{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>
)}
{/* 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>
{msg && (
@@ -351,7 +534,8 @@ const IntelligenceDashboard: React.FC = () => {
</div>
)}
<style dangerouslySetInnerHTML={{ __html: `
<style dangerouslySetInnerHTML={{
__html: `
@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; }
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# --------------------------------------------------------------------------
# Setup Host-Level Crontab for Intaleq Map Platform
# --------------------------------------------------------------------------
set -e
echo "Setting up crontab for map-saas..."
CRON_FILE="/tmp/map_saas_cron"
# 1. Update Map Data (OSM/Overture) every 10 days at 3:00 AM
echo "0 3 */10 * * /home/hamzadoctor/app/infrastructure/scripts/update-data.sh >> /home/hamzadoctor/app/infrastructure/logs/update-data.log 2>&1" > $CRON_FILE
# 2. Discover road closures daily at 4:00 AM
echo "0 4 * * * curl -X POST -H \"x-api-key: intaleq_secret_2026\" http://localhost:3200/api/map-refinement/roads/discover-closures >> /home/hamzadoctor/app/infrastructure/logs/closures.log 2>&1" >> $CRON_FILE
# Install crontab
crontab $CRON_FILE
rm $CRON_FILE
echo "✅ Crontab installed successfully!"
crontab -l