diff --git a/css/map.css b/css/map.css
index 64110111d..66d1e4496 100644
--- a/css/map.css
+++ b/css/map.css
@@ -123,7 +123,8 @@ g.vertex.selected .shadow {
.mode-draw-line g.midpoint,
.mode-add-area g.midpoint,
.mode-add-line g.midpoint,
-.mode-add-point g.midpoint {
+.mode-add-point g.midpoint,
+.behavior-drag-node g.midpoint {
display: none;
}
@@ -716,14 +717,16 @@ text.point {
.mode-draw-line .behavior-hover .way,
.mode-draw-area .behavior-hover .way,
.mode-add-line .behavior-hover .way,
-.mode-add-area .behavior-hover .way {
+.mode-add-area .behavior-hover .way,
+.behavior-drag-node.behavior-hover .way {
cursor:url(../img/cursor-draw-connect-line.png) 9 9, auto;
}
.mode-draw-line .behavior-hover .vertex,
.mode-draw-area .behavior-hover .vertex,
.mode-add-line .behavior-hover .vertex,
-.mode-add-area .behavior-hover .vertex {
+.mode-add-area .behavior-hover .vertex,
+.behavior-drag-node.behavior-hover .vertex {
cursor:url(../img/cursor-draw-connect-vertex.png) 9 9, auto;
}
@@ -734,12 +737,14 @@ text.point {
/* Modes */
.mode-draw-line .vertex.active,
-.mode-draw-area .vertex.active {
+.mode-draw-area .vertex.active,
+.behavior-drag-node .vertex.active {
display: none;
}
.mode-draw-line .way.active,
-.mode-draw-area .way.active {
+.mode-draw-area .way.active,
+.behavior-drag-node .active {
pointer-events: none;
}
diff --git a/index.html b/index.html
index c03ac29ae..b0a1cea53 100644
--- a/index.html
+++ b/index.html
@@ -76,6 +76,7 @@
+
diff --git a/js/id/actions/connect.js b/js/id/actions/connect.js
new file mode 100644
index 000000000..88bf35932
--- /dev/null
+++ b/js/id/actions/connect.js
@@ -0,0 +1,54 @@
+// Connect the ways at the given nodes.
+//
+// The last node will survive. All other nodes will be replaced with
+// the surviving node in parent ways, and then removed.
+//
+// Tags and relation memberships of of non-surviving nodes are merged
+// to the survivor.
+//
+// This is the inverse of `iD.actions.Disconnect`.
+//
+// Reference:
+// https://github.com/openstreetmap/potlatch2/blob/master/net/systemeD/halcyon/connection/actions/MergeNodesAction.as
+// https://github.com/openstreetmap/josm/blob/mirror/src/org/openstreetmap/josm/actions/MergeNodesAction.java
+//
+iD.actions.Connect = function(nodeIds) {
+ var action = function(graph) {
+ var survivor = graph.entity(_.last(nodeIds));
+
+ for (var i = 0; i < nodeIds.length - 1; i++) {
+ var node = graph.entity(nodeIds[i]), index;
+
+ graph.parentWays(node).forEach(function (parent) {
+ while (true) {
+ index = parent.nodes.indexOf(node.id);
+ if (index < 0)
+ break;
+ parent = parent.updateNode(survivor.id, index);
+ }
+ graph = graph.replace(parent);
+ });
+
+ graph.parentRelations(node).forEach(function (parent) {
+ var memberA = parent.memberById(survivor.id),
+ memberB = parent.memberById(node.id);
+ if (!memberA) {
+ graph = graph.replace(parent.addMember({id: survivor.id, role: memberB.role, type: 'node'}));
+ }
+ });
+
+ survivor = survivor.mergeTags(node.tags);
+ graph = iD.actions.DeleteNode(node.id)(graph);
+ }
+
+ graph = graph.replace(survivor);
+
+ return graph;
+ };
+
+ action.enabled = function(graph) {
+ return nodeIds.length > 1;
+ };
+
+ return action;
+};
diff --git a/js/id/actions/disconnect.js b/js/id/actions/disconnect.js
index 8645c436b..415730e77 100644
--- a/js/id/actions/disconnect.js
+++ b/js/id/actions/disconnect.js
@@ -4,6 +4,8 @@
// Normally, this will be undefined and the way will automatically
// be assigned a new ID.
//
+// This is the inverse of `iD.actions.Connect`.
+//
// Reference:
// https://github.com/openstreetmap/potlatch2/blob/master/net/systemeD/halcyon/connection/actions/UnjoinNodeAction.as
// https://github.com/openstreetmap/josm/blob/mirror/src/org/openstreetmap/josm/actions/UnGlueAction.java
diff --git a/js/id/behavior/drag_node.js b/js/id/behavior/drag_node.js
index a0cb06e75..92318756c 100644
--- a/js/id/behavior/drag_node.js
+++ b/js/id/behavior/drag_node.js
@@ -17,39 +17,96 @@ iD.behavior.DragNode = function(context) {
}, 50);
}
- function stopNudge(nudge) {
+ function stopNudge() {
if (nudgeInterval) window.clearInterval(nudgeInterval);
nudgeInterval = null;
}
- function annotation(entity) {
+ function moveAnnotation(entity) {
return t('operations.move.annotation.' + entity.geometry(context.graph()));
}
- return iD.behavior.drag()
- .delegate(".node")
- .origin(function(entity) {
- return context.projection(entity.loc);
- })
- .on('start', function() {
- context.perform(
- iD.actions.Noop());
- })
- .on('move', function(entity) {
- d3.event.sourceEvent.stopPropagation();
+ function connectAnnotation(datum) {
+ return t('operations.connect.annotation.' + datum.geometry(context.graph()));
+ }
- var nudge = edge(d3.event.point, context.map().size());
- if (nudge) startNudge(nudge);
- else stopNudge();
+ function origin(entity) {
+ return context.projection(entity.loc);
+ }
+ function start(entity) {
+ var activeIDs = _.pluck(context.graph().parentWays(entity), 'id');
+ activeIDs.push(entity.id);
+
+ context.surface()
+ .classed('behavior-drag-node', true)
+ .selectAll('.node, .way')
+ .filter(function (d) { return activeIDs.indexOf(d.id) >= 0; })
+ .classed('active', true);
+
+ context.perform(
+ iD.actions.Noop());
+ }
+
+ function datum() {
+ if (d3.event.sourceEvent.altKey) {
+ return {};
+ } else {
+ return d3.event.sourceEvent.target.__data__ || {};
+ }
+ }
+
+ function move(entity) {
+ d3.event.sourceEvent.stopPropagation();
+
+ var nudge = edge(d3.event.point, context.map().size());
+ if (nudge) startNudge(nudge);
+ else stopNudge();
+
+ var loc = context.map().mouseCoordinates();
+
+ var d = datum();
+ if (d.type === 'node') {
+ loc = d.loc;
+ } else if (d.type === 'way') {
+ loc = iD.geo.chooseIndex(d, d3.mouse(context.surface().node()), context).loc;
+ }
+
+ context.replace(iD.actions.MoveNode(entity.id, loc));
+ }
+
+ function end(entity) {
+ context.surface()
+ .classed('behavior-drag-node', false)
+ .selectAll('.active')
+ .classed('active', false);
+
+ stopNudge();
+
+ var d = datum();
+ if (d.type === 'way') {
+ var choice = iD.geo.chooseIndex(d, d3.mouse(context.surface().node()), context);
context.replace(
- iD.actions.MoveNode(entity.id, context.projection.invert(d3.event.point)),
- annotation(entity));
- })
- .on('end', function(entity) {
- stopNudge();
+ iD.actions.MoveNode(entity.id, choice.loc),
+ iD.actions.AddVertex(d.id, entity.id, choice.index),
+ connectAnnotation(d));
+
+ } else if (d.type === 'node' && d.id !== entity.id) {
+ context.replace(
+ iD.actions.Connect([entity.id, d.id]),
+ connectAnnotation(d));
+
+ } else {
context.replace(
iD.actions.Noop(),
- annotation(entity));
- });
+ moveAnnotation(entity));
+ }
+ }
+
+ return iD.behavior.drag()
+ .delegate("g.node")
+ .origin(origin)
+ .on('start', start)
+ .on('move', move)
+ .on('end', end);
};
diff --git a/js/id/svg/vertices.js b/js/id/svg/vertices.js
index f7090d892..e9769a374 100644
--- a/js/id/svg/vertices.js
+++ b/js/id/svg/vertices.js
@@ -23,15 +23,15 @@ iD.svg.Vertices = function(projection) {
group.append('circle')
.attr('r', 10)
- .attr('class', 'shadow');
+ .attr('class', 'node vertex shadow');
group.append('circle')
.attr('r', 4)
- .attr('class', 'stroke');
+ .attr('class', 'node vertex stroke');
group.append('circle')
.attr('r', 3)
- .attr('class', 'fill');
+ .attr('class', 'node vertex fill');
groups.attr('transform', iD.svg.PointTransform(projection))
.call(iD.svg.TagClasses())
diff --git a/locale/en.js b/locale/en.js
index 8f00590c6..516a43475 100644
--- a/locale/en.js
+++ b/locale/en.js
@@ -86,6 +86,14 @@ locale.en = {
multiple: "Deleted {n} objects."
}
},
+ connect: {
+ annotation: {
+ point: "Connected a way to a point.",
+ vertex: "Connected a way to another.",
+ line: "Connected a way to a line.",
+ area: "Connected a way to an area."
+ }
+ },
disconnect: {
title: "Disconnect",
description: "Disconnect these ways from each other.",
diff --git a/test/index.html b/test/index.html
index e6df6a963..5f7c19b5a 100644
--- a/test/index.html
+++ b/test/index.html
@@ -72,6 +72,7 @@
+
@@ -151,6 +152,7 @@
+
diff --git a/test/index_packaged.html b/test/index_packaged.html
index ef91e2823..d21d48ff2 100644
--- a/test/index_packaged.html
+++ b/test/index_packaged.html
@@ -35,6 +35,7 @@
+
diff --git a/test/spec/actions/connect.js b/test/spec/actions/connect.js
new file mode 100644
index 000000000..3838d83c2
--- /dev/null
+++ b/test/spec/actions/connect.js
@@ -0,0 +1,110 @@
+describe("iD.actions.Connect", function() {
+ describe("#enabled", function () {
+ it("returns true for two or more nodes", function () {
+ expect(iD.actions.Connect(['a', 'b']).enabled()).to.be.true;
+ });
+
+ it("returns false for less than two nodes", function () {
+ expect(iD.actions.Connect(['a']).enabled()).to.be.false;
+ });
+ });
+
+ it("removes all but the final node", function() {
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a'}),
+ 'b': iD.Node({id: 'b'}),
+ 'c': iD.Node({id: 'c'})
+ });
+
+ graph = iD.actions.Connect(['a', 'b', 'c'])(graph);
+
+ expect(graph.entity('a')).to.be.undefined;
+ expect(graph.entity('b')).to.be.undefined;
+ expect(graph.entity('c')).not.to.be.undefined;
+ });
+
+ it("replaces non-surviving nodes in parent ways", function() {
+ // a --- b --- c
+ //
+ // e
+ // |
+ // d
+ //
+ // Connect [e, b].
+ //
+ // Expected result:
+ //
+ // a --- b --- c
+ // |
+ // d
+ //
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a'}),
+ 'b': iD.Node({id: 'b'}),
+ 'c': iD.Node({id: 'c'}),
+ 'd': iD.Node({id: 'd'}),
+ 'e': iD.Node({id: 'e'}),
+ '-': iD.Way({id: '-', nodes: ['a', 'b', 'c']}),
+ '|': iD.Way({id: '|', nodes: ['d', 'e']})
+ });
+
+ graph = iD.actions.Connect(['e', 'b'])(graph);
+
+ expect(graph.entity('-').nodes).to.eql(['a', 'b', 'c']);
+ expect(graph.entity('|').nodes).to.eql(['d', 'b']);
+ });
+
+ it("handles circular ways", function() {
+ // c -- a d === e
+ // | /
+ // | /
+ // | /
+ // b
+ //
+ // Connect [a, d].
+ //
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a'}),
+ 'b': iD.Node({id: 'b'}),
+ 'c': iD.Node({id: 'c'}),
+ 'd': iD.Node({id: 'd'}),
+ 'e': iD.Node({id: 'e'}),
+ '-': iD.Way({id: '-', nodes: ['a', 'b', 'c', 'a']}),
+ '=': iD.Way({id: '=', nodes: ['d', 'e']})
+ });
+
+ graph = iD.actions.Connect(['a', 'd'])(graph);
+
+ expect(graph.entity('-').nodes).to.eql(['d', 'b', 'c', 'd']);
+ });
+
+ it("merges tags to the surviving node", function() {
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a', tags: {a: 'a'}}),
+ 'b': iD.Node({id: 'b', tags: {b: 'b'}}),
+ 'c': iD.Node({id: 'c', tags: {c: 'c'}})
+ });
+
+ graph = iD.actions.Connect(['a', 'b', 'c'])(graph);
+
+ expect(graph.entity('c').tags).to.eql({a: 'a', b: 'b', c: 'c'});
+ });
+
+ it("merges memberships to the surviving node", function() {
+ var graph = iD.Graph({
+ 'a': iD.Node({id: 'a'}),
+ 'b': iD.Node({id: 'b'}),
+ 'c': iD.Node({id: 'c'}),
+ 'd': iD.Node({id: 'c'}),
+ '-': iD.Way({id: '-', nodes: ['a', 'b']}),
+ '=': iD.Way({id: '=', nodes: ['c', 'd']}),
+ 'r1': iD.Relation({id: 'r1', members: [{id: 'b', role: 'r1', type: 'node'}]}),
+ 'r2': iD.Relation({id: 'r2', members: [{id: 'b', role: 'r1', type: 'node'}, {id: 'c', role: 'r2', type: 'node'}]})
+ });
+
+ graph = iD.actions.Connect(['b', 'c'])(graph);
+
+ expect(graph.entity('r1').members).to.eql([{id: 'c', role: 'r1', type: 'node'}]);
+ expect(graph.entity('r2').members).to.eql([{id: 'c', role: 'r2', type: 'node'}]);
+ });
+});