Merge pull request #55 from zvx-echo6/feat/map-rework

feat(map-rework): fit-to-results, marker clustering, map-filter toggle, shape/opacity (v0.7.2)
This commit is contained in:
malice 2026-05-24 19:20:42 -06:00 committed by GitHub
commit 8802f2e45b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 196 additions and 56 deletions

View file

@ -2866,6 +2866,14 @@ def _parse_events_params(params, default_time: str | None = None) -> tuple[dict
): ):
bbox = None bbox = None
# Map-filter toggle (v0.7.2): the bbox only constrains the query when the
# operator has explicitly enabled "Filter table by map view". When off
# (default), ignore any region_* params (e.g. from a bookmarked URL) so the
# table shows all filter-matching events regardless of map zoom.
map_filter = (params.get("map_filter") or "").lower() in ("1", "true", "on")
if not map_filter:
bbox = None
# Parse cursor # Parse cursor
cursor_time = None cursor_time = None
cursor_id = None cursor_id = None
@ -2893,6 +2901,7 @@ def _parse_events_params(params, default_time: str | None = None) -> tuple[dict
"since": since, "since": since,
"until": until, "until": until,
"active": active, "active": active,
"map_filter": map_filter,
"bbox": bbox, "bbox": bbox,
"cursor_time": cursor_time, "cursor_time": cursor_time,
"cursor_id": cursor_id, "cursor_id": cursor_id,
@ -3015,6 +3024,7 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
received, received,
adapter, adapter,
category, category,
severity,
ST_AsGeoJSON(geom) as geometry, ST_AsGeoJSON(geom) as geometry,
payload as data, payload as data,
regions regions
@ -3050,6 +3060,7 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
"received": row["received"].isoformat(), "received": row["received"].isoformat(),
"adapter": row["adapter"], "adapter": row["adapter"],
"category": row["category"], "category": row["category"],
"severity": row.get("severity"),
"geometry": geometry, "geometry": geometry,
"data": dict(row["data"]) if row["data"] else {}, "data": dict(row["data"]) if row["data"] else {},
"regions": list(row["regions"]) if row["regions"] else [], "regions": list(row["regions"]) if row["regions"] else [],
@ -3220,6 +3231,7 @@ async def events_list(request: Request) -> HTMLResponse:
"region_south": params.get("region_south", ""), "region_south": params.get("region_south", ""),
"region_east": params.get("region_east", ""), "region_east": params.get("region_east", ""),
"region_west": params.get("region_west", ""), "region_west": params.get("region_west", ""),
"map_filter": pstate.get("map_filter", False),
"limit": str(pstate.get("limit", 50)), "limit": str(pstate.get("limit", 50)),
} }
active_pills = _build_active_pills(pstate, len(adapters_flat)) if parsed else [] active_pills = _build_active_pills(pstate, len(adapters_flat)) if parsed else []

View file

