mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-21 18:14:44 +02:00
feat(L-b): operator /events tab polish — registry-derived filter, all-adapter map, per-adapter row partials
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1cf1eabb1c
commit
49d85021e8
17 changed files with 328 additions and 33 deletions
|
|
@ -2962,6 +2962,13 @@ async def events_list(request: Request) -> HTMLResponse:
|
||||||
for event in events:
|
for event in events:
|
||||||
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
|
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
|
||||||
|
|
||||||
|
# Registry-derived adapter list for the filter <select> and map legend.
|
||||||
|
# Sorted by name for stable ordering; index drives the legend color palette.
|
||||||
|
adapters = [
|
||||||
|
{"name": cls.name, "display_name": cls.display_name}
|
||||||
|
for cls in sorted(discover_adapters().values(), key=lambda c: c.name)
|
||||||
|
]
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="events_list.html",
|
name="events_list.html",
|
||||||
|
|
@ -2974,6 +2981,7 @@ async def events_list(request: Request) -> HTMLResponse:
|
||||||
"filter_error": error,
|
"filter_error": error,
|
||||||
"tile_url": tile_url,
|
"tile_url": tile_url,
|
||||||
"tile_attribution": tile_attribution,
|
"tile_attribution": tile_attribution,
|
||||||
|
"adapters": adapters,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
2
src/central/gui/templates/_event_rows/_default.html
Normal file
2
src/central/gui/templates/_event_rows/_default.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{# Fallback for adapters without a bespoke partial. No curated fields — the
|
||||||
|
full payload is shown in the "Data" block rendered by _events_rows.html. #}
|
||||||
6
src/central/gui/templates/_event_rows/eonet.html
Normal file
6
src/central/gui/templates/_event_rows/eonet.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{# EONET natural-event tracker. Fields from payload->data->data. #}
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{% if d.get('title') is not none %}<dt>Title</dt><dd>{{ d.title }}</dd>{% endif %}
|
||||||
|
{% if d.get('category_id') is not none %}<dt>Category</dt><dd>{{ d.category_id }}</dd>{% endif %}
|
||||||
|
{% if d.get('magnitudeValue') is not none %}<dt>Magnitude</dt><dd>{{ d.magnitudeValue }} {{ d.get('magnitudeUnit', '') }}</dd>{% endif %}
|
||||||
|
{% if d.get('url') is not none %}<dt>Source</dt><dd><a href="{{ d.url }}" target="_blank" rel="noopener">{{ d.url }}</a></dd>{% endif %}
|
||||||
6
src/central/gui/templates/_event_rows/firms.html
Normal file
6
src/central/gui/templates/_event_rows/firms.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{# NASA FIRMS fire hotspots. Fields from payload->data->data. #}
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{% if d.get('confidence') is not none %}<dt>Confidence</dt><dd>{{ d.confidence }}</dd>{% endif %}
|
||||||
|
{% if d.get('frp') is not none %}<dt>FRP (MW)</dt><dd>{{ d.frp }}</dd>{% endif %}
|
||||||
|
{% if d.get('bright_ti4') is not none %}<dt>Brightness (TI4)</dt><dd>{{ d.bright_ti4 }}</dd>{% endif %}
|
||||||
|
{% if d.get('satellite') is not none %}<dt>Satellite</dt><dd>{{ d.satellite }}{% if d.get('daynight') is not none %} ({{ d.daynight }}){% endif %}</dd>{% endif %}
|
||||||
6
src/central/gui/templates/_event_rows/gdacs.html
Normal file
6
src/central/gui/templates/_event_rows/gdacs.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{# GDACS global disaster alerts. Fields from payload->data->data. #}
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{% if d.get('title') is not none %}<dt>Title</dt><dd>{{ d.title }}</dd>{% endif %}
|
||||||
|
{% if d.get('eventtype') is not none %}<dt>Event type</dt><dd>{{ d.eventtype }}</dd>{% endif %}
|
||||||
|
{% if d.get('alertlevel') is not none %}<dt>Alert level</dt><dd>{{ d.alertlevel }}{% if d.get('alertscore') is not none %} ({{ d.alertscore }}){% endif %}</dd>{% endif %}
|
||||||
|
{% if d.get('country') is not none %}<dt>Country</dt><dd>{{ d.country }}</dd>{% endif %}
|
||||||
5
src/central/gui/templates/_event_rows/inciweb.html
Normal file
5
src/central/gui/templates/_event_rows/inciweb.html
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{# NIFC InciWeb wildfire narrative. Fields from payload->data->data. #}
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{% if d.get('title') is not none %}<dt>Title</dt><dd>{{ d.title }}</dd>{% endif %}
|
||||||
|
{% if d.get('description') is not none %}<dt>Description</dt><dd>{{ d.description | truncate(200) }}</dd>{% endif %}
|
||||||
|
{% if d.get('url') is not none %}<dt>Source</dt><dd><a href="{{ d.url }}" target="_blank" rel="noopener">{{ d.url }}</a></dd>{% endif %}
|
||||||
6
src/central/gui/templates/_event_rows/nwis.html
Normal file
6
src/central/gui/templates/_event_rows/nwis.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{# USGS NWIS water observations. Fields from payload->data->data. #}
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{% if d.get('parameter_code') is not none %}<dt>Parameter</dt><dd>{{ d.parameter_code }}</dd>{% endif %}
|
||||||
|
{% if d.get('value') is not none %}<dt>Value</dt><dd>{{ d.value }} {{ d.get('unit_of_measure', '') }}</dd>{% endif %}
|
||||||
|
{% if d.get('monitoring_location_id') is not none %}<dt>Site</dt><dd><code>{{ d.monitoring_location_id }}</code></dd>{% endif %}
|
||||||
|
{% if d.get('statistic_id') is not none %}<dt>Statistic</dt><dd>{{ d.statistic_id }}</dd>{% endif %}
|
||||||
6
src/central/gui/templates/_event_rows/nws.html
Normal file
6
src/central/gui/templates/_event_rows/nws.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{# NWS weather alerts. Fields from payload->data->data. #}
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{% if d.get('event') is not none %}<dt>Event</dt><dd>{{ d.event }}</dd>{% endif %}
|
||||||
|
{% if d.get('severity') is not none %}<dt>Severity</dt><dd>{{ d.severity }}{% if d.get('certainty') is not none %} / {{ d.certainty }}{% endif %}</dd>{% endif %}
|
||||||
|
{% if d.get('headline') is not none %}<dt>Headline</dt><dd>{{ d.headline }}</dd>{% endif %}
|
||||||
|
{% if d.get('areaDesc') is not none %}<dt>Area</dt><dd>{{ d.areaDesc | truncate(200) }}</dd>{% endif %}
|
||||||
5
src/central/gui/templates/_event_rows/swpc_alerts.html
Normal file
5
src/central/gui/templates/_event_rows/swpc_alerts.html
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{# NOAA SWPC space-weather alerts. Fields from payload->data->data. #}
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{% if d.get('product_id') is not none %}<dt>Product</dt><dd><code>{{ d.product_id }}</code></dd>{% endif %}
|
||||||
|
{% if d.get('issue_datetime') is not none %}<dt>Issued</dt><dd>{{ d.issue_datetime }}</dd>{% endif %}
|
||||||
|
{% if d.get('message') is not none %}<dt>Message</dt><dd>{{ d.message | truncate(240) }}</dd>{% endif %}
|
||||||
6
src/central/gui/templates/_event_rows/swpc_kindex.html
Normal file
6
src/central/gui/templates/_event_rows/swpc_kindex.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{# NOAA SWPC planetary K-index. Fields from payload->data->data. #}
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{% if d.get('Kp') is not none %}<dt>Kp</dt><dd>{{ d.Kp }}</dd>{% endif %}
|
||||||
|
{% if d.get('a_running') is not none %}<dt>A-running</dt><dd>{{ d.a_running }}</dd>{% endif %}
|
||||||
|
{% if d.get('station_count') is not none %}<dt>Stations</dt><dd>{{ d.station_count }}</dd>{% endif %}
|
||||||
|
{% if d.get('time_tag') is not none %}<dt>Time tag</dt><dd>{{ d.time_tag }}</dd>{% endif %}
|
||||||
6
src/central/gui/templates/_event_rows/swpc_protons.html
Normal file
6
src/central/gui/templates/_event_rows/swpc_protons.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{# NOAA SWPC GOES proton flux. Fields from payload->data->data. #}
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{% if d.get('flux') is not none %}<dt>Flux</dt><dd>{{ d.flux }}</dd>{% endif %}
|
||||||
|
{% if d.get('energy') is not none %}<dt>Energy</dt><dd>{{ d.energy }}</dd>{% endif %}
|
||||||
|
{% if d.get('satellite') is not none %}<dt>Satellite</dt><dd>{{ d.satellite }}</dd>{% endif %}
|
||||||
|
{% if d.get('time_tag') is not none %}<dt>Time tag</dt><dd>{{ d.time_tag }}</dd>{% endif %}
|
||||||
6
src/central/gui/templates/_event_rows/usgs_quake.html
Normal file
6
src/central/gui/templates/_event_rows/usgs_quake.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{# USGS earthquakes. Fields from payload->data->data. #}
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{% if d.get('magnitude') is not none %}<dt>Magnitude</dt><dd>{{ d.magnitude }}{% if d.get('magType') is not none %} {{ d.magType }}{% endif %}</dd>{% endif %}
|
||||||
|
{% if d.get('place') is not none %}<dt>Place</dt><dd>{{ d.place }}</dd>{% endif %}
|
||||||
|
{% if d.get('depth') is not none %}<dt>Depth (km)</dt><dd>{{ d.depth }}</dd>{% endif %}
|
||||||
|
{% if d.get('url') is not none %}<dt>Source</dt><dd><a href="{{ d.url }}" target="_blank" rel="noopener">{{ d.url }}</a></dd>{% endif %}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{# NIFC WFIGS wildfire incidents. Fields from payload->data->data. #}
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{% if d.get('state') is not none %}<dt>State</dt><dd>{{ d.state }}</dd>{% endif %}
|
||||||
|
{% if d.get('county') is not none %}<dt>County</dt><dd>{{ d.county }}</dd>{% endif %}
|
||||||
|
{% if d.get('reason') is not none %}<dt>Reason</dt><dd>{{ d.reason }}</dd>{% endif %}
|
||||||
|
{% if d.get('irwin_id') is not none %}<dt>IRWIN ID</dt><dd><code>{{ d.irwin_id }}</code></dd>{% endif %}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{# NIFC WFIGS wildfire perimeters. Fields from payload->data->data. #}
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{% if d.get('state') is not none %}<dt>State</dt><dd>{{ d.state }}</dd>{% endif %}
|
||||||
|
{% if d.get('county') is not none %}<dt>County</dt><dd>{{ d.county }}</dd>{% endif %}
|
||||||
|
{% if d.get('reason') is not none %}<dt>Reason</dt><dd>{{ d.reason }}</dd>{% endif %}
|
||||||
|
{% if d.get('irwin_id') is not none %}<dt>IRWIN ID</dt><dd><code>{{ d.irwin_id }}</code></dd>{% endif %}
|
||||||
|
|
@ -43,6 +43,11 @@
|
||||||
<dt>Regions</dt>
|
<dt>Regions</dt>
|
||||||
<dd>{{ event.regions | join(", ") }}</dd>
|
<dd>{{ event.regions | join(", ") }}</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{# Per-adapter curated fields. Dispatch by adapter name with a
|
||||||
|
generic fallback; the chosen template is whichever exists
|
||||||
|
first, so new adapters fall through to _default.html with
|
||||||
|
no hardcoded adapter list here. #}
|
||||||
|
{% include ["_event_rows/" ~ event.adapter ~ ".html", "_event_rows/_default.html"] %}
|
||||||
<dt>Data</dt>
|
<dt>Data</dt>
|
||||||
<dd><pre class="event-data-pre">{{ event.data | tojson(indent=2) }}</pre></dd>
|
<dd><pre class="event-data-pre">{{ event.data | tojson(indent=2) }}</pre></dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@
|
||||||
}
|
}
|
||||||
.map-legend {
|
.map-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.map-legend-item {
|
.map-legend-item {
|
||||||
|
|
@ -105,6 +106,12 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{# 12-slot legend/marker palette. Adapters map to colors by their sorted index
|
||||||
|
(loop.index0 % palette length) — no per-adapter color is hardcoded. #}
|
||||||
|
{% set palette = [
|
||||||
|
"#f59e0b", "#dc2626", "#7c3aed", "#2563eb", "#059669", "#db2777",
|
||||||
|
"#0891b2", "#65a30d", "#ea580c", "#4f46e5", "#9333ea", "#0d9488"
|
||||||
|
] %}
|
||||||
<h1>Events</h1>
|
<h1>Events</h1>
|
||||||
|
|
||||||
{% if filter_error %}
|
{% if filter_error %}
|
||||||
|
|
@ -123,9 +130,9 @@
|
||||||
<label for="adapter">Adapter</label>
|
<label for="adapter">Adapter</label>
|
||||||
<select id="adapter" name="adapter">
|
<select id="adapter" name="adapter">
|
||||||
<option value="">All</option>
|
<option value="">All</option>
|
||||||
<option value="nws" {% if filter_values.adapter == 'nws' %}selected{% endif %}>nws</option>
|
{% for a in adapters %}
|
||||||
<option value="firms" {% if filter_values.adapter == 'firms' %}selected{% endif %}>firms</option>
|
<option value="{{ a.name }}" {% if filter_values.adapter == a.name %}selected{% endif %}>{{ a.display_name }}</option>
|
||||||
<option value="usgs_quake" {% if filter_values.adapter == 'usgs_quake' %}selected{% endif %}>usgs_quake</option>
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -163,18 +170,12 @@
|
||||||
<div id="events-map"></div>
|
<div id="events-map"></div>
|
||||||
<div class="map-controls">
|
<div class="map-controls">
|
||||||
<div class="map-legend">
|
<div class="map-legend">
|
||||||
|
{% for a in adapters %}
|
||||||
<div class="map-legend-item">
|
<div class="map-legend-item">
|
||||||
<div class="map-legend-swatch" style="background-color: #f59e0b;"></div>
|
<div class="map-legend-swatch" style="background-color: {{ palette[loop.index0 % palette|length] }};"></div>
|
||||||
<span>NWS (Weather)</span>
|
<span>{{ a.display_name }}</span>
|
||||||
</div>
|
|
||||||
<div class="map-legend-item">
|
|
||||||
<div class="map-legend-swatch" style="background-color: #dc2626;"></div>
|
|
||||||
<span>FIRMS (Fire)</span>
|
|
||||||
</div>
|
|
||||||
<div class="map-legend-item">
|
|
||||||
<div class="map-legend-swatch" style="background-color: #7c3aed;"></div>
|
|
||||||
<span>USGS (Quake)</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" id="fit-to-results" class="outline secondary">Fit map to results</button>
|
<button type="button" id="fit-to-results" class="outline secondary">Fit map to results</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -189,17 +190,57 @@
|
||||||
var tileUrl = {{ tile_url | tojson }};
|
var tileUrl = {{ tile_url | tojson }};
|
||||||
var tileAttr = {{ tile_attribution | 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 = {
|
var ADAPTER_COLORS = {
|
||||||
"nws": "#f59e0b",
|
{% for a in adapters %}{{ a.name | tojson }}: PALETTE[{{ loop.index0 }} % PALETTE.length]{{ "," if not loop.last }}
|
||||||
"firms": "#dc2626",
|
{% endfor %}
|
||||||
"usgs_quake": "#7c3aed"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getAdapterColor(adapter) {
|
function getAdapterColor(adapter) {
|
||||||
return ADAPTER_COLORS[adapter] || "#3388ff";
|
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
|
// Initialize map
|
||||||
var map = L.map("events-map").setView([39, -98], 4);
|
var map = L.map("events-map").setView([39, -98], 4);
|
||||||
|
|
||||||
|
|
@ -275,7 +316,22 @@
|
||||||
var adapter = row.dataset.adapter || "";
|
var adapter = row.dataset.adapter || "";
|
||||||
var color = getAdapterColor(adapter);
|
var color = getAdapterColor(adapter);
|
||||||
|
|
||||||
var layer = L.geoJSON(geom, {
|
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: {
|
style: {
|
||||||
color: color,
|
color: color,
|
||||||
weight: 2,
|
weight: 2,
|
||||||
|
|
@ -283,15 +339,10 @@
|
||||||
fillOpacity: 0.25
|
fillOpacity: 0.25
|
||||||
},
|
},
|
||||||
pointToLayer: function(feature, latlng) {
|
pointToLayer: function(feature, latlng) {
|
||||||
return L.circleMarker(latlng, {
|
return L.circleMarker(latlng, markerStyle);
|
||||||
radius: 8,
|
|
||||||
color: color,
|
|
||||||
weight: 2,
|
|
||||||
fillColor: color,
|
|
||||||
fillOpacity: 0.25
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
layer.bindPopup(buildPopup(row));
|
layer.bindPopup(buildPopup(row));
|
||||||
layer.on("click", function() {
|
layer.on("click", function() {
|
||||||
|
|
@ -421,10 +472,11 @@
|
||||||
isInitialLoad = false;
|
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) {
|
document.body.addEventListener("htmx:afterSwap", function(evt) {
|
||||||
if (evt.detail.target.id === "events-rows") {
|
if (evt.detail.target.id === "events-rows") {
|
||||||
// rebindEventLayers(); // DISABLED: map shows all events, only table filters
|
rebindEventLayers();
|
||||||
// Do NOT call fitToAllLayers - preserve user viewport
|
// Do NOT call fitToAllLayers - preserve user viewport
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -684,3 +684,161 @@ class TestEventRowDataAttributes:
|
||||||
assert context["events"][0]["adapter"] == "usgs_quake"
|
assert context["events"][0]["adapter"] == "usgs_quake"
|
||||||
assert context["events"][0]["category"] == "quake.event"
|
assert context["events"][0]["category"] == "quake.event"
|
||||||
assert context["events"][0]["subject"] == "M4.2 Earthquake"
|
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 <select> is driven by discover_adapters(), no hardcoded list."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_filter_options_cover_every_discovered_adapter(self):
|
||||||
|
from central.adapter_discovery import discover_adapters
|
||||||
|
registry = discover_adapters()
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
||||||
|
mock_request.state.csrf_token = "test_csrf"
|
||||||
|
mock_request.query_params = {}
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.fetch.return_value = []
|
||||||
|
mock_conn.fetchrow.return_value = {
|
||||||
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
"map_attribution": "OpenStreetMap",
|
||||||
|
}
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_templates = MagicMock()
|
||||||
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
||||||
|
|
||||||
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
||||||
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||||
|
await events_list(mock_request)
|
||||||
|
|
||||||
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
||||||
|
|
||||||
|
# The list is exactly the registry, sorted by name (stable), no extras.
|
||||||
|
assert [a["name"] for a in context["adapters"]] == sorted(registry.keys())
|
||||||
|
# Each entry carries name + display_name straight from the adapter class.
|
||||||
|
for cls in registry.values():
|
||||||
|
assert {"name": cls.name, "display_name": cls.display_name} in context["adapters"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestPerAdapterRowPartials:
|
||||||
|
"""(C) Per-adapter row partials with registry-derived dispatch + _default fallback."""
|
||||||
|
|
||||||
|
def test_every_discovered_adapter_has_a_partial(self):
|
||||||
|
from central.adapter_discovery import discover_adapters
|
||||||
|
from central.gui import templates as gui_templates
|
||||||
|
# get_template raises TemplateNotFound if a per-adapter file is missing.
|
||||||
|
for name in discover_adapters():
|
||||||
|
gui_templates.env.get_template("_event_rows/%s.html" % name)
|
||||||
|
|
||||||
|
def test_default_fallback_partial_exists(self):
|
||||||
|
from central.gui import templates as gui_templates
|
||||||
|
gui_templates.env.get_template("_event_rows/_default.html")
|
||||||
|
|
||||||
|
def test_every_discovered_adapter_renders_without_error(self):
|
||||||
|
from central.adapter_discovery import discover_adapters
|
||||||
|
for name in discover_adapters():
|
||||||
|
html = _render_rows([_event(name)])
|
||||||
|
assert 'data-adapter="%s"' % name in html
|
||||||
|
|
||||||
|
def test_unknown_adapter_falls_back_to_default(self):
|
||||||
|
# No bespoke partial -> dispatch resolves to _default.html (no crash),
|
||||||
|
# and the raw payload block is still rendered.
|
||||||
|
html = _render_rows([_event("not_a_real_adapter", inner={"foo": "bar"})])
|
||||||
|
assert 'data-adapter="not_a_real_adapter"' in html
|
||||||
|
assert "event-data-pre" in html
|
||||||
|
|
||||||
|
def test_usgs_quake_partial_surfaces_curated_fields(self):
|
||||||
|
html = _render_rows([_event(
|
||||||
|
"usgs_quake",
|
||||||
|
inner={"magnitude": 4.2, "magType": "mb", "place": "10km N of Town", "depth": 5.0},
|
||||||
|
)])
|
||||||
|
assert "Magnitude" in html
|
||||||
|
assert "4.2" in html
|
||||||
|
assert "10km N of Town" in html
|
||||||
|
|
||||||
|
def test_nws_partial_surfaces_curated_fields(self):
|
||||||
|
html = _render_rows([_event(
|
||||||
|
"nws",
|
||||||
|
inner={"event": "Tornado Warning", "headline": "TORNADO", "severity": "Extreme"},
|
||||||
|
)])
|
||||||
|
assert "Tornado Warning" in html
|
||||||
|
assert "Extreme" in html
|
||||||
|
|
||||||
|
|
||||||
|
class TestMapAllAdapterGeometry:
|
||||||
|
"""(B) Every non-null geometry reaches the map; rebind-on-swap is enabled."""
|
||||||
|
|
||||||
|
def test_polygon_geometry_emitted_as_data_geometry(self):
|
||||||
|
poly = {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[-104.18, 37.14], [-103.66, 37.14],
|
||||||
|
[-103.66, 37.43], [-104.18, 37.43], [-104.18, 37.14]]],
|
||||||
|
}
|
||||||
|
html = _render_rows([_event("nws", inner={"event": "x"}, geometry=poly)])
|
||||||
|
assert "data-geometry=" in html
|
||||||
|
assert "Polygon" in html
|
||||||
|
|
||||||
|
def test_event_without_geometry_omits_data_geometry(self):
|
||||||
|
html = _render_rows([_event("swpc_protons", inner={"flux": 1.0})])
|
||||||
|
assert "data-geometry=" not in html
|
||||||
|
|
||||||
|
def test_map_rebinds_on_swap_and_handles_degenerate_geometry(self):
|
||||||
|
# Regression guard for the NWIS-only map: rebind must fire on HTMX swap
|
||||||
|
# and the degenerate-geometry fallback must exist.
|
||||||
|
import pathlib
|
||||||
|
from central.gui import templates as gui_templates
|
||||||
|
src = pathlib.Path(
|
||||||
|
gui_templates.env.loader.searchpath[0], "events_list.html"
|
||||||
|
).read_text()
|
||||||
|
assert "// rebindEventLayers(); // DISABLED" not in src
|
||||||
|
assert "isDegenerate" in src
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue