Generating Vector Tiles From Suppressed Rate Data

This guide, part of Publishing Surveillance Map Products, builds the tile set behind an interactive surveillance map, with the specific controls that stop a web map from becoming a data download.

Problem Context & Constraints

Vector tiles ship geometry and attributes to the browser in small binary chunks, one per tile and zoom level. That design is what makes interactive maps fast, and it has a consequence that is easy to miss: everything in the tile is on the viewer’s machine. There is no server-side filter between the tile and the reader. An attribute included “for internal use” or “for the tooltip later” is published the moment the tile is.

Three leaks recur. Attributes that were not meant to be shown — a raw count alongside a rate, a record-count field used for the suppression decision, an internal identifier. Geometry at a finer zoom than the suppression geography — tiles generated to the tool’s default maximum zoom, which lets a reader inspect boundaries at building scale and, with enough of them, infer more than the rate. Slivers and gaps from per-polygon simplification — which look like data holes and invite exactly the misreading an agency wants to avoid.

The constraint that resolves all three is to treat the tile set as a published product in its own right: built from an explicit attribute allow-list, capped at an explicit zoom, simplified as a coverage, and verified after generation by decoding the tiles and checking what they actually contain. Configuration can be wrong; the decoded tiles are the truth.

An Allow-List, Not a Block-List A suppressed aggregate table with several columns enters an allow-list filter before tile generation. Only the area identifier, the rate class, the suppressed flag and the provisional flag pass through into the tiles. The raw count, the population denominator, the record count used for the suppression decision and the internal run identifier are all rejected. A note states that an allow-list fails safe when a new column is added upstream, because the new column is excluded by default, whereas a block-list would publish it. A new upstream column is excluded by default Suppressed table geoid rate_class suppressed provisional cases population n_records, run_id Allow-list four named fields In the tiles geoid · rate_class suppressed · provisional Never leaves cases · population n_records · run_id A block-list would have published whatever column somebody added next

Prerequisites

  • python 3.11
  • geopandas 0.14.4, shapely 2.0.4, pandas 2.2.2
  • A tiling tool that accepts GeoJSON input and a maximum zoom argument, and a decoder for verification — mapbox-vector-tile 2.1.0 is used below
  • Suppressed aggregates from the published store, with a suppression flag per area
  • The publication geography level and its zoom ceiling from the parent guide

Step-by-Step Solution

Filter attributes through an allow-list, simplify as a coverage, write tiling input with the zoom ceiling, generate, then decode a sample of tiles and assert their contents.

# Vector tiles from suppressed data: allow-list, coverage simplification, verification.
# Pinned: geopandas==0.14.4, shapely==2.0.4, pandas==2.2.2, mapbox-vector-tile==2.1.0
import gzip
import json
import logging
import os
import sqlite3
import subprocess

import geopandas as gpd
import mapbox_vector_tile
import pandas as pd
import shapely

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("publish.tiles")

ALLOWED = ["geoid", "rate_class", "suppressed", "provisional"]
MAXZOOM = {"county": 9, "tract": 12}


def prepare_layer(published: gpd.GeoDataFrame, level: str,
                  simplify_m: float) -> gpd.GeoDataFrame:
    """Allow-listed attributes on coverage-simplified geometry, in web projection."""
    if not published["suppressed"].dtype == bool:
        raise TypeError("suppressed flag must be boolean — is this the published store?")
    extra = sorted(set(published.columns) - set(ALLOWED) - {"geometry"})
    log.info("dropping %d non-allow-listed columns: %s", len(extra), extra)
    g = published.sort_values("geoid").reset_index(drop=True)
    simplified = shapely.coverage_simplify(g.geometry.values, simplify_m)
    out = gpd.GeoDataFrame(g[ALLOWED].copy(), geometry=list(simplified), crs=g.crs)
    # Suppressed areas carry no class at all, not a zero.
    out.loc[out["suppressed"], "rate_class"] = None
    return out.to_crs("EPSG:4326")


def build_tiles(layer: gpd.GeoDataFrame, level: str, out_mbtiles: str,
                layer_name: str = "surveillance") -> None:
    src = out_mbtiles.replace(".mbtiles", ".geojson")
    layer.to_file(src, driver="GeoJSON")
    z = MAXZOOM[level]
    cmd = ["tippecanoe", "-o", out_mbtiles, "-l", layer_name,
           f"--maximum-zoom={z}", "--minimum-zoom=4",
           "--no-tile-size-limit", "--detect-shared-borders", "--force", src]
    subprocess.run(cmd, check=True)
    os.remove(src)                       # the intermediate GeoJSON is not a product
    log.info("tiles written %s level=%s maxzoom=%d", out_mbtiles, level, z)


