Spenhouet
Spenhouet

Reputation: 7169

How to fix unwanted circle on / break down of SVG path element for Sankey links with d3?

I'm putting together a Sankey diagram via SVG but suddenly under some unknown conditions one of the paths show a circle where there shouldn't be any.

I tried to remove as much as possible from the my SVG but removing anything further makes this no longer reproducible.

Here you can see my reduced SVG image. The black path element is the one having the issue. I manually marked where the path stroke element should end but somehow it shows a circle which seems to have the same radius as the stroke width.

example image showing the issue

<div style="width: 100%; height: 100%;">
  <svg width=100% height=500px>
    <g class="sankey-layer">
      <g class="link-group">
        <path d="M107.61971830985915,30C107.61971830985915,74,107.61971830985915,74,107.61971830985915,118" fill="none" stroke="#000000" stroke-opacity="1" stroke-width="200"></path>
        <path d="M251.11267605633802,30C251.11267605633802,192,328.943661971831,192,328.943661971831,354" fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="71.74647887323944"></path>
        <path d="M478.38028169014075,30C478.38028169014075,192,384.7464788732394,192,384.7464788732394,354" fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="39.859154929577464"></path>
        <path d="M542.1549295774647,30C542.1549295774647,192,572.1549295774648,192,572.1549295774648,354" fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="87.69014084507042"></path>
        <path d="M344.8169014084507,30C344.8169014084507,192,452.50704225352115,192,452.50704225352115,354" fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="95.66197183098592"></path>
        <path d="M143.49295774647888,148C143.49295774647888,251,153.49295774647888,251,153.49295774647888,354" fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="79.71830985915493"></path>
        <path d="M409.12773522289996,148C409.12773522289996,251,213.28169014084506,251,213.28169014084506,354" fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="39.859154929577464"></path>
      </g>
    </g>
  </svg>
</div>

Any clue where this comes from and what I can do to avoid it?

Clarification:

The paths are computed by d3. It's not a manually created graphic and I'm looking for a general solution to the problem, not a solution for this particular example.

Upvotes: 2

Views: 561

Answers (3)

Spenhouet
Spenhouet

Reputation: 7169

Workarounds

My first workaround to resolve this issue was to also define a clip path for the expected bounds (start and end) of my link path. This way the artifact is simply clipped and no issue is visible:

<defs>
    <clipPath id="cut-off-link-{i}">
        <rect
            y={link.source.x1 - nodeHeight / 2}
            width="100%"
            height={link.target.x0 - link.source.x1 + nodeHeight}
        />
    </clipPath>
</defs>
<path
    d={d3shape.linkVertical()(link)}
    fill="none"
    stroke='#333'
    stroke-width={link.width}
    stroke-linecap="square"
    clip-path="url(#cut-off-link-{i})"
/>

This code is a bit out of context but I included it just to give an idea of the solution. It's from a Svelte application with a vertical Sankey and I reduced the code for brevity.

This workaround actually works great but it feels so wrong. I wanted a "proper" solution.

