2

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.

Spenhouet
  • 6,556
  • 12
  • 51
  • 76
  • 2
    I cannot see what you are describing - there is only a black rectangle. (Both Firefox and Chrome) Try to look closely at the environment you are executing your code in. – ccprog Jun 11 '22 at 13:58
  • if you need a bézier for the path try moving the control points away one of another. For example use this `d="M107.61971830985915,30 C107.61971830985915,50, 107.61971830985915,74, 107.61971830985915,118"` – enxaneta Jun 11 '22 at 14:28
  • @ccprog very interesting. I'm seeing this in Edge and in Brave but did not yet test in Chrome and Firefox. But this suggests it's a browser specific issue which will probably be hard to fix. – Spenhouet Jun 11 '22 at 14:54
  • @enxaneta I added a clarification that the shown SVG is just an example. These are generated by the d3 library and I'm looking for a general solution to the problem, not one specific to this example. – Spenhouet Jun 11 '22 at 14:58
  • I still can't reproduce the error, but I have a hunch this is about the two cubic Bezier control points being in one place. You could try to write a custom [link generator](https://github.com/d3/d3-shape/tree/v3.1.0#link) where the y value for the control points is not [(y1 + y2)/2](https://github.com/d3/d3-shape/blob/cf776d34cf21658b421a730f8ec3628a6644aa24/src/curve/bump.js#L33) for both, but leaves a distance between them. – ccprog Jun 11 '22 at 16:28

3 Answers3

1

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>
WizardCoder
  • 3,353
  • 9
  • 20
  • These paths are generated by d3. So I don't really have control about this. The thing is, if I remove any of the other paths in this SVG, the circle also disappears while the black path stays the same. – Spenhouet Jun 11 '22 at 14:56
  • 1
    @Spenhouet Well that's a massive headache. It appears to be an issue with the actual SVG and I can't see a way that it can be fixed with CSS. – WizardCoder Jun 11 '22 at 15:20
  • 1
    I did actually come up with workaround by defining a SVG clipPath to just clip everything extending the path in y direction. While not beautiful, it works. I no one else has any insight on this issue, I will add the workaround as an answer. But you are right that it's not a solution with CSS. – Spenhouet Jun 11 '22 at 15:24
  • @Spenhouet I'm glad you found some kind of solution. I did find a way to change a SVG path with CSS, but it's only supported in chrome at the moment. Maybe create a new question with your D3 code if that is something you have control over. – WizardCoder Jun 11 '22 at 15:30
1

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/path-data-polyfill@1.0.3/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]

herrstrietzel
  • 11,541
  • 2
  • 12
  • 34
  • I hacked a workaround myself and wasn't happy with it. Your solution still seems to be a workaround rather than a solution. I spent quite some time on this issue now and found the true source as well as a proper solution. I wrote everything down in a new answer. Might be interesting to you. :) – Spenhouet Jun 12 '22 at 14:31
1

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.

Spenhouet
  • 6,556
  • 12
  • 51
  • 76