mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 11:54:37 +02:00
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:
parent
9ff8a33415
commit
c5ed52db4b
3 changed files with 108 additions and 0 deletions
78
src/central/gui/templates/_partials/bbox_map.html
Normal file
78
src/central/gui/templates/_partials/bbox_map.html
Normal 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 || '© 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue