Vector Tile Publishing for Zoning Maps
The pipeline is correct, the history is bitemporal, the overlays compose — and the map on the website still shows last month’s zoning, because a tile generated in June is sitting in a CDN with a one-year cache header. Publishing is where a zoning pipeline meets a caching layer built on the assumption that geometry does not change, and zoning is geometry that changes on a schedule set by planning commissions. This topic covers making tiles that stay truthful: how to shape the pyramid, how to join attributes without rebuilding polygons, and above all how to invalidate exactly what a rezone touched. It closes out the Spatial Impact Analysis & Zoning Change Detection area, consuming the change records produced by change detection & geometry diffing.
Prerequisites and operational context jump to heading
Three things need to be settled before a tile pipeline is worth building, and each one is a decision rather than a task.
A stable feature identity. Every tiled parcel needs an id that survives regeneration, because the id is what a client uses to correlate a rendered feature with the record it came from — for a click-through, a highlight, or a diff. Deriving it from a row number or a tile-local index means it changes on every rebuild and nothing downstream can rely on it.
A declared authority for attributes. Tiles carry a snapshot of attribute values, so they are a copy, and copies go stale. Which copy is authoritative has to be explicit: the tile is for rendering, the API is for answers. A client that reads a zoning code out of a tile and uses it for a compliance statement has taken a rendering optimisation and treated it as a source of truth.
A change feed to invalidate against. Without a per-parcel change record, the only available invalidation strategy is “rebuild everything,” which for a metropolitan parcel layer is hours of work for a handful of changed lots. The change records that change detection & geometry diffing already produces are exactly the input a targeted invalidation needs, which is why tiling is placed after change detection in this area rather than beside it.
Architecture: separate the geometry pyramid from the attribute join jump to heading
The decision that shapes everything else is whether attributes are baked into tiles at generation time or joined at render time. Baking is simpler and makes every attribute change a tile rebuild; joining keeps geometry stable and lets a rezone update a small data file instead.
The joined design is not free — it costs a request, a client-side join, and a brief unstyled flash — but it changes the invalidation problem from “find and purge nine tiles per parcel across five zoom levels” to “overwrite one small file.” For a live zoning map that has to reflect an ordinance adopted this morning, that is the difference between a publishing step and a publishing project.
Zoom levels and generalisation jump to heading
| Zoom | What a user is doing | Geometry needed | Attributes needed |
|---|---|---|---|
| z8–z10 | regional context | district boundaries, dissolved | class only |
| z11–z13 | neighbourhood scanning | simplified parcels, small lots dropped | class, overlay flags |
| z14–z16 | looking at a specific lot | full parcel geometry | code, overlays, capacity |
| z17+ | measuring | full geometry, no simplification | everything, with citations |
Simplification tolerance at each level has one hard constraint that is easy to miss: it must stay below the noise floor used for change detection. If tiles are simplified with a 3-metre tolerance while change detection treats anything above 2 m² of symmetric difference as real, then the tiles disagree with the change feed about what a parcel’s shape is, and a user who measures on the map gets a different answer from the API. Keeping the tolerance under the floor makes the two consistent by construction.
Production implementation jump to heading
The invalidation planner is the piece worth writing carefully, because it is where a targeted rebuild either works or quietly misses tiles.
import math
from dataclasses import dataclass
@dataclass(frozen=True)
class Tile:
z: int
x: int
y: int
def key(self) -> str:
return f"{self.z}/{self.x}/{self.y}"
def tiles_for_bounds(minx, miny, maxx, maxy, zooms) -> set[Tile]:
"""Web-Mercator tile coverage for a lon/lat bounding box.
The +1 on the ranges is deliberate: a parcel whose edge falls exactly on a
tile boundary appears in BOTH neighbouring tiles, and an off-by-one here is
the classic 'one stale tile on the seam' bug that survives every test
because it only shows at one zoom, on one edge, for one parcel.
"""
out = set()
for z in zooms:
n = 2 ** z
x0 = int((minx + 180.0) / 360.0 * n)
x1 = int((maxx + 180.0) / 360.0 * n)
def _ytile(lat):
r = math.radians(lat)
return int((1.0 - math.asinh(math.tan(r)) / math.pi) / 2.0 * n)
y0, y1 = _ytile(maxy), _ytile(miny)
for x in range(max(0, x0), min(n - 1, x1) + 1):
for y in range(max(0, y0), min(n - 1, y1) + 1):
out.add(Tile(z, x, y))
return out
GEOMETRY_CLASSES = {"boundary_adjustment", "split", "merge"}
ATTRIBUTE_CLASSES = {"rezone", "attribute_only", "overlay_only"}
def plan_invalidation(changes, zooms=range(10, 17)) -> dict:
"""Split a change set into an attribute-file rewrite and a tile rebuild.
Attribute-only changes never touch the pyramid; geometry changes do. Handling
them together is what turns a two-second state-file write into an hour of
tile generation for no reason.
"""
state_ids, rebuild = set(), set()
for ch in changes:
if ch.change_class in ATTRIBUTE_CLASSES:
state_ids.add(ch.feature_id)
elif ch.change_class in GEOMETRY_CLASSES:
# Both the old and the new geometry must be invalidated: the parcel may
# have moved out of a tile it used to occupy, leaving a ghost behind.
for geom in (ch.geometry_before, ch.geometry_after):
if geom is None:
continue
rebuild |= tiles_for_bounds(*geom.bounds, zooms)
state_ids.add(ch.feature_id)
else:
raise ValueError(f"unclassified change {ch.change_class!r}: refusing to "
f"guess whether it touches geometry")
return {
"state_file_ids": sorted(state_ids),
"tiles_to_rebuild": sorted(t.key() for t in rebuild),
"purge_urls": [f"/tiles/{t}.pbf" for t in sorted(t.key() for t in rebuild)],
}
Two details in that function are the ones that bite in production. First, a geometry change invalidates the tiles of both the before and after geometry — a parcel that shrank leaves a stale rendering of its old extent in tiles it no longer touches, and invalidating only the new bounds leaves that ghost on the map indefinitely. Second, the ValueError on an unclassified change is deliberate: the planner cannot safely guess, and a new change class that silently falls through to “attribute only” produces a map that is wrong in exactly the way this whole design exists to prevent.
Edge cases and gotchas jump to heading
Tile boundary seams. A parcel crossing a tile edge is clipped into both tiles. If only one is rebuilt, the parcel renders half old and half new, which is more confusing than being entirely stale. The +1 on the tile ranges above is what covers this, and it is worth an explicit test with a parcel placed deliberately on a known tile boundary.
Coordinate precision inside tiles. Vector tiles quantise coordinates to an integer grid, typically 4096 units per tile. At low zoom that grid is coarser than a parcel boundary, so distinct lots collapse onto identical geometry. This is correct behaviour for rendering and wrong for anything else, which is another reason a tile is not an answer.
Feature ids that must be numeric. The vector tile specification’s id field is an unsigned integer, while parcel identifiers are strings like 0714-22-1. A hash into 53 bits works and must be checked for collisions across the whole layer, because a collision silently merges two parcels’ styling. Keep the original string as a regular attribute alongside the numeric id.
A taxonomy change with no data change. When the canonical class set changes — a new RES-MED class splitting an existing one — no parcel changed, so no change record is emitted, and the map’s styling silently has no rule for the new class. Those features render with the fallback style, which usually means invisible. Taxonomy versions therefore need to participate in the style contract, which is the subject of keeping tile styling in step with a taxonomy change.
Stale tiles behind a CDN you do not control. Purge APIs are eventually consistent and occasionally silent. Versioning the tile path — /tiles/v{pyramid_version}/{z}/{x}/{y}.pbf — sidesteps purging entirely for geometry rebuilds, at the cost of a cold cache. For a layer that rebuilds monthly this is usually the better trade.
Integration points jump to heading
Tiling consumes three things and produces one.
It consumes change records with a class, from change detection — the class is what routes a change to a state-file rewrite or a tile rebuild, and an unclassified change stops the planner. It consumes resolved attributes, including the overlay set from overlay district modeling, because a map that shades base zoning while ignoring overlays shows a district that does not govern anything. And it consumes capacity figures where they exist, since allowable floor area is the attribute users most want shaded.
What it produces is a rendering surface, and the discipline that matters is keeping it labelled as one. Tiles should carry an as_of attribute or a sidecar manifest naming the effective date and the run that produced them, so a screenshot taken from the map can be dated later. That manifest is also what lets a stale map be detected rather than merely suspected: a client comparing the manifest’s as_of against the API’s current date can tell the user their map is eleven hours old, which is the same honesty the fallback tiers in fallback routing logic apply to data.
Compliance and audit artifacts jump to heading
Maps get screenshotted, pasted into memos, and cited in meetings, which makes their provenance a compliance concern even though they are “just rendering.”
The pyramid manifest is the primary artifact: pyramid version, generation run id, source snapshot digest, effective date, simplification tolerance per zoom, and the taxonomy version the styling assumes. With it, a screenshot can be traced to the exact data and rules behind it; without it, a map is an undated assertion.
The invalidation log is the second: for each change set, which state ids were rewritten, which tiles were rebuilt, which purges were issued, and which of those purges were confirmed. Unconfirmed purges are the mechanism by which a correct pipeline publishes a wrong map, and they are invisible unless recorded.
Finally, keep the style contract under version control next to the taxonomy. A style file that names classes is a consumer of the taxonomy, and treating it as one — versioned together, tested together, as described in testing spatial data pipelines — is what stops a class rename from quietly erasing a district from the map.
Generating the pyramid jump to heading
Two generation paths dominate, and they suit different publishing rhythms rather than different scales. Pre-generating an archive with a tool such as tippecanoe produces an immutable set of tiles that can be served from object storage with no database in the request path; it is the right choice for a snapshot that will be served unchanged for a month, and it is also the honest choice for an archived historical layer, because the tiles are a file with a digest rather than a query result that may drift.
Serving tiles from the database on demand — PostGIS composing them per request — inverts the trade: nothing to invalidate because nothing is stored, at the cost of putting a spatial query in the critical path of every map pan. With a cache in front it behaves like a pre-generated pyramid whose invalidation is a cache purge rather than a rebuild, which for a layer that changes daily is usually the simpler operational story.
The decision that actually matters is not which generator to use but whether attributes are baked in, because that is what determines whether a rezone is a file write or a rebuild. Both generators can work either way, and choosing the join before choosing the tool avoids rebuilding the pipeline when the publishing rhythm changes.
Telling a user how old the map is jump to heading
The most valuable feature a zoning map can have is not a layer toggle; it is a visible statement of what it is showing. A map is a copy, copies go stale, and a stale zoning map is indistinguishable from a current one on screen.
Ship an as_of value in the pyramid manifest and surface it in the interface. When the state file
carries its own timestamp, a client can compare the two and say something genuinely useful — “zoning
as of 09:15 today” or, during an ingestion outage, “zoning as of yesterday evening; one county is
being served from a snapshot.” That second message is the same tiering the pipeline already computes
for fallback routing, carried one step further into the interface rather than stopping at the API.
The alternative is what most zoning maps do: display polygons with no date and let the user assume they are current. The assumption is usually right, which is exactly what makes the occasional wrong case damaging — a user who has been trained by a hundred correct sessions will not question the hundred-and-first.
FAQ jump to heading
Should zoning attributes be baked into tiles or joined at render time?
Joined, for any map that has to reflect current zoning. Attribute changes are frequent — every rezone is one — and geometry changes are rare, so baking makes the common case an expensive rebuild and the rare case no cheaper. Baking is the better choice only for an immutable archive snapshot, where the point is that nothing will change.
Is it safe to read a zoning code out of a tile?
For rendering, yes; for an answer, no. Tiles quantise coordinates to an integer grid and carry a snapshot of attributes that may be older than the API, so a code read from a tile is a rendering hint rather than a fact about a parcel. Declare the tile as a copy and the API as authoritative, and keep any compliance path off the tile entirely.
Which tiles need invalidating when a parcel's geometry changes?
The tiles covering the geometry before the change as well as after it, across every zoom level served. Invalidating only the new extent leaves a rendering of the old shape in tiles the parcel no longer touches, and that ghost persists until the next full rebuild. Include the tiles the boundary falls on, since a parcel on a tile edge is clipped into both.
What breaks when the zoning taxonomy gains a new class?
The style file, silently. No parcel changed, so no change record is emitted and no tile is rebuilt, but the styling has no rule for the new class and those features fall through to the fallback style — which usually means they vanish. Version the style contract alongside the taxonomy and test that every current class has a matching rule.
Related jump to heading
- Section overview: Spatial Impact Analysis & Zoning Change Detection
- Change Detection & Geometry Diffing — supplies the classified change records invalidation is planned from
- Overlay District Modeling — a map shading base zoning alone shows a district that governs nothing
- Development Capacity & Buildable Area — the derived attribute worth shading, and the one that changes on rezone
- Fallback Routing Logic — the staleness tiering this carries one step further, into the interface