mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-21 18:14:44 +02:00
feat: events feed UX iteration - colors, popups, viewport filter
A. Color-code polygons by adapter (NWS amber, FIRMS red, USGS violet) B. Click popup on polygons showing time + adapter + category + subject C. Map viewport drives spatial filter - pan/zoom updates table via HTMX D. Add legend showing adapter color mapping E. Remove draw-bbox control, region inputs now hidden (auto-managed) Template changes: - _events_rows.html: add data-adapter, data-category, data-time, data-subject - events_list.html: ADAPTER_COLORS mapping, bindPopup, moveend handler Test: verify template renders adapter/category/subject for JS consumption
This commit is contained in:
parent
55e68d038f
commit
482b53404d
3 changed files with 251 additions and 193 deletions
|
|
@ -18,6 +18,10 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for event in events %}
|
{% for event in events %}
|
||||||
<tr data-row-idx="{{ loop.index0 }}"
|
<tr data-row-idx="{{ loop.index0 }}"
|
||||||
|
data-adapter="{{ event.adapter }}"
|
||||||
|
data-category="{{ event.category }}"
|
||||||
|
data-time="{{ event.time.isoformat() }}"
|
||||||
|
data-subject="{{ event.subject or '' }}"
|
||||||
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>
|
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>
|
||||||
<td>{{ event.time }}</td>
|
<td>{{ event.time }}</td>
|
||||||
<td>{{ event.adapter }}</td>
|
<td>{{ event.adapter }}</td>
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,29 @@
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
|
|
||||||
<style>
|
<style>
|
||||||
#events-map {
|
#events-map {
|
||||||
height: 400px;
|
height: 400px;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.5rem;
|
||||||
border-radius: var(--pico-border-radius);
|
border-radius: var(--pico-border-radius);
|
||||||
}
|
}
|
||||||
|
.map-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 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);
|
||||||
|
}
|
||||||
.events-table {
|
.events-table {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
@ -33,20 +49,6 @@
|
||||||
.filter-form input, .filter-form select {
|
.filter-form input, .filter-form select {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
.region-inputs {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.region-inputs input {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.region-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.pagination-info {
|
.pagination-info {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -67,7 +69,7 @@
|
||||||
|
|
||||||
<details open>
|
<details open>
|
||||||
<summary>Filters</summary>
|
<summary>Filters</summary>
|
||||||
<form class="filter-form" action="/events" method="get"
|
<form id="filter-form" class="filter-form" action="/events" method="get"
|
||||||
hx-get="/events/rows" hx-target="#events-rows" hx-push-url="true">
|
hx-get="/events/rows" hx-target="#events-rows" hx-push-url="true">
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
|
|
@ -97,37 +99,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- Hidden region inputs (managed by map viewport) -->
|
||||||
<label>Region (draw on map or enter coordinates)</label>
|
<input type="hidden" id="region_north" name="region_north" value="{{ filter_values.region_north }}">
|
||||||
<div class="region-controls">
|
<input type="hidden" id="region_south" name="region_south" value="{{ filter_values.region_south }}">
|
||||||
<button type="button" id="clear-region-btn" class="outline secondary" style="width: auto; padding: 0.25rem 0.75rem;">
|
<input type="hidden" id="region_east" name="region_east" value="{{ filter_values.region_east }}">
|
||||||
Clear Region
|
<input type="hidden" id="region_west" name="region_west" value="{{ filter_values.region_west }}">
|
||||||
</button>
|
|
||||||
<small>Draw a rectangle on the map to filter by region</small>
|
|
||||||
</div>
|
|
||||||
<div class="region-inputs">
|
|
||||||
<div>
|
|
||||||
<label for="region_north">N</label>
|
|
||||||
<input type="number" id="region_north" name="region_north" step="0.0001" min="-90" max="90" readonly
|
|
||||||
value="{{ filter_values.region_north }}">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="region_south">S</label>
|
|
||||||
<input type="number" id="region_south" name="region_south" step="0.0001" min="-90" max="90" readonly
|
|
||||||
value="{{ filter_values.region_south }}">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="region_east">E</label>
|
|
||||||
<input type="number" id="region_east" name="region_east" step="0.0001" min="-180" max="180" readonly
|
|
||||||
value="{{ filter_values.region_east }}">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="region_west">W</label>
|
|
||||||
<input type="number" id="region_west" name="region_west" step="0.0001" min="-180" max="180" readonly
|
|
||||||
value="{{ filter_values.region_west }}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input type="hidden" name="limit" value="{{ filter_values.limit }}">
|
<input type="hidden" name="limit" value="{{ filter_values.limit }}">
|
||||||
|
|
||||||
|
|
@ -139,20 +115,44 @@
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div id="events-map"></div>
|
<div id="events-map"></div>
|
||||||
|
<div class="map-legend">
|
||||||
|
<div class="map-legend-item">
|
||||||
|
<div class="map-legend-swatch" style="background-color: #f59e0b;"></div>
|
||||||
|
<span>NWS (Weather)</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>
|
||||||
|
|
||||||
<div id="events-rows">
|
<div id="events-rows">
|
||||||
{% include "_events_rows.html" %}
|
{% include "_events_rows.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
const tileUrl = {{ tile_url | tojson }};
|
var tileUrl = {{ tile_url | tojson }};
|
||||||
const tileAttr = {{ tile_attribution | tojson }};
|
var tileAttr = {{ tile_attribution | tojson }};
|
||||||
|
|
||||||
|
// Adapter color mapping
|
||||||
|
var ADAPTER_COLORS = {
|
||||||
|
"nws": "#f59e0b",
|
||||||
|
"firms": "#dc2626",
|
||||||
|
"usgs_quake": "#7c3aed"
|
||||||
|
};
|
||||||
|
|
||||||
|
function getAdapterColor(adapter) {
|
||||||
|
return ADAPTER_COLORS[adapter] || "#3388ff";
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize map
|
// Initialize map
|
||||||
const map = L.map('events-map').setView([39, -98], 4);
|
var map = L.map("events-map").setView([39, -98], 4);
|
||||||
|
|
||||||
L.tileLayer(tileUrl, {
|
L.tileLayer(tileUrl, {
|
||||||
attribution: tileAttr,
|
attribution: tileAttr,
|
||||||
|
|
@ -160,217 +160,218 @@
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
// Layer groups for event geometries
|
// Layer groups for event geometries
|
||||||
const eventLayers = {};
|
var eventLayers = {};
|
||||||
let highlightedRow = null;
|
var highlightedRow = null;
|
||||||
let highlightedLayer = null;
|
var highlightedLayer = null;
|
||||||
|
|
||||||
// Styles
|
|
||||||
const defaultStyle = {
|
|
||||||
color: '#3388ff',
|
|
||||||
weight: 2,
|
|
||||||
fillOpacity: 0.2
|
|
||||||
};
|
|
||||||
const highlightStyle = {
|
|
||||||
color: '#ff3333',
|
|
||||||
weight: 4,
|
|
||||||
fillOpacity: 0.4
|
|
||||||
};
|
|
||||||
|
|
||||||
// Region filter rectangle
|
|
||||||
let filterRect = null;
|
|
||||||
const drawnItems = new L.FeatureGroup();
|
|
||||||
map.addLayer(drawnItems);
|
|
||||||
|
|
||||||
// Draw control for region filter
|
|
||||||
const drawControl = new L.Control.Draw({
|
|
||||||
draw: {
|
|
||||||
rectangle: { shapeOptions: { color: '#ff7800', weight: 2, fillOpacity: 0.1 } },
|
|
||||||
polyline: false,
|
|
||||||
polygon: false,
|
|
||||||
circle: false,
|
|
||||||
marker: false,
|
|
||||||
circlemarker: false
|
|
||||||
},
|
|
||||||
edit: {
|
|
||||||
featureGroup: drawnItems,
|
|
||||||
edit: false,
|
|
||||||
remove: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
map.addControl(drawControl);
|
|
||||||
|
|
||||||
// Region input elements
|
// Region input elements
|
||||||
const northInput = document.getElementById('region_north');
|
var northInput = document.getElementById("region_north");
|
||||||
const southInput = document.getElementById('region_south');
|
var southInput = document.getElementById("region_south");
|
||||||
const eastInput = document.getElementById('region_east');
|
var eastInput = document.getElementById("region_east");
|
||||||
const westInput = document.getElementById('region_west');
|
var westInput = document.getElementById("region_west");
|
||||||
|
|
||||||
// Update inputs from rectangle
|
// Flag to distinguish programmatic vs user map moves
|
||||||
function updateRegionInputs(bounds) {
|
var programmaticMove = false;
|
||||||
|
var viewportDebounceTimer = null;
|
||||||
|
var isInitialLoad = true;
|
||||||
|
|
||||||
|
// Viewport-driven filter
|
||||||
|
map.on("moveend", function() {
|
||||||
|
if (programmaticMove) {
|
||||||
|
programmaticMove = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isInitialLoad) {
|
||||||
|
isInitialLoad = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (viewportDebounceTimer) clearTimeout(viewportDebounceTimer);
|
||||||
|
viewportDebounceTimer = setTimeout(applyViewportFilter, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyViewportFilter() {
|
||||||
|
var bounds = map.getBounds();
|
||||||
northInput.value = bounds.getNorth().toFixed(4);
|
northInput.value = bounds.getNorth().toFixed(4);
|
||||||
southInput.value = bounds.getSouth().toFixed(4);
|
southInput.value = bounds.getSouth().toFixed(4);
|
||||||
eastInput.value = bounds.getEast().toFixed(4);
|
eastInput.value = bounds.getEast().toFixed(4);
|
||||||
westInput.value = bounds.getWest().toFixed(4);
|
westInput.value = bounds.getWest().toFixed(4);
|
||||||
}
|
document.getElementById("filter-form").dispatchEvent(
|
||||||
|
new Event("submit", { bubbles: true })
|
||||||
// Handle new rectangle drawn
|
|
||||||
map.on(L.Draw.Event.CREATED, function(e) {
|
|
||||||
drawnItems.clearLayers();
|
|
||||||
filterRect = e.layer;
|
|
||||||
filterRect.setStyle({ color: '#ff7800', weight: 2, fillOpacity: 0.1 });
|
|
||||||
drawnItems.addLayer(filterRect);
|
|
||||||
updateRegionInputs(filterRect.getBounds());
|
|
||||||
// Auto-submit the form via HTMX
|
|
||||||
document.querySelector('.filter-form').dispatchEvent(new Event('submit', { bubbles: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear region button
|
|
||||||
document.getElementById('clear-region-btn').addEventListener('click', function() {
|
|
||||||
drawnItems.clearLayers();
|
|
||||||
filterRect = null;
|
|
||||||
northInput.value = '';
|
|
||||||
southInput.value = '';
|
|
||||||
eastInput.value = '';
|
|
||||||
westInput.value = '';
|
|
||||||
// Auto-submit to refresh
|
|
||||||
document.querySelector('.filter-form').dispatchEvent(new Event('submit', { bubbles: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
// If region values exist, show the filter rectangle
|
|
||||||
if (northInput.value && southInput.value && eastInput.value && westInput.value) {
|
|
||||||
const bounds = L.latLngBounds(
|
|
||||||
L.latLng(parseFloat(southInput.value), parseFloat(westInput.value)),
|
|
||||||
L.latLng(parseFloat(northInput.value), parseFloat(eastInput.value))
|
|
||||||
);
|
);
|
||||||
filterRect = L.rectangle(bounds, { color: '#ff7800', weight: 2, fillOpacity: 0.1 });
|
|
||||||
drawnItems.addLayer(filterRect);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to add event geometries to map
|
function loadEventGeometries(fitBounds) {
|
||||||
function loadEventGeometries() {
|
|
||||||
// Clear existing event layers
|
// Clear existing event layers
|
||||||
Object.values(eventLayers).forEach(layer => map.removeLayer(layer));
|
for (var key in eventLayers) {
|
||||||
Object.keys(eventLayers).forEach(key => delete eventLayers[key]);
|
map.removeLayer(eventLayers[key].layer);
|
||||||
|
delete eventLayers[key];
|
||||||
|
}
|
||||||
|
|
||||||
const rows = document.querySelectorAll('#events-rows tr[data-geometry]');
|
var rows = document.querySelectorAll("#events-rows tr[data-geometry]");
|
||||||
const bounds = L.latLngBounds();
|
var bounds = L.latLngBounds();
|
||||||
let hasGeometries = false;
|
var hasGeometries = false;
|
||||||
|
|
||||||
rows.forEach((row, idx) => {
|
rows.forEach(function(row, idx) {
|
||||||
const geomStr = row.dataset.geometry;
|
var geomStr = row.dataset.geometry;
|
||||||
if (!geomStr || geomStr === '') return;
|
if (!geomStr || geomStr === "") return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const geom = JSON.parse(geomStr);
|
var geom = JSON.parse(geomStr);
|
||||||
if (!geom) return;
|
if (!geom) return;
|
||||||
|
|
||||||
const layer = L.geoJSON(geom, {
|
var adapter = row.dataset.adapter || "";
|
||||||
style: defaultStyle,
|
var color = getAdapterColor(adapter);
|
||||||
|
|
||||||
|
var style = {
|
||||||
|
color: color,
|
||||||
|
weight: 2,
|
||||||
|
fillColor: color,
|
||||||
|
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,
|
||||||
...defaultStyle
|
color: color,
|
||||||
|
weight: 2,
|
||||||
|
fillColor: color,
|
||||||
|
fillOpacity: 0.25
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
layer.addTo(map);
|
// Bind popup with event details
|
||||||
eventLayers[idx] = layer;
|
var time = row.dataset.time ? new Date(row.dataset.time).toLocaleString() : "";
|
||||||
|
var category = row.dataset.category || "";
|
||||||
|
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 };
|
||||||
|
|
||||||
// Extend bounds
|
|
||||||
try {
|
try {
|
||||||
bounds.extend(layer.getBounds());
|
bounds.extend(layer.getBounds());
|
||||||
hasGeometries = true;
|
hasGeometries = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Point geometries might not have getBounds
|
if (geom.type === "Point" && geom.coordinates) {
|
||||||
if (geom.type === 'Point' && geom.coordinates) {
|
|
||||||
bounds.extend(L.latLng(geom.coordinates[1], geom.coordinates[0]));
|
bounds.extend(L.latLng(geom.coordinates[1], geom.coordinates[0]));
|
||||||
hasGeometries = true;
|
hasGeometries = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click on geometry highlights row
|
layer.on("click", function(e) {
|
||||||
layer.on('click', function() {
|
highlightRow(row, eventLayers[idx]);
|
||||||
highlightRow(row, layer);
|
L.DomEvent.stopPropagation(e);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing geometry:', e);
|
console.error("Error parsing geometry:", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fit map to all geometries
|
if (hasGeometries && fitBounds) {
|
||||||
if (hasGeometries) {
|
programmaticMove = true;
|
||||||
map.fitBounds(bounds.pad(0.1));
|
map.fitBounds(bounds.pad(0.1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Highlight a row and its geometry
|
function highlightRow(row, entry) {
|
||||||
function highlightRow(row, layer) {
|
|
||||||
// Reset previous highlight
|
|
||||||
if (highlightedRow) {
|
if (highlightedRow) {
|
||||||
highlightedRow.classList.remove('highlighted');
|
highlightedRow.classList.remove("highlighted");
|
||||||
}
|
}
|
||||||
if (highlightedLayer) {
|
if (highlightedLayer) {
|
||||||
highlightedLayer.setStyle(defaultStyle);
|
for (var key in eventLayers) {
|
||||||
}
|
if (eventLayers[key].layer === highlightedLayer) {
|
||||||
|
highlightedLayer.setStyle({
|
||||||
// Set new highlight
|
color: eventLayers[key].color,
|
||||||
row.classList.add('highlighted');
|
weight: 2,
|
||||||
highlightedRow = row;
|
fillColor: eventLayers[key].color,
|
||||||
|
fillOpacity: 0.25
|
||||||
if (layer) {
|
});
|
||||||
layer.setStyle(highlightStyle);
|
break;
|
||||||
highlightedLayer = layer;
|
|
||||||
// Pan to geometry
|
|
||||||
try {
|
|
||||||
map.fitBounds(layer.getBounds().pad(0.2));
|
|
||||||
} catch (e) {
|
|
||||||
// For points
|
|
||||||
const geom = JSON.parse(row.dataset.geometry);
|
|
||||||
if (geom && geom.type === 'Point' && geom.coordinates) {
|
|
||||||
map.setView([geom.coordinates[1], geom.coordinates[0]], 10);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
row.classList.add("highlighted");
|
||||||
|
highlightedRow = row;
|
||||||
|
|
||||||
|
if (entry && entry.layer) {
|
||||||
|
entry.layer.setStyle({
|
||||||
|
color: "#ff3333",
|
||||||
|
weight: 4,
|
||||||
|
fillColor: "#ff3333",
|
||||||
|
fillOpacity: 0.4
|
||||||
|
});
|
||||||
|
highlightedLayer = entry.layer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Row hover/click handlers
|
|
||||||
function attachRowHandlers() {
|
function attachRowHandlers() {
|
||||||
const rows = document.querySelectorAll('#events-rows tr[data-row-idx]');
|
var rows = document.querySelectorAll("#events-rows tr[data-row-idx]");
|
||||||
rows.forEach(row => {
|
rows.forEach(function(row) {
|
||||||
const idx = parseInt(row.dataset.rowIdx);
|
var idx = parseInt(row.dataset.rowIdx);
|
||||||
row.addEventListener('click', function() {
|
row.addEventListener("click", function() {
|
||||||
const layer = eventLayers[idx];
|
var entry = eventLayers[idx];
|
||||||
highlightRow(row, layer);
|
if (entry) {
|
||||||
});
|
highlightRow(row, entry);
|
||||||
row.addEventListener('mouseenter', function() {
|
try {
|
||||||
const layer = eventLayers[idx];
|
programmaticMove = true;
|
||||||
if (layer && layer !== highlightedLayer) {
|
map.fitBounds(entry.layer.getBounds().pad(0.2));
|
||||||
layer.setStyle({ ...defaultStyle, weight: 3 });
|
} catch (e) {
|
||||||
|
var geomStr = row.dataset.geometry;
|
||||||
|
if (geomStr) {
|
||||||
|
var geom = JSON.parse(geomStr);
|
||||||
|
if (geom && geom.type === "Point" && geom.coordinates) {
|
||||||
|
programmaticMove = true;
|
||||||
|
map.setView([geom.coordinates[1], geom.coordinates[0]], 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
row.addEventListener('mouseleave', function() {
|
row.addEventListener("mouseenter", function() {
|
||||||
const layer = eventLayers[idx];
|
var entry = eventLayers[idx];
|
||||||
if (layer && layer !== highlightedLayer) {
|
if (entry && entry.layer !== highlightedLayer) {
|
||||||
layer.setStyle(defaultStyle);
|
entry.layer.setStyle({
|
||||||
|
color: entry.color,
|
||||||
|
weight: 3,
|
||||||
|
fillColor: entry.color,
|
||||||
|
fillOpacity: 0.25
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
row.addEventListener("mouseleave", function() {
|
||||||
|
var entry = eventLayers[idx];
|
||||||
|
if (entry && entry.layer !== highlightedLayer) {
|
||||||
|
entry.layer.setStyle({
|
||||||
|
color: entry.color,
|
||||||
|
weight: 2,
|
||||||
|
fillColor: entry.color,
|
||||||
|
fillOpacity: 0.25
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial load
|
loadEventGeometries(true);
|
||||||
loadEventGeometries();
|
|
||||||
attachRowHandlers();
|
attachRowHandlers();
|
||||||
|
|
||||||
// Re-attach handlers after HTMX swap
|
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);
|
||||||
loadEventGeometries();
|
|
||||||
attachRowHandlers();
|
attachRowHandlers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fix map rendering after container shows
|
|
||||||
setTimeout(function() { map.invalidateSize(); }, 100);
|
setTimeout(function() { map.invalidateSize(); }, 100);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -631,3 +631,56 @@ class TestErrorSemantics:
|
||||||
assert context["filter_error"] is not None
|
assert context["filter_error"] is not None
|
||||||
assert "limit" in context["filter_error"].lower()
|
assert "limit" in context["filter_error"].lower()
|
||||||
assert context["events"] == []
|
assert context["events"] == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventRowDataAttributes:
|
||||||
|
"""Test that _events_rows.html renders required data attributes."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_row_renders_data_adapter_attribute(self):
|
||||||
|
"""Event rows include data-adapter attribute for color coding."""
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
||||||
|
mock_request.state.csrf_token = "test_csrf"
|
||||||
|
mock_request.query_params = {}
|
||||||
|
|
||||||
|
mock_events = [
|
||||||
|
{
|
||||||
|
"id": "test1",
|
||||||
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
||||||
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
||||||
|
"adapter": "usgs_quake",
|
||||||
|
"category": "quake.event",
|
||||||
|
"subject": "M4.2 Earthquake",
|
||||||
|
"geometry": None,
|
||||||
|
"data": {},
|
||||||
|
"regions": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.fetch.return_value = mock_events
|
||||||
|
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):
|
||||||
|
result = await events_list(mock_request)
|
||||||
|
|
||||||
|
assert result.status_code == 200
|
||||||
|
mock_templates.TemplateResponse.assert_called_once()
|
||||||
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
||||||
|
# The template receives events with adapter field for data-adapter attribute
|
||||||
|
assert len(context["events"]) == 1
|
||||||
|
assert context["events"][0]["adapter"] == "usgs_quake"
|
||||||
|
assert context["events"][0]["category"] == "quake.event"
|
||||||
|
assert context["events"][0]["subject"] == "M4.2 Earthquake"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue