Tiles¶
Germany alone contributes close to 400,000 rows to coverage_poi (measured 2026-09-10; the
numbers page keeps the current figure): bike
shops, water fountains, viewpoints, shelters, ruins, one country's worth of the fountain's
neighbours. Nineteen countries together bring the table to around two million rows (chapter 5,
making-it-fast.md), and the planet-wide target for the same table is about 4.7
million (the sizing comment above _SOURCE_DDL in pipeline/coverage/load.py, reconciled against
osm-data-architecture.md §5's taginfo breakdown).
You cannot send that to a browser. Not "should not": a rider's phone opening the map cannot download
millions of rows, a JavaScript map layer cannot usefully draw millions of markers even if it had
them, and nobody could read a map covered edge to edge with hundreds of thousands of pins stacked on
top of each other in every town. Chapter 6 (osm-to-database.md) gets the fountain into a table row that
answers questions fast. This chapter is about the completely different problem of turning a table
that big into something a browser can actually fetch and draw, and it is where the fountain finally
becomes a pixel.
The answer has three separate ideas in it, and they solve three separate problems: a way to cut the world into small, cacheable pieces (the tile pyramid), a way to hand the browser real shapes instead of a picture (the vector tile), and a way to ship millions of those pieces as one file instead of millions of small ones (PMTiles). None of the three is optional; drop any one and the other two stop being enough.
The pyramid¶
Start with the simplest possible version of "cut the world into pieces": one picture of the whole
planet. Call that zoom level 0. It is one square image (Web Mercator makes the world square,
chapter 1, coordinates.md) covering everything, at a resolution too coarse to
show anything but continents.
Zoom in one level, to zoom level 1, and that single square splits into four, a 2×2 grid, each quarter covering one quadrant of the world at twice the resolution. Zoom in again, to level 2, and each of those four splits into four more: sixteen tiles total, each covering a sixteenth of the world. Every zoom level quarters every tile from the level above it. That gives a clean rule for how many tiles exist at zoom level z:
z=0 is 1 tile. z=6, the lowest zoom this project's coverage tiles are built at, is 4^6 =
4,096 tiles. z=14, the highest, is 4^14 = 268,435,456, about 268 million tiles for the whole
world. Nobody builds all 268 million of them; a tile only gets built where a tile-cutting tool finds
features to put in it, which is exactly why the pyramid works: the addressing scheme covers the
whole planet at every zoom, but the actual files only exist wherever there is data.
That addressing scheme has a name: every tile is identified by three numbers, written z/x/y.
z is the zoom level just described. x and y are the tile's column and row inside that level's
grid, counting from 0 at the top-left. A slippy map, chapter 1's name for the pan-and-zoom web map
this project uses, never asks for "the whole world"; it works out which z/x/y tiles cover the
current view and asks for exactly those.
This project's coverage layer is built across --minimum-zoom 6 to --maximum-zoom 14
(pipeline/coverage/tiles.py::build_pmtiles), the region-scoping "fit the map to Belgium" view lands
around z7, so z6 is the lowest zoom coverage needs to still be visible at an overview, and z14 is
close enough to street level that individual pins are the right thing to draw.
Why tiles work¶
The pyramid is only useful because of three things that fall out of it almost for free.
The viewport only ever needs a handful of tiles. A rider looking at one town on their screen is
looking at a few square kilometres, which at any reasonable zoom is a small, fixed number of z/x/y
tiles, not the whole table, and not even the whole country. Panning the map swaps a few tiles at the
edge for a few new ones; it does not re-fetch everything.
Each tile is small. A tile only has to hold what happens to sit in that one small square of the
world at that one zoom level, so no single request is ever large, however big the underlying table
gets. Doubling the size of coverage_poi does not double the size of any one tile a browser fetches.
It mostly fills otherwise-empty tiles, or adds a few more features to already-small ones.
Tiles are identical for every user, so they cache perfectly. A tile's contents depend only on its
z/x/y address and the data behind it, not on who is asking, what they searched for, or what time
it is. The exact same 6/32/21.pbf bytes serve every rider who ever looks at that patch of
Belgium, so a cache (a CDN edge, a browser's own disk cache, an intermediate proxy) only ever has to
fetch and store it once and can then answer every later request itself. Contrast that with /map/
coverage/search?q= (coverage-provider.md §5), which depends on q and is cached for a much shorter
max-age for exactly that reason: tiles are the same for everyone, search results are not.
Raster vs vector¶
Tiles come in two fundamentally different flavours, and the difference is about what the tile actually contains.
A raster tile is a picture, a PNG or JPEG, typically 256×256 or 512×512 pixels, already drawn.
Whoever built the tile decided the colours, the labels, the icons, everything, at build time. The
browser's job is just to place the image and nothing else. That is how classic map tiles (OpenStreetMap's
own tile.openstreetmap.org, or any traditional web map) have always worked, and it is simple, but it
means every visual choice is frozen into the pixels: want a different colour for one category, or to
hide one layer, or to know what a particular pixel represents? You cannot, you would have to fetch a
completely different picture.
A vector tile contains the geometry and its properties instead of a picture: "there is a point
at this location, and its properties are {ref: "node/6863042080", t: "Drinking water"}" rather than a
pre-rendered dot. The format this project uses is MVT, Mapbox Vector Tile, a small, widely-adopted
binary encoding for exactly this, and the browser decides how to draw it, at the moment it draws it.
That single difference is what lets the client:
- restyle without refetching, recolour every water point, or swap the whole map's colour scheme, by changing paint rules in JavaScript, with the same tile bytes already sitting in memory;
- filter without refetching, hide everything outside the rider's chosen region by testing a
property already inside the tile (
ridtok/cctok, coverage-provider.md §4), rather than asking the server for a different set of pixels; - hit-test without refetching, answer "what did the rider just click on?" by looking up which
feature's geometry is under the cursor, because the geometry is there, in the tile, not baked into
colour. Chapter 8 (
on-screen.md) coversqueryRenderedFeatures, the MapLibre call that does this.
We serve vector. Every coverage feature this project draws, the fountain included, arrives at the browser as geometry plus a handful of flat properties, never as a picture, which is exactly what makes the client-side filtering and rendering choices covered later in this chapter (and the whole of chapter 8) possible at all.
PMTiles¶
So far, a tile pyramid sounds like it produces one small file per z/x/y address, and historically
that is exactly what it meant: a tile server that owns millions of tiny files (or database rows) and
answers GET /6/32/21.pbf one request at a time. That is a real piece of infrastructure to run,
scale, and keep alive.
Two different files both called .pbf
The .pbf in 6/32/21.pbf is not the same file as the Geofabrik country.osm.pbf you
downloaded in chapter 6. The three letters collide because both are Protocol Buffers,
Google's binary serialization format, .pbf, but they carry completely different things:
.osm.pbf(Geofabrik, chapter 6) is a source-data dump: the raw OpenStreetMap database for a whole country, every node, way, and relation with its full tags. It is not tiled and not styled; it is the input the pipeline reads to buildcoverage_poirows..pbfhere (a tile) is one rendered tile: the MVT geometry for a singlez/x/ysquare, ready for the browser to draw. It is an output, cut and packed long after the.osm.pbfwas parsed away.
Same encoding, opposite ends of the pipeline. One country's .osm.pbf goes in; many tile .pbfs
(bundled into the one .pmtiles file below) come out.
PMTiles is a file format that packs an entire tile pyramid, every zoom, every x, every y,
into one single file, together with an index of where each tile's bytes live inside it. The client
does not download the whole file to read one tile. It reads the index (a small, fixed-location chunk
at a known offset), works out the byte range the tile it wants occupies, and issues an HTTP range
request, a request that asks a server for bytes 4,102,558 through 4,109,884 of a file, not the
whole thing (the Range: HTTP header, which any ordinary static file host understands). One PMTiles
archive, many range reads, no tile-serving process at all.
This project's coverage layer is exactly that, once per country:
build_pmtiles() in pipeline/coverage/tiles.py writes one .pmtiles file per
country, pipeline/coverage/publish.py uploads each one to the cc-maps
object storage bucket under its own versioned key
(coverage/<cc>/<YYYYMMDD-HHMMSS>/points.pmtiles, coverage-provider.md §3 step 8),
and the browser talks to whichever ones it needs through the pmtiles://
protocol handler registered once by web/assets/map/tile-sources.js
(ensureProtocol(): maplibregl.addProtocol('pmtiles', new pmtiles.Protocol().tile)).
tile-sources.js's mountInView() adds one vector source per country whose
bounds meet the viewport, map.addSource('coverage-points-be', {type: 'vector',
url: 'pmtiles://' + url}), reading the URL out of window.CC_TILES.coverage.be.
Nothing in that request path is a tile server: it is a GET against a static
file, with Range: headers doing the work a tile server would otherwise do,
served straight off the bucket (or through an nginx range proxy).
Before any of that upload happens, build_pmtiles() never reads coverage_poi directly, tippecanoe
takes files, not a database connection. export_geojsonl() bridges the two, running one COPY
per letter straight off the table, the country bucket rides along as the first column and the rows
come back ordered by it, so the function can route each row into the right per-country file as it
streams:
"COPY (SELECT COALESCE(country_code, 'ZZ'), jsonb_build_object("
"'type', 'Feature', "
...
"'geometry', ST_AsGeoJSON(geom)::jsonb, "
f"'properties', jsonb_strip_nulls(jsonb_build_object({', '.join(props)}))"
f")::text FROM coverage_poi "
f"WHERE letter = {_lit(letter)} "
f"ORDER BY COALESCE(country_code, 'ZZ'), ref) TO STDOUT"
The second sort key, ref, fixes the order inside a country. (ref, letter) is unique, so the same
rows always export as the same bytes, and the per-country fingerprint that decides whether a country
is rebuilt (inputs_fingerprint() in pipeline/coverage/publish.py) stays the same when nothing
changed. Ordered by country alone, the order inside a country varies from one export to the next on a
large table, and every country would look changed every night.
One COPY per letter, not one per letter × country: scanning the whole table once for every
(letter, country) pair would be well over a hundred passes at nineteen countries, and on the shared
production database each pass evicts the working set from the page cache. Emitting the country as a
column and ordering by it keeps it to one scan per letter, and the per-country split still happens
before tippecanoe: each row is appended to its own <letter>_<cc>.geojsonl file as it arrives.
tippecanoe never sees a mixed file. By the time it runs, the split has already happened one row at a
time, which is what makes a tile layer single-country by construction, not by a filter applied
afterwards: the other country's points were never in the file tippecanoe read to build that layer.
"Why the layers are split per country" below is why that matters.
build_pmtiles() runs, and then one more step happens before any of it reaches a rider: a sanity gate
that refuses to publish a broken archive. verify_pmtiles() in the same module opens the freshly built
file with pmtiles show and asserts the basics a corrupt or empty build would fail:
m = re.search(r"addressed tiles(?: count)?:\s*(\d+)", show)
if not m or int(m.group(1)) == 0:
raise RuntimeError(f"pmtiles verify: no addressed tiles in {path}\n{show}")
...
missing = expected - layers
if missing:
raise RuntimeError(
f"pmtiles verify: missing layer(s) {sorted(missing)} in {path} (found {sorted(layers)})")
Zero addressed tiles, or a layer this run was supposed to produce simply missing, both raise before
the file ever reaches publish.py's upload step. The same function goes on to decode one real tile
inside the header's bounds, so "the index says tiles exist" and "a tile actually decodes to something"
are both checked, not just the first one.
That buys something concrete: the whole coverage layer, for the whole world eventually, is one
small artifact per country, each one hostable anywhere a static file can be hosted, no database,
no application server, no process to keep alive, on the read path. A country's rebuild is one new
file at a new versioned key for that country only; readers mid-pan keep reading the bytes they
already started reading, because nothing at the old key ever changes (coverage-provider.md §3 step 8).
The manifest (coverage/manifest.json, max-age=300) is the one small, frequently-refetched pointer
that says, per country, which versioned key is current; the tiles themselves are cached forever
(max-age=31536000, immutable), because a versioned key's bytes are defined never to change. A
country whose extract has not changed since the live manifest is skipped rather than rebuilt, so a
weekly run typically writes a handful of new keys, not nineteen.
No clustering: individual points and a heatmap¶
The pyramid, vector tiles and PMTiles together solve "get the right small piece of data to the browser fast". They do not solve a different problem that shows up the moment you zoom out: even one tile's worth of ground can hold more points than a screen can usefully show. Zoom out to see all of Belgium and a single water-point layer might have to represent thousands of fountains sitting in a patch of screen a few hundred pixels wide. Drawing every one of them as its own pin is not "cluttered", at that scale, dots simply stack on top of each other and stop being readable at all.
There are three ways a tile build can answer that, and this project's design is the third. It is worth understanding the two it rejects, because each rejection is a measured fact, not a taste.
Thinning alone lies. tippecanoe's default is to keep a random subset of points at low zoom and
drop the rest. Measured on this project's own data, default thinning keeps only 23 of 2,015
D-services (bike shops, repair stations, pumps) at zoom 8, while the on-screen count reads
"2015/2015", because that number comes from an honest SQL count (/map/coverage/counts,
coverage-provider.md §5), not from what the tile happened to keep. The map looks nearly empty. The
rail says everything is there. Both are telling the truth about different things, and that mismatch
is worse than either one being wrong on its own.
Clustering drifts. Instead of discarding points to make room, tippecanoe can merge nearby points
into a single feature carrying a count, an injected point_count property. A bubble reading 48
is not one representative fountain standing in for forty-seven others that got thrown away; it is a
promise that all forty-eight are still there, accounted for, just drawn as one marker until you zoom
in far enough to tell them apart. That promise holds, and it is a genuinely honest fix to the
thinning problem above, but clustering carries two flaws of its own, neither of them a tuning
mistake:
- Phantom bubbles. A cluster renders at the centroid of its members. A cluster straddling a
region border sits at the centroid of whichever points tippecanoe happened to merge, which can land
outside the scoped region altogether. The measured case is a shelter bubble rendered in
Thuringia carrying a single Hesse
ridtok. No tile attribute fixes it: union tokens, leader tokens, a tighter--cluster-distanceand bigger tile budgets all leave phantoms, because the bug is not in which token a cluster carries; it is in where the geometry itself is drawn. - It does not scale. "Ship a region's points to the client and cluster them" is bounded for a Belgian province or a German Land (Bavaria, on the order of tens of thousands of coverage points), but it breaks the moment a region the size of a US state or a Chinese province is onboarded whole: California is roughly 200,000-400,000 points, Guangdong 500,000 to over 2 million. Clustering that many points is not the shape of a worldwide coverage layer.
Both problems are artifacts of clustering itself, not of anything about the underlying data, so the
coverage build does not cluster. Coverage tiles carry individual points only, at zoom 6 through 14,
and nothing is ever merged. A single point carries exactly one ridtok/cctok token pair, its own
region and country, so the scope filter (coverage-provider.md §4) is exact for that one point, at any
zoom, in any country, with no cross-feature union to get wrong and no rendered position that is
anything other than the point's own coordinate. There is no point_count property anywhere in the
coverage tiles, and there is not meant to be one. That is checkable rather than assertable, and this
chapter's Try it checks it: pmtiles show lists every property a layer carries, and point_count is
not among them.
Not clustering does not make the original overview problem disappear: a rider still lands on a
region at roughly z7-z9 (the scope selector's own fit zoom), and thousands of individual points still
cannot be drawn as pins at that scale. The design does not pretend the overview problem is gone; it
stops asking individual pins to solve it and gives the overview a different kind of picture instead:
a density heatmap. build_pmtiles() builds one continuous pyramid from z6 to z14 with two
tippecanoe behaviours, -r1 and --drop-densest-as-needed, each with an honest job to do. z11-14
tiles are complete: a z11 tile is small enough that --drop-densest-as-needed never actually
fires there, so every point in a z11-14 tile is really present, and those are the tiles the client
draws as individual icons. z6-10 tiles are thinned: --drop-densest-as-needed drops the
densest overflow, proportionally, wherever a whole-region tile would exceed the tile's byte budget,
and nothing built from a z6-10 tile ever claims to be a complete list of points. It feeds a heatmap
instead: a smooth density surface built from the thinned sample, answering "where is coverage dense"
rather than "here is every fountain". A thinned sample is exactly what a density surface needs
(relative density survives even heavy thinning, because the drop is proportional across the tile)
and exactly what a pin list must never be handed, the same distinction the "23 of 2015" measurement
above teaches.
On screen (chapter 8, on-screen.md, covers MapLibre's side of this in full) the client mirrors every
coverage icon layer with a heatmap layer on the same source-layer: a <key>-<cc>-heat layer with
maxzoom: 9 beside the <key>-<cc>-cov icon layer with minzoom: 9, where key is the layer's
word (water, services, stays, …, the COVERAGE_KEYS table in coverage.js), so the water
layers for Belgium are water-be-heat and water-be-cov. Only the source-layer inside the tile
is named by letter, b_be. Below z9 a rider sees
the heatmap only, one single hue, semi-transparent, every visible letter's density stacking into
one "how much coverage is here" surface, scope-filtered on the same exact per-point ridtok/cctok
tokens as the icons, so the surface is phantom-free for the same reason the icons are: it is built only
from points already inside the scoped region, never from anything aggregated across a border. From z9
up the individual icons fade in, the z9-10 icons are drawn from the same thinned tiles the heatmap
uses, so they are a sample too, but they densify into the complete set by z11, where every point is
guaranteed present. The heatmap and the icons cross-fade across that z9-10 handoff, so a rider is never
looking at a gap between "blur" and "dots"
. The rail's /map/coverage/counts
(coverage-provider.md §5) stays the one thing in this whole picture that is never a sample: an exact
SQL count, unaffected by what any tile happened to keep, exactly the number that made the
naive-thinning attempt's lie visible in the first place.
The actual flags, all in pipeline/coverage/tiles.py::build_pmtiles:
--minimum-zoom 6/--maximum-zoom 14: the full pyramid coverage is built at. The interesting part is what happens inside that range, not the range itself.-r1: turn off tippecanoe's own point-dropping ("rate") behaviour entirely. The only thing left that can ever remove a point from a tile is the next flag, and it is asked to, explicitly, rather than happening as an unannounced default.--drop-densest-as-needed: every tile has a hard byte-size budget; if a tile would still be too big, this flag drops the densest overflowing points, proportionally, until it fits. At z11-14 a tile is small enough that the budget is never actually hit, so this flag is a safety valve there in theory, not something that fires in practice, the icons stay complete. At z6-10 a whole-region tile genuinely does not fit the budget, so this flag fires for real there and produces exactly the thinned density sample the heatmap is built from.
Lines, and when to ship no geometry at all¶
Everything so far has been points. The road-surface layer is lines, every way OSM has a
surface tag for, and the pyramid handles them the same way, with one difference in the profile:
lines are never dropped to make a tile fit. A dropped point at low zoom is a thinner sample; a
dropped line is a road that vanishes from the map. So the surface build simplifies geometry
instead, a way loses vertices at low zoom, never its existence.
The more interesting case is the layer's other half: the roads nobody has recorded a surface for. Drawn as lines, that is hundreds of thousands of features per country, and at z8 a national road network is an unreadable smear whichever way you style it. But look at the question a rider is actually asking down there. It is not "is this lane gravel?", you cannot even see the lane. It is "which part of the map has nobody surveyed?"
That question has a far cheaper answer: one square per ~6 km carrying how many kilometres inside it are unrecorded. For the Benelux that is 0.6 MB against 34.8 MB of the same information as lines, about 1.5 %, and it reads better, because a choropleth is what a "where" question wants. The lines then take over at z11, where a rider is looking at a road they could actually go and ride.
That is the general lesson, and it is the mirror image of the no-clustering rule above. There, the question was "where exactly is this fountain?", and aggregating would have answered a question nobody asked. Here the question is "where is there work?", and shipping the geometry would answer it at a hundred times the cost. Match the resolution of the answer to the question being asked at that zoom, sometimes that means every feature at its own coordinate, and sometimes it means counting.
Building road-surface tiles is the runbook for both halves.
Why the layers are split per country¶
Every tile a tippecanoe run produces is organised into named layers, one named collection of
features living inside a tile, nothing to do with anything drawn on screen. Chapter 8
(on-screen.md) gives this exact idea its MapLibre name, source-layer; for now, just picture a
layer as a named group of features packed inside a tile. The obvious design is one layer per
catalogue letter (chapter 6,
osm-to-database.md, covers what the letters mean): b for water, d for services, and so on. This
project goes one step further: build_pmtiles() and export_geojsonl() (both in
pipeline/coverage/tiles.py) give tippecanoe one layer per (letter, country_code) pair, b_be,
b_nl, and so on, lowercase, with unstamped rows bucketed under <letter>_zz so a POI that could
not be matched to a country is never silently dropped.
The scope filter does not need that split to be exact. An individual point carries exactly one
ridtok/cctok pair of its own, so the filter (coverage-provider.md §4) is exact per point no matter
which layer it sits in. The per-point token, not the layer boundary, is the phantom-free
guarantee. (Any within-layer aggregation, clustering included, would make the layer boundary
matter again, because tippecanoe aggregates inside one layer with no awareness of what the data
means; a single b layer straddling the Belgian-Dutch border would let a low-zoom bubble mix
fountains from both countries. Nothing aggregates, so that never arises.) So why the split? Two live
reasons:
- The heatmap needs it. Chapter 8 (
on-screen.md) covers the client side in full, but the shape matters here: the density heatmap is built by mirroring each<letter>_<cc>layer with its own<key>-<cc>-heatMapLibre layer, so "how dense is coverage in the Netherlands" and "how dense is coverage in Belgium" are two surfaces the client can toggle and scope independently, rather than one blended surface it would have to un-mix after the fact. - A tile stays single-country by construction. Each
(letter, country)GeoJSONL fileexport_geojsonl()writes is filled from a single per-letterCOPYordered by country and split row by row, so tippecanoe never sees a mixed file to begin with. That keeps "just the Netherlands" a real, checkable property of the tiles themselves, not something a filter has to reconstruct at render time.
That is a different kind of decision than everything else in this chapter. Zoom ranges, -r1, the size-budget thinning: all of those are performance and rendering
tuning. The per-country layer split is not. It exists because coverage per country is a real
distinction a rider cares about (the region-scoping selector lets someone view "just the
Netherlands"), not because a mixed layer would be slow or oversized. Splitting the layers is a
rendering choice driven by the meaning of the data, not by its size.
The whole path¶
Put the pieces in order, from the row in the database to the pixel the fountain becomes:
Reading the six stops in order: coverage_poi (chapter 6) is the full index, one row per point.
export_geojsonl() splits it into one newline-delimited GeoJSON file per (letter, country) pair.
tippecanoe, a third-party tile cutter this project shells out to, never reimplemented, reads those
files, projects them from EPSG:4326 to Web Mercator, and cuts the pyramid: this is genuinely the one
place this project's own data leaves 4326 (chapter 1, coordinates.md), and it
happens inside that third-party tool, on the way out, to a copy, never inside our own code, and never
to the stored rows. build_pmtiles() calls it and gets one coverage.pmtiles archive back.
publish.py uploads that archive under a versioned key. From there, a rider's browser issues an
HTTP range request for exactly the z/x/y bytes it needs, and MapLibre, the map library
chapter 8 covers in full, decodes the MVT bytes into geometry it can paint, filter and hit-test.
What to carry into chapter 8¶
- A tile pyramid addresses the world at
z/x/y: zoom 0 is one tile, and every zoom level quarters every tile from the level before, so zoom z has4^zpossible tiles, only the ones with data in them actually get built. - Tiles work because a viewport only needs a few of them, each one is small regardless of how big the underlying table is, and the same bytes serve every rider, so they cache perfectly.
- A vector tile (MVT) ships geometry and properties, not a picture, so the browser can restyle, filter and hit-test without a new request, a raster tile is a finished picture with none of that freedom.
- PMTiles packs an entire pyramid into one file with an index, and the client reads it with HTTP range requests, no tile server on the request path, ever.
- No clustering, ever. Coverage tiles carry individual points only, z6-14. Clustering's own two
flaws, the phantom bubble (a cluster's rendered centroid landing outside the region its members
scope to) and a poor fit for world-scale data, are why.
-r1plus--drop-densest-as-neededthin the z6-10 tiles, but only to build a density sample; z11-14 tiles are always complete, and nothing is ever merged into a count. - The overview is a heatmap, not dots. A single-hue density surface built from the thinned z6-10
points fills the overview, cross-fading into individual icons from z9 up (complete by z11), without
a rendered centroid that can ever leave the scoped region. The rail's
/countsstays the one exact number in the picture. - The per-country layer split (
<letter>_<cc>) exists to keep the heatmap single-country and the tiles themselves single-country by construction; the scope filter itself is exact per point and needs no help from the layer boundary. Each country is also its own file (coverage/<cc>/<stamp>/points.pmtiles), so a rider's browser only ever fetches the countries whose tile bounds meet the viewport. - Build time and request time are cleanly separated by the moment a country's own
points.pmtilesis written: everything before that line runs once a week, per country whose extract changed; everything after it runs per rider, per pan, with no server process in the path at all.
The fountain is now sitting inside a tile, addressed, waiting to be fetched, as its own point at every
zoom from z11 up, and as one contributor to the density heatmap wherever its tile got thinned below
that. Chapter 8 (on-screen.md) is where it actually appears: MapLibre's model of style, source, layer
and source-layer, and how a rider's click turns a pixel back into the same row this chapter started
from.
Further reading¶
- The PMTiles specification: the archive format, in about ten pages.
- tippecanoe: the tile cutter, and the manual for every flag this chapter quotes.
- Mapbox Vector Tile specification: what is actually inside one tile.
Try it¶
Hands-on: see the layer list before tippecanoe ever runs
"Why the layers are split per country" is easiest to believe by counting the rows behind each
(letter, country_code) pair directly, that grouping is exactly what export_geojsonl() turns
into one file per pair, and what build_pmtiles() hands tippecanoe as one -L layer per pair.
docker compose -f developers/docker/compose.yaml exec db psql -U cc -d cyclingcommons -c "
SELECT letter, coalesce(country_code,'ZZ') AS country_code, count(*)
FROM coverage_poi GROUP BY letter, country_code ORDER BY letter, country_code;
"
letter | country_code | count
--------+--------------+-------
B | ZZ | 1
D | ZZ | 2
F | ZZ | 1
G | ZZ | 1
O | ZZ | 2
P | ZZ | 1
Q | ZZ | 2
(7 rows)
Then ask the finished archive what it actually contains, rather than trusting this chapter for
it. pmtiles show reads the header and metadata over HTTP, so it needs no download:
docker compose -f developers/docker/compose.yaml exec pipeline \
pmtiles show http://minio:9000/cc-maps/coverage/lu/<stamp>/points.pmtiles
That prints the zoom range and the exact tippecanoe invocation, both of which this chapter
quotes, so the flags above are checkable rather than asserted. To see the properties every
feature carries, add --metadata and read vector_layers. On a single country's own archive
that is one layer per letter the country's index holds (Luxembourg: 8), and every layer shares
the same nine distinct property names: acc, cctok, food, kind, n,
potable, ref, ridtok, t. point_count is not one of them, which is the no-clustering
decision above, visible in the artifact rather than promised in prose.
Seven letters, seven groups, seven layers, b_zz, d_zz, f_zz and so on. ZZ is not a
country: it is what coalesce substitutes when country_code is NULL, and the offline fixture
leaves it NULL on purpose. country_code is stamped from the region a row falls inside, and
make course-data loads no region boundaries (those come from a separate, network-bound
download), so no row gets stamped. export_geojsonl() buckets those rows under ZZ rather than
dropping them, an unstamped point still has to reach the map.
That is the mechanism, at the smallest size it is visible. The point of the mechanism only shows once more than one country is loaded:
The same query on a real index, and how to get one
make coverage-refresh (chapter 5 covers what it costs: network, a Geofabrik download)
loads real extracts, and the region boundaries stamp country_code for real. On a machine
with the Benelux extracts loaded, the identical query restricted to those three countries
(WHERE country_code IN ('BE', 'LU', 'NL')) returned this on 2026-09-10:
letter | country_code | count
--------+--------------+-------
B | BE | 5010
B | LU | 450
B | NL | 7024
C | BE | 2339
C | LU | 347
C | NL | 3340
D | BE | 2025
D | LU | 124
D | NL | 3043
F | BE | 901
F | LU | 83
F | NL | 2020
G | BE | 1030
G | LU | 138
G | NL | 599
O | BE | 6468
O | LU | 512
O | NL | 14955
P | BE | 2490
P | LU | 706
P | NL | 2635
Q | BE | 8788
Q | LU | 1141
Q | NL | 9056
(24 rows)
Eight letters times three countries is exactly 24 rows, and no row mixes two countries under
one letter, because country_code is a column on the table, not something tippecanoe
infers. Drop the country filter and the same machine returns one row per (letter, country)
pair the harvests have stamped, 152 of them across nineteen countries at the time of
writing, and the counts add up to the table total chapter 5 opened with.
Either way, this table is the reason the tile layers are named b_be, b_de, b_nl, d_be
and so on rather than just b, d, f: each row above becomes exactly one
(letter, country) GeoJSONL file, and a tile layer built from one file can never mix two
countries, because the other country's points were never in that file to begin with. If your dev
stack has published a .pmtiles archive, pmtiles show <path-or-url> lists those same names back
to you as vector_layers, but the query above needs nothing built, only the seeded database this
course already assumes.