Enables the user to draw points, lines and polygons.

Parameters
Create new shape


Units

100% © IGN

The DrawTool class allows you to draw shapes, such as points, lines and polygons, on surfaces. Each generated Shape can then be exported in GeoJSON.

index.js
import { Color, DoubleSide, MathUtils, Vector3 } from "three";
import { MapControls } from "three/examples/jsm/controls/MapControls.js";

import Extent from "@giro3d/giro3d/core/geographic/Extent.js";
import Coordinates from "@giro3d/giro3d/core/geographic/Coordinates.js";
import Instance from "@giro3d/giro3d/core/Instance.js";
import ElevationLayer from "@giro3d/giro3d/core/layer/ElevationLayer.js";
import ColorLayer from "@giro3d/giro3d/core/layer/ColorLayer.js";
import Map from "@giro3d/giro3d/entities/Map.js";
import WmtsSource from "@giro3d/giro3d/sources/WmtsSource.js";
import BilFormat from "@giro3d/giro3d/formats/BilFormat.js";
import DrawTool, {
  afterRemovePointOfRing,
  afterUpdatePointOfRing,
  inhibitHook,
  limitRemovePointHook,
} from "@giro3d/giro3d/interactions/DrawTool.js";
import Shape, {
  DEFAULT_SURFACE_OPACITY,
  angleSegmentFormatter,
  isShapePickResult,
  slopeSegmentFormatter,
} from "@giro3d/giro3d/entities/Shape.js";
import Fetcher from "@giro3d/giro3d/utils/Fetcher.js";
import Inspector from "@giro3d/giro3d/gui/Inspector.js";

function bindButton(id, onClick) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLButtonElement)) {
    throw new Error(
      "invalid binding element: expected HTMLButtonElement, got: " +
        element.constructor.name,
    );
  }

  element.onclick = () => {
    onClick(element);
  };

  return element;
}

function bindSlider(id, onChange) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLInputElement)) {
    throw new Error(
      "invalid binding element: expected HTMLInputElement, got: " +
        element.constructor.name,
    );
  }

  element.oninput = function oninput() {
    onChange(element.valueAsNumber);
  };

  const setValue = (v) => {
    element.valueAsNumber = v;
    onChange(element.valueAsNumber);
  };

  const initialValue = element.valueAsNumber;

  return [setValue, initialValue, element];
}

function bindColorPicker(id, onChange) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLInputElement)) {
    throw new Error(
      "invalid binding element: expected HTMLInputElement, got: " +
        element.constructor.name,
    );
  }

  element.oninput = function oninput() {
    // Let's change the classification color with the color picker value
    const hexColor = element.value;
    onChange(new Color(hexColor));
  };

  const externalFunction = (v) => {
    element.value = `#${new Color(v).getHexString()}`;
    onChange(element.value);
  };

  return [externalFunction, new Color(element.value), element];
}

function bindDropDown(id, onChange) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLSelectElement)) {
    throw new Error(
      "invalid binding element: expected HTMLSelectElement, got: " +
        element.constructor.name,
    );
  }

  element.onchange = () => {
    onChange(element.value);
  };

  const callback = (v) => {
    element.value = v;
    onChange(element.value);
  };

  return [callback, element.value, element];
}

Instance.registerCRS(
  "EPSG:2154",
  "+proj=lcc +lat_0=46.5 +lon_0=3 +lat_1=49 +lat_2=44 +x_0=700000 +y_0=6600000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs",
);
Instance.registerCRS(
  "IGNF:WGS84G",
  'GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]]',
);

const instance = new Instance({
  target: "view",
  crs: "EPSG:2154",
  backgroundColor: null,
});

const extent = Extent.fromCenterAndSize(
  "EPSG:2154",
  { x: 972_027, y: 6_299_491 },
  10_000,
  10_000,
);

const map = new Map({
  extent,
  backgroundColor: "gray",
  hillshading: {
    enabled: true,
    intensity: 0.6,
    elevationLayersOnly: true,
  },
  side: DoubleSide,
});
instance.add(map);

const noDataValue = -1000;

const capabilitiesUrl =
  "https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetCapabilities";

WmtsSource.fromCapabilities(capabilitiesUrl, {
  layer: "ELEVATION.ELEVATIONGRIDCOVERAGE.HIGHRES",
  format: new BilFormat(),
  noDataValue,
})
  .then((source) => {
    map.addLayer(
      new ElevationLayer({
        extent: map.extent,
        preloadImages: true,
        resolutionFactor: 0.5,
        minmax: { min: 500, max: 1500 },
        source: source,
      }),
    );
  })
  .catch(console.error);

WmtsSource.fromCapabilities(capabilitiesUrl, {
  layer: "HR.ORTHOIMAGERY.ORTHOPHOTOS",
})
  .then((source) => {
    map.addLayer(
      new ColorLayer({
        preloadImages: true,
        extent: map.extent,
        source: source,
      }),
    );
  })
  .catch(console.error);

const center = extent.centerAsVector2();
instance.view.camera.position.set(center.x - 1000, center.y - 1000, 3000);
const lookAt = new Vector3(center.x, center.y, 200);
instance.view.camera.lookAt(lookAt);
instance.notifyChange(instance.view.camera);

const controls = new MapControls(instance.view.camera, instance.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.2;
controls.target.copy(lookAt);
controls.saveState();
instance.view.setControls(controls);

const shapes = [];

const options = {
  lineWidth: 2,
  borderWidth: 1,
  vertexRadius: 4,
  color: "#2978b4",
  areaUnit: "m",
  lengthUnit: "m",
  slopeUnit: "deg",
  surfaceOpacity: DEFAULT_SURFACE_OPACITY,
};

const tool = new DrawTool({ instance });

let abortController;

document.addEventListener("keydown", (e) => {
  switch (e.key) {
    case "Escape":
      try {
        abortController.abort();
      } catch {
        console.log("aborted");
      }
      break;
  }
});

function vertexLabelFormatter({ position }) {
  const latlon = new Coordinates(
    instance.referenceCrs,
    position.x,
    position.y,
  ).as("EPSG:4326");

  return `lat: ${latlon.latitude.toFixed(5)}°, lon: ${latlon.longitude.toFixed(5)}°`;
}

const exportButton = bindButton("export", () => {
  const featureCollection = {
    type: "FeatureCollection",
    features: shapes.map((m) => m.toGeoJSON()),
  };

  const text = JSON.stringify(featureCollection, null, 2);

  const blob = new Blob([text], { type: "application/geo+json" });

  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");

  link.download = `shapes.geojson`;
  link.href = url;
  link.click();
});

const numberFormat = new Intl.NumberFormat(undefined, {
  maximumFractionDigits: 2,
});

const slopeFormatter = (opts) => {
  switch (options.slopeUnit) {
    case "deg":
      return angleSegmentFormatter(opts);
    case "pct":
      return slopeSegmentFormatter(opts);
  }
};

const surfaceLabelFormatter = ({ area }) => {
  switch (options.areaUnit) {
    case "m": {
      if (area > 1_000_000) {
        return `${numberFormat.format(area / 1_000_000)} km²`;
      }
      return `${numberFormat.format(Math.round(area))} m²`;
    }
    case "ha":
      return `${numberFormat.format(area / 10000)} ha`;
    case "acre":
      return `${numberFormat.format(area / 4_046.8564224)} acres`;
  }
};

const lengthFormatter = ({ length }) => {
  switch (options.lengthUnit) {
    case "m":
      return `${numberFormat.format(Math.round(length))} m`;
    case "ft":
      return `${numberFormat.format(Math.round(length * 3.28084))} ft`;
  }
};

// Overrides the default formatter for vertical lines
const verticalLineLabelFormatter = ({ vertexIndex, length }) => {
  if (vertexIndex === 0) {
    return null;
  }

  switch (options.lengthUnit) {
    case "m":
      return `${numberFormat.format(Math.round(length))} m`;
    case "ft":
      return `${numberFormat.format(Math.round(length * 3.28084))} ft`;
  }
};

function fromGeoJSON(feature) {
  if (feature.type !== "Feature") {
    throw new Error("not a valid GeoJSON feature");
  }

  const crs = "EPSG:4326";

  const getPoint = (c) => {
    const coord = new Coordinates(crs, c[0], c[1], c[2] ?? 0);
    return coord.as(instance.referenceCrs, coord).toVector3();
  };

  const uuid = MathUtils.generateUUID();
  let result;

  switch (feature.geometry.type) {
    case "Point":
      result = new Shape({
        showVertexLabels: true,
        showLine: false,
        showVertices: true,
        beforeRemovePoint: inhibitHook,
        vertexLabelFormatter,
      });
      result.setPoints([getPoint(feature.geometry.coordinates)]);
      break;
    case "MultiPoint":
      result = new Shape({
        showVertexLabels: true,
        showLine: false,
        showVertices: true,
        beforeRemovePoint: limitRemovePointHook(1),
        vertexLabelFormatter,
      });
      result.setPoints(feature.geometry.coordinates.map(getPoint));
      break;
    case "LineString":
      result = new Shape({
        showVertexLabels: false,
        showLine: true,
        showVertices: true,
        showSegmentLabels: true,
        segmentLabelFormatter: lengthFormatter,
        beforeRemovePoint: limitRemovePointHook(2),
      });
      result.setPoints(feature.geometry.coordinates.map(getPoint));
      break;
    case "Polygon":
      result = new Shape({
        showVertexLabels: false,
        showLine: true,
        showVertices: true,
        showSurface: true,
        showSurfaceLabel: true,
        surfaceLabelFormatter,
        beforeRemovePoint: limitRemovePointHook(4), // We take into account the doubled first/last point
        afterRemovePoint: afterRemovePointOfRing,
        afterUpdatePoint: afterUpdatePointOfRing,
      });
      result.setPoints(feature.geometry.coordinates[0].map(getPoint));
      break;
  }

  return result;
}

const removeShapesButton = bindButton("remove-shapes", () => {
  shapes.forEach((m) => instance.remove(m));
  shapes.length = 0;
  removeShapesButton.disabled = true;
  exportButton.disabled = true;
  instance.notifyChange();
});

function importGeoJSONFile(json) {
  for (const feature of json.features) {
    const shape = fromGeoJSON(feature);
    instance.add(shape);
    shapes.push(shape);
  }

  if (shapes.length > 0) {
    removeShapesButton.disabled = false;
    exportButton.disabled = false;
  }
  instance.notifyChange();
}

Fetcher.json("data/default-shapes.geojson").then((json) => {
  importGeoJSONFile(json);
});

bindButton("import", () => {
  const input = document.createElement("input");
  input.type = "file";

  input.onchange = () => {
    const file = input.files[0];

    const reader = new FileReader();
    reader.readAsText(file);

    reader.onload = (readerEvent) => {
      const text = readerEvent.target.result;

      const json = JSON.parse(text);
      importGeoJSONFile(json);
    };
  };

  input.click();
});

function disableDrawButtons(disabled) {
  const group = document.getElementById("draw-group");
  const buttons = group.getElementsByTagName("button");
  for (let i = 0; i < buttons.length; i++) {
    const button = buttons.item(i);
    button.disabled = disabled;
  }
}

function createShape(button, callback, specificOptions) {
  disableDrawButtons(true);

  button.classList.remove("btn-primary");
  button.classList.add("btn-secondary");

  abortController = new AbortController();

  callback
    .bind(tool)({
      signal: abortController.signal,
      ...options,
      ...specificOptions,
    })
    .then((shape) => {
      if (shape) {
        shapes.push(shape);
        removeShapesButton.disabled = false;
        exportButton.disabled = false;
      }
    })
    .catch((e) => {
      if (e.message !== "aborted") {
        console.log(e);
      }
    })
    .finally(() => {
      disableDrawButtons(false);
      button.classList.add("btn-primary");
      button.classList.remove("btn-secondary");
    });
}

bindButton("point", (button) => {
  createShape(button, tool.createPoint, {
    showVertexLabels: true,
    vertexLabelFormatter,
  });
});
bindButton("multipoint", (button) => {
  createShape(button, tool.createMultiPoint, {
    showVertexLabels: true,
    vertexLabelFormatter,
  });
});
bindButton("segment", (button) => {
  createShape(button, tool.createSegment, {
    segmentLabelFormatter: lengthFormatter,
    showSegmentLabels: true,
  });
});
bindButton("linestring", (button) => {
  createShape(button, tool.createLineString, {
    segmentLabelFormatter: lengthFormatter,
    showSegmentLabels: true,
  });
});
bindButton("ring", (button) => {
  createShape(button, tool.createRing, {
    showLineLabel: true,
    lineLabelFormatter: lengthFormatter,
  });
});
bindButton("polygon", (button) => {
  createShape(button, tool.createPolygon, {
    surfaceLabelFormatter,
    showSurfaceLabel: true,
  });
});
bindDropDown("area-unit", (v) => {
  options.areaUnit = v;
  shapes.forEach((shape) => shape.rebuildLabels());
});
bindDropDown("length-unit", (v) => {
  options.lengthUnit = v;
  shapes.forEach((shape) => shape.rebuildLabels());
});
bindDropDown("slope-unit", (v) => {
  options.slopeUnit = v;
  shapes.forEach((shape) => shape.rebuildLabels());
});
bindButton("vertical-measurement", (button) => {
  createShape(button, tool.createVerticalMeasure, {
    verticalLineLabelFormatter: verticalLineLabelFormatter,
    segmentLabelFormatter: slopeFormatter,
  });
});
bindButton("angle-measurement", (button) => {
  createShape(button, tool.createSector);
});
bindSlider("point-radius", (v) => {
  options.vertexRadius = v;
  shapes.forEach((m) => {
    m.vertexRadius = v;
  });
});
bindSlider("line-width", (v) => {
  options.lineWidth = v;
  shapes.forEach((m) => {
    m.lineWidth = v;
  });
});
bindSlider("border-width", (v) => {
  options.borderWidth = v;
  shapes.forEach((m) => {
    m.borderWidth = v;
  });
});
bindSlider("surface-opacity", (v) => {
  options.surfaceOpacity = v;
  shapes.forEach((m) => {
    m.surfaceOpacity = v;
  });
});
bindColorPicker("color", (v) => {
  options.color = v;
  shapes.forEach((m) => {
    m.color = v;
  });
});

function pickShape(mouseEvent) {
  const pickResults = instance.pickObjectsAt(mouseEvent, { where: shapes });
  const first = pickResults[0];
  if (isShapePickResult(first)) {
    return first.entity;
  }

  return null;
}

let isEditModeActive = false;
let highlightHoveredShape = false;
let editedShape = null;

const editButton = bindButton("edit-clicked-shape", () => {
  highlightHoveredShape = true;
  editButton.disabled = true;

  const onclick = (mouseEvent) => {
    if (mouseEvent.button === 0) {
      instance.domElement.removeEventListener("click", onclick);
      const shape = pickShape(mouseEvent);

      if (shape) {
        editedShape = shape;
        isEditModeActive = true;
        highlightHoveredShape = false;

        shape.color = "yellow";

        tool.enterEditMode({
          shapesToEdit: [shape],
        });
      }
    }
  };

  const onrightlick = () => {
    editButton.disabled = false;
    tool.exitEditMode();
    isEditModeActive = false;
    if (editedShape) {
      editedShape.color = options.color;
      editedShape = null;
    }
    instance.domElement.removeEventListener("contextmenu", onrightlick);
  };

  instance.domElement.addEventListener("click", onclick);
  instance.domElement.addEventListener("contextmenu", onrightlick);
});

function mousemove(mouseEvent) {
  if (shapes.length === 0) {
    return;
  }

  for (const shape of shapes) {
    shape.labelOpacity = 1;
  }

  if (isEditModeActive || highlightHoveredShape) {
    const shape = pickShape(mouseEvent);

    if (shape) {
      if (isEditModeActive && shape === editedShape) {
        // Dim labels so the user can properly insert vertices on segments.
        shape.labelOpacity = 0.5;
      }
      if (highlightHoveredShape) {
        shape.color = new Color(options.color).offsetHSL(0, 0, 0.2);
      }
    }
  }
}

instance.domElement.addEventListener("mousemove", mousemove);

// We want to prevent moving the camera while dragging a point
tool.addEventListener("start-drag", () => {
  controls.enabled = false;
});
tool.addEventListener("end-drag", () => {
  controls.enabled = true;
});

Inspector.attach("inspector", instance);
index.html
<!doctype html>
<html lang="en">
  <head>
    <title>Draw shapes</title>
    <meta charset="UTF-8" />
    <meta name="name" content="drawtool" />
    <meta
      name="description"
      content="Enables the user to draw points, lines and polygons."
    />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <link rel="icon" href="https://giro3d.org/images/favicon.svg" />
    <link
      href="https://giro3d.org/assets/bootstrap-custom.css"
      rel="stylesheet"
    />
    <script src="https://giro3d.org/assets/bootstrap.bundle.min.js"></script>
    <link
      rel="stylesheet"
      type="text/css"
      href="https://giro3d.org/next/css/example.css"
    />

    <style>
      #view canvas {
        background: rgb(132, 170, 182);
        background: radial-gradient(
          circle,
          rgba(132, 170, 182, 1) 0%,
          rgba(37, 44, 48, 1) 100%
        );
      }
    </style>
  </head>

  <body>
    <div id="view" class="m-0 p-0 w-100 h-100"></div>
    <div
      id="inspector"
      class="position-absolute top-0 start-0 mh-100 overflow-auto"
    ></div>

    <div class="side-pane-with-status-bar" style="width: 20rem" class="pe-none">
      <!--Parameters -->
      <div class="card">
        <div class="card-header">Parameters</div>

        <div class="card-body">
          <!-- Geometry types -->
          <div class="d-grid gap-2 mx-auto" id="draw-group">
            <h5>Create new shape</h5>
            <div class="row">
              <div class="col" style="padding-right: 0 !important">
                <button type="button" class="btn btn-primary w-100" id="point">
                  Point
                </button>
              </div>
              <div class="col">
                <button
                  type="button"
                  class="btn btn-primary w-100"
                  id="multipoint"
                >
                  MultiPoint
                </button>
              </div>
            </div>

            <div class="row">
              <div class="col" style="padding-right: 0 !important">
                <button
                  type="button"
                  class="btn btn-primary w-100"
                  id="segment"
                >
                  Segment
                </button>
              </div>
              <div class="col">
                <button
                  type="button"
                  class="btn btn-primary w-100"
                  id="linestring"
                >
                  LineString
                </button>
              </div>
            </div>

            <div class="row">
              <div class="col" style="padding-right: 0 !important">
                <button type="button" class="btn btn-primary w-100" id="ring">
                  Ring
                </button>
              </div>
              <div class="col">
                <button
                  type="button"
                  class="btn btn-primary w-100"
                  id="polygon"
                >
                  Polygon
                </button>
              </div>
            </div>

            <div class="row">
              <div class="col" style="padding-right: 0 !important">
                <button
                  type="button"
                  class="btn btn-primary w-100"
                  id="vertical-measurement"
                >
                  Height
                </button>
              </div>
              <div class="col">
                <button
                  type="button"
                  class="btn btn-primary w-100"
                  id="angle-measurement"
                >
                  Angle
                </button>
              </div>
            </div>

            <hr />

            <button
              type="button"
              class="btn btn-primary w-100"
              id="edit-clicked-shape"
              title="Click on a shape to start editing it. When finished, press right-click to exit edition."
            >
              <i class="bi bi-pencil"></i>
              Edit first clicked shape
            </button>

            <div class="row">
              <div class="col" style="padding-right: 0 !important">
                <button
                  type="button"
                  disabled
                  class="btn btn-secondary w-100"
                  id="export"
                >
                  <i class="bi bi-upload"></i>
                  Export
                </button>
              </div>
              <div class="col">
                <button
                  type="button"
                  class="btn btn-secondary w-100"
                  id="import"
                >
                  <i class="bi bi-download"></i>
                  Import
                </button>
              </div>
            </div>

            <button
              type="button"
              disabled
              class="btn btn-danger"
              id="remove-shapes"
            >
              <i class="bi bi-trash-fill"></i>
              Remove shapes
            </button>
          </div>

          <hr />

          <h6>Units</h6>

          <!-- Area unit -->
          <div class="input-group mb-2">
            <label class="input-group-text" style="width: 5rem" for="area-unit"
              >Areas</label
            >
            <select class="form-select" id="area-unit" autocomplete="off">
              <option value="m" selected>Metric</option>
              <option value="ha">Hectares</option>
              <option value="acre">Acres</option>
            </select>
          </div>

          <!-- Length unit -->
          <div class="input-group mb-2">
            <label
              class="input-group-text"
              style="width: 5rem"
              for="length-unit"
              >Lengths</label
            >
            <select class="form-select" id="length-unit" autocomplete="off">
              <option value="m" selected>Metric</option>
              <option value="ft">Feet</option>
            </select>
          </div>

          <!-- Slope unit -->
          <div class="input-group">
            <label class="input-group-text" style="width: 5rem" for="slope-unit"
              >Slopes</label
            >
            <select class="form-select" id="slope-unit" autocomplete="off">
              <option value="deg" selected>Degrees</option>
              <option value="pct">Percent</option>
            </select>
          </div>

          <hr />

          <!-- Color -->
          <label class="form-check-label w-100 mb-2" for="color">
            <div class="row">
              <div class="col">Color</div>
              <div class="col">
                <input
                  type="color"
                  class="form-control form-control-color float-end h-100 w-100"
                  id="color"
                  value="#2978b4"
                  title="color"
                />
              </div>
            </div>
          </label>

          <!-- Point radius -->
          <div class="row mb-2">
            <div class="col">
              <label for="point-radius" class="form-label"
                >Point radius (px)</label
              >
            </div>
            <div class="col">
              <input
                type="range"
                min="0"
                max="20"
                step="1"
                value="4"
                class="form-range"
                id="point-radius"
                autocomplete="off"
              />
            </div>
          </div>

          <!-- Line width slider -->
          <div class="row mb-2">
            <div class="col">
              <label for="line-width" class="form-label">Line width (px)</label>
            </div>
            <div class="col">
              <input
                type="range"
                min="0"
                max="20"
                step="1"
                value="2"
                class="form-range"
                id="line-width"
                autocomplete="off"
              />
            </div>
          </div>

          <!-- Border width slider -->
          <div class="row">
            <div class="col">
              <label for="border-width" class="form-label"
                >Border width (px)</label
              >
            </div>
            <div class="col">
              <input
                type="range"
                min="0"
                max="20"
                step="1"
                value="1"
                class="form-range"
                id="border-width"
                autocomplete="off"
              />
            </div>
          </div>

          <!-- Border width slider -->
          <div class="row">
            <div class="col">
              <label for="surface-opacity" class="form-label"
                >Surface opacity</label
              >
            </div>
            <div class="col">
              <input
                type="range"
                min="0"
                max="1"
                step="0.01"
                value="0.35"
                class="form-range"
                id="surface-opacity"
                autocomplete="off"
              />
            </div>
          </div>
        </div>
      </div>
    </div>

    <script type="module" src="index.js"></script>
    <script>
      /* activate popovers */
      const popoverTriggerList = [].slice.call(
        document.querySelectorAll('[data-bs-toggle="popover"]'),
      );
      popoverTriggerList.map(
        // bootstrap is used as script in the template, disable warning about undef
        // eslint-disable-next-line no-undef
        (popoverTriggerEl) =>
          new bootstrap.Popover(popoverTriggerEl, {
            trigger: "hover",
            placement: "left",
            content: document.getElementById(
              popoverTriggerEl.getAttribute("data-bs-content"),
            ).innerHTML,
            html: true,
          }),
      );
    </script>
  </body>
</html>
package.json
{
    "name": "drawtool",
    "dependencies": {
        "@giro3d/giro3d": "git+https://gitlab.com/giro3d/giro3d.git"
    },
    "devDependencies": {
        "vite": "^3.2.3"
    },
    "scripts": {
        "start": "vite",
        "build": "vite build"
    }
}