diff --git a/index.html b/index.html
index 9cdea5a66..490ee3843 100644
--- a/index.html
+++ b/index.html
@@ -43,6 +43,7 @@
+
diff --git a/js/id/geo/turn.js b/js/id/geo/turn.js
new file mode 100644
index 000000000..296a43e31
--- /dev/null
+++ b/js/id/geo/turn.js
@@ -0,0 +1,62 @@
+iD.geo.turns = function(graph, entityID) {
+ var way = graph.entity(entityID);
+ if (way.type !== 'way' || !way.tags.highway || way.isArea())
+ return [];
+
+ function withRestriction(turn) {
+ graph.parentRelations(turn.from).forEach(function(relation) {
+ if (relation.tags.type !== 'restriction')
+ return;
+
+ var f = relation.memberByRole('from'),
+ t = relation.memberByRole('to'),
+ v = relation.memberByRole('via');
+
+ if (f && f.id === turn.from.id &&
+ t && t.id === turn.to.id &&
+ v && v.id === turn.via.id) {
+ turn.restriction = relation;
+ }
+ });
+
+ return turn;
+ }
+
+ var turns = [];
+
+ [way.first(), way.last()].forEach(function(nodeID) {
+ var node = graph.entity(nodeID);
+ graph.parentWays(node).forEach(function(parent) {
+ if (parent === way || parent.isDegenerate() || !parent.tags.highway)
+ return;
+ if (way.first() === node.id && way.tags.oneway === 'yes')
+ return;
+ if (way.last() === node.id && way.tags.oneway === '-1')
+ return;
+
+ var index = parent.nodes.indexOf(node.id);
+
+ // backward
+ if (parent.first() !== node.id && parent.tags.oneway !== 'yes') {
+ turns.push(withRestriction({
+ from: way,
+ to: parent,
+ via: node,
+ toward: graph.entity(parent.nodes[index - 1])
+ }));
+ }
+
+ // forward
+ if (parent.last() !== node.id && parent.tags.oneway !== '-1') {
+ turns.push(withRestriction({
+ from: way,
+ to: parent,
+ via: node,
+ toward: graph.entity(parent.nodes[index + 1])
+ }));
+ }
+ });
+ });
+
+ return turns;
+};
diff --git a/test/index.html b/test/index.html
index 185528971..1ecab3f75 100644
--- a/test/index.html
+++ b/test/index.html
@@ -44,6 +44,7 @@
+
@@ -221,6 +222,7 @@
+
diff --git a/test/index_packaged.html b/test/index_packaged.html
index f187c3925..dbdecfc1c 100644
--- a/test/index_packaged.html
+++ b/test/index_packaged.html
@@ -50,6 +50,7 @@
+
diff --git a/test/spec/geo/turn.js b/test/spec/geo/turn.js
new file mode 100644
index 000000000..e4d378667
--- /dev/null
+++ b/test/spec/geo/turn.js
@@ -0,0 +1,276 @@
+describe("iD.geo.turns", function() {
+ it("returns an empty array for non-ways", function() {
+ var graph = iD.Graph({
+ 'n': iD.Node({id: 'n'})
+ });
+ expect(iD.geo.turns(graph, 'n')).to.eql([]);
+ });
+
+ it("returns an empty array for non-lines", function() {
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v'], tags: {highway: 'residential', area: 'yes'}}),
+ '-': iD.Way({id: '-', nodes: ['v', 'w'], tags: {highway: 'residential'}})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([]);
+ });
+
+ it("returns an empty array for an unconnected way", function() {
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v']})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([]);
+ });
+
+ it("omits turns onto degenerate ways", function() {
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v'], tags: {highway: 'residential'}}),
+ '-': iD.Way({id: '-', nodes: ['v'], tags: {highway: 'residential'}})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([]);
+ });
+
+ it("omits turns from non-highways", function() {
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v']}),
+ '-': iD.Way({id: '-', nodes: ['v', 'w'], tags: {highway: 'residential'}})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([]);
+ });
+
+ it("omits turns onto non-highways", function() {
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v'], tags: {highway: 'residential'}}),
+ '-': iD.Way({id: '-', nodes: ['v', 'w']})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([]);
+ });
+
+ it("omits turns onto non-lines", function() {
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ 'x': iD.Node({id: 'x'}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v'], tags: {highway: 'residential'}}),
+ '-': iD.Way({id: '-', nodes: ['v', 'w', 'x', 'v'], tags: {highway: 'residential', area: 'yes'}})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([]);
+ });
+
+ it("permits turns onto a way forward", function() {
+ // u====v--->w
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v'], tags: {highway: 'residential'}}),
+ '-': iD.Way({id: '-', nodes: ['v', 'w'], tags: {highway: 'residential'}})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([{
+ from: graph.entity('='),
+ to: graph.entity('-'),
+ via: graph.entity('v'),
+ toward: graph.entity('w')
+ }]);
+ });
+
+ it("permits turns onto a way backward", function() {
+ // u====v<---w
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v'], tags: {highway: 'residential'}}),
+ '-': iD.Way({id: '-', nodes: ['w', 'v'], tags: {highway: 'residential'}})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([{
+ from: graph.entity('='),
+ to: graph.entity('-'),
+ via: graph.entity('v'),
+ toward: graph.entity('w')
+ }]);
+ });
+
+ it("permits turns onto a way in both directions", function() {
+ // w
+ // |
+ // u===v
+ // |
+ // x
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ 'x': iD.Node({id: 'x'}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v'], tags: {highway: 'residential'}}),
+ '-': iD.Way({id: '-', nodes: ['w', 'v', 'x'], tags: {highway: 'residential'}})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([{
+ from: graph.entity('='),
+ to: graph.entity('-'),
+ via: graph.entity('v'),
+ toward: graph.entity('w')
+ }, {
+ from: graph.entity('='),
+ to: graph.entity('-'),
+ via: graph.entity('v'),
+ toward: graph.entity('x')
+ }]);
+ });
+
+ it("permits turns from a oneway forward", function() {
+ // u===>v----w
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v'], tags: {highway: 'residential', oneway: 'yes'}}),
+ '-': iD.Way({id: '-', nodes: ['v', 'w'], tags: {highway: 'residential'}})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([{
+ from: graph.entity('='),
+ to: graph.entity('-'),
+ via: graph.entity('v'),
+ toward: graph.entity('w')
+ }]);
+ });
+
+ it("permits turns from a reverse oneway backward", function() {
+ // u<===v----w
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ '=': iD.Way({id: '=', nodes: ['v', 'u'], tags: {highway: 'residential', oneway: '-1'}}),
+ '-': iD.Way({id: '-', nodes: ['v', 'w'], tags: {highway: 'residential'}})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([{
+ from: graph.entity('='),
+ to: graph.entity('-'),
+ via: graph.entity('v'),
+ toward: graph.entity('w')
+ }]);
+ });
+
+ it("omits turns from a oneway backward", function() {
+ // u<===v----w
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ '=': iD.Way({id: '=', nodes: ['v', 'u'], tags: {highway: 'residential', oneway: 'yes'}}),
+ '-': iD.Way({id: '-', nodes: ['v', 'w'], tags: {highway: 'residential'}})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([]);
+ });
+
+ it("omits turns from a reverse oneway forward", function() {
+ // u===>v----w
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v'], tags: {highway: 'residential', oneway: '-1'}}),
+ '-': iD.Way({id: '-', nodes: ['v', 'w'], tags: {highway: 'residential'}})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([]);
+ });
+
+ it("permits turns onto a oneway forward", function() {
+ // u====v--->w
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v'], tags: {highway: 'residential'}}),
+ '-': iD.Way({id: '-', nodes: ['v', 'w'], tags: {highway: 'residential', oneway: 'yes'}})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([{
+ from: graph.entity('='),
+ to: graph.entity('-'),
+ via: graph.entity('v'),
+ toward: graph.entity('w')
+ }]);
+ });
+
+ it("permits turns onto a reverse oneway backward", function() {
+ // u====v<---w
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v'], tags: {highway: 'residential'}}),
+ '-': iD.Way({id: '-', nodes: ['w', 'v'], tags: {highway: 'residential', oneway: '-1'}})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([{
+ from: graph.entity('='),
+ to: graph.entity('-'),
+ via: graph.entity('v'),
+ toward: graph.entity('w')
+ }]);
+ });
+
+ it("omits turns onto a oneway backward", function() {
+ // u====v<---w
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v'], tags: {highway: 'residential'}}),
+ '-': iD.Way({id: '-', nodes: ['w', 'v'], tags: {highway: 'residential', oneway: 'yes'}})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([]);
+ });
+
+ it("omits turns onto a reverse oneway forward", function() {
+ // u====v--->w
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v'], tags: {highway: 'residential'}}),
+ '-': iD.Way({id: '-', nodes: ['v', 'w'], tags: {highway: 'residential', oneway: '-1'}})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([]);
+ });
+
+ it("restricts turns with a restriction relation", function() {
+ // u====v--->w
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v'], tags: {highway: 'residential'}}),
+ '-': iD.Way({id: '-', nodes: ['v', 'w'], tags: {highway: 'residential'}}),
+ 'r': iD.Relation({id: 'r', tags: {type: 'restriction'}, members: [
+ {id: '=', role: 'from', type: 'way'},
+ {id: '-', role: 'to', type: 'way'},
+ {id: 'v', role: 'via', type: 'node'}
+ ]})
+ });
+ expect(iD.geo.turns(graph, '=')).to.eql([{
+ from: graph.entity('='),
+ to: graph.entity('-'),
+ via: graph.entity('v'),
+ toward: graph.entity('w'),
+ restriction: graph.entity('r')
+ }]);
+ });
+
+ // U-turns
+ // Self-intersections
+ // Split point
+});