Architecture

This document explains how Blaeu is put together and, more usefully, why. It assumes you have read the README.

The short version: the kernel owns five things and refuses to own a sixth. Everything a user would call a feature is built on top of them, from the outside, through extension points the kernel defines and the kernel calls. The value of the library is entirely in what the core declines to do.


1. The kernel

BlaeuMap (packages/core/src/BlaeuMap.ts) is a class with sixteen fields and no draw(), no measure(), no snapTo(). Read the field list and notice the absences:

class BlaeuMap {
  readonly events: BlaeuEventBus
  readonly store: BlaeuFeatureStore
  readonly commands: BlaeuCommandBus
  readonly plugins: BlaeuPluginManager
  readonly interaction: SyncInteractionPipeline
  readonly commit: AsyncCommitPipeline
  readonly tools: BlaeuToolManager
  readonly layers: BlaeuLayerManager
  readonly crs: BlaeuCrsService
  readonly theme: BlaeuThemeManager
  readonly i18n: BlaeuI18n
  readonly validation: BlaeuValidationRegistry
  readonly renderer: Renderer
  readonly config: ResolvedConfig
  readonly log: Logger
  readonly debug: { snapshot(); plugins(); interactionMiddleware(); commitMiddleware() }
}

The first six are the kernel proper. Most of the rest are seams: services with an interface the core owns, which plugins extend rather than replace. The last, debug, is introspection only — snapshot() returns live listener, middleware, layer, plugin and feature counts, and is what the teardown test asserts against.

createBlaeuMap() is async because the renderer must mount and every plugin's setup must finish before the map is usable — and a plugin's setup may legitimately fetch a projection definition or warm a spatial index. Returning a half-initialised map from a synchronous constructor and hoping the caller awaits the right thing is how you get bug reports that say "sometimes the first click does nothing."

EventBus

Strongly typed through declaration merging on BlaeuEventMap. Plugins augment the map from their own entry point, and thereby teach the core's bus about events the core has never heard of:

declare module '@blaeu/core' {
  interface BlaeuEventMap {
    'draw:complete': {
      readonly mode: DrawMode
      readonly collection: CollectionId
      readonly feature: BlaeuFeature
    }
    'before:draw:complete': {
      readonly mode: DrawMode
      readonly collection: CollectionId
      readonly feature: FeatureInput
    }
  }
}

Two channels, and the type system keeps them apart:

ListenerOptions.priority (higher first) exists chiefly for before: hooks, where the order decides which validator vetoes first and therefore which error message the user actually sees. Handlers are synchronous by design: the bus sits on the hot path, and an async handler would silently reorder under load.

onAny('draw:*', handler) is the third subscription form — a prefix subscription, and the pattern must end with *. Every listener may stopPropagation() to stop later listeners on the same event, and ListenerOptions also carries once and an AbortSignal (signal), which is what makes a React effect's cleanup a one-liner.

Every on/onBefore/onAny returns a Disposable. listenerCount() exists so the teardown test can assert it returns to zero.

FeatureStore

The single source of truth for geometry. A BlaeuFeature is GeoJSON-shaped but not GeoJSON: geometry and properties are exactly the RFC 7946 fields, so toGeoJSON() is a projection rather than a conversion, but a mandatory string id and a meta block (collection, version, timestamps, locked, hidden, a namespaced ext slot) are ours. Keeping our bookkeeping out of properties means a round-trip through GeoJSON does not ship our internals to the user's server, and does not collide the day a cadastral schema legitimately has a field called version.

Features live in collections, which map 1:1 to renderer sources and are the unit of styling. Collection.query(bbox) is backed by an rbush SpatialIndex — O(log n), because it is on the pointermove path and a 50 000-parcel linear scan is not.

Reads return frozen objects in development, so mutating one fails loudly rather than silently desyncing the renderer and breaking undo three actions later. The write path (_add / _update / _remove) is marked @internal: commands call it, application code does not.

TopologyIndex

store.topology maps a quantised coordinate to every VertexRef sitting on it — at(point), featuresAt(point), isShared(point). The quantisation grid is the working CRS's precision (1 mm for cadastre), and it is the load-bearing detail: exact float equality would treat corners 10⁻¹² m apart as distinct, they would drift apart under editing, and you would have manufactured a sliver. This index is what makes editPlugin({ topological: true }) possible.

CommandBus

