diff --git a/index.html b/index.html
index ed2335394..9cdea5a66 100644
--- a/index.html
+++ b/index.html
@@ -59,6 +59,7 @@
+
diff --git a/js/id/svg/restrictions.js b/js/id/svg/restrictions.js
new file mode 100644
index 000000000..9d9e41b53
--- /dev/null
+++ b/js/id/svg/restrictions.js
@@ -0,0 +1,75 @@
+iD.svg.Restrictions = function(context) {
+ var projection = context.projection;
+
+ function drawRestrictions(surface) {
+ var turns = drawRestrictions.turns(context.graph(), context.selectedIDs());
+
+ var groups = surface.select('.layer-hit').selectAll('g.restriction')
+ .data(turns, iD.Entity.key);
+
+ var enter = groups.enter().append('g')
+ .attr('class', 'restriction');
+
+ enter.append('circle')
+ .attr('class', 'restriction')
+ .attr('r', 4);
+
+ groups
+ .attr('transform', function(restriction) {
+ var via = context.entity(restriction.memberByRole('via').id);
+ return iD.svg.PointTransform(projection)(via);
+ });
+
+ groups.exit()
+ .remove();
+
+ return this;
+ }
+
+ drawRestrictions.turns = function (graph, selectedIDs) {
+ if (selectedIDs.length != 1)
+ return [];
+
+ var from = graph.entity(selectedIDs[0]);
+ if (from.type !== 'way')
+ return [];
+
+ return graph.parentRelations(from).filter(function(relation) {
+ var f = relation.memberById(from.id),
+ t = relation.memberByRole('to'),
+ v = relation.memberByRole('via');
+
+ return relation.tags.type === 'restriction' && f.role === 'from' &&
+ t && t.type === 'way' && graph.hasEntity(t.id) &&
+ v && v.type === 'node' && graph.hasEntity(v.id) &&
+ !graph.entity(t.id).isDegenerate() &&
+ !graph.entity(f.id).isDegenerate() &&
+ graph.entity(t.id).affix(v.id) &&
+ graph.entity(f.id).affix(v.id);
+ });
+ };
+
+ drawRestrictions.datum = function(graph, from, restriction, projection) {
+ var to = graph.entity(restriction.memberByRole('to').id),
+ a = graph.entity(restriction.memberByRole('via').id),
+ b;
+
+ if (to.first() === a.id) {
+ b = graph.entity(to.nodes[1]);
+ } else {
+ b = graph.entity(to.nodes[to.nodes.length - 2]);
+ }
+
+ a = projection(a.loc);
+ b = projection(b.loc);
+
+ return {
+ from: from,
+ to: to,
+ restriction: restriction,
+ angle: Math.atan2(b[1] - a[1], b[0] - a[0])
+ }
+ };
+
+ return drawRestrictions;
+};
diff --git a/test/index.html b/test/index.html
index ff9c9d143..185528971 100644
--- a/test/index.html
+++ b/test/index.html
@@ -60,6 +60,7 @@
+
@@ -242,6 +243,7 @@
+
diff --git a/test/index_packaged.html b/test/index_packaged.html
index 793cad825..f187c3925 100644
--- a/test/index_packaged.html
+++ b/test/index_packaged.html
@@ -72,6 +72,7 @@
+
diff --git a/test/spec/svg/restrictions.js b/test/spec/svg/restrictions.js
new file mode 100644
index 000000000..dc6169eb5
--- /dev/null
+++ b/test/spec/svg/restrictions.js
@@ -0,0 +1,151 @@
+describe("iD.svg.Restrictions", function() {
+ var restrictions = iD.svg.Restrictions({});
+
+ describe("#turns", function() {
+ it("returns an empty array with no selection", function() {
+ var graph = iD.Graph();
+ expect(restrictions.turns(graph, [])).to.eql([]);
+ });
+
+ it("returns an empty array with a multiselection", function() {
+ var graph = iD.Graph();
+ expect(restrictions.turns(graph, ['a', 'b'])).to.eql([]);
+ });
+
+ var valid = iD.Graph({
+ 'u': iD.Node({id: 'u'}),
+ 'v': iD.Node({id: 'v'}),
+ 'w': iD.Node({id: 'w'}),
+ 'f': iD.Way({id: 'f', nodes: ['u', 'v']}),
+ 't': iD.Way({id: 't', nodes: ['v', 'w']}),
+ 'r': iD.Relation({id: 'r', tags: {type: 'restriction'}, members: [
+ { role: 'via', id: 'v', type: 'node' },
+ { role: 'from', id: 'f', type: 'way' },
+ { role: 'to', id: 't', type: 'way' }
+ ]})
+ });
+
+ it("returns a valid restriction when the selected way has role 'from'", function() {
+ expect(restrictions.turns(valid, ['f'])).to.eql([valid.entity('r')]);
+ });
+
+ it("returns an empty array when the selected way has role 'to'", function() {
+ expect(restrictions.turns(valid, ['t'])).to.eql([]);
+ });
+
+ it("ignores restrictions missing a 'to' role", function() {
+ var graph = valid.replace(valid.entity('r').removeMembersWithID('t'));
+ expect(restrictions.turns(graph, ['f'])).to.eql([]);
+ });
+
+ it("ignores restrictions with an incomplete 'to' role", function() {
+ var graph = valid.remove(valid.entity('t'));
+ expect(restrictions.turns(graph, ['f'])).to.eql([]);
+ });
+
+ it("ignores restrictions missing a 'via' role", function() {
+ var graph = valid.replace(valid.entity('r').removeMembersWithID('v'));
+ expect(restrictions.turns(graph, ['f'])).to.eql([]);
+ });
+
+ it("ignores restrictions with an incomplete 'via' role", function() {
+ var graph = valid.remove(valid.entity('v'));
+ expect(restrictions.turns(graph, ['f'])).to.eql([]);
+ });
+
+ it("ignores restrictions whose 'from' role is not a way", function() {
+ var graph = valid.replace(iD.Node({id: 'f2'}))
+ .replace(valid.entity('r').replaceMember({id: 'f'}, {id: 'f2', type: 'node'}));
+ expect(restrictions.turns(graph, ['f2'])).to.eql([]);
+ });
+
+ it("ignores restrictions whose 'to' role is not a way", function() {
+ var graph = valid.replace(iD.Node({id: 't2'}))
+ .replace(valid.entity('r').replaceMember({id: 't'}, {id: 't2', type: 'node'}));
+ expect(restrictions.turns(graph, ['f'])).to.eql([]);
+ });
+
+ it("ignores restrictions whose 'via' role is not a node", function() {
+ var graph = valid.replace(iD.Way({id: 'v2'}))
+ .replace(valid.entity('r').replaceMember({id: 'v'}, {id: 'v2', type: 'way'}));
+ expect(restrictions.turns(graph, ['f'])).to.eql([]);
+ });
+
+ it("ignores restrictions whose 'from' role does not start or end with the via node", function() {
+ var graph = valid.replace(valid.entity('f').update({nodes: ['o']}));
+ expect(restrictions.turns(graph, ['f'])).to.eql([]);
+ });
+
+ it("ignores restrictions whose 'to' role does not start or end with the via node", function() {
+ var graph = valid.replace(valid.entity('t').update({nodes: ['o']}));
+ expect(restrictions.turns(graph, ['f'])).to.eql([]);
+ });
+
+ it("ignores restrictions whose 'from' role has less than two nodes", function() {
+ var graph = valid.replace(valid.entity('f').update({nodes: ['v']}));
+ expect(restrictions.turns(graph, ['f'])).to.eql([]);
+ });
+
+ it("ignores restrictions whose 'to' role has less than two nodes", function() {
+ var graph = valid.replace(valid.entity('t').update({nodes: ['v']}));
+ expect(restrictions.turns(graph, ['f'])).to.eql([]);
+ });
+
+ it("ignores restriction subtypes", function() {
+ var graph = valid.replace(valid.entity('r').update({tags: {type: 'restriction:hgv'}}));
+ expect(restrictions.turns(graph, ['f'])).to.eql([]);
+ });
+ });
+
+ describe("#datum", function() {
+ function projection(x) { return x; }
+
+ it("calculates the angle of a forward 'to' role", function() {
+ // w---x--->y
+ // |
+ // u====>v
+ // From = to - via v
+
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u', loc: [0, 0]}),
+ 'v': iD.Node({id: 'v', loc: [1, 0]}),
+ 'w': iD.Node({id: 'w', loc: [1, 1]}),
+ 'x': iD.Node({id: 'w', loc: [2, 1]}),
+ 'y': iD.Node({id: 'w', loc: [3, 1]}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v']}),
+ '-': iD.Way({id: '-', nodes: ['v', 'w', 'x', 'y']}),
+ 'r': iD.Relation({id: 'r', tags: {type: 'restriction'}, members: [
+ { role: 'via', id: 'v', type: 'node' },
+ { role: 'from', id: '=', type: 'way' },
+ { role: 'to', id: '-', type: 'way' }
+ ]})
+ });
+
+ expect(restrictions.datum(graph, graph.entity('='), graph.entity('r'), projection).angle).to.eql(Math.PI / 2);
+ });
+
+ it("calculates the angle of a reverse 'to' role", function() {
+ // w<---x---y
+ // |
+ // u====>v
+ // From = to - via v
+
+ var graph = iD.Graph({
+ 'u': iD.Node({id: 'u', loc: [0, 0]}),
+ 'v': iD.Node({id: 'v', loc: [1, 0]}),
+ 'w': iD.Node({id: 'w', loc: [1, 1]}),
+ 'x': iD.Node({id: 'w', loc: [2, 1]}),
+ 'y': iD.Node({id: 'w', loc: [3, 1]}),
+ '=': iD.Way({id: '=', nodes: ['u', 'v']}),
+ '-': iD.Way({id: '-', nodes: ['y', 'x', 'w', 'v']}),
+ 'r': iD.Relation({id: 'r', tags: {type: 'restriction'}, members: [
+ { role: 'via', id: 'v', type: 'node' },
+ { role: 'from', id: '=', type: 'way' },
+ { role: 'to', id: '-', type: 'way' }
+ ]})
+ });
+
+ expect(restrictions.datum(graph, graph.entity('='), graph.entity('r'), projection).angle).to.eql(Math.PI / 2);
+ });
+ });
+});