Merge pull request #10632 from jahow/draw-interaction-append-coords

Draw interaction: Append coordinates to polygons and lines (reworked)
This commit is contained in:
Olivier Guyot
2020-02-11 13:46:01 +01:00
committed by GitHub
4 changed files with 443 additions and 9 deletions

22
examples/tracing.html Normal file
View File

@@ -0,0 +1,22 @@
---
layout: example.html
title: Tracing around a polygon
shortdesc: Example of setting up a draw interaction to easily snap to an existing feature.
docs: >
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.
tags: "draw, trace, snap, vector, topology"
---
<div id="map" class="map"></div>
<form class="form-inline">
<label>Geometry type &nbsp;</label>
<select id="type">
<option value="Polygon">Polygon</option>
<option value="LineString">LineString</option>
<option value="None">None</option>
</select>
</form>

253
examples/tracing.js Normal file
View File

@@ -0,0 +1,253 @@
import Map from '../src/ol/Map.js';
import View from '../src/ol/View.js';
import Draw from '../src/ol/interaction/Draw.js';
import Snap from '../src/ol/interaction/Snap.js';
import Style from '../src/ol/style/Style.js';
import Stroke from '../src/ol/style/Stroke.js';
import Fill from '../src/ol/style/Fill.js';
import GeoJSON from '../src/ol/format/GeoJSON.js';
import {Tile as TileLayer, Vector as VectorLayer} from '../src/ol/layer.js';
import {OSM, Vector as VectorSource} from '../src/ol/source.js';
import LineString from '../src/ol/geom/LineString.js';
import Feature from '../src/ol/Feature.js';
// 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) {
const lengthAc = length(a, c);
const lengthAb = length(a, b);
const 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) {
let polygon = feature.getGeometry();
if (polygon.getType() === 'MultiPolygon') {
polygon = polygon.getPolygon(0);
}
const ringCoords = polygon.getLinearRing().getCoordinates();
let 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;
}
}
const cwCoordinates = [];
let cwLength = 0;
const ccwCoordinates = [];
let 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
const raster = new TileLayer({
source: new OSM()
});
// features in this layer will be snapped to
const 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
const 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
const previewLine = new Feature({
geometry: new LineString([])
});
const previewVector = new VectorLayer({
source: new VectorSource({
features: [previewLine]
}),
style: new Style({
stroke: new Stroke({
color: 'rgba(255, 0, 0, 1)',
width: 2
})
})
});
const map = new Map({
layers: [raster, baseVector, drawVector, previewVector],
target: 'map',
view: new View({
center: [-12986427, 5678422],
zoom: 5
})
});
let drawInteraction, tracingFeature, startPoint, endPoint;
let drawing = false;
const getFeatureOptions = {
hitTolerance: 10,
layerFilter: (layer) => {
return layer === baseVector;
}
};
// the click event is used to start/end tracing around a feature
map.on('click', (event) => {
if (!drawing) {
return;
}
let hit = false;
map.forEachFeatureAtPixel(
event.pixel,
(feature) => {
if (tracingFeature && feature !== tracingFeature) {
return;
}
hit = true;
const coord = map.getCoordinateFromPixel(event.pixel);
// second click on the tracing feature: append the ring coordinates
if (feature === tracingFeature) {
endPoint = tracingFeature.getGeometry().getClosestPoint(coord);
const 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', (event) => {
if (tracingFeature && drawing) {
let coord = null;
map.forEachFeatureAtPixel(
event.pixel,
(feature) => {
if (tracingFeature === feature) {
coord = map.getCoordinateFromPixel(event.pixel);
}
},
getFeatureOptions
);
let previewCoords = [];
if (coord) {
endPoint = tracingFeature.getGeometry().getClosestPoint(coord);
previewCoords = getPartialRingCoords(tracingFeature, startPoint, endPoint);
}
previewLine.getGeometry().setCoordinates(previewCoords);
}
});
const snapInteraction = new Snap({
source: baseVector.getSource()
});
const typeSelect = document.getElementById('type');
function addInteraction() {
const value = typeSelect.value;
if (value !== 'None') {
drawInteraction = new Draw({
source: drawVector.getSource(),
type: typeSelect.value
});
drawInteraction.on('drawstart', () => {
drawing = true;
});
drawInteraction.on('drawend', () => {
drawing = false;
previewLine.getGeometry().setCoordinates([]);
tracingFeature = null;
});
map.addInteraction(drawInteraction);
map.addInteraction(snapInteraction);
}
}
typeSelect.onchange = function() {
map.removeInteraction(drawInteraction);
map.removeInteraction(snapInteraction);
addInteraction();
};
addInteraction();

View File

@@ -505,7 +505,7 @@ class Draw extends PointerInteraction {
if (this.freehand_ &&
event.type === MapBrowserEventType.POINTERDRAG &&
this.sketchFeature_ !== null) {
this.addToDrawing_(event);
this.addToDrawing_(event.coordinate);
pass = false;
} else if (this.freehand_ &&
event.type === MapBrowserEventType.POINTERDOWN) {
@@ -580,7 +580,7 @@ class Draw extends PointerInteraction {
this.finishDrawing();
}
} else {
this.addToDrawing_(event);
this.addToDrawing_(event.coordinate);
}
pass = false;
} else if (this.freehand_) {
@@ -764,13 +764,12 @@ class Draw extends PointerInteraction {
/**
* Add a new coordinate to the drawing.
* @param {import("../MapBrowserEvent.js").default} event Event.
* @param {!PointCoordType} coordinate Coordinate
* @private
*/
addToDrawing_(event) {
const coordinate = event.coordinate;
addToDrawing_(coordinate) {
const geometry = this.sketchFeature_.getGeometry();
const projection = event.map.getView().getProjection();
const projection = this.getMap().getView().getProjection();
let done;
let coordinates;
if (this.mode_ === Mode.LINE_STRING) {
@@ -903,9 +902,44 @@ class Draw extends PointerInteraction {
}
/**
* Extend an existing geometry by adding additional points. This only works
* on features with `LineString` geometries, where the interaction will
* extend lines by adding points to the end of the coordinates array.
* Append coordinates to the end of the geometry that is currently being drawn.
* This can be used when drawing LineStrings or Polygons. Coordinates will
* either be appended to the current LineString or the outer ring of the current
* Polygon.
* @param {!LineCoordType} coordinates Linear coordinates to be appended into
* the coordinate array.
* @api
*/
appendCoordinates(coordinates) {
const mode = this.mode_;
let sketchCoords = [];
if (mode === Mode.LINE_STRING) {
sketchCoords = /** @type {LineCoordType} */ this.sketchCoords_;
} else if (mode === Mode.POLYGON) {
sketchCoords = this.sketchCoords_ && this.sketchCoords_.length ? /** @type {PolyCoordType} */ (this.sketchCoords_)[0] : [];
}
// Remove last coordinate from sketch drawing (this coordinate follows cursor position)
const ending = sketchCoords.pop();
// Append coordinate list
for (let i = 0; i < coordinates.length; i++) {
this.addToDrawing_(coordinates[i]);
}
// Duplicate last coordinate for sketch drawing
this.addToDrawing_(ending);
}
/**
* Initiate draw mode by starting from an existing geometry which will
* receive new additional points. This only works on features with
* `LineString` geometries, where the interaction will extend lines by adding
* points to the end of the coordinates array.
* This will change the original feature, instead of drawing a copy.
*
* The function will dispatch a `drawstart` event.
*
* @param {!Feature<LineString>} feature Feature to be extended.
* @api
*/

View File

@@ -1399,4 +1399,129 @@ describe('ol.interaction.Draw', function() {
});
});
describe('append coordinates when drawing a Polygon feature', function() {
let draw;
let coordinates;
let coordinates2;
beforeEach(function() {
draw = new Draw({
source: source,
type: 'Polygon'
});
map.addInteraction(draw);
coordinates = [[0, 0], [1, 1], [2, 0], [0, 3], [3, 2], [4, 4]];
coordinates2 = [[10, 10], [11, 11], [12, 10], [10, 13], [13, 12], [14, 14]];
});
function isClosed(polygon) {
const first = polygon.getFirstCoordinate();
const last = polygon.getLastCoordinate();
expect(first).to.eql(last);
}
it('draws polygon with clicks, adds coordinates to drawing, finishing on first point', function() {
// first point
simulateEvent('pointermove', 10, 20);
simulateEvent('pointerdown', 10, 20);
simulateEvent('pointerup', 10, 20);
isClosed(draw.sketchFeature_.getGeometry());
// add coordinates
draw.appendCoordinates(coordinates);
// finish on first point
simulateEvent('pointermove', 10, 20);
simulateEvent('pointerdown', 10, 20);
simulateEvent('pointerup', 10, 20);
const features = source.getFeatures();
expect(features).to.have.length(1);
const geometry = features[0].getGeometry();
expect(geometry).to.be.a(Polygon);
expect(geometry.getCoordinates()).to.eql([
[[10, -20], [0, 0], [1, 1], [2, 0], [0, 3], [3, 2], [4, 4], [10, -20]]
]);
});
it('adds coordinates to empty drawing', function() {
// first point
simulateEvent('pointermove', 0, 0);
simulateEvent('pointerdown', 0, 0);
simulateEvent('pointerup', 0, 0);
draw.removeLastPoint();
draw.appendCoordinates(coordinates);
isClosed(draw.sketchFeature_.getGeometry());
// finish drawing
simulateEvent('pointerdown', 0, 0);
simulateEvent('pointerup', 0, 0);
const features = source.getFeatures();
expect(features).to.have.length(1);
const geometry = features[0].getGeometry();
expect(geometry).to.be.a(Polygon);
expect(geometry.getCoordinates()).to.eql([
[[0, 0], [1, 1], [2, 0], [0, 3], [3, 2], [4, 4], [0, 0]]
]);
});
it('keeps updating the sketch feature after appending coordinates', function() {
// first point
simulateEvent('pointermove', 10, 20);
simulateEvent('pointerdown', 10, 20);
simulateEvent('pointerup', 10, 20);
isClosed(draw.sketchFeature_.getGeometry());
// add coordinates
draw.appendCoordinates(coordinates);
// add another point
simulateEvent('pointermove', 30, 20);
simulateEvent('pointerdown', 30, 20);
simulateEvent('pointerup', 30, 20);
// sketchGeom should have a complete ring, with a double coordinate for cursor
const sketchGeom = draw.sketchFeature_.getGeometry();
expect(sketchGeom.getCoordinates()).to.eql([
[[10, -20], [0, 0], [1, 1], [2, 0], [0, 3], [3, 2], [4, 4], [30, -20], [30, -20], [10, -20]]
]);
});
it('keeps updating the sketch feature after multiple appendiges', function() {
// first point
simulateEvent('pointermove', 10, 20);
simulateEvent('pointerdown', 10, 20);
simulateEvent('pointerup', 10, 20);
isClosed(draw.sketchFeature_.getGeometry());
// add coordinates
draw.appendCoordinates(coordinates);
// another point
simulateEvent('pointermove', 100, 100);
simulateEvent('pointerdown', 100, 100);
simulateEvent('pointerup', 100, 100);
// add another array of coordinates
draw.appendCoordinates(coordinates2);
// finish on first point
simulateEvent('pointermove', 10, 20);
simulateEvent('pointerdown', 10, 20);
simulateEvent('pointerup', 10, 20);
const features = source.getFeatures();
expect(features).to.have.length(1);
const geometry = features[0].getGeometry();
expect(geometry).to.be.a(Polygon);
expect(geometry.getCoordinates()).to.eql([
[[10, -20], [0, 0], [1, 1], [2, 0], [0, 3], [3, 2], [4, 4], [100, -100], [10, 10], [11, 11], [12, 10], [10, 13], [13, 12], [14, 14], [10, -20]]
]);
});
});
});