npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@osm-editor-kit/react-map-gl-draw

v0.2.1

Published

Draw and edit points, lines and polygons on a react-map-gl (MapLibre) map as a controlled React component: value in, onChange out, styles as layer styles.

Readme

@osm-editor-kit/react-map-gl-draw

Draw and edit points, lines and polygons on a react-map-gl (MapLibre) map, as a controlled React component.

  • Your state. Shapes are a value you own (URL, Zustand, a form field, a query cache). onChange fires once per finished edit, never during a drag.
  • Your styles. The shapes are ordinary <Source> and <Layer> elements. You style them with MapLibre layer styles and expressions.
  • No modes. A press on a corner drags it, a press on a line inserts a corner and drags it, a click on empty map adds to the shape being drawn. There is no "edit mode" to switch into.
  • No map plumbing. Pointer gestures arrive through <Map> props. Nothing calls map.addLayer, map.on or useControl, so style changes and Strict Mode need no handling.

Part of the osm-editor-kit family. npm: @osm-editor-kit/react-map-gl-draw · Live preview: osm-editor-kit.github.io/react-map-gl-draw

Status: 0.0.x. The API may still change. Mouse input is exercised on two drawing surfaces of TILDA; touch input is implemented and passes a simulated touch drag in Chromium, but has not been verified on real devices yet.

Install

bun add @osm-editor-kit/react-map-gl-draw

Peer dependencies: react ≥ 19.2, react-map-gl ≥ 8, maplibre-gl ≥ 4.

Quick start

import {
  createDrawController,
  DrawLayers,
  useDraw,
  type DrawFeature,
} from '@osm-editor-kit/react-map-gl-draw'
import { useState } from 'react'
import { Map } from 'react-map-gl/maplibre'

const controller = createDrawController()

export function DrawMap() {
  const [shapes, setShapes] = useState<DrawFeature[]>([])
  const draw = useDraw(controller, { value: shapes, onChange: setShapes })

  return (
    <>
      <button onClick={() => draw.setTool('polygon')}>Polygon</button>
      <button onClick={draw.deleteSelected} disabled={!draw.canDeleteSelected}>
        Delete
      </button>

      <Map initialViewState={{ longitude: 13.4, latitude: 52.52, zoom: 12 }} {...draw.mapProps}>
        <DrawLayers draw={draw} />
      </Map>
    </>
  )
}

How it works

Three pieces, wired by one hook:

| Piece | Holds | Where | | ------------------------ | -------------------------------------------- | -------------------------------------- | | value | The shapes | Your app | | createDrawController() | Tool, selection and the gesture in progress | A small store, one per drawing surface | | <DrawLayers> | Nothing; it renders value plus the gesture | Inside <Map> |

useDraw(controller, options) returns mapProps for <Map>, the input for <DrawLayers>, and state and actions for a toolbar. Call it in every component that needs one of these, with the same controller and options. In an app, wrap it once:

// useAreaDraw.ts
const controller = createDrawController()

export const useAreaDraw = () => {
  const { areas, setAreas } = useAreasFromUrl()
  return useDraw(controller, { value: areas, onChange: setAreas, emptyTool: 'polygon' })
}

The component that renders <Map> spreads useAreaDraw().mapProps; a child renders <DrawLayers draw={useAreaDraw()} />; the toolbar calls useAreaDraw().setTool(…).

Using it next to your own map handlers

mapProps contains onMouseDown, onMouseMove, onMouseUp, onDblClick, the four touch handlers, and cursor when drawing wants a specific one. It is empty while enabled is false. Put your own props first and merge the ones you also use:

const { cursor, onMouseMove, ...drawProps } = draw.mapProps

<Map
  cursor={cursor ?? ownCursor}
  onClick={drawActive ? undefined : openInspector}
  onMouseMove={(event) => {
    updateHover(event)
    onMouseMove?.(event)
  }}
  {...drawProps}
/>

Drawing does not use interactiveLayerIds or invisible hit layers for its own shapes. It hit-tests them in screen space from value, because an editor needs an answer that matches its state right now: react-map-gl answers a press from a hover cache, and rendered-feature queries lag behind a source update. It is not a performance choice. The full reasoning is in docs/architecture.md.

Keep using interactiveLayerIds for your own layers, and pass an empty list while drawing if they should not react.

Gestures

The only stored choice is the tool: select, point, line, polygon or freehand. It decides what a press on empty map does. What is under the pointer comes first:

