Merge pull request #71 from zvx-echo6/v0.9.10-bbox-map

Read-only Leaflet map preview for multi-bbox editor (v0.9.10)
This commit is contained in:
malice 2026-05-26 12:43:54 -06:00 committed by GitHub
commit 92a1b3f2c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 108 additions and 0 deletions

View file

@ -0,0 +1,78 @@
{# Read-only Leaflet preview of every bbox row in a model_list whose row model
exposes min_lon/min_lat/max_lon/max_lat (e.g. TomTom incidents). Passive:
no drag-to-edit, no click-to-draw — precise tuning stays in the per-row coord
inputs below. Mirrors _region_picker.html's init (tile layer, invalidateSize,
fitBounds). Rectangles redraw live as inputs change; the viewport only fits
on initial render so typing never makes the map jump.
The map div is rendered ABOVE the row table, so init() is deferred until the
DOM is parsed — otherwise the inline script would run before .model-list-body
exists and the rows would be unreadable. #}
<div class="bbox-map-wrap" data-field="{{ field.name }}"
data-tile-url="{{ tile_url if tile_url is defined and tile_url else '' }}"
data-tile-attr="{{ tile_attribution if tile_attribution is defined and tile_attribution else '' }}">
<div id="bbox-map-{{ field.name }}" class="bbox-map"
style="height: 360px; margin-bottom: 1rem; border: 1px solid var(--rule); border-radius: var(--radius);"></div>
</div>
<script>
(function() {
function init() {
var wrap = document.querySelector('.bbox-map-wrap[data-field="{{ field.name }}"]');
if (!wrap) return;
var field = wrap.dataset.field;
var container = document.querySelector('.model-list[data-field="' + field + '"]');
if (!container) return;
var body = container.querySelector('.model-list-body');
if (!body) return;
var tileUrl = wrap.dataset.tileUrl || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
var tileAttr = wrap.dataset.tileAttr || '&copy; OpenStreetMap contributors';
var map = L.map('bbox-map-' + field).setView([44.0, -114.5], 6);
L.tileLayer(tileUrl, { attribution: tileAttr, maxZoom: 18 }).addTo(map);
// Container may not be laid out yet at init; nudge Leaflet once it is.
setTimeout(function() { map.invalidateSize(); }, 100);
var layer = L.featureGroup().addTo(map);
function num(row, sub) {
var el = row.querySelector('[data-sub="' + sub + '"]');
return el ? parseFloat(el.value) : NaN;
}
// (Re)build all rectangles from the current row inputs. fit=true only on
// the first render so live edits never jolt the viewport.
function redraw(fit) {
layer.clearLayers();
body.querySelectorAll('.model-list-row').forEach(function(row) {
var s = num(row, 'min_lat'), w = num(row, 'min_lon'),
n = num(row, 'max_lat'), e = num(row, 'max_lon');
if ([s, w, n, e].some(isNaN)) return; // skip blank/partial rows
var nameEl = row.querySelector('[data-sub="name"]');
var name = (nameEl && nameEl.value) ? nameEl.value : '(unnamed)';
var rect = L.rectangle(L.latLngBounds(L.latLng(s, w), L.latLng(n, e)),
{ color: '#3388ff', weight: 2, fillOpacity: 0.2 });
rect.bindTooltip(name, { sticky: true });
layer.addLayer(rect);
});
if (fit && layer.getLayers().length) {
map.fitBounds(layer.getBounds().pad(0.1));
}
}
// Live redraw: typing in any coord/name input, and row add/remove.
body.addEventListener('input', function() { redraw(false); });
new MutationObserver(function() { redraw(false); }).observe(body, { childList: true });
redraw(true);
}
// The include sits above the row table, so the rows aren't parsed yet when
// this runs mid-parse. Defer to DOMContentLoaded so body/rows exist.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>

View file

@ -2,6 +2,12 @@
Driven entirely by field.sub_fields — no adapter-name branching.
Vanilla IIFE JS, matching _region_picker.html. #}
{% set rows = (form_data[field.name] if form_data and field.name in form_data else field.current_value) or [] %}
{# Auto-detect a bbox row model (min/max lon+lat) to show a read-only map
preview; non-bbox model_lists (StateConfig, TileCoord) render no map. #}
{% set _bbox_keys = ['min_lon', 'min_lat', 'max_lon', 'max_lat'] %}
{% set _ns = namespace(hits=0) %}
{% for sub in field.sub_fields %}{% if sub.name in _bbox_keys %}{% set _ns.hits = _ns.hits + 1 %}{% endif %}{% endfor %}
{% set is_bbox = _ns.hits == 4 %}
<div class="model-list" data-field="{{ field.name }}">
<label>{{ field.label }}</label>
{% if field.description %}<small>{{ field.description }}</small>{% endif %}
@ -9,6 +15,8 @@
<small class="field-error">{{ errors[field.name] }}</small>
{% endif %}
{% if is_bbox %}{% include "_partials/bbox_map.html" %}{% endif %}
<div class="table-wrap"><table class="model-list-table">
<thead><tr>
{% for sub in field.sub_fields %}<th>{{ sub.label }}</th>{% endfor %}<th></th>

View file

@ -190,3 +190,25 @@ class TestGetRoute:
ctx = tmpl.TemplateResponse.call_args.kwargs["context"]
assert ctx["quota"]["calls_per_month"] == 1920 # 720+480+720
assert ctx["quota"]["blocked"] is False
class TestBboxMapPreview:
"""v0.9.10 — read-only Leaflet preview of bbox rows in the model_list editor."""
def test_tomtom_renders_bbox_map(self):
"""bbox row model (min/max lon+lat) → map div + Leaflet init present."""
fields = describe_fields(TomTomIncidentsAdapter.settings_schema, _SETTINGS)
quota = TomTomIncidentsAdapter.quota_estimate(TomTomIncidentsSettings(**_SETTINGS), 1800)
out = _render("adapters_edit.html", _ctx(_SETTINGS, fields, quota))
assert "bbox-map" in out # map container present
assert "L.map(" in out and "L.rectangle(" in out # Leaflet init present
def test_state_511_atis_no_bbox_map(self):
"""Non-bbox model_list (StateConfig) → generic editor, no map (no regression)."""
from central.adapters.state_511_atis import State511ATISAdapter
s = {"states": [{"code": "ID", "base_url": "https://511.idaho.gov"}]}
out = _render("adapters_edit.html",
_ctx(s, describe_fields(State511ATISAdapter.settings_schema, s), None,
name="state_511_atis", display="511 ATIS"))
assert "model-list" in out # generic editor still renders
assert "bbox-map" not in out # but no map div