block by nitaku 564fcb398fdad08442c1

Node-link circular layout: Sankey links

Full Screen

Another experiment along the line of this one. This time, links are drawn in a way that the overlap near the node is removed. The method is somehow reminiscent of Sankey diagrams (see this paper for similar considerations, applied to arc diagrams).

Besides reducing visual clutter, two advantages of this design are:

index.js

(function() {
  var MAX_WIDTH, TENSION_SLIDER_Y, brushed, circular, circular_layout, compute_degree, graph_data, handle, height, labels, link_thickness, links, links_layer, list_links, max, nodes, nodes_layer, objectify, radius, redraw, sankey, slider, svg, tension, tension_brush, tension_scale, width;

  graph_data = {
    nodes: [
      {
        id: 'A',
        size: 14
      }, {
        id: 'B',
        size: 56
      }, {
        id: 'C',
        size: 26
      }, {
        id: 'D',
        size: 16
      }, {
        id: 'E',
        size: 32
      }, {
        id: 'F',
        size: 16
      }, {
        id: 'G',
        size: 12
      }
    ],
    links: [
      {
        source: 'A',
        target: 'B',
        weight: 12
      }, {
        source: 'A',
        target: 'C',
        weight: 2
      }, {
        source: 'A',
        target: 'D',
        weight: 33
      }, {
        source: 'A',
        target: 'F',
        weight: 5
      }, {
        source: 'A',
        target: 'G',
        weight: 24
      }, {
        source: 'B',
        target: 'D',
        weight: 10
      }, {
        source: 'B',
        target: 'E',
        weight: 10
      }, {
        source: 'B',
        target: 'F',
        weight: 8
      }, {
        source: 'B',
        target: 'G',
        weight: 16
      }, {
        source: 'C',
        target: 'D',
        weight: 29
      }, {
        source: 'C',
        target: 'E',
        weight: 11
      }, {
        source: 'D',
        target: 'E',
        weight: 4
      }, {
        source: 'D',
        target: 'F',
        weight: 12
      }, {
        source: 'E',
        target: 'F',
        weight: 19
      }
    ]
  };

  objectify = function(graph) {
    return graph.links.forEach(function(l) {
      return graph.nodes.forEach(function(n) {
        if (l.source === n.id) {
          l.source = n;
        }
        if (l.target === n.id) {
          return l.target = n;
        }
      });
    });
  };

  objectify(graph_data);

  list_links = function(graph) {
    return graph.nodes.forEach(function(n) {
      n.links = graph.links.filter(function(link) {
        return link.source === n || link.target === n;
      });
      return n.links.sort(function(a, b) {
        return a.weight - b.weight;
      });
    });
  };

  list_links(graph_data);

  sankey = function(graph) {
    return graph.nodes.forEach(function(n) {
      var acc;

      acc = 0;
      return n.links.forEach(function(link) {
        if (link.source === n) {
          return link.sankey_source = {
            start: acc,
            middle: acc + link.weight / 2,
            end: acc += link.weight
          };
        } else if (link.target === n) {
          return link.sankey_target = {
            start: acc,
            middle: acc + link.weight / 2,
            end: acc += link.weight
          };
        }
      });
    });
  };

  sankey(graph_data);

  compute_degree = function(graph) {
    return graph.nodes.forEach(function(n) {
      return n.degree = d3.sum(n.links, function(link) {
        return link.weight;
      });
    });
  };

  compute_degree(graph_data);

  svg = d3.select('svg');

  width = svg.node().getBoundingClientRect().width;

  height = svg.node().getBoundingClientRect().height;

  svg.attr({
    viewBox: "" + (-width / 2) + " " + (-height / 2 - 30) + " " + width + " " + height
  });

  circular_layout = function() {
    var delta_theta, rho, self, theta, theta_0;

    rho = function(d, i, data) {
      return 100;
    };
    theta_0 = function(d, i, data) {
      return -Math.PI / 2;
    };
    delta_theta = function(d, i, data) {
      return 2 * Math.PI / data.length;
    };
    theta = function(d, i, data) {
      return theta_0(d, i, data) + i * delta_theta(d, i, data);
    };
    self = function(data) {
      data.forEach(function(d, i) {
        d.rho = rho(d, i, data);
        d.theta = theta(d, i, data);
        d.x = d.rho * Math.cos(d.theta);
        return d.y = d.rho * Math.sin(d.theta);
      });
      return data;
    };
    self.rho = function(x) {
      if (x != null) {
        if (typeof x === 'function') {
          rho = x;
        } else {
          rho = function() {
            return x;
          };
        }
        return self;
      }
      return rho;
    };
    self.theta_0 = function(x) {
      if (x != null) {
        if (typeof x === 'function') {
          theta_0 = x;
        } else {
          theta_0 = function() {
            return x;
          };
        }
        return self;
      }
      return theta_0;
    };
    self.delta_theta = function(x) {
      if (x != null) {
        if (typeof x === 'function') {
          delta_theta = x;
        } else {
          delta_theta = function() {
            return x;
          };
        }
        return self;
      }
      return delta_theta;
    };
    self.theta = function(x) {
      if (x != null) {
        if (typeof x === 'function') {
          theta = x;
        } else {
          theta = function() {
            return x;
          };
        }
        return self;
      }
      return theta;
    };
    return self;
  };

  circular = circular_layout().rho(160);

  circular(graph_data.nodes);

  MAX_WIDTH = 40;

  links_layer = svg.append('g');

  nodes_layer = svg.append('g');

  radius = d3.scale.sqrt().domain([
    0, d3.min(graph_data.nodes, function(n) {
      return n.size;
    })
  ]).range([0, MAX_WIDTH / 2]);

  nodes = nodes_layer.selectAll('.node').data(graph_data.nodes);

  nodes.enter().append('circle').attr({
    "class": 'node',
    r: function(node) {
      return radius(node.size);
    },
    cx: function(node) {
      return node.x + (4 + radius(node.size)) * Math.cos(node.theta);
    },
    cy: function(node) {
      return node.y + (4 + radius(node.size)) * Math.sin(node.theta);
    }
  });

  labels = nodes_layer.selectAll('.label').data(graph_data.nodes);

  labels.enter().append('text').text(function(node) {
    return node.id;
  }).attr({
    "class": 'label',
    dy: '0.35em',
    x: function(node) {
      return node.x + (4 + radius(node.size)) * Math.cos(node.theta);
    },
    y: function(node) {
      return node.y + (4 + radius(node.size)) * Math.sin(node.theta);
    }
  });

  max = d3.max(graph_data.nodes, function(n) {
    return n.degree;
  });

  link_thickness = d3.scale.linear().domain([0, max]).range([0, MAX_WIDTH]);

  links = links_layer.selectAll('.link').data(graph_data.links);

  tension = 0;

  links.enter().append('path').attr({
    "class": 'link',
    'stroke-width': function(link) {
      return link_thickness(link.weight);
    }
  });

  redraw = function() {
    return links.attr({
      d: function(link) {
        var cxs, cxt, cys, cyt, sankey_ds, sankey_dt, sankey_dxs, sankey_dxt, sankey_dys, sankey_dyt, xs, xt, ys, yt;

        sankey_ds = link_thickness(link.source.degree) / 2 - link_thickness(link.sankey_source.middle);
        sankey_dt = link_thickness(link.target.degree) / 2 - link_thickness(link.sankey_target.middle);
        sankey_dxs = sankey_ds * Math.cos(link.source.theta + Math.PI / 2);
        sankey_dys = sankey_ds * Math.sin(link.source.theta + Math.PI / 2);
        sankey_dxt = sankey_dt * Math.cos(link.target.theta + Math.PI / 2);
        sankey_dyt = sankey_dt * Math.sin(link.target.theta + Math.PI / 2);
        xs = link.source.x + sankey_dxs;
        ys = link.source.y + sankey_dys;
        xt = link.target.x + sankey_dxt;
        yt = link.target.y + sankey_dyt;
        cxs = xs - link.source.x * tension;
        cys = ys - link.source.y * tension;
        cxt = xt - link.target.x * tension;
        cyt = yt - link.target.y * tension;
        return "M" + xs + " " + ys + " C" + cxs + " " + cys + " " + cxt + " " + cyt + " " + xt + " " + yt;
      }
    });
  };

  tension_scale = d3.scale.linear().domain([0, 1]).range([-width / 2 + 50, width / 2 - 50]).clamp(true);

  tension_brush = d3.svg.brush().x(tension_scale).extent([0, 0]);

  TENSION_SLIDER_Y = -240;

  svg.append('g').attr('class', 'x axis').attr('transform', "translate(0," + TENSION_SLIDER_Y + ")").call(d3.svg.axis().scale(tension_scale).orient('bottom').tickFormat(function(d) {
    return d;
  }).tickSize(0).tickPadding(12)).select('.domain').select(function() {
    return this.parentNode.appendChild(this.cloneNode(true));
  }).attr('class', 'halo');

  slider = svg.append('g').attr('class', 'slider').call(tension_brush);

  slider.selectAll('.extent,.resize').remove();

  slider.select('.background').attr('transform', "translate(0," + (TENSION_SLIDER_Y - 11) + ")").attr('height', 22);

  handle = slider.append('circle').attr('class', 'handle').attr('transform', "translate(0," + TENSION_SLIDER_Y + ")").attr('r', 9);

  tension_brush.extent([0.1, 0.1]);

  slider.call(tension_brush.event).transition().duration(1800).call(tension_brush.extent([0.4, 0.4])).call(tension_brush.event);

  brushed = function() {
    tension = tension_brush.extent()[0];
    if (d3.event.sourceEvent) {
      tension = tension_scale.invert(d3.mouse(this)[0]);
      tension_brush.extent([tension, tension]);
    }
    handle.attr('cx', tension_scale(tension));
    return redraw();
  };

  tension_brush.on('brush', brushed);

}).call(this);

