DEV Community

Shalvah
Shalvah

Posted on • Edited on • Originally published at blog.shalvah.me

Learn SVG by drawing an arrow

How SVG works

The common way of representing digital images is via pixels. Take an image and break it down into small squares ("pixels"), where each square has a single colour. If you have many tiny squares, the colours blend in, and the image looks smooth to the human eye. If you don't have enough squares, the image looks rough and jagged, and you can see the edges of the individual squares. This is the difference between high-resolution screens/images (more pixels) and low-resolution ones (fewer pixels).

But there's also vector graphics (SVG = Scalable Vector Graphics). They don't use pixels, but instead work by drawing the image on the display, using geometry (maths). For example, an arrow in PNG would consist of many, many squares lined up together in the shape of an arrow, while in SVG, it could consist of a line starting from some coordinates (x1, y1), then turning and turning until the shape of the arrow head is complete.

Both types of graphics have their use cases. Adobe has a good introductory explainer on raster graphics vs vector graphics.

(PS: all of the illustrations in this article are SVGs. If you try zooming into them, you'll see that they don't get pixelated.)

This article will demonstrate SVGs by constructing an arrow in HTML/JavaScript.

Drawing with geometry

SVG shapes are defined based on geometry. When you define an <svg> element, you get an invisible Cartesian plane; there are x and y axes, and all points you will draw in this SVG are specified relative to an origin. Note that by default, x increases to the right as normal, but y increases downwards, and the origin is at the top left. I've made a grid to illustrate this:

xy5010015020025030050100150200250300

For instance, to draw a circle, we specify the coordinates of its centre (cx and cy), and the value of its radius; for a line, we specify its start (x1, y1) and end (x2, y2) coordinates; for a rectangle, start coordinates (x, y), width and height:

<svg width="300" height="300" viewBox="0 0 300 300">
  <circle cx="150" cy="100" r="50" 
    style="fill: none; stroke: red; stroke-width: 2px;">
  </circle>
  <line x1="50" y1="50" x2="200" y2="200" 
    style="fill: none; stroke: black; stroke-width: 2px;">
  </line>
  <rect x="100" y="150" width="50" height="75"
    style="fill: none; stroke: green; stroke-width: 2px;">
  </rect>
</svg>
Enter fullscreen mode Exit fullscreen mode

xy5010015020025030050100150200250300

(Note that the grid lines and axes are not automatically added; these are separate SVG lines I added to make things clearer.)

You may have noticed the style attributes; SVGs can be styled like regular HTML elements, with inline CSS, classes, and selectors.

We've used <circle>, <rect> and <line>, but at the basic level, you can construct any shape by describing its path (the <path> element). Here's one way to draw the same shapes as above:

<svg width="300" height="300" viewBox="0 0 300 300">
  <!-- line -->
  <path d="M 50,50 L 200,200"
    style="fill: none; stroke: black; stroke-width: 2px;">
  </path>

  <!-- rectangle -->
  <path d="M 100,150 l 50,0 l 0,75 l -50,0 Z"
    style="fill: none; stroke: green; stroke-width: 2px;">
  </path>

  <!-- circle -->
  <path d="M 100,100 A 50 50 0 0 1 200,100 A 50 50 0 0 1 100,100"
    style="fill: none; stroke: red; stroke-width: 2px;">
  </path>
</svg>
Enter fullscreen mode Exit fullscreen mode

xy5010015020025030050100150200250300

The d attribute has specific syntax, which I like to think is for instructing the "artist" on how to move their pen.

  • For the line: M 50,50 L 200,200 says "**Move your pen to x1=50,y1=50, then draw a straight **Line until you get to x2=200,y2=200**".
  • For the rectangle, M 100,150 l 50,0 l 0,75 l -50,0 Z draws a line from corner to corner: "**Move your pen to x1=100,y1=150, then draw a straight **line until you get to x2=x1 + **50,y2=y1 + **0, then another **line until x3=x2 + **0,y3=y2 + **75, then another **line until x4=x3 *- 50*,y4=y3 + **0, then connect back to the start point (Z)".
  • For the circle, M 100,100 A 50 50 0 0 1 200,100 A 50 50 0 0 1 100,100 moves to 100,100, and then draws one arc (A) with a radius of 50 until it gets to 200,100. Then from that point, it draws another arc with the same radius to connect to the start point. (I won't explain the Arc syntax in detail because it's fairly complex.)

I like this article for a quick explainer on the syntax, and of course MDN for a full reference.

<path> is useful when you need to draw an irregular shape. It can get super complex, though; for regular images, you'll probably use some graphic design software that generates it for you. You'll likely only need to work with paths directly when animating something or making some custom graphic element, although you may want to use the Canvas API instead.

Manipulating SVGs with JavaScript

There's no special SVG JavaScript API; we must use the DOM APIs, similar to other HTML elements. An important difference is that you need to use document.createElementNS() instead of document.createElement(), otherwise the elements won't be rendered.

