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:
on(type, handler, options?)— past-tense notification. Cannot be cancelled.onBefore(type, handler, options?)— a cancellable hook. The handler receives aCancellableBlaeuEventwithpreventDefault(reason?).emitCancellable()accepts only keys matching`before:${string}`, so thebefore:prefix is the capability, not a naming convention we ask people to respect.
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 stack —
onDidExecute 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.
SyncInteractionPipeline — run(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.
AsyncCommitPipeline — run(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):
- If snapping is off, or the event is a
keydown, or Alt is held, it publishes "no snap" and callsnext(). Alt-to-suppress is the universal CAD convention, and handling it here, once, is what gives every tool the behaviour for free. - Otherwise it queries every registered
SnapProviderwith(ctx.rawLngLat, tolerancePx, q). NoterawLngLat: a provider must see where the pointer actually is, not where a previous frame's snap left it, or the snap would be sticky. - The
SnapQueryContexthands each providerproject/unproject, a precomputedbboxof the tolerance circle (so the provider hits the spatial index instead of scanning),exclude, andinProgress(the ring's committed vertices, so the user can close a polygon on its own first corner).excludeis the union of what a plugin asked to ignore (SnapApi.exclude, used by draw for the ring it is closing) andctx.dragging, which the kernel carries: a tool declares what it has hold of throughtools.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. - Candidates are ranked by
priority, ties broken bydistancePx. 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. - It writes
ctx.snap = { candidate, alternatives }and — the load-bearing line —ctx.lngLat = candidate.point. Thennext().
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:
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.- 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.
- Outside one, it executes, and — unless
transient— notifiesonDidExecutesubscribers and emitscommand:executed. - 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:
BlaeuValidationRegistry.asCommitMiddleware(), at priority −100 so it runs last. That is deliberate: the middleware that fills in defaults, quantises coordinates and rewinds ring winding order all sit above zero, and a rule that judged the pre-quantised ring while the store keeps the quantised one is a bug you find in production, in a land registry. It emitsvalidation:failedwith every issue (warnings ride along with the errors, because a UI wants to say "sliver, 0.4 m²" even when the write succeeds), and callsctx.reject()only if anerroris present. Removals are never validated — validating a delete would make an already-invalid parcel impossible to remove, which is the exact opposite of what a data steward cleaning up a bad import needs.- The cadastre preset's
deriveAreaMiddleware, above it, which recomputesyuzolcumufrom the geometry on every write. Area is derived, never typed: a hand-entered area that disagrees with the boundary is the single most common source of a cadastral dispute, and the cheapest way never to have one is to make the field un-typeable. - The game preset's
game:generate, at priority 0 — between the two. A placement's procedural generators run inside the commit they belong to, so what they spawn is validated by the rules below them rather than arriving afterwards as an unchecked write.
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:
- Capabilities.
provides: ['snap-engine']lets a plugin satisfy a dependency on a capability rather than an id. A product that swaps our snapping for its own implements the capability, and every dependent plugin is satisfied without knowing anything changed. - Version ranges.
{ id: 'history', range: '^1.0.0' }is checked at registration against the dependency's declaredversion, and throws immediately on a mismatch. - Removal is refused when it would strand a dependent.
remove('snap')withedithard-depending on it throws, naming the dependents. Teardown (destroyAll) walks in reverse install order, so dependents go before their dependencies. - Optional dependencies resolve by declaration, not by timing. A plugin also parks on an
optional dependency that has been announced in this batch but has not finished
installing, so
ctx.tryPlugin('snap')answers the same way every run. Once nothing is actively installing (#quiescent), the waiters are released to degrade rather than deadlock — which is what lets two plugins optionally depend on each other. An optional dependency that was never announced is genuinely absent and does not park at all.
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.
ARCHITECTURE.md, which is the source of truth.