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 <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-06 12:42:36 -06:00 committed by GitHub
commit e35aade819
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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]]]
// 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 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
const cur: number[] = bbox ? [...bbox] : [-125, 24, -65, 50]
cur[idx] = n
updateBbox(cur as Bbox)
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 (
<div className="space-y-6 max-w-4xl">
{/* Page description + action bar */}
<div className="flex items-start justify-between gap-4">
<p className="text-sm text-[#777]">
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{' '}
<a href="/environment" className="text-accent hover:underline">
Data Feeds
</a>{' '}
@ -289,11 +330,11 @@ export default function Coverage() {
<div className="border border-border p-4 flex items-center justify-between">
<div>
<span className="text-sm font-medium text-[#e0e0e0]">
Use coverage bbox to scope all adapters
Use coverage areas to scope all adapters
</span>
<p className="text-xs text-[#666] mt-0.5">
When disabled, every adapter uses its own geographic config regardless of the
bbox below.
areas below.
</p>
</div>
<button
@ -334,7 +375,7 @@ export default function Coverage() {
className="flex items-center gap-1 px-2 py-1 text-xs bg-accent/20 hover:bg-accent/30 text-accent border border-accent/30 disabled:opacity-40"
>
<MousePointer size={12} />
{bbox ? 'Redraw' : 'Draw bbox'}
Add area
</button>
</div>
</div>
@ -349,18 +390,21 @@ export default function Coverage() {
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, &copy; <a href="https://carto.com/attributions">CARTO</a>'
/>
<InitialFit bounds={leafletBounds} />
{leafletBounds && (
<InitialFit bounds={unionBounds} />
{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 (
<Rectangle
bounds={leafletBounds}
pathOptions={{
color: '#f59e0b',
fillColor: '#f59e0b',
fillOpacity: 0.08,
weight: 2,
}}
key={i}
bounds={bounds}
pathOptions={{ color, fillColor: color, fillOpacity: 0.08, weight: 2 }}
/>
)}
)
})}
<MapClickHandler
mode={clickMode}
firstCorner={firstCorner}
@ -370,29 +414,79 @@ export default function Coverage() {
</MapContainer>
</div>
{/* Numeric W/S/E/N inputs — always visible, sync with map */}
<div className="border border-border p-4 space-y-3">
{/* Areas list / editor */}
<div className="border border-border p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="text-xs font-sans font-medium uppercase tracking-widest text-[#666]">
Bounding Box Coordinates
Coverage Areas
</div>
<div className="grid grid-cols-4 gap-3">
{bboxFields.map(({ label, placeholder }, i) => (
<div key={label}>
<label className="text-xs text-[#777] mb-1 block">{label}</label>
<button
onClick={() => setClickMode('awaiting-first')}
disabled={clickMode !== 'idle'}
className="flex items-center gap-1 px-2 py-1 text-xs bg-accent/20 hover:bg-accent/30 text-accent border border-accent/30 disabled:opacity-40"
>
<Plus size={12} /> Add area
</button>
</div>
{areas.length === 0 ? (
<p className="text-xs text-[#555]">
No areas defined. Draw one on the map or click "Add area" to start.
</p>
) : (
<div className="space-y-3">
{areas.map((area, i) => {
const color = AREA_COLORS[i % AREA_COLORS.length]
return (
<div key={i} className="border border-border p-3 space-y-2">
{/* Name row with color swatch and delete */}
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-sm flex-shrink-0"
style={{ backgroundColor: color }}
/>
<input
type="text"
value={area.name}
onChange={(e) => 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"
/>
<button
onClick={() => deleteArea(i)}
title="Delete area"
className="flex items-center gap-1 px-2 py-1 text-xs text-[#777] hover:text-red-400 border border-border"
>
<Trash2 size={12} />
</button>
</div>
{/* W / S / E / N coordinate inputs */}
<div className="grid grid-cols-4 gap-2">
{(['west', 'south', 'east', 'north'] as const).map((field) => (
<div key={field}>
<label className="text-xs text-[#777] mb-1 block capitalize">
{field}
</label>
<input
type="number"
step="0.01"
value={bbox !== null ? bbox[i] : ''}
placeholder={placeholder}
onChange={(e) => handleBboxInput(i, e.target.value)}
className="w-full bg-[#0d0d0d] border border-border px-3 py-2 text-sm font-mono"
step="0.000001"
value={area[field]}
onChange={(e) => updateArea(i, field, e.target.value)}
className="w-full bg-[#0d0d0d] border border-border px-2 py-1.5 text-xs font-mono"
/>
</div>
))}
</div>
</div>
)
})}
</div>
)}
<p className="text-xs text-[#666]">
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.
</p>
</div>
@ -403,7 +497,7 @@ export default function Coverage() {
Adapter Overrides
</div>
<p className="text-xs text-[#777]">
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{' '}
<a href="/environment" className="text-accent hover:underline">
@ -425,23 +519,18 @@ export default function Coverage() {
</span>
) : (
<span className="text-[10px] text-[#555] uppercase tracking-wide">
coverage bbox
coverage areas
</span>
)}
</div>
<div className="flex items-center gap-3 flex-shrink-0">
{isExcluded && (
<a
href="/environment"
className="text-xs text-accent hover:underline"
>
<a href="/environment" className="text-xs text-accent hover:underline">
Configure
</a>
)}
<label className="flex items-center gap-2 cursor-pointer select-none">
<span className="text-xs text-[#666] whitespace-nowrap">
Use own config
</span>
<span className="text-xs text-[#666] whitespace-nowrap">Use own config</span>
<button
type="button"
onClick={() => toggleExcluded(key)}