From 7ed3dd5992c6b4585d142c42a1f0daae96bbe689 Mon Sep 17 00:00:00 2001 From: Charl Smit Date: Thu, 10 Sep 2026 08:51:30 +0200 Subject: [PATCH 1/7] Draw building footprints client-side from Overture PMTiles Microplanning wants building footprints under the work areas, so what is on the ground in an area is visible before it is assigned. Overture publishes each release as a PMTiles archive, and Mapbox GL has read PMTiles vector sources natively since 3.21 (we ship 3.24), so the browser can fetch footprints straight from Overture's bucket. That leaves the server nothing to do but compute a URL: no endpoint, no cache, no parsing, no new dependency, and Mapbox owns tile fetching, caching and eviction. What is added here is a source, two layers and a toggle. The archive stops at zoom 14 and footprints are drawn from 14 up, so every rendered zoom past the first is overzoomed from z14 tiles. Verified this loses nothing in the dense areas that matter: one z14 tile over Kibera carries 11,568 buildings and reaches the client as a single 784 KB request, Kano's carries 34,067, and neither is thinned. Geometry is quantized to ~0.6m by the tile grid. Two things worth flagging: - Overture's buckets drop releases after 60 days and keep only the latest two, so a pinned release is a dead URL within about two months. OVERTURE_RELEASE is pinned in code rather than per environment, since every environment wants the same current release, and a blank one hides the overlay rather than pointing the map at a URL that 404s. - Overture buildings are largely OSM derived, so the source carries the OpenStreetMap + Overture attribution Overture ships in its own metadata. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MgpMVxf9EuqYBYdB7W1ou3 --- commcare_connect/microplanning/buildings.py | 69 +++++++++++ .../microplanning/tests/test_buildings.py | 56 +++++++++ commcare_connect/microplanning/views.py | 2 + commcare_connect/static/js/mapbox.js | 107 ++++++++++++++++++ .../templates/microplanning/home.html | 4 + .../templates/microplanning/map_handler.html | 24 ++++ .../microplanning/map_layer_toggles.html | 14 +++ tailwind/tailwind.css | 9 ++ 8 files changed, 285 insertions(+) create mode 100644 commcare_connect/microplanning/buildings.py create mode 100644 commcare_connect/microplanning/tests/test_buildings.py create mode 100644 commcare_connect/templates/microplanning/map_layer_toggles.html diff --git a/commcare_connect/microplanning/buildings.py b/commcare_connect/microplanning/buildings.py new file mode 100644 index 0000000000..7dad30b69a --- /dev/null +++ b/commcare_connect/microplanning/buildings.py @@ -0,0 +1,69 @@ +""" +Building footprints for the microplanning map. + +Overture publishes its buildings as a PMTiles archive and Mapbox GL reads PMTiles natively, so the +browser fetches footprints straight from Overture. This module only works out what to point it at: +there is no proxy view, no cache and no table behind the overlay, and no building data passes +through this process. + +The one thing needing care is the release. Overture's buckets drop everything older than 60 days, +so a release that is fine today is a dead URL in two months -- see ``OVERTURE_RELEASE``. +""" + +# The Overture release footprints are read from. Overture keeps only the two most recent releases +# -- its buckets carry a 60 day retention rule -- so this has to move forward every month or so, +# which currently means a deploy. Pinned here rather than in settings because it is not per +# environment: every environment wants the same, current release. +OVERTURE_RELEASE = "2026-08-19.0" + +# https://docs.overturemaps.org/examples/overture-tiles/ +OVERTURE_TILES_URL = ( + "https://overturemaps-extras-us-west-2.s3.us-west-2.amazonaws.com/tiles/{release}/buildings.pmtiles" +) + +OVERTURE_BUILDINGS_LAYER = "building" + +# A fact about the archive, handed to the Mapbox *source* as `maxzoom`: Overture builds tiles down +# to zoom 14 and no deeper. Declaring it is what makes Mapbox overzoom those z14 tiles for closer +# views; without it Mapbox requests z15+ tiles that do not exist and the overlay comes up empty. +OVERTURE_ARCHIVE_MAX_ZOOM = 14 + +# Our display policy, handed to the Mapbox *layers* as `minzoom`: the zoom at which footprints +# start being drawn at all. +# +# Set to the archive's max zoom, so footprints appear at Overture's own deepest tiles and are drawn +# at native resolution there; only the zooms past it are overzoomed. These two are not a range -- +# one is a property of the data, this one is a choice. +# +# Do not drop it below the archive max. z14 is the last complete level: Overture thins the lower +# zooms hard (a z13 tile over Kibera carries about a fifth of the buildings its z14 tiles do), so +# drawing there would show a partial set of buildings while looking complete. +BUILDINGS_DISPLAY_MIN_ZOOM = 14 + +# Overture's buildings are largely OpenStreetMap derived, so both need crediting. This is the +# attribution Overture ships in the archive's own metadata. +OVERTURE_ATTRIBUTION = ( + '© OpenStreetMap ' + '© Overture Maps Foundation' +) + + +def buildings_overlay_config(): + """ + Return the config the map needs to draw building footprints, or ``None`` if it cannot. + + ``None`` means the overlay is unavailable and its control should not be rendered: with no + release configured there is no archive to point the browser at, and a toggle that switches on + an empty layer is worse than no toggle. + """ + release = (OVERTURE_RELEASE or "").strip() + if not release: + return None + + return { + "tilesUrl": OVERTURE_TILES_URL.format(release=release), + "sourceLayer": OVERTURE_BUILDINGS_LAYER, + "archiveMaxZoom": OVERTURE_ARCHIVE_MAX_ZOOM, + "displayMinZoom": BUILDINGS_DISPLAY_MIN_ZOOM, + "attribution": OVERTURE_ATTRIBUTION, + } diff --git a/commcare_connect/microplanning/tests/test_buildings.py b/commcare_connect/microplanning/tests/test_buildings.py new file mode 100644 index 0000000000..fe9b0f3fe1 --- /dev/null +++ b/commcare_connect/microplanning/tests/test_buildings.py @@ -0,0 +1,56 @@ +import pytest + +from commcare_connect.microplanning import buildings +from commcare_connect.microplanning.buildings import buildings_overlay_config + + +@pytest.fixture +def release(monkeypatch): + """Set the pinned Overture release for one test.""" + + def _set(value): + monkeypatch.setattr(buildings, "OVERTURE_RELEASE", value) + + return _set + + +@pytest.mark.parametrize("pinned", ["2026-08-19.0", " 2026-08-19.0\n"]) +def test_config_points_at_the_configured_release(release, pinned): + """The release is stripped before use: a stray newline must not reach the tile URL.""" + release(pinned) + + config = buildings_overlay_config() + + assert config["tilesUrl"].endswith("/tiles/2026-08-19.0/buildings.pmtiles") + assert config["sourceLayer"] == "building" + assert config["archiveMaxZoom"] == 14 + assert config["displayMinZoom"] == 14 + # Footprints must never start below the archive's deepest level: the lower zooms are thinned, + # so drawing there would show a partial set of buildings while looking complete. + assert config["displayMinZoom"] >= config["archiveMaxZoom"] + + +def test_config_credits_openstreetmap_and_overture(): + """Overture's buildings are largely OSM derived, so both are required in the attribution.""" + attribution = buildings_overlay_config()["attribution"] + + assert "OpenStreetMap" in attribution + assert "Overture Maps Foundation" in attribution + + +def test_the_pinned_release_is_usable(): + """The pin ships in code, so a blank one would silently disable the overlay for everyone.""" + assert buildings_overlay_config() is not None + + +@pytest.mark.parametrize("pinned", [None, "", " "]) +def test_no_config_without_a_release(release, pinned): + """ + A missing release makes the overlay unavailable rather than pointing the browser at a bad URL. + + The map itself has to keep working; only the footprint control goes away. Nothing can blank the + pin today, but the release is due to move to the database, where absence is a real state. + """ + release(pinned) + + assert buildings_overlay_config() is None diff --git a/commcare_connect/microplanning/views.py b/commcare_connect/microplanning/views.py index 80bb428961..c55b3d16fd 100644 --- a/commcare_connect/microplanning/views.py +++ b/commcare_connect/microplanning/views.py @@ -47,6 +47,7 @@ from commcare_connect.commcarehq.api import create_or_update_case_by_work_area from commcare_connect.flags.flag_names import MICROPLANNING +from commcare_connect.microplanning.buildings import buildings_overlay_config from commcare_connect.microplanning.const import ( MAX_AUTOZOOM_ZOOM, MAX_EXCLUDE_WORK_AREAS, @@ -246,6 +247,7 @@ def microplanning_home(request, *args, **kwargs): "show_rerun_clear_work_area_groups_btn": show_rerun_clear_work_area_groups_btn, "clustering_is_rerun": show_rerun_clear_work_area_groups_btn, "mapbox_api_key": settings.MAPBOX_TOKEN, + "buildings_config": buildings_overlay_config(), "task_id": request.GET.get("task_id"), "import_status_url": import_status_url, "opportunity": opportunity, diff --git a/commcare_connect/static/js/mapbox.js b/commcare_connect/static/js/mapbox.js index 6d76f90a85..160408065e 100644 --- a/commcare_connect/static/js/mapbox.js +++ b/commcare_connect/static/js/mapbox.js @@ -124,6 +124,10 @@ function addCatchmentAreas(map, catchments) { window.addCatchmentAreas = addCatchmentAreas; +const BUILDINGS_SOURCE = 'overture-buildings'; +const BUILDINGS_FILL_LAYER = 'overture-buildings-fill'; +const BUILDINGS_OUTLINE_LAYER = 'overture-buildings-outline'; + const MapboxUtils = { setAccessToken(token) { if (!token) { @@ -166,6 +170,109 @@ const MapboxUtils = { return draw; }, + /** + * Draw Overture building footprints, read by the browser straight from Overture's PMTiles archive. + * + * `archiveMaxZoom` is where Overture's tiles stop; `displayMinZoom` is where we choose to start + * drawing. Declaring the former is what makes Mapbox overzoom the deepest tiles for closer views + * rather than request tiles that do not exist. + * + * The source and layers are created on the first `setVisible(true)` rather than up front: adding + * a PMTiles source is not lazy in Mapbox, so doing it eagerly costs the provider plugin and an + * S3 range read of the archive header on every map load, even for the users who never switch + * footprints on. + * + * @param {mapboxgl.Map} map - Mapbox Map + * @param {{tilesUrl: string, sourceLayer: string, archiveMaxZoom: number, displayMinZoom: number, attribution: string}} config + * @param {object} [options] + * @param {string} [options.beforeId] - existing layer to insert the footprints beneath, so they + * sit under the map's own layers rather than over them. + * @param {function(boolean): void} [options.onAvailabilityChange] - called with whether the map + * is zoomed in far enough for footprints to draw at all. + * @returns {{setVisible: function(boolean): void}} handle for toggling the footprints on and off + */ + addBuildingsOverlay(map, config, options = {}) { + const { beforeId, onAvailabilityChange } = options; + const FILL_COLOR = '#1d4ed8'; + const FILL_OPACITY = 0.25; + const OUTLINE_COLOR = '#1e3a8a'; + const OUTLINE_WIDTH = 0.8; + + let added = false; + const addSourceAndLayers = () => { + if (added) return; + added = true; + + map.addSource(BUILDINGS_SOURCE, { + type: 'vector', + url: config.tilesUrl, + maxzoom: config.archiveMaxZoom, + attribution: config.attribution, + }); + + // Below displayMinZoom footprints are too small to tell apart, so Mapbox is told not to draw + // them rather than the overlay policing zoom itself. + const shared = { + source: BUILDINGS_SOURCE, + 'source-layer': config.sourceLayer, + minzoom: config.displayMinZoom, + }; + + map.addLayer( + { + ...shared, + id: BUILDINGS_FILL_LAYER, + type: 'fill', + paint: { 'fill-color': FILL_COLOR, 'fill-opacity': FILL_OPACITY }, + }, + beforeId, + ); + + map.addLayer( + { + ...shared, + id: BUILDINGS_OUTLINE_LAYER, + type: 'line', + paint: { 'line-color': OUTLINE_COLOR, 'line-width': OUTLINE_WIDTH }, + }, + beforeId, + ); + }; + + // The release this points at is retired by Overture after 60 days, at which point the archive + // 404s and the overlay silently draws nothing. Surface that rather than failing quietly. + map.on('error', (e) => { + if (e.sourceId !== BUILDINGS_SOURCE) return; + // eslint-disable-next-line no-console -- the retired-release case has no other signal + console.error('Overture buildings source failed to load', e.error); + }); + + // One threshold, applied twice from here: as the layers' Mapbox `minzoom`, and as the + // availability reported to the caller. Callers never re-derive it, so the control cannot end up + // offering a toggle for a zoom at which Mapbox draws nothing. + let available = null; + const syncAvailability = () => { + const next = map.getZoom() >= config.displayMinZoom; + if (next === available) return; + available = next; + if (onAvailabilityChange) onAvailabilityChange(available); + }; + syncAvailability(); + map.on('zoomend', syncAvailability); + + return { + setVisible(visible) { + if (visible) addSourceAndLayers(); + if (!added) return; + + const visibility = visible ? 'visible' : 'none'; + [BUILDINGS_FILL_LAYER, BUILDINGS_OUTLINE_LAYER].forEach((layer) => { + map.setLayoutProperty(layer, 'visibility', visibility); + }); + }, + }; + }, + createMarker(map, opts) { // Creating markers using HTML might present performance issues at scale, so better // to use layers instead for large datasets. diff --git a/commcare_connect/templates/microplanning/home.html b/commcare_connect/templates/microplanning/home.html index 8b9fbf8d85..b055fcdcb2 100644 --- a/commcare_connect/templates/microplanning/home.html +++ b/commcare_connect/templates/microplanning/home.html @@ -181,6 +181,7 @@

{{ status_meta|json_script:"status-meta-data" }} + {% if buildings_config %}{{ buildings_config|json_script:"buildings-config" }}{% endif %}
+ {% if buildings_config %} + {% include "microplanning/map_layer_toggles.html" %} + {% endif %}
diff --git a/commcare_connect/templates/microplanning/map_handler.html b/commcare_connect/templates/microplanning/map_handler.html index a290f222d1..f376c94b26 100644 --- a/commcare_connect/templates/microplanning/map_handler.html +++ b/commcare_connect/templates/microplanning/map_handler.html @@ -12,6 +12,11 @@ showReviewInaccessibilityModal: false, showVisits: false, showImplementationAreas: false, + + // Building footprint overlay. `buildingsAvailable` tracks whether the map is zoomed in + // far enough for footprints to be drawn at all; it is driven by the overlay. + showBuildings: false, + buildingsAvailable: false, filterQuery: '', pendingTileQuery: null, workAreaDetailRequestId: 0, @@ -1050,6 +1055,10 @@ await new Promise(resolve => this.map.once('load', resolve)); + // Footprints are inserted beneath the Work Area layers (see `beforeId`): they are + // context for the areas, and the areas are what the user is here to read. + this.initBuildings(); + // Workareas are served using django-vectortiles, // promoteId maps the feature's 'id' property as the feature ID, // which is required for setFeatureState (hover/select) to work correctly. @@ -1391,6 +1400,21 @@ }); }, + // Sets up the Building Data overlay, if it is configured. The overlay owns the source, + // the layers and the zoom threshold; this only wires its state to the control, which is + // hidden below that threshold rather than offering a toggle that appears to do nothing. + initBuildings() { + const configElement = document.getElementById('buildings-config'); + if (!configElement) return; + + const buildings = MapboxUtils.addBuildingsOverlay(this.map, JSON.parse(configElement.textContent), { + beforeId: 'workareas-fill', + onAvailabilityChange: (available) => { this.buildingsAvailable = available; }, + }); + + this.$watch('showBuildings', (visible) => buildings.setVisible(visible)); + }, + // Sets up the Implementation Area overlay: a GeoJSON source, a black outline layer, // a lazy data fetch, hover highlight + name tooltip, and the show/hide toggle. Must be // called from initializeMap after the Work Area layers are added, so the outline diff --git a/commcare_connect/templates/microplanning/map_layer_toggles.html b/commcare_connect/templates/microplanning/map_layer_toggles.html new file mode 100644 index 0000000000..9b7703c443 --- /dev/null +++ b/commcare_connect/templates/microplanning/map_layer_toggles.html @@ -0,0 +1,14 @@ +{% load i18n %} +{% translate "Show building footprints from Overture Maps. Data from OpenStreetMap and the Overture Maps Foundation." as buildings_hint %} +
+ + + + +
diff --git a/tailwind/tailwind.css b/tailwind/tailwind.css index 909006d5a4..fdbe04ba34 100644 --- a/tailwind/tailwind.css +++ b/tailwind/tailwind.css @@ -896,3 +896,12 @@ select:disabled, } } } + +/*--- floating map control panel ------------------- */ +@layer components { + /* Shares the map's top-right corner with the toast and the assignment hint, and is the only one + of the three the user can click, so it stacks above both rather than under them. */ + .map-control-panel { + @apply absolute right-4 top-4 z-[60] flex flex-col gap-2 rounded-lg bg-white px-3 py-2 shadow-md; + } +} From f00b033be0cbbff2d22b7e2dc2e973580f5988d1 Mon Sep 17 00:00:00 2001 From: Charl Smit Date: Thu, 10 Sep 2026 08:58:31 +0200 Subject: [PATCH 2/7] Stop the invisible toast from eating clicks on the map controls The toast container is pinned to the map's top-right corner at z-50 and stays in the DOM when hidden -- opacity-0 is still hit-testable -- so it swallowed clicks over its own box, leaving only the sliver of the Show Building Data panel that reached past it. The two other passive overlays in that corner already set pointer-events-none; the toast never did, and nothing had sat underneath it before now to expose that. Moves the toast's utilities into a .map-toast class alongside .map-control-panel per the project's style rules. opacity-0 and the background colour stay on the element, since showToast toggles both through classList. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MgpMVxf9EuqYBYdB7W1ou3 --- .../templates/microplanning/home.html | 15 ++++++++------- tailwind/tailwind.css | 18 +++++++++++++++--- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/commcare_connect/templates/microplanning/home.html b/commcare_connect/templates/microplanning/home.html index b055fcdcb2..42bff140c8 100644 --- a/commcare_connect/templates/microplanning/home.html +++ b/commcare_connect/templates/microplanning/home.html @@ -186,10 +186,14 @@

