Search code examples
javascriptd3.jssvgannotations

Draw a path from an origin point to the outside radius of a circle


I'm adding annotations to a chart and drawing a curved path from the label to the centre of the circle it's labelling. The path is drawn from a determined origin point (source) to a target point (centre of a circle). I'm doing this by returning a path string. I don't want to use an arc as its important to have control of the curve of the line depending on where its source is in relation to the circle, for aesthetic reasons.

I'm returning the path string using the following. This gives me Figure A

newX = sourceX
newY = sourceY
c1x = newX + ((targetX-newX) * 0.8)
c1y = newY - rem
c2x = targetX - ((targetX-newX)* 0.05)
c2y = targetY - ((targetY-newY) * 0.8)
pathString  = "M " + newX + "," + (newY) + " C " + c1x + "," + c1y + " + c2x + "," + c2y + " " + targetX + "," + targetY;};
return pathString

I've tried looking around for this but it all seems to be straight lines intersecting circles which I can understand. But I've no idea how to achieve Figure B given I have the information on the diagram. Any help appreciated.

enter image description here


Solution

  • The most commonly used mathematical solutions, like this one, won't probably suit you because you want the curved path imaginarily continuing its way until the center of the target circle.

    While you can create a more refined math for this (or use the suggested libraries in the comments), a very simple solution is just drawing your path without any change and then, using stroke-dasharray, removing the last part, making the path ending exactly (almost, see the post scriptum below) at the circle's border.

    So, suppose this path (here I'm using your function, which I named drawPath):

    var svg = d3.select("svg");
    var sourceX = 50,
      sourceY = 50;
    var targetX = 300,
      targetY = 150;
    var radius = 50;
    svg.append("circle")
      .attr("cx", sourceX)
      .attr("cy", sourceY)
      .attr("r", 4);
    svg.append("circle")
      .attr("cx", targetX)
      .attr("cy", targetY)
      .attr("r", 4);
    svg.append("circle")
      .attr("cx", targetX)
      .attr("cy", targetY)
      .attr("r", radius)
      .style("fill", "none")
      .style("stroke", "black");
    svg.append("path")
      .style("fill", "none")
      .style("stroke", "steelblue")
      .style("stroke-width", "2px")
      .attr("d", drawPath);
    
    function drawPath() {
      newX = sourceX
      newY = sourceY
      c1x = newX + ((targetX - newX) * 0.5)
      c1y = newY - ((targetY - newY) * 0.5)
      c2x = targetX - ((targetX - newX) * 0.05)
      c2y = targetY - ((targetY - newY) * 0.5)
      pathString = "M " + newX + "," + (newY) + " C " + c1x + "," + c1y + "," + c2x + ", " + c2y + " " + targetX + ", " + targetY;
      return pathString
    }
    <script src="https://d3js.org/d3.v5.min.js"></script>
    <svg width="500" height="300"></svg>

    We can change the stroke-dasharray to remove the final part of the path with just this:

    .attr("stroke-dasharray", function() {
        return this.getTotalLength() - radius;
    });
    

    Here is the resulting code:

    var svg = d3.select("svg");
    var sourceX = 50,
      sourceY = 50;
    var targetX = 300,
      targetY = 150;
    var radius = 50;
    svg.append("circle")
      .attr("cx", sourceX)
      .attr("cy", sourceY)
      .attr("r", 4);
    svg.append("circle")
      .attr("cx", targetX)
      .attr("cy", targetY)
      .attr("r", 4);
    svg.append("circle")
      .attr("cx", targetX)
      .attr("cy", targetY)
      .attr("r", radius)
      .style("fill", "none")
      .style("stroke", "black");
    svg.append("path")
      .style("fill", "none")
      .style("stroke", "steelblue")
      .style("stroke-width", "2px")
      .attr("d", drawPath)
      .attr("stroke-dasharray", function() {
        return this.getTotalLength() - radius;
      });
    svg.append("circle")
      .attr("cx", function(d) {
        var path = d3.select("path").node()
        var point = path.getPointAtLength(path.getTotalLength() - radius);
        return point.x
      })
      .attr("cy", function(d) {
        var path = d3.select("path").node()
        var point = path.getPointAtLength(path.getTotalLength() - radius);
        return point.y
      })
      .attr("r", 4);
    
    function drawPath() {
      newX = sourceX
      newY = sourceY
      c1x = newX + ((targetX - newX) * 0.5)
      c1y = newY - ((targetY - newY) * 0.5)
      c2x = targetX - ((targetX - newX) * 0.05)
      c2y = targetY - ((targetY - newY) * 0.5)
      pathString = "M " + newX + "," + (newY) + " C " + c1x + "," + c1y + "," + c2x + ", " + c2y + " " + targetX + ", " + targetY;
      return pathString
    }
    <script src="https://d3js.org/d3.v5.min.js"></script>
    <svg width="500" height="300"></svg>

    You can see that the path will continue until the circle's center if we add another unmodified path, in red:

    var svg = d3.select("svg");
    var sourceX = 50,
      sourceY = 50;
    var targetX = 300,
      targetY = 150;
    var radius = 50;
    svg.append("circle")
      .attr("cx", sourceX)
      .attr("cy", sourceY)
      .attr("r", 4);
    svg.append("circle")
      .attr("cx", targetX)
      .attr("cy", targetY)
      .attr("r", 4);
    svg.append("circle")
      .attr("cx", targetX)
      .attr("cy", targetY)
      .attr("r", radius)
      .style("fill", "none")
      .style("stroke", "black");
    svg.append("path")
      .style("fill", "none")
      .style("stroke", "tomato")
      .style("stroke-width", "2px")
      .style("stroke-dasharray", "2,2")
      .attr("d", drawPath);
    svg.append("path")
      .style("fill", "none")
      .style("stroke", "steelblue")
      .style("stroke-width", "2px")
      .attr("d", drawPath)
      .attr("stroke-dasharray", function() {
        return this.getTotalLength() - radius;
      });
    svg.append("circle")
      .attr("cx", function(d) {
        var path = d3.select("path").node()
        var point = path.getPointAtLength(path.getTotalLength() - radius);
        return point.x
      })
      .attr("cy", function(d) {
        var path = d3.select("path").node()
        var point = path.getPointAtLength(path.getTotalLength() - radius);
        return point.y
      })
      .attr("r", 4);
    
    function drawPath() {
      newX = sourceX
      newY = sourceY
      c1x = newX + ((targetX - newX) * 0.5)
      c1y = newY - ((targetY - newY) * 0.5)
      c2x = targetX - ((targetX - newX) * 0.05)
      c2y = targetY - ((targetY - newY) * 0.5)
      pathString = "M " + newX + "," + (newY) + " C " + c1x + "," + c1y + "," + c2x + ", " + c2y + " " + targetX + ", " + targetY;
      return pathString
    }
    <script src="https://d3js.org/d3.v5.min.js"></script>
    <svg width="500" height="300"></svg>

    PS: It's worth mentioning that here I'm assuming (wrongly) that the length of the portion of that path inside the circle is equal to the circle's radius. It's not: the difference is visually negligible for the chart if the two points are not very close, but it's mathematically well defined. However, if the origin is close to the circle's border the difference will be noticeable. In that case, use the libraries indicated.