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