@softwarity/draw-adapter
Headless, generic map adapter for the @softwarity drawing libs
(sigmet-draw, sigwx-draw, …). It grafts a drawing onto a host-owned map
(à la Terra Draw): the host owns the basemap, controls, projection and zoom; the
adapter only adds the drawing overlays, reports pointer events in lon/lat,
registers a glyph sprite atlas and optionally renders a native toolbar.
One set of engine implementations — MapLibre GL, OpenLayers, Leaflet —
shared by every product. The adapter knows no domain type: it is driven by a
declarative LayerSpec[] manifest and reads a fixed set of render props off each
feature. Each product's controller resolves its domain style into those props
before setOverlay, so styling is entirely data-driven and the three engines
render identically.
Why this exists.
sigmet-drawandsigwx-drawused to each ship their own MapLibre + OpenLayers adapters — near-twin implementations where every fix had to be re-applied in each. This package replaces all of that: all three engines (MapLibre, OpenLayers, Leaflet) are implemented here, once — a single, canonical map layer both products graft onto.
Used by
| Library | What it draws | Repo · demo |
|---|---|---|
@softwarity/sigmet-draw |
SIGMET/AIRMET geometries ICAO TAC | repo · demo |
@softwarity/sigwx-draw |
SIGWX significant-weather charts | repo · demo |
Engine support
| Capability | MapLibre GL | OpenLayers | Leaflet |
|---|---|---|---|
| fill / line / circle / symbol / text | |||
| data-driven props (identical render) | |||
rotatable handle glyphs (icon / symbol + iconRotate) |
¹ | ||
label box (textBackground/textBorder + textBoxSize/textBoxRadius) |
⁴ | (no radius) | |
project/unproject/onViewChange/getViewSpan |
|||
| drag-vs-pan guard | n/a² | (capture-phase) | (capture-phase) |
keyboard onKey (focused-map keydown) |
|||
lock map (setInteractive / toolbar lock button) |
|||
PNG snapshot() (basemap + overlays + widget cards⁵) |
³ | ||
anchored marker widgets (setWidgets — editable cards) |
|||
camera read/drive + container (getBounds/getZoom/fitBounds/getContainer) |
|||
area framing (viewArea, dateline-aware) · dashed frame (highlightArea) |
|||
live reprojection (setProjection({kind:"proj4"})) |
⁷ | ⁷ | |
overlay visibility (setOverlayVisible) · right-click (contextmenu) · window-blur (onBlur) |
|||
| touch: tap-to-select & edit widgets | |||
| touch: freehand drawing (drag to draw) | ⁶ | ⁶ | |
| peer dependency | maplibre-gl >=5 |
ol >=9 (+ proj4 >=2.8, optional⁷) |
leaflet >=1.9 |
¹ data-URI icons are materialized lazily via styleimagemissing; sprites are tinted per symbolColor.
² MapLibre's dragPan is toggled directly by the controller, no capture-phase hack needed.
³ Leaflet has no single exportable canvas (tiles are <img>, overlays SVG/DOM); snapshot() rejects and the toolbar button is shown disabled. A DOM-snapshot approach is planned.
⁴ MapLibre fakes the box with a per-feature 9-slice image (built on demand via styleimagemissing), so it honours textBackground/textBorder/textBoxSize/textBoxRadius per feature. OpenLayers uses its native text background — same, except textBoxRadius (its box is a rectangle).
⁵ The PNG composites the marker widgets in their static form (inputs → their value) on MapLibre/OpenLayers, with a safe fallback to a card-less snapshot if the foreignObject rasterization taints the canvas (e.g. Safari). Leaflet snapshot is unsupported, so its widgets aren't captured yet.
⁶ MapLibre/Leaflet pointer handlers are mouse-based: a finger tap still selects (a deduped native-click fallback) and widgets are touch-capable (Pointer Events), but dragging to draw a shape doesn't fire. OpenLayers uses Pointer Events, so freehand drawing works there; full touch on ML/Leaflet (unify on Pointer Events) is a planned chantier.
⁷ Only OpenLayers reprojects (needs the optional proj4 peer). MapLibre stays Mercator/globe and Leaflet stays lat/lng-native — a {kind:"proj4"} spec there is a no-op (one console warning). viewArea/highlightArea still work in Mercator on all three.
Install
npm i @softwarity/draw-adapter
# plus the engine(s) you use (optional peer deps):
npm i maplibre-gl # or: ol | leafletSub-path exports keep the engines isolated — importing ./openlayers never pulls
in MapLibre or Leaflet:
import type { MapAdapter, LayerSpec } from "@softwarity/draw-adapter";
import { MapLibreAdapter, createMapLibreMap } from "@softwarity/draw-adapter/maplibre";
import { OpenLayersAdapter } from "@softwarity/draw-adapter/openlayers";
import { LeafletAdapter } from "@softwarity/draw-adapter/leaflet";
import { FakeAdapter } from "@softwarity/draw-adapter/testing"; // unit testsUsage
const LAYERS: LayerSpec[] = [
{ id: "area", kind: "fill" },
{ id: "guide", kind: "line" },
{ id: "symbols", kind: "symbol" },
{ id: "label", kind: "text" },
{ id: "handles", kind: "circle" },
];
const HIT = new Set(["handles", "guide", "area"]);
const map = createMapLibreMap({ container: "map", center: [2.3, 48.8], zoom: 5 });
const adapter = new MapLibreAdapter({ map, layers: LAYERS, hitOverlays: HIT });
await adapter.ready();
adapter.onPointer((ev) => { /* controller orchestrates here */ });
// push a FeatureCollection whose features already carry their render props:
adapter.setOverlay("area", {
type: "FeatureCollection",
features: [{ type: "Feature", geometry: poly, properties: { fillColor: "#58a6ff", fillOpacity: 0.2 } }],
});All three adapters take the same options: { map, layers, hitOverlays?, spritePx?, defaultSymbolColor? }.
Feature render-prop contract
The adapter reads only these props, picked by the layer's kind. Bake them on
the features in your controller (resolving your domain style) — there is no
setStyle(DomainStyle).
kind |
props read on each feature |
|---|---|
fill |
fillColor, fillOpacity, stroke?, strokeWidth?, strokeOpacity? |
line |
stroke, strokeWidth, dash? (number[]), strokeOpacity? |
symbol |
symbol (sprite id), size? (×spritePx), rotation? (deg, cw), symbolColor? |
text |
text, textColor, textSize, textHalo?, textBackground?, textBorder?, textBorderWidth?, textBoxSize?, textBoxRadius?, maxWidth?, rotation? |
circle |
role?, control?, collinear?, fill?, stroke?, radius?, strokeWidth?, icon? (data-URI), symbol? (sprite id), iconRotate? (deg, cw), symbolColor? |
Cross-cutting conventions:
role— present on any draggable handle/guide; names what the drag targets ("center","radius","v0","lon", …). DrivescursorForHitand the drag-vs-pan guard.featureId— on hit-testable features, so a click resolves to a domain object.control: true/collinear: true— style hints you bake into the other props (the adapter does not special-case them beyond the cursor).- rotation (
rotation/iconRotate) is degrees, clockwise, identical on all three engines.
Notes per engine
- A
lineoverlay may also containPolygonfeatures (e.g. wind-barb saw teeth): they are filled withfillColor(falling back tostroke). - A
filloverlay draws an outline only when a feature carriesstroke. - Rotatable handle glyphs (
icondata-URI orsymbolsprite) render over the dot on acircleoverlay. On MapLibre, data-URIs are materialized lazily viastyleimagemissing; sprites are tinted persymbolColor. - A label box is drawn behind a
textfeature only when it carriestextBackground(fill) and/ortextBorder(outline).textBoxSize(small/medium/large, defaultmedium) tunes its padding,textBorderWidth(small/medium/large, defaultmedium≈ 1.4px) the border width, andtextBoxRadius(none(default)/small/medium/round) its corners; the box rotates with the text. Leaflet (CSS) and MapLibre (a per-feature 9-slice image) honour all of them; OpenLayers honours them too excepttextBoxRadius(its native text background is a rectangle).
Sprites
Provide an atlas of inline SVGs (stroke/fill using the currentColor token, which
the adapters re-tint per symbolColor):
await adapter.registerSymbols({ MOD: "<svg …>currentColor…</svg>" });The default atlas and default ink stay in your product (they are domain). The
lib exports the plumbing: colorizeSprite, svgToDataUrl, loadSpriteImage,
SPRITE_PX.
Local development against the sibling libs
sigmet-draw / sigwx-draw resolve this package via TypeScript paths (config
only — no link/copy scripts). Each repo's tsconfig points the bare specifier at the
sibling dist, with the published npm package as a fallback:
// sigmet-draw/tsconfig.json
"paths": {
"@softwarity/draw-adapter": ["../draw-adapter/dist/index", "./node_modules/@softwarity/draw-adapter/dist/index"],
"@softwarity/draw-adapter/maplibre": ["../draw-adapter/dist/maplibre", "./node_modules/@softwarity/draw-adapter/dist/maplibre"]
// …same for /openlayers /leaflet
}So a build compiles against the local dist if the sibling is present, else the
published version. Just build the lib at least once (npm run build); npm run build:watch (tsc -w) gives instant rebuilds, which the consumer's dev server picks up.
Single engine copy matters. A demo that bundles a consumer from a path outside its own
node_modulescan duplicate the engine peer (especially Leaflet / OpenLayers), and two copies break cross-instance checks (Leaflet won't draw the other copy's paths → handles vanish; OpenLayers'instanceof DragPanfails → handle-drag pans the map). The demos forceleaflet/ol/maplibre-glto resolve from their ownnode_modulesviatsconfigpaths, so each engine collapses to a single copy.
Packaging / Node ESM
The published output is real Node ESM and is verified per sub-path in CI
(npm run test:esm). Two things bundlers silently paper over but Node does not,
both handled here: ol/* value imports end in .js (ol ships no exports map),
and maplibre-gl (CJS-only) is imported as a namespace with a runtime ctor
resolve rather than import { Map }. The peer-free entry (.) never imports an
engine, so optional peer deps stay optional.
Toolbar
addToolbar(items, options?) renders a toolbar inside the engine's native control box
and returns the element. You supply the items; the adapter owns the rendering,
placement and click wiring (it knows no action — each item's onClick is yours).
adapter.addToolbar(
[{ id: "circle", title: "Circle", svg: "<svg…>", toggle: true, onClick: () => draw.circle() }],
{ position: "top-left" }, // 12 anchors (flow derived from the edge) + padding / gap / className / tools / clear / snapshot / fullscreen / lock
);A ToolbarItem is { id, title, svg?, toggle?, standalone?, disabled?, onClick?, children?, onRender? }
(a missing svg falls back to a neutral icon; toggle makes a split-button that mirrors its picked
child's icon; standalone marks a utility button).
Active-tool highlight (consumer-driven)
The bar doesn't highlight a tool on click. The consumer drives it: call adapter.setActiveTool(id)
when a tool's mode starts and adapter.setActiveTool(null) when it ends (commit / Escape / cancel) —
so utility buttons (clear / snapshot) never stay lit and the highlight tracks your drawing lifecycle.
id is a ToolbarItem id (a submenu/toggle child highlights its parent bar trigger); one tool is
active at a time. Identical on all three engines. Style it via ToolbarOptions.activeStyle
({ background?, color?, outline?, boxShadow? }, default { background: "#dbeafe" }):
adapter.addToolbar(tools, { activeStyle: { background: "#ffedd5", outline: "2px solid #e8731a" } });
adapter.setActiveTool("cb"); // CB button lit
adapter.setActiveTool(null); // clearedBuilt-in buttons
The adapter appends its own chrome buttons at the end of the bar (they're
standalone, so clicking them never deselects your active tool):
- Lock map — a padlock toggle that freezes pan/zoom/rotate so the map can't move
while drawing (default on;
lock: falsehides it). It'ssetInteractive(false)under the hood, and the lock wins over the controller's transientsetPanEnableduntil you unlock. - Snapshot — the PNG capture button (see Snapshots).
- Fullscreen — a toggle (sits between the snapshot and lock buttons; default on,
fullscreen: falsehides it) that requests the browser Fullscreen API on the map container and resizes the engine to fit. Its icon/tooltip flip with state and stay in sync when you leave fullscreen with Esc. Hidden automatically where the Fullscreen API is unavailable (e.g. an iframe withoutallowfullscreen).
Submenus (flyouts)
Give an item children: ToolbarItem[] and its button becomes a flyout. It opens on
hover (desktop) and on click (touch / when closed), into the map — derived from the
toolbar edge (top ⇒ below, bottom ⇒ above, left ⇒ right, right ⇒ left) so it's never
clipped. An outside press closes it. There are two modes:
Click (default) — the parent is a fixed category; picking a child runs its onClick,
and a click on the (open) parent runs the parent's own optional onClick:
{ id: "shapes", title: "Shapes", svg: SHAPES_ICON, children: [
{ id: "rect", title: "Rectangle", svg: RECT_ICON, onClick: () => draw.rect() },
{ id: "circle", title: "Circle", svg: CIRCLE_ICON, onClick: () => draw.circle() },
]}Toggle (toggle: true, a split button) — the parent mirrors the selected child
(the first one initially) and becomes the active tool; picking a child runs it and makes the
parent adopt its icon; clicking the (open) parent re-runs the selected child:
{ id: "text", title: "Text", toggle: true, children: [
{ id: "label", title: "Label", svg: LABEL_ICON, onClick: () => draw.label() },
{ id: "box", title: "Box", svg: BOX_ICON, onClick: () => draw.box() },
]}Nested — a child can itself have children, becoming a sub-submenu. Each level opens on
the flipped axis, so the menus zig-zag (with a top/bottom bar: bar (horizontal) → submenu (vertical) → sub-submenu (horizontal) → …); a nested trigger shows a chevron pointing the way
its flyout opens. Hover-bridging, click/touch open, sibling auto-collapse and outside-press close
all work at every depth — picking any leaf collapses the whole cascade. Nesting is unlimited in
code, but two levels deep is the practical UX limit:
{ id: "shapes", title: "Shapes", svg: SHAPES_ICON, children: [
{ id: "rect", title: "Rectangle", svg: RECT_ICON, onClick: () => draw.rect() },
{ id: "curves", title: "Curves", svg: CURVES_ICON, children: [ // ← sub-submenu
{ id: "bezier", title: "Bézier", svg: BEZIER_ICON, onClick: () => draw.bezier() },
{ id: "arc", title: "Arc", svg: ARC_ICON, onClick: () => draw.arc() },
]},
]}Snapshots (PNG)
Capture the current map — basemap and overlays — as a PNG Blob. The capture
always returns the Blob; target optionally delivers it too:
const blob = await adapter.snapshot(); // just the Blob ("as on screen")
await adapter.snapshot({ scale: 3 }); // supersample (best-effort)
await adapter.snapshot({ target: "download", filename: "x.png" }); // capture + download the file
await adapter.snapshot({ target: "clipboard" }); // capture + copy to clipboard
await adapter.snapshot({ hideOverlays: ["handles", "edge"] }); // clean drawing, no editing chrome- Always resolves to an
image/pngBlob.scaleis the output pixel-ratio (device px per CSS px); it defaults towindow.devicePixelRatio. target("blob"default ·"download"·"clipboard") is whatsnapshot()does with the PNG — the Blob is returned in every case.hideOverlayslists overlay ids to hide just for this capture (e.g. editing handles/guides) and restore after — so the snapshot shows the clean drawing without the construction chrome. (Toolbar:snapshot: { hideOverlays: [...] }.)- Capture happens inside the engine's render frame, so the host map needs no
special flag (in particular, no
preserveDrawingBufferon the MapLibre/WebGL map). - Leaflet is not supported yet —
snapshot()rejects (tiles are<img>and overlays are SVG/DOM, so there is no single exportable canvas). A DOM-snapshot approach is planned. - Marker widgets are composited into the PNG in their static (non-editable) form on MapLibre/OpenLayers — see Marker widgets. The card-less blob is produced first, so if the DOM→bitmap step taints the canvas (e.g. Safari) the snapshot degrades to the card-less image rather than failing.
scale > 1(medium/high) is supersampling, best-effort: it re-scales the captured composition, which enlarges but does not add real map detail.- Clipboard uses the async Clipboard API — it needs a secure context
(HTTPS/localhost), a user gesture, and only
image/pngis broadly supported.
Toolbar button — one button, two deliveries
addToolbar adds a single camera button. It always offers both deliveries: a
plain click runs onClick (default "download"); a modifier-click (Ctrl on
PC/Linux, ⌘ on Mac) runs the other one.
adapter.addToolbar(tools); // defaults: click → download, ⌘/Ctrl-click → copy
adapter.addToolbar(tools, { snapshot: { quality: "high", onClick: "clipboard" } }); // swapped
adapter.addToolbar(tools, { snapshot: "none" }); // hide it (also: null / false)The snapshot option:
- omitted /
undefined⇒ button with defaults (quality: "native",onClick: "download"), null/false/"none"⇒ no button,{ quality?, onClick? }⇒ configured button.
quality |
output pixel-ratio | notes |
|---|---|---|
low |
1 |
CSS-pixel resolution |
native (default) |
window.devicePixelRatio |
capture as on screen |
medium / high |
2 / 3 |
supersample (best-effort) |
onClick ("download" | "clipboard") just picks which delivery is on the plain
click; the other is always one modifier-click away. The button's tooltip is fixed
per mode and spells both out — e.g. "Snapshot: click to file — ⌘+click to
clipboard" (or, in clipboard mode, "…click to clipboard — ⌘+click to file"). While
you hover, holding the modifier live-swaps the icon (not the tooltip) to preview
which delivery a click will trigger. (The key listeners exist only for the hover's
duration, so there is no global event churn.)
A successful capture plays a brief curtain shutter over the map — two translucent
blades close to the centre and reopen (the map stays faintly visible). It's visual
feedback that doubles as the "copied" confirmation for the otherwise-silent clipboard
delivery. Turn it off with snapshot: { shutter: false } (default true). It honours
prefers-reduced-motion (degrades to a single quick dim) and is exported as
shutterFlash(container, { durationMs? }) for manual use.
The button icon is a camera; the two deliveries differ only by the lens — filled for download, an empty ring for clipboard — and the hover preview swaps between them.
On the Leaflet adapter the button is rendered disabled, with the
unavailability message as its tooltip. Exported helpers: snapshotScale(quality)
(preset→ratio), downloadPng(blob, name?), copyPng(blob), shutterFlash(el).
Keyboard (onKey)
onKey(cb) forwards a normalized KeyEvent on keydown while the map is focused.
It is a raw transport — the adapter has no domain semantics; the consumer
maps keys to actions. The canonical example: Delete/Backspace ⇒ remove the
selected shape.
adapter.onKey((e) => {
if (e.key === "Backspace" || e.key === "Delete") {
e.preventDefault();
controller.deleteSelected(); // domain action lives in the consumer
}
});The KeyEvent carries key, code, ctrl, meta, shift, alt, and
preventDefault() — the last forwards to the native event (e.g. to stop Backspace
from navigating back).
- Scoping / focus. The listener is attached to the map container (not
window), so only the focused map reacts — this is multi-instance safe. The container is made click-focusable (tabindex="-1"if it has none); a keydown then bubbles up from the engine's focused canvas. The map gets focus naturally when the user clicks/draws on it. - Editable-target filtering. Keydowns whose target is an
input/textarea/select/contenteditableare skipped, so typing into the host app's form fields never triggers a map shortcut — the key benefit of centralizing this here. - Lifecycle. The listener is removed in
destroy().
All three engines implement it — listening on the MapLibre getContainer(),
OpenLayers getViewport(), Leaflet getContainer(). The exported helper
bindKeyListener(container, cb) does the same for manual use and returns a teardown
function. FakeAdapter (./testing) supports it too, with a .key("Backspace", { meta: true }) replay helper for unit tests.
Marker widgets
Anchored, inline-editable DOM cards pinned at a lon/lat — a generic, domain-free
primitive for things like a named tropical-cyclone / volcano / spot marker whose name the
forecaster types in place while the lon/lat auto-fills from the marker's position.
(This needs a real <input> — caret, selection, IME, paste, mobile keyboard — which the
rendered text features can't provide; only the adapter can place DOM on the map.)
adapter.setWidgets([{
id: "v1", anchor: { lon: 3, lat: 46 }, origin: "bottom",
border: "#1f2328", radius: "small", padding: "small", font: { color: "#1f2328", size: 13 },
child: { dir: "v", align: "center", gap: 1, items: [
{ kind: "glyph", svg: "<svg>…</svg>", size: 24 },
{ kind: "text", value: "ETNA", editable: true, control: "input", autofocus: true },
{ kind: "coord" },
] },
}]);
adapter.onWidgetEdit(e => updateName(e.id, e.value)); // { id, value } per keystroke
adapter.setCoordFormat(({ lon, lat }) => formatLatLng(lat, lon)); // formats the `coord` linesetWidgets(widgets)is declarative and diffed byid(likesetOverlay): pass the full current set each render. Cards are created / updated in place / removed — a focused input keeps its focus and caret across re-setWidgets, so it's safe to re-push every render.Container (
MarkerWidget) only positions (anchor+origin— which point of the card pins to the anchor, named or a{x,y}fraction) and frames (bg,border,borderWidth,radius,padding,font). It holds exactly one root box;radius/padding/borderWidthreuse the label-box presets so widgets and label boxes look consistent.boxShapeturns the rectangular frame into a contour-following SVG outline —"pentagon-up"/"pentagon-down"("house" shapes) or a custom normalizednumber[][]polygon (points outside[0,1]form a cap/point and the card grows to reserve it);"rect"/absent is the plain CSS box.font.lineHeight(unitless, default1.2) tightens multi-line labels.Boxes (
{ dir: "v"|"h", align?, gap?, color?, size?, items }) do layout (vbox/hbox) and may setcolor/sizethat cascade to descendant text/coord (plain CSS inheritance).Items:
glyph(inline SVG,currentColor-tintable) ·text(a static label; an inline<input>wheneditable— auto-grows,uppercaseenters/emits upper case; or acontrol: "picker"for choosing amongoptions, see below) ·coord(the anchor, formatted bysetCoordFormat, live).Selection / move reuse the pointer model: a click or drag on the card surfaces through
onPointeras a hit{ overlay: "widget", props: { id } }(with the real lon/lat), so your existing select / drag-to-move logic works unchanged. The card never drives map pan/zoom; while an input is focused, presses inside it edit (no select/drag/pan).One implementation, all three engines: the card rides each engine's native anchored-overlay primitive (MapLibre
Marker/ OpenLayersOverlay/ LeafletdivIcon), so it tracks per-frame through pan/zoom and stays screen-upright. It's wired with Pointer Events, so touch works.Read-only sprite mode (
static: true): for a non-selected cartouche, setstatic: trueand the adapter rasterizes thechildtree to a bitmap once and places it as a native icon (MapLibre symbol / OpenLayersIcon/ Leaflet<img>) instead of a live DOM card — so N read-only call-outs cost N icons, not N DOM cards repositioned every frame. It re-rasterizes only whenchild/frame or the device-pixel-ratio change (never on pan/zoom) and is always painted (never hidden by label collision — placement stays the consumer's job). The sprite is draggable and surfaces the same hit as a canvas call-out —onPointer→{ overlay: "text-boxes", props: { featureId: id, labelId } }— so your existing call-out drag/select handles it unchanged (it does not take the DOM-cardwidgethit). It has no internal controls (input/picker/gauge) and ignoresdeletable/buttons;labelId(default"l") pairs withfeatureIdto key the drag.Delete:
deletable: true(or{ title }for a tooltip) shows a bare×in the card's top-right corner; clicking it firesonWidgetDelete({ id })— the lib doesn't remove the card, the consumer drops theidfrom its nextsetWidgets. It's a separate element from the input (so an input-only card stays deletable) and isn't drawn into snapshots.Action buttons:
buttons: [{ event, place?, svg?, bordered?, title?, gap? }]renders small buttons (a+, a pen, …) straddling the card's edges/corners; clicking one firesonWidgetAction({ id, event }).placeis an enum or an array (unioned & deduped):- Edge/corner keywords:
top/bottom/left/right·top-left/top-right/bottom-left/bottom-right·edges/h-edges/v-edges·corners/top-corners/bottom-corners/left-corners/right-corners "axis-top"/"axis-bottom"— centres the button on the gauge track axis (not the card midpoint) and places it at the track's top or bottom end. Robust to label-column width. Intended for+buttons above/below a verticalrangesgauge.gap?: number(px, default0) — pushes the button outward from its reference point. Use withaxis-top/axis-bottomto lift the button clear of a maxed-out knob.
Domain-free: you name the
eventand decide what it does.FakeAdapter.actionWidget.- Edge/corner keywords:
Deselect on window blur: wire
adapter.onBlur(() => deselect())if you want a marker to stop looking editable once the user switches to another window/app. The lib is domain-free — it emits the focus-lost signal, the consumer owns the selection and decides whether to drop it.Picker control: a
textitem withcontrol: "picker"+optionslets the user choose a value, emitting it viaonWidgetEdit({ id, name, value }). The presentation is set bymodeand degrades with the option count so the control stays usable:mode: "carousel"(default) — carousel for ≤5 options (click = next, shift-click = previous, slide effect, cycles in place); a flower for 6–10; a grid beyond 10.mode: "flower"— a radial petal menu: a tap fans the petals out around the control, picking a petal makes it the centre and closes the flower (re-tap the centre to re-open); a grid beyond 10.mode: "grid"— a grid popover, always.
The flower/grid popups are appended to
<body>(position:fixed, JS-placed), so they're never clipped and sit above the map; a press outside closes them, and a press between petals falls through to the map. A tap also selects the card and a press-drag moves it (the control doubles as a drag handle) — it never blocks selecting/dragging. Options are text or glyphs —options: ["ISOL","OCNL","FRQ"]or[{ value:"a", svg:"<svg…>" }, …]. Give each control anameso a card with several editable controls knows which one changed. A picker renders bold so it reads as interactive (vs a static label) without adding width that would shift the value off the anchor; give it an accentcolor(like the gauge/dial controls) so all editable elements share one cue. Each option may carry atitle(its tooltip in the flower/grid + on the trigger; notitle⇒ no tooltip). An open flower/grid is keyboard-navigable — arrows browse, Enter/Space picks, Escape closes (the keys never pan the map) — and closes when you start dragging the card.Gauge / dial value-editors: two node kinds (not text controls) for on-map value editing.
Cursor mode (default):
{ kind: "gauge", min, max, cursors: [{ name, value, label? }] }is a linear slider: 1–3 cursors that can't cross,stepsnapping, an optional one-notchbeyond(off-chart "XXX" ⇒ emitsmin - step/max + step), a filled span + per-cursor labels. When two cursors reach the same value, the central one (middle by index) stays on top and is draggable; the duplicate label is hidden (redundant).Multi-range mode:
{ kind: "gauge", min, max, ranges: [...] }renders N independent[base, top]intervals on ONE shared axis. Intended for multicouche SIGWX/TEMSI (one FL gauge per cloud layer → N ranges per gauge). Each range carries its owncolorfor knobs and labels; ranges overlap freely — the blend of semi-transparencies signals the common zone. Within a range,base ≤ topis enforced; between ranges, no clamping. Dragging a knob emitsonWidgetEdit({ id, name, value })per move; dragging the band (between the two knobs) translates both bounds together (width preserved). Theactivefield (rangeidor index) puts that range on top (z-index) for tie-break when knobs coincide. Band fill (fill?: string): by default the coloured band usescolor. Setfill: ""for a transparent, borderless band (CAT turbulence convention) — knobs and labels remain visible. Setfillto any CSS colour to paint the band differently fromcolor. TheknobStrokegauge field controls the knob border colour in ranges mode (default white,""→ no border). Drag-to-trash (vertical gauges only): a predominantly horizontal drag (|dx| > 8 px,|dx| > |dy|) on a band reveals a trash icon to the right of the card; releasing past 50 px firesonWidgetAction({ id, event: "removeRange:${idx}:${rangeId}" }). Releasing before the threshold snaps the band back — no event. Disabled when only one range remains. Hover-add (canAdd?: boolean, defaultfalse): whencanAdd: true, hovering an empty span of the axis (a gap between or around bands) shows a transient+glyph on the track axis with the snapped FL value beside it. Clicking firesonWidgetAction({ id, event: "addLayerAt:<v>" }). The+is suppressed while dragging a knob or band, when the cursor is over an occupied band or atg.max, and whenevercanAddis falsy. SetcanAdd: false(or omit) on gauges that never support add (CB wafs, …); set ittrueon TEMSI multicouche gauges, and clear it back tofalseonce the layer count reachesrepeat.max.adapter.setWidgets([{ id: "temsi-layers", anchor: { lon: 10, lat: 48 }, child: { dir: "v", items: [{ kind: "gauge", min: 0, max: 450, step: 10, length: 120, active: 1, // render range 1 on top ranges: [ { id: "0", color: "#d1242f", base: { name: "layers.0.baseFL", value: 50, label: "FL050" }, top: { name: "layers.0.topFL", value: 250, label: "FL250" } }, { id: "1", color: "#0969da", base: { name: "layers.1.baseFL", value: 200, label: "FL200" }, top: { name: "layers.1.topFL", value: 400, label: "FL400" } }, ], }] }, }]); adapter.onWidgetEdit(({ id, name, value }) => { // name is list-scoped: "layers.0.baseFL", "layers.1.topFL", … controller.updateLayer(id, name, Number(value)); });{ kind: "dial", name, min, max, value }is a radial sweep (jet speed; speedometer angle) whose label is a readout that follows the knob outside the ring (never rotated). It is a true ring: its centre is transparent to pointer events, so a handle/feature drawn at the dial's centre stays clickable underneath (a press in the hole falls through); the whole couronne (ring band + knob) grabs the value. Dragging streamsonWidgetEdit({ id, name, value })per move (Pointer Events, never drags the card).length/orientation(gauge),sweep/radius(dial), andcolor/labelColor/labelHalo/knobFill/knobStrokestyle them. The guide is a thin, well-marked central line with a wider faint glow on the selected part — the gauge span between cursors (whole line for a single cursor; extended a bit past the cursors, never min→max) and the dial arc from its start to the value. Map-ready defaults: black labels + white halo, knobs in the main colour + white border; pass""to opt a piece out. A11y: knobs arerole="slider"(aria-valuemin/max/now) and arrow keys step the value bystep(or 1% of the range); the picker trigger is a focusable button (Enter/Space/↓ act, ↑ cycles back).controlis the extension point:"input"and"picker"are implemented ("gauge"/"dial"are their ownWidgetNodekinds — see above).FakeAdapter(./testing) records the set and adds.editWidget(id, value, name?)/.dragGauge(id, name, value)/.deleteWidget(id)/.actionWidget(id, event)/.clickWidget(id).
Camera, container & overlay visibility
Read the view, drive it (sparingly), reach the DOM, and toggle layers — all on the three
engines + FakeAdapter:
adapter.getBounds(); // [west, south, east, north] (lon/lat)
adapter.getZoom(); // engine-native zoom
adapter.getContainer(); // the host map's DOM element (attach a panel, measure…)
adapter.fitBounds([w, s, e, n], { padding: 24 }); // frame the drawing — DRIVES the host camera, use sparingly
adapter.setOverlayVisible("guide", false); // hide a layer without dropping its data (lossless)(getCenter() and getViewSpan() — a rough lon/lat span for sizing dropped geometry — are also there.)
Right-click surfaces through onPointer as type: "contextmenu" (the browser menu is
suppressed), carrying the hit + lon/lat — e.g. finish a polygon / delete a vertex. onBlur(cb)
fires when the map's window loses focus, so the consumer can drop transient UI state (e.g. deselect
— see Marker widgets).
Projection & area framing
Frame the camera onto a fixed chart area (dateline-aware), optionally switch the live projection, and outline the area with a dashed frame:
// Switch the live projection. Only OpenLayers actually reprojects.
adapter.setProjection({ // a polar-stereographic CRS (WAFS polar charts)
kind: "proj4", code: "EPSG:3995",
def: "+proj=stere +lat_0=90 +lat_ts=71 +lon_0=0 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs",
});
adapter.viewArea([-90, 0, 30, 90]); // frame a lon/lat bbox; padding/duration optional
adapter.viewArea([110, -10, -110, 72]); // antimeridian-crossing bbox (west > east) → one span
adapter.highlightArea([110, -10, -110, 72], { color: "#666", dash: [6, 4] }); // dashed frame
adapter.highlightArea(null); // clear the frame
adapter.setProjection("mercator"); // back to Web Mercator ("globe" is a MapLibre built-in)setProjection(spec)—"mercator"/"globe"/{ kind: "proj4", code, def }. Only the OpenLayers adapter reprojects: aproj4spec registers the CRS (needs the optionalproj4peer dependency), rebuilds the view in it and re-reads the overlays so handles stay aligned with the basemap. MapLibre handlesmercator/globenatively and ignoresproj4(stays Mercator, warns once); Leaflet is lat/lng-native and ignores any non-mercatorspec (warns once).viewArea(extent, { padding?, duration? })— likefitBoundsbut antimeridian-aware (awest > eastbbox is framed as one span, not the whole globe) and projection-aware (under a non-Mercator OpenLayers view it fits the projected, curved area).highlightArea(extent | null, style?)— a non-interactive dashed frame in a dedicated overlay above the basemap and below the drawing overlays. The frame is a densified geographic polygon, so under a non-Mercator OpenLayers view its edges curve to follow the projection.nullclears it; it never intercepts pointer events.
proj4is an optional peer dependency — install it only to use a{ kind: "proj4" }projection on the OpenLayers adapter (npm i proj4). It is never imported otherwise, so Mercator-only and MapLibre/Leaflet consumers don't need it.
API surface
MapAdapter — ready, registerSymbols, setOverlay, setOverlayVisible, snapshot,
setTooltip, addToolbar, setActiveTool, getCenter, getViewSpan, getBounds, getZoom, getContainer,
fitBounds, setProjection, viewArea, highlightArea, project, unproject, onViewChange, setPanEnabled, setDoubleClickZoom,
setInteractive, setCursor, onPointer, onKey, onBlur, setWidgets, onWidgetEdit,
onWidgetDelete, onWidgetAction, setCoordFormat, destroy.
onKey and marker widgets are documented above; bindKeyListener(container, cb) and
defaultCoordFormat(ll) are exported for manual use.
A product simply never calls the methods it doesn't need (sigmet ignores
project/unproject/onViewChange/registerSymbols).
License
MIT