The solution proposed by @herrstrietzel (https://stackoverflow.com/a/72588392/2230045) seems to be a technically more correct workaround but I can't shake off that it still feels like a hacky workaround.


Solution

While looking for solutions I came across this blog post: https://observablehq.com/@enjalot/weird-sankey-links

The default d3.sankeyLinkHorizontal tends to break down if the nodes are too close together and the width is to big

Sankey Link Failing

While the issue the blog post focuses on looks slightly different, I'm sure it's the exact same issue I'm experiencing.

In some sense, I feel like the underlying issue here is a misuse of the path element. Setting fill to none and increasing the stroke width to draw the line seems to not work properly when the line is shorter than the stroke width.

The blog post proposes to draw custom Bézier curves instead. This removes the need for the stroke property and instead we can just use the normal fill. This feels like a proper solution to me. Let's look into the code.

Before this question I did not really look into the graphical side of Sankey diagrams. While trying to understand the solution proposed in the blog post (I need to change the code to work vertically instead of horizontally), I struggled to fully graphs what coordinates are referring to what. The d3 docs are not really helpful on that. Here an info graphic I made for myself:

Sankey Diagram Coordinates

The code from the blog post uses a slightly different coordinate naming. I adapted this, added some comments and tried to simplify it.

function sankeyLinkPath(link) {
    /**
     * This function is a drop in replacement for d3.sankeyLinkHorizontal().
     * Except any accessors/options.
     */
    // Start and end of the link
    let sx1 = link.source.x1;
    let tx0 = link.target.x0 + 1;

    // All four outer corners of the link
    // where e.g. lsy0 is the upper corner of the link on the source side
    let lsy0 = link.y0 - link.width / 2;
    let lsy1 = link.y0 + link.width / 2;
    let lty0 = link.y1 - link.width / 2;
    let lty1 = link.y1 + link.width / 2;

    // Center (x) of the link
    let lcx = sx1 + (tx0 - sx1) / 2;

    // Define outline of link as path
    let path = d3.path();
    path.moveTo(sx1, lsy0);
    path.bezierCurveTo(lcx, lsy0, lcx, lty0, tx0, lty0);
    path.lineTo(tx0, lty1);
    path.bezierCurveTo(lcx, lty1, lcx, lsy1, sx1, lsy1);
    path.lineTo(sx1, lsy0);
    return path.toString();
}

What this does is, that it draws a path of the outline of the link.

Steps to draw outline of link.

Which reduces my code to:

<path
    d={sankeyLinkPath(link)}
    fill='#333'
/>

Adjust for Vertical Plotting of Sankey

Finally, I adjusted the code to work vertically instead of horizontally:

function sankeyLinkPath(link) {
    /**
     * This function is a drop in replacement for d3.sankeyLinkVertical().
     * Except any accessors/options.
     */
    // Start and end of the link
    let sy1 = link.source.x1;
    let ty0 = link.target.x0 + 1;

    // All four outer corners of the link
    // where e.g. lsx0 is the right corner of the link on the source side
    let lsx0 = link.y0 - (link.width / 2) * linkWidth;
    let lsx1 = link.y0 + (link.width / 2) * linkWidth;
    let ltx0 = link.y1 - (link.width / 2) * linkWidth;
    let ltx1 = link.y1 + (link.width / 2) * linkWidth;

    // Center (y) of the link
    let lcy = sy1 + (ty0 - sy1) / 2;

    // Define outline of link as path
    let path = d3.path();
    path.moveTo(lsx0, sy1);
    path.bezierCurveTo(lsx0, lcy, ltx0, lcy, ltx0, ty0);
    path.lineTo(ltx1, ty0);
    path.bezierCurveTo(ltx1, lcy, lsx1, lcy, lsx1, sy1);
    path.lineTo(lsx0, sy1);
    return path.toString();
}

Which now yields this absolutely fine working Sankey plot:

.opacity-40 {
    opacity: 0.4;
}
<div width="100%" height="100%">
    <svg width="648" height="384">
        <g class="sankey-links">
            <g class="sankey-link group">
                <path
                    class="opacity-40 group-hover:opacity-80"
                    d="M11.370422535211262,40C11.370422535211262,77.83333333333334,11.370422535211262,77.83333333333334,11.370422535211262,115.66666666666667L216.03802816901413,115.66666666666667C216.03802816901413,77.83333333333334,216.03802816901413,77.83333333333334,216.03802816901413,40L11.370422535211262,40"
                    fill="#0011ff"
                ></path>
            </g>
            <g class="sankey-link group">
                <path
                    class="opacity-40 group-hover:opacity-80"
                    d="M5.47464788732394,154.66666666666669C5.47464788732394,249.83333333333334,5.47464788732394,249.83333333333334,5.47464788732394,345L104.01830985915494,345C104.01830985915494,249.83333333333334,104.01830985915494,249.83333333333334,104.01830985915494,154.66666666666669L5.47464788732394,154.66666666666669"
                    fill="#00bbff"
                ></path>
            </g>
            <g class="sankey-link group">
                <path
                    class="opacity-40 group-hover:opacity-80"
                    d="M318.26478873239444,40C318.26478873239444,192.5,312.9971830985916,192.5,312.9971830985916,345L403.9605633802817,345C403.9605633802817,192.5,409.22816901408453,192.5,409.22816901408453,40L318.26478873239444,40"
                    fill="#0011ff"
                ></path>
            </g>
            <g class="sankey-link group">
                <path
                    class="opacity-40 group-hover:opacity-80"
                    d="M487.8718309859155,40C487.8718309859155,192.5,559.9845070422538,192.5,559.9845070422538,345L643.367605633803,345C643.367605633803,192.5,571.2549295774647,192.5,571.2549295774647,40L487.8718309859155,40"
                    fill="#00bbff"
                ></path>
            </g>
            <g class="sankey-link group">
                <path
                    class="opacity-40 group-hover:opacity-80"
                    d="M113.70422535211267,154.66666666666669C113.70422535211267,249.83333333333334,123.70422535211273,249.83333333333334,123.70422535211273,345L199.50704225352118,345C199.50704225352118,249.83333333333334,189.50704225352112,249.83333333333334,189.50704225352112,154.66666666666669L113.70422535211267,154.66666666666669"
                    fill="#00bbff"
                ></path>
            </g>
            <g class="sankey-link group">
                <path
                    class="opacity-40 group-hover:opacity-80"
                    d="M231.1985915492958,40C231.1985915492958,192.5,412.8042253521127,192.5,412.8042253521127,345L481.0267605633804,345C481.0267605633804,192.5,299.4211267605634,192.5,299.4211267605634,40L231.1985915492958,40"
                    fill="#0011ff"
                ></path>
            </g>
            <g class="sankey-link group">
                <path
                    class="opacity-40 group-hover:opacity-80"
                    d="M417.22957746478875,40C417.22957746478875,77.83333333333334,413.3159790754746,77.83333333333334,413.3159790754746,115.66666666666667L466.37795090646057,115.66666666666667C466.37795090646057,77.83333333333334,470.2915492957747,77.83333333333334,470.2915492957747,40L417.22957746478875,40"
                    fill="#00bbff"
                ></path>
            </g>
            <g class="sankey-link group">
                <path
                    class="opacity-40 group-hover:opacity-80"
                    d="M577.9929577464789,40C577.9929577464789,192.5,486.92253521126764,192.5,486.92253521126764,345L524.8239436619718,345C524.8239436619718,192.5,615.894366197183,192.5,615.894366197183,40L577.9929577464789,40"
                    fill="#0011ff"
                ></path>
            </g>
            <g class="sankey-link group">
                <path
                    class="opacity-40 group-hover:opacity-80"
                    d="M412.4737255543479,154.66666666666669C412.4737255543479,249.83333333333334,205.82394366197187,249.83333333333334,205.82394366197187,345L243.72535211267612,345C243.72535211267612,249.83333333333334,450.37513400505213,249.83333333333334,450.37513400505213,154.66666666666669L412.4737255543479,154.66666666666669"
                    fill="#00bbff"
                ></path>
            </g>
            <g class="sankey-link group">
                <path
                    class="opacity-40 group-hover:opacity-80"
                    d="M195.40281690140844,154.66666666666669C195.40281690140844,249.83333333333334,257.5154929577465,249.83333333333334,257.5154929577465,345L287.8366197183099,345C287.8366197183099,249.83333333333334,225.72394366197184,249.83333333333334,225.72394366197184,154.66666666666669L195.40281690140844,154.66666666666669"
                    fill="#00bbff"
                ></path>
            </g>
            <g class="sankey-link group">
                <path
                    class="opacity-40 group-hover:opacity-80"
                    d="M453.32302132899576,154.66666666666669C453.32302132899576,192.5,457.2918698867473,192.5,457.2918698867473,230.33333333333334L472.452433267029,230.33333333333334C472.452433267029,192.5,468.48358470927747,192.5,468.48358470927747,154.66666666666669L453.32302132899576,154.66666666666669"
                    fill="#00bbff"
                ></path>
            </g>
            <g class="sankey-link group">
                <path
                    class="opacity-40 group-hover:opacity-80"
                    d="M465.2932783374515,269.33333333333337C465.2932783374515,307.1666666666667,289.94225352112676,307.1666666666667,289.94225352112676,345L297.5225352112676,345C297.5225352112676,307.1666666666667,472.87356002759236,307.1666666666667,472.87356002759236,269.33333333333337L465.2932783374515,269.33333333333337"
                    fill="#00bbff"
                ></path>
            </g>
            <g class="sankey-link group">
                <path
                    class="opacity-40 group-hover:opacity-80"
                    d="M456.8707431261839,269.33333333333337C456.8707431261839,307.1666666666667,537.3507042253524,307.1666666666667,537.3507042253524,345L544.9309859154931,345C544.9309859154931,307.1666666666667,464.4510248163248,307.1666666666667,464.4510248163248,269.33333333333337L456.8707431261839,269.33333333333337"
                    fill="#00bbff"
                ></path>
            </g>
        </g>
    </svg>
</div>

I gained quite some insight into working with SVGs and d3 with this. I hope the solution helps someone else in the future.

Upvotes: 1

herrstrietzel
herrstrietzel

Reputation: 17205

Apperently a browser related bug you should report.

As a "post-processing" workaround you could use this helper I've once created to convert flat curves to L commands. (based on path data polyfill by Jarek Foksa)

Fixed the issue in edge.

let paths = document.querySelectorAll('path');
convertStraightCurves(paths)

function convertStraightCurves(paths){
  paths.forEach(function(path){
      let pathData = path.getPathData({normalize:true}); 
      pathData.forEach(function(com, c){
          let [type, values] = [ com['type'], com['values'] ];

          //check straight vertical curves
          if(type=='C'){
            let [x1,x2,x3] = [values[0], values[2], values[4]]
            let [y1,y2,y3] = [values[1], values[3], values[5]]
            
              // check if curve is straight
              let angle1 =  getAngle(x2,y2, x3, y3);
              let angle2 =  getAngle(x1,y1, x3, y3);

              if(angle1===angle2 ){
                  pathData[c]['type']='L';
                  pathData[c]['values']= [x3, y3];
            }
          }
      })
      path.setPathData(pathData);
    })
}


 function getAngle(x1, y1, x2, y2){
     let angle = Math.atan2(y2 - y1, x2 - x1) * 180 / Math.PI;
     return Math.floor(angle);   
 }
<script src="https://cdn.jsdelivr.net/npm/[email protected]/path-data-polyfill.min.js"></script>
<div style="width: 100%; height: 100%;">
  <svg width=100% height=500px>
    <g class="sankey-layer">
      <g class="link-group">
        <path d="M107.62,30L107.62,118" fill="none" stroke="#000000" stroke-opacity="1" stroke-width="200"></path>
        <path d="M251.11267605633802,30C251.11267605633802,192,328.943661971831,192,328.943661971831,354"  fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="71.74647887323944"></path>
        <path d="M478.38028169014075,30C478.38028169014075,192,384.7464788732394,192,384.7464788732394,354" fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="39.859154929577464"></path>
        <path d="M542.1549295774647,30C542.1549295774647,192,572.1549295774648,192,572.1549295774648,354" fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="87.69014084507042"></path>
        <path d="M344.8169014084507,30C344.8169014084507,192,452.50704225352115,192,452.50704225352115,354" fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="95.66197183098592"></path>
        <path d="M143.49295774647888,148C143.49295774647888,251,153.49295774647888,251,153.49295774647888,354" fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="79.71830985915493"></path>
        <path d="M409.12773522289996,148C409.12773522289996,251,213.28169014084506,251,213.28169014084506,354" fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="39.859154929577464"></path>
      </g>
    </g>
  </svg>
</div>

Also working curveto commands

d="M107.6 30 C 107.6 74 107.6 118 107.6 118"
[x2,y2] = [x3,y3]

d="M107.6 30 C 107.6 118 107.6 118 107.6 118"
[x1,y1] = [x2,y2] = [x3,y3]

Upvotes: 1

WizardCoder
WizardCoder

Reputation: 3461

I changed the first path to <path d="M107.62,30L107.62,118" fill="none" stroke="#000000" stroke-opacity="1" stroke-width="200"></path>. I worked this out by loading it in to Illustrator.

I'm not 100% sure what the issue was, but maybe someone smarter than me will figure it out. The maths of SVGs hurts my brain.

<div style="width: 100%; height: 100%;">
  <svg width=100% height=500px>
    <g class="sankey-layer">
      <g class="link-group">
        <path d="M107.62,30L107.62,118" fill="none" stroke="#000000" stroke-opacity="1" stroke-width="200"></path>
        <path d="M251.11267605633802,30C251.11267605633802,192,328.943661971831,192,328.943661971831,354"  fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="71.74647887323944"></path>
        <path d="M478.38028169014075,30C478.38028169014075,192,384.7464788732394,192,384.7464788732394,354" fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="39.859154929577464"></path>
        <path d="M542.1549295774647,30C542.1549295774647,192,572.1549295774648,192,572.1549295774648,354" fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="87.69014084507042"></path>
        <path d="M344.8169014084507,30C344.8169014084507,192,452.50704225352115,192,452.50704225352115,354" fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="95.66197183098592"></path>
        <path d="M143.49295774647888,148C143.49295774647888,251,153.49295774647888,251,153.49295774647888,354" fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="79.71830985915493"></path>
        <path d="M409.12773522289996,148C409.12773522289996,251,213.28169014084506,251,213.28169014084506,354" fill="none" stroke="#00bbff35" stroke-opacity="0.5" stroke-width="39.859154929577464"></path>
      </g>
    </g>
  </svg>
</div>

Upvotes: 1

Related Questions