feat(map-rework): fit-to-results, marker clustering, map-filter toggle, shape/opacity encoding (v0.7.2)

PR #3 of the v0.7.x GUI rework arc. Makes the /events Leaflet map readable and
intentional. Production code; central-gui restart only (no adapter change).

- Fit-to-results default: the map now fits the actual event distribution on
  load (previously disabled -> fixed global zoom-4). Empty result set falls
  back to the CONUS setView (no crash). Re-fits after each HTMX swap, but only
  when the map-filter toggle is OFF (when ON the viewport drives the bbox, so
  re-fitting would fight/loop the filter).
- leaflet.markercluster (1.5.3, via CDN): point markers cluster into numbered
  badges (disableClusteringAtZoom=9, showCoverageOnHover=false,
  spiderfyOnMaxZoom=true). markercluster supports point markers only, so
  polygons/lines render in a separate un-clustered featureGroup; fit unions both.
- Map-filter toggle ("Filter table by map view"), default OFF. When off the
  table shows all filter-matching events regardless of map zoom; the backend
  ignores region_* unless map_filter is set (guards bookmarked URLs too). URL
  carries map_filter=1 only when on (hidden input disabled otherwise).
- Per-event_type marker shape (derived event_type = first category segment):
  circle = quake/hydro/space (points), square = fire (areas),
  triangle = wx (NWS alerts/warnings), star = disaster (GDACS/EONET).
  Rendered as divIcon + CSS clip-path; point markers switched from circleMarker
  to L.marker(divIcon) (also required for markercluster compatibility).
- Per-severity opacity: critical(4)=1.0, high(3)=0.85, moderate(2)=0.7,
  low(1)=0.5, unknown(0/NULL)=0.4. Needed adding severity to the _fetch_events
  SELECT + event dict (row.get for mock-tolerance) + a data-severity row attr.

Adds 4 tests (map_filter gating on/off, bbox reaches query only when on,
severity in SELECT); updates test_events_bbox_guard for the new toggle contract.

Full suite: 662 passed, 1 skipped (central and unprivileged zvx). Vanilla JS +
HTMX + Leaflet/markercluster; CSS functional-only (polish deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-05-25 01:20:04 +00:00
commit ed9b6b53be
5 changed files with 196 additions and 56 deletions

View file

@ -2866,6 +2866,14 @@ def _parse_events_params(params, default_time: str | None = None) -> tuple[dict
):
bbox = None
# Map-filter toggle (v0.7.2): the bbox only constrains the query when the
# operator has explicitly enabled "Filter table by map view". When off
# (default), ignore any region_* params (e.g. from a bookmarked URL) so the
# table shows all filter-matching events regardless of map zoom.
map_filter = (params.get("map_filter") or "").lower() in ("1", "true", "on")
if not map_filter:
bbox = None
# Parse cursor
cursor_time = None
cursor_id = None
@ -2893,6 +2901,7 @@ def _parse_events_params(params, default_time: str | None = None) -> tuple[dict
"since": since,
"until": until,
"active": active,
"map_filter": map_filter,
"bbox": bbox,
"cursor_time": cursor_time,
"cursor_id": cursor_id,
@ -3015,6 +3024,7 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
received,
adapter,
category,
severity,
ST_AsGeoJSON(geom) as geometry,
payload as data,
regions
@ -3050,6 +3060,7 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
"received": row["received"].isoformat(),
"adapter": row["adapter"],
"category": row["category"],
"severity": row.get("severity"),
"geometry": geometry,
"data": dict(row["data"]) if row["data"] else {},
"regions": list(row["regions"]) if row["regions"] else [],
@ -3220,6 +3231,7 @@ async def events_list(request: Request) -> HTMLResponse:
"region_south": params.get("region_south", ""),
"region_east": params.get("region_east", ""),
"region_west": params.get("region_west", ""),
"map_filter": pstate.get("map_filter", False),
"limit": str(pstate.get("limit", 50)),
}
active_pills = _build_active_pills(pstate, len(adapters_flat)) if parsed else []