{"id":51200329,"url":"https://github.com/softwarity/draw-adapter","last_synced_at":"2026-06-28T00:01:36.359Z","repository":{"id":362868963,"uuid":"1261090395","full_name":"softwarity/draw-adapter","owner":"softwarity","description":"Engine-agnostic map drawing \u0026 on-map widgets for MapLibre GL, OpenLayers and Leaflet (TypeScript).","archived":false,"fork":false,"pushed_at":"2026-06-25T03:53:49.000Z","size":483,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-25T05:21:20.150Z","etag":null,"topics":["drawing","geojson","gis","leaflet","map","maplibre","openlayers","typescript","web-mapping"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/softwarity.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-06T08:13:25.000Z","updated_at":"2026-06-25T03:53:53.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/softwarity/draw-adapter","commit_stats":null,"previous_names":["softwarity/draw-adapter"],"tags_count":27,"template":false,"template_full_name":null,"purl":"pkg:github/softwarity/draw-adapter","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/softwarity%2Fdraw-adapter","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/softwarity%2Fdraw-adapter/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/softwarity%2Fdraw-adapter/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/softwarity%2Fdraw-adapter/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/softwarity","download_url":"https://codeload.github.com/softwarity/draw-adapter/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/softwarity%2Fdraw-adapter/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34872279,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-27T02:00:06.362Z","response_time":126,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["drawing","geojson","gis","leaflet","map","maplibre","openlayers","typescript","web-mapping"],"created_at":"2026-06-28T00:01:35.759Z","updated_at":"2026-06-28T00:01:36.338Z","avatar_url":"https://github.com/softwarity.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# @softwarity/draw-adapter\n\n[![npm](https://img.shields.io/npm/v/@softwarity/draw-adapter.svg)](https://www.npmjs.com/package/@softwarity/draw-adapter)\n[![CI](https://github.com/softwarity/draw-adapter/actions/workflows/main.yml/badge.svg)](https://github.com/softwarity/draw-adapter/actions/workflows/main.yml)\n[![license](https://img.shields.io/npm/l/@softwarity/draw-adapter.svg)](./LICENSE)\n[![types](https://img.shields.io/npm/types/@softwarity/draw-adapter.svg)](./dist/index.d.ts)\n\nHeadless, **generic** map adapter for the @softwarity drawing libs\n(`sigmet-draw`, `sigwx-draw`, …). It *grafts* a drawing onto a host-owned map\n(à la Terra Draw): the host owns the basemap, controls, projection and zoom; the\nadapter only adds the drawing overlays, reports pointer events in lon/lat,\nregisters a glyph sprite atlas and optionally renders a native toolbar.\n\nOne set of engine implementations — **MapLibre GL**, **OpenLayers**, **Leaflet** —\nshared by every product. The adapter knows **no domain type**: it is driven by a\ndeclarative `LayerSpec[]` manifest and reads a fixed set of *render props* off each\nfeature. Each product's controller resolves its domain style into those props\n*before* `setOverlay`, so styling is entirely **data-driven** and the three engines\nrender identically.\n\n\u003e **Why this exists.** `sigmet-draw` and `sigwx-draw` used to each ship their own\n\u003e MapLibre + OpenLayers adapters — near-twin implementations where every fix had to be\n\u003e re-applied in each. This package replaces all of that: **all three engines (MapLibre,\n\u003e OpenLayers, Leaflet) are implemented here, once** — a single, canonical map layer both\n\u003e products graft onto.\n\n## Used by\n\n| Library | What it draws | Repo · demo |\n|---------|---------------|-------------|\n| [`@softwarity/sigmet-draw`](https://github.com/softwarity/sigmet-draw) | SIGMET/AIRMET geometries ↔ ICAO TAC | [repo](https://github.com/softwarity/sigmet-draw) · [demo](https://softwarity.github.io/sigmet-draw/) |\n| [`@softwarity/sigwx-draw`](https://github.com/softwarity/sigwx-draw) | SIGWX significant-weather charts | [repo](https://github.com/softwarity/sigwx-draw) · [demo](https://softwarity.github.io/sigwx-draw/) |\n\n## Engine support\n\n| Capability | MapLibre GL | OpenLayers | Leaflet |\n|------------|:----------:|:----------:|:-------:|\n| fill / line / circle / symbol / text | ✅ | ✅ | ✅ |\n| data-driven props (identical render) | ✅ | ✅ | ✅ |\n| rotatable handle glyphs (`icon` / `symbol` + `iconRotate`) | ✅¹ | ✅ | ✅ |\n| label box (`textBackground`/`textBorder` + `textBoxSize`/`textBoxRadius`) | ✅⁴ | ✅ (no radius) | ✅ |\n| `project`/`unproject`/`onViewChange`/`getViewSpan` | ✅ | ✅ | ✅ |\n| drag-vs-pan guard | n/a² | ✅ (capture-phase) | ✅ (capture-phase) |\n| keyboard `onKey` (focused-map keydown) | ✅ | ✅ | ✅ |\n| lock map (`setInteractive` / toolbar lock button) | ✅ | ✅ | ✅ |\n| PNG `snapshot()` (basemap + overlays + widget cards⁵) | ✅ | ✅ | ❌³ |\n| anchored **marker widgets** (`setWidgets` — editable cards) | ✅ | ✅ | ✅ |\n| camera read/drive + container (`getBounds`/`getZoom`/`fitBounds`/`getContainer`) | ✅ | ✅ | ✅ |\n| area framing (`viewArea`, dateline-aware) · dashed frame (`highlightArea`) | ✅ | ✅ | ✅ |\n| live **reprojection** (`setProjection({kind:\"proj4\"})`) | ❌⁷ | ✅ | ❌⁷ |\n| overlay visibility (`setOverlayVisible`) · right-click (`contextmenu`) · window-blur (`onBlur`) | ✅ | ✅ | ✅ |\n| touch: tap-to-select \u0026 edit widgets | ✅ | ✅ | ✅ |\n| touch: freehand **drawing** (drag to draw) | ❌⁶ | ✅ | ❌⁶ |\n| peer dependency | `maplibre-gl \u003e=5` | `ol \u003e=9` (+ `proj4 \u003e=2.8`, optional⁷) | `leaflet \u003e=1.9` |\n\n¹ data-URI icons are materialized lazily via `styleimagemissing`; sprites are tinted per `symbolColor`.\n² MapLibre's `dragPan` is toggled directly by the controller, no capture-phase hack needed.\n³ Leaflet has no single exportable canvas (tiles are `\u003cimg\u003e`, overlays SVG/DOM); `snapshot()` rejects and the toolbar button is shown **disabled**. A DOM-snapshot approach is planned.\n⁴ 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).\n⁵ The PNG composites the [marker widgets](#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.\n⁶ 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.\n⁷ 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.\n\n## Install\n\n```bash\nnpm i @softwarity/draw-adapter\n# plus the engine(s) you use (optional peer deps):\nnpm i maplibre-gl        # or: ol  | leaflet\n```\n\nSub-path exports keep the engines isolated — importing `./openlayers` never pulls\nin MapLibre or Leaflet:\n\n```ts\nimport type { MapAdapter, LayerSpec } from \"@softwarity/draw-adapter\";\nimport { MapLibreAdapter, createMapLibreMap } from \"@softwarity/draw-adapter/maplibre\";\nimport { OpenLayersAdapter } from \"@softwarity/draw-adapter/openlayers\";\nimport { LeafletAdapter } from \"@softwarity/draw-adapter/leaflet\";\nimport { FakeAdapter } from \"@softwarity/draw-adapter/testing\"; // unit tests\n```\n\n## Usage\n\n```ts\nconst LAYERS: LayerSpec[] = [\n  { id: \"area\",    kind: \"fill\" },\n  { id: \"guide\",   kind: \"line\" },\n  { id: \"symbols\", kind: \"symbol\" },\n  { id: \"label\",   kind: \"text\" },\n  { id: \"handles\", kind: \"circle\" },\n];\nconst HIT = new Set([\"handles\", \"guide\", \"area\"]);\n\nconst map = createMapLibreMap({ container: \"map\", center: [2.3, 48.8], zoom: 5 });\nconst adapter = new MapLibreAdapter({ map, layers: LAYERS, hitOverlays: HIT });\n\nawait adapter.ready();\nadapter.onPointer((ev) =\u003e { /* controller orchestrates here */ });\n\n// push a FeatureCollection whose features already carry their render props:\nadapter.setOverlay(\"area\", {\n  type: \"FeatureCollection\",\n  features: [{ type: \"Feature\", geometry: poly, properties: { fillColor: \"#58a6ff\", fillOpacity: 0.2 } }],\n});\n```\n\nAll three adapters take the same options: `{ map, layers, hitOverlays?, spritePx?, defaultSymbolColor? }`.\n\n## Feature render-prop contract\n\nThe adapter reads only these props, picked by the layer's `kind`. **Bake them on\nthe features** in your controller (resolving your domain style) — there is no\n`setStyle(DomainStyle)`.\n\n| `kind`   | props read on each feature |\n|----------|----------------------------|\n| `fill`   | `fillColor`, `fillOpacity`, `stroke?`, `strokeWidth?`, `strokeOpacity?` |\n| `line`   | `stroke`, `strokeWidth`, `dash?` (`number[]`), `strokeOpacity?` |\n| `symbol` | `symbol` (sprite id), `size?` (×spritePx), `rotation?` (deg, cw), `symbolColor?` |\n| `text`   | `text`, `textColor`, `textSize`, `textHalo?`, `textBackground?`, `textBorder?`, `textBorderWidth?`, `textBoxSize?`, `textBoxRadius?`, `maxWidth?`, `rotation?` |\n| `circle` | `role?`, `control?`, `collinear?`, `fill?`, `stroke?`, `radius?`, `strokeWidth?`, `icon?` (data-URI), `symbol?` (sprite id), `iconRotate?` (deg, cw), `symbolColor?` |\n\nCross-cutting conventions:\n\n- **`role`** — present on any draggable handle/guide; names what the drag targets\n  (`\"center\"`, `\"radius\"`, `\"v0\"`, `\"lon\"`, …). Drives `cursorForHit` and the\n  drag-vs-pan guard.\n- **`featureId`** — on hit-testable features, so a click resolves to a domain object.\n- **`control: true`** / **`collinear: true`** — style hints you bake into the\n  other props (the adapter does not special-case them beyond the cursor).\n- **rotation** (`rotation` / `iconRotate`) is **degrees, clockwise**, identical on\n  all three engines.\n\n### Notes per engine\n\n- A `line` overlay may also contain `Polygon` features (e.g. wind-barb saw teeth):\n  they are filled with `fillColor` (falling back to `stroke`).\n- A `fill` overlay draws an outline only when a feature carries `stroke`.\n- Rotatable handle glyphs (`icon` data-URI **or** `symbol` sprite) render over the\n  dot on a `circle` overlay. On MapLibre, data-URIs are materialized lazily via\n  `styleimagemissing`; sprites are tinted per `symbolColor`.\n- A **label box** is drawn behind a `text` feature **only** when it carries\n  `textBackground` (fill) and/or `textBorder` (outline). `textBoxSize`\n  (`small`/`medium`/`large`, default `medium`) tunes its padding, `textBorderWidth`\n  (`small`/`medium`/`large`, default `medium` ≈ 1.4px) the **border width**, and `textBoxRadius`\n  (`none`(default)/`small`/`medium`/`round`) its corners; the box rotates with the text.\n  Leaflet (CSS) and MapLibre (a per-feature 9-slice image) honour all of them; OpenLayers\n  honours them too **except** `textBoxRadius` (its native text background is a rectangle).\n\n## Sprites\n\nProvide an atlas of inline SVGs (stroke/fill using the `currentColor` token, which\nthe adapters re-tint per `symbolColor`):\n\n```ts\nawait adapter.registerSymbols({ MOD: \"\u003csvg …\u003ecurrentColor…\u003c/svg\u003e\" });\n```\n\nThe default atlas and default ink stay in **your** product (they are domain). The\nlib exports the plumbing: `colorizeSprite`, `svgToDataUrl`, `loadSpriteImage`,\n`SPRITE_PX`.\n\n## Local development against the sibling libs\n\n`sigmet-draw` / `sigwx-draw` resolve this package via **TypeScript `paths`** (config\nonly — no link/copy scripts). Each repo's `tsconfig` points the bare specifier at the\nsibling `dist`, with the published npm package as a fallback:\n\n```jsonc\n// sigmet-draw/tsconfig.json\n\"paths\": {\n  \"@softwarity/draw-adapter\":            [\"../draw-adapter/dist/index\", \"./node_modules/@softwarity/draw-adapter/dist/index\"],\n  \"@softwarity/draw-adapter/maplibre\":   [\"../draw-adapter/dist/maplibre\", \"./node_modules/@softwarity/draw-adapter/dist/maplibre\"]\n  // …same for /openlayers /leaflet\n}\n```\n\nSo a build compiles against the **local** `dist` if the sibling is present, else the\npublished version. Just build the lib at least once (`npm run build`); `npm run\nbuild:watch` (`tsc -w`) gives instant rebuilds, which the consumer's dev server picks up.\n\n\u003e **Single engine copy matters.** A demo that bundles a consumer from a path *outside its\n\u003e own `node_modules`* can duplicate the engine peer (especially **Leaflet** / **OpenLayers**),\n\u003e and two copies break cross-instance checks (Leaflet won't draw the other copy's paths →\n\u003e handles vanish; OpenLayers' `instanceof DragPan` fails → handle-drag pans the map). The\n\u003e demos force `leaflet`/`ol`/`maplibre-gl` to resolve from their own `node_modules` via\n\u003e `tsconfig` `paths`, so each engine collapses to a single copy.\n\n### Packaging / Node ESM\n\nThe published output is real Node ESM and is verified per sub-path in CI\n(`npm run test:esm`). Two things bundlers silently paper over but Node does not,\nboth handled here: `ol/*` value imports end in `.js` (ol ships no `exports` map),\nand `maplibre-gl` (CJS-only) is imported as a namespace with a runtime ctor\nresolve rather than `import { Map }`. The peer-free entry (`.`) never imports an\nengine, so optional peer deps stay optional.\n\n## Toolbar\n\n`addToolbar(items, options?)` renders a toolbar inside the engine's native control box\nand returns the element. You supply the **items**; the adapter owns the **rendering,\nplacement and click wiring** (it knows no action — each item's `onClick` is yours).\n\n```ts\nadapter.addToolbar(\n  [{ id: \"circle\", title: \"Circle\", svg: \"\u003csvg…\u003e\", toggle: true, onClick: () =\u003e draw.circle() }],\n  { position: \"top-left\" }, // 12 anchors (flow derived from the edge) + padding / gap / className / tools / clear / snapshot / fullscreen / lock\n);\n```\n\nA `ToolbarItem` is `{ id, title, svg?, toggle?, standalone?, disabled?, onClick?, children?, onRender? }`\n(a missing `svg` falls back to a neutral icon; `toggle` makes a split-button that mirrors its picked\nchild's icon; `standalone` marks a utility button).\n\n### Active-tool highlight (consumer-driven)\n\nThe bar **doesn't** highlight a tool on click. The consumer drives it: call `adapter.setActiveTool(id)`\nwhen a tool's mode starts and `adapter.setActiveTool(null)` when it ends (commit / Escape / cancel) —\nso utility buttons (clear / snapshot) never stay lit and the highlight tracks your drawing lifecycle.\n`id` is a `ToolbarItem` id (a submenu/toggle child highlights its parent **bar trigger**); one tool is\nactive at a time. Identical on all three engines. Style it via `ToolbarOptions.activeStyle`\n(`{ background?, color?, outline?, boxShadow? }`, default `{ background: \"#dbeafe\" }`):\n\n```ts\nadapter.addToolbar(tools, { activeStyle: { background: \"#ffedd5\", outline: \"2px solid #e8731a\" } });\nadapter.setActiveTool(\"cb\");   // CB button lit\nadapter.setActiveTool(null);   // cleared\n```\n\n### Built-in buttons\n\nThe adapter appends its own **chrome** buttons at the end of the bar (they're\n`standalone`, so clicking them never deselects your active tool):\n\n- **Lock map** — a padlock toggle that freezes pan/zoom/rotate so the map can't move\n  while drawing (default on; `lock: false` hides it). It's `setInteractive(false)`\n  under the hood, and the lock **wins** over the controller's transient `setPanEnabled`\n  until you unlock.\n- **Snapshot** — the PNG capture button (see [Snapshots](#snapshots-png)).\n- **Fullscreen** — a toggle (sits **between the snapshot and lock buttons**; default on,\n  `fullscreen: false` hides it) that requests the browser **Fullscreen API** on the map container and\n  resizes the engine to fit. Its icon/tooltip flip with state and stay in sync when you leave\n  fullscreen with **Esc**. Hidden automatically where the Fullscreen API is unavailable (e.g. an\n  iframe without `allowfullscreen`).\n\n### Submenus (flyouts)\n\nGive an item `children: ToolbarItem[]` and its button becomes a **flyout**. It opens on\n**hover** (desktop) and on click (touch / when closed), **into the map** — derived from the\ntoolbar edge (`top ⇒ below`, `bottom ⇒ above`, `left ⇒ right`, `right ⇒ left`) so it's never\nclipped. An outside press closes it. There are two modes:\n\n**Click** (default) — the parent is a fixed category; picking a child runs its `onClick`,\nand a click on the (open) parent runs the parent's own optional `onClick`:\n\n```ts\n{ id: \"shapes\", title: \"Shapes\", svg: SHAPES_ICON, children: [\n  { id: \"rect\",   title: \"Rectangle\", svg: RECT_ICON,   onClick: () =\u003e draw.rect() },\n  { id: \"circle\", title: \"Circle\",    svg: CIRCLE_ICON, onClick: () =\u003e draw.circle() },\n]}\n```\n\n**Toggle** (`toggle: true`, a split button) — the parent mirrors the **selected** child\n(the first one initially) and becomes the active tool; picking a child runs it and makes the\nparent adopt its icon; clicking the (open) parent re-runs the selected child:\n\n```ts\n{ id: \"text\", title: \"Text\", toggle: true, children: [\n  { id: \"label\", title: \"Label\", svg: LABEL_ICON, onClick: () =\u003e draw.label() },\n  { id: \"box\",   title: \"Box\",   svg: BOX_ICON,   onClick: () =\u003e draw.box() },\n]}\n```\n\n**Nested** — a child can itself have `children`, becoming a **sub-submenu**. Each level opens on\nthe **flipped axis**, so the menus zig-zag (with a top/bottom bar: `bar (horizontal) → submenu\n(vertical) → sub-submenu (horizontal) → …`); a nested trigger shows a chevron pointing the way\nits flyout opens. Hover-bridging, click/touch open, sibling auto-collapse and outside-press close\nall work at every depth — picking any leaf collapses the whole cascade. Nesting is unlimited in\ncode, but **two levels deep** is the practical UX limit:\n\n```ts\n{ id: \"shapes\", title: \"Shapes\", svg: SHAPES_ICON, children: [\n  { id: \"rect\",   title: \"Rectangle\", svg: RECT_ICON, onClick: () =\u003e draw.rect() },\n  { id: \"curves\", title: \"Curves\", svg: CURVES_ICON, children: [   // ← sub-submenu\n    { id: \"bezier\", title: \"Bézier\", svg: BEZIER_ICON, onClick: () =\u003e draw.bezier() },\n    { id: \"arc\",    title: \"Arc\",    svg: ARC_ICON,    onClick: () =\u003e draw.arc() },\n  ]},\n]}\n```\n\n## Snapshots (PNG)\n\nCapture the current map — basemap **and** overlays — as a PNG `Blob`. The capture\nalways returns the Blob; `target` optionally **delivers** it too:\n\n```ts\nconst blob = await adapter.snapshot();                          // just the Blob (\"as on screen\")\nawait adapter.snapshot({ scale: 3 });                           // supersample (best-effort)\nawait adapter.snapshot({ target: \"download\", filename: \"x.png\" }); // capture + download the file\nawait adapter.snapshot({ target: \"clipboard\" });               // capture + copy to clipboard\nawait adapter.snapshot({ hideOverlays: [\"handles\", \"edge\"] }); // clean drawing, no editing chrome\n```\n\n- Always resolves to an `image/png` Blob. `scale` is the output **pixel-ratio**\n  (device px per CSS px); it defaults to `window.devicePixelRatio`.\n- `target` (`\"blob\"` default · `\"download\"` · `\"clipboard\"`) is what `snapshot()`\n  does with the PNG — the Blob is returned in every case.\n- `hideOverlays` lists overlay ids to hide **just for this capture** (e.g. editing\n  handles/guides) and restore after — so the snapshot shows the clean drawing without\n  the construction chrome. (Toolbar: `snapshot: { hideOverlays: [...] }`.)\n- Capture happens **inside the engine's render frame**, so the host map needs **no\n  special flag** (in particular, no `preserveDrawingBuffer` on the MapLibre/WebGL map).\n- **Leaflet is not supported yet** — `snapshot()` rejects (tiles are `\u003cimg\u003e` and\n  overlays are SVG/DOM, so there is no single exportable canvas). A DOM-snapshot\n  approach is planned.\n- **Marker widgets** are composited into the PNG in their static (non-editable) form\n  on MapLibre/OpenLayers — see [Marker widgets](#marker-widgets). The card-less blob is\n  produced first, so if the DOM→bitmap step taints the canvas (e.g. Safari) the snapshot\n  **degrades** to the card-less image rather than failing.\n- `scale \u003e 1` (`medium`/`high`) is **supersampling, best-effort**: it re-scales the\n  captured composition, which enlarges but does not add real map detail.\n- **Clipboard** uses the async Clipboard API — it needs a **secure context**\n  (HTTPS/localhost), a user gesture, and only `image/png` is broadly supported.\n\n### Toolbar button — one button, two deliveries\n\n`addToolbar` adds a single camera button. It always offers **both** deliveries: a\nplain click runs `onClick` (default `\"download\"`); a **modifier-click** (Ctrl on\nPC/Linux, ⌘ on Mac) runs the other one.\n\n```ts\nadapter.addToolbar(tools);                            // defaults: click → download, ⌘/Ctrl-click → copy\nadapter.addToolbar(tools, { snapshot: { quality: \"high\", onClick: \"clipboard\" } }); // swapped\nadapter.addToolbar(tools, { snapshot: \"none\" });     // hide it (also: null / false)\n```\n\nThe `snapshot` option:\n\n- **omitted / `undefined`** ⇒ button with **defaults** (`quality: \"native\"`, `onClick: \"download\"`),\n- **`null` / `false` / `\"none\"`** ⇒ no button,\n- **`{ quality?, onClick? }`** ⇒ configured button.\n\n| `quality` | output pixel-ratio | notes |\n|--------|--------------------|-------|\n| `low` | `1` | CSS-pixel resolution |\n| `native` *(default)* | `window.devicePixelRatio` | capture as on screen |\n| `medium` / `high` | `2` / `3` | supersample (best-effort) |\n\n`onClick` (`\"download\"` | `\"clipboard\"`) just picks which delivery is on the plain\nclick; the other is always one modifier-click away. The button's tooltip is **fixed\nper mode** and spells both out — e.g. *\"Snapshot: click to file — ⌘+click to\nclipboard\"* (or, in clipboard mode, *\"…click to clipboard — ⌘+click to file\"*). While\nyou hover, holding the modifier **live-swaps the icon** (not the tooltip) to preview\nwhich delivery a click will trigger. (The key listeners exist only for the hover's\nduration, so there is no global event churn.)\n\nA successful capture plays a brief **curtain shutter** over the map — two translucent\nblades close to the centre and reopen (the map stays faintly visible). It's visual\nfeedback that doubles as the *\"copied\"* confirmation for the otherwise-silent clipboard\ndelivery. Turn it off with `snapshot: { shutter: false }` (default `true`). It honours\n`prefers-reduced-motion` (degrades to a single quick dim) and is exported as\n`shutterFlash(container, { durationMs? })` for manual use.\n\nThe button icon is a camera; the two deliveries differ only by the **lens** — filled\nfor download, an empty ring for clipboard — and the hover preview swaps between them.\n\nOn the **Leaflet** adapter the button is rendered **disabled**, with the\nunavailability message as its tooltip. Exported helpers: `snapshotScale(quality)`\n(preset→ratio), `downloadPng(blob, name?)`, `copyPng(blob)`, `shutterFlash(el)`.\n\n## Keyboard (`onKey`)\n\n`onKey(cb)` forwards a normalized `KeyEvent` on **keydown while the map is focused**.\nIt is a **raw transport** — the adapter has **no** domain semantics; the *consumer*\nmaps keys to actions. The canonical example: `Delete`/`Backspace` ⇒ remove the\nselected shape.\n\n```ts\nadapter.onKey((e) =\u003e {\n  if (e.key === \"Backspace\" || e.key === \"Delete\") {\n    e.preventDefault();\n    controller.deleteSelected(); // domain action lives in the consumer\n  }\n});\n```\n\nThe `KeyEvent` carries `key`, `code`, `ctrl`, `meta`, `shift`, `alt`, and\n`preventDefault()` — the last forwards to the native event (e.g. to stop `Backspace`\nfrom navigating back).\n\n- **Scoping / focus.** The listener is attached to the **map container** (not\n  `window`), so only the *focused* map reacts — this is multi-instance safe. The\n  container is made click-focusable (`tabindex=\"-1\"` if it has none); a keydown then\n  bubbles up from the engine's focused canvas. The map gets focus naturally when the\n  user clicks/draws on it.\n- **Editable-target filtering.** Keydowns whose target is an `input` / `textarea` /\n  `select` / `contenteditable` are skipped, so typing into the host app's form fields\n  never triggers a map shortcut — the key benefit of centralizing this here.\n- **Lifecycle.** The listener is removed in `destroy()`.\n\nAll three engines implement it — listening on the MapLibre `getContainer()`,\nOpenLayers `getViewport()`, Leaflet `getContainer()`. The exported helper\n`bindKeyListener(container, cb)` does the same for manual use and returns a teardown\nfunction. `FakeAdapter` (`./testing`) supports it too, with a `.key(\"Backspace\",\n{ meta: true })` replay helper for unit tests.\n\n## Marker widgets\n\nAnchored, inline-editable **DOM cards** pinned at a `lon/lat` — a generic, domain-free\nprimitive for things like a named tropical-cyclone / volcano / spot marker whose name the\nforecaster types **in place** while the lon/lat auto-fills from the marker's position.\n(This needs a real `\u003cinput\u003e` — caret, selection, IME, paste, mobile keyboard — which the\nrendered `text` features can't provide; only the adapter can place DOM on the map.)\n\n```ts\nadapter.setWidgets([{\n  id: \"v1\", anchor: { lon: 3, lat: 46 }, origin: \"bottom\",\n  border: \"#1f2328\", radius: \"small\", padding: \"small\", font: { color: \"#1f2328\", size: 13 },\n  child: { dir: \"v\", align: \"center\", gap: 1, items: [\n    { kind: \"glyph\", svg: \"\u003csvg\u003e…\u003c/svg\u003e\", size: 24 },\n    { kind: \"text\", value: \"ETNA\", editable: true, control: \"input\", autofocus: true },\n    { kind: \"coord\" },\n  ] },\n}]);\nadapter.onWidgetEdit(e =\u003e updateName(e.id, e.value));            // { id, value } per keystroke\nadapter.setCoordFormat(({ lon, lat }) =\u003e formatLatLng(lat, lon)); // formats the `coord` line\n```\n\n- **`setWidgets(widgets)`** is declarative and **diffed by `id`** (like `setOverlay`): pass the\n  full current set each render. Cards are created / updated **in place** / removed — a focused\n  input **keeps its focus and caret** across re-`setWidgets`, so it's safe to re-push every render.\n- **Container** (`MarkerWidget`) only *positions* (`anchor` + `origin` — which point of the card\n  pins to the anchor, named or a `{x,y}` fraction) and *frames* (`bg`, `border`, `borderWidth`,\n  `radius`, `padding`, `font`). It holds exactly one root **box**; `radius`/`padding`/`borderWidth`\n  reuse the label-box presets so widgets and label boxes look consistent. **`boxShape`** turns the\n  rectangular frame into a contour-following **SVG** outline — `\"pentagon-up\"`/`\"pentagon-down\"`\n  (\"house\" shapes) or a custom normalized `number[][]` polygon (points outside `[0,1]` form a\n  cap/point and the card grows to reserve it); `\"rect\"`/absent is the plain CSS box. `font.lineHeight`\n  (unitless, default `1.2`) tightens multi-line labels.\n- **Boxes** (`{ dir: \"v\"|\"h\", align?, gap?, color?, size?, items }`) do layout (vbox/hbox) and may\n  set `color`/`size` that **cascade** to descendant text/coord (plain CSS inheritance).\n- **Items:** `glyph` (inline SVG, `currentColor`-tintable) · `text` (a static label; an inline\n  `\u003cinput\u003e` when `editable` — auto-grows, `uppercase` enters/emits upper case; or a\n  `control: \"picker\"` for choosing among `options`, see below) · `coord` (the anchor, formatted by\n  `setCoordFormat`, **live**).\n- **Selection / move reuse the pointer model:** a click or drag on the card surfaces through\n  `onPointer` as a hit `{ overlay: \"widget\", props: { id } }` (with the real lon/lat), so your\n  existing select / drag-to-move logic works unchanged. The card **never** drives map pan/zoom;\n  while an input is focused, presses inside it edit (no select/drag/pan).\n- **One implementation, all three engines:** the card rides each engine's native anchored-overlay\n  primitive (MapLibre `Marker` / OpenLayers `Overlay` / Leaflet `divIcon`), so it tracks per-frame\n  through pan/zoom and stays screen-upright. It's wired with Pointer Events, so touch works.\n- **Read-only sprite mode (`static: true`):** for a **non-selected** cartouche, set `static: true` and\n  the adapter **rasterizes** the `child` tree to a bitmap **once** and places it as a **native icon**\n  (MapLibre symbol / OpenLayers `Icon` / Leaflet `\u003cimg\u003e`) instead of a live DOM card — so N read-only\n  call-outs cost N icons, not N DOM cards repositioned every frame. It re-rasterizes only when\n  `child`/frame or the device-pixel-ratio change (never on pan/zoom) and is **always painted** (never\n  hidden by label collision — placement stays the consumer's job). The sprite is draggable and surfaces\n  the **same hit as a canvas call-out** — `onPointer` →\n  `{ overlay: \"text-boxes\", props: { featureId: id, labelId } }` — so your existing call-out drag/select\n  handles it unchanged (it does **not** take the DOM-card `widget` hit). It has **no internal controls**\n  (input/picker/gauge) and ignores `deletable`/`buttons`; `labelId` (default `\"l\"`) pairs with\n  `featureId` to key the drag.\n- **Delete:** `deletable: true` (or `{ title }` for a tooltip) shows a bare `×` in the card's **top-right corner**; clicking it\n  fires `onWidgetDelete({ id })` — the lib doesn't remove the card, the consumer drops the `id`\n  from its next `setWidgets`. It's a **separate element** from the input (so an input-only card\n  stays deletable) and isn't drawn into snapshots.\n- **Action buttons:** `buttons: [{ event, place?, svg?, bordered?, title?, gap? }]` renders small\n  buttons (a `+`, a pen, …) straddling the card's edges/corners; clicking one fires\n  `onWidgetAction({ id, event })`.\n  `place` is an enum or an array (unioned \u0026 deduped):\n  - Edge/corner keywords: `top`/`bottom`/`left`/`right` · `top-left`/`top-right`/`bottom-left`/`bottom-right`\n    · `edges`/`h-edges`/`v-edges` · `corners`/`top-corners`/`bottom-corners`/`left-corners`/`right-corners`\n  - **`\"axis-top\"` / `\"axis-bottom\"`** — centres the button on the **gauge track axis** (not the card\n    midpoint) and places it at the track's top or bottom end. Robust to label-column width. Intended\n    for `+` buttons above/below a vertical `ranges` gauge.\n  - **`gap?: number`** (px, default `0`) — pushes the button outward from its reference point. Use\n    with `axis-top`/`axis-bottom` to lift the button clear of a maxed-out knob.\n\n  Domain-free: you name the `event` and decide what it does. `FakeAdapter.actionWidget`.\n- **Deselect on window blur:** wire `adapter.onBlur(() =\u003e deselect())` if you want a marker to stop\n  looking editable once the user switches to another window/app. The lib is domain-free — it emits\n  the focus-lost **signal**, the consumer owns the selection and decides whether to drop it.\n- **Picker control:** a `text` item with `control: \"picker\"` + `options` lets the user choose a value,\n  emitting it via `onWidgetEdit({ id, name, value })`. The presentation is set by `mode` and\n  **degrades with the option count** so the control stays usable:\n  - `mode: \"carousel\"` *(default)* — **carousel** for ≤5 options (**click** = next, **shift-click** =\n    previous, slide effect, cycles in place); a **flower** for 6–10; a **grid** beyond 10.\n  - `mode: \"flower\"` — a **radial petal menu**: a tap fans the petals out around the control, picking a\n    petal makes it the centre and closes the flower (re-tap the centre to re-open); a **grid** beyond 10.\n  - `mode: \"grid\"` — a **grid popover**, always.\n\n  The flower/grid popups are appended to `\u003cbody\u003e` (`position:fixed`, JS-placed), so they're never\n  clipped and sit above the map; a press outside closes them, and a press *between* petals falls through\n  to the map. A **tap also selects the card** and a **press-drag** moves it (the control doubles as a\n  drag handle) — it never blocks selecting/dragging. Options are text **or** glyphs —\n  `options: [\"ISOL\",\"OCNL\",\"FRQ\"]` or `[{ value:\"a\", svg:\"\u003csvg…\u003e\" }, …]`. Give each control a **`name`**\n  so a card with several editable controls knows which one changed. A picker renders **bold** so it\n  reads as interactive (vs a static label) without adding width that would shift the value off the\n  anchor; give it an **accent `color`** (like the gauge/dial controls) so all editable elements share\n  one cue. Each option may carry a **`title`** (its tooltip in the flower/grid + on the trigger; no\n  `title` ⇒ no tooltip). An open flower/grid is **keyboard-navigable** — arrows browse, Enter/Space\n  picks, Escape closes (the keys never pan the map) — and **closes** when you start dragging the card.\n- **Gauge / dial value-editors:** two **node kinds** (not text controls) for on-map value editing.\n\n  **Cursor mode** (default): `{ kind: \"gauge\", min, max, cursors: [{ name, value, label? }] }` is a\n  linear slider: **1–3 cursors that can't cross**, `step` snapping, an optional one-notch `beyond`\n  (off-chart \"XXX\" ⇒ emits `min - step` / `max + step`), a filled span + per-cursor labels.\n  When two cursors reach the same value, the central one (middle by index) stays on top and is\n  draggable; the duplicate label is hidden (redundant).\n\n  **Multi-range mode**: `{ kind: \"gauge\", min, max, ranges: [...] }` renders **N independent\n  `[base, top]` intervals on ONE shared axis**. Intended for multicouche SIGWX/TEMSI (one FL gauge\n  per cloud layer → N ranges per gauge). Each range carries its own `color` for knobs and labels;\n  ranges overlap freely — the blend of semi-transparencies signals the common zone. Within a range,\n  `base ≤ top` is enforced; between ranges, no clamping. Dragging a knob emits\n  `onWidgetEdit({ id, name, value })` per move; dragging the **band** (between the two knobs)\n  translates both bounds together (width preserved). The `active` field (range `id` or index) puts\n  that range on top (z-index) for tie-break when knobs coincide.\n  **Band fill** (`fill?: string`): by default the coloured band uses `color`. Set `fill: \"\"` for a\n  **transparent, borderless band** (CAT turbulence convention) — knobs and labels remain visible.\n  Set `fill` to any CSS colour to paint the band differently from `color`. The `knobStroke` gauge\n  field controls the knob border colour in ranges mode (default white, `\"\"` → no border).\n  **Drag-to-trash (vertical gauges only):** a predominantly horizontal drag (`|dx| \u003e 8 px`,\n  `|dx| \u003e |dy|`) on a band reveals a trash icon to the right of the card; releasing past 50 px fires\n  `onWidgetAction({ id, event: \"removeRange:${idx}:${rangeId}\" })`. Releasing before the threshold\n  snaps the band back — no event. Disabled when only one range remains.\n  **Hover-add** (`canAdd?: boolean`, default `false`): when `canAdd: true`, hovering an **empty span**\n  of the axis (a gap between or around bands) shows a transient `+` glyph on the track axis with the\n  snapped FL value beside it. Clicking fires\n  `onWidgetAction({ id, event: \"addLayerAt:\u003cv\u003e\" })`. The `+` is suppressed while dragging a knob or\n  band, when the cursor is over an occupied band or at `g.max`, and whenever `canAdd` is falsy. Set\n  `canAdd: false` (or omit) on gauges that never support add (CB wafs, …); set it `true` on TEMSI\n  multicouche gauges, and clear it back to `false` once the layer count reaches `repeat.max`.\n\n  ```ts\n  adapter.setWidgets([{\n    id: \"temsi-layers\", anchor: { lon: 10, lat: 48 },\n    child: { dir: \"v\", items: [{\n      kind: \"gauge\", min: 0, max: 450, step: 10, length: 120,\n      active: 1,   // render range 1 on top\n      ranges: [\n        { id: \"0\", color: \"#d1242f\",\n          base: { name: \"layers.0.baseFL\", value: 50,  label: \"FL050\" },\n          top:  { name: \"layers.0.topFL\",  value: 250, label: \"FL250\" } },\n        { id: \"1\", color: \"#0969da\",\n          base: { name: \"layers.1.baseFL\", value: 200, label: \"FL200\" },\n          top:  { name: \"layers.1.topFL\",  value: 400, label: \"FL400\" } },\n      ],\n    }] },\n  }]);\n  adapter.onWidgetEdit(({ id, name, value }) =\u003e {\n    // name is list-scoped: \"layers.0.baseFL\", \"layers.1.topFL\", …\n    controller.updateLayer(id, name, Number(value));\n  });\n  ```\n\n  `{ kind: \"dial\", name, min, max, value }` is a radial sweep (jet speed; speedometer angle) whose\n  **label is a readout that follows the knob** outside the ring (never rotated). It is a **true ring:\n  its centre is transparent to pointer events**, so a handle/feature drawn *at* the dial's centre stays\n  clickable underneath (a press in the hole falls through); the whole couronne (ring band + knob) grabs\n  the value. Dragging streams `onWidgetEdit({ id, name, value })` per move (Pointer Events, never drags the card).\n  `length`/`orientation` (gauge), `sweep`/`radius` (dial), and `color` / `labelColor` / `labelHalo` /\n  `knobFill` / `knobStroke` style them. The guide is a **thin, well-marked central line** with a\n  **wider faint glow on the *selected* part** — the gauge span between cursors (whole line for a\n  single cursor; extended a bit past the cursors, never min→max) and the dial arc from its start to\n  the value. **Map-ready defaults**: black labels + white halo, knobs in the main colour + white\n  border; pass `\"\"` to opt a piece out. **A11y**: knobs are `role=\"slider\"` (`aria-valuemin/max/now`)\n  and **arrow keys** step the value by `step` (or 1% of the range); the picker trigger is a focusable\n  button (Enter/Space/↓ act, ↑ cycles back).\n- `control` is the extension point: **`\"input\"` and `\"picker\"` are implemented** (`\"gauge\"` /\n  `\"dial\"` are their own `WidgetNode` kinds — see above). `FakeAdapter` (`./testing`) records the set\n  and adds `.editWidget(id, value, name?)` / `.dragGauge(id, name, value)` / `.deleteWidget(id)` /\n  `.actionWidget(id, event)` / `.clickWidget(id)`.\n\n## Camera, container \u0026 overlay visibility\n\nRead the view, drive it (sparingly), reach the DOM, and toggle layers — all on the three\nengines + `FakeAdapter`:\n\n```ts\nadapter.getBounds();        // [west, south, east, north] (lon/lat)\nadapter.getZoom();          // engine-native zoom\nadapter.getContainer();     // the host map's DOM element (attach a panel, measure…)\nadapter.fitBounds([w, s, e, n], { padding: 24 }); // frame the drawing — DRIVES the host camera, use sparingly\nadapter.setOverlayVisible(\"guide\", false);        // hide a layer without dropping its data (lossless)\n```\n\n(`getCenter()` and `getViewSpan()` — a rough lon/lat span for sizing dropped geometry — are also there.)\n\n**Right-click** surfaces through `onPointer` as `type: \"contextmenu\"` (the browser menu is\nsuppressed), carrying the hit + lon/lat — e.g. finish a polygon / delete a vertex. **`onBlur(cb)`**\nfires when the map's window loses focus, so the consumer can drop transient UI state (e.g. deselect\n— see [Marker widgets](#marker-widgets)).\n\n## Projection \u0026 area framing\n\nFrame the camera onto a **fixed chart area** (dateline-aware), optionally switch the live\n**projection**, and outline the area with a dashed frame:\n\n```ts\n// Switch the live projection. Only OpenLayers actually reprojects.\nadapter.setProjection({                      // a polar-stereographic CRS (WAFS polar charts)\n  kind: \"proj4\", code: \"EPSG:3995\",\n  def: \"+proj=stere +lat_0=90 +lat_ts=71 +lon_0=0 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs\",\n});\nadapter.viewArea([-90, 0, 30, 90]);          // frame a lon/lat bbox; padding/duration optional\nadapter.viewArea([110, -10, -110, 72]);      // antimeridian-crossing bbox (west \u003e east) → one span\nadapter.highlightArea([110, -10, -110, 72], { color: \"#666\", dash: [6, 4] }); // dashed frame\nadapter.highlightArea(null);                 // clear the frame\nadapter.setProjection(\"mercator\");           // back to Web Mercator (\"globe\" is a MapLibre built-in)\n```\n\n- **`setProjection(spec)`** — `\"mercator\"` / `\"globe\"` / `{ kind: \"proj4\", code, def }`. **Only the\n  OpenLayers adapter reprojects:** a `proj4` spec registers the CRS (needs the optional **`proj4`**\n  peer dependency), rebuilds the view in it and re-reads the overlays so handles stay aligned with the\n  basemap. MapLibre handles `mercator`/`globe` natively and ignores `proj4` (stays Mercator, warns\n  once); Leaflet is lat/lng-native and ignores any non-`mercator` spec (warns once).\n- **`viewArea(extent, { padding?, duration? })`** — like `fitBounds` but **antimeridian-aware** (a\n  `west \u003e east` bbox is framed as one span, not the whole globe) and **projection-aware** (under a\n  non-Mercator OpenLayers view it fits the projected, curved area).\n- **`highlightArea(extent | null, style?)`** — a **non-interactive** dashed frame in a dedicated\n  overlay above the basemap and below the drawing overlays. The frame is a densified geographic\n  polygon, so under a non-Mercator OpenLayers view its edges curve to follow the projection. `null`\n  clears it; it never intercepts pointer events.\n\n\u003e `proj4` is an **optional** peer dependency — install it only to use a `{ kind: \"proj4\" }` projection\n\u003e on the OpenLayers adapter (`npm i proj4`). It is never imported otherwise, so Mercator-only and\n\u003e MapLibre/Leaflet consumers don't need it.\n\n## API surface\n\n`MapAdapter` — `ready`, `registerSymbols`, `setOverlay`, `setOverlayVisible`, `snapshot`,\n`setTooltip`, `addToolbar`, `setActiveTool`, `getCenter`, `getViewSpan`, `getBounds`, `getZoom`, `getContainer`,\n`fitBounds`, `setProjection`, `viewArea`, `highlightArea`, `project`, `unproject`, `onViewChange`, `setPanEnabled`, `setDoubleClickZoom`,\n`setInteractive`, `setCursor`, `onPointer`, `onKey`, `onBlur`, `setWidgets`, `onWidgetEdit`,\n`onWidgetDelete`, `onWidgetAction`, `setCoordFormat`, `destroy`.\n`onKey` and marker widgets are documented above; `bindKeyListener(container, cb)` and\n`defaultCoordFormat(ll)` are exported for manual use.\n\nA product simply never calls the methods it doesn't need (sigmet ignores\n`project`/`unproject`/`onViewChange`/`registerSymbols`).\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsoftwarity%2Fdraw-adapter","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsoftwarity%2Fdraw-adapter","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsoftwarity%2Fdraw-adapter/lists"}