mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 20:04:43 +02:00
feat(map-rework): fit-to-results, marker clustering, map-filter toggle, shape/opacity encoding (v0.7.2)
PR #3 of the v0.7.x GUI rework arc. Makes the /events Leaflet map readable and intentional. Production code; central-gui restart only (no adapter change). - Fit-to-results default: the map now fits the actual event distribution on load (previously disabled -> fixed global zoom-4). Empty result set falls back to the CONUS setView (no crash). Re-fits after each HTMX swap, but only when the map-filter toggle is OFF (when ON the viewport drives the bbox, so re-fitting would fight/loop the filter). - leaflet.markercluster (1.5.3, via CDN): point markers cluster into numbered badges (disableClusteringAtZoom=9, showCoverageOnHover=false, spiderfyOnMaxZoom=true). markercluster supports point markers only, so polygons/lines render in a separate un-clustered featureGroup; fit unions both. - Map-filter toggle ("Filter table by map view"), default OFF. When off the table shows all filter-matching events regardless of map zoom; the backend ignores region_* unless map_filter is set (guards bookmarked URLs too). URL carries map_filter=1 only when on (hidden input disabled otherwise). - Per-event_type marker shape (derived event_type = first category segment): circle = quake/hydro/space (points), square = fire (areas), triangle = wx (NWS alerts/warnings), star = disaster (GDACS/EONET). Rendered as divIcon + CSS clip-path; point markers switched from circleMarker to L.marker(divIcon) (also required for markercluster compatibility). - Per-severity opacity: critical(4)=1.0, high(3)=0.85, moderate(2)=0.7, low(1)=0.5, unknown(0/NULL)=0.4. Needed adding severity to the _fetch_events SELECT + event dict (row.get for mock-tolerance) + a data-severity row attr. Adds 4 tests (map_filter gating on/off, bbox reaches query only when on, severity in SELECT); updates test_events_bbox_guard for the new toggle contract. Full suite: 662 passed, 1 skipped (central and unprivileged zvx). Vanilla JS + HTMX + Leaflet/markercluster; CSS functional-only (polish deferred). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b3b61d8f44
commit
ed9b6b53be
5 changed files with 196 additions and 56 deletions
|
|
@ -2866,6 +2866,14 @@ def _parse_events_params(params, default_time: str | None = None) -> tuple[dict
|
|||
):
|
||||
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
|
||||
cursor_time = None
|
||||
cursor_id = None
|
||||
|
|
@ -2893,6 +2901,7 @@ def _parse_events_params(params, default_time: str | None = None) -> tuple[dict
|
|||
"since": since,
|
||||
"until": until,
|
||||
"active": active,
|
||||
"map_filter": map_filter,
|
||||
"bbox": bbox,
|
||||
"cursor_time": cursor_time,
|
||||
"cursor_id": cursor_id,
|
||||
|
|
@ -3015,6 +3024,7 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
|||
received,
|
||||
adapter,
|
||||
category,
|
||||
severity,
|
||||
ST_AsGeoJSON(geom) as geometry,
|
||||
payload as data,
|
||||
regions
|
||||
|
|
@ -3050,6 +3060,7 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
|||
"received": row["received"].isoformat(),
|
||||
"adapter": row["adapter"],
|
||||
"category": row["category"],
|
||||
"severity": row.get("severity"),
|
||||
"geometry": geometry,
|
||||
"data": dict(row["data"]) if row["data"] 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_east": params.get("region_east", ""),
|
||||
"region_west": params.get("region_west", ""),
|
||||
"map_filter": pstate.get("map_filter", False),
|
||||
"limit": str(pstate.get("limit", 50)),
|
||||
}
|
||||
active_pills = _build_active_pills(pstate, len(adapters_flat)) if parsed else []
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
data-event-id="{{ event.id }}"
|
||||
data-adapter="{{ event.adapter }}"
|
||||
data-category="{{ event.category }}"
|
||||
data-severity="{{ event.severity if event.severity is not none else '' }}"
|
||||
data-time="{{ event.time }}"
|
||||
data-subject="{{ subject_summary | trim }}"
|
||||
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
{% block head %}
|
||||
<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>
|
||||
#events-map {
|
||||
height: 400px;
|
||||
|
|
@ -138,6 +140,18 @@
|
|||
.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; }
|
||||
.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>
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -199,6 +213,10 @@
|
|||
<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" 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">
|
||||
<button type="submit" class="filter-apply">Apply</button>
|
||||
|
|
@ -219,6 +237,10 @@
|
|||
</div>
|
||||
{% endfor %}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
|
@ -227,6 +249,7 @@
|
|||
</div>
|
||||
|
||||
<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>
|
||||
(function() {
|
||||
var tileUrl = {{ tile_url | tojson }};
|
||||
|
|
@ -291,18 +314,51 @@
|
|||
maxZoom: 18
|
||||
}).addTo(map);
|
||||
|
||||
// Layer group for event geometries
|
||||
var eventLayerGroup = L.layerGroup().addTo(map);
|
||||
// Point markers cluster (v0.7.2); polygons/lines go in a separate
|
||||
// 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 highlightedLayer = null;
|
||||
var isInitialLoad = true;
|
||||
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
|
||||
var northInput = document.getElementById("region_north");
|
||||
var southInput = document.getElementById("region_south");
|
||||
var eastInput = document.getElementById("region_east");
|
||||
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
|
||||
var viewportDebounceTimer = null;
|
||||
|
|
@ -312,6 +368,8 @@
|
|||
programmaticMove = false;
|
||||
return;
|
||||
}
|
||||
// Only drive the table from the viewport when the toggle is ON.
|
||||
if (!mapFilterOn()) return;
|
||||
if (viewportDebounceTimer) clearTimeout(viewportDebounceTimer);
|
||||
viewportDebounceTimer = setTimeout(applyViewportFilter, 400);
|
||||
});
|
||||
|
|
@ -361,7 +419,8 @@
|
|||
}
|
||||
|
||||
function rebindEventLayers() {
|
||||
eventLayerGroup.clearLayers();
|
||||
markerCluster.clearLayers();
|
||||
polyGroup.clearLayers();
|
||||
|
||||
var rows = document.querySelectorAll("#events-rows tr.event-row[data-geometry]");
|
||||
|
||||
|
|
@ -375,33 +434,27 @@
|
|||
|
||||
var adapter = row.dataset.adapter || "";
|
||||
var color = getAdapterColor(adapter);
|
||||
var op = severityOpacity(row);
|
||||
|
||||
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;
|
||||
// Point-like geometries (Points + zero-extent polygons from
|
||||
// enrichment) become divIcon markers (shape=event_type,
|
||||
// opacity=severity) and cluster. Real polygons/lines render as
|
||||
// geometry in the un-clustered group.
|
||||
var layer, isMarker;
|
||||
if (isDegenerate(geom)) {
|
||||
layer = L.circleMarker(centroidLatLng(geom), markerStyle);
|
||||
layer = L.marker(centroidLatLng(geom), { icon: makeDivIcon(row, color) });
|
||||
isMarker = true;
|
||||
layer.addTo(markerCluster);
|
||||
} else {
|
||||
layer = L.geoJSON(geom, {
|
||||
style: {
|
||||
color: color,
|
||||
weight: 2,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.25
|
||||
},
|
||||
style: { color: color, weight: 2, fillColor: color,
|
||||
opacity: op, fillOpacity: op * 0.5 },
|
||||
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));
|
||||
|
|
@ -409,23 +462,32 @@
|
|||
highlightRow(row, layer, color);
|
||||
});
|
||||
|
||||
layer.addTo(eventLayerGroup);
|
||||
|
||||
// Store reference for row highlighting
|
||||
row._mapLayer = layer;
|
||||
row._mapColor = color;
|
||||
row._isMarker = isMarker;
|
||||
} catch (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) {
|
||||
// Reset previous highlight
|
||||
// Reset previous highlight (marker via class, polygon via setStyle).
|
||||
if (highlightedRow) {
|
||||
highlightedRow.classList.remove("highlighted");
|
||||
}
|
||||
if (highlightedLayer && highlightedLayer._originalColor) {
|
||||
if (highlightedLayer) {
|
||||
if (highlightedLayer._isMarker) {
|
||||
setMarkerHl(highlightedLayer, false);
|
||||
} else if (highlightedLayer._originalColor) {
|
||||
highlightedLayer.setStyle({
|
||||
color: highlightedLayer._originalColor,
|
||||
weight: 2,
|
||||
|
|
@ -433,12 +495,17 @@
|
|||
fillOpacity: 0.25
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set new highlight
|
||||
row.classList.add("highlighted");
|
||||
highlightedRow = row;
|
||||
|
||||
if (layer) {
|
||||
layer._isMarker = row._isMarker;
|
||||
if (layer._isMarker) {
|
||||
setMarkerHl(layer, true);
|
||||
} else {
|
||||
layer._originalColor = originalColor;
|
||||
layer.setStyle({
|
||||
color: "#ff3333",
|
||||
|
|
@ -446,16 +513,16 @@
|
|||
fillColor: "#ff3333",
|
||||
fillOpacity: 0.4
|
||||
});
|
||||
}
|
||||
highlightedLayer = layer;
|
||||
}
|
||||
}
|
||||
|
||||
function fitToAllLayers() {
|
||||
var layers = eventLayerGroup.getLayers();
|
||||
if (layers.length === 0) return;
|
||||
var layers = markerCluster.getLayers().concat(polyGroup.getLayers());
|
||||
if (layers.length === 0) return; // empty set -> keep CONUS setView (graceful)
|
||||
programmaticMove = true;
|
||||
var group = L.featureGroup(layers);
|
||||
map.fitBounds(group.getBounds(), { padding: [20, 20] });
|
||||
map.fitBounds(L.featureGroup(layers).getBounds(), { padding: [20, 20] });
|
||||
}
|
||||
|
||||
// Row click handler (event delegation)
|
||||
|
|
@ -583,26 +650,42 @@
|
|||
updateSortIndicators(ths);
|
||||
}
|
||||
|
||||
// Initial load - bind layers and fit bounds
|
||||
rebindEventLayers(); // Initial load only
|
||||
// Initial load - bind layers and fit to the actual result distribution
|
||||
// (v0.7.2). Empty set -> fitToAllLayers no-ops, keeping the CONUS setView.
|
||||
rebindEventLayers();
|
||||
bindSortHandlers();
|
||||
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.
|
||||
// Re-bind layers after each HTMX swap so the map tracks the current
|
||||
// (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) {
|
||||
if (evt.detail.target.id === "events-rows") {
|
||||
rebindEventLayers();
|
||||
// Re-bind sort handlers to the new rows and re-apply the active sort.
|
||||
bindSortHandlers();
|
||||
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
|
||||
setTimeout(function() { map.invalidateSize(); }, 100);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -27,9 +27,10 @@ def test_out_of_range_bbox_is_dropped_not_errored():
|
|||
|
||||
|
||||
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(
|
||||
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 parsed["bbox"] == {
|
||||
|
|
|
|||
|
|
@ -208,3 +208,46 @@ def test_url_round_trip():
|
|||
reparsed, _ = routes._parse_events_params(requery)
|
||||
for k in ("q", "adapters", "categories", "event_types", "severities", "time_token"):
|
||||
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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue