Skip to content

Draw and Edit Shapes in MapLibre GL JS with Terra Draw

Terra Draw adds drawing and editing to MapLibre GL JS through a small adapter. Pick a tool, draw on the map, then use Edit to move shapes, drag their corners or add corners from the midpoints. The box on the right always holds the current shapes as GeoJSON, and the map starts with one saved shape to show how earlier drawings are restored.

const { TerraDraw, TerraDrawPolygonMode, TerraDrawLineStringMode, TerraDrawRectangleMode,
            TerraDrawPointMode, TerraDrawSelectMode } = terraDraw;
    const { TerraDrawMapLibreGLAdapter } = terraDrawMaplibreGlAdapter;

    const map = new maplibregl.Map({
      container: "map",
      style: "https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=YOUR_API_KEY",
      center: [11.3945, 47.2735],
      zoom: 14,
      attributionControl: { compact: false },
    });

    // Editing is switched on per geometry type in the select mode.
    const editable = {
      feature: {
        draggable: true,
        coordinates: { draggable: true, midpoints: true, deletable: true },
      },
    };

    const draw = new TerraDraw({
      adapter: new TerraDrawMapLibreGLAdapter({ map }),
      modes: [
        new TerraDrawPolygonMode(),
        new TerraDrawLineStringMode(),
        new TerraDrawRectangleMode(),
        new TerraDrawPointMode(),
        new TerraDrawSelectMode({
          flags: {
            polygon: editable,
            linestring: editable,
            rectangle: { feature: { draggable: true, coordinates: { resizable: "opposite" } } },
            point: { feature: { draggable: true } },
          },
        }),
      ],
    });

    function setMode(mode) {
      draw.setMode(mode);
      document.querySelectorAll("#tools [data-mode]").forEach((button) =>
        button.classList.toggle("active", button.dataset.mode === mode));
    }

    function showGeoJSON() {
      // While a shape is being edited, the snapshot also holds the edit handles.
      const features = draw.getSnapshot().filter((feature) => feature.properties.mode !== "select");
      document.getElementById("geojson").value = features.length
        ? JSON.stringify({ type: "FeatureCollection", features })
        : "";
    }

    map.on("load", () => {
      draw.start();

      // A shape saved earlier. Its `mode` property decides which mode owns it.
      draw.addFeatures([{
        type: "Feature",
        properties: { mode: "polygon" },
        geometry: {
          type: "Polygon",
          coordinates: [[[11.3905, 47.2755], [11.3985, 47.2755], [11.3985, 47.2715], [11.3905, 47.2715], [11.3905, 47.2755]]],
        },
      }]);

      setMode("select");
      showGeoJSON();
    });

    draw.on("finish", showGeoJSON);
    draw.on("change", (ids, type) => { if (type === "delete") showGeoJSON(); });

    document.querySelectorAll("#tools [data-mode]").forEach((button) =>
      button.addEventListener("click", () => setMode(button.dataset.mode)));

    document.getElementById("delete").addEventListener("click", () => {
      const selected = draw.getSnapshot().filter((feature) => feature.properties.selected);
      draw.removeFeatures(selected.map((feature) => feature.id));
    });

    document.getElementById("clear").addEventListener("click", () => draw.clear());
<!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/maplibre-gl@5.6.1/dist/maplibre-gl.css" />
  <script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5.6.1/dist/maplibre-gl.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/terra-draw@1.35.0/dist/terra-draw.umd.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/terra-draw-maplibre-gl-adapter@1.4.1/dist/terra-draw-maplibre-gl-adapter.umd.js"></script>
  <style>
    html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
    #map { width: 100%; height: 100%; }
    #tools {
      position: absolute; top: 10px; left: 10px; z-index: 1; display: flex; flex-wrap: wrap; gap: 4px;
      max-width: 60%; font: 13px sans-serif;
    }
    #tools button {
      padding: 6px 10px; border: none; border-radius: 4px; background: #fff; cursor: pointer;
      box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
    }
    #tools button.active { background: #303f7e; color: #fff; }
    #geojson {
      position: absolute; top: 10px; right: 10px; z-index: 1; width: 260px; height: 140px;
      font: 11px/1.4 monospace; padding: 6px; border: none; border-radius: 4px;
      box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
    }
  </style>
