diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index e6bb580..7673e21 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -2962,6 +2962,13 @@ async def events_list(request: Request) -> HTMLResponse: for event in events: event["geometry_summary"] = _geometry_summary(event.get("geometry")) + # Registry-derived adapter list for the filter - - - + {% for a in adapters %} + + {% endfor %}
@@ -163,18 +170,12 @@
+ {% for a in adapters %}
-
- NWS (Weather) -
-
-
- FIRMS (Fire) -
-
-
- USGS (Quake) +
+ {{ a.display_name }}
+ {% endfor %}
@@ -189,17 +190,57 @@ var tileUrl = {{ tile_url | tojson }}; var tileAttr = {{ tile_attribution | tojson }}; - // Adapter color mapping + // Adapter color mapping — built from the registry-derived adapter list and + // the same palette the legend uses, keyed by sorted index. No adapter name + // or color is hardcoded here. + var PALETTE = {{ palette | tojson }}; var ADAPTER_COLORS = { - "nws": "#f59e0b", - "firms": "#dc2626", - "usgs_quake": "#7c3aed" + {% for a in adapters %}{{ a.name | tojson }}: PALETTE[{{ loop.index0 }} % PALETTE.length]{{ "," if not loop.last }} + {% endfor %} }; function getAdapterColor(adapter) { return ADAPTER_COLORS[adapter] || "#3388ff"; } + // Flatten arbitrarily-nested GeoJSON coordinates into a flat [lng, lat] list. + function flattenCoords(coords, out) { + if (coords.length && typeof coords[0] === "number") { + out.push(coords); + return; + } + for (var i = 0; i < coords.length; i++) { + flattenCoords(coords[i], out); + } + } + + // A geometry whose vertices all collapse to a single point (zero extent in + // both dimensions). Enrichment stores some point-like sources as degenerate + // polygons; Leaflet would draw these invisibly, so we plot a marker instead. + function isDegenerate(geom) { + if (!geom || !geom.coordinates) return false; + var pts = []; + flattenCoords(geom.coordinates, pts); + if (pts.length === 0) return false; + var minX = pts[0][0], maxX = pts[0][0], minY = pts[0][1], maxY = pts[0][1]; + for (var i = 1; i < pts.length; i++) { + if (pts[i][0] < minX) minX = pts[i][0]; + if (pts[i][0] > maxX) maxX = pts[i][0]; + if (pts[i][1] < minY) minY = pts[i][1]; + if (pts[i][1] > maxY) maxY = pts[i][1]; + } + return (maxX - minX) < 1e-9 && (maxY - minY) < 1e-9; + } + + // Mean of all vertices, returned as Leaflet [lat, lng]. + function centroidLatLng(geom) { + var pts = []; + flattenCoords(geom.coordinates, pts); + var sx = 0, sy = 0; + for (var i = 0; i < pts.length; i++) { sx += pts[i][0]; sy += pts[i][1]; } + return [sy / pts.length, sx / pts.length]; + } + // Initialize map var map = L.map("events-map").setView([39, -98], 4); @@ -275,23 +316,33 @@ var adapter = row.dataset.adapter || ""; var color = getAdapterColor(adapter); - var layer = L.geoJSON(geom, { - style: { - color: color, - weight: 2, - fillColor: color, - fillOpacity: 0.25 - }, - pointToLayer: function(feature, latlng) { - return L.circleMarker(latlng, { - radius: 8, + var markerStyle = { + radius: 8, + color: color, + weight: 2, + fillColor: color, + fillOpacity: 0.25 + }; + + // Real polygons/lines render as geometry; zero-extent geometries + // (degenerate polygons from enrichment) render as a point marker + // so every non-null geometry is actually visible on the map. + var layer; + if (isDegenerate(geom)) { + layer = L.circleMarker(centroidLatLng(geom), markerStyle); + } else { + layer = L.geoJSON(geom, { + style: { color: color, weight: 2, fillColor: color, fillOpacity: 0.25 - }); - } - }); + }, + pointToLayer: function(feature, latlng) { + return L.circleMarker(latlng, markerStyle); + } + }); + } layer.bindPopup(buildPopup(row)); layer.on("click", function() { @@ -421,10 +472,11 @@ isInitialLoad = false; } - // Re-bind layers after HTMX swap (but do NOT fit bounds) + // Re-bind layers after HTMX swap so the map tracks the current (filtered / + // paginated) result set. Viewport is preserved — we never auto-fit here. document.body.addEventListener("htmx:afterSwap", function(evt) { if (evt.detail.target.id === "events-rows") { - // rebindEventLayers(); // DISABLED: map shows all events, only table filters + rebindEventLayers(); // Do NOT call fitToAllLayers - preserve user viewport } }); diff --git a/tests/test_events_feed_frontend.py b/tests/test_events_feed_frontend.py index 2ec1700..ce13739 100644 --- a/tests/test_events_feed_frontend.py +++ b/tests/test_events_feed_frontend.py @@ -684,3 +684,161 @@ class TestEventRowDataAttributes: assert context["events"][0]["adapter"] == "usgs_quake" assert context["events"][0]["category"] == "quake.event" assert context["events"][0]["subject"] == "M4.2 Earthquake" + + +# --- PR L-b: operator /events tab polish --------------------------------- + + +def _events_context(events): + """Minimal context for rendering _events_rows.html as a standalone fragment.""" + return { + "events": events, + "next_cursor": None, + "filter_error": None, + "filter_values": { + "adapter": "", "category": "", "since": "", "until": "", + "region_north": "", "region_south": "", "region_east": "", + "region_west": "", "limit": "50", + }, + } + + +def _event(adapter, inner=None, geometry=None): + """Build an event dict matching _fetch_events output shape. + + `inner` populates payload->data->data (the adapter-specific payload) at + event["data"]["data"]["data"], which the per-adapter partials read. + """ + return { + "id": "evt-" + adapter, + "time": "2026-05-17T12:00:00+00:00", + "received": "2026-05-17T12:00:00+00:00", + "adapter": adapter, + "category": adapter + ".test", + "subject": "subject", + "geometry": geometry, + "geometry_summary": "", + "data": {"data": {"data": inner or {}}}, + "regions": [], + } + + +def _render_rows(events): + """Render _events_rows.html through the real Jinja environment.""" + from central.gui import templates as gui_templates + return gui_templates.env.get_template("_events_rows.html").render( + **_events_context(events) + ) + + +class TestRegistryDrivenAdapterFilter: + """(A) Adapter filter