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
|
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 []
|
||||||
|
|
|
||||||
|
|
@ -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 %}>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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"] == {
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue