Visualizing Tile Coverage Gaps on a Geomap
A coverage percentage tells you that 3.4% of the pyramid is missing; a map tells you it is one region, which is a different problem with a different owner. Build the panel from the tile ledger rather than from metrics, aggregate to a zoom level coarse enough that the browser receives hundreds of features rather than millions, colour by age rather than by presence, and fit the initial view to the layer’s own extent. The result answers “what is missing and how stale is the rest” in one glance, which is the question a coverage number raises and cannot answer.
When to Use This Pattern
- A tile ledger exists recording when each tile was last built, as described in skipping tiles with no new source data.
- Coverage is below a hundred per cent and nobody can say where the gaps are without opening QGIS.
- Freshness varies by region, which it does whenever sources are published on different schedules.
- The pyramid is large enough that listing missing tiles is unhelpful — a thousand-row table is not an answer.
Complete Working Example
The query does the aggregation, so the panel receives a manageable number of features. Grafana’s Geomap reads latitude and longitude columns, so the tile index becomes a centroid in WGS84 in SQL.
-- One row per aggregated cell, at a zoom coarse enough to render.
-- The bit-shift turns a z14 tile index into its z8 ancestor, which is
-- 64x fewer cells and still fine enough to localise a regional gap.
WITH agg AS (
SELECT
(x >> (z - 8)) AS ax,
(y >> (z - 8)) AS ay,
count(*) AS expected,
count(*) FILTER (WHERE built_at IS NOT NULL) AS built,
max(EXTRACT(EPOCH FROM now() - built_at)) / 3600.0 AS oldest_hours
FROM tile_ledger
WHERE layer = '$layer' AND z = $zoom
GROUP BY 1, 2
)
SELECT
ST_Y(ST_Centroid(ST_Transform(ST_TileEnvelope(8, ax, ay), 4326))) AS latitude,
ST_X(ST_Centroid(ST_Transform(ST_TileEnvelope(8, ax, ay), 4326))) AS longitude,
round(100.0 * built / expected, 1) AS coverage_pct,
round(oldest_hours::numeric, 1) AS oldest_hours,
expected - built AS missing
FROM agg
-- Only cells worth drawing: complete and fresh cells are the background, not the subject.
WHERE built < expected OR oldest_hours > 48
ORDER BY missing DESC;
The panel definition colours by age and sizes by how much is missing, so a small stale area and a large fresh gap are visually distinguishable:
{
"title": "Coverage gaps and stale areas — $layer at z$zoom",
"description": "Circle size is the number of missing tiles; colour is the age of the oldest tile in the cell. Empty map means full, fresh coverage.",
"type": "geomap",
"datasource": "PostGIS",
"options": {
"view": { "id": "fit", "padding": 10 },
"basemap": { "type": "osm-standard" },
"layers": [
{
"type": "markers",
"config": {
"size": { "field": "missing", "min": 4, "max": 22 },
"color": { "field": "oldest_hours" },
"showLegend": true
}
}
]
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "orange", "value": 48 },
{ "color": "red", "value": 168 }
]
}
}
}
}
The choice to colour by age rather than by presence is worth dwelling on, because it is what makes one panel serve two questions. A gap and a stale area are both coverage problems and they have different causes: a gap usually means a source has never been delivered for that region, and a stale area means the pipeline stopped updating one it used to. Rendering both on the same map, distinguished by colour, means a glance answers “is anything missing” and “is anything rotting” together — and in practice the second is the more common failure and the one with no other detector.
Parameter & Option Reference
| Setting | Value | Spatial notes |
|---|---|---|
| aggregation zoom | 8 | Coarse enough to render, fine enough to localise. Bit-shifting the index is exact and free. |
view.id |
fit |
Fits to the returned data. The default world view renders a national layer as a dot. |
| size field | missing |
Absolute count, so a large gap looks large. Coverage percentage would flatten it. |
| colour field | oldest_hours |
Age, not presence — a fresh gap and a stale region are different problems. |
| filter | incomplete or stale | Drawing every cell puts the interesting ones under thousands of green dots. |
| thresholds | 48 h / 168 h | Per layer. A basemap tolerates a week; a flood extent does not tolerate a day. |
| basemap | OSM | Context matters here: the gap’s shape only means something against coastlines and borders. |
Verification & Testing
The query is the part worth testing, and the bit-shift is where the bugs are.
def test_aggregation_preserves_the_tile_count(conn, seeded_ledger) -> None:
total = seeded_ledger.tile_count
rows = run_coverage_query(conn, layer="ortho", zoom=14, agg_zoom=8)
assert sum(r["expected"] for r in rows) <= total # filtered rows only
# …and no cell claims more tiles than a z8 cell can contain at z14.
assert all(r["expected"] <= 4 ** (14 - 8) for r in rows)
def test_bit_shift_maps_children_to_the_right_parent() -> None:
"""A z14 tile's z8 ancestor is its index shifted right by six."""
assert (8802 >> (14 - 8), 5121 >> (14 - 8)) == (137, 80)
def test_centroids_land_in_the_right_hemisphere(conn) -> None:
rows = run_coverage_query(conn, layer="ortho", zoom=14, agg_zoom=8)
for row in rows:
assert 55 < row["latitude"] < 72, "Norwegian layer with a latitude outside the country"
assert 3 < row["longitude"] < 32
def test_complete_fresh_layer_returns_nothing(conn, complete_ledger) -> None:
"""An empty map is the correct rendering of a healthy layer."""
assert run_coverage_query(conn, layer="ortho", zoom=14, agg_zoom=8) == []
That last test encodes a design decision worth being explicit about: the panel is empty when everything is fine. That is unusual for a dashboard panel and it is the right behaviour here — the map’s job is to show problems, and a map covered in green dots trains people to stop looking at it. The panel description says as much, so an empty map reads as “nothing to see” rather than “the query is broken”.
Common Pitfalls
- Returning every tile. A million markers will hang the browser tab, and the panel gets removed rather than fixed. Aggregate in SQL, always, and make the aggregation zoom a variable so it can be adjusted without editing the query.
- Using the default world view. A national layer renders as a speck in the Atlantic.
view.id: fitcosts one line and makes the panel usable on open. - Colouring by presence rather than by age. Presence answers a yes/no question the coverage stat already answered. Age is what distinguishes “not built yet” from “built in March and never since”.
- Computing centroids in the panel. Grafana’s field transformations can do arithmetic, but the transform belongs in PostGIS where it is testable and where the same query works from
psqlduring an investigation. - Forgetting that tile y counts downward.
ST_TileEnvelopeuses the XYZ convention, so a query that flips y renders a perfect mirror image of the real coverage — plausible enough to mislead for a surprisingly long time. - Aggregating without weighting. A cell containing four thousand tiles and one containing forty look identical if the marker size is coverage percentage rather than missing count. Size by the absolute number, and put the percentage in the tooltip.
Frequently Asked Questions
Why bit-shifting instead of a spatial join?
Because it is exact and free. A tile index at zoom z maps to its ancestor at zoom a by shifting right by z - a, with no geometry involved. A spatial join to a coarser grid produces the same answer, reads more clearly to some people, and costs an index scan per row on a table with millions of them. For an aggregation running on every dashboard refresh, the shift is the right choice.
Should the panel show polygons rather than markers?
Polygons are more honest — a cell is an area, not a point — and they are heavier to render and fiddlier to size by value. Markers with size and colour convey the same information at a fraction of the cost, and the map’s job here is localisation rather than precise depiction. Where a stakeholder needs the exact shape, export the query to GeoJSON and open it in a GIS client.
How do I show gaps across several layers at once?
One panel per layer, driven by the same $layer variable, is usually clearer than overlaying them — overlapping markers of different colours in the same place are hard to read. If a combined view is genuinely needed, aggregate to a coarser zoom and use a table panel listing the worst cells per layer, which is more legible than a crowded map.
What is the right staleness threshold?
The same one the layer’s freshness SLO uses, which is where that number should be defined in the first place. Duplicating it in the panel means the two drift; referencing the SLO’s value keeps them together. See defining freshness SLOs for tile layers.
Can this panel drive an alert?
Not directly — alerting on a map is awkward, and the useful alert is on the aggregate anyway. What the panel provides is the localisation after an aggregate alert fires. Alert on coverage falling below a threshold or on the oldest tile ageing past the SLO, and let this panel answer where.
Related
- Grafana dashboards for GIS workflows — where this panel sits in the layout
- Building a raster pipeline Grafana dashboard — the other eight panels
- Skipping tiles with no new source data — the ledger this reads
- Defining freshness SLOs for tile layers — where the staleness threshold is defined