forsooth!

By necessity, by proclivity, and by delight, we all quote.

Partial Bézier Curves


Motivation

I recently worked on some visualization with d3, rendering a directed graph of nodes with a force simulation. An easy way to render the edges of the graph is to simply draw a straight line, but that’s not very pretty. Instead, a neat trick is to introduce an invisible node inbetween two connected nodes, and then render a quadratic Bézier curve between the nodes, using the invisible node as control point.

This example shows the curved link between two nodes, without force simulation, the nodes can be dragged around interactively. The nodes are transparent to illustrate the actual ends of the curve.

The relevant code for rendering the path of the edges in d3:

var edgeElements = edgeLayer
    .selectAll(".edge")
    .data(edges);

edgeElements.enter()
    .append("path")
    .attr("class", "edge")
    .attr("stroke", "#ddd")
    .attr("stroke-width", 5);

// on update
edgeLayer
    .selectAll(".edge")
    .attr("d", e => {
        return "M" + e.source.x + "," + e.source.y
            + "Q" + e.intermediate.x + "," + e.intermediate.y
            + " " + e.target.x + "," + e.target.y;
    });

(See SVG:path docs for explanation of the M and Q commands.)

This connects the centers of each node, which is great for undirected graphs, but not very useful for directed graphs. Where and how should the arrow be rendered? And how can the curve be rendered only from node edge to node edge? This is particularly important if the nodes are transparent, as above, or have non-circular shapes, for instance based on an SVG pattern fill, a logo, etc..

Solution

Finding the touching points

The first step must be finding the touching points of the curve with the rim of the node. The “rim” will be assumed based on the radius of the node, even if the node itself might have an irregular shape, but since I want a little spacing between node and curve anyway, this is sufficient.

Wikipedia has a more thorough explanation of Bézier curves, but for what I’m doing I need this function:

\[ B\colon [0, 1] \Rightarrow \mathbb{R}^2 \] \[ B(t) \, = \, (1 - t)^2 P_0 + 2 (1 - t) t P_1 + t^2 P_2 \]

Where \(P_0\) and \(P_2\) are the end points of the curve and \(P_1\) is the control point. It’s easy to see that \(B(0) = P_0\) and \(B(1) = P_2\).

Let’s assume the radius of our node around \(P_0\) is \(r\), then we need to find the smallest \(t\), such that \(|B(t) - P_0| = r\), i.e. the \(t\) at which the curve is at a distance of \(r\) from \(P_0\) for the first time. In most curves there’ll only be one such point, but just in case the nodes are very close together, perhaps even overlapping, the first one is the point we want. The same can be done from the end.

I tried to solve this mathematically, but even for quadratic Bézier curves this grows into big expressions and polynomials of 4th degree, so I went with a binary search for a close approximation instead:

function calculateBezierPoint(P0, P1, P2, t) {
    return {
        x: (1 - t) * (1 - t) * P0.x + 2 * (1 - t) * t * P1.x + t * t * P2.x,
        y: (1 - t) * (1 - t) * P0.y + 2 * (1 - t) * t * P1.y + t * t * P2.y
    };
}

function findProximityPoint(P0, P1, P2, r) {
    var rSquared = r * r;

    var dt = 0.05;
    var t = dt;
    var lastWasLower = true;

    while (t >= 0 && t <= 1) {
        var p = calculateBezierPoint(P0, P1, P2, t);
        p.t = t;

        var distSquared = (p.x - P0.x) * (p.x - P0.x) +
                          (p.y - P0.y) * (p.y - P0.y);
        if (lastWasLower && distSquared > rSquared) {
            dt /= -2;
            lastWasLower = false;
        } else if (!lastWasLower && distSquared <= rSquared) {
            dt /= -2;
            lastWasLower = true;
        }

        if (Math.abs(dt) < 0.001 && distSquared > rSquared) {
            return p;
        }

        t += dt;
    }

    return {
        x: P0.x,
        y: P0.y,
        t: 0
    };
}

And the relevant code in d3 to render the path of the edge, which now first calculates the points on the curve close to the node edges and then uses those points as start and end of the curve:

