ADR 0003 — Snapping is interaction middleware, not a service tools call

Status: accepted · Amends: — · Amended by: ADR 0010 — a tool that drags existing geometry must declare it via tools.setDragging(); snapping is not purely transparent for those tools

Context

Snapping is the feature that decides whether a drawing tool is usable for survey work. It is also the feature that, in every mapping library we looked at, is implemented as a method the drawing tool calls — snapTo(lngLat), or a snap option on the draw tool's constructor.

That arrangement has a specific failure mode, and it is not subtle once you have seen it: every tool must remember to snap. The draw tool does. The vertex-edit tool does. The measure tool was added six months later and does not, and nobody notices until a surveyor measures a boundary and gets a number 4 cm off, because the measurement endpoint landed near the corner rather than on it. Then the split tool is added, and it happens again. The library has no way to make a tool snap; it can only ask.

And there is a second, worse version: a tool written by someone else — a plugin from a marketplace, an internal tool at a municipality — cannot snap at all unless it imports our snap plugin and calls it correctly. We would have made snapping a privilege of the tools we shipped.

Decision

Snapping is interaction middleware. It rewrites the pointer position before any tool sees the event.

The snap plugin registers exactly one middleware, at priority 100 — the highest in the chain. On every pointer event it queries its SnapProviders, ranks the candidates, and sets:

ctx.snap = { candidate, alternatives }
ctx.lngLat = candidate.point // ← the load-bearing line
next()

InteractionContext.lngLat is mutable for exactly this purpose. ctx.rawLngLat keeps the original (providers query against it, so the snap is not sticky), and ctx.xy is a getter that re-projects lngLat on every read, so a cached projected coordinate cannot drift out of sync with a snapped geographic one.

By the time BlaeuMap hands the context to the active tool, the position has already been snapped, grid-locked, and constrained by whatever the preset installed. The draw plugin has never heard of the snap plugin and does not import it. It reads ctx.lngLat, like every other tool.

Snapping is open-ended in the same way: SnapProvider is an interface anyone can implement. Core snapping covers vertex, edge, midpoint, intersection, extension, perpendicular and grid; a cadastre plugin adds parcel-corner, a utilities plugin adds pipe-junction, and preset-game adds hex-centre in one file. Register it, and every tool in the product snaps to it.

Alternatives rejected

A SnapService that tools call (const p = snap.resolve(lngLat)). The obvious design, and the one we rejected: it makes correct snapping opt-in per tool, so the guarantee is only as strong as the least careful tool author. It also cannot express Alt-to-suppress, cursor feedback or snap-indicator rendering once — each tool would reimplement all three, slightly differently.

A snap option on each tool (drawPlugin({ snap: true })). Same failure, plus it makes the draw plugin depend on the snap plugin's option shape, which is precisely the plugin→plugin coupling the boundary rules forbid.

Snapping inside the renderer, i.e. the renderer emits already-snapped pointer events. Tempting, because the renderer already owns project/unproject. Rejected because the renderer would then need to know about the store, the spatial index, the working CRS and the provider registry — which is to say it would stop being a renderer. It would also make snapping untestable without a renderer, and impossible to reorder relative to other constraints.

Post-processing in the tool ("let the tool snap the point it just received"). Identical to the service option in every practical respect, and it additionally denies the UI a position to render an indicator at until after the tool has run.

Consequences

Edit this page on GitHub — this site is generated from docs/adr/0003-snapping-as-interaction-middleware.md, which is the source of truth.