@ -33,6 +33,7 @@
data-event-id="{{ event.id }}" data-event-id="{{ event.id }}"
data-adapter="{{ event.adapter }}" data-adapter="{{ event.adapter }}"
data-category="{{ event.category }}" data-category="{{ event.category }}"
data-severity="{{ event.severity if event.severity is not none else '' }}"
data-time="{{ event.time }}" data-time="{{ event.time }}"
data-subject="{{ subject_summary | trim }}" data-subject="{{ subject_summary | trim }}"
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}> {% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>

View file

@ -4,6 +4,8 @@
{% 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://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
<style> <style>
#events-map { #events-map {
height: 400px; height: 400px;
@ -138,6 +140,18 @@
.pill-remove, .pill-clear-all { background: none; border: none; cursor: pointer; .pill-remove, .pill-clear-all { background: none; border: none; cursor: pointer;
color: var(--pico-color); padding: 0; font-size: 1rem; line-height: 1; width: auto; } color: var(--pico-color); padding: 0; font-size: 1rem; line-height: 1; width: auto; }
.pill-clear-all { font-size: 0.8rem; text-decoration: underline; } .pill-clear-all { font-size: 0.8rem; text-decoration: underline; }
/* --- v0.7.2 map markers: per-event_type shape + per-severity opacity --- */
.evt-marker { width: 15px; height: 15px; box-sizing: border-box;
border: 1px solid rgba(0,0,0,0.55); }
.evt-circle { border-radius: 50%; }
.evt-square { border-radius: 1px; }
.evt-triangle { border: none; clip-path: polygon(50% 0%, 100% 100%, 0% 100%); }
.evt-star { border: none; clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%,
79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%); }
.evt-marker.evt-hl { filter: drop-shadow(0 0 4px #ff3333); transform: scale(1.35); }
.map-filter-toggle { display: inline-flex; align-items: center; gap: 0.35rem;
font-size: 0.85rem; cursor: pointer; }
.map-filter-toggle input { width: auto; margin: 0; }
</style> </style>
{% endblock %} {% endblock %}
@ -199,6 +213,10 @@
<input type="hidden" id="region_east" name="region_east" value="{{ filter_state.region_east }}"> <input type="hidden" id="region_east" name="region_east" value="{{ filter_state.region_east }}">
<input type="hidden" id="region_west" name="region_west" value="{{ filter_state.region_west }}"> <input type="hidden" id="region_west" name="region_west" value="{{ filter_state.region_west }}">
<input type="hidden" name="limit" value="{{ filter_state.limit }}"> <input type="hidden" name="limit" value="{{ filter_state.limit }}">
{# Map-filter toggle state. Disabled (omitted from the URL) when off; the
map-controls checkbox below syncs + enables it. #}
<input type="hidden" name="map_filter" id="filter-map_filter" value="1"
{{ '' if filter_state.map_filter else 'disabled' }}>
<div class="filter-actions"> <div class="filter-actions">
<button type="submit" class="filter-apply">Apply</button> <button type="submit" class="filter-apply">Apply</button>
@ -219,6 +237,10 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<label class="map-filter-toggle">
<input type="checkbox" id="map-filter-toggle" {{ 'checked' if filter_state.map_filter else '' }}>
Filter table by map view
</label>
<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>
@ -227,6 +249,7 @@
</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://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<script> <script>
(function() { (function() {
var tileUrl = {{ tile_url | tojson }}; var tileUrl = {{ tile_url | tojson }};
@ -291,18 +314,51 @@
maxZoom: 18 maxZoom: 18
}).addTo(map); }).addTo(map);
// Layer group for event geometries // Point markers cluster (v0.7.2); polygons/lines go in a separate
var eventLayerGroup = L.layerGroup().addTo(map); // un-clustered group (markercluster only supports point markers).
var markerCluster = L.markerClusterGroup({
disableClusteringAtZoom: 9,
showCoverageOnHover: false,
spiderfyOnMaxZoom: true
}).addTo(map);
var polyGroup = L.featureGroup().addTo(map);
var highlightedRow = null; var highlightedRow = null;
var highlightedLayer = null; var highlightedLayer = null;
var isInitialLoad = true; var isInitialLoad = true;
var programmaticMove = false; var programmaticMove = false;
// event_type is the first dotted segment of category (disaster/fire/hydro/
// quake/space/wx). Shape encodes it; severity encodes opacity.
var EVENT_TYPE_SHAPE = {
quake: "evt-circle", hydro: "evt-circle", space: "evt-circle",
fire: "evt-square", wx: "evt-triangle", disaster: "evt-star"
};
var SEVERITY_OPACITY = { "4": 1.0, "3": 0.85, "2": 0.7, "1": 0.5, "0": 0.4 };
function eventTypeOf(row) { return (row.dataset.category || "").split(".")[0]; }
function shapeClass(row) { return EVENT_TYPE_SHAPE[eventTypeOf(row)] || "evt-circle"; }
function severityOpacity(row) {
var s = row.dataset.severity;
return (s !== undefined && s !== "" && SEVERITY_OPACITY[s] !== undefined)
? SEVERITY_OPACITY[s] : 0.4; // null / 0 / missing -> unknown
}
function makeDivIcon(row, color) {
var op = severityOpacity(row);
return L.divIcon({
className: "", // suppress Leaflet's default icon styling
html: '<div class="evt-marker ' + shapeClass(row) +
'" style="background:' + color + ';opacity:' + op + '"></div>',
iconSize: [15, 15], iconAnchor: [8, 8]
});
}
// Region input elements // Region input elements
var northInput = document.getElementById("region_north"); var northInput = document.getElementById("region_north");
var southInput = document.getElementById("region_south"); var southInput = document.getElementById("region_south");
var eastInput = document.getElementById("region_east"); var eastInput = document.getElementById("region_east");
var westInput = document.getElementById("region_west"); var westInput = document.getElementById("region_west");
var mapFilterHidden = document.getElementById("filter-map_filter");
var mapFilterToggle = document.getElementById("map-filter-toggle");
function mapFilterOn() { return mapFilterToggle && mapFilterToggle.checked; }
// Viewport-driven filter with debounce // Viewport-driven filter with debounce
var viewportDebounceTimer = null; var viewportDebounceTimer = null;
@ -312,6 +368,8 @@
programmaticMove = false; programmaticMove = false;
return; return;
} }
// Only drive the table from the viewport when the toggle is ON.
if (!mapFilterOn()) return;
if (viewportDebounceTimer) clearTimeout(viewportDebounceTimer); if (viewportDebounceTimer) clearTimeout(viewportDebounceTimer);
viewportDebounceTimer = setTimeout(applyViewportFilter, 400); viewportDebounceTimer = setTimeout(applyViewportFilter, 400);
}); });
@ -361,7 +419,8 @@
} }
function rebindEventLayers() { function rebindEventLayers() {
eventLayerGroup.clearLayers(); markerCluster.clearLayers();
polyGroup.clearLayers();
var rows = document.querySelectorAll("#events-rows tr.event-row[data-geometry]"); var rows = document.querySelectorAll("#events-rows tr.event-row[data-geometry]");
@ -375,33 +434,27 @@
var adapter = row.dataset.adapter || ""; var adapter = row.dataset.adapter || "";
var color = getAdapterColor(adapter); var color = getAdapterColor(adapter);
var op = severityOpacity(row);
var markerStyle = { // Point-like geometries (Points + zero-extent polygons from
radius: 8, // enrichment) become divIcon markers (shape=event_type,
color: color, // opacity=severity) and cluster. Real polygons/lines render as
weight: 2, // geometry in the un-clustered group.
fillColor: color, var layer, isMarker;
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)) { if (isDegenerate(geom)) {
layer = L.circleMarker(centroidLatLng(geom), markerStyle); layer = L.marker(centroidLatLng(geom), { icon: makeDivIcon(row, color) });
isMarker = true;
layer.addTo(markerCluster);
} else { } else {
layer = L.geoJSON(geom, { layer = L.geoJSON(geom, {
style: { style: { color: color, weight: 2, fillColor: color,
color: color, opacity: op, fillOpacity: op * 0.5 },
weight: 2,
fillColor: color,
fillOpacity: 0.25
},
pointToLayer: function(feature, latlng) { pointToLayer: function(feature, latlng) {
return L.circleMarker(latlng, markerStyle); return L.marker(latlng, { icon: makeDivIcon(row, color) });
} }
}); });
isMarker = false;
layer.addTo(polyGroup);
} }
layer.bindPopup(buildPopup(row)); layer.bindPopup(buildPopup(row));
@ -409,29 +462,39 @@
highlightRow(row, layer, color); highlightRow(row, layer, color);
}); });
layer.addTo(eventLayerGroup);
// Store reference for row highlighting
row._mapLayer = layer; row._mapLayer = layer;
row._mapColor = color; row._mapColor = color;
row._isMarker = isMarker;
} catch (e) { } catch (e) {
console.error("Error parsing geometry:", e); console.error("Error parsing geometry:", e);
} }
}); });
} }
// Toggle the red highlight ring on a divIcon marker's element (may be null
// when the marker is collapsed inside a cluster).
function setMarkerHl(layer, on) {
var el = layer.getElement && layer.getElement();
var inner = el && el.querySelector(".evt-marker");
if (inner) inner.classList.toggle("evt-hl", on);
}
function highlightRow(row, layer, originalColor) { function highlightRow(row, layer, originalColor) {
// Reset previous highlight // Reset previous highlight (marker via class, polygon via setStyle).
if (highlightedRow) { if (highlightedRow) {
highlightedRow.classList.remove("highlighted"); highlightedRow.classList.remove("highlighted");
} }
if (highlightedLayer && highlightedLayer._originalColor) { if (highlightedLayer) {
highlightedLayer.setStyle({ if (highlightedLayer._isMarker) {
color: highlightedLayer._originalColor, setMarkerHl(highlightedLayer, false);
weight: 2, } else if (highlightedLayer._originalColor) {
fillColor: highlightedLayer._originalColor, highlightedLayer.setStyle({
fillOpacity: 0.25 color: highlightedLayer._originalColor,
}); weight: 2,
fillColor: highlightedLayer._originalColor,
fillOpacity: 0.25
});
}
} }
// Set new highlight // Set new highlight
@ -439,23 +502,27 @@
highlightedRow = row; highlightedRow = row;
if (layer) { if (layer) {
layer._originalColor = originalColor; layer._isMarker = row._isMarker;
layer.setStyle({ if (layer._isMarker) {
color: "#ff3333", setMarkerHl(layer, true);
weight: 4, } else {
fillColor: "#ff3333", layer._originalColor = originalColor;
fillOpacity: 0.4 layer.setStyle({
}); color: "#ff3333",
weight: 4,
fillColor: "#ff3333",
fillOpacity: 0.4
});
}
highlightedLayer = layer; highlightedLayer = layer;
} }
} }
function fitToAllLayers() { function fitToAllLayers() {
var layers = eventLayerGroup.getLayers(); var layers = markerCluster.getLayers().concat(polyGroup.getLayers());
if (layers.length === 0) return; if (layers.length === 0) return; // empty set -> keep CONUS setView (graceful)
programmaticMove = true; programmaticMove = true;
var group = L.featureGroup(layers); map.fitBounds(L.featureGroup(layers).getBounds(), { padding: [20, 20] });
map.fitBounds(group.getBounds(), { padding: [20, 20] });
} }
// Row click handler (event delegation) // Row click handler (event delegation)
@ -583,26 +650,42 @@
updateSortIndicators(ths); updateSortIndicators(ths);
} }
// Initial load - bind layers and fit bounds // Initial load - bind layers and fit to the actual result distribution
rebindEventLayers(); // Initial load only // (v0.7.2). Empty set -> fitToAllLayers no-ops, keeping the CONUS setView.
rebindEventLayers();
bindSortHandlers(); bindSortHandlers();
if (false) { // DISABLED: map never auto-fits fitToAllLayers();
fitToAllLayers(); isInitialLoad = false;
isInitialLoad = false;
}
// Re-bind layers after HTMX swap so the map tracks the current (filtered / // Re-bind layers after each HTMX swap so the map tracks the current
// paginated) result set. Viewport is preserved — we never auto-fit here. // (filtered / paginated) result set. Re-fit ONLY when the map-filter toggle
// is OFF; when ON the viewport is operator-driven (it feeds the bbox) and
// re-fitting would fight/loop the filter.
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(); rebindEventLayers();
// Re-bind sort handlers to the new rows and re-apply the active sort.
bindSortHandlers(); bindSortHandlers();
applySort(); applySort();
// Do NOT call fitToAllLayers - preserve user viewport if (!mapFilterOn()) fitToAllLayers();
} }
}); });
// Map-filter toggle: sync the form's hidden input (enabled only when ON, so
// it's omitted from the URL when OFF) and apply immediately.
if (mapFilterToggle) {
mapFilterToggle.addEventListener("change", function() {
if (mapFilterToggle.checked) {
mapFilterHidden.disabled = false;
applyViewportFilter(); // sends current bbox + map_filter=1
} else {
mapFilterHidden.disabled = true;
northInput.value = ""; southInput.value = "";
eastInput.value = ""; westInput.value = "";
htmx.trigger(document.getElementById("filter-form"), "submit");
}
});
}
// Fix map rendering after container shows // Fix map rendering after container shows
setTimeout(function() { map.invalidateSize(); }, 100); setTimeout(function() { map.invalidateSize(); }, 100);
})(); })();

