Example of setting up a draw interaction to easily snap to an existing feature.
This example showcases how the draw interaction API can be set up to make snapping along an existing geometry easier while preserving topology, which is sometimes called "tracing". When the user clicks on two different points on the Idaho state border, the part of the border comprised between these two points is added to the currently drawn feature. This leverages the appendCoordinates
method of the ol/interaction/Draw
interaction.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Tracing around a polygon</title>
<!-- The line below is only needed for old environments like Internet Explorer and Android 4.x -->
<script src="https://cdn.polyfill.io/v2/polyfill.min.js?features=fetch,requestAnimationFrame,Element.prototype.classList,URL"></script>
<style>
.map {
width: 100%;
height:400px;
}
</style>
</head>
<body>
<div id="map" class="map"></div>
<form class="form-inline">
<label>Geometry type </label>
<select id="type">
<option value="Polygon">Polygon</option>
<option value="LineString">LineString</option>
<option value="None">None</option>
</select>
</form>
<script src="index.js"></script>
</body>
</html>
import 'ol/ol.css';
import Map from 'ol/Map';
import View from 'ol/View';
import Draw from 'ol/interaction/Draw';
import Snap from 'ol/interaction/Snap';
import Style from 'ol/style/Style';
import Stroke from 'ol/style/Stroke';
import Fill from 'ol/style/Fill';
import GeoJSON from 'ol/format/GeoJSON';
import {Tile as TileLayer, Vector as VectorLayer} from 'ol/layer';
import {OSM, Vector as VectorSource} from 'ol/source';
import LineString from 'ol/geom/LineString';
import Feature from 'ol/Feature';
// math utilities
// coordinates; will return the length of the [a, b] segment
function length(a, b) {
return Math.sqrt((b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1]));
}
// coordinates; will return true if c is on the [a, b] segment
function isOnSegment(c, a, b) {
var lengthAc = length(a, c);
var lengthAb = length(a, b);
var dot = ((c[0] - a[0]) * (b[0] - a[0]) + (c[1] - a[1]) * (b[1] - a[1])) / lengthAb;
return Math.abs(lengthAc - dot) < 1e-6 && lengthAc < lengthAb;
}
// modulo for negative values, eg: mod(-1, 4) returns 3
function mod(a, b) {
return ((a % b) + b) % b;
}
// returns a coordinates array which contains the segments of the feature's
// outer ring between the start and end points
// Note: this assumes the base feature is a single polygon
function getPartialRingCoords(feature, startPoint, endPoint) {
var polygon = feature.getGeometry();
if (polygon.getType() === 'MultiPolygon') {
polygon = polygon.getPolygon(0);
}
var ringCoords = polygon.getLinearRing().getCoordinates();
var i, pointA, pointB, startSegmentIndex = -1;
for (i = 0; i < ringCoords.length; i++) {
pointA = ringCoords[i];
pointB = ringCoords[mod(i + 1, ringCoords.length)];
// check if this is the start segment dot product
if (isOnSegment(startPoint, pointA, pointB)) {
startSegmentIndex = i;
break;
}
}
var cwCoordinates = [];
var cwLength = 0;
var ccwCoordinates = [];
var ccwLength = 0;
// build clockwise coordinates
for (i = 0; i < ringCoords.length; i++) {
pointA = i === 0 ? startPoint : ringCoords[mod(i + startSegmentIndex, ringCoords.length)];
pointB = ringCoords[mod(i + startSegmentIndex + 1, ringCoords.length)];
cwCoordinates.push(pointA);
if (isOnSegment(endPoint, pointA, pointB)) {
cwCoordinates.push(endPoint);
cwLength += length(pointA, endPoint);
break;
} else {
cwLength += length(pointA, pointB);
}
}
// build counter-clockwise coordinates
for (i = 0; i < ringCoords.length; i++) {
pointA = ringCoords[mod(startSegmentIndex - i, ringCoords.length)];
pointB = i === 0 ? startPoint : ringCoords[mod(startSegmentIndex - i + 1, ringCoords.length)];
ccwCoordinates.push(pointB);
if (isOnSegment(endPoint, pointA, pointB)) {
ccwCoordinates.push(endPoint);
ccwLength += length(endPoint, pointB);
break;
} else {
ccwLength += length(pointA, pointB);
}
}
// keep the shortest path
return ccwLength < cwLength ? ccwCoordinates : cwCoordinates;
}
// layers definition
var raster = new TileLayer({
source: new OSM()
});
// features in this layer will be snapped to
var baseVector = new VectorLayer({
source: new VectorSource({
format: new GeoJSON(),
url: 'https://ahocevar.com/geoserver/wfs?service=wfs&request=getfeature&typename=topp:states&cql_filter=STATE_NAME=\'Idaho\'&outputformat=application/json'
})
});
// this is were the drawn features go
var drawVector = new VectorLayer({
source: new VectorSource(),
style: new Style({
stroke: new Stroke({
color: 'rgba(100, 255, 0, 1)',
width: 2
}),
fill: new Fill({
color: 'rgba(100, 255, 0, 0.3)'
})
})
});
// this line only appears when we're tracing a feature outer ring
var previewLine = new Feature({
geometry: new LineString([])
});
var previewVector = new VectorLayer({
source: new VectorSource({
features: [previewLine]
}),
style: new Style({
stroke: new Stroke({
color: 'rgba(255, 0, 0, 1)',
width: 2
})
})
});
var map = new Map({
layers: [raster, baseVector, drawVector, previewVector],
target: 'map',
view: new View({
center: [-12986427, 5678422],
zoom: 5
})
});
var drawInteraction, tracingFeature, startPoint, endPoint;
var drawing = false;
var getFeatureOptions = {
hitTolerance: 10,
layerFilter: function (layer) {
return layer === baseVector;
}
};
// the click event is used to start/end tracing around a feature
map.on('click', function (event) {
if (!drawing) {
return;
}
var hit = false;
map.forEachFeatureAtPixel(
event.pixel,
function (feature) {
if (tracingFeature && feature !== tracingFeature) {
return;
}
hit = true;
var coord = map.getCoordinateFromPixel(event.pixel);
// second click on the tracing feature: append the ring coordinates
if (feature === tracingFeature) {
endPoint = tracingFeature.getGeometry().getClosestPoint(coord);
var appendCoords = getPartialRingCoords(tracingFeature, startPoint, endPoint);
drawInteraction.removeLastPoint();
drawInteraction.appendCoordinates(appendCoords);
tracingFeature = null;
}
// start tracing on the feature ring
tracingFeature = feature;
startPoint = tracingFeature.getGeometry().getClosestPoint(coord);
},
getFeatureOptions
);
if (!hit) {
// clear current tracing feature & preview
previewLine.getGeometry().setCoordinates([]);
tracingFeature = null;
}
});
// the pointermove event is used to show a preview of the result of the tracing
map.on('pointermove', function (event) {
if (tracingFeature && drawing) {
var coord = null;
map.forEachFeatureAtPixel(
event.pixel,
function (feature) {
if (tracingFeature === feature) {
coord = map.getCoordinateFromPixel(event.pixel);
}
},
getFeatureOptions
);
var previewCoords = [];
if (coord) {
endPoint = tracingFeature.getGeometry().getClosestPoint(coord);
previewCoords = getPartialRingCoords(tracingFeature, startPoint, endPoint);
}
previewLine.getGeometry().setCoordinates(previewCoords);
}
});
var snapInteraction = new Snap({
source: baseVector.getSource()
});
var typeSelect = document.getElementById('type');
function addInteraction() {
var value = typeSelect.value;
if (value !== 'None') {
drawInteraction = new Draw({
source: drawVector.getSource(),
type: typeSelect.value
});
drawInteraction.on('drawstart', function () {
drawing = true;
});
drawInteraction.on('drawend', function () {
drawing = false;
previewLine.getGeometry().setCoordinates([]);
tracingFeature = null;
});
map.addInteraction(drawInteraction);
map.addInteraction(snapInteraction);
}
}
typeSelect.onchange = function() {
map.removeInteraction(drawInteraction);
map.removeInteraction(snapInteraction);
addInteraction();
};
addInteraction();
{
"name": "tracing",
"dependencies": {
"ol": "6.3.1"
},
"devDependencies": {
"parcel": "1.11.0"
},
"scripts": {
"start": "parcel index.html",
"build": "parcel build --experimental-scope-hoisting --public-url . index.html"
}
}