| Under the pointer | Result | | ----------------------------------------------------------------- | ------------------------------------------------ | | First or last corner of the shape being drawn | Finish the shape | | Corner of the selected shape | Drag the corner | | Midpoint handle, or anywhere on the outline of the selected shape | Insert a corner; keep the button down to drag it | | Body of a shape | Select it; drag it where moveBy is 'body' | | Empty map while drawing | Add a corner | | Empty map with a shape tool | Start a shape (a point is placed at once) | | Empty map with select | Deselect; the map pans |

Also:

  • Double click, Enter, or a click on the last corner finishes a line or polygon.
  • A click on the first or last corner of a selected line continues the line from there. Escape leaves the line as it was.
  • Escape cancels the shape being drawn, or a drag in progress.
  • Backspace removes the last corner while drawing.
  • Cmd/Ctrl+Z takes back the last corner while drawing, otherwise the last change (with history). Shift+Cmd/Ctrl+Z or Ctrl+Y brings it back.
  • Delete removes the corner touched last, or the selected shape. A double click on a corner removes it too.
  • After a shape is finished the tool returns to select and the shape is selected. Set keepTool to stay in the tool, for example to place several points in a row.
  • With a shape tool armed, the body of an existing shape does not capture the press, so a new shape can start on top of an old one. Its corners and outline still edit.

Options

useDraw(controller, {
  value, // DrawFeature[]
  onChange, // (next, meta) => void; meta = { reason: 'add' | 'edit' | 'delete', featureId }
  //          or { reason: 'undo' | 'redo' | 'replace' }
  enabled, // false turns interaction off and empties mapProps. Default true.
  limits, // see below
  moveBy, // { point, polygon }: 'handle' | 'body'. Default { point: 'body', polygon: 'handle' }.
  emptyTool, // tool that is armed while value is empty
  selectSingle, // treat the only shape as selected. Default false.
  keepTool, // keep a shape tool armed after adding a shape. Default false.
  closeLines, // a line that ends on its first corner becomes a polygon. Default false.
  precision, // decimals kept for coordinates. Default 7.
  createId, // (type) => id for a new shape of that type. Default crypto.randomUUID().
  tolerance, // hit distance in px. Default { mouse: 10, touch: 20 }.
  snap, // snap corners to lines of the map underneath; see below
  history, // steps for undo and redo; see below
  historyKey, // names what is edited when one surface edits different things in turn
})

limits

limits: { point?: number; line?: number; polygon?: number; total?: number; min?: number; singleType?: boolean }

| Want | Use | | -------------------------------------------- | ------------------------------ | | Polygons only | { point: 0, line: 0 } | | Exactly one shape of any type | { total: 1 } | | Several parts, all of one type, at least one | { singleType: true, min: 1 } |

When a limit is reached, draw.tool falls back to select and draw.canAdd(type) returns false; use both to hide or disable toolbar buttons.

moveBy

How a whole shape is moved, per type.

  • 'handle': only by the move handle that the selected shape shows. A press on the shape itself selects it and leaves the map free to pan.
  • 'body': by pressing the shape and dragging.
moveBy: { point: 'body', polygon: 'handle' } // the defaults

Moving a whole area is the rare case, its surface is large, and the handle says what it does. Lines always use the handle, because a press on a selected line inserts a corner.

The handle sits inside a polygon, above the top corner of a line, and above a point.

closeLines

With closeLines: true, a line that ends on its own first corner becomes a polygon:

  • while drawing a line with three or more corners, a click on its first corner,
  • while continuing a line, a click on its other end (the shape keeps its id),
  • a freehand stroke that is released where it started.

The corner that closes the line is highlighted like the closing corner of a polygon. Leave the option off where a shape must keep its type.

snap

Snaps corners to lines of the map underneath, for example the streets of the basemap, so a street can be traced by hand, corner by corner.

snap: {
  source: 'openmaptiles', // id of the map source; corners snap to all its line layers
  sourceLayer: 'transportation', // for a vector source
  filter: ['in', ['get', 'class'], ['literal', ['primary', 'secondary', 'tertiary', 'minor']]],
  radius: 14, // px, the default
}
  • New corners, dragged corners and a continued line snap. A shape moved as a whole does not.
  • A ring (style slot snap) shows where the corner under the pointer would land.
  • Where three or more streets meet, the ring is slightly stronger, and it pulls from further away. In the style, the feature property junction is true there.
  • A corner of the street wins over a spot along it when the pointer is close, so shapes meet streets at their bends and crossings.
  • Hold Alt (Option on a Mac) to place a corner freely. Leave snap out to switch snapping off.

This reads the lines as the map has drawn them (queryRenderedFeatures). It works across tile borders, because each corner is snapped on its own. Positions are as exact as the tiles at the current zoom; zoom in for exact work. It does not route along streets between two clicks.

