diff --git a/data/core.yaml b/data/core.yaml
index f048fb0cb..2c1c29bc3 100644
--- a/data/core.yaml
+++ b/data/core.yaml
@@ -63,6 +63,13 @@ en:
line: Squared the corners of a line.
area: Squared the corners of an area.
not_closed: This can't be made square because it's not a loop.
+ straighten:
+ title: Straighten
+ description: Straighten this line.
+ key: S
+ annotation: Straightened the line.
+ is_closed: This can't be straightened because it's a loop.
+ too_bendy: This can't be straightened because it's too bendy.
delete:
title: Delete
description: Remove this from the map.
diff --git a/index.html b/index.html
index 490ee3843..3b387de5e 100644
--- a/index.html
+++ b/index.html
@@ -146,6 +146,7 @@
+
@@ -179,6 +180,7 @@
+
diff --git a/js/id/actions/straighten.js b/js/id/actions/straighten.js
new file mode 100644
index 000000000..c891cbcce
--- /dev/null
+++ b/js/id/actions/straighten.js
@@ -0,0 +1,75 @@
+/*
+ * Based on https://github.com/openstreetmap/potlatch2/net/systemeD/potlatch2/tools/Straighten.as
+ */
+
+iD.actions.Straighten = function(wayId, projection) {
+ function positionAlongWay(n, s, e) {
+ return ((n[0] - s[0]) * (e[0] - s[0]) + (n[1] - s[1]) * (e[1] - s[1]))/
+ (Math.pow(e[0] - s[0], 2) + Math.pow(e[1] - s[1], 2));
+ }
+
+ var action = function(graph) {
+ var way = graph.entity(wayId),
+ nodes = graph.childNodes(way),
+ points = nodes.map(function(n) { return projection(n.loc); }),
+ startPoint = points[0],
+ endPoint = points[points.length-1],
+ toDelete = [],
+ i;
+
+ for (i = 1; i < points.length-1; i++) {
+ var node = nodes[i],
+ point = points[i];
+
+ if (graph.parentWays(node).length > 1 || (node.tags && Object.keys(node.tags).length)) {
+ var u = positionAlongWay(point, startPoint, endPoint),
+ p0 = startPoint[0] + u * (endPoint[0] - startPoint[0]),
+ p1 = startPoint[1] + u * (endPoint[1] - startPoint[1]),
+
+ graph = graph.replace(graph.entity(node.id)
+ .move(projection.invert([p0, p1])));
+ } else {
+ // safe to delete
+ if (toDelete.indexOf(node) == -1) {
+ toDelete.push(node);
+ }
+ }
+ }
+
+ for (i = 0; i < toDelete.length; i++) {
+ graph = iD.actions.DeleteNode(toDelete[i].id)(graph);
+ }
+
+ return graph;
+ };
+
+ action.disabled = function(graph) {
+ if (graph.entity(wayId).isClosed()) {
+ return 'is_closed';
+ }
+
+ // check way isn't too bendy
+ var way = graph.entity(wayId),
+ nodes = graph.childNodes(way),
+ points = nodes.map(function(n) { return projection(n.loc); }),
+ startPoint = points[0],
+ endPoint = points[points.length-1],
+ threshold = 0.2 * Math.sqrt(Math.pow(startPoint[0] - endPoint[0], 2) + Math.pow(startPoint[1] - endPoint[1], 2)),
+ i;
+
+ for (i = 1; i < points.length-1; i++) {
+ var point = points[i],
+ u = positionAlongWay(point, startPoint, endPoint),
+ p0 = startPoint[0] + u * (endPoint[0] - startPoint[0]),
+ p1 = startPoint[1] + u * (endPoint[1] - startPoint[1]),
+ dist = Math.sqrt(Math.pow(p0 - point[0], 2) + Math.pow(p1 - point[1], 2));
+
+ // to bendy if point is off by 20% of total start/end distance in projected space
+ if (dist > threshold) {
+ return 'too_bendy';
+ }
+ }
+ };
+
+ return action;
+};
diff --git a/js/id/operations/straighten.js b/js/id/operations/straighten.js
new file mode 100644
index 000000000..44d98b95e
--- /dev/null
+++ b/js/id/operations/straighten.js
@@ -0,0 +1,33 @@
+iD.operations.Straighten = function(selectedIDs, context) {
+ var entityId = selectedIDs[0],
+ action = iD.actions.Straighten(entityId, context.projection);
+
+ var operation = function() {
+ var annotation = t('operations.straighten.annotation');
+ context.perform(action, annotation);
+ };
+
+ operation.available = function() {
+ return selectedIDs.length === 1 &&
+ context.entity(entityId).type === 'way' &&
+ _.uniq(context.entity(entityId).nodes).length > 2;
+ };
+
+ operation.disabled = function() {
+ return action.disabled(context.graph());
+ };
+
+ operation.tooltip = function() {
+ var disable = operation.disabled();
+ return disable ?
+ t('operations.straighten.' + disable) :
+ t('operations.straighten.description');
+ };
+
+ operation.id = "straighten";
+ operation.keys = [t('operations.straighten.key')];
+ operation.title = "title";
+ operation.description = "description";
+
+ return operation;
+};
diff --git a/test/index.html b/test/index.html
index 1ecab3f75..7836b34b2 100644
--- a/test/index.html
+++ b/test/index.html
@@ -128,6 +128,7 @@
+
@@ -160,6 +161,7 @@
+
@@ -203,6 +205,7 @@
+
diff --git a/test/spec/actions/straighten.js b/test/spec/actions/straighten.js
new file mode 100644
index 000000000..d1f6ca43c
--- /dev/null
+++ b/test/spec/actions/straighten.js
@@ -0,0 +1,44 @@
+describe("iD.actions.Straighten", function () {
+ var projection = d3.geo.mercator();
+
+ it("deletes empty nodes", function() {
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a', loc: [0, 0]}),
+ 'b': iD.Node({id: 'b', loc: [2, 0], tags: {}}),
+ 'c': iD.Node({id: 'c', loc: [2, 2]}),
+ '-': iD.Way({id: '-', nodes: ['a', 'b', 'c']})
+ });
+
+ graph = iD.actions.Straighten('-', projection)(graph);
+
+ expect(graph.hasEntity('b')).to.be.undefined;
+ });
+
+ it("does not delete tagged nodes", function() {
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a', loc: [0, 0]}),
+ 'b': iD.Node({id: 'b', loc: [2, 0], tags: {foo: 'bar'}}),
+ 'c': iD.Node({id: 'c', loc: [2, 2]}),
+ '-': iD.Way({id: '-', nodes: ['a', 'b', 'c']})
+ });
+
+ graph = iD.actions.Straighten('-', projection)(graph);
+
+ expect(graph.entity('-').nodes.sort()).to.eql(['a', 'b', 'c']);
+ });
+
+ it("does not delete nodes connected to other ways", function() {
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a', loc: [0, 0]}),
+ 'b': iD.Node({id: 'b', loc: [2, 0]}),
+ 'c': iD.Node({id: 'c', loc: [2, 2]}),
+ 'd': iD.Node({id: 'd', loc: [0, 2]}),
+ '-': iD.Way({id: '-', nodes: ['a', 'b', 'c', 'd']}),
+ '=': iD.Way({id: '=', nodes: ['b']})
+ });
+
+ graph = iD.actions.Straighten('-', projection)(graph);
+
+ expect(graph.entity('-').nodes).to.have.length(3);
+ });
+});