Repository navigation
Draw building footprints client-side from Overture PMTiles #1522
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7ed3dd5
f00b033
6332f46
3b0d873
4d85d81
b2a0475
2d81e8a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = ( | ||
| '<a href="https://www.openstreetmap.org/copyright" target="_blank">© OpenStreetMap</a> ' | ||
| '<a href="https://docs.overturemaps.org/attribution" target="_blank">© Overture Maps Foundation</a>' | ||
| ) | ||
|
|
||
|
|
||
| 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, | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
||
|
Comment on lines
+127
to
+130
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🤖 get_repo_knowledge executed:
Length of output: 44046 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- changed files ---'
git diff --stat
printf '%s\n' '--- mapbox diff ---'
git diff -- commcare_connect/static/js/mapbox.js
printf '%s\n' '--- relevant source ---'
sed -n '100,320p' commcare_connect/static/js/mapbox.js
printf '%s\n' '--- nearby TypeScript files ---'
git ls-files 'commcare_connect/static/js/*' | grep -E '\.(ts|tsx)$' || trueRepository: dimagi/commcare-connect Length of output: 8317 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- repository instruction files ---'
find .. -maxdepth 3 -type f \( -name 'AGENTS.md' -o -name 'CONTRIBUTING*' -o -name '.coderabbit*' \) -print
printf '%s\n' '--- JavaScript/TypeScript imports and exports in mapbox.js ---'
sed -n '1,110p' commcare_connect/static/js/mapbox.js
printf '%s\n' '--- all references to addBuildingsOverlay ---'
rg -n -C 3 'addBuildingsOverlay|BUILDINGS_SOURCE|BUILDINGS_FILL_LAYER|BUILDINGS_OUTLINE_LAYER' commcare_connectRepository: dimagi/commcare-connect Length of output: 10099 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- repository instructions ---'
cat AGENTS.md
printf '%s\n' '--- build/config files ---'
find . -maxdepth 3 -type f \( -name 'package.json' -o -name 'webpack.config.*' -o -name 'tsconfig*.json' -o -name 'babel.config.*' \) -print
printf '%s\n' '--- package and webpack references ---'
for f in $(find . -maxdepth 3 -type f \( -name 'package.json' -o -name 'webpack.config.*' -o -name 'tsconfig*.json' -o -name 'babel.config.*' \)); do
printf '\n--- %s ---\n' "$f"
sed -n '1,240p' "$f"
done
printf '%s\n' '--- repository state ---'
git status --short
git diff --numstatRepository: dimagi/commcare-connect Length of output: 1961 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- source history boundary ---'
git show HEAD:commcare_connect/static/js/mapbox.js | sed -n '120,215p'
printf '%s\n' '--- static asset references ---'
rg -n -C 3 'mapbox\.js|webpack|entry|typescript|ts-loader|\.ts' package.json webpack.config.* config commcare_connect --glob '!**/node_modules/**' 2>/dev/null || trueRepository: dimagi/commcare-connect Length of output: 50379 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- CLAUDE instructions relevant to frontend/build ---'
rg -n -C 4 'TypeScript|JavaScript|webpack|static/js|frontend|prettier|new code' CLAUDE.md
printf '%s\n' '--- webpack files ---'
find webpack -maxdepth 2 -type f -print
printf '%s\n' '--- webpack module rules and entries ---'
rg -n -C 6 'entry|module|rules|babel-loader|\.js|extensions|resolve' webpackRepository: dimagi/commcare-connect Length of output: 8031 Add TypeScript build support before moving the building overlay. The repository requires TypeScript for new code, but 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| 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. | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.