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.

A year of changes, split by what they force Stacked bar chart of change events by quarter, split into attribute-only changes and geometry changes. Attribute-only changes — rezones and overlay adoptions — dominate every quarter, totalling 347 for the year against 23 geometry changes. Each attribute-only change is a pyramid rebuild the client-side join eliminates; each geometry change still requires one. A year of changes, split by what they force 0 25 50 75 100 125 78 Q1 96 Q2 71 Q3 8 102 Q4 Change events geometry (split, merge, boundary) attribute-only (rezone, overlay) Only the amber band forces a tile rebuild once attributes are joined at render time.
347 attribute changes against 23 geometry changes: 94% of the year's events are pyramid rebuilds the join removes entirely. That ratio is the whole business case, and it is worth measuring on your own feed before adopting the extra moving part.
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.

Four ways the state file and the pyramid drift apart A four-row by three-column matrix of state-file and pyramid mismatches. Rows are tile features with no state entry, state entries referencing absent features, a taxonomy version mismatch, and a state file cached longer than its lifetime. Columns are the cause, what the user sees, and the fix. Features with no state entry render unstyled, which looks like a data outage rather than a version mismatch. Four ways the state file and the pyramid drift apart cause what the user sees fix Tile features with no state entry a split published without a rebuild unstyled parcels rebuild the pyramid State entries for absent features stale state file no visible effect republish the state file Taxonomy version mismatch artifacts published out of step new classes unstyled publish all three together State file cached too long long max-age on a stable path yesterday’s colours 60-second max-age fixable cosmetic or silent looks like an outage
Every row produces the same user-visible symptom — unstyled parcels — from four different causes, which is why the pair validation runs before publishing rather than after somebody reports a grey map.
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.

What one rezone costs, baked against joined Lollipop chart comparing the work one rezone causes under two architectures. With attributes baked into tiles it means about 200 tile regenerations and 200 purge requests. With a client-side join it means one state file write of about 40 kilobytes and zero purges. A dashed rule marks ten units of work, above which the change stops being a routine publish and becomes an operation. What one rezone costs, baked against joined 0 50 100 150 200 250 10 — beyond this a publish becomes an operation baked: tiles regenerated 200 baked: purge requests 200 joined: state file writes 1 joined: purge requests 0 Operations per rezone
One rezone: 200 tile regenerations and 200 purges, or one 40 kB file write. The join does not make geometry changes cheaper and it does not claim to — it removes the cost from the case that happens several hundred times a year.

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.