Search code examples
javascriptsvgd3.jsbundle-layout

Non-hierarchical (without gaps between groups) bundle layout in D3


I'm trying to make a bundle layout with D3. However, I'd like to make it non-hierarchical (or pseudo non-hierarchical) so as to have no gaps between the edge nodes. The image below hopefully serves as a good example of the gaps I would like to remove (see top of image between different coloured text).

Bundle chart

I've attempted to remedy this two ways: by replacing the type which distinguishes node types with a generic type, and by replacing the node type with the authors' names (thereby making all nodes a different type, forcing equal spacing). The former of these attempts being similar to the suggestion proposed in response to this query.

However, when I run the line

 var nodes = cluster.nodes(packageHierarchy(theArr, id_to_user_dict)); 

I inevitably get the error "Uncaught TypeError: Cannot read property 'parent' of undefined." How can I force a generic root on this data so that my code works properly?

My code to generate the code structure for the included diagram is below. The two solutions that I proposed above are commented out within the "package hierarchy" function. Note that it is derived from Bostock's sample for hierarchical edge bundling.

function packageHierarchy(classes, dictionary) {
for (var i = 0; i < classes.length; i++){
    var imports = [];
    // classes[i].name = "root" + "^" + classes[i]["author"];
    // classes[i].name = classes[i]["author"] + "^" + classes[i]["author"];
    classes[i].name = classes[i]["type"] + "^" + classes[i]["author"];
    for (var j = 0; j < classes[i]["values"].length; j++){
        if (classes[i]["values"][j]["type"] == "comments"){
            var responseObj = classes[i]["values"][j]["in_response_to"];
            var responseAuthor = dictionary[classes[i]["values"][j]["in_response_to"]];
            if (responseAuthor != undefined){
                imports.push(responseAuthor);
            }
        }
    }
    classes[i].imports = imports;

}


var map = {};

function find(name, data) {
    var node = map[name], i;
    if (!node) {
        node = map[name] = data || {name: name, children: [] };
        if (name.length) {
                node.parent = find(name.substring(0, i = name.indexOf("^")));
                node.parent.children.push(node);
                node.key = name.substring(i + 1);
        }
    }
    return node;
}

classes.forEach(function(d) {
    find(d.name, d);
});

return map[""];
}

