Building a maps SDK from a Mapbox GL fork
- 7 minutes read - 1399 wordsRewriting 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.
Why
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.
Staying on 1.13 forever wasn’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’s roadmap puts you in the same position, just with different maintainers.
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: new woosmap.map.Map(), woosmap.map.Marker, woosmap.map.event.addListener. 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.
Architecture
Two layers, cleanly separated.
Mapbox GL fork (src/mapbox-gl/): 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: get, has, match, case, interpolate, step, let/var, coalesce, arithmetic, string ops, comparisons.
Woosmap layer (src/woosmap/): the Google Maps compatibility wrapper. Map, Marker, InfoWindow, OverlayView, Data layer, pane management, gesture handling overlay. Plus service clients: stores, distance matrix, localities autocomplete, datasets, transit, directions.
The Woosmap Map class wraps the Mapbox Map:
import { default as MapBoxMap } from "../mapbox-gl/ui/map";
class Map extends MVCObject {
_mapboxMap: MapBoxMap;
constructor(element: HTMLElement, options: MapOptions) {
// Create the Mapbox map internally
this._mapboxMap = new MapBoxMap({ container: element, style, ... });
// Wire up Google Maps-style events, panes, controls
}
}Properties fire change events automatically through MVCObject. Set marker.position and position_changed fires. Same pattern Google Maps uses.
The style system
Woosmap customers use Google Maps-style JSON rules to customize maps. Hue shifts, saturation, lightness, gamma correction, invert, applied hierarchically by feature type.
type MapStyler = {
color?: string; // hex color
hue?: string; // extract hue, keep lightness/saturation
saturation?: number; // shift saturation
lightness?: number; // shift lightness
gamma?: number; // gamma correction on lightness
invert_lightness?: boolean;
visibility?: string; // "on" | "off"
weight?: number; // line/label weight
};A LayerRegistry maps Google’s feature type hierarchy (road.highway, poi.park, water) 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.
Styles load dynamically from the Woosmap API via StyleFetcher, not baked into the bundle. Change your style server-side, maps update without redeployment.
Three bundles
Not every integration needs a full map. Some just need geocoding or store search. Building one monolithic bundle wastes bandwidth.
Bun builds three separate bundles from three entry points:
// Full SDK: map + services + worker
await Bun.build({
entrypoints: ["./src/maps.ts", "src/worker.ts", "src/painter.ts"],
splitting: true, minify: true, sourcemap: "linked",
loader: { ".glsl": "text" },
});
// Services only: API clients, no WebGL
await Bun.build({ entrypoints: ["src/services.ts"], ... });
// Localities: place autocomplete widget
await Bun.build({ entrypoints: ["src/localities.ts"], ... });GLSL shaders load as text strings via Bun’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.
Environment configuration (local, staging, production) injects API endpoints at build time through define:
retVal[`Bun.env.API_BASE_URL`] = JSON.stringify(environment.API_BASE_URL);
retVal[`Bun.env.ASSETS_BASE_URL`] = JSON.stringify(environment.ASSETS_BASE_URL);Worker architecture
Tile processing is expensive: protobuf decoding, geometry clipping, feature indexing. Doing it on the main thread blocks interactions.
The SDK spins up web workers from a blob URL constructed at load time:
const scriptURL = import.meta.url.replace("maps.js", "worker.js");
const response = `import "${scriptURL}";`;
const blob = new Blob([response], { type: "application/javascript" });
const workerUrl = URL.createObjectURL(blob);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.
Offscreen rendering experiment
The maps-offscreen.ts entry point pushes further: move the entire WebGL renderer into a worker using OffscreenCanvas + comlink.
The OnscreenMap lives on the main thread: handles DOM events, gesture recognition, camera state. The OffscreenMap 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.
export class OnscreenMap {
offscreenMap: Remote<OffscreenMap>;
handlers: HandlerManager;
transform: Transform;
constructor(element: HTMLElement, options?: object) {
this.canvas = document.createElement("canvas");
const offscreen = this.canvas.transferControlToOffscreen();
// Transfer canvas to worker, all GL happens there
}
}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 requestAnimationFrame, send at most one update per frame interval. Immediate sends for significant zoom changes, deferred sends for continuous panning.
Still experimental. Safari’s OffscreenCanvas support has quirks, and synchronizing the transform state between threads without jank requires careful debouncing.
Symbol placement
Text labels on maps are deceptively hard. The Mapbox GL fork handles:
- Glyph atlasing: PBF glyph format, dynamically growing texture atlas (1024x1024 initial, doubles when full), SDF rendering for clean scaling at any size
- Collision detection: grid-based spatial index, labels grouped by feature (icon + text placed together, all-or-nothing), sorted by layer priority and
symbol-sort-key - Cross-tile continuity:
CrossTileSymbolIndexmaintains label placement across tile boundaries so labels don’t pop in and out when panning - Variable anchor: if a label’s primary anchor collides, try alternatives. Pre-compute screen positions for each variant, test in order
- CJK text: local ideograph font families bypass the glyph server for Chinese, Japanese, Korean characters. Properly sized using browser font metrics
The symbol size evaluation is complex. text-size gets evaluated at five different zoom levels per feature for layout, collision boxes, line placement, and shader interpolation.
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’re capped.
Services integration
The Woosmap layer isn’t just rendering. It includes API clients for the full Woosmap platform:
- StoresService: store locator with search, autocomplete, radius queries
- DistanceService: matrix routing, isochrones, multiple travel modes (driving, walking, cycling)
- LocalitiesService: place autocomplete supporting 70+ languages
- DatasetService: query and overlay custom geospatial datasets
- TransitService: public transportation routing
- DirectionsService: turn-by-turn directions with renderer
Each service handles its own error taxonomy (INVALID_REQUEST, REQUEST_DENIED, OVER_QUERY_LIMIT, MAX_ELEMENTS_EXCEEDED) and throws typed errors (BadRequestError, ForbiddenError, TooManyRequestsError).
Overlays (StoresOverlay, DatasetsOverlay, DirectionsRenderer) connect service responses directly to map layers. Fetch stores, get markers. Compute a route, get a polyline.
Testing
97 test files. Vitest with Playwright for browser testing. The WebGL context needs a real browser, not jsdom.
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.
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.
Visual regression tests capture screenshots and diff against baselines. Useful for catching rendering changes across the 57 shaders.
bun run test:browser # Vitest + Playwright (Chromium)
npx biome check src # LintWhat’s there
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.
536 TypeScript files, 57 GLSL shaders. The Google Maps API compatibility layer means existing Woosmap integrations can switch renderers without code changes.
What’s still rough:
- Offscreen rendering works but the camera synchronization needs more tuning for smooth panning on slower devices
- Complex text shaping is 1:1 codepoint-to-glyph. Arabic, Thai, and other complex scripts need a proper shaper
- Bundle size could be smaller with better tree-shaking of the Mapbox GL fork
- Line rendering has no dash patterns or gradients yet
About 5MB of TypeScript source. The output bundles are significantly smaller after minification and tree-shaking.