From be30344cfd8f593a6f00fe13c4d3a8fc5f1d436e Mon Sep 17 00:00:00 2001 From: Paul Mach Date: Wed, 25 Sep 2013 13:42:42 -0700 Subject: [PATCH] Improve Circularize Action --- js/id/actions/circularize.js | 124 +++++++++++++++++++++---------- test/spec/actions/circularize.js | 74 ++++++++++++------ 2 files changed, 133 insertions(+), 65 deletions(-) diff --git a/js/id/actions/circularize.js b/js/id/actions/circularize.js index 86a6bc489..d1c973723 100644 --- a/js/id/actions/circularize.js +++ b/js/id/actions/circularize.js @@ -1,60 +1,102 @@ -iD.actions.Circularize = function(wayId, projection, count) { - count = count || 12; - - function closestIndex(nodes, loc) { - var idx, min = Infinity, dist; - for (var i = 0; i < nodes.length; i++) { - dist = iD.geo.dist(nodes[i].loc, loc); - if (dist < min) { - min = dist; - idx = i; - } - } - return idx; - } +iD.actions.Circularize = function(wayId, projection, maxAngle) { + maxAngle = (maxAngle || 20) * Math.PI / 180; var action = function(graph) { var way = graph.entity(wayId), nodes = _.uniq(graph.childNodes(way)), + keyNodes = nodes.filter(function(n) { return graph.parentWays(n).length != 1; }), points = nodes.map(function(n) { return projection(n.loc); }), + keyPoints = keyNodes.map(function(n) { return projection(n.loc); }), centroid = d3.geom.polygon(points).centroid(), - radius = d3.median(points, function(p) { - return iD.geo.dist(centroid, p); - }), - ids = [], - sign = d3.geom.polygon(points).area() > 0 ? -1 : 1; + radius = d3.median(points, function(p) { return iD.geo.dist(centroid, p); }), + sign = d3.geom.polygon(points).area() > 0 ? 1 : -1, + ids; - for (var i = 0; i < count; i++) { - var node, - loc = projection.invert([ - centroid[0] + Math.cos(sign * (i / 12) * Math.PI * 2) * radius, - centroid[1] + Math.sin(sign * (i / 12) * Math.PI * 2) * radius]); + // we need atleast two key nodes for the algorithm to work + if (!keyNodes.length) { + keyNodes = [nodes[0]]; + keyPoints = [points[0]]; + } - if (nodes.length) { - var idx = closestIndex(nodes, loc); - node = nodes[idx]; - nodes.splice(idx, 1); - } else { - node = iD.Node(); + if (keyNodes.length == 1) { + var index = nodes.indexOf(keyNodes[0]), + oppositeIndex = Math.floor((index + nodes.length / 2) % nodes.length); + + keyNodes.push(nodes[oppositeIndex]); + keyPoints.push(points[oppositeIndex]); + } + + // key points and nodes are those connected to the ways, + // they are projected onto the circle, inbetween nodes are moved + // to constant internals between key nodes, extra inbetween nodes are + // added if necessary. + for (var i = 0; i < keyPoints.length; i++) { + var nextKeyNodeIndex = (i + 1) % keyNodes.length, + startNodeIndex = nodes.indexOf(keyNodes[i]), + endNodeIndex = nodes.indexOf(keyNodes[nextKeyNodeIndex]), + numberNewPoints = -1, + indexRange = endNodeIndex - startNodeIndex, + distance, totalAngle, eachAngle, startAngle, endAngle, + angle, loc, node, j; + + if (indexRange < 0) { + indexRange += nodes.length; } - ids.push(node.id); - graph = graph.replace(node.move(loc)); + // position this key node + distance = iD.geo.dist(centroid, keyPoints[i]); + keyPoints[i] = [ + centroid[0] + (keyPoints[i][0] - centroid[0]) / distance * radius, + centroid[1] + (keyPoints[i][1] - centroid[1]) / distance * radius]; + graph = graph.replace(keyNodes[i].move(projection.invert(keyPoints[i]))); + + // figure out the between delta angle we want to match to + startAngle = Math.atan2(keyPoints[i][1] - centroid[1], keyPoints[i][0] - centroid[0]); + endAngle = Math.atan2(keyPoints[nextKeyNodeIndex][1] - centroid[1], keyPoints[nextKeyNodeIndex][0] - centroid[0]); + totalAngle = endAngle - startAngle; + + // detects looping around -pi/pi + if (totalAngle*sign > 0) { + totalAngle = -sign * (2 * Math.PI - Math.abs(totalAngle)); + } + + do { + numberNewPoints++; + eachAngle = totalAngle / (indexRange + numberNewPoints); + } while (Math.abs(eachAngle) > maxAngle); + + // move existing points + for (j = 1; j < indexRange; j++) { + angle = startAngle + j * eachAngle; + loc = projection.invert([ + centroid[0] + Math.cos(angle)*radius, + centroid[1] + Math.sin(angle)*radius]); + + node = nodes[(j + startNodeIndex) % nodes.length].move(loc); + graph = graph.replace(node); + } + + // add new inbetween nodes if necessary + for (j = 0; j < numberNewPoints; j++) { + angle = startAngle + (indexRange + j) * eachAngle; + loc = projection.invert([ + centroid[0] + Math.cos(angle) * radius, + centroid[1] + Math.sin(angle) * radius]); + + node = iD.Node({loc: loc}); + graph = graph.replace(node); + + nodes.splice(endNodeIndex + j, 0, node); + } } + // update the way to have all the new nodes + ids = nodes.map(function(n) { return n.id; }); ids.push(ids[0]); + way = way.update({nodes: ids}); graph = graph.replace(way); - for (i = 0; i < nodes.length; i++) { - graph.parentWays(nodes[i]).forEach(function(parent) { - graph = graph.replace(parent.replaceNode(nodes[i].id, - ids[closestIndex(graph.childNodes(way), nodes[i].loc)])); - }); - - graph = iD.actions.DeleteNode(nodes[i].id)(graph); - } - return graph; }; diff --git a/test/spec/actions/circularize.js b/test/spec/actions/circularize.js index eccccd4ce..9456f109e 100644 --- a/test/spec/actions/circularize.js +++ b/test/spec/actions/circularize.js @@ -1,7 +1,7 @@ describe("iD.actions.Circularize", function () { var projection = d3.geo.mercator(); - it("creates a circle of 12 nodes", function () { + it("creates nodes if necessary", function () { var graph = iD.Graph({ 'a': iD.Node({id: 'a', loc: [0, 0]}), 'b': iD.Node({id: 'b', loc: [2, 0]}), @@ -12,7 +12,7 @@ describe("iD.actions.Circularize", function () { graph = iD.actions.Circularize('-', projection)(graph); - expect(graph.entity('-').nodes).to.have.length(13); + expect(graph.entity('-').nodes).to.have.length(20); }); it("reuses existing nodes", function () { @@ -21,43 +21,69 @@ describe("iD.actions.Circularize", function () { 'b': iD.Node({id: 'b', loc: [2, 0]}), 'c': iD.Node({id: 'c', loc: [2, 2]}), 'd': iD.Node({id: 'd', loc: [0, 2]}), - '-': iD.Way({id: '-', nodes: ['a', 'b', 'c', 'd', 'a']}) + 'e': iD.Node({id: 'e', loc: [0, 2]}), + '-': iD.Way({id: '-', nodes: ['a', 'b', 'c', 'd', 'e', 'a']}) + }), + nodes; + + graph = iD.actions.Circularize('-', projection)(graph); + + nodes = graph.entity('-').nodes; + expect(nodes.indexOf('a')).to.be.gte(0); + expect(nodes.indexOf('b')).to.be.gte(0); + expect(nodes.indexOf('c')).to.be.gte(0); + expect(nodes.indexOf('d')).to.be.gte(0); + expect(nodes.indexOf('e')).to.be.gte(0); + }); + + it("limits movement of nodes that are members of other ways", function () { + var graph = iD.Graph({ + 'a': iD.Node({id: 'a', loc: [2, 2]}), + 'b': iD.Node({id: 'b', loc: [-2, 2]}), + 'c': iD.Node({id: 'c', loc: [-2, -2]}), + 'd': iD.Node({id: 'd', loc: [2, -2]}), + '-': iD.Way({id: '-', nodes: ['a', 'b', 'c', 'd', 'a']}), + '=': iD.Way({id: '=', nodes: ['d']}) }); graph = iD.actions.Circularize('-', projection)(graph); - expect(graph.entity('-').nodes.slice(0, 4).sort()).to.eql(['a', 'b', 'c', 'd']); + expect(iD.geo.dist(graph.entity('d').loc, [2, -2])).to.be.lt(0.5); }); - it("deletes unused nodes that are not members of other ways", function () { + function angle(point1, point2, center) { + var vector1 = [point1[0] - center[0], point1[1] - center[1]], + vector2 = [point2[0] - center[0], point2[1] - center[1]], + distance; + + distance = iD.geo.dist(vector1, [0, 0]); + vector1 = [vector1[0] / distance, vector1[1] / distance]; + + distance = iD.geo.dist(vector2, [0, 0]); + vector2 = [vector2[0] / distance, vector2[1] / distance]; + + return 180 / Math.PI * Math.acos(vector1[0] * vector2[0] + vector1[1] * vector2[1]); + } + + it("creates circle respecting min-angle limit", function() { var graph = iD.Graph({ 'a': iD.Node({id: 'a', loc: [0, 0]}), 'b': iD.Node({id: 'b', loc: [2, 0]}), 'c': iD.Node({id: 'c', loc: [2, 2]}), 'd': iD.Node({id: 'd', loc: [0, 2]}), '-': iD.Way({id: '-', nodes: ['a', 'b', 'c', 'd', 'a']}) - }); + }), + centroid, points; - graph = iD.actions.Circularize('-', projection, 3)(graph); + graph = iD.actions.Circularize('-', projection, 20)(graph); + points = _.pluck(graph.childNodes(graph.entity('-')), 'loc').map(projection); + centroid = d3.geom.polygon(points).centroid(); - expect(graph.hasEntity('a')).to.be.undefined; - }); + for (var i = 0; i < points.length - 1; i++) { + expect(angle(points[i], points[i+1], centroid)).to.be.lte(20); + } - it("reconnects unused nodes that are members of other ways", function () { - var graph = iD.Graph({ - 'a': iD.Node({id: 'a', loc: [0, 0]}), - 'b': iD.Node({id: 'b', loc: [2, 0]}), - 'c': iD.Node({id: 'c', loc: [2, 2]}), - 'd': iD.Node({id: 'd', loc: [0, 2]}), - 'e': iD.Node({id: 'e', loc: [1, 1]}), - '-': iD.Way({id: '-', nodes: ['a', 'b', 'c', 'd', 'e', 'a']}), - '=': iD.Way({id: '=', nodes: ['a']}) - }); - - graph = iD.actions.Circularize('-', projection, 3)(graph); - - expect(graph.hasEntity('a')).to.be.undefined; - expect(graph.entity('=').nodes).to.eql(['c']); + expect(angle(points[points.length - 1], points[0], centroid)).to.be.lte(20); }); function area(id, graph) {