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..a73c517530 --- /dev/null +++ b/commcare_connect/microplanning/tests/test_buildings.py @@ -0,0 +1,51 @@ +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_the_pinned_release_is_usable_and_credited(): + config = buildings_overlay_config() + + assert config is not None + assert "OpenStreetMap" in config["attribution"] + assert "Overture Maps Foundation" in config["attribution"] + + +@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..09c1ecce28 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,152 @@ 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.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. + * @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, onFailed } = + 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, + ); + }; + + // 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. + let everLoaded = false; + ['sourcedataloading', 'sourcedata'].forEach((event) => { + map.on(event, (e) => { + 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 + // 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 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 + // 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); + }); + shown = visible; + syncLoading(); + }, + }; + }, + 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..c20913fc8b 100644 --- a/commcare_connect/templates/microplanning/home.html +++ b/commcare_connect/templates/microplanning/home.html @@ -181,23 +181,26 @@