How to Connect HTML Elements With an Arrow Using SVG

Posted on Posted in Javascript, web design

The other day, had a list of items on the left-hand side, and another list of items on the right-hand side. Some of these items are connected, some of them are not. Wouldn’t it be nice to show this by connecting the item with an arrow?

At first, there’s nothing new here. We’ve been drawing and connecting boxes, circles and arbitrary shapes in the IT business for ages. Just think of flow charts or organigrams. There’s even a nice PrimeFaces component for that.

The problem is that my application was an Angular2 application, not a JSF application. Plus, the PrimeFaces component, which is really nice, didn’t match my requirements. I didn’t want to display a flow chart. The boxes I wanted to connect contain live data and even images. So the task is to connect arbitrary <div /> elements with an arrow.

At first, the task seems a bit intimidating, but once I’ve started to delve into it, it became surprisingly simple. Simple enough I can even provide the source code at the end of the post.

There’s a library for that!

It goes without saying that I started with the usual approach. Obviously, drawing arrow is an incredibly difficult task, so don’t you dare to do it yourself. There must be a library for that. Following the standard approach, I fired up a quick Google search, which led me to (guess what!) Stackoverflow.com. And, voilá: there’s a library for it! As far as I remember, there’s only a single library for connecting HTML elements with arrows. In 2016, that’s a bit surprising. But of course, it reduces the amount of time necessary to select the best library.

The library I’m talking about is jsplumb, and it’s really powerful library. You can buy it for a modest price (well, modest if you’re a company), or you can use the community version which has fewer features. But even the community version is powerful enough. Highly recommended. And there’s no irony there, even if I can’t resist the temptation to write much of this article in an ironic style. Jsplumb is really a great library. I decided not to use it for other reasons.

I was a bit disturbed when I saw that the library consisted of 13800 lines. Obviously, our first impression was correct: drawing arrows must be a difficult task. Adding insult to injury, the library made my application slow. Truth to tell, I don’t think that’s the fault of the library. My development environment is far from being fast. It’s a virtual machine running on Citrix, which means a lot of network traffic. It also means that there’s no graphics accelerator card. Little wonder the browser renders at 10% speed.

However, not every user has a fast PC, so it’s a good idea to go easy with the browser’s resources.

Is it really that difficult?

My curiosity was piqued. Plus, I wanted to send my customer a screenshot, but for some reason, jsplumb doesn’t cope with my project setup. Maybe it’s because we’re using Angular2, which does a lot of magic in the background, or because we’re using Bootstrap 4, which is still an alpha version. I didn’t investigate the reasons. I just noticed that my arrow started from the start node from the correct position. But then its Odyssey began. For some reason, it followed an elegant curve before vanishing behind the start node. Like I’ve said before, I consider jsplumb a good library, so I’m sure I could have solved the issue.

However, I followed a different approach. I looked at the HTML source code jsplumb generated and was surprised to see three simple SVG graphics. Simplifying things a bit, what I found was something like this:

<svg style="position:absolute;left:0px;top:0px" 
     width="800" height="200">
  <circle cx="10" cy="10" r="10"            fill="#456" stroke="none" />
  <circle cx="726" cy="180" r="10"          fill="#456" stroke="none" />
  <path   d="M 726 180 C 276 87 270 10 0 0" fill="none" stroke="#456"/>
</svg>

Granted, I’ve removed a lot of boilerplate code. But even the original version looked surprisingly simple. The story might have been different if I had opted for actual arrows instead of choosing simple circles at the ends of the lines. But so, I was lucky, and I ended up with a few lines of SVG I could easily generate myself.

Setting the stage

As you can see in the code snippet above, an SVG image is a small XML file. It consists of the SVG tag itself and several nested elements. In our case, that’s two circles and a path. This path, in turn, is a line connecting two points along a bezier curve. We don’t need the bezier curve to connect two items, but it looks a lot more attractive than a simple straight line, so we’ll stick to it even if the path definition looks a bit cryptic at first.

Let’s start with the SVG tag. The nice thing about SVG is that you can define a so-called viewbox. Inside the viewbox, you can define your own coordinate system. In many cases, it’s a good idea to define the coordinate system as 100 units wide and 100 units high. What a unit is, depends on the real size of the image, which usually is defined outside. Thus, the image content scales seamlessly when the image grows or shrinks.

However, decoupling the coordinate system from the real coordinate system of the screen isn’t useful in our case. There’s no easy way to define a size in real-world pixels. At first, I followed this approach, until I observed that the circles at the end points of the line grow when the image grows. But I wanted each circle to be the same size, no matter how long the connecting line is.

