<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Maps on Man-You</title><link>https://man-you.ringum.net/categories/maps/</link><description>Recent content in Maps on Man-You</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Tue, 24 Mar 2026 12:00:00 +0100</lastBuildDate><atom:link href="https://man-you.ringum.net/categories/maps/index.xml" rel="self" type="application/rss+xml"/><item><title>Freddie part 2: from tiles to map</title><link>https://man-you.ringum.net/posts/freddie-part2/</link><pubDate>Tue, 24 Mar 2026 12:00:00 +0100</pubDate><guid>https://man-you.ringum.net/posts/freddie-part2/</guid><description>&lt;p&gt;In &lt;a href="https://man-you.ringum.net/posts/scaling-freddie-10m/"&gt;part 1&lt;/a&gt; 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.&lt;/p&gt;
&lt;h2 id="barbapapa-is-dead"&gt;Barbapapa is dead&lt;/h2&gt;
&lt;p&gt;I killed Barbapapa a few weeks after shipping it.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Land cover at 10m resolution doesn&amp;rsquo;t have smooth boundaries. A forest edge is where a satellite sensor switched from &amp;ldquo;tree&amp;rdquo; to &amp;ldquo;grass&amp;rdquo; at some pixel boundary. It&amp;rsquo;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.&lt;/p&gt;
&lt;p&gt;Meanwhile, the chain-based Douglas-Peucker simplifier I&amp;rsquo;d built alongside it was doing more useful work with less ceremony.&lt;/p&gt;
&lt;h2 id="edge-dp"&gt;Edge DP&lt;/h2&gt;
&lt;p&gt;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&amp;rsquo;t carry information. Less romantic, more honest.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Five phases:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Find junctions.&lt;/strong&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Extract chains.&lt;/strong&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Douglas-Peucker per chain.&lt;/strong&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Reconcile.&lt;/strong&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Rebuild twins.&lt;/strong&gt; Remove the marked vertices from all rings and relink half-edge twin pointers so the topology stays consistent.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h3 id="z14factor"&gt;Z14Factor&lt;/h3&gt;
&lt;p&gt;One subtlety at high zoom: SDF upscaling leaves residual staircase artifacts. The Gaussian blur approximates class boundaries but doesn&amp;rsquo;t eliminate the pixel grid entirely. A &lt;code&gt;Z14Factor&lt;/code&gt; of 0.6 multiplies the DP threshold at z14 and above, a &lt;em&gt;less&lt;/em&gt; 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.&lt;/p&gt;
&lt;h3 id="what-got-deleted"&gt;What got deleted&lt;/h3&gt;
&lt;p&gt;This was the satisfying part. With Edge DP handling everything alone, I got to delete a lot of code:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Barbapapa: gone (~6,800 lines including tests and docs)&lt;/li&gt;
&lt;li&gt;Visvalingam-Whyatt: gone (experimental, Edge DP outperformed it everywhere)&lt;/li&gt;
&lt;li&gt;SimplifyFaces: gone (subsumed by Edge DP&amp;rsquo;s chain extraction)&lt;/li&gt;
&lt;li&gt;Ten per-class viewer layers: collapsed into one fill layer with zoom-interpolated colors&lt;/li&gt;
&lt;li&gt;Duplicated config across &lt;code&gt;freddie&lt;/code&gt; and &lt;code&gt;freddie-server&lt;/code&gt;: unified into a single &lt;code&gt;ProcessingConfig&lt;/code&gt; struct&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The pipeline description shrank to one line: &lt;code&gt;SDF (blur=6.0, minZ=10) + mode filter 7×7 + edge DP (threshold=2.0)&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="painters-algorithm"&gt;Painter&amp;rsquo;s algorithm&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;The fix: topological sorting before MVT encoding. For each face, ray-cast to check whether it&amp;rsquo;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.&lt;/p&gt;
&lt;h2 id="the-200gb-problem"&gt;The 200GB problem&lt;/h2&gt;
&lt;p&gt;Freddie generates beautiful tiles. But beautiful tiles sitting on my laptop don&amp;rsquo;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.&lt;/p&gt;
&lt;p&gt;The obvious approach: merge the two mbtiles files offline into one. Base map (~80GB) + landcover (~60GB) = one file, ship it. I built &lt;code&gt;freddie-merge&lt;/code&gt; for exactly this. Iterated over every tile in both files, combined the MVT layers, wrote the result. Clean, simple, done.&lt;/p&gt;
&lt;p&gt;The merged output was over 200GB. I stared at the number for a while.&lt;/p&gt;
&lt;p&gt;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&amp;rsquo;t equal 200, but 80 (heavily deduplicated) + 60 (heavily deduplicated) merged together becomes 200 (nothing deduplicated). I&amp;rsquo;d undone one of Planetiler&amp;rsquo;s best optimizations without realizing it.&lt;/p&gt;
&lt;h2 id="runtime-mvt-concatenation"&gt;Runtime MVT concatenation&lt;/h2&gt;
&lt;p&gt;The fix is almost too simple to be satisfying.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;So: don&amp;rsquo;t merge at build time. Keep both tile pyramids as separate mbtiles files and concatenate at read time:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Fetch base tile from mbtiles A&lt;/li&gt;
&lt;li&gt;Fetch landcover tile from mbtiles B&lt;/li&gt;
&lt;li&gt;Decompress both (gzip)&lt;/li&gt;
&lt;li&gt;Concatenate, landcover first, so it paints behind everything&lt;/li&gt;
&lt;li&gt;Recompress, serve&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;When there&amp;rsquo;s no landcover configured, the merge path is never entered. Zero overhead for the existing setup. Both files keep their original deduplication intact. And &lt;code&gt;freddie-merge&lt;/code&gt; (the tool I built, debugged, optimized with ATTACH JOIN, rewrote to iterate the smaller set) joins the growing pile of deleted code.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;landcover_wood&lt;/code&gt; and &lt;code&gt;landcover_grass&lt;/code&gt; layers take over with a minzoom of 12.&lt;/p&gt;
&lt;h2 id="teaching-the-sdk-about-fill-colors"&gt;Teaching the SDK about fill colors&lt;/h2&gt;
&lt;p&gt;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?&lt;/p&gt;
&lt;p&gt;No. POIs and landcover need fundamentally different MapLibre expression shapes, and the difference is annoyingly subtle.&lt;/p&gt;
&lt;p&gt;A POI layer uses &lt;code&gt;case&lt;/code&gt; at the root to pick an icon, with &lt;code&gt;interpolate&lt;/code&gt; nested inside for zoom-dependent sizing:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;case&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;==&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;get&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;class&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;park&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;park-icon&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;==&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;get&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;class&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;school&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;school-icon&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;default-icon&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;A landcover fill layer needs the structure inverted: &lt;code&gt;interpolate&lt;/code&gt; at the root for smooth color transitions across zoom, with &lt;code&gt;case&lt;/code&gt; nested inside each zoom stop to pick the per-class color:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;interpolate&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;linear&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;zoom&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;case&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;==&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;get&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;class&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;wood&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;#1a3d1a&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;==&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;get&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;class&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;grass&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;#2d5a1e&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;transparent&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;case&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;==&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;get&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;class&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;wood&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;#2d6b2d&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;==&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;get&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;class&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;grass&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;#4a8c3f&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;transparent&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The inversion matters because MapLibre can&amp;rsquo;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.&lt;/p&gt;
&lt;p&gt;A new &lt;code&gt;ClassMetadata&lt;/code&gt; type carries zoom color stops instead of flat color strings, and the expression builder constructs the full &lt;code&gt;interpolate&lt;/code&gt;+&lt;code&gt;case&lt;/code&gt; tree from the metadata. The POI path stays untouched, just extracted into its own methods so the two flows don&amp;rsquo;t tangle. One of those changes where the diff is medium-sized but the head-scratching that preceded it was substantial.&lt;/p&gt;
&lt;h2 id="four-repos-one-pipeline"&gt;Four repos, one pipeline&lt;/h2&gt;
&lt;p&gt;Stepping back, the whole story spans four repositories and a surprising amount of deleted code:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;freddie&lt;/strong&gt; generates 10m land cover vector tiles: SDF smoothing, Edge DP simplification, painter&amp;rsquo;s algorithm sorting, mbtiles output&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data-pipeline&lt;/strong&gt; spins up a beefy ARM instance, downloads ~100GB of ESA WorldCover source TIFFs from S3, runs freddie, uploads the result, self-destructs&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;maps&lt;/strong&gt; serves tiles with runtime MVT concatenation: no offline merge, no deduplication loss, zero overhead when landcover isn&amp;rsquo;t configured&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;maps-js&lt;/strong&gt; renders them with zoom-interpolated fill colors through the multiclass style system&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;The part that surprised me most wasn&amp;rsquo;t any individual algorithm. It was how cleanly everything composed once each piece had well-defined inputs and outputs. Freddie doesn&amp;rsquo;t know about the map SDK. The map server doesn&amp;rsquo;t know about SDF smoothing. The SDK doesn&amp;rsquo;t know the tiles came from satellite imagery. They just agree on protobuf bytes and the rest takes care of itself.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;</description></item><item><title>When "mostly aligned" stops being enough</title><link>https://man-you.ringum.net/posts/supply-chain-control/</link><pubDate>Sat, 07 Mar 2026 22:41:00 +0100</pubDate><guid>https://man-you.ringum.net/posts/supply-chain-control/</guid><description>&lt;p&gt;Every product starts by depending on open source it doesn&amp;rsquo;t fully understand. That&amp;rsquo;s fine. It&amp;rsquo;s the whole point. You stand on the shoulders of people who solved problems you haven&amp;rsquo;t even encountered yet, and you ship. The dependency is invisible because it works.&lt;/p&gt;
&lt;p&gt;Then the product grows, and you start noticing the edges. The upstream project makes choices you wouldn&amp;rsquo;t have made. The schema carries fields you don&amp;rsquo;t use. The routing engine takes a turn you can&amp;rsquo;t explain. The update process becomes a gamble: bump to the latest version and hope nothing changed in a way that breaks your use case. You&amp;rsquo;re accountable to your customers for software you treat as a black box.&lt;/p&gt;
&lt;p&gt;This is the story of how that played out for us across map tiles, routing, and rendering, and why the hardest part isn&amp;rsquo;t the engineering.&lt;/p&gt;
&lt;h2 id="not-all-dependencies-are-equal"&gt;Not all dependencies are equal&lt;/h2&gt;
&lt;p&gt;We depend on dozens of open source projects. FastAPI, Django, Pydantic, httpx, pytest, the list goes on. If FastAPI disappeared tomorrow, we&amp;rsquo;d rewrite some decorators and route definitions. It would be work, but the product would still be the product. These are plumbing. They shape the surface (how requests arrive, how responses leave) but they don&amp;rsquo;t define what the product &lt;em&gt;is&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Map tiles and routing are different. Without tiles, the maps SDK renders nothing. Without a routing engine, the distance API returns nothing. These aren&amp;rsquo;t tools we use to build the product. They &lt;em&gt;are&lt;/em&gt; the product, or at least the foundation it stands on. An empty shell without them.&lt;/p&gt;
&lt;p&gt;The dependency risk isn&amp;rsquo;t about how many lines of code use a library. It&amp;rsquo;s about how much of the product&amp;rsquo;s core value lives in upstream code you don&amp;rsquo;t control. When that answer is &amp;ldquo;most of it,&amp;rdquo; the relationship with that upstream project stops being a convenience and starts being an existential question.&lt;/p&gt;
&lt;h2 id="the-illusion"&gt;The illusion&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s the part nobody talks about.&lt;/p&gt;
&lt;p&gt;When you ship an MVP built on third-party foundations, the customer doesn&amp;rsquo;t know. They don&amp;rsquo;t know the tiles come from MapTiler. They don&amp;rsquo;t know the routing runs on Valhalla. They see a Woosmap maps API and a Woosmap distance API. They think they bought a product.&lt;/p&gt;
&lt;p&gt;And they did, from their perspective. The API works. The tiles render. The routes compute. The fact that most of the core value is someone else&amp;rsquo;s software, running in your infrastructure, with your branding on top, is an implementation detail they never see.&lt;/p&gt;
&lt;p&gt;So when you sell the MVP, you&amp;rsquo;re implicitly selling the product three years from now. The customer is buying what they think exists. Leadership is reporting on what they think the team built. And the engineering team knows the truth: that the product, as everyone imagines it, doesn&amp;rsquo;t exist yet. What exists is a thin layer on top of open source that happens to work.&lt;/p&gt;
&lt;p&gt;This creates a brutal dynamic. When you invest in actually building the foundations (replacing vendor tiles with your own schema, understanding the routing engine you depend on, building a style compiler) it looks like you&amp;rsquo;re doing nothing. No new features. No visible progress. The maps still render. The routes still compute. From the outside, nothing changed.&lt;/p&gt;
&lt;p&gt;Worse, the transition introduces blips. You&amp;rsquo;re replacing running systems with new ones you wrote, and you&amp;rsquo;re human, so mistakes happen. A regression in tile rendering. A routing edge case the old system handled that yours doesn&amp;rsquo;t yet. For a brief window, you&amp;rsquo;ve made things slightly worse in pursuit of making them fundamentally better. And nobody outside the team has the context to understand why.&lt;/p&gt;
&lt;p&gt;You&amp;rsquo;re in a fight against everyone&amp;rsquo;s perception: customers, leadership, sometimes other engineering teams. Living up to what people think the product already does while quietly building the thing they assumed was there all along.&lt;/p&gt;
&lt;h2 id="the-pattern"&gt;The pattern&lt;/h2&gt;
&lt;p&gt;It starts the same way every time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 1: it works.&lt;/strong&gt; You adopt the open source project. It does 90% of what you need. The remaining 10% you work around. The trade-off is obvious and good: building from scratch would take months, this takes days. You ship.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 2: it mostly works.&lt;/strong&gt; The product grows. The 10% gap becomes visible to customers. You can&amp;rsquo;t change it because it&amp;rsquo;s upstream. You file issues, maybe contribute patches, but the project&amp;rsquo;s priorities aren&amp;rsquo;t your priorities. They&amp;rsquo;re building for a community. You&amp;rsquo;re building for a product.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 3: you&amp;rsquo;re accountable.&lt;/strong&gt; A customer reports a bug that lives in the upstream code. You can&amp;rsquo;t just say &amp;ldquo;that&amp;rsquo;s an open source issue.&amp;rdquo; It&amp;rsquo;s your product. You need to fix it, but fixing it means understanding a codebase that wasn&amp;rsquo;t written for you, by people who had different goals. The gap between &amp;ldquo;using&amp;rdquo; and &amp;ldquo;maintaining&amp;rdquo; becomes real.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 4: the fork in the road.&lt;/strong&gt; You have three options. Contribute upstream and hope the community accepts your direction. Fork and maintain your own version. Or rebuild the parts you need from scratch.&lt;/p&gt;
&lt;h2 id="map-tiles-from-vendor-to-vertical-integration"&gt;Map tiles: from vendor to vertical integration&lt;/h2&gt;
&lt;h3 id="buying-tiles"&gt;Buying tiles&lt;/h3&gt;
&lt;p&gt;The first version of Woosmap Maps didn&amp;rsquo;t generate tiles at all. We licensed vector tiles from MapTiler, a flat yearly fee for an MBTiles pyramid. Predictable, simple. We focused entirely on the SDK: the Mapbox GL fork, the Google Maps compatibility layer, the service clients. The tiles were someone else&amp;rsquo;s problem.&lt;/p&gt;
&lt;p&gt;This was the right call. We were building a maps product, not a geodata pipeline. MapTiler tiles worked, the OpenMapTiles schema was well-documented, and we could ship without learning anything about OSM data processing.&lt;/p&gt;
&lt;p&gt;By the time we started thinking about generating tiles ourselves, the pricing model had changed. I don&amp;rsquo;t remember the exact structure anymore, but it was enough of a signal: the terms we&amp;rsquo;d signed up for weren&amp;rsquo;t the terms going forward. Beyond pricing, tiles carried 16 layers with attributes we never read, the update cadence wasn&amp;rsquo;t ours, and the OpenMapTiles CC-BY 4.0 license required visible attribution, which was friction in every enterprise sales conversation.&lt;/p&gt;
&lt;h3 id="running-openmaptiles-ourselves"&gt;Running OpenMapTiles ourselves&lt;/h3&gt;
&lt;p&gt;We took the OpenMapTiles stack in-house. PostgreSQL-based: import OSM data with imposm3 into PostGIS, run generated SQL scripts to transform it layer by layer, then generate vector tiles through per-tile PostgreSQL queries. Same schema, our infrastructure.&lt;/p&gt;
&lt;p&gt;It worked, but planet generation took days and the database needed hundreds of gigabytes. And we still had the same schema, the same unused attributes, the same attribution requirements. We&amp;rsquo;d replaced a vendor bill with an infrastructure bill while keeping every constraint.&lt;/p&gt;
&lt;h3 id="planetiler"&gt;Planetiler&lt;/h3&gt;
&lt;p&gt;Planetiler (originally Flatmap) changed the equation. Java, streaming processing, no database. A full planet in hours on a single machine instead of days on a database cluster. We ran it with the &lt;code&gt;planetiler-openmaptiles&lt;/code&gt; profile: same output, fraction of the cost.&lt;/p&gt;
&lt;p&gt;But we still had the schema problem. Faster generation of tiles you don&amp;rsquo;t control is still tiles you don&amp;rsquo;t control.&lt;/p&gt;
&lt;h3 id="clean-room-schema"&gt;Clean-room schema&lt;/h3&gt;
&lt;p&gt;The real break was writing our own &lt;a href="https://man-you.ringum.net/backroom/woosmap-tiles/"&gt;Planetiler profile from scratch&lt;/a&gt;. Not a fork of OpenMapTiles. A clean-room reimplementation with our own layer names, our own attributes, and nothing we don&amp;rsquo;t use.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;transportation&lt;/code&gt; became &lt;code&gt;roads&lt;/code&gt;. &lt;code&gt;transportation_name&lt;/code&gt; became &lt;code&gt;road_labels&lt;/code&gt;. &lt;code&gt;housenumber&lt;/code&gt; got dropped entirely. Almost every layer is written in Kotlin because the logic is too specific for declarative YAML: transit network propagation from OSM relations, building height arithmetic, multi-script labels across 85 languages, roads pre-split by bridge/tunnel structure.&lt;/p&gt;
&lt;h3 id="the-style-compiler"&gt;The style compiler&lt;/h3&gt;
&lt;p&gt;Owning the tile schema without owning the style is half the job. A production Mapbox GL style is thousands of lines of JSON. Roads alone need six variants each across eight road classes.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://man-you.ringum.net/posts/map-style-compiler/"&gt;Elzar&lt;/a&gt;, our style compiler, generates that JSON from Python code. The tile schema and the style are designed in lockstep. When the Kotlin tile generator emits a &lt;code&gt;hide_3d&lt;/code&gt; attribute on buildings, the style compiler references it in the same PR. Schema and style can&amp;rsquo;t drift because they live in the same repo.&lt;/p&gt;
&lt;h3 id="the-spec-question"&gt;The spec question&lt;/h3&gt;
&lt;p&gt;Once you own both the tiles and the style generation, a new question surfaces: does the Mapbox GL style spec itself actually fit what you&amp;rsquo;re building?&lt;/p&gt;
&lt;p&gt;The spec was designed for a general-purpose renderer serving a general-purpose schema. It has no concept of road structure, so a single road class needs six style layers (tunnel casing, tunnel fill, road casing, road fill, bridge casing, bridge fill) because brunnel is just another filter attribute. Eight road classes times six variants is 48 layers just for roads, before labels and shields. It&amp;rsquo;s like using assembly to build a web server: technically possible, but is it reasonable? Complex logic gets encoded as JSON expression trees (nested arrays of &lt;code&gt;[&amp;quot;case&amp;quot;, [&amp;quot;all&amp;quot;, [&amp;quot;has&amp;quot;, &amp;quot;name:nonlatin&amp;quot;], ...]]&lt;/code&gt;) that are technically correct and practically unreadable.&lt;/p&gt;
&lt;p&gt;Elzar makes this manageable. Define a road once, get six variants. Use &lt;code&gt;~Q(hide_3d__exists=True)&lt;/code&gt; instead of raw filter arrays. Pre-splitting roads into &lt;code&gt;roads_tunnel&lt;/code&gt;, &lt;code&gt;roads&lt;/code&gt;, and &lt;code&gt;roads_bridge&lt;/code&gt; at the tile level was our way of optimizing &lt;em&gt;around&lt;/em&gt; the spec, moving work from render time to tile generation time because the style format couldn&amp;rsquo;t express it efficiently.&lt;/p&gt;
&lt;p&gt;Elzar would stay regardless of the output format. Even if we designed a custom style spec, we&amp;rsquo;d still want to define styles in Python (something executable, debuggable, testable) rather than editing a declarative format by hand. The compiler&amp;rsquo;s value isn&amp;rsquo;t papering over the Mapbox spec. It&amp;rsquo;s that Python is a better authoring language for styles than any JSON dialect ever will be. The compiler stays. Only its target changes.&lt;/p&gt;
&lt;h3 id="the-renderer"&gt;The renderer&lt;/h3&gt;
&lt;p&gt;That&amp;rsquo;s where &lt;a href="https://man-you.ringum.net/posts/gpu-map-renderer/"&gt;Nimbus&lt;/a&gt;, the GPU map renderer experiment in Rust, enters the picture. If you control the renderer, you control the style format. The Mapbox spec stops being a constraint and becomes a choice. You can support it for compatibility while designing something better for the pipeline you actually have.&lt;/p&gt;
&lt;p&gt;A style format designed for our schema wouldn&amp;rsquo;t need six layers per road class. It would know about brunnel structure because the tiles already express it. Tiles that carry exactly the data the style describes. A style format that maps naturally to the tile schema. A renderer that understands both natively. And Elzar at the center, generating whatever output the renderer needs from the same Python definitions we already have.&lt;/p&gt;
&lt;h2 id="routing-the-black-box-problem"&gt;Routing: the black box problem&lt;/h2&gt;
&lt;h3 id="osrm"&gt;OSRM&lt;/h3&gt;
&lt;p&gt;The distance API started on OSRM. It&amp;rsquo;s fast and well-proven. But it loads the entire road graph into RAM, and for a world-scale service, that&amp;rsquo;s a hard constraint. The memory footprint made it impractical for the coverage we needed.&lt;/p&gt;
&lt;h3 id="valhalla"&gt;Valhalla&lt;/h3&gt;
&lt;p&gt;Valhalla solved the memory problem. Tile-based graph storage means you can serve the world without loading it all into RAM. We migrated, it worked, and for a while the &amp;ldquo;mostly aligned&amp;rdquo; phase was comfortable.&lt;/p&gt;
&lt;p&gt;Then we started hitting the edges.&lt;/p&gt;
&lt;p&gt;Valhalla is a massive C++ codebase maintained by a community with its own priorities. When a customer reports a routing bug (a wrong turn cost, a route through a service road that should be avoided) we can&amp;rsquo;t just fix it. We&amp;rsquo;d need to understand why the cost model works the way it does, trace through code that wasn&amp;rsquo;t written for our use case, and either patch it locally or convince the upstream community that our fix is the right one.&lt;/p&gt;
&lt;p&gt;The tile update process is where the black box problem gets painful. When we want to regenerate Valhalla tiles with fresh OSM data, we typically also bump Valhalla to the latest version, because the version that generates tiles and the version that serves them sometimes need to match. But a version bump means inheriting every change the community made since our last update. New cost model tweaks we didn&amp;rsquo;t ask for. Behavioral changes we don&amp;rsquo;t understand. Route quality regressions we discover from customer complaints, not from changelogs.&lt;/p&gt;
&lt;p&gt;We&amp;rsquo;re accountable for this software. Customers don&amp;rsquo;t care that Valhalla is open source. They see a Woosmap API returning a bad route and they file a ticket with us. And we&amp;rsquo;re stuck between &amp;ldquo;we don&amp;rsquo;t understand why it does that&amp;rdquo; and &amp;ldquo;we can&amp;rsquo;t change it without forking a C++ project we don&amp;rsquo;t deeply know.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;The C++ ecosystem makes this harder. Build systems, dependency management, debugging tooling: none of it is as accessible as the JVM or Rust&amp;rsquo;s cargo. Taking deeper ownership of Valhalla isn&amp;rsquo;t just a knowledge problem, it&amp;rsquo;s an ergonomics problem.&lt;/p&gt;
&lt;h3 id="calculon"&gt;Calculon&lt;/h3&gt;
&lt;p&gt;&lt;a href="https://man-you.ringum.net/posts/calculon/"&gt;Calculon&lt;/a&gt; is the &amp;ldquo;what if we built it ourselves&amp;rdquo; experiment: a routing engine in Rust. Bidirectional A*, many-to-many distance matrices, driving/cycling/pedestrian profiles, Valhalla-compatible API. It reuses Valhalla&amp;rsquo;s tile format (designing a graph format from scratch would be its own project) but everything else is new code.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s not a Valhalla replacement yet. But when a cost model produces a weird route, we can read the code. When we want to change how turn penalties work, we change them. When we bump a dependency, it&amp;rsquo;s a Rust crate with a changelog we understand, not a C++ monolith where any subsystem might have shifted.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a middle path too: taking deeper ownership of Valhalla itself, contributing changes that serve our use case. But that requires investing heavily in a C++ codebase whose architecture and community may or may not align with where we need to go.&lt;/p&gt;
&lt;h2 id="when-replacing-a-black-box-makes-things-better"&gt;When replacing a black box makes things better&lt;/h2&gt;
&lt;p&gt;The pattern isn&amp;rsquo;t always painful. Sometimes replacing a dependency you don&amp;rsquo;t control with something you wrote yourself turns out to be a straight upgrade.&lt;/p&gt;
&lt;p&gt;The Woosmap Store Locator used Mapnik (a C++ rendering library) to generate map tiles showing store locations. Mapnik is a serious piece of software, battle-tested for raster map rendering. But it wasn&amp;rsquo;t designed for our use case: rendering MVT vector tiles with point data. It had become a dependency we couldn&amp;rsquo;t easily update, couldn&amp;rsquo;t debug when it misbehaved, and couldn&amp;rsquo;t adapt to our evolving needs.&lt;/p&gt;
&lt;p&gt;We &lt;a href="https://github.com/Woosmap/stores/pull/815"&gt;replaced it&lt;/a&gt; with a Python renderer built on Cairo. The new renderer was simpler: written in a language the whole team knows, doing exactly what we need and nothing more. And it turned out to be more performant. Less load on the database. Enough of an improvement that we could scale down the RDS instance.&lt;/p&gt;
&lt;p&gt;A full-featured C++ rendering engine, replaced by a focused Python implementation, and the result was &lt;em&gt;faster&lt;/em&gt; and &lt;em&gt;cheaper&lt;/em&gt;. Not because Python is faster than C++. Obviously it isn&amp;rsquo;t. But because a tool built for your specific problem, that you understand completely, can be optimized in ways a general-purpose tool never will be. You know which queries to run, which data to skip, which shortcuts are safe. The black box can&amp;rsquo;t know any of that.&lt;/p&gt;
&lt;h2 id="the-ones-still-early-in-the-cycle"&gt;The ones still early in the cycle&lt;/h2&gt;
&lt;p&gt;Not every dependency has reached the accountability phase yet. &lt;a href="https://man-you.ringum.net/backroom/static_maps/"&gt;Maparazzo&lt;/a&gt;, our static maps renderer, wraps Mapbox GL Native&amp;rsquo;s C++ core in a Python library for headless map image generation. Right now it&amp;rsquo;s in phase 1: it works. The C++ renderer produces correct images, the Python wrapper makes it callable from our FastAPI services, and static maps ship to customers.&lt;/p&gt;
&lt;p&gt;But the signs are already there. The C++ renderer leaks memory: OpenGL contexts hold state after objects are destroyed. We&amp;rsquo;ve worked around it by pooling &lt;code&gt;Map&lt;/code&gt; instances and reusing GL contexts, but the leak is in code we inherited, not code we wrote. When it misbehaves, debugging means diving into a massive C++ codebase with its own conventions, its own build system, its own history of decisions we weren&amp;rsquo;t part of.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s comfortable enough today. The problems will come. And when they do, Nimbus (which can already render our tiles headlessly from Rust) is the path out. Same pattern, different timeline.&lt;/p&gt;
&lt;h2 id="the-open-source-dependency-lifecycle"&gt;The open source dependency lifecycle&lt;/h2&gt;
&lt;p&gt;The uncomfortable truth is that depending on open source unknowingly makes you a maintainer. Not in the &amp;ldquo;you have commit access&amp;rdquo; sense. In the &amp;ldquo;you&amp;rsquo;re responsible for its behavior in your product&amp;rdquo; sense.&lt;/p&gt;
&lt;p&gt;For an MVP, &amp;ldquo;mostly aligned&amp;rdquo; is enough. The open source project does 90% of what you need, the community is active, the abstractions hold. You&amp;rsquo;d be foolish to build from scratch. And the community&amp;rsquo;s goals don&amp;rsquo;t need to perfectly match yours. They just need to overlap enough that you can ship.&lt;/p&gt;
&lt;p&gt;But products have customers. Customers have expectations. When the open source project&amp;rsquo;s goals diverge from yours, even slightly, you feel it. Not as a single breaking change, but as accumulated friction. Features you can&amp;rsquo;t add because the schema doesn&amp;rsquo;t support them. Bugs you can&amp;rsquo;t fix because the codebase is impenetrable. Updates you can&amp;rsquo;t skip because the format requires version parity.&lt;/p&gt;
&lt;p&gt;And ownership doesn&amp;rsquo;t stop at the obvious dependencies. Once you own the data and the generation, you discover that the &lt;em&gt;formats&lt;/em&gt; and &lt;em&gt;specs&lt;/em&gt; are dependencies too. The Mapbox style spec. Valhalla&amp;rsquo;s tile format. These are constraints you inherited from the ecosystem, and they shape what&amp;rsquo;s easy and what&amp;rsquo;s hard in ways that aren&amp;rsquo;t visible until you try to push past them.&lt;/p&gt;
&lt;p&gt;The question isn&amp;rsquo;t whether to take ownership. It&amp;rsquo;s when, of what, and how deep. Do the changes you need benefit the community? Would they accept a PR, or is your direction orthogonal to theirs? Is the ecosystem accessible enough to invest in, or is the cost of understanding it higher than the cost of rebuilding?&lt;/p&gt;
&lt;p&gt;For tiles, we kept Planetiler as the engine and own everything else: schema, style, and the compiler that connects them. For routing, the answer is still forming. For the style format and the renderer, we can work around the constraints today, but we can see the path to not having to.&lt;/p&gt;
&lt;h2 id="we-have-no-vision"&gt;&amp;ldquo;We have no vision&amp;rdquo;&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;ve heard this from engineers on the team. That we&amp;rsquo;re only reactive. That we just respond to customer requests and there&amp;rsquo;s no plan. And I understand why it looks that way: if you think phase 1 is the end, then everything after looks like firefighting.&lt;/p&gt;
&lt;p&gt;But if you lay out the phases, the vision is obvious. We have years of deliberate work ahead: owning the tile schema, closing the style spec gap, making the renderer a first-class citizen, taking control of routing. Each phase builds on the last. Each one makes the product more ours and less a wrapper around someone else&amp;rsquo;s decisions. That&amp;rsquo;s not reactive. That&amp;rsquo;s a roadmap.&lt;/p&gt;
&lt;p&gt;The problem is that this roadmap is invisible to anyone who thinks the product was finished when the MVP shipped. And that&amp;rsquo;s the majority of people: customers, leadership, and yes, most engineers. They see the API, it works, so the hard part must be done. Everything after is maintenance.&lt;/p&gt;
&lt;p&gt;This is exactly the same problem as technical debt, just at a different scale. Everyone will sign up for taking on debt if they think it ships something faster. Add a dependency you don&amp;rsquo;t understand. Skip the tests. Use the upstream schema as-is. Ship it. Move on to the next thing, take on more debt there too. And when someone proposes paying it down (replacing Mapnik with our own renderer, writing a clean-room tile schema, building a style compiler) the response is &amp;ldquo;why? it works.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;It works until it doesn&amp;rsquo;t. And by then the debt has compounded. The codebase is half-rotten, everyone complains about it, but nobody wants to stop adding features long enough to fix it. The boy scout rule (leave the code better than you found it) gets dismissed as slowing things down. So the rot spreads.&lt;/p&gt;
&lt;p&gt;Supply chain debt works the same way. Every dependency you don&amp;rsquo;t understand is a loan against your future ability to move fast. The interest is paid in debugging time, in workarounds, in version bumps that break things you can&amp;rsquo;t explain, in customers waiting for fixes that live in upstream code you can&amp;rsquo;t change. And just like financial debt, the longer you ignore it, the more it costs.&lt;/p&gt;
&lt;p&gt;The vision isn&amp;rsquo;t a feature roadmap. It&amp;rsquo;s the systematic transfer of ownership from upstream to us, one layer at a time, while the product keeps running. It&amp;rsquo;s unglamorous, often invisible, and it&amp;rsquo;s the only way to build something that lasts.&lt;/p&gt;
&lt;p&gt;My role as CTO is to grow the technological asset of the company. Features are what the product &lt;em&gt;does&lt;/em&gt;. The asset is what the product &lt;em&gt;is&lt;/em&gt;. The PO grows the former. I grow the latter. When the asset is a thin wrapper around upstream dependencies, it&amp;rsquo;s fragile. Anyone can replicate it. Every phase of ownership makes it thicker, harder to reproduce, more valuable. That&amp;rsquo;s not maintenance. That&amp;rsquo;s building the company&amp;rsquo;s core value.&lt;/p&gt;
&lt;p&gt;And that means making these phases crystal clear to everyone. If the team doesn&amp;rsquo;t see the multi-phase roadmap, they&amp;rsquo;ll assume there isn&amp;rsquo;t one. If leadership doesn&amp;rsquo;t understand why replacing a working renderer with a new one matters, they&amp;rsquo;ll see wasted effort. If engineers think phase 1 is the destination, they&amp;rsquo;ll resist every investment in phase 2 through 5 as unnecessary complexity.&lt;/p&gt;
&lt;p&gt;And here&amp;rsquo;s where I haven&amp;rsquo;t done a good enough job. When the foundational work isn&amp;rsquo;t visible (or worse, when I end up doing it on my own because it&amp;rsquo;s hard to justify in sprint planning) it creates the illusion that there&amp;rsquo;s only one stream of work: the product owner&amp;rsquo;s backlog. Feature requests, customer issues, integration tickets. That becomes the only work that feels real, the only work that gets discussed in standups, the only work that counts.&lt;/p&gt;
&lt;p&gt;Engineers naturally gravitate toward pleasing the PO. The PO has the backlog, the priorities, the stakeholder pressure. The CTO has&amp;hellip; a vague sense that the foundations need work. If I haven&amp;rsquo;t made the supply chain roadmap as concrete and visible as the feature backlog, I can&amp;rsquo;t blame anyone for treating it as optional.&lt;/p&gt;
&lt;p&gt;But it&amp;rsquo;s a dangerous dynamic. A team that only serves the PO&amp;rsquo;s backlog is a team that only adds features to an MVP while the foundations quietly rot. The PO&amp;rsquo;s job is to maximize customer value &lt;em&gt;now&lt;/em&gt;. The CTO&amp;rsquo;s job is to make sure the product can still deliver that value in two years. Those aren&amp;rsquo;t opposing goals, but they operate on different timescales, and if the long-term one isn&amp;rsquo;t articulated clearly enough, it loses every single sprint planning meeting.&lt;/p&gt;
&lt;p&gt;The vision exists. It&amp;rsquo;s in the progression from bought tiles to clean-room schema. It&amp;rsquo;s in Calculon sitting next to Valhalla. It&amp;rsquo;s in Elzar generating styles that match a tile schema we designed. The work speaks for itself, but only if someone tells the story clearly enough that it becomes a roadmap, not a side project. And that someone is me.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a harder question underneath all of this: is growing the technological asset even possible when the company is purely ARR-driven?&lt;/p&gt;
&lt;p&gt;ARR optimizes for now. Ship the feature that closes the deal. Fix the bug that stops the churn. Hit the number this quarter. Every sprint is a negotiation between what moves the metric today and what makes the product real. And the metric always wins, because it&amp;rsquo;s concrete: a number on a dashboard, a target in a board deck.&lt;/p&gt;
&lt;p&gt;The technological asset doesn&amp;rsquo;t have a dashboard. There&amp;rsquo;s no metric for &amp;ldquo;we now own our tile schema&amp;rdquo; or &amp;ldquo;we can debug a routing issue in hours instead of weeks.&amp;rdquo; The value is real but it compounds invisibly: faster iteration, fewer black-box surprises, the ability to say yes to product requests that would have been impossible before. None of that shows up in ARR until months or years later, and by then nobody connects the cause to the effect.&lt;/p&gt;
&lt;p&gt;A company that&amp;rsquo;s purely ARR-driven will always deprioritize foundational work. Not out of malice. Out of measurement. If the only thing that counts is what moves the number this quarter, then building the asset that makes next year&amp;rsquo;s numbers possible will always lose. And you end up in a trap: a product that looks successful from the revenue side while its foundations quietly thin out, making it harder and more expensive to deliver on the promises that drive that very revenue.&lt;/p&gt;
&lt;p&gt;The uncomfortable truth is that ARR and the technological asset need each other. ARR without the asset is a product that gets harder to maintain every quarter. The asset without ARR is an engineering project with no customers. The CTO&amp;rsquo;s job is to make the case that both need investment, and that starving one to feed the other is a debt that always comes due.&lt;/p&gt;
&lt;p&gt;You start by depending. You end by being accountable. What happens in between is the difference between an MVP and a product.&lt;/p&gt;</description></item><item><title>Scaling Freddie to 10m landcover</title><link>https://man-you.ringum.net/posts/scaling-freddie-10m/</link><pubDate>Tue, 10 Feb 2026 12:00:00 +0200</pubDate><guid>https://man-you.ringum.net/posts/scaling-freddie-10m/</guid><description>&lt;p&gt;Serving vector tiles from 10-meter resolution land cover data. What breaks when you 100x the input resolution, and how topology-preserving smoothing fixes the ugly parts.&lt;/p&gt;
&lt;h2 id="background"&gt;Background&lt;/h2&gt;
&lt;p&gt;Freddie is our tile server for land cover data. It reads GeoTIFF raster files (pixels classified as wood, grass, built-up, water, etc.) and serves them as vector tiles or raster tiles over HTTP. Nothing fancy. Read pixels, make shapes, encode, serve.&lt;/p&gt;
&lt;p&gt;The original setup used ESA WorldCover at ~100m resolution. Native resolution sits around z8 in Web Mercator, so serving z0–z8 tiles was straightforward. Sample pixels, run-length encode, merge adjacent faces of the same class, encode to MVT. Done. It just worked, and I didn&amp;rsquo;t think much about it.&lt;/p&gt;
&lt;p&gt;Then we switched to the 10m dataset. Native resolution jumps to ~z13–z14. The source TIFFs balloon to 36,000 x 36,000 pixels each. Everything that worked at 100m either broke or looked terrible. This is the story of fixing all of it.&lt;/p&gt;
&lt;h2 id="what-breaks"&gt;What breaks&lt;/h2&gt;
&lt;p&gt;Three problems, in order of how quickly you notice them.&lt;/p&gt;
&lt;h3 id="staircase-boundaries"&gt;Staircase boundaries&lt;/h3&gt;
&lt;p&gt;At zoom levels above native resolution (z15, z16), each source pixel stretches across 4–16 tile pixels. The vector boundaries follow pixel edges exactly: 90-degree staircase patterns everywhere. At 100m this was never visible because nobody zoomed past z8. At 10m you can zoom to z16 and the pixel grid is right there, staring back at you. Minecraft terrain, basically.&lt;/p&gt;
&lt;h3 id="performance-at-low-zoom"&gt;Performance at low zoom&lt;/h3&gt;
&lt;p&gt;A z0 tile covers the entire world. With a 36,000 x 36,000 source TIFF, generating that tile means sampling millions of pixels. At 100m the source was small enough that brute force worked. At 10m, a single z0 request could take seconds. I watched the first one come back and thought the server had hung.&lt;/p&gt;
&lt;h3 id="topology-seams"&gt;Topology seams&lt;/h3&gt;
&lt;p&gt;The naive fix for staircases (smooth each polygon independently) creates gaps and overlaps between adjacent regions. Two polygons that share a boundary get smoothed in different directions. You end up with visible seams, z-fighting, or missing pixels at class boundaries. Fix one problem, create another. Classic.&lt;/p&gt;
&lt;h2 id="cog-multi-resolution"&gt;COG multi-resolution&lt;/h2&gt;
&lt;p&gt;The performance problem, at least, has a clean solution: Cloud Optimized GeoTIFF.&lt;/p&gt;
&lt;p&gt;A COG file stores the full-resolution image plus downsampled overviews, typically at 2x, 4x, 8x, 16x, 32x reductions. Instead of one 36,000 x 36,000 image, you get a pyramid:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Use for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;36,000 x 36,000&lt;/td&gt;
&lt;td&gt;z10+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;18,000 x 18,000&lt;/td&gt;
&lt;td&gt;z8–z9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;9,000 x 9,000&lt;/td&gt;
&lt;td&gt;z6–z7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;4,500 x 4,500&lt;/td&gt;
&lt;td&gt;z4–z5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2,250 x 2,250&lt;/td&gt;
&lt;td&gt;z2–z3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;1,125 x 1,125&lt;/td&gt;
&lt;td&gt;z0–z1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The heuristic is simple: &lt;code&gt;levelIndex = (maxZoom - zoom) / 2&lt;/code&gt;. A z0 tile now samples from a 1,125 x 1,125 overview instead of the full image. About 1,000x less data to read.&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;Resolution level selection&lt;/figcaption&gt;
&lt;div class="highlight" title="Resolution level selection"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tc&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;TiffCollection&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;selectLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;levelIndex&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxZoom&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;levelIndex&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;levels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;levelIndex&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;levels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;levelIndex&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The &lt;code&gt;TiffCollection&lt;/code&gt; manages multiple TIFF files with spatial indexing: a 3 x 3 degree grid for fast lookups. At tile boundaries where multiple TIFFs overlap, deterministic ordering ensures consistent output.&lt;/p&gt;
&lt;p&gt;z0 tiles went from seconds to milliseconds. Problem one down, two to go.&lt;/p&gt;
&lt;h2 id="the-smoothing-problem"&gt;The smoothing problem&lt;/h2&gt;
&lt;p&gt;With COG handling performance, the real challenge is visual quality. Run-length encoding produces pixel-aligned polygons. At native resolution that&amp;rsquo;s fine: the boundaries are detailed enough. But zoom in past native and you see the grid. It&amp;rsquo;s ugly, and no amount of squinting makes it acceptable.&lt;/p&gt;
&lt;p&gt;Two complementary approaches: smooth the raster before vectorizing, or smooth the vectors after. I ended up doing both.&lt;/p&gt;
&lt;h3 id="sdf-raster-filtering"&gt;SDF raster filtering&lt;/h3&gt;
&lt;p&gt;Signed Distance Fields are typically used in font rendering. Valve popularized them for GPU text back in 2007. Turns out the same idea applies beautifully to classified rasters.&lt;/p&gt;
&lt;p&gt;For each class in the tile:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Compute a distance transform (Meijster algorithm, O(n) per dimension): every pixel gets its Euclidean distance to the nearest boundary&lt;/li&gt;
&lt;li&gt;Sign it: positive inside the class region, negative outside&lt;/li&gt;
&lt;li&gt;Gaussian blur the SDF&lt;/li&gt;
&lt;li&gt;Reclassify pixels based on the blurred SDF values&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Blurring an SDF instead of blurring raw pixel values is the key insight. Raw blur smears class boundaries into garbage. SDF blur produces smooth, continuous boundaries that still snap cleanly to class assignments.&lt;/p&gt;
&lt;p&gt;The blur radius scales with the upscale factor:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;z14 (native, 1x): blur = 6.0 source pixels
z15 (2x upscale): blur = 3.0 source pixels
z16 (4x upscale): blur = 1.5 source pixels
z17+ (8x+): clamped to 1.0&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;A 5 x 5 mode filter can run before SDF to remove isolated single-pixel noise, common in land cover data where a lone &amp;ldquo;built-up&amp;rdquo; pixel appears in a forest.&lt;/p&gt;
&lt;h3 id="barbapapa-topology-preserving-vector-smoothing"&gt;Barbapapa: topology-preserving vector smoothing&lt;/h3&gt;
&lt;p&gt;SDF smoothing handles the raster level. But even with pre-smoothed rasters, the vectorized output still has pixel-aligned vertices. Near native resolution, where SDF barely does anything, the staircases are still there. We need vector-level smoothing too.&lt;/p&gt;
&lt;p&gt;The fundamental problem: polygons that share a boundary must be smoothed identically along that boundary. Smooth them independently and you get gaps and overlaps. I tried independent smoothing first. It looked like the polygons were trying to escape from each other.&lt;/p&gt;
&lt;p&gt;The solution uses the half-edge data structure (DCEL) that Freddie already maintains for face merging. Adjacent polygons share vertex references through twin half-edges. Move a shared vertex and it moves for both polygons simultaneously. The topology enforces consistency.&lt;/p&gt;
&lt;p&gt;I named the algorithm Barbapapa, after the cartoon character that can reshape into anything while staying the same blob. The algorithm has five stages:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Vertex deduplication.&lt;/strong&gt; After face merging, some vertices at identical coordinates are separate objects. Canonicalize them so shared boundaries reference the same vertex instance.&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;Vertex deduplication&lt;/figcaption&gt;
&lt;div class="highlight" title="Vertex deduplication"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;vertex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;range&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;allVertices&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;X&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Y&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// Repoint all half-edges from v to existing&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;replaceVertex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;2. Edge subdivision.&lt;/strong&gt; Laplacian smoothing can only move existing vertices: it can&amp;rsquo;t add curvature where there are no points. Subdivide edges by inserting midpoints, spaced at ~3.5 source pixels apart. Both sides of a shared edge (the half-edge and its twin) get subdivided identically.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Identify immovable vertices.&lt;/strong&gt; Tile boundary vertices must not move (adjacent tiles need matching edges). Vertices that exist only on hole boundaries are also pinned (I&amp;rsquo;ll get to why that caveat matters in a moment).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Laplacian smoothing.&lt;/strong&gt; For each movable interior vertex, compute the centroid of its neighbors and blend:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;new_position = (1 - alpha) * current + alpha * centroid&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;Jacobi-style: compute all new positions first, then apply all updates simultaneously. This avoids order-dependent results. Three to four iterations with alpha = 0.5–0.6 produces good results.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Collision prevention.&lt;/strong&gt; Ensure no vertex collapses onto another. Sorted vertex iteration guarantees deterministic output.&lt;/p&gt;
&lt;p&gt;The algorithm overhead is under 5% of total tile generation time. Most time is still spent on TIFF reading and face merging. Good: the smoothing is essentially free.&lt;/p&gt;
&lt;h3 id="the-hole-vertex-problem"&gt;The hole vertex problem&lt;/h3&gt;
&lt;p&gt;Version 1 of Barbapapa had a subtle bug that killed 93% of its effectiveness. I shipped it thinking it was working. It was barely doing anything.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the setup: small classified regions (a pond in a forest, a building in a field) appear both as a face (the pond) and as a hole in the surrounding face (the forest). The vertices on these boundaries are shared between the face&amp;rsquo;s outer ring and the surrounding face&amp;rsquo;s hole ring.&lt;/p&gt;
&lt;p&gt;My original code marked all hole-boundary vertices as immovable. The reasoning seemed sound: holes have specific shapes that shouldn&amp;rsquo;t be distorted.&lt;/p&gt;
&lt;p&gt;The problem: a vertex on a small face&amp;rsquo;s outer boundary is &lt;em&gt;also&lt;/em&gt; on the surrounding face&amp;rsquo;s hole boundary. So marking all hole vertices as immovable pinned almost every interior vertex. In test datasets, 110 out of 118 interior faces had zero smoothing applied. The algorithm was running, doing math, producing output, and changing almost nothing. I only caught it when I added per-face metrics and saw the numbers.&lt;/p&gt;
&lt;p&gt;The fix: only pin vertices that appear &lt;em&gt;exclusively&lt;/em&gt; on hole boundaries and never on any face&amp;rsquo;s outer boundary. After the fix, unsmoothed interior faces dropped from 110 to 8, the remaining 8 being sub-pixel features too small to matter. Suddenly Barbapapa actually worked.&lt;/p&gt;
&lt;h3 id="zoom-adaptive-parameters"&gt;Zoom-adaptive parameters&lt;/h3&gt;
&lt;p&gt;Smoothing needs vary wildly across zoom levels. At z14 (native resolution) the pixels are small, so light smoothing is fine. At z16 (4x upscale) the pixel grid is screaming at you, so you need to be aggressive. Below native, the data is already downsampled and naturally smooth.&lt;/p&gt;
&lt;p&gt;Rather than hardcoding per-zoom parameters, everything scales with &lt;code&gt;sourcePixelSize&lt;/code&gt;: how many tile pixels each source pixel occupies:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;Adaptive smoothing parameters&lt;/figcaption&gt;
&lt;div class="highlight" title="Adaptive smoothing parameters"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;GetEffectiveParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourcePixelSize&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iterations&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;alpha&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;sourcePixelSize&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// Below native: scale down proportionally&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;sourcePixelSize&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;3.0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// Above native: boost with log curve, cap at reasonable limits&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;iterBoost&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Log2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourcePixelSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;alphaBoost&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Log2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourcePixelSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;3.0&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;iterBoost&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;alphaBoost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;Using pixel size directly instead of zoom level means the same code works regardless of source resolution. Swap in a different dataset and the smoothing adapts automatically. One less thing to tune by hand.&lt;/p&gt;
&lt;h2 id="pipeline-system"&gt;Pipeline system&lt;/h2&gt;
&lt;p&gt;With multiple raster filters and vector simplifiers, the number of possible combinations grew fast. I was adding flags to the server every other day (&lt;code&gt;--sdf&lt;/code&gt;, &lt;code&gt;--mode-filter&lt;/code&gt;, &lt;code&gt;--barbapapa-iterations&lt;/code&gt;, &lt;code&gt;--sdf-blur-radius&lt;/code&gt;) and it was getting out of hand. So I built a pipeline system instead.&lt;/p&gt;
&lt;p&gt;A pipeline is a named sequence of processing steps:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;TIFF Data → [Raster Filters] → Vectorization → [Vector Simplifiers] → MVT&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;Built-in pipelines cover common configurations:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pipeline&lt;/th&gt;
&lt;th&gt;Raster filters&lt;/th&gt;
&lt;th&gt;Vector simplifiers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;SimplifyFaces&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;default&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;SimplifyFaces, Barbapapa&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;smooth-sdf&lt;/td&gt;
&lt;td&gt;SDF&lt;/td&gt;
&lt;td&gt;SimplifyFaces, Barbapapa&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;smooth-mode5x5&lt;/td&gt;
&lt;td&gt;Mode 5x5&lt;/td&gt;
&lt;td&gt;SimplifyFaces, Barbapapa&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;smooth-combined&lt;/td&gt;
&lt;td&gt;Mode 5x5, SDF&lt;/td&gt;
&lt;td&gt;SimplifyFaces, Barbapapa&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;aggressive&lt;/td&gt;
&lt;td&gt;Mode 5x5, SDF (8px)&lt;/td&gt;
&lt;td&gt;SimplifyFaces, Barbapapa (8 iter), Douglas-Peucker&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Each step collects metrics (timing, face counts, vertex counts) so you can see exactly where time is spent. This turned out to be invaluable for debugging: when a tile looks wrong, you can trace exactly which step mangled it.&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;Running with a pipeline&lt;/figcaption&gt;
&lt;div class="highlight" title="Running with a pipeline"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./freddie-server --pipeline smooth-combined&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;One flag instead of six. Custom pipelines can still be composed from individual filters if you want to experiment.&lt;/p&gt;
&lt;h2 id="determinism"&gt;Determinism&lt;/h2&gt;
&lt;p&gt;This one bit me late. Go maps have non-deterministic iteration order. It&amp;rsquo;s by design, to prevent people from depending on it. When iterating over faces to apply smoothing, different runs could produce different vertex orderings, leading to different (though equally valid) output. Visually identical, but different bytes.&lt;/p&gt;
&lt;p&gt;For tile caching that&amp;rsquo;s a disaster. You want the same input to always produce the exact same bytes, otherwise your cache never hits. The fix: an &lt;code&gt;OrderedFaceMap&lt;/code&gt; that sorts keys once at construction and iterates in sorted order. Combined with Jacobi-style (simultaneous) updates in Barbapapa, the output is now bit-for-bit reproducible. Run it twice, get the same file. Should have been obvious from the start, but Go&amp;rsquo;s map randomization is the kind of thing you forget about until it ruins your afternoon.&lt;/p&gt;
&lt;h2 id="performance"&gt;Performance&lt;/h2&gt;
&lt;p&gt;Numbers matter, so here they are. Benchmarks on M1 Max, single tile at z8:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Memory&lt;/th&gt;
&lt;th&gt;Allocations&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GetWebMercatorTileMVT&lt;/td&gt;
&lt;td&gt;345ms&lt;/td&gt;
&lt;td&gt;152MB&lt;/td&gt;
&lt;td&gt;599k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GetBitmapTileWithMargins&lt;/td&gt;
&lt;td&gt;259ms&lt;/td&gt;
&lt;td&gt;117MB&lt;/td&gt;
&lt;td&gt;10k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GetWebMercatorTilePNG&lt;/td&gt;
&lt;td&gt;247ms&lt;/td&gt;
&lt;td&gt;113MB&lt;/td&gt;
&lt;td&gt;10k&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;With 10 workers: ~35ms per tile, ~286 tiles/second. A full z0–z8 pyramid (21,845 tiles) generates in about 90 seconds.&lt;/p&gt;
&lt;p&gt;I built a benchmark harness that runs multiple pipelines across multiple regions and zoom levels, spitting out per-tile metrics and SVG/PNG outputs for visual comparison. Change a value, regenerate, compare. Without this I would have been guessing at parameter values forever. The difference between &amp;ldquo;looks right&amp;rdquo; and &amp;ldquo;is right&amp;rdquo; is hard to eyeball across thousands of tiles.&lt;/p&gt;
&lt;h2 id="the-algorithms-evolution"&gt;The algorithm&amp;rsquo;s evolution&lt;/h2&gt;
&lt;p&gt;Barbapapa went through seven revisions. Each one was driven by a specific visual artifact I found while staring at tiles, the kind of bug where you zoom into some random patch of Indonesia and go &amp;ldquo;wait, what is &lt;em&gt;that&lt;/em&gt;.&amp;rdquo;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;v1.0&lt;/strong&gt;: Basic Laplacian smoothing. Worked, but barely visible. I thought the whole approach was a dead end.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;v1.1&lt;/strong&gt;: Edge subdivision. Suddenly there were actual curves instead of straight lines. The moment it clicked.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;v1.2&lt;/strong&gt;: Vertex deduplication. Enabled cross-face consistency. Shared boundaries finally moved together.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;v1.3&lt;/strong&gt;: Linked twin subdivision. Topological correctness for shared edges, fixing the cases where v1.2 missed a link.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;v1.4&lt;/strong&gt;: Zero-length edge prevention. Eliminated degenerate geometry that made the MVT encoder choke.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;v2.0&lt;/strong&gt;: Hole-only vertex identification. The 93% fix. The algorithm finally did what it was supposed to do all along.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;v2.1&lt;/strong&gt;: Deterministic iteration. Reproducible output for caching, after Go&amp;rsquo;s map randomization burned me.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The benchmark harness made this iteration loop possible. Every revision got validated against the full test suite before shipping: no visual regressions, no degenerate geometry, no byte-level surprises.&lt;/p&gt;
&lt;h2 id="known-limitations"&gt;Known limitations&lt;/h2&gt;
&lt;p&gt;Two artifacts I decided to live with.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fish-tail spikes at z14+.&lt;/strong&gt; Where two smoothing forces compete at narrow polygon tips, V-shaped spikes can appear. They&amp;rsquo;re small, they&amp;rsquo;re rare, and fixing them would mean adding a post-smoothing vertex cleanup pass that I don&amp;rsquo;t want to maintain.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Integer coordinate quantization.&lt;/strong&gt; MVT uses integer coordinates. Smoothed positions get rounded, which at very high zoom can produce slightly uneven curves. Floating-point vertices would fix it, but that&amp;rsquo;s a change that ripples through the entire encoding pipeline. Not worth it for an artifact you have to zoom to z17 to notice.&lt;/p&gt;
&lt;h2 id="what-changed-architecturally"&gt;What changed architecturally&lt;/h2&gt;
&lt;p&gt;Looking back, the 10m upgrade touched four areas of the codebase, and the interesting thing is how little of the original code it replaced:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Multi-resolution TIFF handling&lt;/strong&gt;: COG support with automatic level selection. The &lt;code&gt;TiffCollection&lt;/code&gt; manages spatial indexing across multiple files. This was the most &amp;ldquo;engineering&amp;rdquo; change: straightforward but essential.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Half-edge topology operations&lt;/strong&gt;: Safe edge chaining, twin linking, subdivision. Error handling for topology invariant violations. This is the foundation that makes Barbapapa possible, and the part I&amp;rsquo;m most proud of. Getting the DCEL right was hard, but once it worked, everything built on top of it was clean.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Pipeline system&lt;/strong&gt;: Composable raster and vector processing steps with metrics. Named pipelines for one-command configuration. Born out of flag fatigue.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Adaptive parameters&lt;/strong&gt;: Everything scales with &lt;code&gt;sourcePixelSize&lt;/code&gt; instead of zoom level. Swap the dataset and the system adapts. The kind of design decision that pays for itself the first time someone asks &amp;ldquo;can we try a different source?&amp;rdquo;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The core tile serving path (HTTP handler, TIFF sampling, face merging, MVT encoding) stayed the same. The new code wraps around it rather than replacing it. I like that.&lt;/p&gt;
&lt;h2 id="where-it-stands"&gt;Where it stands&lt;/h2&gt;
&lt;p&gt;Freddie serves 10m land cover data from z0 to z16. Low zoom tiles are fast thanks to COG overviews. High zoom tiles look smooth thanks to Barbapapa and optional SDF filtering. The pipeline system makes it easy to experiment with different filter combinations without touching server code.&lt;/p&gt;
&lt;p&gt;The 10m dataset covers the full globe at a resolution where you can see individual fields and city blocks. At 100m everything was green-or-gray blobs. At 10m you can tell a park from a garden.&lt;/p&gt;</description></item><item><title>Building a maps SDK from a Mapbox GL fork</title><link>https://man-you.ringum.net/posts/maps-v2/</link><pubDate>Sun, 08 Feb 2026 14:00:00 +0200</pubDate><guid>https://man-you.ringum.net/posts/maps-v2/</guid><description>&lt;p&gt;Rewriting the Woosmap Maps SDK on top of a Mapbox GL JS fork, with a Google Maps-compatible API surface, built with Bun, and an experiment in offscreen rendering.&lt;/p&gt;
&lt;h2 id="why"&gt;Why&lt;/h2&gt;
&lt;p&gt;The Woosmap Maps SDK depended on Mapbox GL JS. Then Mapbox changed the license. Version 2.0 moved to the Business Source License, not open source anymore. The last truly open version was 1.13.1 (December 2020). No more upstream bug fixes, no WebGL2 improvements, no new features. We were stuck on a frozen codebase.&lt;/p&gt;
&lt;p&gt;Staying on 1.13 forever wasn&amp;rsquo;t viable. Browser APIs move, WebGL evolves, security patches stop. Switching to Mapbox 2.x meant accepting the BSL and its usage restrictions. MapLibre forked from the same 1.13 base, but taking a dependency on another project&amp;rsquo;s roadmap puts you in the same position, just with different maintainers.&lt;/p&gt;
&lt;p&gt;So we forked 1.13.1 ourselves, ported the whole thing to TypeScript, and built a Google Maps-compatible API wrapper on top. Our customers use the Google Maps API surface: &lt;code&gt;new woosmap.map.Map()&lt;/code&gt;, &lt;code&gt;woosmap.map.Marker&lt;/code&gt;, &lt;code&gt;woosmap.map.event.addListener&lt;/code&gt;. Switching to a Mapbox-style API would break every integration. One codebase, two API surfaces: Mapbox GL rendering engine underneath, Google Maps API on top.&lt;/p&gt;
&lt;h2 id="architecture"&gt;Architecture&lt;/h2&gt;
&lt;p&gt;Two layers, cleanly separated.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mapbox GL fork&lt;/strong&gt; (&lt;code&gt;src/mapbox-gl/&lt;/code&gt;): the rendering engine. WebGL2 context management, vector tile decoding, style evaluation, symbol placement with collision detection, gesture handling. 57 GLSL shaders, vertex and fragment pairs for fill, line, symbol, heatmap, hillshade, raster, extrusion. The full Mapbox style spec expression engine: &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;has&lt;/code&gt;, &lt;code&gt;match&lt;/code&gt;, &lt;code&gt;case&lt;/code&gt;, &lt;code&gt;interpolate&lt;/code&gt;, &lt;code&gt;step&lt;/code&gt;, &lt;code&gt;let/var&lt;/code&gt;, &lt;code&gt;coalesce&lt;/code&gt;, arithmetic, string ops, comparisons.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Woosmap layer&lt;/strong&gt; (&lt;code&gt;src/woosmap/&lt;/code&gt;): the Google Maps compatibility wrapper. &lt;code&gt;Map&lt;/code&gt;, &lt;code&gt;Marker&lt;/code&gt;, &lt;code&gt;InfoWindow&lt;/code&gt;, &lt;code&gt;OverlayView&lt;/code&gt;, &lt;code&gt;Data&lt;/code&gt; layer, pane management, gesture handling overlay. Plus service clients: stores, distance matrix, localities autocomplete, datasets, transit, directions.&lt;/p&gt;
&lt;p&gt;The Woosmap &lt;code&gt;Map&lt;/code&gt; class wraps the Mapbox &lt;code&gt;Map&lt;/code&gt;:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;src/woosmap/map.ts&lt;/figcaption&gt;
&lt;div class="highlight" title="src/woosmap/map.ts"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kr"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;MapBoxMap&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kr"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;../mapbox-gl/ui/map&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;class&lt;/span&gt; &lt;span class="nx"&gt;Map&lt;/span&gt; &lt;span class="kr"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;MVCObject&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;_mapboxMap&lt;/span&gt;: &lt;span class="kt"&gt;MapBoxMap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;element&lt;/span&gt;: &lt;span class="kt"&gt;HTMLElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;: &lt;span class="kt"&gt;MapOptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Create the Mapbox map internally
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_mapboxMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;MapBoxMap&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;container&lt;/span&gt;: &lt;span class="kt"&gt;element&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Wire up Google Maps-style events, panes, controls
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;Properties fire change events automatically through &lt;code&gt;MVCObject&lt;/code&gt;. Set &lt;code&gt;marker.position&lt;/code&gt; and &lt;code&gt;position_changed&lt;/code&gt; fires. Same pattern Google Maps uses.&lt;/p&gt;
&lt;h2 id="the-style-system"&gt;The style system&lt;/h2&gt;
&lt;p&gt;Woosmap customers use Google Maps-style JSON rules to customize maps. Hue shifts, saturation, lightness, gamma correction, invert, applied hierarchically by feature type.&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;src/woosmap/map-style/map-style.ts&lt;/figcaption&gt;
&lt;div class="highlight" title="src/woosmap/map-style/map-style.ts"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;MapStyler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;color?&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// hex color
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;hue?&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// extract hue, keep lightness/saturation
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;saturation?&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// shift saturation
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;lightness?&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// shift lightness
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;gamma?&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// gamma correction on lightness
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;invert_lightness?&lt;/span&gt;: &lt;span class="kt"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;visibility?&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// &amp;#34;on&amp;#34; | &amp;#34;off&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;weight?&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// line/label weight
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;A &lt;code&gt;LayerRegistry&lt;/code&gt; maps Google&amp;rsquo;s feature type hierarchy (&lt;code&gt;road.highway&lt;/code&gt;, &lt;code&gt;poi.park&lt;/code&gt;, &lt;code&gt;water&lt;/code&gt;) to Mapbox GL layer IDs. Style rules walk the tree, match layers, apply HSL transforms. The result: customers keep their existing style configurations, the renderer is completely different underneath.&lt;/p&gt;
&lt;p&gt;Styles load dynamically from the Woosmap API via &lt;code&gt;StyleFetcher&lt;/code&gt;, not baked into the bundle. Change your style server-side, maps update without redeployment.&lt;/p&gt;
&lt;h2 id="three-bundles"&gt;Three bundles&lt;/h2&gt;
&lt;p&gt;Not every integration needs a full map. Some just need geocoding or store search. Building one monolithic bundle wastes bandwidth.&lt;/p&gt;
&lt;p&gt;Bun builds three separate bundles from three entry points:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;scripts/builder.ts&lt;/figcaption&gt;
&lt;div class="highlight" title="scripts/builder.ts"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Full SDK: map + services + worker
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Bun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;build&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;entrypoints&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;./src/maps.ts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;src/worker.ts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;src/painter.ts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;splitting&lt;/span&gt;: &lt;span class="kt"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;minify&lt;/span&gt;: &lt;span class="kt"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sourcemap&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;linked&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;loader&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;.glsl&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;text&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Services only: API clients, no WebGL
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Bun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;build&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;entrypoints&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;src/services.ts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Localities: place autocomplete widget
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Bun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;build&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;entrypoints&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;src/localities.ts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;GLSL shaders load as text strings via Bun&amp;rsquo;s loader plugin. No webpack shader-loader, no build-time compilation. The shader source gets inlined in the bundle and compiled at runtime by WebGL.&lt;/p&gt;
&lt;p&gt;Environment configuration (&lt;code&gt;local&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt;, &lt;code&gt;production&lt;/code&gt;) injects API endpoints at build time through &lt;code&gt;define&lt;/code&gt;:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;retVal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sb"&gt;`Bun.env.API_BASE_URL`&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;API_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;retVal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sb"&gt;`Bun.env.ASSETS_BASE_URL`&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ASSETS_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;h2 id="worker-architecture"&gt;Worker architecture&lt;/h2&gt;
&lt;p&gt;Tile processing is expensive: protobuf decoding, geometry clipping, feature indexing. Doing it on the main thread blocks interactions.&lt;/p&gt;
&lt;p&gt;The SDK spins up web workers from a blob URL constructed at load time:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;src/maps.ts&lt;/figcaption&gt;
&lt;div class="highlight" title="src/maps.ts"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scriptURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;maps.js&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;worker.js&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sb"&gt;`import &amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;scriptURL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;&amp;#34;;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kr"&gt;type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;application/javascript&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;workerUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;Workers handle tile deserialization and geometry processing. The main thread handles rendering, events, and camera updates. Communication is message-based. Tiles arrive asynchronously, trigger geometry extraction, and the painter picks them up on the next frame.&lt;/p&gt;
&lt;h2 id="offscreen-rendering-experiment"&gt;Offscreen rendering experiment&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;maps-offscreen.ts&lt;/code&gt; entry point pushes further: move the entire WebGL renderer into a worker using &lt;code&gt;OffscreenCanvas&lt;/code&gt; + &lt;code&gt;comlink&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;OnscreenMap&lt;/code&gt; lives on the main thread: handles DOM events, gesture recognition, camera state. The &lt;code&gt;OffscreenMap&lt;/code&gt; lives in a worker: owns the WebGL context, runs the painter, processes tiles. Camera updates flow from main thread to worker, rendered frames appear on the transferred canvas.&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;src/woosmap/onscreen-map.ts&lt;/figcaption&gt;
&lt;div class="highlight" title="src/woosmap/onscreen-map.ts"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;class&lt;/span&gt; &lt;span class="nx"&gt;OnscreenMap&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;offscreenMap&lt;/span&gt;: &lt;span class="kt"&gt;Remote&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;OffscreenMap&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;handlers&lt;/span&gt;: &lt;span class="kt"&gt;HandlerManager&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;transform&lt;/span&gt;: &lt;span class="kt"&gt;Transform&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;element&lt;/span&gt;: &lt;span class="kt"&gt;HTMLElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options?&lt;/span&gt;: &lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;canvas&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offscreen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transferControlToOffscreen&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Transfer canvas to worker, all GL happens there
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The tricky part is camera update batching. Every pan/scroll event would trigger a worker message, overwhelming the channel. The solution: batch camera updates with a timer and &lt;code&gt;requestAnimationFrame&lt;/code&gt;, send at most one update per frame interval. Immediate sends for significant zoom changes, deferred sends for continuous panning.&lt;/p&gt;
&lt;p&gt;Still experimental. Safari&amp;rsquo;s &lt;code&gt;OffscreenCanvas&lt;/code&gt; support has quirks, and synchronizing the transform state between threads without jank requires careful debouncing.&lt;/p&gt;
&lt;h2 id="symbol-placement"&gt;Symbol placement&lt;/h2&gt;
&lt;p&gt;Text labels on maps are deceptively hard. The Mapbox GL fork handles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Glyph atlasing&lt;/strong&gt;: PBF glyph format, dynamically growing texture atlas (1024x1024 initial, doubles when full), SDF rendering for clean scaling at any size&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Collision detection&lt;/strong&gt;: grid-based spatial index, labels grouped by feature (icon + text placed together, all-or-nothing), sorted by layer priority and &lt;code&gt;symbol-sort-key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-tile continuity&lt;/strong&gt;: &lt;code&gt;CrossTileSymbolIndex&lt;/code&gt; maintains label placement across tile boundaries so labels don&amp;rsquo;t pop in and out when panning&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Variable anchor&lt;/strong&gt;: if a label&amp;rsquo;s primary anchor collides, try alternatives. Pre-compute screen positions for each variant, test in order&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CJK text&lt;/strong&gt;: local ideograph font families bypass the glyph server for Chinese, Japanese, Korean characters. Properly sized using browser font metrics&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The symbol size evaluation is complex. &lt;code&gt;text-size&lt;/code&gt; gets evaluated at five different zoom levels per feature for layout, collision boxes, line placement, and shader interpolation.&lt;/p&gt;
&lt;p&gt;One practical fix we added: a maximum label count per bucket to prevent repeated line labels from overflowing. Road names on long highways would generate hundreds of labels. Now they&amp;rsquo;re capped.&lt;/p&gt;
&lt;h2 id="services-integration"&gt;Services integration&lt;/h2&gt;
&lt;p&gt;The Woosmap layer isn&amp;rsquo;t just rendering. It includes API clients for the full Woosmap platform:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;StoresService&lt;/strong&gt;: store locator with search, autocomplete, radius queries&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DistanceService&lt;/strong&gt;: matrix routing, isochrones, multiple travel modes (driving, walking, cycling)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LocalitiesService&lt;/strong&gt;: place autocomplete supporting 70+ languages&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DatasetService&lt;/strong&gt;: query and overlay custom geospatial datasets&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TransitService&lt;/strong&gt;: public transportation routing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DirectionsService&lt;/strong&gt;: turn-by-turn directions with renderer&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each service handles its own error taxonomy (&lt;code&gt;INVALID_REQUEST&lt;/code&gt;, &lt;code&gt;REQUEST_DENIED&lt;/code&gt;, &lt;code&gt;OVER_QUERY_LIMIT&lt;/code&gt;, &lt;code&gt;MAX_ELEMENTS_EXCEEDED&lt;/code&gt;) and throws typed errors (&lt;code&gt;BadRequestError&lt;/code&gt;, &lt;code&gt;ForbiddenError&lt;/code&gt;, &lt;code&gt;TooManyRequestsError&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Overlays (&lt;code&gt;StoresOverlay&lt;/code&gt;, &lt;code&gt;DatasetsOverlay&lt;/code&gt;, &lt;code&gt;DirectionsRenderer&lt;/code&gt;) connect service responses directly to map layers. Fetch stores, get markers. Compute a route, get a polyline.&lt;/p&gt;
&lt;h2 id="testing"&gt;Testing&lt;/h2&gt;
&lt;p&gt;97 test files. Vitest with Playwright for browser testing. The WebGL context needs a real browser, not jsdom.&lt;/p&gt;
&lt;p&gt;Handler tests cover the full gesture matrix: mouse drag pan, scroll zoom, keyboard navigation, touch zoom/rotate, double-click zoom, box zoom. Each handler type has its own test file, testing event sequences and resulting transform states.&lt;/p&gt;
&lt;p&gt;Style-spec tests validate the expression engine: filter compilation, color space conversions (RGB, HSL, LAB), interpolation, type coercion. These are ported from the Mapbox GL test suite and adapted for the TypeScript rewrite.&lt;/p&gt;
&lt;p&gt;Visual regression tests capture screenshots and diff against baselines. Useful for catching rendering changes across the 57 shaders.&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;bun run test:browser &lt;span class="c1"&gt;# Vitest + Playwright (Chromium)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;npx biome check src &lt;span class="c1"&gt;# Lint&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;h2 id="whats-there"&gt;What&amp;rsquo;s there&lt;/h2&gt;
&lt;p&gt;The basics are solid. Fill, line, symbol, raster, background, heatmap, hillshade, fill-extrusion layers all render. The full style expression engine works. Collision detection, variable anchor, text wrapping, cross-tile symbol continuity. Markers with labels, info windows, data layer with per-feature styling. All five service clients.&lt;/p&gt;
&lt;p&gt;536 TypeScript files, 57 GLSL shaders. The Google Maps API compatibility layer means existing Woosmap integrations can switch renderers without code changes.&lt;/p&gt;
&lt;p&gt;What&amp;rsquo;s still rough:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Offscreen rendering&lt;/strong&gt; works but the camera synchronization needs more tuning for smooth panning on slower devices&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Complex text shaping&lt;/strong&gt; is 1:1 codepoint-to-glyph. Arabic, Thai, and other complex scripts need a proper shaper&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bundle size&lt;/strong&gt; could be smaller with better tree-shaking of the Mapbox GL fork&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Line rendering&lt;/strong&gt; has no dash patterns or gradients yet&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;About 5MB of TypeScript source. The output bundles are significantly smaller after minification and tree-shaking.&lt;/p&gt;</description></item><item><title>A map style compiler in Python</title><link>https://man-you.ringum.net/posts/map-style-compiler/</link><pubDate>Sat, 07 Feb 2026 16:00:00 +0200</pubDate><guid>https://man-you.ringum.net/posts/map-style-compiler/</guid><description>&lt;p&gt;&lt;figure&gt;
&lt;img src="https://man-you.ringum.net/posts/map-style-compiler/elzar.svg" alt="Elzar" /&gt;
&lt;figcaption&gt;BAM!&lt;/figcaption&gt;
&lt;/figure&gt;&lt;/p&gt;
&lt;p&gt;Generating Mapbox GL GL style JSON from Python code instead of editing 3,000-line JSON files by hand. Hierarchical style resolution, a Django-style filter DSL, injection-based layer ordering, and a Go sprite generator called from Python via ctypes.&lt;/p&gt;
&lt;h2 id="the-problem-with-style-json"&gt;The problem with style JSON&lt;/h2&gt;
&lt;p&gt;A Mapbox GL GL style is a single JSON file that describes everything about how a map looks. Background color, road widths at every zoom level, label fonts, icon placement, landcover tints, building extrusion heights. All of it.&lt;/p&gt;
&lt;p&gt;A real production style runs to thousands of lines. Ours had over 150 layers. Roads alone need six layer variants each (tunnel casing, tunnel fill, road casing, road fill, bridge casing, bridge fill), and there are eight road classes. That&amp;rsquo;s 48 road layers before you count labels, oneway arrows, or rail.&lt;/p&gt;
&lt;p&gt;Editing this by hand is slow and error-prone. Change the motorway color and you need to find it in six places. Add a new road class and you need to insert layers at exactly the right positions between tunnels and bridges. Reorder a layer and you break z-ordering for everything above it.&lt;/p&gt;
&lt;h2 id="python-instead-of-json"&gt;Python instead of JSON&lt;/h2&gt;
&lt;p&gt;The style compiler replaces hand-edited JSON with Python code. You define layers, colors, filters, and style properties in Python. The compiler resolves everything and emits a Mapbox GL GL style JSON file.&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python -m map_style --style&lt;span class="o"&gt;=&lt;/span&gt;streets -o style/streets_classic.json&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The output is a standard Mapbox GL GL style: the renderer doesn&amp;rsquo;t know or care that it was generated. But the source is modular, typed, and DRY.&lt;/p&gt;
&lt;h2 id="layer-definitions"&gt;Layer definitions&lt;/h2&gt;
&lt;p&gt;A road is defined once. &lt;code&gt;ClassicRoadDataNode&lt;/code&gt; generates all six variants:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;streets_classic/roads.py&lt;/figcaption&gt;
&lt;div class="highlight" title="streets_classic/roads.py"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;ClassicRoadDataNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;motorway&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;classes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;motorway&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;feature_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;road.highway.motorway&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;extra_filter&lt;/span&gt;&lt;span class="o"&gt;=~&lt;/span&gt;&lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ramp__eq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;casing_feature_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;road.highway.motorway&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;This produces:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;tunnel_motorway_casing&lt;/code&gt;: tunnel stroke&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tunnel_motorway&lt;/code&gt;: tunnel fill&lt;/li&gt;
&lt;li&gt;&lt;code&gt;road_motorway_casing&lt;/code&gt;: road stroke&lt;/li&gt;
&lt;li&gt;&lt;code&gt;road_motorway&lt;/code&gt;: road fill&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bridge_motorway_casing&lt;/code&gt;: bridge stroke&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bridge_motorway&lt;/code&gt;: bridge fill&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each variant gets the correct brunnel filter automatically. Tunnel layers filter on &lt;code&gt;brunnel == &amp;quot;tunnel&amp;quot;&lt;/code&gt;, bridge on &lt;code&gt;brunnel == &amp;quot;bridge&amp;quot;&lt;/code&gt;, normal roads on &lt;code&gt;brunnel not in [&amp;quot;bridge&amp;quot;, &amp;quot;tunnel&amp;quot;]&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Rail works the same way: &lt;code&gt;ClassicRailDataNode&lt;/code&gt; generates tunnel, road, and bridge variants with optional hatching layers for the cross-ties pattern.&lt;/p&gt;
&lt;h2 id="injection-points"&gt;Injection points&lt;/h2&gt;
&lt;p&gt;With 150+ layers, ordering is everything. Roads must render above landcover. Bridges must render above buildings. Labels above everything. Getting one layer out of place breaks the visual hierarchy.&lt;/p&gt;
&lt;p&gt;Instead of maintaining an explicit ordered list, the system uses injection points: named slots that define the rendering order:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;generator/data.py&lt;/figcaption&gt;
&lt;div class="highlight" title="generator/data.py"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InjectionsNames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;TUNNEL_ROAD_CASING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;tunnel_road_casing&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;TUNNEL_ROAD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;tunnel_road&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;ROAD_AREA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;road_area&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;ROAD_CASING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;road_casing&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;ROAD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;road&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;BUILDING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;_building_injection&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;BRIDGE_ROAD_CASING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;bridge_road_casing&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;BRIDGE_ROAD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;bridge_road&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;BUILDING_3D&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;building_3d&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;LABEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;labels&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;POI_SYMBOL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;poi_symbol&amp;#34;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;Layers insert themselves before a named injection point:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;stack&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_layer_before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InjectionsNames&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ROAD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Layer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;road_motorway&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;stack&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_layer_before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InjectionsNames&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BRIDGE_ROAD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Layer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;bridge_motorway&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The &lt;code&gt;Stack&lt;/code&gt; maintains the order. Injection points themselves are stripped from the final output. They&amp;rsquo;re scaffolding, not layers. Adding a new road class doesn&amp;rsquo;t require knowing the index of every other layer. You just say &amp;ldquo;this goes in the ROAD group&amp;rdquo; and the system handles placement.&lt;/p&gt;
&lt;h2 id="filter-dsl"&gt;Filter DSL&lt;/h2&gt;
&lt;p&gt;Mapbox GL filters are nested JSON arrays. Writing them by hand is unreadable:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;all&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;==&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;get&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;class&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;motorway&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;!&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;in&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;get&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;brunnel&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;literal&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;bridge&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;tunnel&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]]]]]&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The compiler uses Django-style Q objects instead:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;class__eq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;motorway&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brunnel__in&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;bridge&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;tunnel&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;&lt;code&gt;&amp;amp;&lt;/code&gt; means AND, &lt;code&gt;|&lt;/code&gt; means OR, &lt;code&gt;~&lt;/code&gt; means NOT. The &lt;code&gt;ExpressionSet&lt;/code&gt; class compiles Q trees to Mapbox GL expressions:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;generator/filters.py&lt;/figcaption&gt;
&lt;div class="highlight" title="generator/filters.py"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;OPERATORS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;eq&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;==&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;neq&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;!=&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;gt&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;gte&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;gt;=&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;lt&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;lt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;lte&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;lt;=&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Q(class__in=[&amp;#34;motorway&amp;#34;]) becomes:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# [&amp;#34;in&amp;#34;, [&amp;#34;get&amp;#34;, &amp;#34;class&amp;#34;], [&amp;#34;literal&amp;#34;, [&amp;#34;motorway&amp;#34;]]]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Q(brunnel__exists=False) becomes:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# [&amp;#34;!&amp;#34;, [&amp;#34;has&amp;#34;, &amp;#34;brunnel&amp;#34;]]&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;Filters compose naturally. The ramp filter for motorway links:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;class__in&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;motorway&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ramp__eq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The non-ramp filter for main motorways:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;class__in&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;motorway&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ramp__eq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The &lt;code&gt;Case&lt;/code&gt; class builds conditional expressions for layers that vary by feature class, like different icon colors per POI type:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;restaurant&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;class__eq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;restaurant&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;hospital&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;class__eq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;hospital&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;case&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;restaurant&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;#e74c3c&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;hospital&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;#3498db&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;#666&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# [&amp;#34;case&amp;#34;, [&amp;#34;==&amp;#34;, [&amp;#34;get&amp;#34;, &amp;#34;class&amp;#34;], &amp;#34;restaurant&amp;#34;], &amp;#34;#e74c3c&amp;#34;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# [&amp;#34;==&amp;#34;, [&amp;#34;get&amp;#34;, &amp;#34;class&amp;#34;], &amp;#34;hospital&amp;#34;], &amp;#34;#3498db&amp;#34;, &amp;#34;#666&amp;#34;]&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;h2 id="hierarchical-style-resolution"&gt;Hierarchical style resolution&lt;/h2&gt;
&lt;p&gt;A &lt;code&gt;StyleTree&lt;/code&gt; organizes paint and layout properties in a dot-separated hierarchy. Child selectors inherit from parents:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;streets_classic/style.py&lt;/figcaption&gt;
&lt;div class="highlight" title="streets_classic/style.py"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;styles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;road&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;StyleElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;LinePaint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line_cap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;round&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;road.highway&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;StyleElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;LinePaint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line_color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ROAD_HIGHWAY_FILL_COLOR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line_width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;road.highway.motorway&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;StyleElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;LinePaint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line_width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;Resolving &lt;code&gt;road.highway.motorway&lt;/code&gt; walks the tree from root to leaf, collecting all &lt;code&gt;StyleElement&lt;/code&gt; instances along the path. Properties merge: the motorway gets &lt;code&gt;line_cap=&amp;quot;round&amp;quot;&lt;/code&gt; from &lt;code&gt;road&lt;/code&gt;, &lt;code&gt;line_color=&amp;quot;#fc8&amp;quot;&lt;/code&gt; from &lt;code&gt;road.highway&lt;/code&gt;, and &lt;code&gt;line_width=6&lt;/code&gt; from its own node.&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;generator/styler.py&lt;/figcaption&gt;
&lt;div class="highlight" title="generator/styler.py"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;selector_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;StyleElement&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;nodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_node_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;selector_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;elements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;StyleElement&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;elements&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;elements&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;elements&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;Same pattern as CSS cascade, but explicit. You see exactly what inherits from where by reading the selector paths. No specificity wars.&lt;/p&gt;
&lt;h2 id="colors"&gt;Colors&lt;/h2&gt;
&lt;p&gt;The color module parses hex, RGB, RGBA, HSL, and HTML color names. More usefully, it does HSL-based transformations:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;generator/color.py&lt;/figcaption&gt;
&lt;div class="highlight" title="generator/color.py"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;#fc8&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;darken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to_rgba&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Shifts lightness down by 30%, used for deriving label colors from fill colors&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;All colors live in one file per style:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;streets_classic/colors.py&lt;/figcaption&gt;
&lt;div class="highlight" title="streets_classic/colors.py"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;BACKGROUND_COLOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;rgba(252, 247, 229, 1)&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;WATER_COLOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;rgba(134, 204, 250, 1)&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;ROAD_HIGHWAY_FILL_COLOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;#fc8&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;ROAD_HIGHWAY_STROKE_COLOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;#e9ac77&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;ROAD_MINOR_FILL_COLOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;#fff&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;ROAD_MINOR_STROKE_COLOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;hsl(35, 6%, 80%)&amp;#34;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;Change a color constant, regenerate, and every layer using it updates. No grep through JSON.&lt;/p&gt;
&lt;h2 id="pydantic-models-for-the-style-spec"&gt;Pydantic models for the style spec&lt;/h2&gt;
&lt;p&gt;Every Mapbox GL layer type has a corresponding Pydantic v2 model with strict validation:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;generator/mapbox_style.py&lt;/figcaption&gt;
&lt;div class="highlight" title="generator/mapbox_style.py"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LinePaint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;model_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ConfigDict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;populate_by_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;line_color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;line-color&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;line_width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;line-width&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;line_opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;line-opacity&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;line_dasharray&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;line-dasharray&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;line_cap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;line-cap&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# ...&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The &lt;code&gt;alias&lt;/code&gt; parameter handles the mismatch between Python identifiers (underscores) and Mapbox GL property names (hyphens). &lt;code&gt;line_color&lt;/code&gt; in Python becomes &lt;code&gt;line-color&lt;/code&gt; in JSON output.&lt;/p&gt;
&lt;p&gt;Pydantic catches type errors at definition time, not when the renderer fails to parse the JSON. Pass a string where a number is expected and you get an immediate error.&lt;/p&gt;
&lt;h2 id="sprite-generation"&gt;Sprite generation&lt;/h2&gt;
&lt;p&gt;Map icons (POI markers, highway shields, oneway arrows) are packed into a sprite sheet: a single PNG with a JSON manifest mapping icon names to pixel regions.&lt;/p&gt;
&lt;p&gt;The sprite generator is written in Go for performance. Python calls it through ctypes:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;figcaption&gt;generator/sprite.py&lt;/figcaption&gt;
&lt;div class="highlight" title="generator/sprite.py"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;lib&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CDLL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lib_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;lib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GenerateSprite&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argtypes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c_char_p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c_char_p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c_int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;lib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GenerateSprite&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;restype&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c_char_p&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GenerateSprite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;icon_library_path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;pixel_ratio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# 1 for sprite.png, 2 for sprite@2x.png&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The Go library handles SVG rasterization, SDF (signed distance field) generation for runtime-colorable icons, bin-packing into a texture atlas, and both 1x and 2x output.&lt;/p&gt;
&lt;p&gt;Template-based icon names resolve from filter context. When a layer uses &lt;code&gt;icon-image: &amp;quot;{class}_15&amp;quot;&lt;/code&gt;, the compiler extracts which &lt;code&gt;class&lt;/code&gt; values the filter allows and registers each concrete icon (&lt;code&gt;restaurant_15&lt;/code&gt;, &lt;code&gt;hospital_15&lt;/code&gt;, etc.) for sprite generation.&lt;/p&gt;
&lt;h2 id="poi-metadata"&gt;POI metadata&lt;/h2&gt;
&lt;p&gt;POI layers carry metadata beyond what Mapbox GL needs. The Woosmap API uses it to support dynamic styling at runtime:&lt;/p&gt;
&lt;figure class="code-block-figure"&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;POIMetadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;symbol_color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;text_color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;This metadata embeds in the layer&amp;rsquo;s &lt;code&gt;metadata&lt;/code&gt; field (which Mapbox GL ignores but passes through). The API can query which POI categories exist, their colors, and their visibility, without parsing the style expressions.&lt;/p&gt;
&lt;p&gt;Feature types follow a hierarchy: &lt;code&gt;poi.school&lt;/code&gt;, &lt;code&gt;poi.sports_complex&lt;/code&gt;, &lt;code&gt;transit.station.rail&lt;/code&gt;. The &lt;code&gt;LayerRegistry&lt;/code&gt; maps these to the concrete Mapbox GL layers they affect, enabling Google Maps-style feature type styling through the Woosmap SDK.&lt;/p&gt;
&lt;h2 id="multiple-styles"&gt;Multiple styles&lt;/h2&gt;
&lt;p&gt;The architecture supports multiple style variants from the same infrastructure. &lt;code&gt;streets_classic&lt;/code&gt; is the production style. &lt;code&gt;streets_satellite&lt;/code&gt; reuses the same layer definitions, road nodes, and filter DSL with a different color palette tuned for satellite imagery overlay: higher contrast labels, semi-transparent fills, no background color.&lt;/p&gt;
&lt;p&gt;Adding a new style variant means writing a new color file and adjusting a few layer properties. The layer structure, injection points, and filter expressions stay the same.&lt;/p&gt;
&lt;h2 id="what-this-gets-you"&gt;What this gets you&lt;/h2&gt;
&lt;p&gt;The streets_classic style has ~150 renderable layers. The Python source is organized across about a dozen files: colors, roads, mapping, style tree, POI definitions. Each file is under 300 lines.&lt;/p&gt;
&lt;p&gt;The equivalent hand-maintained JSON was one file, thousands of lines, with duplicated filter expressions and no inheritance. Every road color change required finding and editing six layer definitions. Every new road class required inserting layers at the correct positions relative to all other layers.&lt;/p&gt;
&lt;p&gt;Now it&amp;rsquo;s: change a constant in &lt;code&gt;colors.py&lt;/code&gt;, run the compiler, get a valid style JSON. Type-checked, with the layer ordering handled by injection points instead of manual index management.&lt;/p&gt;</description></item></channel></rss>