edgeLayer
    .selectAll(".edge")
    .attr("d", e => {
        var start = findProximityPoint(e.source, e.intermediate, e.target, e.source.radius + 2);
        var end = findProximityPoint(e.target, e.intermediate, e.source, e.target.radius + 2);

        return "M" + start.x + "," + start.y
            + "Q" + e.intermediate.x + "," + e.intermediate.y
            + " " + end.x + "," + end.y;
    });

This works quite well, but it isn’t perfect. Since we use the same control point with different start points, the curve differs quite a bit. The ends also don’t necessarily point towards the center of the nodes anymore, which you can easily see by moving the control point around one of the points.

Adding an arrow

To illustrate this, below the same example with the original edge included, and including an arrow marker to better see where it points. For that the end’s proximity point must be pushed out a bit further (by the length of the arrow) and the arrow is achieved like this:

var defs = svg.append('svg:defs');
var arrowMarker = defs.append("marker")
    .attr("id", "arrow")
    .attr("markerWidth", 10)
    .attr("markerHeight", 10)
    .attr("refX", 4)
    .attr("refY", 3)
    .attr("orient", "auto")
    .attr("markerUnits", "strokeWidth");
arrowMarker.append("path")
    .attr("d", "M1,3 L0,5 L6,3 L0,1 z")
    .attr("fill", "#ddd");

// applying it to the edges

var edgeElements = edgeLayer
    .selectAll(".edge")
    .data(edges);

edgeElements.enter()
    .append("path")
    .attr("class", "edge")
    .attr("stroke", "#ddd")
    .attr("stroke-width", 5)
    .attr("marker-end", "url(#arrow)");

Construct a partial curve

Ideally the curve between the two touching points follows the original curve. We know the values \(t_{start}\) and \(t_{end}\) for which \(B(t_{start}) = P_{start}\) and \(B(t_{end}) = P_{end}\), so \(B(t)\) could simply be rendered for \(t \in [t_{start}, t_{end}]\) instead of \(t \in [0, 1]\). Unfortunately SVG’s path commands don’t support such a feature.

To achieve the effect nonetheless, we first define a new function \(C\) which follows \(B\) along the relevant interval, but itself using the domain \([0, 1]\):

\[ C\colon [0, 1] \Rightarrow \mathbb{R}^2 \] \[ C(t) = B(t_{start} + (t_{end} - t_{start}) t) \]

We also know the start and end points:

\[ P_{start} = C(0) = B(t_{start}) \] \[ P_{end} = C(1) = B(t_{end}) \]

Finally, we want \(C\) to be a Bézier curve, so it must have the same structure. Calling the control point \(X\) we have:

\[ C(t) \, = \, (1 - t)^2 P_{start} + 2 (1 - t) t X + t^2 P_{end} \]

Putting it all together it is: \[ (1 - t)^2 P_{start} + 2 (1 - t) t X + t^2 P_{end} \, = \, \] \[ (1 - (t_{start} + (t_{end} - t_{start}) t))^2 P_0 + \] \[ 2 (1 - (t_{start} + (t_{end} - t_{start}) t)) (t_{start} + \] \[(t_{end} - t_{start}) t) P_1 + (t_{start} + (t_{end} - t_{start}) t)^2 P_2 \]

which means

\[ (1 - t)^2 ((1 - t_{start})^2 P_0 + 2 (1 - t_{start}) t_{start} P_1 + t_{start}^2 P_2) + \] \[ 2 (1 - t) t X + \] \[ t^2 ((1 - t_{end})^2 P_0 + 2 (1 - t_{end}) t_{end} P_1 + t_{end}^2 P_2) \, = \, \] \[ (1 - (t_{start} + (t_{end} - t_{start}) t))^2 P_0 + \] \[ 2 (1 - (t_{start} + (t_{end} - t_{start}) t)) (t_{start} + \] \[(t_{end} - t_{start}) t) P_1 + (t_{start} + (t_{end} - t_{start}) t)^2 P_2 \]