</head>
<body>
  <div id="map"></div>
  <div id="tools">
    <button data-mode="polygon">Polygon</button>
    <button data-mode="linestring">Line</button>
    <button data-mode="rectangle">Rectangle</button>
    <button data-mode="point">Point</button>
    <button data-mode="select">Edit</button>
    <button id="delete">Delete selected</button>
    <button id="clear">Clear all</button>
  </div>
  <textarea id="geojson" readonly></textarea>
  <script>
    const { TerraDraw, TerraDrawPolygonMode, TerraDrawLineStringMode, TerraDrawRectangleMode,
            TerraDrawPointMode, TerraDrawSelectMode } = terraDraw;
    const { TerraDrawMapLibreGLAdapter } = terraDrawMaplibreGlAdapter;

    const map = new maplibregl.Map({
      container: "map",
      style: "https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=YOUR_API_KEY",
      center: [11.3945, 47.2735],
      zoom: 14,
      attributionControl: { compact: false },
    });

    // Editing is switched on per geometry type in the select mode.
    const editable = {
      feature: {
        draggable: true,
        coordinates: { draggable: true, midpoints: true, deletable: true },
      },
    };

    const draw = new TerraDraw({
      adapter: new TerraDrawMapLibreGLAdapter({ map }),
      modes: [
        new TerraDrawPolygonMode(),
        new TerraDrawLineStringMode(),
        new TerraDrawRectangleMode(),
        new TerraDrawPointMode(),
        new TerraDrawSelectMode({
          flags: {
            polygon: editable,
            linestring: editable,
            rectangle: { feature: { draggable: true, coordinates: { resizable: "opposite" } } },
            point: { feature: { draggable: true } },
          },
        }),
      ],
    });

    function setMode(mode) {
      draw.setMode(mode);
      document.querySelectorAll("#tools [data-mode]").forEach((button) =>
        button.classList.toggle("active", button.dataset.mode === mode));
    }

    function showGeoJSON() {
      // While a shape is being edited, the snapshot also holds the edit handles.
      const features = draw.getSnapshot().filter((feature) => feature.properties.mode !== "select");
      document.getElementById("geojson").value = features.length
        ? JSON.stringify({ type: "FeatureCollection", features })
        : "";
    }

    map.on("load", () => {
      draw.start();

      // A shape saved earlier. Its `mode` property decides which mode owns it.
      draw.addFeatures([{
        type: "Feature",
        properties: { mode: "polygon" },
        geometry: {
          type: "Polygon",
          coordinates: [[[11.3905, 47.2755], [11.3985, 47.2755], [11.3985, 47.2715], [11.3905, 47.2715], [11.3905, 47.2755]]],
        },
      }]);

      setMode("select");
      showGeoJSON();
    });

    draw.on("finish", showGeoJSON);
    draw.on("change", (ids, type) => { if (type === "delete") showGeoJSON(); });

    document.querySelectorAll("#tools [data-mode]").forEach((button) =>
      button.addEventListener("click", () => setMode(button.dataset.mode)));

    document.getElementById("delete").addEventListener("click", () => {
      const selected = draw.getSnapshot().filter((feature) => feature.properties.selected);
      draw.removeFeatures(selected.map((feature) => feature.id));
    });

    document.getElementById("clear").addEventListener("click", () => draw.clear());
  </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 MapLibre GL JS map with the Maptoolkit Summer style centered on Innsbruck at zoom level 14. Add Terra Draw with buttons for polygon, line, rectangle, point and edit modes, delete and clear buttons, a text box with the drawn shapes as GeoJSON, and one saved polygon loaded at start.

How it works

Terra Draw has no toolbar of its own. It is a set of modes, and your buttons switch between them with draw.setMode(). The built-in mode names are polygon, linestring, rectangle, point and select. The TerraDrawMapLibreGLAdapter connects the modes to the map and draws the shapes as MapLibre layers, so they zoom and pan with the map like any other layer.

draw.start() has to wait for the map’s load event, because the adapter adds its sources and layers to the style.

Editing is off until you switch it on. TerraDrawSelectMode only lets you change what its flags allow, per mode. Here polygons and lines can be moved, their corners dragged or deleted, and new corners added from the midpoints; rectangles can be moved and resized from the opposite corner; points can be moved.

The snapshot contains the edit handles while you edit. draw.getSnapshot() returns every feature Terra Draw is drawing, and while a shape is selected that includes the corner and midpoint handles, as features with mode: "select". The filter in showGeoJSON() removes them, so they never end up in what you save.

Each feature carries a generated id and a mode property. The mode decides which tool owns the shape, which is why the saved polygon passed to addFeatures() needs mode: "polygon", and the id lets you match a shape to its stored copy when it changes later.

finish fires when a shape is completed and when an edit ends, and change with the type delete covers removals, so those two events keep the GeoJSON current.

The MapLibre GL JS 5 script tag provides the global maplibregl that the UMD builds of Terra Draw expect. In a bundled app, install terra-draw and terra-draw-maplibre-gl-adapter from npm and import the same classes.

Next steps

The exported polygons are ready to use with your own data: store them, or use one to select the points inside it. Measure Area and Bearing shows the area and perimeter math for a polygon without a geometry library.

Terra Draw has adapters for Leaflet and OpenLayers as well, so the same modes and the same GeoJSON work across the libraries if your project uses more than one.