Choosing minzoom for Sparse Datasets
For a sparse layer — weather stations, ferry routes, a few hundred sites across a country — the tile-size budget never binds, so the usual density arithmetic gives no answer at all. The shallowest useful zoom is decided by two other things: the zoom at which the features stop being legible as individuals, and the number of near-empty tiles you are willing to generate and serve below it.
When to Use This
Any layer where the whole dataset would fit in a handful of tiles at high zoom. Monitoring networks, retail locations, incident reports, protected areas, transport termini. The symptom that brings people here is usually a tileset with tens of thousands of tiles that are almost all empty, or a map where a layer is technically present at z4 and reads as noise.
Specification Detail
| Flag | Scope | Effect |
|---|---|---|
-Z / --minimum-zoom |
Whole build | The shallowest zoom generated |
minzoom in vector_layers |
Per layer, metadata | Documentation; does not suppress requests |
minzoom on a style layer |
Runtime | The zoom below which the layer is not drawn |
minzoom on a style source |
Runtime | The zoom below which no tile is requested at all |
The distinction in the last two rows is the one that matters operationally. Setting minzoom on a style layer stops the drawing and not the fetching; setting it on the source stops the fetching. For a sparse layer in its own tileset, the source-level setting is what removes the wasted requests.
That table looks like a weak argument for raising -Z, and it is: the deep zooms dominate the count regardless. The real saving from a higher minzoom is not in tile count but in requests — a world-view map that does not request this layer at all makes eight to sixteen fewer requests per pan at low zoom.
The Two Criteria That Actually Decide It
Legibility
At some zoom, a sparse layer’s features are closer together on screen than the symbol used to draw them, and the layer becomes a clump rather than a set of locations. That is the shallowest zoom worth drawing it at, and it is a function of the symbol size and the closest pair of features, not the average spacing.
import math
from itertools import combinations
def screen_separation_px(lon1, lat1, lon2, lat2, zoom, tile_px=256):
"""Distance in screen pixels between two coordinates at a zoom."""
def project(lon, lat):
x = (lon + 180) / 360 * (2 ** zoom) * tile_px
s = math.sin(math.radians(lat))
y = (0.5 - math.log((1 + s) / (1 - s)) / (4 * math.pi)) * (2 ** zoom) * tile_px
return x, y
(x1, y1), (x2, y2) = project(lon1, lat1), project(lon2, lat2)
return math.hypot(x2 - x1, y2 - y1)
def legible_minzoom(points, symbol_px=14, percentile=0.02):
"""Shallowest zoom at which all but the closest `percentile` of pairs are separated."""
for zoom in range(0, 20):
seps = [screen_separation_px(*a, *b, zoom) for a, b in combinations(points, 2)]
seps.sort()
cutoff = seps[int(len(seps) * percentile)]
if cutoff >= symbol_px:
return zoom
return 19
stations = [(-0.12, 51.50), (-2.24, 53.48), (-3.19, 55.95), (-1.55, 53.80)]
print(legible_minzoom(stations)) # 6
Allowing a small percentile of pairs to overlap is deliberate: insisting that every pair be separated pushes the answer several zooms deeper because of one cluster, and clusters are exactly what a renderer’s collision detection is for.
Request cost below that zoom
Once the legible zoom is known, the question is whether to generate anything below it. Generating down to z0 costs almost nothing in storage — low zooms are few tiles — and it does cost requests, because a client with no source-level minzoom will ask for them.
The clean arrangement is to generate from the legible zoom and declare it on the source, so that no request is made below it. The alternative — generating everything and hiding the layer in the style — leaves the requests in place.
Production Command
tippecanoe \
--output stations.pmtiles \
--layer stations \
--minimum-zoom 6 \
--maximum-zoom 12 \
--include name \
--include operator \
--force \
stations_4326.geojson
And the source declaration that stops the requests:
{
"sources": {
"stations": {
"type": "vector",
"url": "https://tiles.example.com/v43/stations.json",
"minzoom": 6,
"maxzoom": 12
}
}
}
The minzoom on the source must not be shallower than the build’s -Z, or the client will request tiles that were never generated and receive 404s at world view — a common and confusing symptom, because the map looks fine as soon as anyone zooms in.
Interaction Effects
With overzoom. A sparse layer benefits from a low maxzoom and overzoom more than a dense one does, because points do not gain detail with zoom. Generating to z12 and overzooming to z16 is usually indistinguishable from generating to z16 — see overzooming versus generating higher max zoom.
With clustering. If the layer must appear at world view, an aggregated companion layer — counts per region, generated separately — reads far better than the raw points and costs a fraction of the tiles.
With combining into one tileset. A sparse layer alongside dense ones in a single tileset inherits the tileset’s zoom range, so its shallow zooms are generated whether or not they are wanted. That is usually an acceptable trade for the reduction in sources, since the empty tiles are tiny.
Common Mistakes
Deriving minzoom from the size budget. For a sparse layer the budget is never reached, so the calculation returns z0 and the answer is useless.
Setting minzoom on the style layer only. Stops the drawing, not the fetching. The requests continue at every zoom.
Using average spacing rather than the closest pairs. The average is dominated by the wide empty areas; what makes a layer illegible is the tight cluster.
Declaring a source minzoom shallower than the build. Produces 404s at exactly the zooms where the layer is least important, which is why it goes unnoticed for a long time.
FAQ
Should a sparse layer live in its own tileset?
Often yes, because its zoom range and update cadence differ from a basemap’s. The cost is one more source and its round trip, so the trade is worth making when the layer updates on its own schedule and not otherwise.
What symbol size should the legibility calculation use?
The rendered size including any halo or padding, not the icon’s intrinsic size. A 12-pixel circle with a 2-pixel halo occupies 16 pixels, and using 12 gives an answer one zoom too shallow.
Does an empty tile cost anything?
Very little individually — a few dozen bytes — but they are still objects to store, requests to serve and cache entries to hold. The cost is in the request count at low zoom, not the bytes.
Can I set a different minzoom per layer within one tileset?
Tippecanoe supports per-layer zoom ranges through separate invocations merged with tile-join, or through -Z and -z applied per input file. Within a single invocation over one input, the range applies to the whole build.
Related
- Zoom Level Optimization Strategies — the parent topic and the density-driven side of the same decision.
- Calculating Optimal Max Zoom for Urban Datasets — the dense case, where the budget does bind.
- Web Mercator Latitude Limits and Distortion — why the pixel separation above is latitude-dependent.
- Writing Valid TileJSON for MapLibre Sources — declaring the range so the client honours it.