dispatch(command) returns { ok, value, rejectedReason }. It holds no undo stackonDidExecute is the seam history subscribes to. transaction(label, fn) groups everything dispatched inside fn into one atomic, single-undo unit and rolls the store back if fn throws. A one-command transaction collapses rather than wrapping, because the undo menu should say "Move vertex", not "Transaction".

That is the synchronous transaction, and since dispatch() refuses any feature-writing command it groups only transient scaffolding. A durable multi-step edit — a parcel split — uses commitTransaction(label, async (tx) => { await tx.commit(...) }), whose tx handle is the transaction's identity: anything submitted through it is a child, anything submitted through the bus queues behind the whole group. Writes are serialised in call order by a queue on commit/commitTransaction, so two overlapping async writes cannot interleave. A veto anywhere inside rolls the whole group back to the up-front snapshot. See ADR 0012.

CompositeCommand.undo() walks its children in reverse. Undoing "remove A, add B" forwards would try to re-add A while B still occupies its geometry, which a topology validator would correctly reject.

_apply(command, 'undo' | 'redo') is the internal replay hook the history plugin uses. It deliberately bypasses the before:command:execute gate: a hook that vetoed the original command never saw it execute, so there is nothing to veto on the way back.

The two pipelines

Both are Koa-style: each middleware gets (ctx, next) and may call next, skip it, or wrap its own work around it. Both sort by descending priority. Both refuse a double next(), because a middleware that calls it twice would run the rest of the chain again and double-apply the snap offset — silent, and horrible to find.

SyncInteractionPipelinerun(ctx: InteractionContext): InteractionContext. Synchronous by contract (void, not Promise<void>). Runs on every pointer event. A middleware that throws is logged, the rest of the chain is abandoned, and the context still reaches the tool as far as it got: a partly-processed pointer event is far better than a dead cursor.

AsyncCommitPipelinerun(ctx: CommitContext): Promise<CommitContext>. Short-circuits the moment anything sets ctx.rejected, which is why priority ordering puts cheap local rules ahead of expensive server ones. A middleware that throws rejects the write: failing closed is the only defensible default when the thing being guarded is a land registry.

PluginManager

Installs, resolves dependencies, runs the lifecycle: setup once → enable/disable any number of times → destroy once. Covered in detail in §4.

The disable/destroy split matters more than it looks. disable means "go dormant but keep your state" — a user toggling the measurement tool off and on again expects their measurements to still be there, and a user toggling history off has not asked us to forget what they did. destroy means "you are gone, release everything."

The seams

Seam Interface What a plugin does with it
renderer Renderer Draws. MapLibre ships; FakeRenderer proves the seam is real.
crs CrsService register() a custom plane; working.forward/inverse for maths
layers LayerManager registerType() a whole new rendering category
tools ToolManager register() an interactive mode
validation ValidationRegistry add() a rule that runs in the commit pipeline
theme ThemeManager token() instead of hardcoding a colour; register()/use() a named theme
i18n I18n register() a message bundle; presets override it

ToolManager keeps exactly one primary tool active at a time, which is what makes a map feel coherent rather than like modal soup. Ambient behaviour that should always run — hover highlighting, the snap indicator — is not a tool; it is middleware.

ThemeTokens feed both the UI chrome (as CSS custom properties) and the map styling (as values inside paint expressions). That single source of truth is why the selection halo on the map is exactly the same blue as the selected row in the attribute table — a detail you cannot get if the map style and the CSS are maintained separately.

