Joining zoning attributes to tiles without rebuilding geometry
Eleven rezones landed this morning. The geometry did not change — the same parcels with the same boundaries — but because the zoning class is baked into the tiles, showing the new colours means regenerating every tile containing those eleven parcels across five zoom levels, then purging each URL. That is roughly two hundred tiles and a purge round-trip for a change that is eleven rows of data. This guide moves the attributes out of the pyramid into a small state file joined at render time, so a rezone costs one file write. It is the architectural half of vector tile publishing for zoning maps.
Diagnosis: how often each kind of change actually happens jump to heading
The case for the join is empirical, so measure it from your own change feed before building anything.
GEOMETRY_CLASSES = {"boundary_adjustment", "split", "merge"}
ATTRIBUTE_CLASSES = {"rezone", "overlay_only", "attribute_only"}
def change_mix(changes, days=365):
"""What fraction of a year's changes are attribute-only? That fraction is exactly
the fraction of tile rebuilds the join eliminates."""
from collections import Counter
counts = Counter(c.change_class for c in changes)
attr = sum(v for k, v in counts.items() if k in ATTRIBUTE_CLASSES)
geom = sum(v for k, v in counts.items() if k in GEOMETRY_CLASSES)
return {"attribute_only": attr, "geometry": geom,
"attribute_share": round(attr / max(attr + geom, 1), 3),
"by_class": dict(counts)}
A typical county produces a great many rezones and overlay adoptions and very few splits or merges, so the attribute share is high — and every attribute-only change is a pyramid rebuild the join removes. If your mix is unusual, that is worth knowing before adopting the extra moving part.
Step-by-step implementation jump to heading
1. Emit a state file keyed on the tile feature id jump to heading
import json
import hashlib
def build_state_file(parcels, taxonomy_version, as_of):
"""Compact by construction: parallel arrays rather than a list of objects, and
class names replaced by indices into a legend. A county of 41 000 parcels lands
around 40 kB gzipped, which is a 60-second-cacheable asset."""
classes = sorted({p.canonical_class for p in parcels})
class_index = {c: i for i, c in enumerate(classes)}
ids, cls, ovl = [], [], []
for p in sorted(parcels, key=lambda p: p.feature_id):
ids.append(p.feature_id)
cls.append(class_index[p.canonical_class])
ovl.append(",".join(sorted(p.overlay_codes)))
payload = {
"as_of": as_of.isoformat(),
"taxonomy_version": taxonomy_version,
"legend": classes,
"ids": ids, # ascending, so a client can binary-search
"class": cls,
"overlays": ovl,
}
body = json.dumps(payload, separators=(",", ":")).encode()
return body, hashlib.sha256(body).hexdigest()[:12]
2. Join it in the renderer with feature state jump to heading
// The join happens per rendered feature, so it must be O(1) per lookup.
async function loadZoningState(url) {
const res = await fetch(url, { cache: 'no-cache' }); // short max-age on the server
const s = await res.json();
const byId = new Map();
for (let i = 0; i < s.ids.length; i++) {
byId.set(s.ids[i], { cls: s.class[i], overlays: s.overlays[i] });
}
return { ...s, byId };
}
function applyZoningState(map, state) {
// setFeatureState is the mechanism that makes this cheap: it attaches data to
// already-rendered features without re-parsing the tile.
for (const [id, v] of state.byId) {
map.setFeatureState({ source: 'parcels', sourceLayer: 'parcels', id },
{ zoningClass: v.cls, overlays: v.overlays });
}
}
// Styling reads feature state rather than a baked attribute.
const zoningPaint = {
'fill-color': [
'match', ['feature-state', 'zoningClass'],
0, '#e0f2f1', // indices into state.legend
1, '#fde68a',
2, '#d1fae5',
/* … */
'#cccccc', // fallback: a class the style does not know
],
};
3. Handle the unstyled interval honestly jump to heading
Between the tiles rendering and the state loading, features have no class. That gap is short and it is visible, so it needs a deliberate treatment rather than a flash of grey.
map.on('load', async () => {
map.setPaintProperty('parcels-fill', 'fill-opacity', 0.15); // muted, not invisible
showStatus('Loading current zoning…');
const state = await loadZoningState('/state/zoning.json');
applyZoningState(map, state);
map.setPaintProperty('parcels-fill', 'fill-opacity', 0.7);
showStatus(`Zoning as of ${state.as_of}`); // the map now says what it is showing
// New tiles arrive as the user pans; their features need the state too.
map.on('sourcedata', (e) => {
if (e.sourceId === 'parcels' && e.isSourceLoaded) applyZoningState(map, state);
});
});
The sourcedata handler is easy to omit and produces a specific bug: the initial viewport is styled correctly and everything the user pans to is grey.
4. Keep the two versions consistent jump to heading
A state file and a pyramid can disagree — a parcel split adds feature ids the old state file has never heard of, and removes ids it still contains.
def validate_pair(manifest, state_payload, tile_ids):
"""Refuse to publish a state file that does not match the pyramid it will be
joined to. A mismatch renders parcels unstyled, which looks like a data outage."""
state_ids = set(state_payload["ids"])
problems = []
missing = tile_ids - state_ids
if missing:
problems.append(f"{len(missing)} tile feature(s) have no state entry — they "
f"will render unstyled (e.g. {sorted(missing)[:3]})")
orphaned = state_ids - tile_ids
if orphaned:
problems.append(f"{len(orphaned)} state entr(ies) reference features absent "
f"from the pyramid — a stale state file")
if state_payload["taxonomy_version"] != manifest["taxonomy_version"]:
problems.append(f"taxonomy mismatch: state {state_payload['taxonomy_version']} "
f"vs pyramid {manifest['taxonomy_version']}")
if problems:
raise StatePyramidMismatch("; ".join(problems))
Verification & testing jump to heading
def test_a_rezone_touches_only_the_state_file():
before = pyramid_digest(TILES)
apply_rezone(parcel_id="0714-22-1", new_class="MU-2")
publish()
assert pyramid_digest(TILES) == before # the pyramid is untouched
assert "MU-2" in read_state()["legend"]
def test_a_split_requires_a_pyramid_rebuild():
before = pyramid_digest(TILES)
apply_split(parcel_id="0714-22-1", into=["0714-22-1A", "0714-22-1B"])
publish()
assert pyramid_digest(TILES) != before # geometry changed: rebuild required
def test_state_file_stays_small():
body, _digest = build_state_file(COUNTY_PARCELS, "tax-v4", date.today())
import gzip
assert len(gzip.compress(body)) < 80_000 # a 60-second-cacheable asset
def test_publishing_a_mismatched_pair_is_refused():
state, _ = build_state_file(PARCELS_AFTER_SPLIT, "tax-v4", date.today())
with pytest.raises(StatePyramidMismatch, match="no state entry"):
validate_pair(OLD_MANIFEST, json.loads(state), OLD_TILE_IDS)
def test_taxonomy_mismatch_is_refused():
state, _ = build_state_file(COUNTY_PARCELS, "tax-v5", date.today())
with pytest.raises(StatePyramidMismatch, match="taxonomy mismatch"):
validate_pair(MANIFEST_V4, json.loads(state), TILE_IDS)
In the browser, the check worth automating is a screenshot diff after panning: render the initial viewport, pan two tiles east, and assert the newly loaded parcels are styled. That catches the missing sourcedata handler, which no unit test sees.
Failure recovery jump to heading
Every parcel renders grey. The state file failed to load or its ids do not match the pyramid. Check the pair validation first — a split published without a pyramid rebuild is the usual cause, and the fix is to rebuild the pyramid rather than to patch the state file.
Only panned-to areas are grey. The sourcedata handler is missing, so state is applied once to the initial viewport and never to tiles loaded later.
A stale state file cached too long. Shorten its max-age — sixty seconds is reasonable for a file that changes on a rezone — and check that the response is not being cached by an intermediary with its own policy. The pyramid can be cached for a year because its path is versioned; the state file cannot, because its path is stable.
Frequently asked questions jump to heading
Why not put the attributes in the tiles and rebuild when they change?
Because attribute changes are frequent and geometry changes are rare, so baking makes the common case expensive. A rezone is one row of data and becomes roughly two hundred tile regenerations plus a purge round-trip; the same change against a state file is a 40 kB write. Baking is right only for an immutable archive snapshot, where nothing will change by design.
Does the client-side join hurt performance?
Barely, if the state file is compact and the lookup is a map. The cost is one extra request of tens of kilobytes and a setFeatureState call per rendered feature, which is far cheaper than re-downloading tiles. The perceptible cost is the brief unstyled interval, which is why the map mutes rather than hides parcels until the state arrives.
What happens when a parcel is split?
Geometry changed, so the pyramid must be rebuilt — the join does not eliminate that and does not claim to. What it eliminates is the far more frequent attribute-only rebuild. The pair validation exists precisely to stop a state file being published against a pyramid that predates the split, which would leave the new parcels unstyled.
Should the state file carry overlays as well as the base class?
Yes, if the map shades or filters on them — and it should, because a map showing base zoning alone displays a district that does not fully govern the parcel. Overlays compress well as a sorted comma-joined string, and keeping them in the same file means one request and one consistency check rather than two.
Related jump to heading
- Parent topic: Vector Tile Publishing for Zoning Maps
- Section overview: Spatial Impact Analysis & Zoning Change Detection
- Building a zoning vector tile pyramid with tippecanoe — the stable feature id this join depends on
- Invalidating tile caches when a rezone lands — what still needs invalidating once attributes have moved out
- Overlay District Modeling — why the state file carries overlays too