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.
Prerequisites
python3.11geopandas0.14.4,shapely2.0.4,pandas2.2.2- A tiling tool that accepts GeoJSON input and a maximum zoom argument, and a decoder for verification —
mapbox-vector-tile2.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”.
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:
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.
Related Topics
- Publishing Surveillance Map Products — the parent guide and the zoom ceilings used here.
- Small-Count Cell Suppression in Rate Maps — producing the suppression flag the tiles depend on.
- Serving a Read-Only Spatial API for Partner Agencies — the programmatic counterpart to the web map.