View file

@ -27,9 +27,10 @@ def test_out_of_range_bbox_is_dropped_not_errored():
def test_valid_bbox_is_preserved(): def test_valid_bbox_is_preserved():
# v0.7.2: a bbox is only honored when the map-filter toggle is on.
parsed, error = _parse_events_params(_params( parsed, error = _parse_events_params(_params(
region_north="42.0", region_south="31.0", region_north="42.0", region_south="31.0",
region_east="-102.0", region_west="-124.5", region_east="-102.0", region_west="-124.5", map_filter="1",
)) ))
assert error is None assert error is None
assert parsed["bbox"] == { assert parsed["bbox"] == {

View file

@ -208,3 +208,46 @@ def test_url_round_trip():
reparsed, _ = routes._parse_events_params(requery) reparsed, _ = routes._parse_events_params(requery)
for k in ("q", "adapters", "categories", "event_types", "severities", "time_token"): for k in ("q", "adapters", "categories", "event_types", "severities", "time_token"):
assert reparsed[k] == parsed[k] assert reparsed[k] == parsed[k]
# --- map-filter toggle + severity column (v0.7.2) ---------------------------
_REGION = {
"region_north": "42", "region_south": "31", "region_east": "-102", "region_west": "-124.5",
"time": "all",
}
def test_map_filter_off_ignores_bbox():
"""Default (no map_filter): region params are present but the bbox must be
dropped, so the table is not constrained by the map view."""
parsed, err = routes._parse_events_params(dict(_REGION))
assert err is None
assert parsed["map_filter"] is False
assert parsed["bbox"] is None
def test_map_filter_on_applies_bbox():
"""map_filter=1: the region bbox is honored."""
parsed, err = routes._parse_events_params(dict(_REGION, map_filter="1"))
assert err is None
assert parsed["map_filter"] is True
assert parsed["bbox"] == {"north": 42.0, "south": 31.0, "east": -102.0, "west": -124.5}
@pytest.mark.asyncio
async def test_bbox_reaches_query_only_when_map_filter_on():
on, _ = routes._parse_events_params(dict(_REGION, map_filter="1"))
off, _ = routes._parse_events_params(dict(_REGION))
cap_on = await _capture(on)
cap_off = await _capture(off)
assert "ST_Intersects" in cap_on["query"]
assert "ST_Intersects" not in cap_off["query"]
@pytest.mark.asyncio
async def test_select_includes_severity_column():
"""v0.7.2 marker opacity needs severity in the row -> SELECT must fetch it."""
parsed, _ = routes._parse_events_params({"time": "all"})
cap = await _capture(parsed)
assert "severity" in cap["query"]