[{"data":1,"prerenderedAt":555},["ShallowReactive",2],{"page:\u002Fposts\u002Fcode_n_shit\u002Fcuando_vector":3,"translations:\u002Fposts\u002Fcode_n_shit\u002Fcuando_vector":531,"series:en:cuando":536},{"id":4,"title":5,"author":6,"body":7,"date":516,"description":517,"draft":518,"extension":519,"image":520,"listed":521,"meta":522,"navigation":521,"path":523,"seo":524,"series":525,"stem":526,"tags":527,"theme":525,"toc":521,"__hash__":530},"content_en\u002Fposts\u002Fcode_n_shit\u002Fcuando_vector.md","Fifty-eight megabytes","slnt_opp",{"type":8,"value":9,"toc":504},"minimark",[10,14,22,27,30,33,48,51,54,57,79,83,87,90,113,116,119,123,135,138,145,162,169,172,175,198,201,205,208,216,219,222,225,232,236,239,242,253,256,259,263,280,291,294,305,311,314,317,344,348,351,450,456,459,463,466,477,480,484],[11,12,13],"p",{},"Part two ended with a joke. The server that draws our map as pictures reads a perfectly good vector map of the region, and the only reason it exists is that our phones couldn't draw that vector map fast enough.",[11,15,16,17,21],{},"Eighteen months later, the joke is mostly over. As of this week, one style per theme, served by our API, and one archive of ",[18,19,20],"strong",{},"58.67 MB"," draw the map in our admin console and, on Android, in our new partner app. The Flutter app that's in the stores today still shows pictures, and it'll keep doing that until it's replaced. This is how we got here, and why vector wins.",[23,24,26],"h2",{"id":25},"what-pictures-cost","What pictures cost",[11,28,29],{},"The pictures worked. That's worth saying first: from February 2025 on, every map in the app except satellite was drawn by us, and nobody noticed.",[11,31,32],{},"What they cost was time. Part two promised we'd draw tiles ahead of time, so nobody waits three seconds for a street nobody has seen before. That arrived in two steps. The day after the switch we added a small script that walked the middle of town at zooms 17 and 18 and asked our own tile server for every tile, so they'd be drawn and stored before anyone looked. In April 2026 our Flutter developer turned it into a real prerender, with a pool of renderers working in parallel, and a couple of days later it started drawing straight from the vector tiles into files.",[11,34,35,36,39,40,43,44,47],{},"Then we ran it for real. Zooms 13 to 19 over the box the zoom grid covered until May come to ",[18,37,38],{},"944,382 tiles per theme",", and 94% of them are zooms 18 and 19. Our render VM, the one from part two, manages about four renders at a time at around three seconds each. That's about eight days per theme. So the prerender runs on my Mac mini instead, where one theme takes ",[18,41,42],{},"10 to 12 hours"," and comes out at about ",[18,45,46],{},"15 GB",". The VM now only draws what's missing.",[11,49,50],{},"The script's own estimate said about seven hours. It counted the tiles it skipped, because they were already on disk, as progress, and those skips all came in a burst at the start. So the estimate got worse the longer it ran.",[11,52,53],{},"Those 15 GB are only the storage bill. The real price shows up when you want to change something. In April we darkened the road names and gave them a halo, because they were hard to read. For a vector map that's a few lines in a style file. For pictures it means drawing the whole pyramid again, for both themes: a day of the Mac mini and about 30 GB to upload.",[11,55,56],{},"And some things pictures simply can't do:",[58,59,60,67,73],"ul",{},[61,62,63,66],"li",{},[18,64,65],{},"The labels are baked in."," Every street name is part of the image, in one language, so a reader with the app in Russian sees exactly the same labels as one with it in English.",[61,68,69,72],{},[18,70,71],{},"The labels can't stay upright."," Turn the map and the street names turn with it, upside down if you keep going.",[61,74,75,78],{},[18,76,77],{},"A new region is another pyramid."," The app is growing beyond Torrevieja, and every town we add means rendering and storing its pictures, in every theme.",[80,81],"gallery",{":images":82},"[{\"src\":\"\u002Fimages\u002Fcuando_vector\u002Flabels-raster.jpg\",\"alt\":\"Pictures: the street names are part of the image, in capitals, in one language\"},{\"src\":\"\u002Fimages\u002Fcuando_vector\u002Flabels-vector.jpg\",\"alt\":\"Vector: the phone draws the names along the roads, and they stay readable when the map turns\"}]",[23,84,86],{"id":85},"vector-data-before-a-vector-renderer","Vector data, before a vector renderer",[11,88,89],{},"The first archive in our own vector Worker wasn't a map at all. It was our points of interest.",[11,91,92,93,100,101,106,107,112],{},"In April 2025 we set up a second Cloudflare Worker next to the tile server. It's a copy of ",[94,95,99],"a",{"href":96,"rel":97},"https:\u002F\u002Fprotomaps.com",[98],"nofollow","Protomaps","' Worker for ",[94,102,105],{"href":103,"rel":104},"https:\u002F\u002Fdocs.protomaps.com\u002Fpmtiles\u002F",[98],"PMTiles",", a format that packs a whole pyramid of map tiles, vector or raster, into one file. We gave it its own domain 27 minutes after the first commit. The first archive we put in it was our POIs, cut into vector tiles with ",[94,108,111],{"href":109,"rel":110},"https:\u002F\u002Fgithub.com\u002Ffelt\u002Ftippecanoe",[98],"tippecanoe",".",[11,114,115],{},"The Flutter app didn't draw vector tiles any more, but it could read them. Since mid-2025, whenever a picture tile arrives, the app fetches the POI tile for the same spot, decodes it in Dart and draws each point as a marker on top. Vector data, no vector renderer.",[11,117,118],{},"Point features are cheap, so the POI tiles go all the way down to zoom 22. And whether a POI shows up at a given zoom became a property of the POI itself: one number in the archive, with no pictures to draw again. In May 2026 tippecanoe moved into a Cloudflare Container that rebuilds the archive every night. It can't live in the Worker itself: tippecanoe is a native binary, and a Worker can't run one.",[23,120,122],{"id":121},"how-the-vector-side-works","How the vector side works",[11,124,125,126,131,132,134],{},"The basemap is one PMTiles file too. It's OpenStreetMap data for the region, cut with tilemaker in the ",[94,127,130],{"href":128,"rel":129},"https:\u002F\u002Fopenmaptiles.org",[98],"OpenMapTiles"," schema. It's the same data our picture server has been reading since its first tile. It weighs ",[18,133,20],{},", and it lives in R2 behind the same Worker as the POIs.",[11,136,137],{},"The Worker never loads that file. It reads the header and the top-level index in one 16 KB request for a byte range of the file, then fetches each tile the same way: one range, just that tile's bytes. The only thing it keeps in memory is up to 25 parsed index blocks. Tiles are cached at Cloudflare's edge for a day. So the Worker's memory has nothing to do with the size of the archive, and that's how Protomaps serves the whole planet from one file. For us, a bigger region is a bigger file in the same bucket, not a new architecture. Swapping the file is an upload: every read checks the file's ETag, so the Worker notices the new version by itself.",[11,139,140,141,144],{},"On top of the data sits a ",[18,142,143],{},"style",": a JSON file that says what to draw, in which colour, at which zoom. We adapted OpenMapTiles' Positron for light and Dark Matter for dark, for phones:",[58,146,147,150,153,156,159],{},[61,148,149],{},"district names stay on screen when you zoom in, instead of vanishing exactly when you need them;",[61,151,152],{},"every text size grows smoothly with zoom;",[61,154,155],{},"when labels collide, towns beat districts, districts beat streets, and bigger towns beat smaller ones;",[61,157,158],{},"street names run along the roads;",[61,160,161],{},"footpaths are a fine dotted line.",[11,163,164,165,168],{},"Each style is about ",[18,166,167],{},"22 KB",". That's the whole cost of a theme.",[11,170,171],{},"This week those styles moved into our API, and the phone changes above shipped with the move. Until now the console, the partner app and the consumer app's rewrite each bundled the same older copy, so any change had to be made in every one of them by hand. Now there's one maintained version, built into the API and served by it. The apps keep a bundled copy only as a seed, so a map can draw before the network answers. A breaking change gets a new path, so old app builds never receive a style they can't handle.",[11,173,174],{},"Two things we learned doing it:",[58,176,177,183],{},[61,178,179,182],{},[18,180,181],{},"Fonts come one at a time."," A style may list fallback fonts, and MapLibre asks for all of them in one request, with the names joined by commas. Our glyphs live in R2, as files, and there's no file with that combined name. So every label in our styles uses exactly one weight of one font, Metropolis.",[61,184,185,188,189,193,194,197],{},[18,186,187],{},"Credit where it's due."," The PMTiles file had no attribution in its metadata, so MapLibre credited the one party it knew about, itself. The map said ",[190,191,192],"em",{},"\"MapLibre Android\""," where it should have said ",[190,195,196],{},"\"© OpenStreetMap contributors\"",", and OpenStreetMap's licence requires the latter. The fix, also this week, is a small table in the Worker: the basemap credits OpenStreetMap, and our own POIs credit nobody. It was opened and merged within eight minutes, and it fixed the credit on every map that reads our vector tiles, in the console and on Android, without re-uploading a single file.",[11,199,200],{},"In July the basemap moved behind the tile Worker as well. The old address now redirects there, for anything that still has it baked into a style. The most loyal user of the old address is our own picture renderer: the styles it draws with still point there.",[23,202,204],{"id":203},"the-console-went-first","The console went first",[11,206,207],{},"Our admin console got its first maps in May 2026, and they were MapLibre from the first line. The reason is a comment at the top of the first map component:",[209,210,211],"blockquote",{},[11,212,213],{},[190,214,215],{},"\"Switched away from mapbox-gl deliberately — maplibre is API-compatible for everything we use, requires no access token, doesn't ping events.mapbox.com \u002F map-sessions for telemetry, and doesn't render the Mapbox logo.\"",[11,217,218],{},"It also got something our Flutter map doesn't have. Every bus line in the console carries little chevrons pointing the way the bus goes: MapLibre repeats a small image along the line as it draws it and turns it with every curve, so they always point forward. The Flutter version is written up and still waiting.",[11,220,221],{},"Two weeks later we deleted our old route editor, the last thing in the repository that used Mapbox's web library.",[11,223,224],{},"There's one bit of irony left. The console has a tab that shows the zoom grid, the heatmap from part two that stops the Flutter app from asking for pictures nobody needs, and previews a new one before it's published. That tab draws the grid on a vector map.",[11,226,227],{},[228,229],"img",{"alt":230,"src":231},"The console on our vector basemap: a test request's pin and two made-up providers' coverage over Alicante, with OpenStreetMap credited in the corner","\u002Fimages\u002Fcuando_vector\u002Fconsole-map.jpg",[23,233,235],{"id":234},"flutter-one-more-time","Flutter, one more time",[11,237,238],{},"We tried vector in Flutter twice. The first time, in November 2024, is in part two: it worked, and on Android it stuttered and drew itself in tile by tile.",[11,240,241],{},"The second try ended in March 2026. The vector package for flutter_map had a new major version in beta that renders through Flutter's GPU API instead of the ordinary canvas, which is exactly what a map full of paths needs. Our Flutter developer tried it, and on a mid-range Redmi the app crashed:",[243,244,250],"pre",{"className":245,"code":247,"language":248,"meta":249},[246],"language-text","MSAA is not currently supported on this backend.\n[ERROR:flutter\u002Fimpeller\u002Frenderer\u002Fbackend\u002Fgles\u002Fbuffer_bindings_gles.cc(409)] … Float uniform should have a float type.\n[FATAL:flutter\u002Fimpeller\u002Frenderer\u002Fbackend\u002Fgles\u002Frender_pass_gles.cc(726)] Check failed: result. Must be able to encode GL commands without error.\nFatal signal 6 (SIGABRT) … (1.raster)\n","text","",[251,252,247],"code",{"__ignoreMap":249},[11,254,255],{},"In plain English: Flutter's renderer on Android has two backends, a modern one on Vulkan and an older one on OpenGL ES, and on some phones it picks the older one. That phone was one of them. The OpenGL ES backend met a value in the map's shaders it didn't know how to pass to the GPU. It doesn't treat that as a glitch: failing to encode a GL command is fatal by design, so the engine aborts the whole app. And it doesn't try Vulkan instead.",[11,257,258],{},"Without the GPU path, on the previous beta, everything worked. Slowly. That version lives on a branch that was never merged, and the crash never made it into git at all: the branch is the fallback. In April the app got a newer flutter_map, still drawing pictures.",[23,260,262],{"id":261},"the-new-apps-three-map-engines-behind-one-view","The new apps: three map engines behind one view",[11,264,265,266,271,272,275,276,279],{},"In July we started a partner app for businesses, written in Swift and compiled for Android with ",[94,267,270],{"href":268,"rel":269},"https:\u002F\u002Fskip.tools",[98],"Skip",". Its first map was raster, and not by choice: Skip's SwiftUI had neither ",[251,273,274],{},"Map"," nor ",[251,277,278],{},"Canvas"," yet, so the map is our own picture tiles laid out as images. The comment on it reads:",[209,281,282],{},[11,283,284],{},[190,285,286,287,290],{},"\"Raster rather than vector because it needs no rendering engine at all: a tile is a PNG, ",[251,288,289],{},"AsyncImage"," can load it, and that works identically on both platforms with zero native dependencies.\"",[11,292,293],{},"The same evening it got MapKit on iOS, and the next morning MapLibre on Android, drawing the console's own vector style. The Android part needs some Kotlin, because Skip's hook for native Android views takes a Compose function, and those have no Swift spelling. Everything that decides what the map shows stays in Swift. All three sit behind one view:",[209,295,296],{},[11,297,298],{},[190,299,300,301,304],{},"\"MapKit on iOS, MapLibre on Android, raster slippy map as the floor beneath both. All three sit behind ",[251,302,303],{},"CuandoMapView",", so the choice is invisible above that line and a backend that fails costs a nicer map and nothing else.\"",[11,306,307,308],{},"Why MapKit and not MapLibre on iOS too? One renderer would mean one set of styling. But it would also mean giving up Apple's own map and everything iOS does with it. Our rule for the consumer app's rewrite, which is in progress: ",[190,309,310],{},"\"A novel bit of styling never outranks the map itself.\"",[80,312],{":images":313},"[{\"src\":\"\u002Fimages\u002Fcuando_vector\u002Fpartner-android.jpg\",\"alt\":\"Cuando Partner on Android: MapLibre drawing our vector style\"},{\"src\":\"\u002Fimages\u002Fcuando_vector\u002Fpartner-ios.jpg\",\"alt\":\"The same screen on iOS, on MapKit and Apple Maps\"}]",[11,315,316],{},"A map with a fallback beneath it has one special way of failing: silently. We managed three of them in two weeks.",[58,318,319,328,338],{},[61,320,321,324,325],{},[18,322,323],{},"A blank map that was reloading."," Swift rebuilds the bridge object to Kotlin every time the view is re-evaluated, so a \"have I already applied this style?\" flag on it was always fresh. The map applied its style about twice a second, and applying a style throws away everything loaded so far. So no tile ever arrived. It looked exactly like a broken tile server. The same blank map came back twice more in the consumer rewrite, from other causes, and the note we left ourselves there reads: ",[190,326,327],{},"\"A map that renders its background colour and no tiles is a map reloading its style.\"",[61,329,330,333,334,337],{},[18,331,332],{},"A vector map that was secretly raster."," When the consumer rewrite borrowed the map from the partner app, it kept looking up the Kotlin class under the partner app's package name. The lookup failed, the failure was swallowed, and Android quietly drew the raster floor. It took a day, and someone actually using the app, to notice: panning felt \"way too sensitive\", the map wouldn't rotate, and zoom went in steps. That's a picture map. The app has a developer screen that says which engine drew the map, and it had been saying ",[190,335,336],{},"\"Raster tiles — fallback\""," the whole time.",[61,339,340,343],{},[18,341,342],{},"Icons that drew nothing."," The styles take their icons from OpenMapTiles' sprite, which is hosted by someone else, and an icon missing from it draws as nothing at all. Our pins are now drawn on the device from Maki's SVG paths instead.",[23,345,347],{"id":346},"why-vector-wins","Why vector wins",[11,349,350],{},"Here's the comparison for our region, pictures against vector:",[352,353,354,369],"table",{},[355,356,357],"thead",{},[358,359,360,363,366],"tr",{},[361,362],"th",{},[361,364,365],{},"Pictures (raster)",[361,367,368],{},"Vector",[370,371,372,384,395,406,417,428,439],"tbody",{},[358,373,374,378,381],{},[375,376,377],"td",{},"What we produce",[375,379,380],{},"944,382 PNG tiles per theme, zooms 13 to 19",[375,382,383],{},"one archive for every theme",[358,385,386,389,392],{},[375,387,388],{},"Storage",[375,390,391],{},"about 15 GB per theme",[375,393,394],{},"58.67 MB in total, plus about 22 KB per theme",[358,396,397,400,403],{},[375,398,399],{},"Changing the look",[375,401,402],{},"10–12 hours per theme on a Mac mini, then upload",[375,404,405],{},"edit a JSON file",[358,407,408,411,414],{},[375,409,410],{},"Deeper than the data goes",[375,412,413],{},"four times the tiles per zoom level",[375,415,416],{},"the phone scales up what it has",[358,418,419,422,425],{},[375,420,421],{},"Serving a tile",[375,423,424],{},"Worker, storage, and on a miss a GL render on a VM",[375,426,427],{},"Worker, edge cache, a byte range from storage",[358,429,430,433,436],{},[375,431,432],{},"Labels",[375,434,435],{},"baked in, one language, turn with the map",[375,437,438],{},"drawn on the phone, follow the roads, stay upright",[358,440,441,444,447],{},[375,442,443],{},"Another town",[375,445,446],{},"another pyramid, in every theme",[375,448,449],{},"a bigger file in the same bucket",[11,451,452],{},[228,453],{"alt":454,"src":455},"One raster theme next to the whole vector region, with areas to scale","\u002Fimages\u002Fcuando_vector\u002Fsize.png",[11,457,458],{},"The numbers aren't even the main argument. With pictures, every decision about how the map looks is paid for in render hours, and every new region in gigabytes. With vector, both become edits. And there's nothing left on a server that renders anything.",[23,460,462],{"id":461},"the-last-of-mapbox","The last of Mapbox",[11,464,465],{},"Two small endings, both in the last two weeks.",[11,467,468,469,472,473,476],{},"On August 4 we finally dropped the MapboxMaps CocoaPod from the Flutter app. It arrived with the native SDK on August 20, 2024, and the SDK itself was gone five days later. The pod stayed for almost two years. Nothing imported it, but CocoaPods downloads it from Mapbox, and that download needs a secret token in ",[251,470,471],{},"~\u002F.netrc",". So ",[251,474,475],{},"pod install"," failed on every machine that hadn't been given one.",[11,478,479],{},"On August 11 satellite left the map picker. It was the last style still coming from Mapbox. Anyone who already had it selected keeps it for now.",[23,481,483],{"id":482},"whats-next","What's next",[58,485,486,492,498],{},[61,487,488,491],{},[18,489,490],{},"The consumer app on Skip."," When it ships, Android draws our vector map with MapLibre and iOS draws Apple's. The picture pyramid, the prerender and the render VM can retire with the Flutter app.",[61,493,494,497],{},[18,495,496],{},"Labels in your language."," With vector, the language of the labels is a setting in the style, one expression per label layer, not a re-render. Ours still say Latin script only, and that's the next thing to change.",[61,499,500,503],{},[18,501,502],{},"More region."," A bigger file in the same bucket.",{"title":249,"searchDepth":505,"depth":505,"links":506},2,[507,508,509,510,511,512,513,514,515],{"id":25,"depth":505,"text":26},{"id":85,"depth":505,"text":86},{"id":121,"depth":505,"text":122},{"id":203,"depth":505,"text":204},{"id":234,"depth":505,"text":235},{"id":261,"depth":505,"text":262},{"id":346,"depth":505,"text":347},{"id":461,"depth":505,"text":462},{"id":482,"depth":505,"text":483},"2026-08-15","Eighteen months of drawing our map as pictures, and why Cuando is moving to vector: one 58.67 MB file instead of 15 GB per theme, a Flutter renderer that crashed, and three map engines behind one view in the new apps.",false,"md","\u002Fimages\u002Fcuando_vector\u002Fcover.jpg",true,{},"\u002Fposts\u002Fcode_n_shit\u002Fcuando_vector",{"title":5,"description":517},"cuando","posts\u002Fcode_n_shit\u002Fcuando_vector",[251,525,528,529],"maps","cloudflare","xSAkmsIryvQ1Hl-Mz_mDsrrM6sLlJ-OSRKPoVXtDx5k",[532],{"locale":533,"path":534,"title":535},"ru","\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_vector","Пятьдесят восемь мегабайт",{"slug":525,"title":537,"path":538,"chapters":539},"Building Cuando","\u002Fseries\u002Fcuando",[540,547,553],{"path":541,"title":542,"description":543,"date":544,"image":545,"draft":518,"series":525,"n":546},"\u002Fposts\u002Fcode_n_shit\u002Fcuando_bus_app","We made a bus app for Torrevieja. It asks one question","Cuando is a bus app for Torrevieja, now on Google Play and the App Store. There was no data to start from, so we drew the network by hand: 163 stops, 16 lines, a stopwatch app and a lot of YAML. Here is how it works, and why a graph database lasted eight weeks.","2025-01-24","\u002Fimages\u002Fcuando_bus_app\u002Fcover.jpg",1,{"path":548,"title":549,"description":550,"date":551,"image":552,"draft":518,"series":525,"n":505},"\u002Fposts\u002Fcode_n_shit\u002Fcuando_tiles","Paying per tile","Every map decision in Cuando so far followed from how the map was billed. Six months of Mapbox, one week of vector tiles, a heatmap that decides how far you may zoom in, and, as of this week, a map we draw ourselves.","2025-02-22","\u002Fimages\u002Fcuando_tiles\u002Fcover.jpg",{"path":523,"title":5,"description":517,"date":516,"image":520,"draft":518,"series":525,"n":554},3,1790723720990]