history

const controller = createDrawController()
const history = createDrawHistory() // { limit: 100 } steps by default

const draw = useDraw(controller, { value, onChange, history })

<button disabled={!draw.canUndo} onClick={draw.undo}>Undo</button>
<button disabled={!draw.canRedo} onClick={draw.redo}>Redo</button>

Every finished change is one step. Undo and redo call onChange with the earlier or later value as a whole, so your app applies and saves a step like any other change.

  • While a shape is drawn, a step is one corner. This also works without history.
  • One history per thing that is edited. Two drawing surfaces with their own history undo independently. <DrawLayers> of a surface that is not enabled ignores the keys.
  • Changes from outside the map (a delete button in a list) become a step when they go through draw.replace(next) instead of your own setter.
  • The steps belong to the shapes they were recorded on. When value becomes something else without the package (another record is opened, a refetch brings a change, a failed save is rolled back), canUndo and canRedo are false and the next change starts a new history. Shapes are compared by geometry, not by id, so an app that stores one geometry and hands the parts back with new ids keeps its steps.
  • One surface, different records. Pass the record's id as historyKey. Steps are only offered under the key they were recorded with, so switching records needs no clean-up, and two records with the same shapes do not share steps. history.clear() drops the steps by hand.
  • Shown first, then reported. A step is drawn on the map before onChange is called (at most 250 ms later), so heavy work in your onChange does not delay it.
  • The steps are kept in memory. history.store is a zustand vanilla store with { past, present, future } if you want to show or persist them.

emptyTool and selectSingle

For a surface that usually has one shape:

useDraw(controller, { value, onChange, emptyTool: 'polygon', selectSingle: true })

The first click starts the shape without a toolbar button, and its handles always show.

Return value

| Field | Meaning | | ------------------------------------------ | ---------------------------------------------------------------------------------------------------- | | mapProps | Spread onto <Map>. | | tool | The tool in effect: the stored one, emptyTool, or select when the limits are reached. | | selectedId | Id of the selected shape, or null. | | isDrawing | A line or polygon is being drawn. | | hasActiveVertex | A corner was touched last; deleteActiveVertex() would remove it. | | canAdd(type) | Whether the limits allow another 'point', 'line' or 'polygon' ('freehand' counts as a line). | | canDeleteSelected | A shape is selected and limits.min allows deleting it. | | setTool(tool), select(id) | Change tool or selection. | | finish(), cancel() | Finish or drop the shape being drawn. | | deleteSelected(), deleteActiveVertex() | Delete through onChange. | | canUndo, canRedo | A step is available: a corner of the shape being drawn, or a change in history. | | undo(), redo() | Take the step, through onChange. | | replace(next) | Your own change to the shapes, through onChange and as a step of history. |

useDrawPreview(controller, value) returns the shapes as they look right now, including a drag that has not been committed. Use it for a live readout (an area, a sum) while dragging.

useDrawDraft(draw) returns the shape that is still being drawn as a LineString or Polygon that ends at the pointer, or null. Use it to show a value before the shape is finished, e.g. the length of a line.

useDrawFocus(draw) returns the corner the user is placing or dragging right now as { position, point }, or null. position is on the precision grid and snapped, so it is what would be stored; point is the pointer in pixels. It changes on every pointer move, so read it in a small component.

Loupe

<DrawLoupe> is a magnifier for exact corners: a small second map that follows the corner being placed or dragged, with a crosshair on it. It shows while a tool is armed, while a shape is drawn and while a corner is dragged. It docks in a corner of the map and changes to another one when the pointer comes near.

<Map {...draw.mapProps}>
  <DrawLayers draw={draw} />
  <DrawLoupe draw={draw} zoom={20}>
    <Source id="aerial" type="raster" tiles={[aerialTiles]} tileSize={256} maxzoom={20} />
    <Layer id="aerial" type="raster" source="aerial" />
  </DrawLoupe>
</Map>

The children are the sources and layers the loupe shows, typically the imagery of the main map. They live in the loupe's own map, so their ids may repeat those of the main map. The loupe requests its own tiles, at zoom.

| Prop | Meaning | | ------------ | --------------------------------------------------------------------------------- | | zoom | Zoom of the loupe, e.g. the highest zoom the imagery has tiles for. | | mapStyle | Style of the loupe's map. Default: empty, so only the children show. | | size | Width and height in pixels. Default 160. | | corners | Where it docks, in order of preference. Default ['top-left', 'top-right']. | | inset | Distance to the map's edges, a number or per side, e.g. to keep clear of a panel. | | margin | How close the pointer may come before the loupe changes its corner. Default 48. | | shapeColor | Color of the drawn shapes inside the loupe; null hides them. | | crosshair | Replaces the default crosshair. | | hidden | Hides it, e.g. while the main map is zoomed out too far for exact corners. | | mapId | Id of the loupe's map for MapProvider. Default draw-loupe. |

