Connect via drag and drop

Fixes #598.
This commit is contained in:
John Firebaugh
2013-02-04 16:10:56 -08:00
parent 73098d259e
commit c9fb1444db
10 changed files with 271 additions and 31 deletions

View File

@@ -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;
}

View File

@@ -76,6 +76,7 @@
<script src='js/id/actions/add_entity.js'></script>
<script src='js/id/actions/add_vertex.js'></script>
<script src='js/id/actions/change_tags.js'></script>
<script src='js/id/actions/connect.js'></script>
<script src='js/id/actions/delete_multiple.js'></script>
<script src='js/id/actions/delete_node.js'></script>
<script src="js/id/actions/delete_relation.js"></script>

54
js/id/actions/connect.js Normal file
View File

@@ -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;
};

View File

@@ -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

View File

@@ -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);
};

View File

@@ -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())

View File

@@ -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.",

View File

@@ -72,6 +72,7 @@
<script src='../js/id/actions/add_entity.js'></script>
<script src='../js/id/actions/add_vertex.js'></script>
<script src='../js/id/actions/change_tags.js'></script>
<script src='../js/id/actions/connect.js'></script>
<script src='../js/id/actions/circularize.js'></script>
<script src='../js/id/actions/orthogonalize.js'></script>
<script src="../js/id/actions/delete_multiple.js"></script>
@@ -151,6 +152,7 @@
<script src="spec/actions/add_midpoint.js"></script>
<script src="spec/actions/add_entity.js"></script>
<script src="spec/actions/change_tags.js"></script>
<script src='spec/actions/connect.js'></script>
<script src="spec/actions/delete_multiple.js"></script>
<script src="spec/actions/delete_node.js"></script>
<script src="spec/actions/delete_relation.js"></script>

View File

@@ -35,6 +35,7 @@
<script src="spec/actions/add_midpoint.js"></script>
<script src="spec/actions/add_entity.js"></script>
<script src="spec/actions/change_tags.js"></script>
<script src='spec/actions/connect.js'></script>
<script src="spec/actions/delete_multiple.js"></script>
<script src="spec/actions/delete_node.js"></script>
<script src="spec/actions/delete_relation.js"></script>

View File

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