The city of Leipzig publishes a thermal-burden map: PET index (Physiological Equivalent Temperature) at 2 p.m., rendered as a colored heatmap over the whole city. For the official Leipzig App, which we build at DroidSolutions on our KULDIG platform, the city wanted this map inside the app, overlaid with cooling-relevant POIs like drinking fountains, public pools and air-conditioned libraries. The feature is called Erfrischungskarte, and the data source is a plain OGC WMS on geo.leipzig.de.
That sounds simple, and it isn’t. The app’s map is MapLibre GL with vector tiles, and MapLibre renders raster overlays just fine. The catch sits in one line of the WMS GetCapabilities response: the service offers EPSG:4326 and EPSG:25833, and nothing else. MapLibre’s raster sources assume Web Mercator — you either give them XYZ tiles or a WMS URL with the {bbox-epsg-3857} template. EPSG:3857 is exactly the one projection this server does not speak.
The wrong architecture first
Our first answer was a second map. flutter_map ships a WMSTileLayerOptions with a CRS driver for EPSG:4326, so we swapped the whole MapLibre widget for a flutter_map widget whenever the user activated the Erfrischungskarte, and handed the camera position back and forth between the two.
This worked, eventually. The “eventually” deserves some detail.
flutter_map’s WMS layer computes the request bbox from the map’s internal tile grid. Our map ran EPSG:3857 internally while the WMS layer was declared as EPSG:4326, and the reverse transformation put the bbox thousands of kilometers away from Leipzig. The server answered politely with valid, empty tiles. There was no error anywhere, just an invisible heatmap. We ended up writing a custom TileProvider that derives the EPSG:4326 bbox directly from the slippy-map tile coordinates, with the latitude and longitude axes swapped, because WMS 1.3.0 wants lat,lon order for EPSG:4326.
The next bug was Dart’s fault. Uri.replace(queryParameters:) drops the = for empty string values, so STYLES= became STYLES. The Leipzig WMS rejects that with a StylesNotDefined exception and returns a 424-byte XML error page for every tile. On our side the symptom was “Invalid image data” from the PNG decoder — two layers below the actual cause. We now build the query string by hand.
And since the heatmap paints over the streets, we stacked a third tile layer with Carto’s transparent label tiles on top so street names stayed readable. That brought its own attribution obligations.
At some point we even switched to the internal ArcGIS REST endpoint the official city web viewer uses, because it accepts EPSG:3857 natively. Then we switched back. The REST service is an implementation detail of someone else’s viewer; the WMS is the documented, contract-bound interface. Building on the convenient endpoint instead of the stable one is the kind of decision that feels smart for exactly one deployment cycle.
This version never made it into a production release — it only ran internally, because the architecture was wrong and we knew it. Two map widgets, a SharedMapCameraState class whose only job was to smuggle the camera between them, zoom buttons with two code paths, and a raster basemap that never quite matched our vector style. Every future map feature would have had to be built twice.
The proxy
The fix came in the second iteration: keep MapLibre as the only map, and put a small HTTP proxy between MapLibre and the WMS. A Dart HttpServer binds to 127.0.0.1 on an OS-assigned port for the lifetime of the map screen. MapLibre gets an ordinary raster source:
http://127.0.0.1:{port}/wms/{z}/{x}/{y}.png
MapLibre requests slippy-map tiles in its native scheme and neither knows nor cares what happens behind the port. The proxy does what our custom TileProvider already did: convert z/x/y to an EPSG:4326 bbox, swap the axis order, build the WMS 1.3.0 GetMap URL by hand (the STYLES= lesson was already paid for), fetch the tile, return the PNG. All the projection weirdness lives in one file, behind an interface every map client understands.
Two things make this more than a URL rewriter.
The first is caching. The proxy keeps the raw upstream bytes in memory, keyed by the upstream URL. MapLibre’s own tile cache sits in front of that, so the WMS gets hit once per tile and everything after that stays local.
The other is the part I like: the proxy enables a feature that neither MapLibre nor the WMS offers. Users can filter the heatmap by PET band, for example show only the “very hot” zones and hide the comfortable ones. The WMS has no parameter for that; it always renders all bands. So the proxy filters pixels instead. Each tile pixel is classified against the four known band colors, pixels of deselected bands are set to transparent, and the tile is re-encoded as PNG. When the user toggles a band, the app swaps the raster source with a fresh ?b= cache-buster in the URL. MapLibre drops its cached tiles, the proxy ignores the parameter, refilters from the cached raw bytes and never touches the network again. A band toggle re-renders the visible map in well under a second, from RAM.
The whole proxy is about 180 lines including comments. The heatmap now renders as a raster layer inside the vector map, anchored below the POI symbol layer so markers stay on top and tappable. The second map widget is deleted, the camera-state handoff is deleted, and the Carto label overlay is deleted too — the vector style has its own labels, and they render above the heatmap where they belong.
The pattern
The general shape is worth keeping. When a rendering library and a data service disagree on protocol, you don’t have to replace either of them. A loopback HTTP server is cheap in any runtime that can open a socket, and “ordinary raster tile server” is the lingua franca every map SDK accepts. The adapter costs a few kilobytes of code and gives you a place for caching and pixel-level transformations that neither side of the fence would let you do.
I’ve written before about bending map infrastructure until it does what users actually need. This is the same category of work: the elegant solution was not the first one we shipped.