Styling

<DrawLayers> renders one GeoJSON source and six layers. Pass layer styles per slot; they are merged over the defaults key by key. null removes a layer.

| Slot | Layer type | Shows | | ---------- | ---------- | ------------------------------------------------- | | fill | fill | Polygon interiors | | line | line | Lines and polygon outlines | | point | circle | Point shapes | | midpoint | circle | "Add a corner here" handles of the selected shape | | vertex | circle | Corner handles | | snap | circle | Ring around the place a corner snaps to |

State reaches the style as feature properties:

| Property | On | Meaning | | ----------- | ---------------- | ------------------------------------------------------------------------ | | role | all | 'shape', 'draft' (being drawn), 'vertex', 'midpoint' or 'snap' | | shape | shapes, drafts | 'point', 'line' or 'polygon' | | selected | shapes | The shape is selected | | active | vertex, midpoint | Under the pointer, or the corner touched last | | closing | vertex | A click here finishes the shape being drawn | | junction | snap | Three or more lines of the map meet at the snap position | | featureId | shapes, handles | The shape's id | | your own | shapes | Everything in the shape's properties |

<DrawLayers
  draw={draw}
  styles={{
    fill: {
      paint: {
        'fill-color': ['case', ['boolean', ['get', 'selected'], false], '#f59e0b', '#2563eb'],
        'fill-opacity': 0.25,
      },
    },
    line: { paint: { 'line-color': ['coalesce', ['get', 'color'], '#2563eb'] } },
    midpoint: null,
  }}
/>

styles is always a plain object. For state of the whole surface, choose between objects:

<DrawLayers draw={draw} styles={draw.isDrawing ? stylesWhileDrawing : styles} />

Other props: id (source id and layer id prefix, default draw), beforeId, moveHandle (your own content for the move handle), keyboard (default true).

Where to keep the shapes

onChange hands you the next list. Apply it at once (or optimistically): the component shows the committed change for up to a second while your value catches up, then follows value.

URL (TanStack Router)

const shapes = Route.useSearch({ select: (search) => search.shapes })
const navigate = Route.useNavigate()
const onChange = (next: DrawFeature[]) =>
  navigate({ search: (prev) => ({ ...prev, shapes: next }), replace: true })

TanStack Query, saved on every edit

Write the new geometry to the query cache in onChange, then run the mutation, and restore the previous cache entry in onError.

A form field holding one geometry

const value = featuresFromGeometry(field.value) // Multi* becomes one shape per part
const onChange = (next: DrawFeature[]) => field.onChange(geometryFromFeatures(next))

Give the parts stable ids across a save: createId: () => \part-${value.length}``.

Helpers

  • featuresFromGeometry(geometry, { createId? }): one shape per part of any GeoJSON geometry.
  • geometryFromFeatures(features): one geometry from shapes of one type (a single shape stays simple, several become the Multi* type).
  • shapeTypeOf(geometry): 'point', 'line' or 'polygon'.

Not included

  • Routing along streets, rectangles, circles, rotation and scaling.
  • Editing Multi* geometries as one shape; split them with featuresFromGeometry.

Thanks to TerraDraw

This package exists because of TerraDraw by James Milner. We used it in production first, and it taught us what the interactions should feel like: midpoint handles, closing a polygon on its first corner, screen-space hit-testing with a pixel tolerance. If you are not building on react-map-gl, or you need rectangles or circles out of the box, use TerraDraw.

We wrote our own because of how our apps hold their data, not because of a fault in TerraDraw. TerraDraw is built to work with any map library and any framework. To do that it owns a feature store, listens on the map canvas itself and adds its own layers. Our apps already own the shapes (in a URL, a form field, a query cache) and already describe their map as <Source> and <Layer> elements. Joining the two gave us two copies of every shape, and most of our integration code did nothing but keep them in step:

  • telling a change we wrote into TerraDraw apart from one the user made,
  • waiting for the map style before starting, and rebuilding TerraDraw's layers after a style change,
  • mirroring selection and "can I add another shape" out of TerraDraw's events,
  • splitting Multi* geometries on the way in and combining them on the way out.

A drawing tool that is a controlled React component has none of that to do. That narrower job, for react-map-gl only, is what this package is.

License

MIT