Sankey chart "unwanted circle" fix for D3.js

Cyrill JaunerCyrill Jauner
3 min read

In some of our projects, we use D3.js to create interactive web charts. It is a very comprehensive library that allows for creating a wide variety of charts as SVG graphics. This blog post is not about D3 in general, but specifically about a small fix for Sankey charts.

What are Sankey charts?

Sankey diagrams offer the ability to represent flow quantities (see also Wikipedia). For this type of diagram, a graph is first calculated from the data. The nodes and edges of this graph are then usually displayed in the diagram along with labels. Here is an example of a Sankey diagram with sample data from D3. The image was also rendered with D3.

What is the problem with the standard implementation?

Sankey diagrams often require a lot of space. Especially when the diagram has many levels, it becomes horizontally cramped. In such cases, a visual error can occur in the D3 standard implementation. This error is well explained here, and there is also a GitHub issue from 2022 that unfortunately has not been resolved yet.

The issue also directly presents a solution. This solution works technically flawlessly. With a few adjustments, the fix can be applied.

// default attributes for sankey links
.attr('d', sankeyLinkHorizontal())
.attr('stroke-opacity', 0.3)
.attr('stroke', color)

// changed attributes for sankey links with the fixed path rendering
.attr('d', link => sankeyLinkPathHorizontal(link))
.attr('fill-opacity', 0.3)
.attr('fill', color)

What bothered us about the fix?

We wanted to use the adjusted rendering logic for the links in all our Sankey diagrams. Unfortunately, the fix significantly changes the appearance of certain edges. Especially when the edges are wide and represent a steep connection, they appear "stretched." We observed this behavior not only in extreme cases with limited space, and it bothered us accordingly.

What does our solution look like?

We made only one significant adjustment to the already known fix. In our logic, there is an additional case distinction. This ensures that the edges do not become too narrow. Applied to the example diagram, the edges are finally drawn as follows.

In the code, there is now a calculation for horizontalGapBetweenNodes. This variable describes the distance between the middle control points of the Bezier curves. If this distance is smaller than the edge width in the graph (link.width), we reset the middle control points. The complete code for the function is provided here.

Additionally, there is a GitHub repository with a working D3 Sankey diagram. It also contains the entire code along with example data.

In our codebase, we used TypeScript. The original fix, as is common in the D3 community, is written in JavaScript.

function sankeyLinkPathHorizontal(link: SankeyLink<ISankeyNode, ISankeyLink>): string {
    // Source and target of the link
    const sourceNode: SankeyNode<ISankeyNode, ISankeyLink> = link.source as ISankeyNode;
    const targetNode: SankeyNode<ISankeyNode, ISankeyLink> = link.target as ISankeyNode;
    const sx1: number = sourceNode.x1;
    const tx0: number = targetNode.x0;

    // All four corners of the link
    const linkWidthOrMinWidth = Math.max(link.width, 1);
    const halfLinkWidth = linkWidthOrMinWidth / 2;
    const lsy0: number = link.y0 - halfLinkWidth;
    const lsy1: number = link.y0 + halfLinkWidth;
    const lty0: number = link.y1 - halfLinkWidth;
    const lty1: number = link.y1 + halfLinkWidth;

    // Center of the link
    const lcx: number = sx1 + (tx0 - sx1) / 2;
    let lcxLeft: number = link.y0 < link.y1 ? lcx + halfLinkWidth : lcx - halfLinkWidth;
    let lcxRight: number = link.y0 > link.y1 ? lcx + halfLinkWidth : lcx - halfLinkWidth;

    const horizontalGapBetweenNodes: number = Math.abs(lty0 - lsy0);
    if (horizontalGapBetweenNodes < linkWidthOrMinWidth) {
        lcxLeft = lcx;
        lcxRight = lcx;
    }

    // Define the outline of the link as path
    const path: d3.Path = d3.path();
    path.moveTo(sx1, lsy0);
    path.bezierCurveTo(lcxLeft, lsy0, lcxLeft, lty0, tx0, lty0);
    path.lineTo(tx0, lty1);
    path.bezierCurveTo(lcxRight, lty1, lcxRight, lsy1, sx1, lsy1);
    path.lineTo(sx1, lsy0);
    return path.toString();
}
0
Subscribe to my newsletter

Read articles from Cyrill Jauner directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Cyrill Jauner
Cyrill Jauner