Parameters
Enables the user to draw points, lines and polygons.
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.
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,
conditions,
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, min, max, step) => {
if (min != null && max != null) {
element.min = min.toString();
element.max = max.toString();
if (step != null) {
element.step = step;
}
}
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);
};
const setOptions = (options) => {
const items = options.map(
(opt) =>
`<option value=${opt.id} ${opt.selected ? "selected" : ""}>${opt.name}</option>`,
);
element.innerHTML = items.join("\n");
};
return [callback, element.value, element, setOptions];
}
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",
endCondition: "rightclick",
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,
endCondition:
options.endCondition === "rightclick"
? conditions.rightClick
: conditions.doubleClick,
onTemporaryPointMoved: () => console.log("onTemporaryPointMoved"),
})
.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());
});
bindDropDown("end-condition", (v) => {
options.endCondition = v;
});
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;
removeShapesButton.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],
onPointInserted: (arg) => console.log("onPointInserted", arg),
onPointUpdated: (arg) => console.log("onPointMoved", arg),
onPointRemoved: (arg) => console.log("onPointRemoved", arg),
});
}
}
};
const onrightlick = () => {
editButton.disabled = false;
removeShapesButton.disabled = shapes.length === 0;
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);
<!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/latest/examples/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>
<!-- End condition -->
<div class="input-group mt-2">
<label
class="input-group-text"
title="The condition to complete a drawing. Only applies to shapes with unlimited number of points (LineString, Polygon, MultiPoint)"
for="end-condition"
>End with</label
>
<select class="form-select" id="end-condition" autocomplete="off">
<option value="rightclick" selected>Right-click</option>
<option value="doubleclick">Double click</option>
</select>
</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"
autocomplete="off"
/>
</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>
{
"name": "drawtool",
"dependencies": {
"@giro3d/giro3d": "0.41.0"
},
"devDependencies": {
"vite": "^3.2.3"
},
"scripts": {
"start": "vite",
"build": "vite build"
}
}