<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Mapbox on Man-You</title><link>https://man-you.ringum.net/tags/mapbox/</link><description>Recent content in Mapbox on Man-You</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Sun, 08 Feb 2026 14:00:00 +0200</lastBuildDate><atom:link href="https://man-you.ringum.net/tags/mapbox/index.xml" rel="self" type="application/rss+xml"/><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></channel></rss>