Add Sentinel-2 road freight trends with Analyze Here UI.

Port DrishX truck-motion detection as an opt-in slow layer: on-demand map-center analysis, preset corridors, layer panel toggle, and Docker road-corridor extras.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
BigBodyCobain
2026-06-07 23:39:13 -06:00
co-authored by Cursor
parent 76f4deb3a7
commit b64b9e0962
33 changed files with 3127 additions and 21 deletions
+3
View File
@@ -185,6 +185,7 @@ export default function Dashboard() {
highres_satellite: false,
sentinel_hub: false,
viirs_nightlights: false,
road_corridor_trends: false,
// Hazards — no fire, rest ON
earthquakes: true,
firms: false,
@@ -446,6 +447,7 @@ export default function Dashboard() {
highres_satellite: false,
sentinel_hub: false,
viirs_nightlights: false,
road_corridor_trends: false,
psk_reporter: false,
tinygs: false,
datacenters: false,
@@ -589,6 +591,7 @@ export default function Dashboard() {
setTrackedScanner={setTrackedScanner}
isMinimized={leftDataMinimized}
onMinimizedChange={setLeftDataMinimized}
viewBoundsRef={viewBoundsRef}
/>
</ErrorBoundary>
) : (
@@ -0,0 +1,213 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Loader2, MapPin, Truck } from 'lucide-react';
import { API_BASE } from '@/lib/api';
import { LAYER_TOGGLE_EVENT } from '@/hooks/useDataPolling';
import { VIEWPORT_COMMITTED_EVENT } from '@/components/map/hooks/useViewportBounds';
import { useTranslation } from '@/i18n';
type ViewBounds = { south: number; west: number; north: number; east: number };
type RoadCorridorStatus = {
deps_installed: boolean;
credentials_configured: boolean;
active_job?: AnalyzeJob | null;
};
type AnalyzeJob = {
job_id: string;
status: string;
message: string;
progress: number;
error?: string | null;
result?: {
total_detections?: number;
daily_counts?: Array<{ date: string; count: number }>;
status?: string;
error?: string | null;
} | null;
};
function viewCenter(bounds: ViewBounds | null | undefined): { lat: number; lon: number } | null {
if (!bounds) return null;
const { south, west, north, east } = bounds;
if (![south, west, north, east].every((v) => Number.isFinite(v))) return null;
return { lat: (south + north) / 2, lon: (west + east) / 2 };
}
export default function RoadCorridorLayerControls({
viewBoundsRef,
}: {
viewBoundsRef?: React.RefObject<ViewBounds | null>;
}) {
const { t } = useTranslation();
const [status, setStatus] = useState<RoadCorridorStatus | null>(null);
const [job, setJob] = useState<AnalyzeJob | null>(null);
const [submitting, setSubmitting] = useState(false);
const [mapCenter, setMapCenter] = useState<{ lat: number; lon: number } | null>(() =>
viewCenter(viewBoundsRef?.current ?? null),
);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
const syncCenter = () => setMapCenter(viewCenter(viewBoundsRef?.current ?? null));
syncCenter();
window.addEventListener(VIEWPORT_COMMITTED_EVENT, syncCenter);
return () => window.removeEventListener(VIEWPORT_COMMITTED_EVENT, syncCenter);
}, [viewBoundsRef]);
const stopPolling = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
const pollJob = useCallback(
async (jobId?: string) => {
try {
const qs = jobId ? `?job_id=${encodeURIComponent(jobId)}` : '';
const res = await fetch(`${API_BASE}/api/road-corridors/analyze/status${qs}`);
if (!res.ok) return;
const body = await res.json();
const next = body.job as AnalyzeJob | null;
if (!next) return;
setJob(next);
if (next.status === 'ok' || next.status === 'error') {
stopPolling();
setSubmitting(false);
if (next.status === 'ok') {
window.dispatchEvent(new Event(LAYER_TOGGLE_EVENT));
}
}
} catch {
// ignore transient poll errors
}
},
[stopPolling],
);
const startPolling = useCallback(
(jobId: string) => {
stopPolling();
void pollJob(jobId);
pollRef.current = setInterval(() => void pollJob(jobId), 2500);
},
[pollJob, stopPolling],
);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`${API_BASE}/api/road-corridors/status`);
if (!res.ok || cancelled) return;
const body = await res.json();
setStatus(body);
if (body.active_job?.status === 'queued' || body.active_job?.status === 'running') {
setJob(body.active_job);
setSubmitting(true);
startPolling(body.active_job.job_id);
}
} catch {
// backend may be offline during boot
}
})();
return () => {
cancelled = true;
stopPolling();
};
}, [startPolling, stopPolling]);
const ready = Boolean(status?.deps_installed && status?.credentials_configured);
const running = submitting || job?.status === 'queued' || job?.status === 'running';
const handleAnalyze = async () => {
const c = mapCenter ?? viewCenter(viewBoundsRef?.current ?? null);
if (!c || running) return;
setSubmitting(true);
setJob(null);
try {
const res = await fetch(`${API_BASE}/api/road-corridors/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat: c.lat, lon: c.lon }),
});
const body = await res.json().catch(() => ({}));
if (res.status === 409 && body.detail) {
const statusRes = await fetch(`${API_BASE}/api/road-corridors/analyze/status`);
const statusBody = await statusRes.json();
if (statusBody.job) {
setJob(statusBody.job);
startPolling(statusBody.job.job_id);
}
return;
}
if (!res.ok) {
setSubmitting(false);
setJob({
job_id: '',
status: 'error',
message: typeof body.detail === 'string' ? body.detail : t('roadCorridor.analyzeFailed'),
progress: 100,
error: typeof body.detail === 'string' ? body.detail : undefined,
});
return;
}
setJob(body as AnalyzeJob);
startPolling((body as AnalyzeJob).job_id);
} catch {
setSubmitting(false);
setJob({
job_id: '',
status: 'error',
message: t('roadCorridor.analyzeFailed'),
progress: 100,
});
}
};
let statusLine = t('roadCorridor.hintTrends');
if (!ready) {
statusLine = !status?.deps_installed
? t('roadCorridor.missingDeps')
: t('roadCorridor.missingCreds');
} else if (!mapCenter) {
statusLine = t('roadCorridor.panMapFirst');
} else if (running && job) {
statusLine = job.message || t('roadCorridor.analyzing');
} else if (job?.status === 'ok' && job.result) {
const days = job.result.daily_counts?.length ?? 0;
const total = job.result.total_detections ?? 0;
statusLine = `${total} truck signatures · ${days} day${days === 1 ? '' : 's'}`;
} else if (job?.status === 'error') {
statusLine = job.error || job.message || t('roadCorridor.analyzeFailed');
}
return (
<div className="ml-7 mt-2 flex flex-col gap-1.5" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={() => void handleAnalyze()}
disabled={!ready || !mapCenter || running}
className="flex items-center gap-1.5 text-[9px] font-mono tracking-wide text-amber-400 hover:text-amber-200 border border-amber-500/30 hover:border-amber-500/50 bg-amber-500/5 hover:bg-amber-500/10 disabled:opacity-40 disabled:hover:text-amber-400 disabled:hover:border-amber-500/30 disabled:hover:bg-amber-500/5 px-2.5 py-1 rounded transition w-fit"
>
{running ? <Loader2 size={10} className="animate-spin" /> : <MapPin size={10} />}
{running ? t('roadCorridor.analyzing') : t('roadCorridor.analyzeHere')}
</button>
<div className="flex items-start gap-1.5 text-[10px] font-mono text-[var(--text-muted)] leading-snug max-w-[220px]">
<Truck size={10} className="mt-0.5 shrink-0 text-amber-500/70" />
<span>{statusLine}</span>
</div>
{running && job && job.progress > 0 ? (
<div className="h-1 w-full max-w-[180px] bg-amber-950/40 rounded overflow-hidden">
<div
className="h-full bg-amber-500/70 transition-all duration-500"
style={{ width: `${Math.min(100, job.progress)}%` }}
/>
</div>
) : null}
</div>
);
}
+20 -3
View File
@@ -43,7 +43,9 @@ import {
Droplets,
Radar,
MapPin,
Truck,
} from 'lucide-react';
import RoadCorridorLayerControls from '@/components/RoadCorridorLayerControls';
import { API_BASE } from '@/lib/api';
import { useLiveUamapScraperOptIn } from '@/hooks/useLiveUamapScraperOptIn';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
@@ -107,6 +109,7 @@ const FRESHNESS_MAP: Record<string, string> = {
wastewater: 'wastewater',
ai_intel: '',
crowdthreat: 'crowdthreat',
road_corridor_trends: 'road_corridor_trends',
};
// POTUS fleet ICAO hex codes for client-side filtering
@@ -650,6 +653,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
isMinimized: isMinimizedProp,
onMinimizedChange,
onOpenSarAoiEditor,
viewBoundsRef,
}: {
activeLayers: ActiveLayers;
setActiveLayers: React.Dispatch<React.SetStateAction<ActiveLayers>>;
@@ -675,6 +679,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
isMinimized?: boolean;
onMinimizedChange?: (minimized: boolean) => void;
onOpenSarAoiEditor?: () => void;
viewBoundsRef?: React.RefObject<{ south: number; west: number; north: number; east: number } | null>;
}) {
const data = useDataSnapshot() as import('@/types/dashboard').DashboardData;
const { t } = useTranslation();
@@ -1039,6 +1044,15 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
count: null,
icon: Moon,
},
{
id: 'road_corridor_trends',
name: t('layers.roadCorridorTrends'),
source: t('layers.roadCorridorSource'),
count:
data?.road_corridor_trends?.corridors?.filter((c) => (c.total_detections ?? 0) > 0)
.length ?? 0,
icon: Truck,
},
],
},
{
@@ -1401,21 +1415,21 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
<button
title={
Object.entries(activeLayers)
.filter(([k]) => !['gibs_imagery', 'highres_satellite', 'sentinel_hub', 'viirs_nightlights'].includes(k))
.filter(([k]) => !['gibs_imagery', 'highres_satellite', 'sentinel_hub', 'viirs_nightlights', 'road_corridor_trends'].includes(k))
.every(([, v]) => v)
? 'Disable all layers'
: 'Enable all layers'
}
className={`${
Object.entries(activeLayers)
.filter(([k]) => !['gibs_imagery', 'highres_satellite', 'sentinel_hub', 'viirs_nightlights'].includes(k))
.filter(([k]) => !['gibs_imagery', 'highres_satellite', 'sentinel_hub', 'viirs_nightlights', 'road_corridor_trends'].includes(k))
.every(([, v]) => v)
? 'text-cyan-400'
: 'text-[var(--text-muted)]'
} hover:text-cyan-400 transition-colors`}
onClick={(e) => {
e.stopPropagation();
const excluded = new Set(['gibs_imagery', 'highres_satellite', 'sentinel_hub', 'viirs_nightlights']);
const excluded = new Set(['gibs_imagery', 'highres_satellite', 'sentinel_hub', 'viirs_nightlights', 'road_corridor_trends']);
const allOn = Object.entries(activeLayers)
.filter(([k]) => !excluded.has(k))
.every(([, v]) => v);
@@ -1839,6 +1853,9 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
</div>
)}
{/* SAR inline controls — AOI editor button */}
{active && layer.id === 'road_corridor_trends' && (
<RoadCorridorLayerControls viewBoundsRef={viewBoundsRef} />
)}
{active && layer.id === 'sar' && onOpenSarAoiEditor && (
<div
className="ml-7 mt-2 flex items-center gap-2"
+12 -1
View File
@@ -201,7 +201,18 @@
"crowdThreat": "CrowdThreat",
"shodanOverlay": "Shodan Overlay",
"aiIntel": "AI Intel",
"sar": "SAR"
"sar": "SAR",
"roadCorridorTrends": "Road Freight Trends",
"roadCorridorSource": "Copernicus S-2 · trends not live"
},
"roadCorridor": {
"analyzeHere": "ANALYZE HERE",
"analyzing": "Analyzing…",
"hintTrends": "Satellite truck-volume trends on major highways (~5 day revisit)",
"missingDeps": "Install backend road-corridor extras to analyze",
"missingCreds": "Add Sentinel creds in Settings → Imagery",
"panMapFirst": "Pan the map to choose an area",
"analyzeFailed": "Analysis failed"
},
"shodan": {
"title": "Shodan Connector",
+12 -1
View File
@@ -201,7 +201,18 @@
"crowdThreat": "CrowdThreat",
"shodanOverlay": "Couche Shodan",
"aiIntel": "Infos IA",
"sar": "SAR"
"sar": "SAR",
"roadCorridorTrends": "Tendances fret routier",
"roadCorridorSource": "Copernicus S-2 · tendances (pas en direct)"
},
"roadCorridor": {
"analyzeHere": "ANALYSER ICI",
"analyzing": "Analyse…",
"hintTrends": "Tendances camions par satellite sur autoroutes (~5 j de revisite)",
"missingDeps": "Installer les extras backend road-corridor",
"missingCreds": "Ajouter les identifiants Sentinel dans Réglages → Imagerie",
"panMapFirst": "Déplacez la carte pour choisir une zone",
"analyzeFailed": "Échec de l'analyse"
},
"shodan": {
"title": "Connecteur Shodan",
+12 -1
View File
@@ -201,7 +201,18 @@
"crowdThreat": "人群威胁",
"shodanOverlay": "Shodan 叠加",
"aiIntel": "AI 情报",
"sar": "SAR"
"sar": "SAR",
"roadCorridorTrends": "公路货运趋势",
"roadCorridorSource": "Copernicus S-2 · 趋势(非实时)"
},
"roadCorridor": {
"analyzeHere": "分析此处",
"analyzing": "分析中…",
"hintTrends": "主要高速公路卫星卡车量趋势(约 5 天重访)",
"missingDeps": "请安装 backend road-corridor 可选依赖",
"missingCreds": "请在设置 → 影像 中添加 Sentinel 凭据",
"panMapFirst": "请先平移地图以选择区域",
"analyzeFailed": "分析失败"
},
"shodan": {
"title": "Shodan 连接器",
+14
View File
@@ -921,6 +921,19 @@ export interface DashboardData {
sar_anomalies?: SarAnomaly[];
sar_aoi_coverage?: SarAoiCoverage[];
sar_aois?: SarAoi[];
road_corridor_trends?: {
updated_at?: string | null;
corridors?: Array<{
preset_id: string;
label: string;
status: string;
total_detections?: number;
daily_counts?: Array<{ date: string; count: number }>;
updated_at?: string;
error?: string | null;
}>;
};
}
// ─── SAR ─────────────────────────────────────────────────────────────────────
@@ -1030,6 +1043,7 @@ export interface ActiveLayers {
ai_intel: boolean;
crowdthreat: boolean;
sar: boolean;
road_corridor_trends: boolean;
}
export interface SelectedEntity {