Keeping tile styling in step with a taxonomy change
The taxonomy gains a class: RES-MED splits out of RES-LOW to describe the middle-density districts properly. No parcel geometry changed, no parcel’s actual zoning changed, so no change event fires and no tile is rebuilt — and 3 100 parcels now carry a class the style file has no rule for. They fall through to the fallback paint, which in most style files means a light grey nobody notices, or fill-opacity: 0, which means they vanish. The map is wrong in the way that is hardest to spot: not showing the wrong colour, but showing nothing. This guide couples the style to the taxonomy so the divergence fails a build instead of reaching a user, completing vector tile publishing for zoning maps.
Diagnosis: finding classes the style cannot paint jump to heading
The check is a set difference, and it is worth running as a gate rather than as an investigation.
import json
import re
def style_classes(style_path) -> set[str]:
"""Extract every class the style names, from `match` and `case` expressions on the
zoning class. Walks the whole expression tree, because a class can be named at any
depth inside a nested expression."""
style = json.loads(open(style_path).read())
found = set()
def walk(node, in_zoning_match=False):
if isinstance(node, list):
head = node[0] if node and isinstance(node[0], str) else None
names_zoning = any(
isinstance(x, list) and x[:1] == ["get"] and
x[1:2] in (["zoning_class"], ["canonical_class"])
or isinstance(x, list) and x[:1] == ["feature-state"] and
x[1:2] == ["zoningClass"]
for x in node)
here = in_zoning_match or (head in ("match", "case") and names_zoning)
for item in node:
if here and isinstance(item, str) and re.match(r"^[A-Z][A-Z0-9-]+$", item):
found.add(item)
walk(item, here)
elif isinstance(node, dict):
for v in node.values():
walk(v, in_zoning_match)
walk(style.get("layers", []))
return found
def taxonomy_style_gap(taxonomy, style_path):
styled = style_classes(style_path)
declared = {c.code for c in taxonomy.classes}
return {
"taxonomy_version": taxonomy.version,
"unstyled": sorted(declared - styled), # render as the fallback: the bug
"orphaned_rules": sorted(styled - declared), # dead style rules
}
Both directions matter. unstyled classes render as the fallback and are the failure this page is about. orphaned_rules are harmless to render and are a signal that a class was renamed — which usually means some parcels moved to a class nobody has styled either.
Step-by-step implementation jump to heading
1. Version the taxonomy, and make the style declare which version it targets jump to heading
{
"version": 8,
"name": "municipal-zoning",
"metadata": {
"taxonomy_version": "tax-v5",
"taxonomy_digest": "9c1f4a2b7e08",
"note": "Must match the pyramid manifest and the state file. The build fails if it does not."
},
"layers": [
{
"id": "parcels-fill",
"source": "parcels",
"source-layer": "parcels",
"type": "fill",
"paint": {
"fill-color": [
"match", ["feature-state", "zoningClass"],
"RES-LOW-1", "#e0f2f1",
"RES-LOW-2", "#c9e9e6",
"RES-MED", "#a9dcd8",
"MIXED-USE", "#fde68a",
"COMMERCIAL","#f7c07a",
"INDUSTRIAL","#d9b8a0",
"AGRICULTURAL","#d1fae5",
"#ff00ff"
]
}
}
]
}
The fallback is deliberately #ff00ff. A conspicuous magenta is the single highest-value change in this whole page: an unstyled class becomes impossible to miss in review, where a light grey or a transparent fill is invisible. Ship the loud fallback in staging always, and in production too if you can stand it.
2. Fail the build on a divergence jump to heading
class StyleTaxonomyMismatch(Exception):
"""The style cannot paint every class the taxonomy declares."""
def gate_style(taxonomy, style_path, manifest, state_payload):
gap = taxonomy_style_gap(taxonomy, style_path)
style = json.loads(open(style_path).read())
declared = style.get("metadata", {}).get("taxonomy_version")
problems = []
if gap["unstyled"]:
problems.append(f"{len(gap['unstyled'])} class(es) have no style rule and will "
f"render as the fallback: {', '.join(gap['unstyled'])}")
if declared != taxonomy.version:
problems.append(f"style targets {declared!r}, taxonomy is {taxonomy.version!r}")
if manifest["taxonomy_version"] != taxonomy.version:
problems.append(f"pyramid manifest targets {manifest['taxonomy_version']!r}")
if state_payload["taxonomy_version"] != taxonomy.version:
problems.append(f"state file targets {state_payload['taxonomy_version']!r}")
if problems:
raise StyleTaxonomyMismatch("; ".join(problems))
if gap["orphaned_rules"]:
# Not fatal — dead rules render nothing — but usually a rename in disguise.
log.warning("style has %d rule(s) for classes no longer in the taxonomy: %s",
len(gap["orphaned_rules"]), ", ".join(gap["orphaned_rules"]))
return gap
3. Emit a taxonomy change event, because nothing else will jump to heading
A taxonomy version bump changes what the map shows while no parcel changed, so the change feed is silent by construction. The publishing pipeline has to generate the event itself — the same class of internally generated event as a lapsing PUD.
def taxonomy_change_event(old, new, parcels) -> dict:
"""What a taxonomy bump actually did, in terms of parcels."""
added = {c.code for c in new.classes} - {c.code for c in old.classes}
removed = {c.code for c in old.classes} - {c.code for c in new.classes}
remapped = {}
for p in parcels:
before = old.classify(p.jurisdiction, p.base_code)
after = new.classify(p.jurisdiction, p.base_code)
if before != after:
remapped.setdefault((before, after), []).append(p.parcel_id)
return {
"kind": "taxonomy_version_change",
"from": old.version, "to": new.version,
"classes_added": sorted(added), "classes_removed": sorted(removed),
"parcels_remapped": {f"{a}→{b}": len(v) for (a, b), v in remapped.items()},
"total_parcels_remapped": sum(len(v) for v in remapped.values()),
# No parcel's zoning changed — only our description of it.
"regulatory_change": False,
}
That regulatory_change: False field matters for how the event is communicated. A subscriber told “3 100 parcels changed class” will reasonably assume an ordinance did something; the honest message is that the classification improved and the underlying zoning is untouched.
4. Publish the three artifacts together jump to heading
Style, pyramid manifest and state file all carry the taxonomy version, so they must be published as one unit. Publishing the state file first leaves the style unable to paint the new classes; publishing the style first leaves it painting classes no parcel carries yet. Neither is catastrophic and both produce a visibly wrong map for as long as the window lasts, so make it atomic — a versioned directory containing all three, with a single pointer flipped at the end.
Verification & testing jump to heading
def test_every_taxonomy_class_has_a_style_rule():
gap = taxonomy_style_gap(TAXONOMY, STYLE_PATH)
assert not gap["unstyled"], (
f"{gap['unstyled']} will render as the fallback — parcels carrying these "
f"classes are effectively invisible")
def test_adding_a_class_without_styling_it_fails_the_build():
tax = TAXONOMY.with_class("RES-MED")
with pytest.raises(StyleTaxonomyMismatch, match="RES-MED"):
gate_style(tax, STYLE_PATH, MANIFEST, STATE)
def test_all_three_artifacts_agree_on_the_version():
gate_style(TAXONOMY, STYLE_PATH, MANIFEST, STATE) # raises if any disagrees
def test_the_fallback_is_conspicuous():
"""A quiet fallback is why this bug reaches production. Assert it is loud."""
fill = style_paint(STYLE_PATH, "parcels-fill", "fill-color")
assert fill[-1].lower() in ("#ff00ff", "#f0f"), \
"the fallback colour must be impossible to miss in review"
def test_taxonomy_bump_reports_no_regulatory_change():
ev = taxonomy_change_event(TAX_V4, TAX_V5, PARCELS)
assert ev["total_parcels_remapped"] > 0
assert ev["regulatory_change"] is False
def test_orphaned_rules_warn_but_do_not_fail(caplog):
style = add_rule(STYLE_PATH, "RES-OBSOLETE", "#123456")
gate_style(TAXONOMY, style, MANIFEST, STATE)
assert "no longer in the taxonomy" in caplog.text
Failure recovery jump to heading
Parcels rendering invisible in production. Add the missing style rules and republish the style; no tile rebuild is needed, because the geometry and the state file are both fine. Then add the gate, because this will recur on the next taxonomy change otherwise.
A class renamed and the old rule left in place. The orphaned-rule warning catches it, and the parcels are almost certainly unstyled under the new name. Fix both ends in one commit — remove the dead rule, add the new one — so the style’s class set matches the taxonomy exactly.
Three artifacts published out of step. Roll the pointer back to the previous versioned directory, which is why they are published as a unit. Then republish all three together. A partial rollback that leaves the state file ahead of the style reproduces the original symptom.
Frequently asked questions jump to heading
Why does a taxonomy change produce no change event?
Because no parcel’s zoning changed — only the vocabulary used to describe it. The change detector compares base codes, overlay sets, geometry and effective dates, none of which moved, so it correctly reports nothing. That is exactly why the publishing pipeline has to generate a taxonomy change event itself; it is a change in the map, not in the world.
Should the style fallback be invisible or conspicuous?
Conspicuous, and magenta is the traditional choice. A quiet grey fallback is why this bug reaches production: an unstyled class looks like an ordinary parcel or like nothing at all, and nobody reviewing the map notices. Magenta is noticed within seconds, which converts a silent production defect into a staging one.
Are orphaned style rules worth fixing?
They render nothing, so they are harmless in themselves — but they are usually a rename in disguise, which means some parcels have moved to a class that has no rule either. Treat an orphaned rule as a prompt to check the other direction, and remove it in the same commit that adds the replacement.
Why publish the style, manifest and state file together?
Because all three name the taxonomy version and any pair of them being out of step produces a visibly wrong map: a state file ahead of the style leaves new classes unpainted, and a style ahead of the state file paints classes nothing carries yet. Publishing a versioned directory and flipping one pointer makes the window zero rather than however long the second upload takes.
Related jump to heading
- Parent topic: Vector Tile Publishing for Zoning Maps
- Section overview: Spatial Impact Analysis & Zoning Change Detection
- Zoning Taxonomy Mapping — where the class set is defined and versioned
- Joining zoning attributes to tiles without rebuilding geometry — the state file that also carries the taxonomy version
- Testing Spatial Data Pipelines — where the style gate belongs in the suite