mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-21 18:14:44 +02:00
489 lines
16 KiB
HTML
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 ▾</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 ? "▸" : "▾";
|
|
}
|
|
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 = "▾";
|
|
}
|
|
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 %}
|