and sympy helps us with that, yielding:

\[ X \, = \, t_{start} t_{end} (P_0 - 2 P_1 + P_2) + (1 - t_{start} - t_{end}) P_0 + (t_{start} + t_{end}) P_1 \]

So finally we can construct a new Bézier curve from \(P_{start}\) to \(P_{end}\), using control point \(X\). In code it looks like this:

edgeLayer
    .selectAll(".edge")
    .attr("d", e => {
        var start = findProximityPoint(e.source, e.intermediate, e.target, e.source.radius + 2);
        var end = findProximityPoint(e.target, e.intermediate, e.source, e.target.radius + 10);
        var P0 = e.source;
        var P1 = e.intermediate;
        var P2 = e.target;
        var tStart = start.t;
        var tEnd = 1 - end.t;

        var adjustedIntermediate = {
            x: tStart * tEnd * (P0.x - 2 * P1.x + P2.x) + (1 - tStart - tEnd) * P0.x + (tStart + tEnd) * P1.x,
            y: tStart * tEnd * (P0.y - 2 * P1.y + P2.y) + (1 - tStart - tEnd) * P0.y + (tStart + tEnd) * P1.y,
        };

        return "M" + start.x + "," + start.y
            + "Q" + adjustedIntermediate.x + "," + adjustedIntermediate.y
            + " " + end.x + "," + end.y;
    });

For the final result (the wrongly cropped curve included for comparison):

#+TITLE: Partial Bézier Curves
#+DATE: <2019-04-22 Mon>
#+AUTHOR: @or
#+CATEGORY: maths
#+SUMMARY: Render partial quadratic Bézier curves
#+SLUG: partial-bezier-curves
#+TAGS: d3, svg, maths, visualization
#+JS: MathJax-2.6.1/MathJax.js?config=TeX-AMS_CHTML (top), d3.min.js (top), partial-bezier-curves.js (bottom)

# #+begin_export html
# <script>
# MathJax.Hub.Config({
#     displayAlign: "left"
# });
# </script>
# #+end_export

** Motivation
I recently worked on some visualization with =d3=, rendering a directed graph of
nodes with a force simulation. An easy way to render the edges of the graph is
to simply draw a straight line, but that's not very pretty. Instead, a neat
trick is to introduce an invisible node inbetween two connected nodes, and then
render a *quadratic* Bézier curve between the nodes, using the invisible node as
control point.

This example shows the curved link between two nodes, without force simulation,
the nodes can be dragged around interactively. The nodes are transparent to
illustrate the actual ends of the curve.

#+HTML: <div id="graph-1" style="border: 1px solid #eee"></div>

The relevant code for rendering the path of the edges in =d3=:
#+begin_src js

var edgeElements = edgeLayer
    .selectAll(".edge")
    .data(edges);

edgeElements.enter()
    .append("path")
    .attr("class", "edge")
    .attr("stroke", "#ddd")
    .attr("stroke-width", 5);

// on update
edgeLayer
    .selectAll(".edge")
    .attr("d", e => {
        return "M" + e.source.x + "," + e.source.y
            + "Q" + e.intermediate.x + "," + e.intermediate.y
            + " " + e.target.x + "," + e.target.y;
    });
