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, 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/index.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,
slopeSegmentFormatter,
} from '@giro3d/giro3d/entities/Shape.js';
import Fetcher from '@giro3d/giro3d/utils/Fetcher.js';
import Inspector from '@giro3d/giro3d/gui/Inspector.js';
/**
* Binds a button.
* @param {string} id The id of the <button> element.
* @param {(button: HTMLButtonElement) => void} onClick The click handler.
* @returns {HTMLButtonElement} The button element.
*/
function bindButton(id, onClick) {
const element = document.getElementById(id);
element.onclick = () => {
onClick(element);
};
return element;
}
/**
* Binds a {@link HTMLInputElement} in slider mode.
* @param {string} id The id of the <input> element.
* @param {(v: number) => void} onChange The callback when the slider value changes.
* @returns {(v: number) => void} The function to update the value from outside.
*/
function bindSlider(id, onChange) {
/** @type {HTMLInputElement} */
const slider = document.getElementById(id);
slider.oninput = function oninput() {
onChange(slider.valueAsNumber);
};
return v => {
slider.valueAsNumber = v;
onChange(slider.valueAsNumber);
};
}
function bindColorPicker(id, onChange) {
const colorPicker = document.getElementById(id);
colorPicker.oninput = function oninput() {
// Let's change the classification color with the color picker value
const hexColor = colorPicker.value;
onChange(hexColor);
};
return v => {
colorPicker.value = v;
onChange(colorPicker.value);
};
}
/**
* Binds a text-value dropdown.
* @param {string} id The id of the <input> element.
* @param {(v: string) => void} onChange The callback when the dropdown value changes.
* @returns {(v: string) => void} The function to update the value from outside.
*/
function bindDropDown(id, onChange) {
/** @type {HTMLInputElement} */
const mode = document.getElementById(id);
mode.onchange = () => {
onChange(mode.value);
};
return v => {
mode.value = v;
onChange(mode.value);
};
}
// Defines projection that we will use (taken from https://epsg.io/2154, Proj4js section)
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 viewerDiv = document.getElementById('viewerDiv');
const instance = new Instance(viewerDiv, {
crs: 'EPSG:2154',
renderer: {
clearColor: false,
},
});
// create a map
const extent = Extent.fromCenterAndSize('EPSG:2154', { x: 972_027, y: 6_299_491 }, 10_000, 10_000);
const map = new Map('planar', {
extent,
backgroundColor: 'gray',
hillshading: {
enabled: true,
intensity: 0.6,
elevationLayersOnly: true,
},
doubleSided: true,
});
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(elevationWmts => {
map.addLayer(
new ElevationLayer({
name: 'wmts_elevation',
extent: map.extent,
resolutionFactor: 1,
minmax: { min: 500, max: 1500 },
noDataOptions: {
replaceNoData: false,
},
source: elevationWmts,
}),
);
})
.catch(console.error);
WmtsSource.fromCapabilities(capabilitiesUrl, {
layer: 'HR.ORTHOIMAGERY.ORTHOPHOTOS',
})
.then(orthophotoWmts => {
map.addLayer(
new ColorLayer({
extent: map.extent,
source: orthophotoWmts,
}),
);
})
.catch(console.error);
const center = extent.centerAsVector2();
instance.camera.camera3D.position.set(center.x - 1000, center.y - 1000, 3000);
const lookAt = new Vector3(center.x, center.y, 200);
instance.camera.camera3D.lookAt(lookAt);
instance.notifyChange(instance.camera.camera3D);
// Creates controls
const controls = new MapControls(instance.camera.camera3D, instance.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.2;
controls.target.copy(lookAt);
controls.saveState();
instance.useTHREEControls(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,
hoverColor: options.highlightColor,
dragColor: options.dragColor,
});
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(uuid, {
showVertexLabels: true,
showLine: false,
showVertices: true,
beforeRemovePoint: inhibitHook,
vertexLabelFormatter,
});
result.setPoints([getPoint(feature.geometry.coordinates)]);
break;
case 'MultiPoint':
result = new Shape(uuid, {
showVertexLabels: true,
showLine: false,
showVertices: true,
beforeRemovePoint: limitRemovePointHook(1),
vertexLabelFormatter,
});
result.setPoints(feature.geometry.coordinates.map(getPoint));
break;
case 'LineString':
result = new Shape(uuid, {
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(uuid, {
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 = e => {
const file = e.target.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();
});
let isCurrentlyDrawing = false;
function disableDrawButtons(disabled) {
const group = document.getElementById('draw-group');
const buttons = group.getElementsByTagName('button');
for (const button of buttons) {
button.disabled = disabled;
}
}
/**
* @param {HTMLButtonElement} button - TTh
* @param {*} callback
* @param {*} specificOptions
*/
function createShape(button, callback, specificOptions) {
disableDrawButtons(true);
button.classList.remove('btn-primary');
button.classList.add('btn-secondary');
abortController = new AbortController();
isCurrentlyDrawing = true;
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');
isCurrentlyDrawing = false;
});
}
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 dimLabels(mouseEvent) {
if (shapes.length === 0) {
return;
}
const pickResults = instance.pickObjectsAt(mouseEvent, { where: shapes });
for (const shape of shapes) {
shape.labelOpacity = 1;
}
if (pickResults.length > 0) {
const picked = pickResults[0];
const shape = picked.entity;
// Dim labels so the user can properly insert vertices on segments.
shape.labelOpacity = 0.5;
}
}
instance.domElement.addEventListener('mousemove', dimLabels);
// We allow editing existing shapes
tool.enterEditMode();
// 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(document.getElementById('panelDiv'), 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/css/example.css"
/>
<style>
#viewerDiv 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="viewerDiv" class="m-0 p-0 w-100 h-100"></div>
<div id="panelDiv" class="position-absolute top-0 start-0 mh-100 overflow-auto"></div>
<div class="side-pane-with-status-bar" 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 />
<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 />
<!-- Area unit -->
<div class="input-group mb-2">
<label class="input-group-text" 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" 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" 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>
{
"name": "drawtool",
"dependencies": {
"@giro3d/giro3d": "0.38.5"
},
"devDependencies": {
"vite": "^3.2.3"
},
"scripts": {
"start": "vite",
"build": "vite build"
}
}