For instance, here's a small class for this:

class Svg {
  static NAMESPACE_URI = 'http://www.w3.org/2000/svg';

  constructor(domElementId, width, height) {
    this.$svg = document.createElementNS(Svg.NAMESPACE_URI, 'svg');
    this.$svg.setAttribute('xmlns', Svg.NAMESPACE_URI);
    this.$svg.setAttribute('width', width);
    this.$svg.setAttribute('height', height);
    this.$svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
    document.querySelector(`#${domElementId}`).appendChild(this.$svg);
  }

  add(elementType, attributes = {}, styles = {}) {
    let $element = document.createElementNS(Svg.NAMESPACE_URI, elementType);
    Object.entries(attributes).forEach(([k, v]) => {
      $element.setAttribute(k, v);
    });
    Object.entries(styles).forEach(([k, v]) => {
      $element.style[k] = v;
    });
    this.$svg.appendChild($element);
    return $element;
  }
}
Enter fullscreen mode Exit fullscreen mode
<div id="container"></div>

<script>
let root = new Svg(`container`, 300, 300);
root.add(`path`,
  {d: `M 50,50 L 200,200`},
  {strokeWidth: `2px`, stroke: `green`}
);
root.add(`circle`,
  {cx: `150`, cy: `100`, r: `50`},
  {strokeWidth: `2px`, stroke: `red`, fill: 'none'}
)
</script>
Enter fullscreen mode Exit fullscreen mode

For a full-featured API, you can use a library such as Snap.svg or SVG.js.

Constructing an arrowhead

To add an arrowhead to this line, let's go with the <path> approach. Think about the shape of an arrowhead for a moment. How do you move your pen when drawing this?

Tip: There's an easier way to draw an arrowhead in SVG (skip straight to the "marker" section). But if you want to have fun with the maths or go deeper, enjoy!

To draw an arrowhead as a path, we need four things: the coordinates of the starting point P, and those of the corner points Q, R, and S. Then our SVG path will be (assuming the pen is already at P): L Qx,Qy L Rx,Ry L Sx,Sy L Px,Py. However, we only know the coordinates of the starting point P.

We also know the vertical height of the arrowhead, h, and its base, b, because an arrowhead is an isosceles triangle, and we can pick whatever height and base we want to make it look good.

So let's do a little trigonometry to find the unknowns. For each unknown point, we will calculate the "delta" (Δ) of its coordinates (the distance from P in x and y).

To find R, we can use the angle between line PR and the horizontal.

This is a right-angled triangle, so this gives us \Delta R_x = h \cos \theta and \Delta R_y = h \sin \theta. But the second diagram shows that the angle \theta is also present in the smaller P-triangle (vertically opposite angles are equal), so \tan \theta = \frac{\Delta P_y}{\Delta P_x}.

So we have

\theta = \tan^{-1} \frac{\Delta P_y}{\Delta P_x}

\Delta R_x = h \cos \theta

\Delta R_y = h \sin \theta

This gives R's coordinates in terms of P's (R_x = P_x + \Delta R_x and R_y = P_y + \Delta R_y).

To find Q, we take the angle \alpha between the arrowhead's base and the horizontal, which gives us \Delta Q_x = b \cos \alpha and \Delta Q_y = b \sin \alpha

Now, this angle adds up with \theta to make 90° (or π/2 radians), so we have that:

\Delta Q_x = b \cos (\frac{\pi}{2} - \theta)

\Delta Q_y = b \sin (\frac{\pi}{2} - \theta)

And Q is:

Q_x = P_x - \Delta Q_x

Q_y = P_y + \Delta Q_y

We subtract the x-delta, since the line \overrightarrow{\rm PQ} is going to the left (x is decreasing).

S is the reflection of Q, so it has the same deltas as Q, but in the opposite direction. For S, the y-delta is negative, since \overrightarrow{\rm PS} is going downwards (decreasing y).

S_x = P_x + \Delta Q_x

S_y = P_y - \Delta Q_y

And here's all this as a JavaScript function (with h = 10, b = 5):

function makeArrow(destination, start = { x: 0, y: 0 }) {
  let height = 10;
  let base = 5;

  let P = destination;

  // I'd normally write this out as "theta", but JavaScript supports Unicode identifiers, nice 😎
  let θ = Math.atan((destination.y - start.y)/(destination.x - start.x));
  let α = Math.PI / 2 - θ;

  let ΔQ = { x: base * Math.cos(α), y: base * Math.sin(α) }
  let Q = { x: P.x - ΔQ.x, y: P.y + ΔQ.y }
  let S = { x: P.x + ΔQ.x, y: P.y - ΔQ.y }

  let ΔR = { x: height * Math.cos(θ), y: height * Math.sin(θ) }
  let R = { x: P.x + ΔR.x, y: P.y + ΔR.y }

  root.add(`path`,
    { d: `M ${start.x},${start.y} L ${P.x},${P.y} L ${Q.x},${Q.y} L ${R.x},${R.y} L ${S.x},${S.y} L ${P.x},${P.y}` },
    { strokeWidth: `2px`, stroke: `black` }
  );
}

