How to draw beautiful connections with SVG

We are making a system for simulating various processes, in which the user, using visual programming, can describe and see how a particular process works. In other words, to check how certain cause-and-effect relationships affect the result of the process. The whole system is built on nodes – visual representations of functions that receive, process, display and eventually send data to the following nodes.

Describing the process of hiring an employee using such nodes

Only two options come to my mind, how it would be possible to present the connections between the nodes so that it is clear and beautiful:

  1. Broken lines with right angles as in UML diagrams. This type of join is good when we need to show clear hierarchies and relationships between the objects being joined, for which it often doesn’t matter where the join comes from. In the real world, this might look like a pipeline, with various branches and intersections, that connects the tanks.

  2. Smooth curves like using Nodes in UE4 or Shader Nodes in Blender. They clearly show not only the relationship between objects, but also their interaction, and also define specific inputs and outputs for different data. In turn, these connections can be thought of as wires in an analog modular synthesizer that connect sound generators and multiple filters together to produce a unique sound.

The second option looks ideal for solving the problem – our nodes may not have a clear structure and hierarchy, they may have several inputs and outputs, but interactions between them are strictly limited by the types of input and output data. How beautiful to draw these connections?

Implementation

Since our application does not use canvas, the solution must also use the DOM to render connections. The first candidate for drawing curves is <path /> in SVG.

Below is a conditional representation of what the main space in which work takes place looks like:

<div class="container">
	<div class="nodes-container">
		<!--...-->
	</div>
</div>
.container {
  position: absolute;
  width: 100%;
  height: 100%;
  top: 0px;
  left: 0px;
  overflow: hidden;
}

.nodes-container {
  position: absolute;
  top: 0px;
  left: 0px;
  width: 100%;
  height: 100%;
  transform-origin: left top;
  /* 
    Задается динамически, но здесь и далее для удобства 
    будут использованы эти значения 
  */
  transform: translate(640px, 360px) scale(0.5);
}

Let’s place <svg /> in the DOM above nodes-container so that it renders first and sits below. We will also throw some styles on it so that it occupies all the space and does not intercept events, and inside we wrap all connections in <g /> for synchronization transform With .nodes-container.

<div class="container">
	<svg class="connections-container">
		<g transform="translate(640, 360) scale(0.5)">
			<!--...-->
		</g>
	</svg>
	<div class="nodes-container">
		<!--...-->
	</div>
</div>
.container {
  /* ... */
}

.nodes-container {
  /* ... */
}

.connections-container {
  pointer-events: none;
  overflow: hidden;
  position: absolute;
  width: 100%;
  height: 100%;
  transform-origin: left top;
}

This completes the preparation and you can proceed to drawing the connections themselves. First, let’s connect the ports with straight lines to understand their positioning. At the element <path/> there is an attribute dA that describes the shape’s geometry. For a straight line, two commands are enough – “Move to” – M and “Line to” – L. The first indicates the point from which the drawing of the figure begins, the second – draws a line to the next point. Both commands have the following syntax:

M x, y
L x, y

We know the port centers in the format {x, y}so to connect the dots {x: 20, y: 60} And { x: 45, y: 90 } expression d will look like:

M 20, 60 L 45, 90

<path /> we need to add a few more properties to avoid filling the shape, as well as specify the color and thickness of the line itself:

<path 
  d="M 20 60 L 45 90" 
  fill="transparent"
  stroke="rgba(0, 0, 0, 0.2)"
  stroke-width="1"
></path>

Now it’s time to add beauty and make natural bending the resulting lines for cases where the ports are at different heights. To do this, we will use a bunch of two quadratic Bezier curves. As a result, we should get a curve that will resemble the letter S in shape, since node ports can be located on the left and right, but not above or below. A quadratic bezier curve is defined by three control points P₀ (initial), P₁ (control) And P₂ (final), and its equation is as follows:

To display such a curve in d, use the Q command with arguments P₁ And P₂. In turn, the point P₀ is determined by the previous command of the expression d, which in our case is M , indicating the point of the beginning of the figure. Thus, half of the required line is obtained.

M x0, y0 Q x1, y1 x2, y2

In order to draw the second half – the same curve reflected horizontally, it is enough to use the T command. This command takes only one point as an argument P₂ for the equation. P₀ for it is the end point of the preceding curve, and P₁ calculated as a reflection of the previous control point relative to the current one P₀. In other words, the line continues as a reflection of the previous Bezier curve up to the specified point.

M x0, y0 Q x1, y1 x2, y2 T x3, y3

Let’s write a function to generate the necessary expression d. We know the points {x0, y0} And {x3, y3} are the coordinates of the exit and entry ports. Dot {x2, y2} – will be the center of the line between these two points.

type Point = {
  x: number,
  y: number
};

function calculatePath(start: Point, end: Point) {
  const center = {
    x: (start.x + end.x) / 2,
    y: (start.y + end.y) / 2,
  };

  return `
    M ${start.x},${start.y} 
    Q x1, y1 ${center.x},${center.y} 
    T ${end.x},${end.y}
  `;
}

It remains to calculate the control point {x1, y1}. To do this, we will shift the start point of the line along the axis X. The original y must be left so that at the entry and exit points the line tends to a horizontal position. To calculate the displacement, we take the minimum from the distance between the points start And endhalf the distance along the axis Yas well as a limit of 150 to avoid excessive stretching of the curve at large distances of nodes from each other.

type Point = {
  x: number,
  y: number
}

function distance(start: Point, end: Point)
{
  const dx = to.x - from.x
  const dy = to.y - from.y

  return Math.sqrt(dx * dx + dy * dy)
}

function calculatePath(start: Point, end: Point) {
	const center = {
      x: (start.x + end.x) / 2,
      y: (start.y + end.y) / 2,
	}

	const controlPoint = {
      x: start.x + Math.min(
          distance(start, end),
          Math.abs(end.y - start.y) / 2,
          150
      ),
      y: start.y,
	};

	return `
      M ${start.x},${start.y} 
      Q ${controlPoint.x}, ${controlPoint.y} ${center.x},${center.y} 
      T ${end.x},${end.y}
    `;
}

With this calculation of the control point, if the ports are at the same height, the line will be straight, but will curve in proportion to the distance of the nodes from each other.

Beauty!

Conclusion

This method of drawing a connection is valid for nodes whose ports are located on opposite sides. However, for ports located on the same side, you can use cubic bezier curves by adding the same second control point calculation that will use the offset from the end point.

Thank you for reading this article, I hope you enjoyed it!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *