@softwarity/draw-adapter
v0.6.0
Published
Headless map adapter (grafts on MapLibre / OpenLayers / Leaflet, Terra-Draw-style) for the @softwarity drawing libs. Generic, data-driven, zero domain types.
Maintainers
Readme
@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 / lock / snapshot
);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).
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.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