The other idea was to set the SVG image to the upper left corner of the HTML page. So I wanted an SVG image starting at the coordinate (0, 0) and ending at the lower left corner of the HTML page. That’s not identical to the window size: the HTML page may be a lot larger than the real screen estate, and we also want to be able to connect divs outside the current viewport. Luckily, it’s possible to determine the real height of the HTML page. This way we can make sure that the coordinate system of the HTML page and the SVG image are almost identical:

<svg style="position:absolute;left:0px;top:0px" 
     width="{{document.body.clientWidth}}" 
     height="{{document.body.clientHeight}}">
   ...
</svg>

Remains the question how to resolve the mustaches. You can do this with document.write(). However, in my case, it turned out to be more useful to create the SVG in JavaScript, so I wrote this function:

function createSVG() {
  var svg = document.getElementById("svg-canvas");
  if (null == svg) {
    svg = document.createElementNS("http://www.w3.org/2000/svg", 
                                   "svg");
    svg.setAttribute('id', 'svg-canvas');
    svg.setAttribute('style', 'position:absolute;top:0px;left:0px');
    svg.setAttribute('width', document.body.clientWidth);
    svg.setAttribute('height', document.body.clientHeight);
    svg.setAttributeNS("http://www.w3.org/2000/xmlns/", 
                       "xmlns:xlink", 
                       "http://www.w3.org/1999/xlink");
    document.body.appendChild(svg);
  }
  return svg;
}

For the sake of simplicity, this function is designed to be called multiple times. It creates the SVG canvas only once. After that, the canvas created during the first call is reused.

Drawing the end points

Now that the coordinate systems of the SVG and the HTML file are (almost) identical, it’s easy to draw the end points of the connection line as circles:

function drawCircle(x, y, radius, color) {
    var svg = createSVG();
	    var shape = document.createElementNS("http://www.w3.org/2000/svg", "circle");
    shape.setAttributeNS(null, "cx", x);
    shape.setAttributeNS(null, "cy", y);
    shape.setAttributeNS(null, "r",  radius);
    shape.setAttributeNS(null, "fill", color);
    svg.appendChild(shape);
}

That’s a more flexible, albeit less readable version of the XML declaration:

<circle cx="10" cy="10" r="10" fill="blue" />

You probably can already decipher this statement. The attributes cx and cy are the coordinates of the circle, and r is the radius. It’s filled with blue color.

Just in case you wonder why I insist on the tiny difference between the coordinate systems: SVG doesn’t use integer numbers to define coordinates, but floating point numbers. So the HTML coordinate of a pixel isn’t identical to the same coordinate in SVG. Instead, the coordinate refers to the upper left corner of the pixel. This is why SVG images are often translated half a pixel to the right and to the bottom, like so:

  <svg style="position:absolute;left:0px;top:0px" 
       width="800" height="200">
    <g transform="translate(0.5,0.5)">
      <circle ... />
      <circle ... />
      <path   ... />
    </g>
  </svg>

Getting the absolute coordinates of HTML elements

HTML is a language that allows to nest elements into container elements. This is a tremendously useful property, but it’s a bit inconvenient if you want to determine the absolute coordinates of an HTML element. You have to traverse the entire tree to the root in order to learn the absolute coordinates. However, we need to know this coordinate in order to determine the start point and the end point of our connection line, so we don’t have any choice. Here’s how it’s done:

function findAbsolutePosition(htmlElement) {
  var x = htmlElement.offsetLeft;
  var y = htmlElement.offsetTop;
  for (var x=0, y=0, el=htmlElement; 
       el != null; 
       el = el.offsetParent) {
         x += el.offsetLeft;
         y += el.offsetTop;
  }
  return {
      "x": x,
      "y": y
  };
}

Drawing the end points

We’re almost there. Next thing we need to know is the coordinates of the endpoints of the connecting line. For the sake of simplicity, let’s assume that we know which divs are on the right-hand side, and which divs are on the left-hand side. So the code to determince the coordinates of the end point of the line and to draw the end point and the line itself looks like so:

function connectDivs(leftId, rightId, color, tension) {
  var left = document.getElementById(leftId);
  var right = document.getElementById(rightId);
	
  var leftPos = findAbsolutePosition(left);
  var x1 = leftPos.x;
  var y1 = leftPos.y;
  x1 += left.offsetWidth;
  y1 += (left.offsetHeight / 2);

  var rightPos = findAbsolutePosition(right);
  var x2 = rightPos.x;
  var y2 = rightPos.y;
  y2 += (right.offsetHeight / 2);

  var width=x2-x1;
  var height = y2-y1;

  drawCircle(x1, y1, 3, color);
  drawCircle(x2, y2, 3, color);
  drawCurvedLine(x1, y1, x2, y2, color, tension);
}

Connecting the end points

