block by micahstubbs 2d9ac2340dd81e676be9aca6e2e93a4a

Sankey Gradients - Missing Gradient Solution

Full Screen

a solution 🎉

solution
[tweet]

specifically, this stackoverflow answer has the workaround to solve this apparent bug in Chromium‘s implementation of the SVG 1.1 standard

in d3.sankey.js, we want to alter the return value of the path generator to ensure that we never return perfectly straight paths. inserting this this new moveto command "M" + -10 + "," + -10 on the first line does just that:

return "M" + -10 + "," + -10
     + "M" + x0  + "," + y0
     + "C" + x2  + "," + y0
     + " " + x3  + "," + y1
     + " " + x1  + "," + y1;

an iteration on by Patient Flow Sankey Particles from @micahstubbs

see also the earlier version with 13 layout iterations that happens to avoid any perfectly straight paths.

and also this earlier bug reproduction example with 14 Sankey layout iterations that does produce a couple of those problematic-for-Chromium perfectly straight SVG paths

inspired by the blog post Data-based and unique gradients for visualizations with d3.js and associated example Data based gradients - Simple - Solar system from @nadiehbremer

index.html

<!DOCTYPE html>
<meta charset='utf-8'>
<title>Sankey Gradients</title>
<script src='https://cdnjs.cloudflare.com/ajax/libs/d3/4.4.0/d3.min.js'></script>
<script src='d3.sankey.js'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.19.0/babel.min.js'></script>
<style>
.node rect {
  cursor: move;
  fill-opacity: .9;
  shape-rendering: crispEdges;
}
 
.node text {
  pointer-events: none;
  font-family: Helvetica;
  font-size: 12px;
}
</style>
<body>
<div id='chart'>
<script lang='babel' type='text/babel'>
const units = '';
const margin = {top: 10, right: 10, bottom: 10, left: 10};
const width = 960 - margin.left - margin.right;
const height = 300 - margin.top - margin.bottom;

// zero decimal places
const formatNumber = d3.format(',.0f');

const format = d => `${formatNumber(d)} ${units}`;

const color = d3.scaleOrdinal()
  .domain([
    'All referred patients',
    'First consult outpatient clinic',
    'OR-receipt',
    'Start surgery',
    // 'No OR-receipt',
    // 'No emergency',
    // 'No surgery',
    'Emergency'
  ])
  .range([
    '#90eb9d',
    '#f9d057',
    '#f29e2e',
    '#00ccbc',
    '#d7191c'
  ]);

d3.select('#chart')
  .style('visibility', 'visible');

// append the svg canvas to the page
const svg = d3.select('#chart').append('svg')
  .attr('width', width + margin.left + margin.right)
  .attr('height', height + margin.top + margin.bottom)
  .append('g')
    .attr('transform', `translate(${margin.left},${margin.top})`);

// set the sankey diagram properties
const sankey = d3.sankey()
  .nodeWidth(12)
  .nodePadding(10)
  .size([width, height]);

const path = sankey.link();

// append a defs (for definition) element to your SVG
const defs = svg.append('defs');

// load the data
d3.json('data.json', (error, graph) => {
  console.log('graph', graph);
  sankey
    .nodes(graph.nodes)
    .links(graph.links)
    .layout(14); // any value > 13 breaks the link gradient
 
  // add in the links
  const link = svg.append('g').selectAll('.link')
    .data(graph.links)
    .enter().append('path')
      .attr('class', 'link')
      .attr('d', path)
      .style('stroke-width', d => Math.max(1, d.dy))
      .style('fill', 'none')
      .style('stroke-opacity', 0.18)
      .sort((a, b) => b.dy - a.dy)
      .on('mouseover', function() {
        d3.select(this).style('stroke-opacity', 0.5);
      })
      .on('mouseout', function() {
        d3.select(this).style('stroke-opacity', 0.2);
      });
 
  // add the link titles
  link.append('title')
    .text(d => `${d.source.name} → ${d.target.name}\n${format(d.value)}`);
 
  // add in the nodes
  const node = svg.append('g').selectAll('.node')
    .data(graph.nodes)
    .enter().append('g')
      .attr('class', 'node')
      .attr('transform', d => `translate(${d.x},${d.y})`)
      .call(d3.drag()
        .subject(d => d)
        .on('start', function() { 
          this.parentNode.appendChild(this); })
        .on('drag', dragmove));
 
  // add the rectangles for the nodes
  node.append('rect')
    .attr('height', d => d.dy)
    .attr('width', sankey.nodeWidth())
    .style('fill', d => { 
      if(color.domain().indexOf(d.name) > -1){
        return d.color = color(d.name);  
      } else {
        return d.color = '#ccc';
      }
    })
    .append('title')
      .text(d => `${d.name}\n${format(d.value)}`);
 
  // add in the title for the nodes
  node.append('text')
    .attr('x', -6)
    .attr('y', d => d.dy / 2)
    .attr('dy', '.35em')
    .attr('text-anchor', 'end')
    .attr('transform', null)
    .text(d => d.name)
    .filter(d => d.x < width / 2)
      .attr('x', 6 + sankey.nodeWidth())
      .attr('text-anchor', 'start');

  // add gradient to links
  link.style('stroke', (d, i) => {
    console.log('d from gradient stroke func', d);

    // make unique gradient ids  
    const gradientID = `gradient${i}`;

    const startColor = d.source.color;
    const stopColor = d.target.color;

    console.log('startColor', startColor);
    console.log('stopColor', stopColor);

    const linearGradient = defs.append('linearGradient')
        .attr('id', gradientID);

    linearGradient.selectAll('stop') 
      .data([                             
          {offset: '10%', color: startColor },      
          {offset: '90%', color: stopColor }    
        ])                  
      .enter().append('stop')
      .attr('offset', d => {
        console.log('d.offset', d.offset);
        return d.offset; 
      })   
      .attr('stop-color', d => {
        console.log('d.color', d.color);
        return d.color;
      });

    return `url(#${gradientID})`;
  })
 
// the function for moving the nodes
  function dragmove(d) {
    d3.select(this).attr('transform', 
      `translate(${d.x = Math.max(0, Math.min(width - d.dx, d3.event.x))},${d.y = Math.max(0, Math.min(height - d.dy, d3.event.y))})`);
    sankey.relayout();
    link.attr('d', path);
  }
});
</script>
</body>
</html>

d3.sankey.js

d3.sankey = function() {
  var sankey = {},
      nodeWidth = 24,
      nodePadding = 8,
      size = [1, 1],
      nodes = [],
      links = [];

  sankey.nodeWidth = function(_) {
    if (!arguments.length) return nodeWidth;
    nodeWidth = +_;
    return sankey;
  };

  sankey.nodePadding = function(_) {
    if (!arguments.length) return nodePadding;
    nodePadding = +_;
    return sankey;
  };

  sankey.nodes = function(_) {
    if (!arguments.length) return nodes;
    nodes = _;
    return sankey;
  };

  sankey.links = function(_) {
    if (!arguments.length) return links;
    links = _;
    return sankey;
  };

  sankey.size = function(_) {
    if (!arguments.length) return size;
    size = _;
    return sankey;
  };

  sankey.layout = function(iterations) {
    computeNodeLinks();
    computeNodeValues();
    computeNodeBreadths();
    computeNodeDepths(iterations);
    computeLinkDepths();
    return sankey;
  };

  sankey.relayout = function() {
    computeLinkDepths();
    return sankey;
  };

  sankey.link = function() {
    var curvature = .5;

    function link(d) {
      var x0 = d.source.x + d.source.dx,
          x1 = d.target.x,
          xi = d3.interpolateNumber(x0, x1),
          x2 = xi(curvature),
          x3 = xi(1 - curvature),
          y0 = d.source.y + d.sy + d.dy / 2,
          y1 = d.target.y + d.ty + d.dy / 2;
      // prevent a perfectly straight path
      // to avoid missing SVG path gradient bug in Chromium
      return "M" + -10 + "," + -10 
           + "M" + x0  + "," + y0
           + "C" + x2  + "," + y0
           + " " + x3  + "," + y1
           + " " + x1  + "," + y1;
    }

    link.curvature = function(_) {
      if (!arguments.length) return curvature;
      curvature = +_;
      return link;
    };

    return link;
  };

  // Populate the sourceLinks and targetLinks for each node.
  // Also, if the source and target are not objects, assume they are indices.
  function computeNodeLinks() {
    nodes.forEach(function(node) {
      node.sourceLinks = [];
      node.targetLinks = [];
    });
    links.forEach(function(link) {
      var source = link.source,
          target = link.target;
      if (typeof source === "number") source = link.source = nodes[link.source];
      if (typeof target === "number") target = link.target = nodes[link.target];
      source.sourceLinks.push(link);
      target.targetLinks.push(link);
    });
  }

  // Compute the value (size) of each node by summing the associated links.
  function computeNodeValues() {
    nodes.forEach(function(node) {
      node.value = Math.max(
        d3.sum(node.sourceLinks, value),
        d3.sum(node.targetLinks, value)
      );
    });
  }

  // Iteratively assign the breadth (x-position) for each node.
  // Nodes are assigned the maximum breadth of incoming neighbors plus one;
  // nodes with no incoming links are assigned breadth zero, while
  // nodes with no outgoing links are assigned the maximum breadth.
  function computeNodeBreadths() {
    var remainingNodes = nodes,
        nextNodes,
        x = 0;

    while (remainingNodes.length) {
      nextNodes = [];
      remainingNodes.forEach(function(node) {
        node.x = x;
        node.dx = nodeWidth;
        node.sourceLinks.forEach(function(link) {
          if (nextNodes.indexOf(link.target) < 0) {
            nextNodes.push(link.target);
          }
        });
      });
      remainingNodes = nextNodes;
      ++x;
    }

    //
    moveSinksRight(x);
    scaleNodeBreadths((size[0] - nodeWidth) / (x - 1));
  }

  function moveSourcesRight() {
    nodes.forEach(function(node) {
      if (!node.targetLinks.length) {
        node.x = d3.min(node.sourceLinks, function(d) { return d.target.x; }) - 1;
      }
    });
  }

  function moveSinksRight(x) {
    nodes.forEach(function(node) {
      if (!node.sourceLinks.length) {
        node.x = x - 1;
      }
    });
  }

  function scaleNodeBreadths(kx) {
    nodes.forEach(function(node) {
      node.x *= kx;
    });
  }

  function computeNodeDepths(iterations) {
    var nodesByBreadth = d3.nest()
        .key(function(d) { return d.x; })
        .sortKeys(d3.ascending)
        .entries(nodes)
        .map(function(d) { return d.values; });

    //
    initializeNodeDepth();
    resolveCollisions();
    for (var alpha = 1; iterations > 0; --iterations) {
      relaxRightToLeft(alpha *= .99);
      resolveCollisions();
      relaxLeftToRight(alpha);
      resolveCollisions();
    }

    function initializeNodeDepth() {
      var ky = d3.min(nodesByBreadth, function(nodes) {
        return (size[1] - (nodes.length - 1) * nodePadding) / d3.sum(nodes, value);
      });

      nodesByBreadth.forEach(function(nodes) {
        nodes.forEach(function(node, i) {
          node.y = i;
          node.dy = node.value * ky;
        });
      });

      links.forEach(function(link) {
        link.dy = link.value * ky;
      });
    }

    function relaxLeftToRight(alpha) {
      nodesByBreadth.forEach(function(nodes, breadth) {
        nodes.forEach(function(node) {
          if (node.targetLinks.length) {
            var y = d3.sum(node.targetLinks, weightedSource) / d3.sum(node.targetLinks, value);
            node.y += (y - center(node)) * alpha;
          }
        });
      });

      function weightedSource(link) {
        return center(link.source) * link.value;
      }
    }

    function relaxRightToLeft(alpha) {
      nodesByBreadth.slice().reverse().forEach(function(nodes) {
        nodes.forEach(function(node) {
          if (node.sourceLinks.length) {
            var y = d3.sum(node.sourceLinks, weightedTarget) / d3.sum(node.sourceLinks, value);
            node.y += (y - center(node)) * alpha;
          }
        });
      });

      function weightedTarget(link) {
        return center(link.target) * link.value;
      }
    }

    function resolveCollisions() {
      nodesByBreadth.forEach(function(nodes) {
        var node,
            dy,
            y0 = 0,
            n = nodes.length,
            i;

        // Push any overlapping nodes down.
        nodes.sort(ascendingDepth);
        for (i = 0; i < n; ++i) {
          node = nodes[i];
          dy = y0 - node.y;
          if (dy > 0) node.y += dy;
          y0 = node.y + node.dy + nodePadding;
        }

        // If the bottommost node goes outside the bounds, push it back up.
        dy = y0 - nodePadding - size[1];
        if (dy > 0) {
          y0 = node.y -= dy;

          // Push any overlapping nodes back up.
          for (i = n - 2; i >= 0; --i) {
            node = nodes[i];
            dy = node.y + node.dy + nodePadding - y0;
            if (dy > 0) node.y -= dy;
            y0 = node.y;
          }
        }
      });
    }

    function ascendingDepth(a, b) {
      return a.y - b.y;
    }
  }

  function computeLinkDepths() {
    nodes.forEach(function(node) {
      node.sourceLinks.sort(ascendingTargetDepth);
      node.targetLinks.sort(ascendingSourceDepth);
    });
    nodes.forEach(function(node) {
      var sy = 0, ty = 0;
      node.sourceLinks.forEach(function(link) {
        link.sy = sy;
        sy += link.dy;
      });
      node.targetLinks.forEach(function(link) {
        link.ty = ty;
        ty += link.dy;
      });
    });

    function ascendingSourceDepth(a, b) {
      return a.source.y - b.source.y;
    }

    function ascendingTargetDepth(a, b) {
      return a.target.y - b.target.y;
    }
  }

  function center(node) {
    return node.y + node.dy / 2;
  }

  function value(link) {
    return link.value;
  }

  return sankey;
};

data.json

{
  "nodes": [
    {
      "name": "All referred patients",
      "id": 0
    },
    {
      "name": "First consult outpatient clinic",
      "id": 1
    },
    {
      "name": "No OR-receipt",
      "id": 2
    },
    {
      "name": "OR-receipt",
      "id": 3
    },
    {
      "name": "No surgery",
      "id": 4
    },
    {
      "name": "Start surgery",
      "id": 5
    },
    {
      "name": "Emergency",
      "id": 6
    },
    {
      "name": "No emergency",
      "id": 7
    }
  ],
  "links": [
    {
      "source": 0,
      "target": 1,
      "value": 1,
      "label": 1
    },
    {
      "source": 1,
      "target": 2,
      "value": 0.64,
      "label": 0.64
    },    
    {
      "source": 1,
      "target": 3,
      "value": 0.36,
      "label": 0.36
    },
    {
      "source": 3,
      "target": 4,
      "value": 0.1188,
      "label": 0.33
    },
    {
      "source": 3,
      "target": 5,
      "value": 0.2412,
      "label": 0.67
    },
    {
      "source": 5,
      "target": 6,
      "value": 0.038592,
      "label": 0.16
    },
    {
      "source": 5,
      "target": 7,
      "value": 0.20260799999999998,
      "label": 0.84
    }
  ]
}