At Flode, we are building a system for simulating various processes, in which the user can describe and see how a particular process works using visual programming. In other words, to check how the result of the process is affected by certain cause-and-effect relationships. The entire system is based on nodes - visual representations of functions that receive, process, display, and ultimately send data to the next nodes.
I can think of only two ways to represent the connections between nodes so that it is clear and beautiful:
Lines with straight angles like in UML diagrams. Such type of connections is good when we need to show clear hierarchies and relationships between connecting objects, for which, often, there is no difference where this connection comes from. In the real world, this could resemble a pipeline, with various branches and intersections, which connects reservoirs.
Smooth curves, such as those used by Nodes in UE4 or Shader Nodes in Blender. They visually show not only the relationships between objects, but also their interaction, and also define specific inputs and outputs for different data. In turn, these connections can be represented as wires in an analog modular synthesizer, which connect sound generators and a multitude of filters to extract a unique sound.
The second option looks perfect for solving the problem - our nodes may not have a clear structure and hierarchy, they may have several inputs and outputs, but between them, the interactions of input and output data types are strictly limited. How can we draw these connections?
Implementation
Since our application does not use canvas, the solution should also use DOM capabilities to display connections. The first candidate for drawing curves is <path />
in SVG.
Below is an example of what the main workspace 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;
/* Set dynamically, but here and further for convenience these values will be used */
transform: translate(640px, 360px) scale(0.5);
}
Place <svg />
in the DOM above nodes-container
so that it is rendered first and is located below. Also, add some styles to it so that it takes up all the space and does not intercept events, and wrap all connections in <g />
inside to synchronize 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;
}
The preparation is complete, and we can move on to drawing the connections themselves. To begin with, let's connect the ports with straight lines to figure out their positioning. The <path/>
element has a d
attribute that describes the geometry of the shape. For a straight line, only two commands are sufficient - “Move to” - M
and “Line to” - L
. The first specifies the point from which the shape drawing starts, and the second draws a line to the next point. Both commands have the following syntax:
M x, y
L x, y
We know the centers of the ports in the { x, y }
format, so for connecting the points { x: 20, y: 60 }
and { x: 45, y: 90 }
, the d
expression would look like this:
M 20, 60 L 45, 90
The <path />
element needs to add a few more properties to prevent the figure from filling, 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 add a natural curve to the resulting lines for cases where the node ports are at different heights. To do this, we will use a combination of two quadratic Bezier curves. As a result, we should get a curve that resembles the letter S in shape, as the node ports can be on the left and right, but not on top or bottom. A quadratic Bezier curve is defined by three control points P₀ (initial), P₁ (control) and P₂ (final), and its equation looks like this:
The Q
command with arguments P₁ and P₂ is used to display such a curve in d
. In turn, the point P₀ is determined by the previous command of the d
expression, which in our case is M
, indicating the starting point of the shape. Thus, half of the required line is obtained.
M x0, y0 Q x1, y1 x2, y2
To draw the second half - the same curve reflected horizontally, just use the T
command. This command takes only one point P₂ as an argument for the equation. P₀ for it is the end point of the previous curve, and P₁ is calculated as a reflection of the previous control point relative to the current P₀. In other words, the line continues as a reflection of the previous Bezier curve to the specified point.
M x0, y0 Q x1, y1 x2, y2 T x3, y3
Let's write a function to generate the required d
expression. We know the points { x0, y0 }
and { x3, y3 }
- these are the coordinates of the output and input ports. The point { 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 move the line start point along the X axis. The initial y
must be left to ensure that the line for input and output points tends to a horizontal position. To calculate the offset, we take the minimum of the distance between the start
and end
points, half the distance along the Y axis, and limitations of 150 to avoid excessive stretching of the curve at large distances between nodes.
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, when the ports are at the same height, the line will be straight, but it will bend proportionally to the distance between the nodes when they are separated.
Conclusion
This way of drawing connections is fair for nodes whose ports are located on opposite sides. However, for ports located on the same side, cubic Bezier curves can be used, adding the same calculation of the second control point, which will use the offset from the end.
Thank you for reading this article, I hope you enjoyed!
Top comments (4)
Сongratulations 🥳! Your article hit the top posts for the week - dev.to/fruntend/top-10-posts-for-f...
Keep it up 👍
Amazing!
Good job!
I am workig on similar UI tasks - dgrm.net.
May be we can collaborate. I can't send you private message. Drop me a message, my contacts are in GitHub.
Great tutorial!