Solution

  • You already mentioned two ideas for the solution, however, I would suggest third, completely different idea.

    The distance between groups is determined by D3 cluster layout. The default behavior is to leave empty space of size of one node between groups.

    I have this code snippet involving D3 bundle layout, with default gap between groups:

    var diameter = 300,
        radius = diameter / 2,
        innerRadius = radius - 80;
    
    var cluster = d3.layout.cluster()
        .size([360, innerRadius])
        .sort(null)
        .value(function(d) { return d.size; });
    
    var bundle = d3.layout.bundle();
    
    var line = d3.svg.line.radial()
        .interpolate("bundle")
        .tension(.85)
        .radius(function(d) { return d.y; })
        .angle(function(d) { return d.x / 180 * Math.PI; });
    
    var svg = d3.select("body").append("svg")
        .attr("width", diameter)
        .attr("height", diameter)
        .append("g")
        .attr("transform", "translate(" + radius + "," + radius + ")");
    
    var classes = getData();
    
    var nodes = cluster.nodes(packageHierarchy(classes)),
        links = packageImports(nodes);
    
    svg.selectAll(".link")
        .data(bundle(links))
       .enter()
        .append("path")
        .attr("class", "link")
        .attr("d", line);
    
    svg.selectAll(".node")
        .data(nodes.filter(function(n) { return !n.children; }))
       .enter()
        .append("g")
        .attr("class", "node")
        .attr("transform", function(d) {
            return "rotate(" + (d.x - 90) + ")translate(" + d.y + ")";
        })
        .append("text")
        .attr("dx", function(d) { return d.x < 180 ? 8 : -8; })
        .attr("dy", ".31em")
        .attr("text-anchor", function(d) { return d.x < 180 ? "start" : "end"; })
        .attr("transform", function(d) { return d.x < 180 ? null : "rotate(180)"; })
        .text(function(d) { return d.key; });
    
    d3.select(self.frameElement).style("height", diameter + "px");
    
    // Lazily construct the package hierarchy from class names.
    function packageHierarchy(classes) {
      var map = {};
    
      function find(name, data) {
        var node = map[name], i;
        if (!node) {
          node = map[name] = data || {name: name, children: []};
          if (name.length) {
            node.parent = find(name.substring(0, i = name.lastIndexOf(".")));
            node.parent.children.push(node);
            node.key = name.substring(i + 1);
          }
        }
        return node;
      }
    
      classes.forEach(function(d) {
        find(d.name, d);
      });
    
      return map[""];
    }
    
    // Return a list of imports for the given array of nodes.
    function packageImports(nodes) {
      var map = {},
          imports = [];
    
      // Compute a map from name to node.
      nodes.forEach(function(d) {
        map[d.name] = d;
      });
    
      // For each import, construct a link from the source to target node.
      nodes.forEach(function(d) {
        if (d.imports) d.imports.forEach(function(i) {
          imports.push({source: map[d.name], target: map[i]});
        });
      });
    
      return imports;
    }
    
    function getData() {
        return [
            {"name":"Female.Ann",     "imports":["Male.Andrew"]},
            {"name":"Female.Brenda",  "imports":["Male.Bill"]},
            {"name":"Female.Claudia", "imports":["Male.Andrew"]},
            {"name":"Female.Claudia", "imports":["Male.Bill"]},
            {"name":"Female.Deborah", "imports":["Male.Darryl"]},
            {"name":"Female.Emily",   "imports":["Male.Andrew"]},
            {"name":"Female.Flora",   "imports":["Male.Darryl"]},
            {"name":"Female.Gia",     "imports":["Male.Darryl"]},
            {"name":"Female.Hannah",  "imports":["Male.Curtis"]},
            {"name":"Female.Irene",   "imports":["Male.Hugh"]},
            {"name":"Male.Andrew",    "imports":["Female.Emily"]},
            {"name":"Male.Bill",      "imports":["Female.Emily"]},
            {"name":"Male.Curtis",    "imports":["Female.Emily"]},
            {"name":"Male.Darryl",    "imports":["Female.Claudia"]},
            {"name":"Male.Edgar",     "imports":["Female.Claudia"]},
            {"name":"Male.Franklin",  "imports":["Female.Claudia"]},
            {"name":"Male.George",    "imports":["Female.Claudia"]},
            {"name":"Male.Hugh",      "imports":["Female.Claudia"]},
        ];
    };
    .node {
      font: 11px "Helvetica Neue", Helvetica, Arial, sans-serif;
    }
    
    .link {
      stroke: steelblue;
      stroke-opacity: .4;
      fill: none;
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>

    enter image description here

    This can be modified by providing custom separation() function to the cluster layout.

    In the following snippet, I added only a line:

        .separation(function(a, b) { return 1; })
    

    and the gap disappeared!

    var diameter = 300,
        radius = diameter / 2,
        innerRadius = radius - 80;
    
    var cluster = d3.layout.cluster()
        .separation(function(a, b) { return 1; })
        .size([360, innerRadius])
        .sort(null)
        .value(function(d) { return d.size; });
    
    var bundle = d3.layout.bundle();
    
    var line = d3.svg.line.radial()
        .interpolate("bundle")
        .tension(.85)
        .radius(function(d) { return d.y; })
        .angle(function(d) { return d.x / 180 * Math.PI; });
    
    var svg = d3.select("body").append("svg")
        .attr("width", diameter)
        .attr("height", diameter)
        .append("g")
        .attr("transform", "translate(" + radius + "," + radius + ")");
    
    var classes = getData();
    
    var nodes = cluster.nodes(packageHierarchy(classes)),
        links = packageImports(nodes);
    
    svg.selectAll(".link")
        .data(bundle(links))
       .enter()
        .append("path")
        .attr("class", "link")
        .attr("d", line);
    
    svg.selectAll(".node")
        .data(nodes.filter(function(n) { return !n.children; }))
       .enter()
        .append("g")
        .attr("class", "node")
        .attr("transform", function(d) {
            return "rotate(" + (d.x - 90) + ")translate(" + d.y + ")";
        })
        .append("text")
        .attr("dx", function(d) { return d.x < 180 ? 8 : -8; })
        .attr("dy", ".31em")
        .attr("text-anchor", function(d) { return d.x < 180 ? "start" : "end"; })
        .attr("transform", function(d) { return d.x < 180 ? null : "rotate(180)"; })
        .text(function(d) { return d.key; });
    
    d3.select(self.frameElement).style("height", diameter + "px");
    
    // Lazily construct the package hierarchy from class names.
    function packageHierarchy(classes) {
      var map = {};
    
      function find(name, data) {
        var node = map[name], i;
        if (!node) {
          node = map[name] = data || {name: name, children: []};
          if (name.length) {
            node.parent = find(name.substring(0, i = name.lastIndexOf(".")));
            node.parent.children.push(node);
            node.key = name.substring(i + 1);
          }
        }
        return node;
      }
    
      classes.forEach(function(d) {
        find(d.name, d);
      });
    
      return map[""];
    }
    
    // Return a list of imports for the given array of nodes.
    function packageImports(nodes) {
      var map = {},
          imports = [];
    
      // Compute a map from name to node.
      nodes.forEach(function(d) {
        map[d.name] = d;
      });
    
      // For each import, construct a link from the source to target node.
      nodes.forEach(function(d) {
        if (d.imports) d.imports.forEach(function(i) {
          imports.push({source: map[d.name], target: map[i]});
        });
      });
    
      return imports;
    }
    
    function getData() {
        return [
            {"name":"Female.Ann",     "imports":["Male.Andrew"]},
            {"name":"Female.Brenda",  "imports":["Male.Bill"]},
            {"name":"Female.Claudia", "imports":["Male.Andrew"]},
            {"name":"Female.Claudia", "imports":["Male.Bill"]},
            {"name":"Female.Deborah", "imports":["Male.Darryl"]},
            {"name":"Female.Emily",   "imports":["Male.Andrew"]},
            {"name":"Female.Flora",   "imports":["Male.Darryl"]},
            {"name":"Female.Gia",     "imports":["Male.Darryl"]},
            {"name":"Female.Hannah",  "imports":["Male.Curtis"]},
            {"name":"Female.Irene",   "imports":["Male.Hugh"]},
            {"name":"Male.Andrew",    "imports":["Female.Emily"]},
            {"name":"Male.Bill",      "imports":["Female.Emily"]},
            {"name":"Male.Curtis",    "imports":["Female.Emily"]},
            {"name":"Male.Darryl",    "imports":["Female.Claudia"]},
            {"name":"Male.Edgar",     "imports":["Female.Claudia"]},
            {"name":"Male.Franklin",  "imports":["Female.Claudia"]},
            {"name":"Male.George",    "imports":["Female.Claudia"]},
            {"name":"Male.Hugh",      "imports":["Female.Claudia"]},
        ];
    };
    .node {
      font: 11px "Helvetica Neue", Helvetica, Arial, sans-serif;
    }
    
    .link {
      stroke: steelblue;
      stroke-opacity: .4;
      fill: none;
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
    enter image description here