makeArrow({x: 100, y: 200});
Enter fullscreen mode Exit fullscreen mode

Here's an interactive playground. You can edit the values to see how this arrow behaves. Can you spot its limitations?

xy5010015020025030050100150200250300

Arrow end (x, y):

Arrow start (x, y):

Two things you may have noticed:

  1. The arrowhead doesn't handle rotation correctly. For instance, makeArrow({ x: 30, y: 0 }, {x: 100, y: 200}) produces an inverted arrowhead.
  2. A minor annoyance is that the arrowhead actually points beyond the destination. For instance an arrow to (100, 200) will have its arrowhead a few units past (100,200).

We can adapt the calculations to account for these scenarios, but that's enough maths for today. Instead, we'll switch to using the <marker> element, which can automatically handle these issues for us.

Using <marker>

The good news is that SVG already has an element for this. The <marker> element is meant for placing "markers" on a shape. For instance:

  • arrowheads (duh) or some other shape you want to put at the end of a line
  • path breakpoints
  • resize handles on an element
  • drag-and-drop handles

The marker approach builds on the "manual" maths we've already done, but fixes these two limitations:

  1. By setting orient="auto-start-reverse" on the marker, the arrowhead will be rotated appropriately to match the line's placement.
  2. By setting marker-end={url-to-the-marker} on the line, the arrowhead will end exactly at our destination.

The marker approach comprises a few things: first, a <marker> element which has an id. We set orient="auto-start-reverse", and set the markerHeight and markerWidth to our triangle height (10) and 2 * base (5), respectively.

<marker
  markerWidth="10" markerHeight="10" orient="auto-start-reverse" 
  id="arrowhead" style="stroke-width: 2px; stroke: black;">

</marker>
Enter fullscreen mode Exit fullscreen mode

Within the <marker> element, we add a nested <path> describing the arrowhead (only the triangle, not the rest of the arrow). We don't have to use <path>, by the way; we could also use three <line>s.

<marker ...>
   <path d="...something..."></path>
</marker>
Enter fullscreen mode Exit fullscreen mode

How do we determine d? Well, we can use the same values we calculated earlier, but we don't need to. One benefit of <marker> is that its location on the grid doesn't matter. It can even be in another file; it will be rotated and rendered properly for any element it's attached to. This means our path does not need to depend on the actual coordinates of the line (P, Q, R, S). Instead, we can pretend P is at (0, 0) and we're facing upwards. This allows us to directly use the base and height as our deltas, giving us:

Q_x = P_x - b = -b

Q_y = P_y + 0 = 0

R_x = P_x + 0 = 0

R_y = P_y + h = h

S_x = P_x + b = b

S_y = P_y - 0 = 0

And so we have the arrowhead path, which will remain the same regardless of the line:

<marker ...>
   <path d="M 0,0 L -5,0 L 0,10 L 5,0 Z"></path>
</marker>
Enter fullscreen mode Exit fullscreen mode

Finally, a <line> for the rest of the arrow as usual, setting its marker-end to the id of the marker:

<line 
  x1="0" y1="0" x2="100" y2="200"
  marker-end="url(#arrowhead)" 
  style="stroke-width: 2px; stroke: black;">
</line>
Enter fullscreen mode Exit fullscreen mode

Markers are reusable elements; we can draw many different lines which have the same marker, and it will be rendered and appropriately rotated for each. You can also set marker-start instead (or both), if you wish to have the arrow on the other end.

Porting this to JavaScript:


let height = 10;
let base = 5;

let $marker = root.add(`marker`,
  {
    markerWidth: base * 2,
    markerHeight: height,
    orient: `auto-start-reverse`,
    id: 'arrowhead'
  },
  {strokeWidth: `2px`, stroke: `black`}
);

let $path = document.createElementNS(Svg.NAMESPACE_URI, 'path');
$path.setAttribute(`d`, `M 0,0 L -${base},0 L 0,${height} L ${base},0 Z`)
$marker.appendChild($path);

function makeArrowWithMarker(destination, start = {x: 0, y: 0}) {
  root.add(`line`,
    {
      x1: start.x,
      y1: start.y,
      x2: destination.x,
      y2: destination.y,
      'marker-end': `url(#${$marker.id})`,
    },
    { strokeWidth: `2px`, stroke: `black` }
  );
}

makeArrowWithMarker({x: 100, y: 200});
Enter fullscreen mode Exit fullscreen mode

And finally, here it is in action:

xy5010015020025030050100150200250300

Arrow end (x, y):

Arrow start (x, y):

Further reading

Top comments (0)