index.html

<!DOCTYPE html>
<html>
	<head>
        <meta charset="utf-8">
        <meta name="description" content="Node-link circular layout: Sankey links" />
        <title>Node-link circular layout: Sankey links</title>
		<link type="text/css" href="index.css" rel="stylesheet"/>
        <script src="//d3js.org/d3.v3.min.js"></script>
	</head>
	<body>
        <svg height="500" width="960"></svg>
        <script src="index.js"></script>
	</body>
</html>

index.coffee

# data
graph_data = {
  nodes: [
    {id: 'A', size: 14},
    {id: 'B', size: 56},
    {id: 'C', size: 26},
    {id: 'D', size: 16},
    {id: 'E', size: 32},
    {id: 'F', size: 16},
    {id: 'G', size: 12}
  ],
  links: [
    {source: 'A', target: 'B', weight: 12},
    {source: 'A', target: 'C', weight:  2},
    {source: 'A', target: 'D', weight: 33},
    {source: 'A', target: 'F', weight:  5},
    {source: 'A', target: 'G', weight: 24},
    {source: 'B', target: 'D', weight: 10},
    {source: 'B', target: 'E', weight: 10},
    {source: 'B', target: 'F', weight:  8},
    {source: 'B', target: 'G', weight: 16},
    {source: 'C', target: 'D', weight: 29},
    {source: 'C', target: 'E', weight: 11},
    {source: 'D', target: 'E', weight:  4},
    {source: 'D', target: 'F', weight: 12},
    {source: 'E', target: 'F', weight: 19}
  ]
}

