5

How can I draw an arrowed line between two circles, given:

  1. Location of the centers of the cirlces
  2. Radius of the circles

I am using line and marker svg objects.

If I draw the arrows to the "center" of the circle - then the arrow is invisible. If I move the arrow too far back - then the line shows through and hides the pointy end of the arrow (here exaggerated for better visibility): Arrows moved waaaay back

As per request, here is the relevant bits of my code (in livescript):

# Draw an arrow to use for lines
svg.append("svg:defs")
 .append("svg:marker")
  .attr("id", "arrow")
  .attr("viewBox", "0 0 10 10")
  .attr("refX", 27)
  .attr("refY", 5)
  .attr("markerUnits", "strokeWidth")
  .attr("markerWidth", 8)
  .attr("markerHeight", 6)
  .attr("orient", "auto")
  .append("svg:path")
  .attr("d", "M 0 0 L 10 5 L 0 10 z")

svg.append("line")
 .attr "x1" 5 
 .attr "x2" 50 
 .attr "y1" 5 
 .attr "y2" 50
 .style "stroke" "black"
 .attr "stroke-width" 2
 .attr "marker-end" "url(\#arrow)"

Alternatively, here is JSFiddle of the working example (note that the arrows are "fidgeted" to look just right): http://jsfiddle.net/yeQS2/

Andriy Drozdyuk
  • 58,435
  • 50
  • 171
  • 272
  • If you are looking for something prepackaged, D3 already does directed graphs: http://bl.ocks.org/1153292 – mccannf Nov 01 '12 at 20:06
  • @mccannf That is not prepackaged. In fact, that was exactly the example I was using as a reference. The only reason that example works or looks good is because the radius of the circles and the size of the arrows is so small. – Andriy Drozdyuk Nov 02 '12 at 01:39
  • If you change the `markerWidth` and `markerHeight` at the same time as changing the radius of the circles, there does not seem to be a problem. I've played around using the scrollbar in this example: http://jsfiddle.net/mccannf/ethh8/3/ – mccannf Nov 02 '12 at 10:57

4 Answers4

16

If I understood correctly, you need to find the 2D vector that you need to add to the source to get to the border of the target circle.

Pseudo code:

d = distance between A and B; // (sqrt((xB-xA)² + (yB-yA)²)).
d2 = d - radius;

ratio = d2 / d;

dx = (xB - xA) * ratio;
dy = (yB - yA) * ratio;

x = xA + dx;
y = yA + dy;
Joan Charmant
  • 2,012
  • 1
  • 18
  • 23
8

I had the same issue and here's how I solved it. Changes made to the original fiddle:

Change .attr("refX", 27) to .attr("refX", 0). This makes the arrowhead draw beyond the end of the line.

Compute the proper ending positions of the lines using trigonometry, accounting for arrowheads, by adding the following code to "tick":

var arrowheadLength = 8, // from markerWidth
    nodeRadius = 10;
link.each(function(d) {
  var x1 = d.source.x,
      y1 = d.source.y,
      x2 = d.target.x,
      y2 = d.target.y,
      angle = Math.atan2(y2 - y1, x2 - x1);
  d.targetX = x2 - Math.cos(angle) * (nodeRadius + arrowheadLength);
  d.targetY = y2 - Math.sin(angle) * (nodeRadius + arrowheadLength);
});

Use the computed targetX and targetY link properties:

.attr("x2", function(d){
  return d.targetX;
}).attr("y2", function(d){
  return d.targetY;
})

Here is the updated fiddle.

curran
  • 1,261
  • 13
  • 8
  • 1
    I like your solution since it is clean using Math.atan2 and Math.cos/sen but I was testing here and it seems to be exponentially slower than using Math.sqrt(Math.pow()+Math.pow()) plus the other multiplications as shown in the answer by Joan Charmant. Am I right? Thank you http://jsfiddle.net/gujqaf82/ – Henry Nov 02 '17 at 16:18
5

OK, so I thought I'd give it a shot and implement this with some vector math, it's prettier and the result is reusable.

