A reusable engine that turns any municipal address-point dataset into map tiles for OpenStreetMap editors:
- Vector tiles (MVT) -- interactive in iD; click a point for its address tags.
- Raster tiles (PNG) -- house numbers drawn as text; a readable JOSM backdrop. Optionally rendered deeper until no address is left unlabelled.
- A landing page with copy-paste "add this layer" instructions, published to GitHub Pages.
It generalizes toronto-addresses-layer and shares
its data-acquisition design with
ontario-address-changes. Each city is a thin repo
(e.g. oakville-address-layer) that depends on this engine and carries one
layer.toml. Per-city repos (not one monorepo) because a single published site
can approach GitHub Pages' ~1 GB limit (Toronto alone is ~1 GB; raster is ~94%).
- Locked (this engine, deterministic): registry/config load, slim (of an input GeoJSON), tile math, vector (tippecanoe via WSL), raster (Pillow labeller), site templating, publish (orphan gh-pages). The engine does not acquire data -- it slims whatever GeoJSON it is pointed at (see Data input).
- Fuzzy (a Claude Code skill): onboarding a new city -- find the source, map
number/street/unit/full, set licence/attribution, write
layer.toml. Seeskills/onboard-city/SKILL.md.
The layer.toml is the contract between the two halves. The data-source keys are
byte-compatible with ontario-address-changes/datasets/<slug>.toml, so a config
can be lifted from that registry.
oakville-address-layer/
layer.toml # the one per-city config (see the skill)
run.py # from addresslayerist.cli import main; main()
requirements.txt # -e ../address-layerist
assets/ # optional overrides + iD.png / JOSM.png
pip install -e ../address-layerist # once
addressvault pull <slug> --wait # acquire the data (separate tool; not the engine)
python run.py build # slim + vector + raster + site
python run.py update # build + publish (daily entry point)
Individual steps: slim vector raster site publish. Run
addresslayerist onboard for onboarding guidance.
python run.py eli # build/eli/<Id>.geojson, ready to PR
eli renders the raster layer as an
Editor Layer Index entry -- the
index iD and JOSM read to populate their imagery pickers, so a mapper picks the
city off a list instead of copy-pasting a URL template off the landing page. The
vector layer has no equivalent: the index only describes imagery (type: tms).
It is deliberately not part of build. A submission happens once, and a missing
license_url should not be able to break a nightly tile build -- so eli prints
a warning for every field the index wants and the config lacks, plus the
fork/copy/PR steps. Set [layer].boundary to a GeoJSON of the municipal outline
before submitting; the fallback extent is the data's bounding box, which for most
cities also claims part of the neighbours.
The engine never downloads. slim reads, in order: --input PATH; else the
newest <slug>-DATE.geojson in input_dir (a layer.toml key); else the newest
such file in $ADDRESSVAULT_DIR. It treats that directory as a plain folder of
dated dumps -- it has no knowledge of address-vault. Whatever populates it (the
addressvault pull <slug> step above, a manual download, anything) is the
caller's concern, so the daily scheduled task is addressvault pull <slug> --wait && python run.py update (--wait coalesces onto an in-flight pull instead of
racing or erroring).
-
Slim/MVT schema is derived from canonical
[fields](number->housenumber,street->street,full->addr,unit->unit,name=the label, for iD), so raster/vector need no per-city code. Extra source props ship via[layer].mvt_extra. -
One label rule for both layers (
label.py): the suffix is folded into the number (335A), then a unit leads it (3-2280). Both dimensions distinguish addresses that otherwise draw identically -- a townhouse block renders as2280repeated without the unit, and 335 collides with 335A without the suffix.suffixis consumed intohousenumberrather than emitted as its own key, becausemvt_extrashares that key space (Toronto passes through asuffixtag whose value is already inside its number). -
Slim sanity is source-relative: fail if fewer than 95% of the input features survive (no per-city magic count bounds).
-
The deepest raster zoom is audited (
audit.py): after placing labels there, the build lists every address that is stacked (its dot sits under another address's dot) or unlabelled (placement found nowhere for its number) and writesbuild/<slug>-hidden-z<zoom>.csv. Only the deepest zoom is checked, because clients upscale past it rather than fetching a deeper tile -- so a finding there is hidden at every zoom, whereas the same address at z17 is just waiting to be zoomed into. Stacked is almost always a source problem (a tower's doors on one centroid) and unlabelled a density one; the build only reports them, it never fails on them. -
A leader reserves its line, not its bounding box (
raster.py). A label reserves the rectangle it draws into; the thin line joining a label to its dot reserves only itself. The two are collided as box-vs-box, box-vs-segment and segment-vs-segment, so the guarantees still hold -- no label over another address's dot, no leader through another label -- while the empty corners of a diagonal leader's bounding box stay available. They are most of that rectangle: reserving them cost Oakville 39 labels at z19 and all 5 of its z20 drops, and produced the visible symptom of a number missing beside obvious white space. -
A completion zoom ships only the tiles it was added for (
deep.py). Set[layer].raster_complete_toand the build keeps adding deeper zooms until the audit finds nothing unlabelled, stopping as soon as it is clean (Oakville: z19 leaves 57, z20 labels all of them, so it stops there). Those zooms share the layer's URL and advertised range, but they exist only over the ground holding the stragglers -- Toronto finishes in 180 tiles where rendering the zoom whole costs 283,239 tiles and 666 MB, to say nothing new about ground the parent already covers. Everywhere else the zoom 404s, deliberately. Clients differ on how they take that: Leaflet blanks (_tileReadymarks the failed tile active, so_pruneTilesdrops the parent it was scaling), which is why the landing page's preview stops atdeepest_whole_zoomand upscales rather than fetching into the sparse zoom. An editor pointed at one address does not pan across the 404s the way a preview map does. -
The zoom range is read from the tiles, not the config (
built_raster_zooms). A completion zoom only exists if it was needed, so the landing page, the JOSMtms[..]snippet and the ELI entry all advertise what was published. -
A marker box says "there is more here". Where a zoom cannot label everything, it draws a dashed box around the ground whose numbers appear one zoom deeper. The boxes are the outline of their union: a crowded block needs several neighbouring tiles at once, and one box per tile turns the parent into a lattice that reads as debug output. With a sparse completion zoom the box is load-bearing rather than decorative: it is how a mapper knows which of the deeper tiles exist, since the rest of that zoom is 404.
- Python >= 3.11. Installs
Pillow,ijson(see pyproject). The engine has no data-acquisition dependency. - An input GeoJSON the engine can find (see Data input) -- e.g.
ADDRESSVAULT_DIRset to a folder of<slug>-DATE.geojsondumps, or--input PATH. - WSL2 + tippecanoe for the vector step -- see wsl-setup.md.
python -m pytest # tile math + label rules + slim property-map + ELI entry
# + label placement + the hidden-address check