The only thing that’s left to implement is the function drawCurvedLine. This function connects two points along a Bezier curve. You can control the amount of the curvature using the parameter tension. Actually, I should have called it slackness, because a tension of 0.0 results in a straight line, which is the result of maximum tension. Be that as it may, the Bezier curve is drawn like so:

function drawCurvedLine(x1, y1, x2, y2, color, tension) {
    var svg = createSVG();
    var shape = document.createElementNS("http://www.w3.org/2000/svg", 
                                         "path");{
    var delta = (x2-x1)*tension;
    var hx1=x1+delta;
    var hy1=y1;
    var hx2=x2-delta;
    var hy2=y2;
    var path = "M "  + x1 + " " + y1 + 
               " C " + hx1 + " " + hy1 
                     + " "  + hx2 + " " + hy2 
               + " " + x2 + " " + y2;
    shape.setAttributeNS(null, "d", path);
    shape.setAttributeNS(null, "fill", "none");
    shape.setAttributeNS(null, "stroke", color);
    svg.appendChild(shape);
}

This time it’s harder to decipher the program. Things become only slightly simpler when we look at the corresponding XML declaration:

<path d="M 726 180 C 276 87 270 10 0 0" fill="none" stroke="#456"/>

The attributes fill and stroke are simple. stroke defines the color of the line. fill defines the color the shape is filled with. Obviously we can’t fill a one-dimensional line, so the color of the line is completely determine by its stroke color. If we were to provide the fill attribute, too, the area between the straight line and the real line was filled.

The other attribute of the path defines the way of the path. The definitions starts with M. This defines the start coordinates. In this case, that’s (726, 180). The last two numbers are the target coordinates. In our case, that’s (0, 0). The “C” defines a cubic Bezier function. This means that the line approaches two points as close as possible while still being smooth. In our case, these deviation points are at the coordinates (276, 87) and (87, 270). There’s a nice animation at Wikipedia showing the idea in more detail. For now, suffice it to say that the result is an elegantly curved line. The amount of the curvature is defined by the parameter tension of the JavaScript function, which moves the deviation points away from the straight diagonal line.

Putting it all together

We’ve done it! Now we can reap the fruit of our labor. Just in case you’re not familiar with JSF: the next few lines are part of a BootsFaces page.

<h:body>
  <b:container>
  <br />
    <b:panelGrid columns="3">
      <b:panel id="left" title="left" look="success">
      </b:panel>
      <h:panelGroup layout="block"></h:panelGroup>
      <b:panel id="right1" title="right" look="danger">
      </b:panel>
      <h:panelGroup layout="block"></h:panelGroup>
      <h:panelGroup layout="block"></h:panelGroup>
      <b:panel id="right2" title="right" look="danger">
      </b:panel>
      <h:panelGroup layout="block"></h:panelGroup>
      <b:panel id="right3" title="right" look="danger"></b:panel>
      <b:panel id="right4" title="right" look="danger">
      </b:panel>
    </b:panelGrid>
  </b:container>
</h:body>
<script src="svg.js"></script>
    <script>
	connector.connectDivs("left", "right1", "blue", 0.2);
	connector.connectDivs("left", "right2", "blue", 0.2);
	connector.connectDivs("left", "right3", "blue", 0.0);
	connector.connectDivs("left", "right4", "green", -0.5);
</script>
</html>

This is what it looks like:

BTW, when you copy my source code, you’ll notice that the negative tension doesn’t work properly. That’s because I’ve omitted part of the source code for the sake of simplicity. Basically, I’ve implemented negative tensions to work on the y coordinates instead of the x coordinates:

if (tension<0) {
    var delta = (y2-y1)*tension;
    var hx1=x1;
    var hy1=y1-delta;
    var hx2=x2;
    var hy2=y2+delta;
    var path = "M " + x1 + " " + y1 + 
              " C " + hx1 + " " + hy1 + " "  
                    + hx2 + " " + hy2 + " " 
                    + x2 + " " + y2;
} else {
    var delta = (x2-x1)*tension;
    var hx1=x1+delta;
    var hy1=y1;
    var hx2=x2-delta;
    var hy2=y2;
    var path = "M " + x1 + " " + y1 + 
              " C " + hx1 + " " + hy1 + " "  
                    + hx2 + " " + hy2 + " " 
                    + x2 + " " + y2;
}

Bonus section: arrows

Let’s conclude this article with something special. Until now, I’ve deliberately shown simple circles as end points of the arrows. But in reality, you’d probably want to draw an arrow. At first glance, this is simple. You’ve already learned enough of SVG to draw an arrowhead with a little help from the internet:

<path xmlns="http://www.w3.org/2000/svg" 
      d="M 0 0 L 10 5 L 0 10 z" 
      stroke="black" fill="black"/>

But wait. Our lines don’t follow straight lines. So you have to orient the arrowhead according to the direction of the end of the line. This can be achieved using a reusable marker:

<defs>
    <marker id="triangle" viewBox="0 0 10 10" refX="0" refY="5"
            markerUnits="strokeWidth" markerWidth="10"
            markerHeight="8" orient="auto">
        <path d="M 0 0 L 10 5 L 0 10 z"></path>
    </marker>
</defs>
<path d="M 503 55 C 503 55 533 235 533 235" fill="none"
      stroke="blue" marker-end="url(#triangle)"
</path>

The magic is the attribute orient="auto". It rotates the arrowhead to match the direction of the end of the line.

If you want to draw an arrow at the start of the line, you can do this using the attribute marker-start. However, you have to rotate the arrowhead by 180°. That’s not done automatically.

Translating this XML code to JavaScript is a bit lengthy, but I’ll show it for the sake of completeness nonetheless:

markerInitialized = false;

function createTriangleMarker() {
  if (markerInitialized)
    return;
  markerInitialized = true;
  var svg = createSVG();
  var defs = document.createElementNS('http://www.w3.org/2000/svg',
    'defs');
  svg.appendChild(defs);

  var marker = document.createElementNS('http://www.w3.org/2000/svg',
    'marker');
  marker.setAttribute('id', 'triangle');
  marker.setAttribute('viewBox', '0 0 10 10');
  marker.setAttribute('refX', '0');
  marker.setAttribute('refY', '5');
  marker.setAttribute('markerUnits', 'strokeWidth');
  marker.setAttribute('markerWidth', '10');
  marker.setAttribute('markerHeight', '8');
  marker.setAttribute('orient', 'auto');
  var path = document.createElementNS('http://www.w3.org/2000/svg',
    'path');
  marker.appendChild(path);
  path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
  defs.appendChild(marker);
  ... // and the same for the start arrowhead (180° rotated)
}

function drawCurvedLine(x1, y1, x2, y2, color, tension) {
  ... // you've seen this method before, we show only the diffence
  shape.setAttributeNS(null, "marker-start", "url(#trianglebackwards)");
  shape.setAttributeNS(null, "marker-end", "url(#triangle)");
  svg.appendChild(shape);
}

Wrapping it up

No doubt about it: jsplumb is a great library. However, my old observation proves true once again. It’s always a bad idea to be awestruck at the work of other developers. They do great work, but often you only need a small fraction of their pet library. If so, don’t shy away from implementing it yourself. Probably I should add a word of caution: Please don’t do that without thinking it through thoroughly. But once you’ve done that, and if you’re still sure that implementing things yourself is easier than wrapping your head around a third-party library, do it. You’ll be surprised how often this approach pays off.

Because that’s the ugly truth nobody tells you: using third-party libraries doesn’t come for free. Even if you don’t have to pay royalties, you have to learn how to use the API, and you have to update the library frequently. Even worse, in the age of abundant cybercrime, you have to be careful which update to pick. You’ll want the newest security patches without catching the new security leaks. In general, it’s consensus that open source projects are more secure than your own projects. But even so: jsplumb consists of 13800 lines (probably because they’ve included many third-party libraries themselves), and our own implementation covered a few dozen lines of code at most. With such a huge difference it’s likely that writing your own implementation is safer than using an open source project. Not to mention the difference with respect to performance and memory footprint.


Dig deeper

Wikipedia on SVG
Animated illustration of Bezier curse at Wikipedia
detailed explanation of the SVG commands at wikibooks.org
creating SVG markers with JavaScript

3 thoughts on “How to Connect HTML Elements With an Arrow Using SVG

  1. Hi,
    I am trying to do a similar thing connecting html elements in svg in an angular2 app. Do you have the code how you used this in your angular2 application? Are you tying these in any specific lifecycle hooks?

    1. Currently, my Angular code is not much more than a proof of concept. Plus, I’ve learned a lot when I started studying the source code of ngx-charts, so I’d restart from scratch now. Things get a lot simpler if you realize that you can put the SVG source code in a template file:

      @Component({
        selector: 'connector',
        template: `
      <svg style="position:absolute;left:0px;top:0px" 
           width="800" height="200">
        <circle cx="10" cy="10" r="10"            fill="#456" stroke="none" />
        <circle cx="726" cy="180" r="10"          fill="#456" stroke="none" />
        <path   d="M 726 180 C 276 87 270 10 0 0" fill="none" stroke="#456"/>
      </svg>
      `})
      export class ConnectorComponent implements OnChanges {
         @Input divId1: string;
         @Input divId2: string;
      ...
      }
      

      Of course, you’d have to replace the fixed numbers by calculated variables, as sketched in the article.

Leave a Reply

Your email address will not be published.