# objectify the graph
# resolve node IDs (not optimized at all!)
objectify = (graph) ->
  graph.links.forEach (l) ->
    graph.nodes.forEach (n) ->
      if l.source is n.id
        l.source = n
      
      if l.target is n.id
        l.target = n
      
objectify(graph_data)

# create link arrays for each node
list_links = (graph) ->
  graph.nodes.forEach (n) ->
    n.links = graph.links.filter (link) -> link.source is n or link.target is n
    
    # sort in decreasing weight order
    n.links.sort (a,b) -> a.weight-b.weight
    
list_links(graph_data)

# sankeify the graph
sankey = (graph) ->
  graph.nodes.forEach (n) ->
    acc = 0
    n.links.forEach (link) ->
      if link.source is n
        link.sankey_source = {
          start: acc,
          middle: acc + link.weight/2,
          end: acc += link.weight
        }
      else if link.target is n
        link.sankey_target = {
          start: acc,
          middle: acc + link.weight/2,
          end: acc += link.weight
        }
  
sankey(graph_data)

# compute node weighted degrees (sankey totals)
compute_degree = (graph) ->
  graph.nodes.forEach (n) ->
    n.degree = d3.sum n.links, (link) -> link.weight

compute_degree(graph_data)
    
svg = d3.select('svg')
width = svg.node().getBoundingClientRect().width
height = svg.node().getBoundingClientRect().height

# translate the viewBox to have (0,0) at the center of the vis
svg
  .attr
    viewBox: "#{-width/2} #{-height/2-30} #{width} #{height}"
    
    
# layout
circular_layout = () ->
  rho = (d, i, data) -> 100
  theta_0 = (d, i, data) -> -Math.PI/2 # start from the angle pointing north
  delta_theta = (d, i, data) -> 2*Math.PI/data.length
  theta = (d, i , data) -> theta_0(d, i, data) + i*delta_theta(d, i, data)
  
  self = (data) ->
    data.forEach (d, i) ->
      d.rho = rho(d, i, data)
      d.theta = theta(d, i, data)
      d.x = d.rho * Math.cos(d.theta)
      d.y = d.rho * Math.sin(d.theta)
      
    return data
    
  self.rho = (x) ->
    if x?
      if typeof(x) is 'function'
        rho = x
      else
        rho = () -> x
        
      return self
    
    # else
    return rho
    
  self.theta_0 = (x) ->
    if x?
      if typeof(x) is 'function'
        theta_0 = x
      else
        theta_0 = () -> x
      
      return self
    
    # else
    return theta_0
  
  self.delta_theta = (x) ->
    if x?
      if typeof(x) is 'function'
        delta_theta = x
      else
        delta_theta = () -> x
      
      return self
    
    # else
    return delta_theta
  
  self.theta = (x) ->
    if x?
      if typeof(x) is 'function'
        theta = x
      else
        theta = () -> x
      
      return self
    
    # else
    return theta
  
  return self

# apply the layout
circular = circular_layout()
  .rho(160)
  
circular(graph_data.nodes)

MAX_WIDTH = 40

# draw nodes above links
links_layer = svg.append('g')
nodes_layer = svg.append('g')

radius = d3.scale.sqrt()
  .domain([0, d3.min graph_data.nodes, (n) -> n.size])
  .range([0, MAX_WIDTH/2])

nodes = nodes_layer.selectAll('.node')
  .data(graph_data.nodes)
  
nodes.enter().append('circle')
  .attr
    class: 'node'
    r: (node) -> radius(node.size)
    cx: (node) -> node.x + (4+radius(node.size))*Math.cos(node.theta)
    cy: (node) -> node.y + (4+radius(node.size))*Math.sin(node.theta)
    
# draw node labels
labels = nodes_layer.selectAll('.label')
  .data(graph_data.nodes)
  
labels.enter().append('text')
  .text((node) -> node.id)
  .attr
    class: 'label'
    dy: '0.35em'
    x: (node) -> node.x + (4+radius(node.size))*Math.cos(node.theta)
    y: (node) -> node.y + (4+radius(node.size))*Math.sin(node.theta)
    
    
max = d3.max graph_data.nodes, (n) -> n.degree
link_thickness = d3.scale.linear()
  .domain([0, max])
  .range([0, MAX_WIDTH])
  
links = links_layer.selectAll('.link')
  .data(graph_data.links)
  
tension = 0

