mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
6cb1d47ed5
commit
e35aade819
1 changed files with 187 additions and 98 deletions
|
|
@ -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 (
|
||||
<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='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, © <a href="https://carto.com/attributions">CARTO</a>'
|
||||
/>
|
||||
<InitialFit bounds={leafletBounds} />
|
||||
{leafletBounds && (
|
||||
<Rectangle
|
||||
bounds={leafletBounds}
|
||||
pathOptions={{
|
||||
color: '#f59e0b',
|
||||
fillColor: '#f59e0b',
|
||||
fillOpacity: 0.08,
|
||||
weight: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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
|
||||
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">
|
||||
<div className="text-xs font-sans font-medium uppercase tracking-widest text-[#666]">
|
||||
Bounding Box Coordinates
|
||||
</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>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* 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]">
|
||||
Coverage Areas
|
||||
</div>
|
||||
<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.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)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue