Reputation: 7169
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.
<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
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
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:
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.
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
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
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