central/src/central/gui/templates/events_list.html
2026-05-21 05:45:15 +00:00

489 lines
16 KiB
HTML

{% extends "base.html" %}
{% block title %}Events - Central{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
#events-map {
height: 400px;
margin-bottom: 0.5rem;
border-radius: var(--pico-border-radius);
}
.map-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.map-legend {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
font-size: 0.85rem;
}
.map-legend-item {
display: flex;
align-items: center;
gap: 0.25rem;
}
.map-legend-swatch {
width: 16px;
height: 16px;
border-radius: 3px;
border: 1px solid rgba(0,0,0,0.2);
}
#fit-to-results {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
}
.events-table {
font-size: 0.9rem;
}
.events-table td {
vertical-align: middle;
}
.events-table tr.event-row:hover {
background-color: var(--pico-primary-focus);
cursor: pointer;
}
.events-table tr.event-row.highlighted {
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 {
margin-bottom: 0.5rem;
}
.filter-form label {
margin-bottom: 0.25rem;
}
.filter-form input, .filter-form select {
margin-bottom: 0.5rem;
}
.pagination-info {
margin-top: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
{% endblock %}
{% 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>
{% if filter_error %}
<article aria-label="Filter Error" style="background-color: var(--pico-del-color); padding: 1rem; margin-bottom: 1rem;">
<strong>Filter Error:</strong> {{ filter_error }}
</article>
{% endif %}
<details open>
<summary>Filters</summary>
<form id="filter-form" class="filter-form" action="/events" method="get"
hx-get="/events/rows" hx-target="#events-rows" hx-push-url="true">
<div class="grid">
<div>
<label for="adapter">Adapter</label>
<select id="adapter" name="adapter">
<option value="">All</option>
{% for a in adapters %}
<option value="{{ a.name }}" {% if filter_values.adapter == a.name %}selected{% endif %}>{{ a.display_name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="category">Category</label>
<input type="text" id="category" name="category" placeholder="Exact match"
value="{{ filter_values.category }}">
</div>
<div>
<label for="since">From</label>
<input type="datetime-local" id="since" name="since"
value="{{ filter_values.since }}">
</div>
<div>
<label for="until">To</label>
<input type="datetime-local" id="until" name="until"
value="{{ filter_values.until }}">
</div>
</div>
<!-- Hidden region inputs (managed by map viewport) -->
<input type="hidden" id="region_north" name="region_north" value="{{ filter_values.region_north }}">
<input type="hidden" id="region_south" name="region_south" value="{{ filter_values.region_south }}">
<input type="hidden" id="region_east" name="region_east" value="{{ filter_values.region_east }}">
<input type="hidden" id="region_west" name="region_west" value="{{ filter_values.region_west }}">
<input type="hidden" name="limit" value="{{ filter_values.limit }}">
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<button type="submit">Apply</button>
<a href="/events" role="button" class="outline">Clear Filters</a>
</div>
</form>
</details>
<div id="events-map"></div>
<div class="map-controls">
<div class="map-legend">
{% for a in adapters %}
<div class="map-legend-item">
<div class="map-legend-swatch" style="background-color: {{ palette[loop.index0 % palette|length] }};"></div>
<span>{{ a.display_name }}</span>
</div>
{% endfor %}
</div>
<button type="button" id="fit-to-results" class="outline secondary">Fit map to results</button>
</div>
<div id="events-rows">
{% include "_events_rows.html" %}
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
(function() {
var tileUrl = {{ tile_url | tojson }};
var tileAttr = {{ tile_attribution | tojson }};
// 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 = {
{% 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);
L.tileLayer(tileUrl, {
attribution: tileAttr,
maxZoom: 18
}).addTo(map);
// Layer group for event geometries
var eventLayerGroup = L.layerGroup().addTo(map);
var highlightedRow = null;
var highlightedLayer = null;
var isInitialLoad = true;
var programmaticMove = false;
// Region input elements
var northInput = document.getElementById("region_north");
var southInput = document.getElementById("region_south");
var eastInput = document.getElementById("region_east");
var westInput = document.getElementById("region_west");
// Viewport-driven filter with debounce
var viewportDebounceTimer = null;
map.on("moveend", function() {
if (programmaticMove) {
programmaticMove = false;
return;
}
if (viewportDebounceTimer) clearTimeout(viewportDebounceTimer);
viewportDebounceTimer = setTimeout(applyViewportFilter, 400);
});
function applyViewportFilter() {
var bounds = map.getBounds();
northInput.value = bounds.getNorth().toFixed(4);
southInput.value = bounds.getSouth().toFixed(4);
eastInput.value = bounds.getEast().toFixed(4);
westInput.value = bounds.getWest().toFixed(4);
htmx.trigger(document.getElementById("filter-form"), "submit");
}
function buildPopup(row) {
var adapter = row.dataset.adapter || "";
var category = row.dataset.category || "";
var time = row.dataset.time ? new Date(row.dataset.time).toLocaleString() : "";
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;
}
function rebindEventLayers() {
eventLayerGroup.clearLayers();
var rows = document.querySelectorAll("#events-rows tr.event-row[data-geometry]");
rows.forEach(function(row) {
var geomStr = row.dataset.geometry;
if (!geomStr || geomStr === "") return;
try {
var geom = JSON.parse(geomStr);
if (!geom) return;
var adapter = row.dataset.adapter || "";
var color = getAdapterColor(adapter);
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() {
highlightRow(row, layer, color);
});
layer.addTo(eventLayerGroup);
// Store reference for row highlighting
row._mapLayer = layer;
row._mapColor = color;
} catch (e) {
console.error("Error parsing geometry:", e);
}
});
}
function highlightRow(row, layer, originalColor) {
// Reset previous highlight
if (highlightedRow) {
highlightedRow.classList.remove("highlighted");
}
if (highlightedLayer && highlightedLayer._originalColor) {
highlightedLayer.setStyle({
color: highlightedLayer._originalColor,
weight: 2,
fillColor: highlightedLayer._originalColor,
fillOpacity: 0.25
});
}
// Set new highlight
row.classList.add("highlighted");
highlightedRow = row;
if (layer) {
layer._originalColor = originalColor;
layer.setStyle({
color: "#ff3333",
weight: 4,
fillColor: "#ff3333",
fillOpacity: 0.4
});
highlightedLayer = layer;
}
}
function fitToAllLayers() {
var layers = eventLayerGroup.getLayers();
if (layers.length === 0) return;
programmaticMove = true;
var group = L.featureGroup(layers);
map.fitBounds(group.getBounds(), { padding: [20, 20] });
}
// 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 (no auto-pan - user controls viewport)
var row = e.target.closest("tr.event-row");
if (row && row._mapLayer) {
highlightRow(row, row._mapLayer, row._mapColor);
// Map stays where user put it
}
});
// Row hover handlers (event delegation)
document.addEventListener("mouseenter", function(e) {
var row = e.target.closest && e.target.closest("tr.event-row");
if (row && row._mapLayer && row._mapLayer !== highlightedLayer) {
row._mapLayer.setStyle({
color: row._mapColor,
weight: 3,
fillColor: row._mapColor,
fillOpacity: 0.25
});
}
}, true);
document.addEventListener("mouseleave", function(e) {
var row = e.target.closest && e.target.closest("tr.event-row");
if (row && row._mapLayer && row._mapLayer !== highlightedLayer) {
row._mapLayer.setStyle({
color: row._mapColor,
weight: 2,
fillColor: row._mapColor,
fillOpacity: 0.25
});
}
}, true);
// Fit to results button
document.getElementById("fit-to-results").addEventListener("click", fitToAllLayers);
// Initial load - bind layers and fit bounds
rebindEventLayers(); // Initial load only
if (false) { // DISABLED: map never auto-fits
fitToAllLayers();
isInitialLoad = false;
}
// 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();
// Do NOT call fitToAllLayers - preserve user viewport
}
});
// Fix map rendering after container shows
setTimeout(function() { map.invalidateSize(); }, 100);
})();
</script>
{% endblock %}