From ed9b6b53beb6156606ba9aad732fb737200ed063 Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Mon, 25 May 2026 01:20:04 +0000 Subject: [PATCH] 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) --- src/central/gui/routes.py | 12 ++ src/central/gui/templates/_events_rows.html | 1 + src/central/gui/templates/events_list.html | 193 ++++++++++++++------ tests/test_events_bbox_guard.py | 3 +- tests/test_events_filtering.py | 43 +++++ 5 files changed, 196 insertions(+), 56 deletions(-) diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index eee01bf..1bdd2d5 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -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 [] diff --git a/src/central/gui/templates/_events_rows.html b/src/central/gui/templates/_events_rows.html index f31e401..e2b9a43 100644 --- a/src/central/gui/templates/_events_rows.html +++ b/src/central/gui/templates/_events_rows.html @@ -33,6 +33,7 @@ data-event-id="{{ event.id }}" data-adapter="{{ event.adapter }}" data-category="{{ event.category }}" + data-severity="{{ event.severity if event.severity is not none else '' }}" data-time="{{ event.time }}" data-subject="{{ subject_summary | trim }}" {% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}> diff --git a/src/central/gui/templates/events_list.html b/src/central/gui/templates/events_list.html index df80b76..1aacb89 100644 --- a/src/central/gui/templates/events_list.html +++ b/src/central/gui/templates/events_list.html @@ -4,6 +4,8 @@ {% block head %} + + {% endblock %} @@ -199,6 +213,10 @@ + {# Map-filter toggle state. Disabled (omitted from the URL) when off; the + map-controls checkbox below syncs + enables it. #} +
@@ -219,6 +237,10 @@
{% endfor %} + @@ -227,6 +249,7 @@ +