A few clarifications:

  • A "vector" is simply two numbers (x and y)
  • "Coordinates" are in structure identical to vectors, they just mean something different to us. We can run the same math on the though.
  • A "positioned vector" is two vectors (like source and destination)
  • You can "free" a positioned vector by subtracting the first vector from the second one (you get a new vector which is no longer anchored in the coordinate system)
  • The "length" of a vector is calculated using pythagoras theorem (also called norm)
  • "Vector addition" is simply adding the the xs and ys of two or more vectors, resulting in a new vector.
  • "Scalar multiplication" and division is done by dividing x and y with the scalar
  • A "unit vector" is the vector divided by its length

Assuming we want this to to work dynamically ("per tick"), the initial links adjustment looks like this (I am using coffeescript):

links.attr('x1', ({source,target}) -> source.x)
     .attr('y1', ({source,target}) -> source.y)
     .attr('x2', ({source,target}) -> target.x)
     .attr('y2', ({source,target}) -> target.y)

What we want to do is move the source and target nodeRadius away from the circle. For that we use vector math to

  1. free the positioned vector (the link consists of two coordinates, we want a single unanchored vector)
  2. calculate the unit vector of the free vector
  3. once we have that, we can multiply it by nodeRadius. This new vector represents the distance between the node center and its border, with the same direction as the link.
  4. add the vector to the source coordinates, these new coordinates will be on the edge of the circle.

OK, so we will use the following functions to do this:

length = ({x,y}) -> Math.sqrt(x*x + y*y)
sum = ({x:x1,y:y1}, {x:x2,y:y2}) -> {x:x1+x2, y:y1+y2}
diff = ({x:x1,y:y1}, {x:x2,y:y2}) -> {x:x1-x2, y:y1-y2}
prod = ({x,y}, scalar) -> {x:x*scalar, y:y*scalar}
div = ({x,y}, scalar) -> {x:x/scalar, y:y/scalar}
unit = (vector) -> div(vector, length(vector))
scale = (vector, scalar) -> prod(unit(vector), scalar)

free = ([coord1, coord2]) -> diff(coord2, coord1)

This might look a little overwhelming, it's very compact because coffeescript allows us to deconstruct things directly in the method signature, quite handy! As you can see there is another function called scale. It's simply a convenience function to combine steps 2. & 3.

Now let's try and set the new x coordinate for the link source. Remember: The coordinate should be moved by nodeRadius, so that it starts on the border of the circle instead of inside it.

(d) ->
    # Step 1
    freed = free(d)
    # Step 2
    unit = unit(freed)
    # Step 3
    scaled = prod(unit, nodeRadius)
    # Step 2+3 would be scale(freed, nodeRadius)
    # Step 4, coords are pretty much just vectors,
    # so we just use the sum() function to move the source coords
    coords = sum(d.source, scaled)
    return coords.x

Nothing to it! Putting all of that into the tick() function, we get:

links.attr('x1', ({source,target}) -> sum(source, scale(free([source,target]), nodeRadius)).x)
     .attr('y1', ({source,target}) -> sum(source, scale(free([source,target]), nodeRadius)).y)
     .attr('x2', ({source,target}) -> diff(target, scale(free([source,target]), nodeRadius)).x)
     .attr('y2', ({source,target}) -> diff(target, scale(free([source,target]), nodeRadius)).y)

Oh, and don't forget to subtract from the target coordinates, otherwise you'd just be making the line longer again (i.e. moving it by nodeRadius).

andsens
  • 6,716
  • 4
  • 30
  • 26
2

As @andsens said, you're doing a simple vector manipulation.

This can be done much more cleanly if you wrap it in a decent library. For example, I use the nice Sylvester matrix and vector library.

What you're essentially calculating is:

V subscript edge equals open paren modulus V minus R close paren times unit vector V

Where v is the vector to the centre of the target, and vedge the vector to the edge of the target with radius r.

Which you can do easily:

// Assume source and target both have x and y properties
// Assume target has radius property
function path2TargetEdge(source, target){

  // V is the vector from the source to the target's center
  var V = $V([target.x-source.x, target.y-source.y]);

  // Vt is the vector from the source to the edge of the target
  var Vt = V.toUnitVector().multiply(V.modulus() - target.radius);

  return {x: Vt.e(1), y: Vt.e(2) }; // Vectors are 1-indexed
}
brice
  • 24,329
  • 7
  • 79
  • 95