-
- - +
+ {% if buildings_config %} + {% include "microplanning/map_layer_toggles.html" %} + {% endif %} +
+ + +
{% if assignment_mode %}
- {% if buildings_config %} - {% include "microplanning/map_layer_toggles.html" %} - {% endif %}
diff --git a/tailwind/tailwind.css b/tailwind/tailwind.css index fdbe04ba34..790f82bf8c 100644 --- a/tailwind/tailwind.css +++ b/tailwind/tailwind.css @@ -899,9 +899,21 @@ select:disabled, /*--- floating map control panel ------------------- */ @layer components { - /* Shares the map's top-right corner with the toast and the assignment hint, and is the only one - of the three the user can click, so it stacks above both rather than under them. */ + /* The map's top-right corner. Everything in here is laid out in one column so a message can + never land on top of the controls */ + .map-control-stack { + @apply pointer-events-none absolute right-4 top-4 z-50 flex flex-col items-end gap-2; + } + .map-control-panel { - @apply absolute right-4 top-4 z-[60] flex flex-col gap-2 rounded-lg bg-white px-3 py-2 shadow-md; + @apply pointer-events-auto flex flex-col gap-2 rounded-lg bg-white px-3 py-2 shadow-md; + } + + /* Positioned by `.map-control-stack`, and in the DOM even when hidden, so it must never take the + pointer -- an invisible toast would otherwise eat clicks on the map beneath it. Callers toggle + `opacity-0`/`opacity-100` and the background colour on the element itself, so those stay out + of here. */ + .map-toast { + @apply pointer-events-none flex items-center gap-3 rounded-lg px-4 py-3 text-sm text-white shadow-lg transition-opacity duration-300; } } From 6332f46d252c04d13d943bd25d89a91ae3d82873 Mon Sep 17 00:00:00 2001 From: Charl Smit Date: Thu, 10 Sep 2026 09:06:02 +0200 Subject: [PATCH 3/7] Show a loading indicator under the buildings toggle Mapbox owns the tile fetching for a PMTiles source, so there is no request of ours to track progress around. addBuildingsOverlay now reads it back off the map's sourcedataloading/sourcedata events for its own source and reports transitions through an onLoadingChange callback, which the map controller binds to a `buildingsLoading` flag. The panel becomes a column so the spinner sits under the toggle rather than beside the label. Two cases the callback has to get right: - Hidden layers request no tiles, so an idle map with the overlay off is not loading, it has nothing to load. `shown` gates the whole thing. - A tile that fails leaves the source permanently unloaded, so `idle` clears the indicator outright instead of asking the source again -- otherwise a failed load would spin forever. Verified by driving the real function against a stub map over seven scenarios (off, loading, arrival, other sources, failed tile, toggle-off mid-load, layer visibility). The repo has no JS test runner, so that harness is not committed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MgpMVxf9EuqYBYdB7W1ou3 --- commcare_connect/static/js/mapbox.js | 33 +++++++++++++++++-- .../templates/microplanning/map_handler.html | 4 ++- .../microplanning/map_layer_toggles.html | 25 +++++++++----- tailwind/tailwind.css | 9 +++++ 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/commcare_connect/static/js/mapbox.js b/commcare_connect/static/js/mapbox.js index 160408065e..8d9efef231 100644 --- a/commcare_connect/static/js/mapbox.js +++ b/commcare_connect/static/js/mapbox.js @@ -187,12 +187,14 @@ const MapboxUtils = { * @param {object} [options] * @param {string} [options.beforeId] - existing layer to insert the footprints beneath, so they * sit under the map's own layers rather than over them. + * @param {function(boolean): void} [options.onLoadingChange] - called when footprint tiles start + * and finish loading. Only ever true while footprints are shown. * @param {function(boolean): void} [options.onAvailabilityChange] - called with whether the map * is zoomed in far enough for footprints to draw at all. * @returns {{setVisible: function(boolean): void}} handle for toggling the footprints on and off */ addBuildingsOverlay(map, config, options = {}) { - const { beforeId, onAvailabilityChange } = options; + const { beforeId, onLoadingChange, onAvailabilityChange } = options; const FILL_COLOR = '#1d4ed8'; const FILL_OPACITY = 0.25; const OUTLINE_COLOR = '#1e3a8a'; @@ -239,10 +241,35 @@ const MapboxUtils = { ); }; + // Mapbox does the fetching, so progress has to be read back off its source events rather than + // tracked around a request of our own. Hidden layers load no tiles, so `shown` gates this: an + // idle map with the overlay off is not "loading", it has nothing to load. + let shown = false; + let loading = false; + const setLoading = (next) => { + if (next === loading) return; + loading = next; + if (onLoadingChange) onLoadingChange(loading); + }; + const syncLoading = () => + setLoading(shown && added && !map.isSourceLoaded(BUILDINGS_SOURCE)); + + // sourcedataloading covers the archive header and directory reads as well as the tiles, so the + // first toggle reports progress while Mapbox is still working out where the tiles are. + ['sourcedataloading', 'sourcedata'].forEach((event) => { + map.on(event, (e) => { + if (e.sourceId === BUILDINGS_SOURCE) syncLoading(); + }); + }); + // An idle map has nothing in flight, so this clears the indicator outright instead of asking + // the source again: a tile that failed leaves the source looking unloaded forever, and reading + // it here would leave the indicator spinning on a load that has already given up. + map.on('idle', () => setLoading(false)); // The release this points at is retired by Overture after 60 days, at which point the archive - // 404s and the overlay silently draws nothing. Surface that rather than failing quietly. + // 404s and the overlay silently draws nothing. Surface that rather than spinning forever. map.on('error', (e) => { if (e.sourceId !== BUILDINGS_SOURCE) return; + setLoading(false); // eslint-disable-next-line no-console -- the retired-release case has no other signal console.error('Overture buildings source failed to load', e.error); }); @@ -269,6 +296,8 @@ const MapboxUtils = { [BUILDINGS_FILL_LAYER, BUILDINGS_OUTLINE_LAYER].forEach((layer) => { map.setLayoutProperty(layer, 'visibility', visibility); }); + shown = visible; + syncLoading(); }, }; }, diff --git a/commcare_connect/templates/microplanning/map_handler.html b/commcare_connect/templates/microplanning/map_handler.html index f376c94b26..97164a7ae9 100644 --- a/commcare_connect/templates/microplanning/map_handler.html +++ b/commcare_connect/templates/microplanning/map_handler.html @@ -14,9 +14,10 @@ showImplementationAreas: false, // Building footprint overlay. `buildingsAvailable` tracks whether the map is zoomed in - // far enough for footprints to be drawn at all; it is driven by the overlay. + // far enough for footprints to be drawn at all; both flags are driven by the overlay. showBuildings: false, buildingsAvailable: false, + buildingsLoading: false, filterQuery: '', pendingTileQuery: null, workAreaDetailRequestId: 0, @@ -1409,6 +1410,7 @@ const buildings = MapboxUtils.addBuildingsOverlay(this.map, JSON.parse(configElement.textContent), { beforeId: 'workareas-fill', + onLoadingChange: (loading) => { this.buildingsLoading = loading; }, onAvailabilityChange: (available) => { this.buildingsAvailable = available; }, }); diff --git a/commcare_connect/templates/microplanning/map_layer_toggles.html b/commcare_connect/templates/microplanning/map_layer_toggles.html index 9b7703c443..5b704ca958 100644 --- a/commcare_connect/templates/microplanning/map_layer_toggles.html +++ b/commcare_connect/templates/microplanning/map_layer_toggles.html @@ -1,14 +1,23 @@ {% load i18n %} -{% translate "Show building footprints from Overture Maps. Data from OpenStreetMap and the Overture Maps Foundation." as buildings_hint %}
- - - - +
+ + + + +
+

