feat: full events feed UX iteration

A. Color-code polygons by adapter with legend
B. Click popup on polygons with "View details" link
C. Viewport-driven spatial filter - pan/zoom updates table via HTMX
   Map never auto-fits after initial load (user controls viewport)
D. Expandable row details showing full event data payload

Changes:
- _events_rows.html: add data-event-id, expand button, detail row
- events_list.html: eventLayerGroup pattern, buildPopup, rebindEventLayers
  Fit to results button, expand/collapse handlers, CSS.escape for IDs
This commit is contained in:
Matt Johnson 2026-05-18 18:00:21 +00:00
commit d17a97dd7e
2 changed files with 227 additions and 143 deletions

View file

@ -8,6 +8,7 @@
<table class="events-table"> <table class="events-table">
<thead> <thead>
<tr> <tr>
<th style="width: 2rem;"></th>
<th>Time</th> <th>Time</th>
<th>Adapter</th> <th>Adapter</th>
<th>Category</th> <th>Category</th>
@ -17,18 +18,36 @@
</thead> </thead>
<tbody> <tbody>
{% for event in events %} {% for event in events %}
<tr data-row-idx="{{ loop.index0 }}" <tr class="event-row" data-row-idx="{{ loop.index0 }}"
data-event-id="{{ event.id }}"
data-adapter="{{ event.adapter }}" data-adapter="{{ event.adapter }}"
data-category="{{ event.category }}" data-category="{{ event.category }}"
data-time="{{ event.time }}" data-time="{{ event.time }}"
data-subject="{{ event.subject or '' }}" data-subject="{{ event.subject or '' }}"
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}> {% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>
<td><button type="button" class="expand-row" aria-label="Expand">&#9656;</button></td>
<td>{{ event.time }}</td> <td>{{ event.time }}</td>
<td>{{ event.adapter }}</td> <td>{{ event.adapter }}</td>
<td>{{ event.category }}</td> <td>{{ event.category }}</td>
<td>{{ event.geometry_summary }}</td> <td>{{ event.geometry_summary }}</td>
<td>{{ event.subject or '—' }}</td> <td>{{ event.subject or '—' }}</td>
</tr> </tr>
<tr class="event-detail" hidden>
<td colspan="6">
<dl class="event-detail-list">
<dt>Event ID</dt>
<dd><code>{{ event.id }}</code></dd>
<dt>Received</dt>
<dd>{{ event.received }}</dd>
{% if event.regions %}
<dt>Regions</dt>
<dd>{{ event.regions | join(", ") }}</dd>
{% endif %}
<dt>Data</dt>
<dd><pre class="event-data-pre">{{ event.data | tojson(indent=2) }}</pre></dd>
</dl>
</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -10,10 +10,15 @@
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
border-radius: var(--pico-border-radius); border-radius: var(--pico-border-radius);
} }
.map-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.map-legend { .map-legend {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
margin-bottom: 1rem;
font-size: 0.85rem; font-size: 0.85rem;
} }
.map-legend-item { .map-legend-item {
@ -27,19 +32,60 @@
border-radius: 3px; border-radius: 3px;
border: 1px solid rgba(0,0,0,0.2); border: 1px solid rgba(0,0,0,0.2);
} }
#fit-to-results {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
}
.events-table { .events-table {
font-size: 0.9rem; font-size: 0.9rem;
} }
.events-table td { .events-table td {
vertical-align: middle; vertical-align: middle;
} }
.events-table tr:hover { .events-table tr.event-row:hover {
background-color: var(--pico-primary-focus); background-color: var(--pico-primary-focus);
cursor: pointer; cursor: pointer;
} }
.events-table tr.highlighted { .events-table tr.event-row.highlighted {
background-color: var(--pico-primary-background); background-color: var(--pico-primary-background);
} }
.expand-row {
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
font-size: 0.9rem;
color: var(--pico-color);
}
.expand-row:hover {
color: var(--pico-primary);
}
.event-detail td {
background-color: var(--pico-card-background-color);
padding: 1rem;
}
.event-detail-list {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
margin: 0;
}
.event-detail-list dt {
font-weight: 600;
color: var(--pico-muted-color);
}
.event-detail-list dd {
margin: 0;
}
.event-data-pre {
max-height: 300px;
overflow: auto;
font-size: 0.8rem;
background: var(--pico-code-background-color);
padding: 0.5rem;
border-radius: var(--pico-border-radius);
margin: 0;
}
.filter-form .grid { .filter-form .grid {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@ -115,6 +161,7 @@
</details> </details>
<div id="events-map"></div> <div id="events-map"></div>
<div class="map-controls">
<div class="map-legend"> <div class="map-legend">
<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: #f59e0b;"></div>
@ -129,6 +176,8 @@
<span>USGS (Quake)</span> <span>USGS (Quake)</span>
</div> </div>
</div> </div>
<button type="button" id="fit-to-results" class="outline secondary">Fit map to results</button>
</div>
<div id="events-rows"> <div id="events-rows">
{% include "_events_rows.html" %} {% include "_events_rows.html" %}
@ -159,10 +208,11 @@
maxZoom: 18 maxZoom: 18
}).addTo(map); }).addTo(map);
// Layer groups for event geometries // Layer group for event geometries
var eventLayers = {}; var eventLayerGroup = L.layerGroup().addTo(map);
var highlightedRow = null; var highlightedRow = null;
var highlightedLayer = null; var highlightedLayer = null;
var isInitialLoad = true;
// Region input elements // Region input elements
var northInput = document.getElementById("region_north"); var northInput = document.getElementById("region_north");
@ -170,21 +220,10 @@
var eastInput = document.getElementById("region_east"); var eastInput = document.getElementById("region_east");
var westInput = document.getElementById("region_west"); var westInput = document.getElementById("region_west");
// Flag to distinguish programmatic vs user map moves // Viewport-driven filter with debounce
var programmaticMove = false;
var viewportDebounceTimer = null; var viewportDebounceTimer = null;
var isInitialLoad = true;
// Viewport-driven filter
map.on("moveend", function() { map.on("moveend", function() {
if (programmaticMove) {
programmaticMove = false;
return;
}
if (isInitialLoad) {
isInitialLoad = false;
return;
}
if (viewportDebounceTimer) clearTimeout(viewportDebounceTimer); if (viewportDebounceTimer) clearTimeout(viewportDebounceTimer);
viewportDebounceTimer = setTimeout(applyViewportFilter, 400); viewportDebounceTimer = setTimeout(applyViewportFilter, 400);
}); });
@ -200,18 +239,29 @@
); );
} }
function loadEventGeometries(fitBounds) { function buildPopup(row) {
// Clear existing event layers var adapter = row.dataset.adapter || "";
for (var key in eventLayers) { var category = row.dataset.category || "";
map.removeLayer(eventLayers[key].layer); var time = row.dataset.time ? new Date(row.dataset.time).toLocaleString() : "";
delete eventLayers[key]; var subject = row.dataset.subject || "";
var eventId = row.dataset.eventId || "";
var html = "<strong>" + adapter + "</strong><br>" +
category + "<br>" +
"<small>" + time + "</small>";
if (subject) {
html += "<br><em>" + subject + "</em>";
}
html += '<br><a href="#" data-show-row="' + eventId + '">View details &#9662;</a>';
return html;
} }
var rows = document.querySelectorAll("#events-rows tr[data-geometry]"); function rebindEventLayers() {
var bounds = L.latLngBounds(); eventLayerGroup.clearLayers();
var hasGeometries = false;
rows.forEach(function(row, idx) { var rows = document.querySelectorAll("#events-rows tr.event-row[data-geometry]");
rows.forEach(function(row) {
var geomStr = row.dataset.geometry; var geomStr = row.dataset.geometry;
if (!geomStr || geomStr === "") return; if (!geomStr || geomStr === "") return;
@ -222,15 +272,13 @@
var adapter = row.dataset.adapter || ""; var adapter = row.dataset.adapter || "";
var color = getAdapterColor(adapter); var color = getAdapterColor(adapter);
var style = { var layer = L.geoJSON(geom, {
style: {
color: color, color: color,
weight: 2, weight: 2,
fillColor: color, fillColor: color,
fillOpacity: 0.25 fillOpacity: 0.25
}; },
var layer = L.geoJSON(geom, {
style: style,
pointToLayer: function(feature, latlng) { pointToLayer: function(feature, latlng) {
return L.circleMarker(latlng, { return L.circleMarker(latlng, {
radius: 8, radius: 8,
@ -242,136 +290,153 @@
} }
}); });
// Bind popup with event details layer.bindPopup(buildPopup(row));
var time = row.dataset.time ? new Date(row.dataset.time).toLocaleString() : ""; layer.on("click", function() {
var category = row.dataset.category || ""; highlightRow(row, layer, color);
var subject = row.dataset.subject || "";
var popupContent = "<strong>" + adapter + "</strong><br>" +
category + "<br>" +
"<small>" + time + "</small>";
if (subject) {
popupContent += "<br><em>" + subject + "</em>";
}
layer.bindPopup(popupContent);
layer.addTo(map);
eventLayers[idx] = { layer: layer, color: color };
try {
bounds.extend(layer.getBounds());
hasGeometries = true;
} catch (e) {
if (geom.type === "Point" && geom.coordinates) {
bounds.extend(L.latLng(geom.coordinates[1], geom.coordinates[0]));
hasGeometries = true;
}
}
layer.on("click", function(e) {
highlightRow(row, eventLayers[idx]);
L.DomEvent.stopPropagation(e);
}); });
layer.addTo(eventLayerGroup);
// Store reference for row highlighting
row._mapLayer = layer;
row._mapColor = color;
} catch (e) { } catch (e) {
console.error("Error parsing geometry:", e); console.error("Error parsing geometry:", e);
} }
}); });
if (hasGeometries && fitBounds) {
programmaticMove = true;
map.fitBounds(bounds.pad(0.1));
}
} }
function highlightRow(row, entry) { function highlightRow(row, layer, originalColor) {
// Reset previous highlight
if (highlightedRow) { if (highlightedRow) {
highlightedRow.classList.remove("highlighted"); highlightedRow.classList.remove("highlighted");
} }
if (highlightedLayer) { if (highlightedLayer && highlightedLayer._originalColor) {
for (var key in eventLayers) {
if (eventLayers[key].layer === highlightedLayer) {
highlightedLayer.setStyle({ highlightedLayer.setStyle({
color: eventLayers[key].color, color: highlightedLayer._originalColor,
weight: 2, weight: 2,
fillColor: eventLayers[key].color, fillColor: highlightedLayer._originalColor,
fillOpacity: 0.25 fillOpacity: 0.25
}); });
break;
}
}
} }
// Set new highlight
row.classList.add("highlighted"); row.classList.add("highlighted");
highlightedRow = row; highlightedRow = row;
if (entry && entry.layer) { if (layer) {
entry.layer.setStyle({ layer._originalColor = originalColor;
layer.setStyle({
color: "#ff3333", color: "#ff3333",
weight: 4, weight: 4,
fillColor: "#ff3333", fillColor: "#ff3333",
fillOpacity: 0.4 fillOpacity: 0.4
}); });
highlightedLayer = entry.layer; highlightedLayer = layer;
} }
} }
function attachRowHandlers() { function fitToAllLayers() {
var rows = document.querySelectorAll("#events-rows tr[data-row-idx]"); var layers = eventLayerGroup.getLayers();
rows.forEach(function(row) { if (layers.length === 0) return;
var idx = parseInt(row.dataset.rowIdx); var group = L.featureGroup(layers);
row.addEventListener("click", function() { map.fitBounds(group.getBounds(), { padding: [20, 20] });
var entry = eventLayers[idx]; }
if (entry) {
highlightRow(row, entry); // Row click handler (event delegation)
document.addEventListener("click", function(e) {
// Expand/collapse handler
if (e.target.classList.contains("expand-row")) {
var row = e.target.closest("tr");
var detail = row.nextElementSibling;
if (detail && detail.classList.contains("event-detail")) {
detail.hidden = !detail.hidden;
e.target.innerHTML = detail.hidden ? "&#9656;" : "&#9662;";
}
return;
}
// Popup "View details" link handler
if (e.target.matches("[data-show-row]")) {
e.preventDefault();
var eventId = e.target.dataset.showRow;
var targetRow = document.querySelector(
'tr[data-event-id="' + CSS.escape(eventId) + '"]'
);
if (!targetRow) return;
targetRow.scrollIntoView({ behavior: "smooth", block: "center" });
var detail = targetRow.nextElementSibling;
if (detail && detail.classList.contains("event-detail")) {
detail.hidden = false;
var button = targetRow.querySelector(".expand-row");
if (button) button.innerHTML = "&#9662;";
}
return;
}
// Row click to highlight and pan
var row = e.target.closest("tr.event-row");
if (row && row._mapLayer) {
highlightRow(row, row._mapLayer, row._mapColor);
try { try {
programmaticMove = true; map.fitBounds(row._mapLayer.getBounds(), { padding: [50, 50] });
map.fitBounds(entry.layer.getBounds().pad(0.2)); } catch (err) {
} catch (e) { // Point geometries
var geomStr = row.dataset.geometry; var geomStr = row.dataset.geometry;
if (geomStr) { if (geomStr) {
var geom = JSON.parse(geomStr); var geom = JSON.parse(geomStr);
if (geom && geom.type === "Point" && geom.coordinates) { if (geom && geom.type === "Point" && geom.coordinates) {
programmaticMove = true;
map.setView([geom.coordinates[1], geom.coordinates[0]], 10); map.setView([geom.coordinates[1], geom.coordinates[0]], 10);
} }
} }
} }
} }
}); });
row.addEventListener("mouseenter", function() {
var entry = eventLayers[idx]; // Row hover handlers (event delegation)
if (entry && entry.layer !== highlightedLayer) { document.addEventListener("mouseenter", function(e) {
entry.layer.setStyle({ var row = e.target.closest && e.target.closest("tr.event-row");
color: entry.color, if (row && row._mapLayer && row._mapLayer !== highlightedLayer) {
row._mapLayer.setStyle({
color: row._mapColor,
weight: 3, weight: 3,
fillColor: entry.color, fillColor: row._mapColor,
fillOpacity: 0.25 fillOpacity: 0.25
}); });
} }
}); }, true);
row.addEventListener("mouseleave", function() {
var entry = eventLayers[idx]; document.addEventListener("mouseleave", function(e) {
if (entry && entry.layer !== highlightedLayer) { var row = e.target.closest && e.target.closest("tr.event-row");
entry.layer.setStyle({ if (row && row._mapLayer && row._mapLayer !== highlightedLayer) {
color: entry.color, row._mapLayer.setStyle({
color: row._mapColor,
weight: 2, weight: 2,
fillColor: entry.color, fillColor: row._mapColor,
fillOpacity: 0.25 fillOpacity: 0.25
}); });
} }
}); }, true);
});
// Fit to results button
document.getElementById("fit-to-results").addEventListener("click", fitToAllLayers);
// Initial load - bind layers and fit bounds
rebindEventLayers();
if (isInitialLoad) {
fitToAllLayers();
isInitialLoad = false;
} }
loadEventGeometries(true); // Re-bind layers after HTMX swap (but do NOT fit bounds)
attachRowHandlers();
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") {
loadEventGeometries(true); rebindEventLayers();
attachRowHandlers(); // Do NOT call fitToAllLayers - preserve user viewport
} }
}); });
// Fix map rendering after container shows
setTimeout(function() { map.invalidateSize(); }, 100); setTimeout(function() { map.invalidateSize(); }, 100);
})(); })();
</script> </script>