The theme also owns the ground. Theme.basemap is pushed to renderer.setBasemap on every theme change (BlaeuMap.#applyBasemap), so a day/night switch swaps the map under the features rather than just the chrome; a renderer with no setBasemap is a no-op, and a failed swap reports on map:error rather than stranding the chrome mid-change. Layers added declaratively with a ThemeStyleFn are re-evaluated against the new tokens by the LayerManager, which is how a preset's parcel outline follows the palette with no subscription of its own. A small registry (register/use/follow) lets the app name themes and track the OS colour-scheme preference.


2. The life of a pointer event

A pointermove at 120 Hz, traced from the DOM to the tool. This path, and the one in §3, explain most of the library between them.

2.1 The renderer normalises

MapLibreRenderer (or FakeRenderer) listens to MapLibre's own pointer events and emits a RendererPointerEvent: { kind, lngLat, screen, button, buttons?, modifiers, originalEvent }. Mouse, touch and pen are already unified here. No tool ever sees a raw DOM event, which is what makes a tool written for a mouse work on a tablet in the field without changes. buttons is the bitmask currently held (undefined for a touch stream) — a pointermove with buttons === 0 is how a drag tool learns the button was released off-canvas.

BlaeuMap.#wireInteraction() subscribes once, in #init, and holds the Disposable.

2.2 The kernel builds an InteractionContext

BlaeuMap.#normalise(event) constructs the context handed down the pipeline. Three of its properties are worth reading closely:

{
  get lngLat() { return lngLat },      // MUTABLE — the whole point
  set lngLat(value) { lngLat = value },
  get xy() { return crs.working.forward(lngLat) },   // DERIVED, never cached
  readonly rawLngLat: event.lngLat,    // the untouched original
  readonly screen: event.screen,       // ground truth; middleware must not rewrite it
  readonly dragging: this.tools.dragging,  // what the active tool has hold of
  snap: undefined,                     // filled in by the snap middleware
  hits: () => this.renderer.queryAt(event.screen),   // lazy hit test
  consume(): void,                     // stops the event reaching the tool at all
}

lngLat is mutable because rewriting it is the entire mechanism of §2.3. xy is a getter rather than a cached field so it cannot drift out of sync when middleware moves lngLat — a stale xy that a snap middleware forgot to update is a wonderfully subtle way to place a vertex a metre from where the user clicked. And the crs service is captured rather than crs.working, so a mid-gesture setWorking() is reflected on the next read.

2.3 The interaction pipeline runs — and rewrites the position

this.interaction.run(ctx) walks the middleware in descending priority.

Snapping, priority 100. The snap plugin's single middleware (packages/plugin-snap/src/engine.ts):

  1. If snapping is off, or the event is a keydown, or Alt is held, it publishes "no snap" and calls next(). Alt-to-suppress is the universal CAD convention, and handling it here, once, is what gives every tool the behaviour for free.
  2. Otherwise it queries every registered SnapProvider with (ctx.rawLngLat, tolerancePx, q). Note rawLngLat: a provider must see where the pointer actually is, not where a previous frame's snap left it, or the snap would be sticky.
  3. The SnapQueryContext hands each provider project/unproject, a precomputed bbox of the tolerance circle (so the provider hits the spatial index instead of scanning), exclude, and inProgress (the ring's committed vertices, so the user can close a polygon on its own first corner). exclude is the union of what a plugin asked to ignore (SnapApi.exclude, used by draw for the ring it is closing) and ctx.dragging, which the kernel carries: a tool declares what it has hold of through tools.setDragging(), and any middleware may read it. Without it, snapping offers the dragged vertex its own position, the tool computes "it didn't move", and every drag shorter than the tolerance is a silent no-op. See ADR 0010.
  4. Candidates are ranked by priority, ties broken by distancePx. The ordering is the one decision the whole engine rests on: vertex (100) > intersection (90) > midpoint (80) > edge (70) > extension/perpendicular (50) > grid (10). A vertex must outrank the edge it sits on, because the perpendicular foot of a pointer near a corner is at exactly the same screen distance as the corner — a tie broken by distance alone would hand a coin flip to the edge, and nobody could ever reliably snap to a corner.
  5. It writes ctx.snap = { candidate, alternatives } and — the load-bearing line — ctx.lngLat = candidate.point. Then next().

Anything downstream reads a snapped position. A grid-lock middleware, an ortho-constraint middleware, or a coordinate-quantisation middleware registers below 100 and can therefore override the snap deliberately; registering above it would let a constraint move the pointer off the corner the indicator is promising, which the user reads as the software lying to them.

The UI pointer feed (plugin-ui) is another middleware. It publishes the post-pipeline position to the coordinate readout, because a readout showing a different number from the one that gets stored is worse than no readout at all.

Any middleware may call ctx.consume(), and the tool then never sees the event.

2.4 The active tool

this.interaction.run(ctx)
if (ctx.consumed) return
const tool = this.tools.activeTool
if (!tool) return
dispatchToTool(tool, ctx) // → tool.onPointerMove?.(ctx), etc.

The draw tool reads ctx.lngLat. It gets a position that is already exactly on the parcel corner, quantised to the millimetre grid, and constrained to whatever the preset installed. It has never heard of the snap plugin, does not import it, and would keep working if you uninstalled it.

This is why a tool implementation is forty lines. All the hard geometry happened upstream, in middleware that every tool shares.

The only thing the draw plugin tells the snap plugin is what is in flight (setInProgress(vertices)) and what to ignore (exclude([previewId])) — and it does even that through a duck-typed handle (plugin-draw/src/snap-handle.ts), obtained via ctx.tryPlugin('snap'), so the dependency stays optional and the degradation test stays honest.

2.5 Key presses take the same road

Renderer.onKey is optional — a headless export target has no focusable surface, so the kernel probes for it exactly as it probes for setBasemap and setInteraction. Where it exists, #normaliseKey builds an InteractionContext of kind 'keydown' (with key, and button: -1 so no tool mistakes it for a primary click), walks it through the same interaction pipeline, and hands it to the same dispatchToTool, which routes it to tool.onKeyDown. A tool's onKeyDown is therefore a real handler, not one only the test harness could reach.


3. The life of a mutation

A drawn polygon, from the tool to the pixels.

3.1 The tool builds a FeatureInput and fires the cancellable hook

DrawSession.complete() (plugin-draw/src/session.ts):

const input: FeatureInput = {
  geometry,
  properties: { ...options.properties(), ...extraProperties },
  meta: { source: 'draw' },
}

const gate = ctx.events.emitCancellable('before:draw:complete', {
  mode,
  collection,
  feature: input,
})
if (!gate.allowed) {
  this.cancel(gate.reason ?? 'vetoed by a before:draw:complete listener')
  return undefined
}

The hook fires before anything is dispatched, so a listener that calls preventDefault() leaves nothing behind: no feature, no history entry, no half-written collection. The payload carries a FeatureInput, not a BlaeuFeature — the store has not minted an id or stamped a version yet, and pretending otherwise would hand listeners an id no later event will ever mention.

The rubber-band preview is cleared here, before the await, and its command is transient, so it never appears in a snapshot history would roll back to.

3.2 The command bus

// `commit`, not `dispatch`: this is the write that makes the shape real, so it is the
// write the product's rules get to refuse. `dispatch` is the transient-scaffolding path
// (the rubber-band preview above) and refuses a feature-writing command outright.
const result = await ctx.commands.commit(new AddFeaturesCommand(collection, [input], { label }))

const created = result.value?.[0]
if (!result.ok || created === undefined) {
  const reason = result.rejectedReason ?? 'the store did not return the drawn feature'
  ctx.log.warn(`${label} was not committed: ${reason}`)
  ctx.events.emit('draw:cancel', { mode, reason })
  return undefined
}

Both entry points funnel into the same execute step — commit() first runs the command through the commit pipeline (§3.3) and applies it only if nothing rejected; dispatch() skips the pipeline and is for transient scaffolding, refusing a CommitCommand at compile time (intent?: never) and at runtime both. Once a command reaches its execute step the bus:

  1. emitCancellable('before:command:execute', { command }). A veto here costs nothing to clean up — the store has not been touched. This is where a permission check or a business rule belongs.
  2. Inside a transaction, the command executes immediately (so later commands in the transaction see its effect) but is recorded into the group rather than announced individually.
  3. Outside one, it executes, and — unless transient — notifies onDidExecute subscribers and emits command:executed.
  4. Throwing inside a transaction restores the up-front store snapshot and returns { ok: false }. A half-completed parcel split cannot be left on screen.

3.3 The commit pipeline

AsyncCommitPipeline is where attribute defaults, precision reduction, audit stamps and validation live. Middleware receives a mutable CommitContext:

{
  readonly operation: 'add' | 'update' | 'remove',
  features: BlaeuFeature[],              // mutable — rewrite them
  readonly previous: readonly BlaeuFeature[],
  readonly crs: CrsService,              // the map's *live* working plane, for survey-grade maths
  readonly command: Command | undefined,
  reject(reason: string): void,
  readonly rejected: boolean,
  readonly rejectReason: string | undefined,
}

reject() is first-veto-wins — a later middleware cannot overwrite an earlier rule's reason with a vaguer one, because the user should be told the first thing that was wrong with their parcel, not the last.

The features a middleware sees are materialised, not written. AddFeaturesCommand.intent() calls store.materialise() before the pipeline runs: ids are minted, meta is stamped, and the geometry is already quantised to the working CRS's grid with its rings wound and closed. Nothing has been written — a rejected commit leaves no minted id and no touched index behind it — but a rule judges the parcel that will exist rather than the raw input, because a rule that passes on the input and would have failed on the stored feature is not a weak rule, it is a lie.

Three things register there today:

Where it runs. Command.execute() is synchronous and the pipeline is async, so dispatch() deliberately does not run the pipeline — it is the transient-scaffolding path (previews, handles), and it refuses a feature-writing CommitCommand at compile time (intent?: never) and at runtime both. A durable write goes through commands.commit(), which runs the pipeline and then applies the write only if nothing rejected — one call, validated:

const result = await ctx.commands.commit(new AddFeaturesCommand(collection, [input], { label }))
if (!result.ok) return [] // rejected in the pipeline; nothing was ever written

preset-game's EntitySession.place() is the reference. It used to hand-roll a CommitContext and call ctx.commit.run() itself, back when the kernel never ran the pipeline on the write path at all — that hole is closed. A placement's generators run inside the same commit, as middleware, so the building and the crates it spawns are validated together, land together and undo together; the appended features come back through AddFeaturesCommand.adopt(), each routed to the collection its own meta names. See ADR 0009 for the contract.

3.4 The store writes, and announces

AddFeaturesCommand.intent() already called store.materialise() — ids minted, meta stamped, coordinates quantised to the working CRS's grid, rings wound and closed — which is why the commit pipeline in §3.3 judged the feature that will actually exist rather than the raw input. execute() then calls store._add() with whatever the pipeline adopted, which writes it, updates the spatial index and the topology index, and emits feature:added plus a StoreChange to onChange subscribers.

The command keeps what the store actually wrote — not what it was asked to write. That is the difference between a command that can undo approximately and one that can undo exactly: on redo, AddFeaturesCommand re-adds the features the store minted the first time, so redo does not resurrect the parcels under new ids, leaving every selection and label that referenced the old ones dangling.

3.5 The LayerManager coalesces, and the renderer draws

BlaeuLayerManager.connectStore() subscribes to store.onChange, marks the changed collection dirty, and flushes on a queueMicrotask. A transaction that writes forty features to one collection therefore produces one renderer.setData() call, not forty. Teardown sets a stopped flag, because a queued flush after destroy would talk to a destroyed renderer.

setData(sourceId, features) is the end of the road. MapLibreRenderer translates our renderer-agnostic LayerStyle into MapLibre paint/layout, filters out meta.hidden features, and hands the rest to a GeoJSON source.

3.6 History, which was watching the whole time

The history plugin subscribed to commands.onDidExecute in its setup. It saw a Command. It pushed it onto a stack, possibly coalescing it into the previous one (coalesceWith, within a 300 ms window — or regardless of the clock, when both commands declare the same gesture, because a drag the user paused mid-way is still one gesture). The subscription also carries a transaction label and an origin whose replay flag is captured at submission time, which is how history avoids recording the echo of its own undo. It knows nothing about polygons, parcels or draw tools, and it never will.


4. Plugin dependency resolution, and why parking beats a topological sort

A plugin declares dependencies as { id, range?, optional? }. PluginManager.use() does not fail when one is missing. It parks the plugin:

const missing = this.#missingDependencies(plugin)
if (missing.length > 0 || this.#awaitedOptional(plugin).length > 0) {
  return await new Promise<TApi>((resolve, reject) => {
    this.#pending.push({ plugin, options, resolve, reject })
  })
}
return await this.#install(plugin, options)

Every successful install then drains the parking lot (#drainPending), looping until a pass makes no progress — so a chain A→B→C installs correctly no matter what order the three arrive in. The promise a parked plugin returned resolves when it finally installs.

BlaeuMap.#init() installs every preset and user plugin with Promise.all, then calls plugins.settle(). Anything still parked at that point has a dependency that is never coming, and settle() throws with a report naming each plugin and what it is waiting for — rather than leaving a plugin sitting inert and being blamed on something else three hours later.

Why not topologically sort the batch up front? Because a sort assumes you have the whole batch. That is true for a preset and false for everything else: map.use(plugin) at runtime, a lazily-loaded feature module, a plugin marketplace that installs on demand. A topological sort handles the static case and needs a second mechanism for the dynamic one. Parking handles both with one mechanism, and the dynamic case is the one that will matter in two years.

Four more details worth knowing:

A failed setup disposes that plugin's DisposableStore before rethrowing. A stray layer or listener from a plugin that "isn't installed" is a genuinely baffling thing to debug.


5. Extension-point catalogue

You want to add X → register a Y.

You want to… Register a… Through And you get
Change the pointer position before any tool sees it InteractionMiddleware ctx.interaction.use(fn, { priority }) Snapping, grid lock, ortho constraint — in every tool, forever
Veto or rewrite a mutation before it lands CommitMiddleware ctx.commit.use(fn, { priority }) Validation, attribute defaults, audit stamps, server checks
Add a new kind of snap target SnapProvider ctx.tryPlugin('snap')?.addProvider() Your targets appear in every tool that snaps, including future ones
Add a whole new rendering category LayerTypeDef ctx.layers.registerType(def) map.layers.add({ type: 'your-type' }) — deck.gl, heatmap, tile-grid
Add an interactive mode Tool ctx.tools.register(id, tool) Exclusive activation, cursor, post-pipeline events
Make a durable edit undoable CommitCommand ctx.commands.commit(cmd) Cross-plugin undo/redo, transactions, validation — free
Block a write on a domain rule ValidationRule ctx.validation.add(rule) Runs last in the commit pipeline; error blocks, warning annotates
Add a coordinate system ProjectedCrs spec ctx.crs.register(spec) Every planar facility (snap, grid, area, topology) works in it unchanged
Add UI chrome Control map.plugin('ui').addControl(c, pos) Themed, localised, torn down with the plugin
Localise anything Messages ctx.i18n.register(locale, messages) Later registration wins, so presets override plugins
React to something handler ctx.events.on(type, fn)
Veto something handler ctx.events.onBefore('before:…', fn) preventDefault(reason)
Ship a whole vertical product Preset definePreset({...}), passed as preset: Plugins, config, layers, rules, theme, i18n and middleware as one composable value

A Preset is data, not a subclass, which is why composePresets/overridePreset let a product extend one rather than fork it (ADR 0006). It is applied in a fixed order in #init: theme, then i18n, then middleware, then validation, then its plugins before the user's, so plugins: [...] alongside a preset extends it and can depend on it.

Two rules of thumb hold across all of it.

If your plugin has to know about another plugin by name, you picked the wrong extension point. The draw plugin does not import the snap plugin; snapping rewrote ctx.lngLat before the draw tool ever read it. That indirection is the architecture.

Everything you register goes into ctx.disposables. The store is disposed for you on destroy, in reverse order. A plugin that registers a listener outside it leaks it forever, and worse, a re-registered plugin then runs its handler twice. The teardown test exists to catch exactly this, and it is not optional.


6. The renderer seam

Renderer is deliberately small: kind, mount, project/unproject, sources, layers, camera, hit testing, pointer/camera events, setCursor, getNative, destroy — plus three optional members the kernel probes for rather than requires: setBasemap (a theme swap), setInteraction (which gestures are live), and onKey (a surface with a keyboard). A renderer that cannot do one of those is a no-op, not an error, which is how a fixed-ground game renderer and a headless export target implement the same interface. Anything that can be built on top of those primitives — measurement, highlighting, editing handles — is a plugin, not a renderer method.

MapLibreRenderer is the only implementation we ship and the right default. FakeRenderer (in @blaeu/core/testing) is the proof that the seam is real rather than aspirational: it implements the whole contract with deterministic, analytically-invertible project/unproject, and the 789-test node suite runs against it with no GPU. A separate 22-test browser suite (vitest.browser.config.ts) mounts real MapLibre in headless Chromium to check the one thing a fake cannot: that MapLibre accepts the paint/layout we hand it, and that our pointer normalisation matches real DOM events. Four of those tests are probe-gated and skip without a GPU, because queryRenderedFeatures hit testing needs a completed render pass and no GPU-less runner delivers one. A test can therefore say "the pointer is 8 pixels from that vertex" and mean it — which is the only honest way to test a snap tolerance denominated in pixels.

The sanctioned escape hatch, and the only one:

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 it is explicit, greppable, and carries a warning: you are outside the abstraction, and we cannot undo what you do there.

Edit this page on GitHub — this site is generated from ARCHITECTURE.md, which is the source of truth.