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
parent 76f4deb3a7
commit b64b9e0962
33 changed files with 3127 additions and 21 deletions
@@ -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"