links.enter().append('path')
  .attr
    class: 'link'
    'stroke-width': (link) -> link_thickness(link.weight)
    
redraw = () ->
  links
    .attr
      d: (link) ->
        sankey_ds = link_thickness(link.source.degree)/2 - link_thickness(link.sankey_source.middle)
        sankey_dt = link_thickness(link.target.degree)/2 - link_thickness(link.sankey_target.middle)
        
        sankey_dxs = sankey_ds*Math.cos(link.source.theta+Math.PI/2)
        sankey_dys = sankey_ds*Math.sin(link.source.theta+Math.PI/2)
        sankey_dxt = sankey_dt*Math.cos(link.target.theta+Math.PI/2)
        sankey_dyt = sankey_dt*Math.sin(link.target.theta+Math.PI/2)
        
        xs = link.source.x + sankey_dxs
        ys = link.source.y + sankey_dys
        xt = link.target.x + sankey_dxt
        yt = link.target.y + sankey_dyt
        
        cxs = xs-link.source.x*tension
        cys = ys-link.source.y*tension
        cxt = xt-link.target.x*tension
        cyt = yt-link.target.y*tension
        return "M#{xs} #{ys} C#{cxs} #{cys} #{cxt} #{cyt} #{xt} #{yt}"
        
  
# draw the controls
tension_scale = d3.scale.linear()
  .domain([0, 1])
  .range([-width/2+50, width/2-50])
  .clamp(true)

tension_brush = d3.svg.brush()
    .x(tension_scale)
    .extent([0, 0])
    
TENSION_SLIDER_Y = -240

svg.append('g')
    .attr('class', 'x axis')
    .attr('transform', "translate(0,#{TENSION_SLIDER_Y})")
    .call(d3.svg.axis()
      .scale(tension_scale)
      .orient('bottom')
      .tickFormat((d) -> d)
      .tickSize(0)
      .tickPadding(12))
  .select('.domain')
  .select(() -> this.parentNode.appendChild(this.cloneNode(true)) )
    .attr('class', 'halo')

slider = svg.append('g')
    .attr('class', 'slider')
    .call(tension_brush)

slider.selectAll('.extent,.resize')
    .remove()

slider.select('.background')
    .attr('transform', "translate(0,#{TENSION_SLIDER_Y-11})")
    .attr('height', 22)

handle = slider.append('circle')
    .attr('class', 'handle')
    .attr('transform', "translate(0,#{TENSION_SLIDER_Y})")
    .attr('r', 9)
    
# initial animation
tension_brush.extent([0.1, 0.1])
slider
    .call(tension_brush.event)
  .transition()
    .duration(1800)
    .call(tension_brush.extent([0.4, 0.4]))
    .call(tension_brush.event)
    
brushed = () ->
  tension = tension_brush.extent()[0]

  if d3.event.sourceEvent # not a programmatic event
    tension = tension_scale.invert(d3.mouse(this)[0])
    tension_brush.extent([tension, tension])

  handle.attr('cx', tension_scale(tension))
  
  # redraw the links
  redraw()
  
tension_brush
  .on('brush', brushed)

index.css

svg {
  background: white;
}

.node {
  fill: lightgray;
  stroke: gray;
  stroke-width: 2px;
}
.link {
  stroke: black;
  fill: none;
  opacity: 0.1;
}
.label {
  text-anchor: middle;
  font-size: 16px;
  fill: #444;
  font-weight: bold;
  font-family: sans-serif;
}

.axis {
  font: 10px sans-serif;
  -webkit-user-select: none;
  -moz-user-select: none;
  user-select: none;
}
.axis .domain {
  fill: none;
  stroke: #000;
  stroke-opacity: .3;
  stroke-width: 10px;
  stroke-linecap: round;
}
.axis .halo {
  fill: none;
  stroke: #ddd;
  stroke-width: 8px;
  stroke-linecap: round;
}
.slider .handle {
  fill: #fff;
  stroke: #000;
  stroke-opacity: .5;
  stroke-width: 1.25px;
  pointer-events: none;
}