Freddie part 2: from tiles to map
- 8 minutes read - 1590 wordsIn part 1 I covered the hard problems: COG multi-resolution, SDF raster smoothing, and Barbapapa topology-preserving Laplacian smoothing. Freddie could generate good-looking 10m land cover vector tiles. This post is about what happened next: killing the algorithm I spent a week perfecting, solving a surprisingly dumb storage problem, and wiring the tiles all the way through to the map SDK.
Barbapapa is dead
I killed Barbapapa a few weeks after shipping it.
It stung a little. Seven revisions, the hole vertex bug that ate two days, the Jacobi-style simultaneous updates, the deterministic iteration fix. Elegant stuff. But after staring at enough tiles side by side, the conclusion was unavoidable: Barbapapa was lying.
Land cover at 10m resolution doesn’t have smooth boundaries. A forest edge is where a satellite sensor switched from “tree” to “grass” at some pixel boundary. It’s inherently jagged. Barbapapa was manufacturing gentle curves that were never in the data: aesthetically pleasing, but dishonest. The tiles looked better in the way a retouched photo looks better. Not more accurate, just smoother.
Meanwhile, the chain-based Douglas-Peucker simplifier I’d built alongside it was doing more useful work with less ceremony.
Edge DP
Edge DP operates on the same half-edge topology that Barbapapa used, but the philosophy is opposite. Instead of moving vertices closer to their neighbors to manufacture curves, it removes the vertices that don’t carry information. Less romantic, more honest.
The core idea: find the topologically significant vertices (junctions where three or more land cover classes meet) and treat everything between them as a chain. Each chain can be simplified independently without breaking the topology.
Five phases:
1. Find junctions. Walk every vertex and count how many distinct classes touch it. Three or more classes makes it a junction, an anchor in the topology skeleton. Tile boundary vertices are also pinned.
2. Extract chains. Walk each polygon ring, breaking at junctions and class transitions. Two adjacent faces sharing a boundary reference the same chain object. Simplify it once, both faces see the result. This is the same principle that made Barbapapa work (shared topology) but applied to vertex removal instead of vertex movement.
3. Douglas-Peucker per chain. The textbook recursive algorithm: find the vertex farthest from the line between endpoints, keep it if it exceeds the threshold, recurse on both sides. Nothing clever here. The cleverness is in applying it at chain granularity instead of per-polygon, where it would tear shared boundaries apart.
4. Reconcile. If any chain needs a vertex, it stays. Prevents holes at multi-chain junctions where one chain would remove a vertex another one depends on.
5. Rebuild twins. Remove the marked vertices from all rings and relink half-edge twin pointers so the topology stays consistent.
The result: aggressive vertex reduction with guaranteed topological consistency. No gaps, no overlaps, no invented curves. What you see is what the sensor saw, with fewer vertices saying it.
Z14Factor
One subtlety at high zoom: SDF upscaling leaves residual staircase artifacts. The Gaussian blur approximates class boundaries but doesn’t eliminate the pixel grid entirely. A Z14Factor of 0.6 multiplies the DP threshold at z14 and above, a less aggressive simplification that preserves more of the SDF-smoothed detail. Base threshold of 2.0 becomes 1.2 at z14, keeping vertices that would otherwise be culled. The SDF already did the heavy lifting on boundary shape. DP just needs to not undo that work. Small tweak, noticeable difference.
What got deleted
This was the satisfying part. With Edge DP handling everything alone, I got to delete a lot of code:
- Barbapapa: gone (~6,800 lines including tests and docs)
- Visvalingam-Whyatt: gone (experimental, Edge DP outperformed it everywhere)
- SimplifyFaces: gone (subsumed by Edge DP’s chain extraction)
- Ten per-class viewer layers: collapsed into one fill layer with zoom-interpolated colors
- Duplicated config across
freddieandfreddie-server: unified into a singleProcessingConfigstruct
The pipeline description shrank to one line: SDF (blur=6.0, minZ=10) + mode filter 7×7 + edge DP (threshold=2.0).
Painter’s algorithm
Collapsing ten per-class layers into one fill layer immediately broke rendering. Enclosed faces could paint over their parents. A lake surrounded by forest would disappear under green because the forest polygon rendered last. Obvious in hindsight, invisible until you actually try it.
The fix: topological sorting before MVT encoding. For each face, ray-cast to check whether it’s enclosed by another face, then depth-first sort so parents paint first. A lake inside a forest inside a continent renders in the right order. Combined with placing the landcover layer right after the map background in the style, one MapLibre fill layer handles everything correctly. Fewer layers, less complexity, better result.
The 200GB problem
Freddie generates beautiful tiles. But beautiful tiles sitting on my laptop don’t help anyone. They need to end up in the actual map, served alongside the base tiles: roads, buildings, POIs, all the stuff Planetiler generates.
The obvious approach: merge the two mbtiles files offline into one. Base map (~80GB) + landcover (~60GB) = one file, ship it. I built freddie-merge for exactly this. Iterated over every tile in both files, combined the MVT layers, wrote the result. Clean, simple, done.
The merged output was over 200GB. I stared at the number for a while.
The reason is embarrassing. Planetiler stores identical tiles once through content-addressed deduplication. Vast stretches of ocean at low zoom are the same tile: one copy, millions of references. Merging landcover into every tile makes each one unique, so deduplication collapses entirely. 80 + 60 doesn’t equal 200, but 80 (heavily deduplicated) + 60 (heavily deduplicated) merged together becomes 200 (nothing deduplicated). I’d undone one of Planetiler’s best optimizations without realizing it.
Runtime MVT concatenation
The fix is almost too simple to be satisfying.
MVT layers are top-level protobuf fields. Two valid MVT byte streams concatenated together produce a valid MVT byte stream. No parsing, no reencoding, no schema reconciliation, just append bytes. I had to read the protobuf spec twice to convince myself this was actually legal.
So: don’t merge at build time. Keep both tile pyramids as separate mbtiles files and concatenate at read time:
- Fetch base tile from mbtiles A
- Fetch landcover tile from mbtiles B
- Decompress both (gzip)
- Concatenate, landcover first, so it paints behind everything
- Recompress, serve
When there’s no landcover configured, the merge path is never entered. Zero overhead for the existing setup. Both files keep their original deduplication intact. And freddie-merge (the tool I built, debugged, optimized with ATTACH JOIN, rewrote to iterate the smaller set) joins the growing pile of deleted code.
Layer ordering matters: landcover sits right after the map background, before roads and buildings and labels. At high zoom where ESA WorldCover runs out of detail, the existing OSM landcover_wood and landcover_grass layers take over with a minzoom of 12.
Teaching the SDK about fill colors
The last piece of the puzzle was the rendering SDK. It already had a multiclass styling system for POIs: one MapLibre layer that renders different icons and text labels depending on the feature class. I naively assumed landcover would slot right in. Same system, different data, right?
No. POIs and landcover need fundamentally different MapLibre expression shapes, and the difference is annoyingly subtle.
A POI layer uses case at the root to pick an icon, with interpolate nested inside for zoom-dependent sizing:
["case",
["==", ["get", "class"], "park"], "park-icon",
["==", ["get", "class"], "school"], "school-icon",
"default-icon"]A landcover fill layer needs the structure inverted: interpolate at the root for smooth color transitions across zoom, with case nested inside each zoom stop to pick the per-class color:
["interpolate", ["linear"], ["zoom"],
0, ["case",
["==", ["get", "class"], "wood"], "#1a3d1a",
["==", ["get", "class"], "grass"], "#2d5a1e",
"transparent"],
14, ["case",
["==", ["get", "class"], "wood"], "#2d6b2d",
["==", ["get", "class"], "grass"], "#4a8c3f",
"transparent"]]The inversion matters because MapLibre can’t interpolate between case results. You have to case-select within each interpolation stop. I spent an embarrassing amount of time trying the other way before reading the spec carefully enough to understand why.
A new ClassMetadata type carries zoom color stops instead of flat color strings, and the expression builder constructs the full interpolate+case tree from the metadata. The POI path stays untouched, just extracted into its own methods so the two flows don’t tangle. One of those changes where the diff is medium-sized but the head-scratching that preceded it was substantial.
Four repos, one pipeline
Stepping back, the whole story spans four repositories and a surprising amount of deleted code:
- freddie generates 10m land cover vector tiles: SDF smoothing, Edge DP simplification, painter’s algorithm sorting, mbtiles output
- data-pipeline spins up a beefy ARM instance, downloads ~100GB of ESA WorldCover source TIFFs from S3, runs freddie, uploads the result, self-destructs
- maps serves tiles with runtime MVT concatenation: no offline merge, no deduplication loss, zero overhead when landcover isn’t configured
- maps-js renders them with zoom-interpolated fill colors through the multiclass style system
From satellite raster to styled vector on screen. GeoTIFF in, mbtiles out, MVT over HTTP, MapLibre expressions in the style JSON. Each piece does one thing.
The part that surprised me most wasn’t any individual algorithm. It was how cleanly everything composed once each piece had well-defined inputs and outputs. Freddie doesn’t know about the map SDK. The map server doesn’t know about SDF smoothing. The SDK doesn’t know the tiles came from satellite imagery. They just agree on protobuf bytes and the rest takes care of itself.
Also: I wrote and then deleted an entire smoothing algorithm, an entire merge tool, and three simplification strategies. The codebase got smaller as the feature got bigger. That might be the most satisfying part of this whole project.