#+end_src
(See [[https://www.w3.org/TR/SVG/paths.html][SVG:path docs]] for explanation of the =M= and =Q= commands.)

This connects the centers of each node, which is great for undirected graphs,
but not very useful for directed graphs. Where and how should the arrow be
rendered? And how can the curve be rendered only from node edge to node edge?
This is particularly important if the nodes are transparent, as above, or have
non-circular shapes, for instance based on an SVG pattern fill, a logo, etc..

** Solution
*** Finding the touching points
The first step must be finding the touching points of the curve with the rim of
the node. The "rim" will be assumed based on the radius of the node, even if
the node itself might have an irregular shape, but since I want a little spacing
between node and curve anyway, this is sufficient.

Wikipedia has a more thorough explanation of [[https://en.wikipedia.org/wiki/B%C3%A9zier_curve][Bézier curves]], but for what I'm
doing I need this function:

$$ B\colon [0, 1] \Rightarrow \mathbb{R}^2 $$
$$ B(t) \, = \, (1 - t)^2 P_0 + 2 (1 - t) t P_1 + t^2 P_2 $$

Where $P_0$ and $P_2$ are the end points of the curve and $P_1$ is the control
point. It's easy to see that $B(0) = P_0$ and $B(1) = P_2$.

Let's assume the radius of our node around $P_0$ is $r$, then we need to find
the smallest $t$, such that $|B(t) - P_0| = r$, i.e. the $t$ at which the curve
is at a distance of $r$ from $P_0$ for the first time. In most curves there'll
only be one such point, but just in case the nodes are very close together,
perhaps even overlapping, the first one is the point we want. The same can be
done from the end.

I tried to solve this mathematically, but even for quadratic Bézier curves this
grows into big expressions and polynomials of 4th degree, so I went with a
binary search for a close approximation instead:

#+begin_src js
function calculateBezierPoint(P0, P1, P2, t) {
    return {
        x: (1 - t) * (1 - t) * P0.x + 2 * (1 - t) * t * P1.x + t * t * P2.x,
        y: (1 - t) * (1 - t) * P0.y + 2 * (1 - t) * t * P1.y + t * t * P2.y
    };
}

function findProximityPoint(P0, P1, P2, r) {
    var rSquared = r * r;

    var dt = 0.05;
    var t = dt;
    var lastWasLower = true;

    while (t >= 0 && t <= 1) {
        var p = calculateBezierPoint(P0, P1, P2, t);
        p.t = t;

        var distSquared = (p.x - P0.x) * (p.x - P0.x) +
                          (p.y - P0.y) * (p.y - P0.y);
        if (lastWasLower && distSquared > rSquared) {
            dt /= -2;
            lastWasLower = false;
        } else if (!lastWasLower && distSquared <= rSquared) {
            dt /= -2;
            lastWasLower = true;
        }

        if (Math.abs(dt) < 0.001 && distSquared > rSquared) {
            return p;
        }

        t += dt;
    }

    return {
        x: P0.x,
        y: P0.y,
        t: 0
    };
}
#+end_src

And the relevant code in =d3= to render the path of the edge, which now first
calculates the points on the curve close to the node edges and then uses those
points as start and end of the curve:
#+begin_src js

edgeLayer
    .selectAll(".edge")
    .attr("d", e => {
        var start = findProximityPoint(e.source, e.intermediate, e.target, e.source.radius + 2);
        var end = findProximityPoint(e.target, e.intermediate, e.source, e.target.radius + 2);

        return "M" + start.x + "," + start.y
            + "Q" + e.intermediate.x + "," + e.intermediate.y
            + " " + end.x + "," + end.y;
    });
#+end_src

#+HTML: <div id="graph-2" style="border: 1px solid #eee"></div>

This works quite well, but it isn't perfect. Since we use the same control
point with different start points, the curve differs quite a bit. The ends also
don't necessarily point towards the center of the nodes anymore, which you can
easily see by moving the control point around one of the points.

*** Adding an arrow
To illustrate this, below the same example with the original edge included, and
including an arrow marker to better see where it points. For that the end's
proximity point must be pushed out a bit further (by the length of the arrow)
and the arrow is achieved like this:
#+begin_src js
var defs = svg.append('svg:defs');
var arrowMarker = defs.append("marker")
    .attr("id", "arrow")
    .attr("markerWidth", 10)
    .attr("markerHeight", 10)
    .attr("refX", 4)
    .attr("refY", 3)
    .attr("orient", "auto")
    .attr("markerUnits", "strokeWidth");
arrowMarker.append("path")
    .attr("d", "M1,3 L0,5 L6,3 L0,1 z")
    .attr("fill", "#ddd");

// applying it to the edges

var edgeElements = edgeLayer
    .selectAll(".edge")
    .data(edges);

edgeElements.enter()
    .append("path")
    .attr("class", "edge")
    .attr("stroke", "#ddd")
    .attr("stroke-width", 5)
    .attr("marker-end", "url(#arrow)");
#+end_src

#+HTML: <div id="graph-3" style="border: 1px solid #eee"></div>

*** Construct a partial curve
Ideally the curve between the two touching points follows the original curve. We
know the values $t_{start}$ and $t_{end}$ for which $B(t_{start}) = P_{start}$ and
$B(t_{end}) = P_{end}$, so $B(t)$ could simply be rendered for $t \in [t_{start},
t_{end}]$ instead of $t \in [0, 1]$. Unfortunately SVG's =path= commands don't
support such a feature.

To achieve the effect nonetheless, we first define a new function $C$ which
follows $B$ along the relevant interval, but itself using the domain $[0, 1]$:

$$ C\colon [0, 1] \Rightarrow \mathbb{R}^2 $$
$$ C(t) = B(t_{start} + (t_{end} - t_{start}) t) $$

We also know the start and end points:

$$ P_{start} = C(0) = B(t_{start}) $$
$$ P_{end} = C(1) = B(t_{end}) $$

Finally, we want $C$ to be a Bézier curve, so it must have the same structure.
Calling the control point $X$ we have:

$$ C(t) \, = \, (1 - t)^2 P_{start} + 2 (1 - t) t X + t^2 P_{end} $$

Putting it all together it is:
$$ (1 - t)^2 P_{start} + 2 (1 - t) t X + t^2 P_{end} \, = \, $$
$$ (1 - (t_{start} + (t_{end} - t_{start}) t))^2 P_0 + $$
$$ 2 (1 - (t_{start} + (t_{end} - t_{start}) t)) (t_{start} + $$
$$(t_{end} - t_{start}) t) P_1 + (t_{start} + (t_{end} - t_{start}) t)^2 P_2 $$

which means

$$ (1 - t)^2 ((1 - t_{start})^2 P_0 + 2 (1 - t_{start}) t_{start} P_1 + t_{start}^2 P_2) + $$
$$ 2 (1 - t) t X + $$
$$ t^2 ((1 - t_{end})^2 P_0 + 2 (1 - t_{end}) t_{end} P_1 + t_{end}^2 P_2) \, = \, $$
$$ (1 - (t_{start} + (t_{end} - t_{start}) t))^2 P_0 + $$
$$ 2 (1 - (t_{start} + (t_{end} - t_{start}) t)) (t_{start} + $$
$$(t_{end} - t_{start}) t) P_1 + (t_{start} + (t_{end} - t_{start}) t)^2 P_2 $$

and =sympy= helps us with that, yielding:

$$ X \, = \, t_{start} t_{end} (P_0 - 2 P_1 + P_2) + (1 - t_{start} - t_{end}) P_0 + (t_{start} + t_{end}) P_1 $$

So finally we can construct a new Bézier curve from $P_{start}$ to $P_{end}$,
using control point $X$. In code it looks like this:

#+begin_src js
edgeLayer
    .selectAll(".edge")
    .attr("d", e => {
        var start = findProximityPoint(e.source, e.intermediate, e.target, e.source.radius + 2);
        var end = findProximityPoint(e.target, e.intermediate, e.source, e.target.radius + 10);
        var P0 = e.source;
        var P1 = e.intermediate;
        var P2 = e.target;
        var tStart = start.t;
        var tEnd = 1 - end.t;

        var adjustedIntermediate = {
            x: tStart * tEnd * (P0.x - 2 * P1.x + P2.x) + (1 - tStart - tEnd) * P0.x + (tStart + tEnd) * P1.x,
            y: tStart * tEnd * (P0.y - 2 * P1.y + P2.y) + (1 - tStart - tEnd) * P0.y + (tStart + tEnd) * P1.y,
        };

        return "M" + start.x + "," + start.y
            + "Q" + adjustedIntermediate.x + "," + adjustedIntermediate.y
            + " " + end.x + "," + end.y;
    });
#+end_src

For the final result (the wrongly cropped curve included for comparison):
#+HTML: <div id="graph-4" style="border: 1px solid #eee"></div>