+ + {% translate "Loading buildings…" %} +

diff --git a/tailwind/tailwind.css b/tailwind/tailwind.css index 790f82bf8c..feeab8667b 100644 --- a/tailwind/tailwind.css +++ b/tailwind/tailwind.css @@ -909,6 +909,15 @@ select:disabled, @apply pointer-events-auto flex flex-col gap-2 rounded-lg bg-white px-3 py-2 shadow-md; } + .map-control-row { + @apply flex items-center justify-between gap-3; + } + + /* Progress note under a control, e.g. footprint tiles still loading. */ + .map-control-status { + @apply flex items-center gap-2 text-xs text-gray-500; + } + /* Positioned by `.map-control-stack`, and in the DOM even when hidden, so it must never take the pointer -- an invisible toast would otherwise eat clicks on the map beneath it. Callers toggle `opacity-0`/`opacity-100` and the background colour on the element itself, so those stay out From 3b0d8735ab9d84b758bc377edf1c92a53788de1f Mon Sep 17 00:00:00 2001 From: Charl Smit Date: Thu, 10 Sep 2026 10:44:17 +0200 Subject: [PATCH 4/7] Stack the work area hover hint under the buildings control The assignment-mode hint shared the map's top-right corner with the Show Building Data panel, at z-40 against the panel's stack. The panel is opaque, so it covered the hint outright whenever the user was zoomed past the footprint threshold -- which is most of the time in assignment mode, and exactly when the hint is worth reading. Moves the hint into .map-control-stack between the panel and the toast, so it flows underneath the control rather than behind it. x-show sets display:none, which takes the hint out of the flex column entirely when it is hidden, so the toast still sits directly under the panel when nothing is hovered -- the column never holds a gap for an absent hint. Its utilities move into a .map-hint class alongside .map-toast per the project's style rules. The hint loses its own positioning to the stack, which also aligns it to right-4 with the panel and toast instead of its previous right-2. Co-Authored-By: Claude Opus 5 (1M context) --- commcare_connect/templates/microplanning/home.html | 14 ++++++-------- tailwind/tailwind.css | 6 ++++++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/commcare_connect/templates/microplanning/home.html b/commcare_connect/templates/microplanning/home.html index 42bff140c8..c20913fc8b 100644 --- a/commcare_connect/templates/microplanning/home.html +++ b/commcare_connect/templates/microplanning/home.html @@ -190,19 +190,17 @@

{% translate "Click to select group · Shift+click to select single area" %}

+ {% endif %}
- {% if assignment_mode %} -
- {% translate "Click to select group · Shift+click to select single area" %} -
- {% endif %}
diff --git a/tailwind/tailwind.css b/tailwind/tailwind.css index feeab8667b..22cb20b420 100644 --- a/tailwind/tailwind.css +++ b/tailwind/tailwind.css @@ -918,6 +918,12 @@ select:disabled, @apply flex items-center gap-2 text-xs text-gray-500; } + /* Positioned by `.map-control-stack`. Hover-driven and passive: x-show drops it out of the + column entirely when hidden, so the toast below it does not move on its account. */ + .map-hint { + @apply pointer-events-none rounded-md border border-white/10 bg-black/50 px-2 py-2 text-sm text-white shadow-lg; + } + /* Positioned by `.map-control-stack`, and in the DOM even when hidden, so it must never take the pointer -- an invisible toast would otherwise eat clicks on the map beneath it. Callers toggle `opacity-0`/`opacity-100` and the background colour on the element itself, so those stay out From 4d85d817235bc3599dbb272c3e552afc453593e8 Mon Sep 17 00:00:00 2001 From: Charl Smit Date: Thu, 10 Sep 2026 10:59:55 +0200 Subject: [PATCH 5/7] Withdraw the buildings toggle when the archive cannot be read Overture retires a release after 60 days, so the pinned OVERTURE_RELEASE eventually points at an archive that 404s. Until now that failed quietly: the toggle stayed, switching it on drew nothing, and the only trace was a console error nobody has open. The overlay now reports an unreadable archive through an onFailed callback. The panel keeps the corner but swaps the toggle for a greyed "Building data unavailable" note, so the control does not simply vanish with no explanation, and the controller clears showBuildings on the way -- once the toggle is gone, leaving the layer on would strand an empty layer nobody can switch off. Only a source that never loaded is treated as fatal. Mapbox raises the same error event for one tile that dropped out of an archive that works, and withdrawing the whole control over that would take the feature away for the rest of the session over a blip panning already recovers from. `everLoaded` tells them apart: reaching loaded even once proves the archive itself is readable, so any error after that is logged and nothing more. onFailed fires at most once. Availability stays purely zoom-driven. The notice has to be visible to be read, so the failure deliberately does not withdraw the panel itself. Verified by driving the real function against a stub map over six scenarios: dead archive reports once and clears the spinner, a tile error after a successful load reports nothing but still logs, five repeated errors give exactly one report, another source's error is ignored entirely, failure leaves availability true, and an error before the first toggle still reports. The repo has no JS test runner, so that harness is not committed. Co-Authored-By: Claude Opus 5 (1M context) --- commcare_connect/static/js/mapbox.js | 18 ++++++++++++++++-- .../templates/microplanning/map_handler.html | 5 +++++ .../microplanning/map_layer_toggles.html | 12 ++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/commcare_connect/static/js/mapbox.js b/commcare_connect/static/js/mapbox.js index 8d9efef231..09c1ecce28 100644 --- a/commcare_connect/static/js/mapbox.js +++ b/commcare_connect/static/js/mapbox.js @@ -191,10 +191,14 @@ const MapboxUtils = { * and finish loading. Only ever true while footprints are shown. * @param {function(boolean): void} [options.onAvailabilityChange] - called with whether the map * is zoomed in far enough for footprints to draw at all. + * @param {function(): void} [options.onFailed] - called once if the archive turns out to be + * unreadable, so the caller can withdraw the toggle and say so. Never called for a failure + * the overlay can recover from. * @returns {{setVisible: function(boolean): void}} handle for toggling the footprints on and off */ addBuildingsOverlay(map, config, options = {}) { - const { beforeId, onLoadingChange, onAvailabilityChange } = options; + const { beforeId, onLoadingChange, onAvailabilityChange, onFailed } = + options; const FILL_COLOR = '#1d4ed8'; const FILL_OPACITY = 0.25; const OUTLINE_COLOR = '#1e3a8a'; @@ -256,9 +260,14 @@ const MapboxUtils = { // sourcedataloading covers the archive header and directory reads as well as the tiles, so the // first toggle reports progress while Mapbox is still working out where the tiles are. + let everLoaded = false; ['sourcedataloading', 'sourcedata'].forEach((event) => { map.on(event, (e) => { - if (e.sourceId === BUILDINGS_SOURCE) syncLoading(); + if (e.sourceId !== BUILDINGS_SOURCE) return; + // Remembered so the error handler can tell a dead archive from a tile that dropped out of + // one that works: reaching loaded even once proves the archive itself is readable. + if (map.isSourceLoaded(BUILDINGS_SOURCE)) everLoaded = true; + syncLoading(); }); }); // An idle map has nothing in flight, so this clears the indicator outright instead of asking @@ -267,11 +276,16 @@ const MapboxUtils = { map.on('idle', () => setLoading(false)); // The release this points at is retired by Overture after 60 days, at which point the archive // 404s and the overlay silently draws nothing. Surface that rather than spinning forever. + + let failed = false; map.on('error', (e) => { if (e.sourceId !== BUILDINGS_SOURCE) return; setLoading(false); // eslint-disable-next-line no-console -- the retired-release case has no other signal console.error('Overture buildings source failed to load', e.error); + if (everLoaded || failed) return; + failed = true; + if (onFailed) onFailed(); }); // One threshold, applied twice from here: as the layers' Mapbox `minzoom`, and as the diff --git a/commcare_connect/templates/microplanning/map_handler.html b/commcare_connect/templates/microplanning/map_handler.html index 97164a7ae9..f5474c711f 100644 --- a/commcare_connect/templates/microplanning/map_handler.html +++ b/commcare_connect/templates/microplanning/map_handler.html @@ -18,6 +18,7 @@ showBuildings: false, buildingsAvailable: false, buildingsLoading: false, + buildingsFailed: false, filterQuery: '', pendingTileQuery: null, workAreaDetailRequestId: 0, @@ -1412,6 +1413,10 @@ beforeId: 'workareas-fill', onLoadingChange: (loading) => { this.buildingsLoading = loading; }, onAvailabilityChange: (available) => { this.buildingsAvailable = available; }, + onFailed: () => { + this.buildingsFailed = true; + this.showBuildings = false; + }, }); this.$watch('showBuildings', (visible) => buildings.setVisible(visible)); diff --git a/commcare_connect/templates/microplanning/map_layer_toggles.html b/commcare_connect/templates/microplanning/map_layer_toggles.html index 5b704ca958..8ff5aa0b04 100644 --- a/commcare_connect/templates/microplanning/map_layer_toggles.html +++ b/commcare_connect/templates/microplanning/map_layer_toggles.html @@ -3,7 +3,7 @@ x-transition.opacity x-cloak class="map-control-panel"> -
+
-

+ + {% translate "Building data unavailable" %} +

+

Date: Tue, 15 Sep 2026 08:12:33 +0200 Subject: [PATCH 6/7] Name the work areas fill layer once The layer id was spelled out at each of its five uses: the layer itself, three event handlers and the buildings overlay's beforeId. Renaming the layer would silently break any use that got missed, so they now share one constant. Co-Authored-By: Claude Opus 5 (1M context) --- .../templates/microplanning/map_handler.html | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/commcare_connect/templates/microplanning/map_handler.html b/commcare_connect/templates/microplanning/map_handler.html index f5474c711f..d544b3485a 100644 --- a/commcare_connect/templates/microplanning/map_handler.html +++ b/commcare_connect/templates/microplanning/map_handler.html @@ -1,6 +1,8 @@ {% load i18n %}