def verify_tiles(mbtiles: str, expected_maxzoom: int, sample: int = 50) -> dict:
    """Decode real tiles and check what they contain — configuration is not proof."""
    con = sqlite3.connect(mbtiles)
    zooms = [r[0] for r in con.execute("SELECT DISTINCT zoom_level FROM tiles")]
    rows = con.execute("SELECT zoom_level, tile_data FROM tiles ORDER BY RANDOM() "
                       f"LIMIT {int(sample)}").fetchall()
    con.close()
    seen_keys, bad_class = set(), 0
    for z, blob in rows:
        data = gzip.decompress(blob) if blob[:2] == b"\x1f\x8b" else blob
        decoded = mapbox_vector_tile.decode(data)
        for lyr in decoded.values():
            for feat in lyr["features"]:
                props = feat["properties"]
                seen_keys |= set(props)
                if props.get("suppressed") and props.get("rate_class") is not None:
                    bad_class += 1
    out = {"max_zoom_present": max(zooms), "unexpected_keys": sorted(seen_keys - set(ALLOWED)),
           "suppressed_with_class": bad_class}
    ok = (out["max_zoom_present"] <= expected_maxzoom and not out["unexpected_keys"]
          and bad_class == 0)
    (log.info if ok else log.error)("tile verification %s: %s", "PASSED" if ok else "FAILED", out)
    out["ok"] = ok
    return out

The verification step decodes actual tiles and inspects what a browser would receive. That is the only check that catches a tiling tool quietly adding a derived attribute, a stray column surviving the allow-list through a later edit, or a maximum-zoom argument being ignored. It costs seconds and is the difference between believing the tiles are safe and knowing it.

Deleting the intermediate GeoJSON after tiling is deliberate. That file is a complete, uncompressed copy of the published layer written to a working directory, and left in place it tends to be picked up by a backup, a sync tool or a colleague looking for “the data behind the map”.

Simplify the Coverage, Not Each Polygon Two versions of the same pair of adjacent areas after simplification. Simplified one polygon at a time, each area's shared edge is reduced independently, so the two simplified edges no longer coincide and a thin sliver of empty space opens between them, with an overlap elsewhere along the same edge. Simplified as a coverage, the shared edge is reduced once and used by both areas, so the boundary remains shared and no gap or overlap appears. On a web map the slivers read as areas with no data. Independent simplification opens gaps that look like missing data Per polygon a sliver and an overlap on one edge As a coverage one shared edge, simplified once Coverage simplification also produces smaller tiles, because shared edges are stored once

Validation & Edge Cases

The tiling tool dropping features at low zoom. Most tools thin features at low zoom to keep tiles small, which can remove small areas entirely — and a small area disappearing from a county view looks like a boundary change. Disable feature dropping for choropleths, or confirm every area is present at every zoom level.

Suppressed areas with a class. If the suppression flag and the class disagree, the map renders a colour for an area whose value should be withheld. The verification step counts exactly this and must find zero.

Tiles regenerated without changing the path. A corrected tile set served from the same path is hidden by caches. Version the path by run identifier.

Feature identifiers that encode more than the area. Tiling tools assign each feature an identifier, and some configurations derive it from a source column. If that column was a record key or a sequence number from the analytic archive, it travels into every tile and becomes a join key back to internal data. Let the tool assign opaque identifiers, or use the public area code only.

Time series in one tile set. Packing every week’s class into one tile set as separate attributes makes the map interactive across time and multiplies the published content. That is legitimate, but the complementary suppression review then has to cover the whole series — a suppressed cell one week can be recovered from its neighbours’ changes the next. Review the series as one release.

Style files that reveal intent. The client style can reference attributes and thresholds that the tiles themselves do not contain, such as a comment naming the suppression threshold or a hidden layer pointing at an internal endpoint. Treat the style file as a published artifact and review it with the tiles.

Minimum zoom too low for fine geography. Tract polygons at zoom 4 are pixel noise and make tiles large. Set a minimum zoom per geography as well as a maximum.

Attribute types changing silently. A boolean written as a string in one run and a boolean in the next breaks client styling without an error. Assert types before writing tiling input.

INFO publish.tiles dropping 5 non-allow-listed columns: ['cases', 'n_records', 'population', 'rate', 'run_id']
INFO publish.tiles tiles written /publish/runs/20260918-4f2a/tracts.mbtiles level=tract maxzoom=12
INFO publish.tiles tile verification PASSED: {'max_zoom_present': 12, 'unexpected_keys': [], 'suppressed_with_class': 0}

The time-series case in that list is where tile sets most easily exceed what was reviewed, because each added week multiplies what can be compared:

A Week Series Is One Release, Not Many Three weekly states of a county and its tracts packed into one tile set. In week one the county total and all tracts are published. In week two one tract is suppressed but the county total and the other tracts are published, and the change in the county total from week one, combined with the published changes in the other tracts, reveals the suppressed tract's value. Reviewing each week in isolation misses this; reviewing the packed series as a single release catches it. Each week is safe alone; the series leaks by differencing week 36 county 120 tracts 40, 45, 35 all published week 37 county 124 tracts 41, 46, suppressed looks safe alone together 124 minus 41 minus 46 = 37, recovered a single-period review misses it review the whole packed series as one release here the county total alone recovers the tract, before week 36 is even needed

Compliance Notes

Record the attribute allow-list, the zoom range per geography, the simplification tolerance and method, the tiling tool and version with its arguments, the verification results, and the run identifier embedded in the tile path.

The verification result belongs in the release decision, not just the log. A tile set whose verification failed must not be promoted to the public path, and the promotion step should read the verification record rather than trusting the build to have succeeded. Because vector tiles are published data in the fullest sense — copied into every viewer’s browser cache — a tile set that leaked an attribute cannot be recalled, which makes the pre-release check the only control that actually works.