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'}]); + }); +});