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 number and the map answer different questionsA stat panel reads ninety-six point six per cent coverage. The map of the same data shows the missing tiles form one contiguous region in the north, which names the cause immediately.96.6%tile coverage“is that bad?”“one region, and it is the north”
Both panels are computed from the same ledger rows. Only the second one gets anybody to the cause.

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.

Three states, one panelComplete and fresh cells are not drawn at all. Cells with missing tiles are large and red. Cells that are complete but stale are amber and sized by how old the oldest tile is.staterendered asusual causecomplete and freshnothing at allworking as intendedtiles missinglarge, redno source for this regioncomplete but stalesmall, amberupdates stopped arrivingThe third row has no other detector: coverage is 100% and every run succeeds.
A panel that only shows presence would draw nothing for the third row, which is the row most likely to persist for months.

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”.

Choosing the aggregation zoomAt the native zoom the query returns over a million markers. At zoom eight it returns about four hundred. At zoom four it returns a dozen, which is too coarse to localise anything.aggregationmarkers returnedverdictnone (z14)1 240 000browser unusablez106 100sluggish, clutteredz8410readable and precisez412too coarse to localise
Six to eight zoom levels below the pyramid’s native resolution is the useful band for most national layers, and it shifts with the extent rather than with the pyramid’s depth.

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: fit costs 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 psql during an investigation.
  • Forgetting that tile y counts downward. ST_TileEnvelope uses 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.

Grafana Dashboards for GIS Workflows