Skip to content

Draw, Edit and Cut Shapes in Leaflet with Leaflet-Geoman

Leaflet-Geoman is the maintained alternative to Leaflet.draw. Its free version draws markers, lines, polygons and rectangles, and edits, drags, rotates, cuts and removes them, with new points snapping to existing shapes. This example measures every shape, keeps a GeoJSON copy of everything on the map in the box on the right, and starts with one saved field boundary. Try the scissors to cut a piece out of it.

const map = L.map("map").setView([47.2735, 11.3945], 15);

    L.tileLayer("https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.summer/{z}/{x}/{y}{r}.png?api_key=YOUR_API_KEY", {
      maxZoom: 18,
      attribution:
        "© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
        "© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>",
    }).addTo(map);

    // The toolbar, without the circle tools: GeoJSON has no circle type.
    map.pm.addControls({ position: "topleft", drawCircle: false, drawCircleMarker: false, drawText: false });
    // New points snap to the corners and edges of existing shapes within 20 px.
    map.pm.setGlobalOptions({ snappable: true, snapDistance: 20 });

    // Area of a ring of [lng, lat] points on the sphere, in square meters.
    function ringArea(ring) {
      const rad = Math.PI / 180, r = 6378137;
      let sum = 0;
      for (let i = 0; i < ring.length; i++) {
        const [lng1, lat1] = ring[i];
        const [lng2, lat2] = ring[(i + 1) % ring.length];
        sum += (lng2 - lng1) * rad * (2 + Math.sin(lat1 * rad) + Math.sin(lat2 * rad));
      }
      return Math.abs((sum * r * r) / 2);
    }

    function measure(layer) {
      const geometry = layer.toGeoJSON().geometry;
      if (geometry.type === "Polygon") {
        // Outer ring minus the holes, which cutting creates.
        const [outer, ...holes] = geometry.coordinates;
        const area = ringArea(outer) - holes.reduce((sum, hole) => sum + ringArea(hole), 0);
        return (area / 10000).toFixed(2) + " ha";
      }
      if (geometry.type === "LineString") {
        const points = layer.getLatLngs();
        let meters = 0;
        for (let i = 1; i < points.length; i++) meters += points[i - 1].distanceTo(points[i]);
        return (meters / 1000).toFixed(2) + " km";
      }
      return "";
    }

    // Every shape Geoman manages, as one FeatureCollection.
    function showGeoJSON() {
      const layers = map.pm.getGeomanLayers(true);
      document.getElementById("geojson").value = layers.getLayers().length ? JSON.stringify(layers.toGeoJSON()) : "";
    }

    // Popup with the measurement, updated whenever the shape changes.
    function track(layer) {
      if (!(layer instanceof L.Marker)) layer.bindPopup(measure(layer));
      layer.on("pm:edit pm:dragend pm:rotateend", () => {
        layer.getPopup()?.setContent(measure(layer));
        showGeoJSON();
      });
    }

    map.on("pm:create", ({ layer }) => {
      track(layer);
      layer.openPopup?.();
      showGeoJSON();
    });
    // Cutting replaces the original shape with a new one.
    map.on("pm:cut", ({ layer }) => {
      track(layer);
      showGeoJSON();
    });
    map.on("pm:remove", showGeoJSON);

    // A shape saved earlier. Loaded through L.geoJSON, it is editable like anything drawn now.
    const saved = {
      type: "Feature",
      properties: { name: "Field" },
      geometry: {
        type: "Polygon",
        coordinates: [[[11.3905, 47.2755], [11.3985, 47.2755], [11.3985, 47.2715], [11.3905, 47.2715], [11.3905, 47.2755]]],
      },
    };
    L.geoJSON(saved).eachLayer((layer) => {
      layer.addTo(map);
      track(layer);
    });
    showGeoJSON();
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css" />
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@geoman-io/leaflet-geoman-free@2.20.2/dist/leaflet-geoman.css" />
  <script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/@geoman-io/leaflet-geoman-free@2.20.2/dist/leaflet-geoman.js"></script>
  <style>
    html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
    #map { width: 100%; height: 100%; }
    #export {
      position: absolute; top: 12px; right: 12px; z-index: 1000; width: 240px; padding: 10px 12px;
      background: #fff; border-radius: 10px; box-shadow: 0 4px 20px rgba(20, 30, 60, 0.18);
      font: 13px/1.4 system-ui, sans-serif; color: #1f2430;
    }
    #export strong { display: block; margin-bottom: 6px; }
    #export textarea {
      box-sizing: border-box; width: 100%; height: 110px; padding: 6px; border: 1px solid #d0d4e0;
      border-radius: 6px; font: 11px/1.3 ui-monospace, monospace; resize: vertical;
    }
    @media (max-width: 600px) { #export { top: auto; bottom: 30px; width: auto; left: 60px; } }
  </style>
</head>
<body>
  <div id="map"></div>
  <div id="export">
    <strong>GeoJSON</strong>
    <textarea id="geojson" readonly></textarea>
  </div>
  <script>
    const map = L.map("map").setView([47.2735, 11.3945], 15);

    L.tileLayer("https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.summer/{z}/{x}/{y}{r}.png?api_key=YOUR_API_KEY", {
      maxZoom: 18,
      attribution:
        "© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
        "© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>",
    }).addTo(map);

    // The toolbar, without the circle tools: GeoJSON has no circle type.
    map.pm.addControls({ position: "topleft", drawCircle: false, drawCircleMarker: false, drawText: false });
    // New points snap to the corners and edges of existing shapes within 20 px.
    map.pm.setGlobalOptions({ snappable: true, snapDistance: 20 });

    // Area of a ring of [lng, lat] points on the sphere, in square meters.
    function ringArea(ring) {
      const rad = Math.PI / 180, r = 6378137;
      let sum = 0;
      for (let i = 0; i < ring.length; i++) {
        const [lng1, lat1] = ring[i];
        const [lng2, lat2] = ring[(i + 1) % ring.length];
        sum += (lng2 - lng1) * rad * (2 + Math.sin(lat1 * rad) + Math.sin(lat2 * rad));
      }
      return Math.abs((sum * r * r) / 2);
    }

    function measure(layer) {
      const geometry = layer.toGeoJSON().geometry;
      if (geometry.type === "Polygon") {
        // Outer ring minus the holes, which cutting creates.
        const [outer, ...holes] = geometry.coordinates;
        const area = ringArea(outer) - holes.reduce((sum, hole) => sum + ringArea(hole), 0);
        return (area / 10000).toFixed(2) + " ha";
      }
      if (geometry.type === "LineString") {
        const points = layer.getLatLngs();
        let meters = 0;
        for (let i = 1; i < points.length; i++) meters += points[i - 1].distanceTo(points[i]);
        return (meters / 1000).toFixed(2) + " km";
      }
      return "";
    }

    // Every shape Geoman manages, as one FeatureCollection.
    function showGeoJSON() {
      const layers = map.pm.getGeomanLayers(true);
      document.getElementById("geojson").value = layers.getLayers().length ? JSON.stringify(layers.toGeoJSON()) : "";
    }

    // Popup with the measurement, updated whenever the shape changes.
    function track(layer) {
      if (!(layer instanceof L.Marker)) layer.bindPopup(measure(layer));
      layer.on("pm:edit pm:dragend pm:rotateend", () => {
        layer.getPopup()?.setContent(measure(layer));
        showGeoJSON();
      });
    }

    map.on("pm:create", ({ layer }) => {
      track(layer);
      layer.openPopup?.();
      showGeoJSON();
    });
    // Cutting replaces the original shape with a new one.
    map.on("pm:cut", ({ layer }) => {
      track(layer);
      showGeoJSON();
    });
    map.on("pm:remove", showGeoJSON);

    // A shape saved earlier. Loaded through L.geoJSON, it is editable like anything drawn now.
    const saved = {
      type: "Feature",
      properties: { name: "Field" },
      geometry: {
        type: "Polygon",
        coordinates: [[[11.3905, 47.2755], [11.3985, 47.2755], [11.3985, 47.2715], [11.3905, 47.2715], [11.3905, 47.2755]]],
      },
    };
    L.geoJSON(saved).eachLayer((layer) => {
      layer.addTo(map);
      track(layer);
    });
    showGeoJSON();
  </script>
</body>
</html>

Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.

Use the Maptoolkit Connector. Create a Leaflet map with Maptoolkit raster tiles and the Leaflet-Geoman toolbar for drawing, editing, dragging, rotating, cutting and removing markers, lines, polygons and rectangles with snapping. Show the area or length of each shape in a popup, keep a GeoJSON export of all shapes in a panel, and load one saved polygon as an editable shape.

How it works

map.pm is the entry point. Loading Leaflet-Geoman adds a pm object to every map and layer. map.pm.addControls() shows the toolbar, and each tool can be switched off by name. The circle tools are off here because GeoJSON has no circle type: a circle exported with toGeoJSON() becomes a point, and its radius is lost.

Snapping. With snappable: true, a new point within snapDistance pixels of an existing corner or edge jumps onto it, so neighboring shapes share their borders without gaps or overlaps. Hold Alt while drawing to place a point without snapping.

No feature group to manage. Leaflet.draw only edits shapes inside the feature group you pass it. Geoman works on every layer on the map, unless a layer is created with pmIgnore: true. map.pm.getGeomanLayers(true) returns everything it manages as one FeatureGroup, and toGeoJSON() on that group gives the FeatureCollection to store or send.

Events. New shapes fire pm:create on the map, removed ones pm:remove. Changes fire on the layer itself: pm:edit after moving points, pm:dragend and pm:rotateend after dragging and rotating. The cut tool replaces the original shape with a new layer and fires pm:cut with that new layer, so the example attaches its listeners to it again.

Measuring. Geoman’s free version does not measure shapes. ringArea() computes the area of a ring on the sphere from its [lng, lat] coordinates, and the holes left by cutting are subtracted from the outer ring. Lines add up distanceTo() between neighboring points.

Saved shapes. L.geoJSON() turns the saved Feature into a polygon layer, and each layer is added to the map on its own, so it is editable like anything drawn in the session.

Leaflet-Geoman has a free version under the MIT licence, used here, and a paid Pro version with more tools. Compared with Leaflet.draw, it is actively released and adds snapping, cutting, rotating and dragging.

Next steps

A drawn line can go straight to the Elevation API for a height profile, and a drawn polygon can be the area you filter your own data by, as the point test in Find Locations Inside an Isochrone does for an isochrone.

To restore a user’s shapes on their next visit, store the GeoJSON and load it back through the same L.geoJSON(...).eachLayer() call the example uses for its saved field.