ADR 0008 — MapLibre, behind a renderer seam

Status: accepted · Amends: — · Amended by: —

See also ADR 0014, which fixes which MapLibre versions this seam accepts, and ADR 0015, which records how the seam's one unfakeable surface is tested.

Context

Blaeu has to draw. MapLibre GL is the obvious engine: open source, no licence key, vector tiles, a mature style specification, WebGL performance, and a large ecosystem. There is no serious argument for writing our own.

The question is not whether to use MapLibre. It is whether to admit it — to let MapLibre types appear in our public API, to let plugins call map.getSource() directly, and to let the architecture assume that a map is a MapLibre map.

Two futures argue against admitting it, and one present:

The counter-argument is real and deserves naming: an abstraction over a rendering engine is the classic leaky abstraction, and a lowest-common-denominator wrapper that hides MapLibre's expressions, its paint properties and its queryRenderedFeatures would be worse than no abstraction. Users would fork on the first thing we failed to wrap.

Decision

MapLibre is the default and only shipped renderer, but it sits behind a Renderer interface, and there is a named escape hatch out of it.

The interface is deliberately small: mount, project/unproject, sources, layers, camera, hit testing, pointer and camera events, setCursor, getNative, destroy, plus three optional members a renderer may decline — setBasemap, setInteraction and onKey — optional because a board with no basemap, no built-in gestures or no focusable surface need not implement them, and the kernel probes for the method rather than assuming it. Anything that can be built on those primitives — measurement, highlighting, editing handles, the snap indicator — is a plugin, not a renderer method. If the interface grows, it is because something genuinely cannot be built on top, and that is a high bar.

Style is expressed as a renderer-agnostic LayerStyle (fill, line, circle, symbol) — plus, explicitly, a native escape:

readonly native?: Record<string, unknown>   // MapLibre paint/layout keys, deck.gl props

Using native couples that layer to a renderer. That is a real cost, and it is better than the alternative: a leaky abstraction that pretends every renderer is the same one.

And the one sanctioned escape hatch from the whole abstraction (core invariant 6):

const maplibre = map.renderer.getNative<maplibregl.Map>()
maplibre.addControl(new maplibregl.NavigationControl())

We want people to reach the underlying map. The alternative is that they fork the library the first time we have not wrapped something. But the hatch is explicit, greppable, and documented as "you are now outside the abstraction; we cannot undo/redo what you do here."

FakeRenderer is the proof the seam is real. It lives in @blaeu/core/testing, implements the entire Renderer contract with deterministic, analytically-invertible project/unproject, and the whole node suite (789 tests) runs against it with no GPU. A seam that only one implementation has ever gone through is not a seam; it is a wish. This one has two, and the second one is exercised on every commit.

That property is what makes a pixel-denominated snap tolerance testable at all: a test can say "the pointer is 8 pixels from that vertex" and mean it.

Alternatives rejected

Depend on MapLibre directly, no interface. Simplest, and honest about reality. Rejected on testability first — the entire non-rendering 95% of the library would need a GPU to test — and on the game/3D/headless futures second. The testability argument alone would have decided it.

A full rendering abstraction that hides MapLibre completely (no getNative, no native style escape). Rejected: the abstraction would have to grow a wrapper for every MapLibre feature anyone ever wants, we would be permanently behind, and the first user who needs an unwrapped queryRenderedFeatures option forks. An escape hatch that is explicit and documented is strictly better than one that is a fork.

Multiple renderers shipped from day one (MapLibre + Three.js), to keep the seam honest. Rejected as premature: a second renderer written speculatively, with no product behind it, would be designed against our guesses rather than a real requirement. FakeRenderer keeps the seam honest at a fraction of the cost, because it must implement everything the kernel actually uses — no more, no less.

Canvas/SVG renderer as the default, for simplicity. Rejected: at 50 000 parcels, WebGL is not optional.

Consequences

Edit this page on GitHub — this site is generated from docs/adr/0008-maplibre-with-a-renderer-seam.md, which is the source of truth.