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.
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.