diff --git a/js/id/behavior/draw.js b/js/id/behavior/draw.js index 214433972..e2553dc67 100644 --- a/js/id/behavior/draw.js +++ b/js/id/behavior/draw.js @@ -58,7 +58,7 @@ iD.behavior.Draw = function(context) { function click() { var d = datum(); if (d.type === 'way') { - var choice = iD.geo.chooseIndex(d, d3.mouse(context.surface().node()), context), + var choice = iD.geo.chooseEdge(context.childNodes(d), d3.mouse(context.surface().node()), context.projection), edge = [d.nodes[choice.index - 1], d.nodes[choice.index]]; event.clickWay(choice.loc, edge); diff --git a/js/id/behavior/draw_way.js b/js/id/behavior/draw_way.js index 8452763c3..f47dced81 100644 --- a/js/id/behavior/draw_way.js +++ b/js/id/behavior/draw_way.js @@ -37,7 +37,7 @@ iD.behavior.DrawWay = function(context, wayId, index, mode, baseGraph) { } else if (datum.type === 'node') { loc = datum.loc; } else if (datum.type === 'way') { - loc = iD.geo.chooseIndex(datum, d3.mouse(context.surface().node()), context).loc; + loc = iD.geo.chooseEdge(context.childNodes(datum), d3.mouse(context.surface().node()), context.projection).loc; } context.replace(iD.actions.MoveNode(end.id, loc)); diff --git a/js/id/geo.js b/js/id/geo.js index 682980758..ff6b060c8 100644 --- a/js/id/geo.js +++ b/js/id/geo.js @@ -15,24 +15,48 @@ iD.geo.dist = function(a, b) { return Math.sqrt((x * x) + (y * y)); }; -iD.geo.chooseIndex = function(way, point, context) { +// Choose the edge with the minimal distance from `point` to its orthogonal +// projection onto that edge, if such a projection exists, or the distance to +// the closest vertex on that edge. Returns an object with the `index` of the +// chosen edge, the chosen `loc` on that edge, and the `distance` to to it. +iD.geo.chooseEdge = function(nodes, point, projection) { var dist = iD.geo.dist, - graph = context.graph(), - nodes = graph.childNodes(way), - projNodes = nodes.map(function(n) { return context.projection(n.loc); }); + points = nodes.map(function(n) { return projection(n.loc); }), + min = Infinity, + idx, loc; - for (var i = 0, changes = []; i < projNodes.length - 1; i++) { - changes[i] = - (dist(projNodes[i], point) + dist(point, projNodes[i + 1])) / - dist(projNodes[i], projNodes[i + 1]); + function dot(p, q) { + return p[0] * q[0] + p[1] * q[1]; } - var idx = _.indexOf(changes, _.min(changes)), - ratio = dist(projNodes[idx], point) / dist(projNodes[idx], projNodes[idx + 1]), - loc = iD.geo.interp(nodes[idx].loc, nodes[idx + 1].loc, ratio); + for (var i = 0; i < points.length - 1; i++) { + var o = points[i], + s = [points[i + 1][0] - o[0], + points[i + 1][1] - o[1]], + v = [point[0] - o[0], + point[1] - o[1]], + proj = dot(v, s) / dot(s, s), + p; + + if (proj < 0) { + p = o; + } else if (proj > 1) { + p = points[i + 1]; + } else { + p = [o[0] + proj * s[0], o[1] + proj * s[1]]; + } + + var d = dist(p, point); + if (d < min) { + min = d; + idx = i + 1; + loc = projection.invert(p); + } + } return { - index: idx + 1, + index: idx, + distance: min, loc: loc }; }; diff --git a/js/id/id.js b/js/id/id.js index 85dedfc3f..a45e49bf0 100644 --- a/js/id/id.js +++ b/js/id/id.js @@ -88,6 +88,10 @@ window.iD = function () { return history.graph().entity(id); }; + context.childNodes = function(way) { + return history.graph().childNodes(way); + }; + context.geometry = function(id) { return context.entity(id).geometry(history.graph()); }; diff --git a/js/id/modes/drag_node.js b/js/id/modes/drag_node.js index ca9d27142..072313d8c 100644 --- a/js/id/modes/drag_node.js +++ b/js/id/modes/drag_node.js @@ -101,7 +101,7 @@ iD.modes.DragNode = function(context) { if (d.type === 'node' && d.id !== entity.id) { loc = d.loc; } else if (d.type === 'way') { - loc = iD.geo.chooseIndex(d, d3.mouse(context.surface().node()), context).loc; + loc = iD.geo.chooseEdge(context.childNodes(d), d3.mouse(context.surface().node()), context.projection).loc; } context.replace( @@ -115,7 +115,7 @@ iD.modes.DragNode = function(context) { var d = datum(); if (d.type === 'way') { - var choice = iD.geo.chooseIndex(d, d3.mouse(context.surface().node()), context); + var choice = iD.geo.chooseEdge(context.childNodes(d), d3.mouse(context.surface().node()), context.projection); context.replace( iD.actions.AddMidpoint({ loc: choice.loc, edge: [d.nodes[choice.index - 1], d.nodes[choice.index]] }, entity), connectAnnotation(d)); diff --git a/js/id/modes/select.js b/js/id/modes/select.js index a110fd034..6c8736032 100644 --- a/js/id/modes/select.js +++ b/js/id/modes/select.js @@ -131,8 +131,8 @@ iD.modes.Select = function(context, selection) { datum = target.datum(); if (datum instanceof iD.Way && !target.classed('fill')) { - var choice = iD.geo.chooseIndex(datum, - d3.mouse(context.surface().node()), context), + var choice = iD.geo.chooseEdge(context.childNodes(datum), + d3.mouse(context.surface().node()), context.projection), node = iD.Node(); var prev = datum.nodes[choice.index - 1], diff --git a/js/id/ui/preset/address.js b/js/id/ui/preset/address.js index 79193367f..79118f9f4 100644 --- a/js/id/ui/preset/address.js +++ b/js/id/ui/preset/address.js @@ -20,11 +20,11 @@ iD.ui.preset.address = function(field, context) { var loc = context.projection([ (extent[0][0] + extent[1][0]) / 2, (extent[0][1] + extent[1][1]) / 2]), - closest = context.projection(iD.geo.chooseIndex(d, loc, context).loc); + choice = iD.geo.chooseEdge(context.childNodes(d), loc, context.projection); return { title: d.tags.name, value: d.tags.name, - dist: iD.geo.dist(closest, loc) + dist: choice.distance }; }).sort(function(a, b) { return a.dist - b.dist; diff --git a/test/spec/geo.js b/test/spec/geo.js index 113d4795d..dfd127099 100644 --- a/test/spec/geo.js +++ b/test/spec/geo.js @@ -36,6 +36,77 @@ describe('iD.geo', function() { }); }); + describe('.chooseEdge', function() { + var projection = function (l) { return l; }; + projection.invert = projection; + + it('returns undefined properties for a degenerate way (no nodes)', function() { + expect(iD.geo.chooseEdge([], [0, 0], projection)).to.eql({ + index: undefined, + distance: Infinity, + loc: undefined + }) + }); + + it('returns undefined properties for a degenerate way (single node)', function() { + expect(iD.geo.chooseEdge([iD.Node({loc: [0, 0]})], [0, 0], projection)).to.eql({ + index: undefined, + distance: Infinity, + loc: undefined + }) + }); + + it('calculates the orthogonal projection of a point onto a segment', function() { + // a --*--- b + // | + // c + // + // * = [2, 0] + var a = [0, 0], + b = [5, 0], + c = [2, 1], + nodes = [ + iD.Node({loc: a}), + iD.Node({loc: b}) + ]; + + var choice = iD.geo.chooseEdge(nodes, c, projection); + expect(choice.index).to.eql(1); + expect(choice.distance).to.eql(1); + expect(choice.loc).to.eql([2, 0]); + }); + + it('returns the starting vertex when the orthogonal projection is < 0', function() { + var a = [0, 0], + b = [5, 0], + c = [-3, 4], + nodes = [ + iD.Node({loc: a}), + iD.Node({loc: b}) + ]; + + var choice = iD.geo.chooseEdge(nodes, c, projection); + expect(choice.index).to.eql(1); + expect(choice.distance).to.eql(5); + expect(choice.loc).to.eql([0, 0]); + }); + + it('returns the ending vertex when the orthogonal projection is > 1', function() { + var a = [0, 0], + b = [5, 0], + c = [8, 4], + nodes = [ + iD.Node({loc: a}), + iD.Node({loc: b}) + ]; + + var choice = iD.geo.chooseEdge(nodes, c, projection); + expect(choice.index).to.eql(1); + expect(choice.distance).to.eql(5); + expect(choice.loc).to.eql([5, 0]); + }); + }); + describe('.pointInPolygon', function() { it('says a point in a polygon is on a polygon', function() { var poly = [[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]];