Read-only Leaflet map preview for multi-bbox editor (v0.9.10)

Adds a passive Leaflet map to the generic model_list editor that renders
every bbox row as a labeled translucent rectangle, auto-detected via the
min/max lon+lat sub-fields (TomTom incidents). Read-only: no drag/draw,
precise tuning stays in the per-row coord inputs. Rectangles redraw live
on input/add/remove; viewport fits only on initial render so typing never
jumps the map. Non-bbox model_lists (StateConfig, TileCoord) are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-05-26 18:43:08 +00:00
commit c5ed52db4b
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