From e35aade819ed2c91e005b5b0b6e0a5f51c8ba924 Mon Sep 17 00:00:00 2001 From: malice Date: Mon, 6 Jul 2026 12:42:36 -0600 Subject: [PATCH] feat(coverage): multi-box Coverage page (draw several areas, set-union) (#69) Upgrades the Coverage page from one bbox to a list of named areas, matching the backend coverage.areas / Shapely set-union gate. Draw multiple boxes, name/edit/delete each, all rendered on the map; saves config.coverage.areas (clears legacy bbox). Coords rounded to 6dp. Enabled + per-adapter override toggles unchanged. Co-authored-by: Matt Johnson Co-authored-by: Claude Opus 4.8 (1M context) --- .../dashboard-frontend/src/pages/Coverage.tsx | 285 ++++++++++++------ 1 file changed, 187 insertions(+), 98 deletions(-) diff --git a/work/dashboard-frontend/src/pages/Coverage.tsx b/work/dashboard-frontend/src/pages/Coverage.tsx index f75aed3..9d0c2d8 100644 --- a/work/dashboard-frontend/src/pages/Coverage.tsx +++ b/work/dashboard-frontend/src/pages/Coverage.tsx @@ -9,7 +9,7 @@ import { } from 'react-leaflet' import type { LatLngTuple } from 'leaflet' import 'leaflet/dist/leaflet.css' -import { Save, RotateCcw, MousePointer } from 'lucide-react' +import { Save, RotateCcw, MousePointer, Plus, Trash2 } from 'lucide-react' import { notifyRestartRequired } from '@/components/RestartBanner' import { useDirty } from '@/context/DirtyContext' @@ -22,17 +22,30 @@ import markerShadow from 'leaflet/dist/images/marker-shadow.png' delete L.Icon.Default.prototype._getIconUrl L.Icon.Default.mergeOptions({ iconUrl: markerIcon, iconRetinaUrl: markerIcon2x, shadowUrl: markerShadow }) -// [W, S, E, N] = [minLon, minLat, maxLon, maxLat] -type Bbox = [number, number, number, number] +// Round Leaflet's ~14-decimal coords to 6dp (some downstream APIs reject >7) +const round6 = (n: number) => Math.round(n * 1_000_000) / 1_000_000 + +// Per-area rectangle colors for the map +const AREA_COLORS = ['#f59e0b', '#60a5fa', '#34d399', '#a78bfa', '#f87171', '#fb923c'] + type ClickMode = 'idle' | 'awaiting-first' | 'awaiting-second' +interface Area { + name: string + west: number + south: number + east: number + north: number +} + interface CoverageConfig { bbox: number[] enabled: boolean excluded_adapters: string[] + areas: Area[] } -// Adapters that participate in the coverage bbox system +// Adapters that participate in the coverage area system const COVERAGE_ADAPTERS: { key: string; label: string }[] = [ { key: 'fires', label: 'NIFC Fire Perimeters' }, { key: 'nws', label: 'NWS Weather Alerts' }, @@ -110,10 +123,20 @@ export default function Coverage() { return r.json() }) .then((data) => { - const cov: CoverageConfig = data.coverage ?? { - bbox: [], - enabled: true, - excluded_adapters: [], + const raw = data.coverage ?? { bbox: [], enabled: true, excluded_adapters: [], areas: [] } + // Normalize areas; back-compat: seed from legacy bbox if areas is empty + let areas: Area[] = Array.isArray(raw.areas) ? (raw.areas as Area[]) : [] + if (areas.length === 0 && Array.isArray(raw.bbox) && raw.bbox.length === 4) { + const [w, s, e, n] = raw.bbox as number[] + areas = [{ name: 'Area 1', west: w, south: s, east: e, north: n }] + } + const cov: CoverageConfig = { + bbox: Array.isArray(raw.bbox) ? (raw.bbox as number[]) : [], + enabled: (raw.enabled as boolean) ?? true, + excluded_adapters: Array.isArray(raw.excluded_adapters) + ? (raw.excluded_adapters as string[]) + : [], + areas, } setConfig(cov) setOriginal(JSON.stringify(cov)) @@ -129,52 +152,66 @@ export default function Coverage() { return () => setDirty(false) }, [hasChanges, setDirty]) - // Derive typed Bbox from raw config.bbox - const bbox: Bbox | null = - config?.bbox?.length === 4 - ? [config.bbox[0], config.bbox[1], config.bbox[2], config.bbox[3]] - : null + const areas = config?.areas ?? [] - // Leaflet expects [[S, W], [N, E]] - const leafletBounds: [[number, number], [number, number]] | null = bbox - ? [[bbox[1], bbox[0]], [bbox[3], bbox[2]]] - : null + // Union bounds for initial map fit: [[S, W], [N, E]] in Leaflet convention + const unionBounds: [[number, number], [number, number]] | null = + areas.length > 0 + ? [ + [Math.min(...areas.map((a) => a.south)), Math.min(...areas.map((a) => a.west))], + [Math.max(...areas.map((a) => a.north)), Math.max(...areas.map((a) => a.east))], + ] + : null // Default center: continental US const defaultCenter: LatLngTuple = [39.5, -98.35] - const updateBbox = useCallback((b: Bbox) => { - setConfig((prev) => (prev ? { ...prev, bbox: b } : prev)) - }, []) - const handleFirstClick = useCallback((pt: LatLngTuple) => { setFirstCorner(pt) setClickMode('awaiting-second') }, []) + // Second click: complete draw and ADD a new area to the list const handleSecondClick = useCallback( (pt: LatLngTuple) => { if (!firstCorner) return const [lat1, lon1] = firstCorner const [lat2, lon2] = pt - updateBbox([ - Math.min(lon1, lon2), // W - Math.min(lat1, lat2), // S - Math.max(lon1, lon2), // E - Math.max(lat1, lat2), // N - ]) + setConfig((prev) => { + if (!prev) return prev + const newArea: Area = { + name: `Area ${prev.areas.length + 1}`, + west: round6(Math.min(lon1, lon2)), + south: round6(Math.min(lat1, lat2)), + east: round6(Math.max(lon1, lon2)), + north: round6(Math.max(lat1, lat2)), + } + return { ...prev, areas: [...prev.areas, newArea] } + }) setFirstCorner(null) setClickMode('idle') }, - [firstCorner, updateBbox], + [firstCorner], ) - const handleBboxInput = (idx: number, val: string) => { - const n = parseFloat(val) - if (isNaN(n)) return - const cur: number[] = bbox ? [...bbox] : [-125, 24, -65, 50] - cur[idx] = n - updateBbox(cur as Bbox) + const updateArea = (idx: number, field: keyof Area, val: string) => { + setConfig((prev) => { + if (!prev) return prev + const updated = prev.areas.map((a, i) => { + if (i !== idx) return a + if (field === 'name') return { ...a, name: val } + const n = parseFloat(val) + if (isNaN(n)) return a + return { ...a, [field]: n } as Area + }) + return { ...prev, areas: updated } + }) + } + + const deleteArea = (idx: number) => { + setConfig((prev) => + prev ? { ...prev, areas: prev.areas.filter((_, i) => i !== idx) } : prev, + ) } const toggleExcluded = (key: string) => { @@ -188,7 +225,7 @@ export default function Coverage() { const discard = () => { if (original) { - setConfig(JSON.parse(original)) + setConfig(JSON.parse(original) as CoverageConfig) setClickMode('idle') setFirstCorner(null) } @@ -203,15 +240,25 @@ export default function Coverage() { const res = await fetch('/api/config/coverage', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config), + // Send areas as authoritative; clear legacy bbox so areas takes precedence + body: JSON.stringify({ + areas: config.areas, + bbox: [], + enabled: config.enabled, + excluded_adapters: config.excluded_adapters, + }), }) const result = await res.json() - if (!res.ok) throw new Error(result.detail || 'Save failed') + if (!res.ok) throw new Error((result as { detail?: string }).detail || 'Save failed') setOriginal(JSON.stringify(config)) setSuccess('Coverage saved') setTimeout(() => setSuccess(null), 3000) - if (result.restart_required) { - notifyRestartRequired(Array.isArray(result.changed_keys) ? result.changed_keys : []) + if ((result as { restart_required?: boolean }).restart_required) { + notifyRestartRequired( + Array.isArray((result as { changed_keys?: unknown }).changed_keys) + ? ((result as { changed_keys: string[] }).changed_keys) + : [], + ) } } catch (e) { setError(e instanceof Error ? e.message : 'Save failed') @@ -237,27 +284,21 @@ export default function Coverage() { const drawHint = clickMode === 'awaiting-first' - ? 'Click the first corner of the bbox on the map…' + ? 'Click the first corner of the new area on the map…' : clickMode === 'awaiting-second' - ? 'Click the opposite corner to complete the bbox…' - : bbox - ? `W ${bbox[0].toFixed(3)} S ${bbox[1].toFixed(3)} E ${bbox[2].toFixed(3)} N ${bbox[3].toFixed(3)}` - : 'No bbox set — draw one on the map or enter coordinates below.' - - const bboxFields: { label: string; placeholder: string }[] = [ - { label: 'West', placeholder: 'e.g. -125' }, - { label: 'South', placeholder: 'e.g. 24' }, - { label: 'East', placeholder: 'e.g. -65' }, - { label: 'North', placeholder: 'e.g. 50' }, - ] + ? 'Click the opposite corner to complete the area…' + : areas.length > 0 + ? `${areas.length} area${areas.length !== 1 ? 's' : ''} defined` + : 'No areas defined — draw one on the map or enter coordinates below.' return (
{/* Page description + action bar */}

- One bounding box scopes every native adapter's geographic focus. Adapters with - "Use own config" on ignore this bbox and use the geographic settings on the{' '} + Define one or more bounding boxes that scope every native adapter's geographic + focus (set-union of all areas). Adapters with "Use own config" on ignore these + areas and use the geographic settings on the{' '} Data Feeds {' '} @@ -289,11 +330,11 @@ export default function Coverage() {

- Use coverage bbox to scope all adapters + Use coverage areas to scope all adapters

When disabled, every adapter uses its own geographic config regardless of the - bbox below. + areas below.

@@ -349,18 +390,21 @@ export default function Coverage() { url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" attribution='© OpenStreetMap, © CARTO' /> - - {leafletBounds && ( - - )} + + {areas.map((area, i) => { + const color = AREA_COLORS[i % AREA_COLORS.length] + const bounds: [[number, number], [number, number]] = [ + [area.south, area.west], + [area.north, area.east], + ] + return ( + + ) + })}
- {/* Numeric W/S/E/N inputs — always visible, sync with map */} -
-
- Bounding Box Coordinates -
-
- {bboxFields.map(({ label, placeholder }, i) => ( -
- - handleBboxInput(i, e.target.value)} - className="w-full bg-[#0d0d0d] border border-border px-3 py-2 text-sm font-mono" - /> -
- ))} + {/* Areas list / editor */} +
+
+
+ Coverage Areas +
+
+ + {areas.length === 0 ? ( +

+ No areas defined. Draw one on the map or click "Add area" to start. +

+ ) : ( +
+ {areas.map((area, i) => { + const color = AREA_COLORS[i % AREA_COLORS.length] + return ( +
+ {/* Name row with color swatch and delete */} +
+ + updateArea(i, 'name', e.target.value)} + className="flex-1 bg-[#0d0d0d] border border-border px-2 py-1 text-sm font-medium text-[#e0e0e0]" + placeholder="Area name" + /> + +
+ {/* W / S / E / N coordinate inputs */} +
+ {(['west', 'south', 'east', 'north'] as const).map((field) => ( +
+ + updateArea(i, field, e.target.value)} + className="w-full bg-[#0d0d0d] border border-border px-2 py-1.5 text-xs font-mono" + /> +
+ ))} +
+
+ ) + })} +
+ )} +

- Decimal degrees. W/E = longitude; S/N = latitude. Inputs stay in sync with - the map rectangle above. + Decimal degrees. W/E = longitude; S/N = latitude. Each area is a bounding box; + the coverage filter uses the set-union of all areas. Drawn coordinates are + rounded to 6 decimal places.

@@ -403,7 +497,7 @@ export default function Coverage() { Adapter Overrides

- Toggle "Use own config" to have that adapter ignore the coverage bbox. + Toggle "Use own config" to have that adapter ignore the coverage areas. Its geographic settings (state, bbox, corridors, observers…) then become active on the{' '} @@ -425,23 +519,18 @@ export default function Coverage() { ) : ( - coverage bbox + coverage areas )}

{isExcluded && ( - + Configure )}