diff --git a/index.html b/index.html
index 69d24896b..af3c68085 100644
--- a/index.html
+++ b/index.html
@@ -134,6 +134,7 @@
+
diff --git a/js/id/actions/merge_polygon.js b/js/id/actions/merge_polygon.js
new file mode 100644
index 000000000..85579e79b
--- /dev/null
+++ b/js/id/actions/merge_polygon.js
@@ -0,0 +1,109 @@
+iD.actions.MergePolygon = function(ids, newRelationId) {
+
+ function groupEntities(graph) {
+ var entities = ids.map(graph.getEntity);
+ return _.extend({
+ closedWay: [],
+ multipolygon: [],
+ other: []
+ }, _.groupBy(entities, function(entity) {
+ if (entity.type === 'way' && entity.isClosed()) {
+ return 'closedWay';
+ } else if (entity.type === 'relation' && entity.isMultipolygon()) {
+ return 'multipolygon';
+ } else {
+ return 'other';
+ }
+ }));
+ }
+
+ var action = function(graph) {
+ var entities = groupEntities(graph);
+
+ // An array of objects representing all the polygons that are part of the multipolygon.
+ //
+ // Each object has two properties:
+ // ids - an array of ids of entities that are part of that polygon
+ // locs - an array of the locations forming the polygon
+ var polygons = entities.multipolygon.reduce(function(polygons, m) {
+ return polygons.concat(m.joinMemberWays(null, graph));
+ }, []).concat(entities.closedWay.map(function(d) {
+ return {
+ ids: [d.id],
+ locs: graph.childNodes(d).map(function(n) { return n.loc; })
+ };
+ }));
+
+ // contained is an array of arrays of boolean values,
+ // where contained[j][k] is true iff the jth way is
+ // contained by the kth way.
+ var contained = polygons.map(function(w, i) {
+ return polygons.map(function(d, n) {
+ if (i === n) return null;
+ return iD.geo.polygonContainsPolygon(d.locs, w.locs);
+ });
+ });
+
+ // Sort all polygons as either outer or inner ways
+ var members = [],
+ outer = true;
+
+ while (polygons.length) {
+ extractUncontained(polygons);
+ polygons = polygons.filter(isContained);
+ contained = contained.filter(isContained).map(filterContained);
+ }
+
+ function isContained(d, i) {
+ return _.any(contained[i]);
+ }
+
+ function filterContained(d, i) {
+ return d.filter(isContained);
+ }
+
+ function extractUncontained(polygons) {
+ polygons.forEach(function(d, i) {
+ if (!isContained(d, i)) {
+ d.ids.forEach(function(id) {
+ members.push({
+ type: 'way',
+ id: id,
+ role: outer ? 'outer' : 'inner'
+ });
+ });
+ }
+ });
+ outer = !outer;
+ }
+
+ // Move all tags to one relation
+ var relation = entities.multipolygon[0] ||
+ iD.Relation({ id: newRelationId, tags: { type: 'multipolygon' }});
+
+ entities.multipolygon.slice(1).forEach(function(m) {
+ relation = relation.mergeTags(m.tags);
+ graph = graph.remove(m);
+ });
+
+ members.forEach(function(m) {
+ var entity = graph.entity(m.id);
+ relation = relation.mergeTags(entity.tags);
+ graph = graph.replace(entity.update({ tags: {} }));
+ });
+
+ return graph.replace(relation.update({
+ members: members,
+ tags: _.omit(relation.tags, 'area')
+ }));
+ };
+
+ action.disabled = function(graph) {
+ var entities = groupEntities(graph);
+ if (entities.other.length > 0 ||
+ entities.closedWay.length + entities.multipolygon.length < 2)
+ return 'not_eligible';
+ };
+
+ return action;
+};
diff --git a/js/id/core/relation.js b/js/id/core/relation.js
index e65862704..cc84e25c0 100644
--- a/js/id/core/relation.js
+++ b/js/id/core/relation.js
@@ -176,52 +176,6 @@ _.extend(iD.Relation.prototype, {
.filter(function(m) { return m.type === 'way' && resolver.hasEntity(m.id); })
.map(function(m) { return { role: m.role || 'outer', id: m.id, nodes: resolver.childNodes(resolver.entity(m.id)) }; });
- function join(ways) {
- var joined = [], current, first, last, i, how, what;
-
- while (ways.length) {
- current = ways.pop().nodes.slice();
- joined.push(current);
-
- while (ways.length && _.first(current) !== _.last(current)) {
- first = _.first(current);
- last = _.last(current);
-
- for (i = 0; i < ways.length; i++) {
- what = ways[i].nodes;
-
- if (last === _.first(what)) {
- how = current.push;
- what = what.slice(1);
- break;
- } else if (last === _.last(what)) {
- how = current.push;
- what = what.slice(0, -1).reverse();
- break;
- } else if (first == _.last(what)) {
- how = current.unshift;
- what = what.slice(0, -1);
- break;
- } else if (first == _.first(what)) {
- how = current.unshift;
- what = what.slice(1).reverse();
- break;
- } else {
- what = how = null;
- }
- }
-
- if (!what)
- break; // Invalid geometry (unclosed ring)
-
- ways.splice(i, 1);
- how.apply(current, what);
- }
- }
-
- return joined.map(function(nodes) { return _.pluck(nodes, 'loc'); });
- }
-
function findOuter(inner) {
var o, outer;
@@ -238,8 +192,8 @@ _.extend(iD.Relation.prototype, {
}
}
- var outers = join(members.filter(function(m) { return m.role === 'outer'; })),
- inners = join(members.filter(function(m) { return m.role === 'inner'; })),
+ var outers = _.pluck(this.joinMemberWays(members.filter(function(m) { return m.role === 'outer'; })), 'locs'),
+ inners = _.pluck(this.joinMemberWays(members.filter(function(m) { return m.role === 'inner'; })), 'locs'),
result = outers.map(function(o) { return [o]; });
for (var i = 0; i < inners.length; i++) {
@@ -251,5 +205,68 @@ _.extend(iD.Relation.prototype, {
}
return result;
+ },
+
+ joinMemberWays: function(ways, resolver) {
+ var joined = [], way, current, first, last, i, how, what;
+
+ ways = ways || this.members.filter(function(m) {
+ return m.type === 'way';
+ }).map(function(m) {
+ return {
+ id: m.id,
+ nodes: resolver.childNodes(resolver.entity(m.id))
+ };
+ });
+
+ while (ways.length) {
+ way = ways.pop();
+ current = way.nodes.slice();
+ current.ids = [way.id];
+ joined.push(current);
+
+ while (ways.length && _.first(current) !== _.last(current)) {
+ first = _.first(current);
+ last = _.last(current);
+
+ for (i = 0; i < ways.length; i++) {
+ what = ways[i].nodes;
+
+ if (last === _.first(what)) {
+ how = current.push;
+ what = what.slice(1);
+ break;
+ } else if (last === _.last(what)) {
+ how = current.push;
+ what = what.slice(0, -1).reverse();
+ break;
+ } else if (first == _.last(what)) {
+ how = current.unshift;
+ what = what.slice(0, -1);
+ break;
+ } else if (first == _.first(what)) {
+ how = current.unshift;
+ what = what.slice(1).reverse();
+ break;
+ } else {
+ what = how = null;
+ }
+ }
+
+ if (!what)
+ break; // Invalid geometry (unclosed ring)
+
+ current.ids.push(ways[i].id);
+ ways.splice(i, 1);
+ how.apply(current, what);
+ }
+ }
+ return joined.map(function(nodes) {
+ return {
+ ids: nodes.ids,
+ locs: _.pluck(nodes, 'loc')
+ };
+ });
}
+
});
diff --git a/js/id/operations/merge.js b/js/id/operations/merge.js
index 8cea1dcad..e7b2d5e43 100644
--- a/js/id/operations/merge.js
+++ b/js/id/operations/merge.js
@@ -1,6 +1,7 @@
iD.operations.Merge = function(selection, context) {
var join = iD.actions.Join(selection),
- merge = iD.actions.Merge(selection);
+ merge = iD.actions.Merge(selection),
+ mergePolygon = iD.actions.MergePolygon(selection);
var operation = function() {
var annotation = t('operations.merge.annotation', {n: selection.length}),
@@ -8,8 +9,10 @@ iD.operations.Merge = function(selection, context) {
if (!join.disabled(context.graph())) {
action = join;
- } else {
+ } else if (!merge.disabled(context.graph())) {
action = merge;
+ } else {
+ action = mergePolygon;
}
var difference = context.perform(action, annotation);
@@ -22,7 +25,8 @@ iD.operations.Merge = function(selection, context) {
operation.disabled = function() {
return join.disabled(context.graph()) &&
- merge.disabled(context.graph());
+ merge.disabled(context.graph()) &&
+ mergePolygon.disabled(context.graph());
};
operation.tooltip = function() {
diff --git a/test/index.html b/test/index.html
index bb78adee4..a21516e91 100644
--- a/test/index.html
+++ b/test/index.html
@@ -116,6 +116,7 @@
+
@@ -201,6 +202,7 @@
+
diff --git a/test/spec/actions/merge_polygon.js b/test/spec/actions/merge_polygon.js
new file mode 100644
index 000000000..a972cc8f8
--- /dev/null
+++ b/test/spec/actions/merge_polygon.js
@@ -0,0 +1,137 @@
+describe("iD.actions.MergePolygon", function () {
+
+ function node(id, x, y) {
+ e[id] = iD.Node({ id: id, loc: [x, y] });
+ }
+
+ function way(id, nodes) {
+ e[id] = iD.Way({ id: id, nodes: nodes.map(function(n) { return 'n' + n; }) });
+ }
+
+ var e = {};
+
+ node('n0', 0, 0);
+ node('n1', 5, 0);
+ node('n2', 5, 5);
+ node('n3', 0, 5);
+
+ node('n4', 1, 1);
+ node('n5', 4, 1);
+ node('n6', 4, 4);
+ node('n7', 1, 4);
+
+ node('n8', 2, 2);
+ node('n9', 3, 2);
+ node('n10', 3, 3);
+ node('n11', 2, 3);
+
+ node('n13', 8, 8);
+ node('n14', 8, 9);
+ node('n15', 9, 9);
+
+ way('w0', [0, 1, 2, 3, 0]);
+ way('w1', [4, 5, 6, 7, 4]);
+ way('w2', [8, 9, 10, 11, 8]);
+
+ way('w3', [4, 5, 6]);
+ way('w4', [6, 7, 4]);
+
+ way('w5', [13, 14, 15, 13]);
+
+ var graph;
+
+ beforeEach(function() {
+ graph = iD.Graph(e);
+ });
+
+ function find(relation, id) {
+ return _.find(relation.members, function(d) {
+ return d.id === id;
+ });
+ }
+
+ it("creates a multipolygon from two closed ways", function() {
+ graph = iD.actions.MergePolygon(['w0', 'w1'], 'r')(graph);
+ var r = graph.entity('r');
+ expect(!!r).to.equal(true);
+ expect(r.geometry()).to.equal('area');
+ expect(r.isMultipolygon()).to.equal(true);
+ expect(r.members.length).to.equal(2);
+ expect(find(r, 'w0').role).to.equal('outer');
+ expect(find(r, 'w0').type).to.equal('way');
+ expect(find(r, 'w1').role).to.equal('inner');
+ expect(find(r, 'w1').type).to.equal('way');
+ });
+
+ it("creates a multipolygon from a closed way and a multipolygon relation", function() {
+ graph = iD.actions.MergePolygon(['w0', 'w1'], 'r')(graph);
+ graph = iD.actions.MergePolygon(['r', 'w2'])(graph);
+ var r = graph.entity('r');
+ expect(r.members.length).to.equal(3);
+ });
+
+ it("creates a multipolygon from two multipolygon relations", function() {
+ graph = iD.actions.MergePolygon(['w0', 'w1'], 'r')(graph);
+ graph = iD.actions.MergePolygon(['w2', 'w5'], 'r2')(graph);
+ graph = iD.actions.MergePolygon(['r', 'r2'])(graph);
+
+ // Delete other relation
+ expect(graph.entity('r2')).to.equal(undefined);
+
+ var r = graph.entity('r');
+ expect(find(r, 'w0').role).to.equal('outer');
+ expect(find(r, 'w1').role).to.equal('inner');
+ expect(find(r, 'w2').role).to.equal('outer');
+ expect(find(r, 'w5').role).to.equal('outer');
+ });
+
+ it("moves all tags to the relation", function() {
+ graph = graph.replace(e.w0.update({ tags: { 'building': 'yes' }}));
+ graph = graph.replace(e.w1.update({ tags: { 'natural': 'water' }}));
+ graph = iD.actions.MergePolygon(['w0', 'w1'], 'r')(graph);
+ var r = graph.entity('r');
+ expect(graph.entity('w0').tags.building).to.equal(undefined);
+ expect(graph.entity('w1').tags.natural).to.equal(undefined);
+ expect(r.tags.natural).to.equal('water');
+ expect(r.tags.building).to.equal('yes');
+ });
+
+ it("doesn't copy area tags from ways", function() {
+ graph = graph.replace(e.w0.update({ tags: { 'area': 'yes' }}));
+ graph = iD.actions.MergePolygon(['w0', 'w1'], 'r')(graph);
+ var r = graph.entity('r');
+ expect(r.tags.area).to.equal(undefined);
+ });
+
+ it("creates a multipolygon with two disjunct outer rings", function() {
+ graph = iD.actions.MergePolygon(['w0', 'w5'], 'r')(graph);
+ var r = graph.entity('r');
+ expect(find(r, 'w0').role).to.equal('outer');
+ expect(find(r, 'w5').role).to.equal('outer');
+ });
+
+ it("creates a multipolygon with an island in a hole", function() {
+ graph = iD.actions.MergePolygon(['w0', 'w1'], 'r')(graph);
+ graph = iD.actions.MergePolygon(['r', 'w2'])(graph);
+ var r = graph.entity('r');
+ expect(find(r, 'w0').role).to.equal('outer');
+ expect(find(r, 'w1').role).to.equal('inner');
+ expect(find(r, 'w2').role).to.equal('outer');
+ });
+
+ it("extends a multipolygon with multi-way rings", function() {
+ console.log('start');
+ var r = iD.Relation({ id: 'r', tags: { type: 'multipolygon' }, members: [
+ { type: 'way', role: 'outer', id: 'w0' },
+ { type: 'way', role: 'inner', id: 'w3' },
+ { type: 'way', role: 'inner', id: 'w4' }
+ ]});
+ graph = graph.replace(r);
+ graph = iD.actions.MergePolygon(['r', 'w2'])(graph);
+ r = graph.entity('r');
+ expect(find(r, 'w0').role).to.equal('outer');
+ expect(find(r, 'w2').role).to.equal('outer');
+ expect(find(r, 'w3').role).to.equal('inner');
+ expect(find(r, 'w4').role).to.equal('inner');
+ });
+});