Pierre
Pierre

Reputation: 943

Drawing a collapsible indented tree with d3

'm drawing an indented tree with d3. I started from Mike Rostock's code and made a few modifications in order to: 1) display a right/down arrow except on leaves; 2) add a check box to each line; 3) hide the root node.

Code is below, it takes any data, I call the drawIntentedTree function with two arguments: the root node, and the div id in which the tree is sketched.

enter image description here

As on might see on the picture, there are a few issues in the code, for which help would be appreciated: 1. the root/start node is redrawn while expending a tree branch, resulting in overlapping the left and down arrow, see SCL line. 2. a similar issue is observed with the check box, which is basically an x hidden with a transparent on white rect. My first intension was to fill the box with the stroke color, but would have to figure out what the css color is for each line since it changes.

Along with addressing these two issues, I had the intension to draw straight lines between nodes, but the original codes draws curly lines instead and allow a intermediate sate (partly collapsed) between collapsed and expended, with a 45° rotated arrow, showing only checked boxes in a branch. Additionally, I'd like branches to be collapsed or partly collapsed when expending another branch to avoid far down scrolling.

Mike Bostock is using a trick to display/hide part of the tree, he backs up children in _children then assigns children to null to hide collapsed branches, but redrawing always starts at the root node and I didn't manage to: 1) avoid the root node redrawing; 2) rotate the preexisting left triangle by 90 or 90°.

Many questions in one post, I'd appreciate any help on any part. jsfiddle link.

d3js code:

function drawIndentedTree(root, wherein) {

var width = 300, minHeight = 800;
var barHeight = 20, barWidth = 50;

var margin = {
        top: -10,
        bottom: 10,
        left: 0,
        right: 10
    }

var i = 0, duration = 200;

var tree = d3.layout.tree()
    .nodeSize([0, 20]);

var diagonal = d3.svg.diagonal()
    .projection(function(d) { return [d.y, d.x]; });

var svg = d3.select("#"+wherein).append("svg")
    .attr("width", width + margin.left + margin.right)
    .append("g")
    .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

// set initial coordinates
root.x0 = 0;
root.y0 = 0;

// collapse all nodes recusively, hence initiate the tree
function collapse(d) {
    d.Selected = false;
    if (d.children) {
        d.numOfChildren = d.children.length;
        d._children = d.children;
        d._children.forEach(collapse);
        d.children = null;
    }
    else {
        d.numOfChildren = 0;
    }
}
root.children.forEach(collapse);

update(root);

function update(source) {

    // Compute the flattened node list. TODO use d3.layout.hierarchy.
    var nodes = tree.nodes(root);

    height = Math.max(minHeight, nodes.length * barHeight + margin.top + margin.bottom);

    d3.select("svg").transition()
        .duration(duration)
        .attr("height", height);

    d3.select(self.frameElement).transition()
        .duration(duration)
        .style("height", height + "px");

    // Compute the "layout".
    nodes.forEach(function(n, i) {
          n.x = i * barHeight;
        });

    // Update the nodes…
    var node = svg.selectAll("g.node")
        .data(nodes, function(d) {
              return d.index || (d.index = ++i); });

    var nodeEnter = node.enter().append("g").filter(function(d) { return d.id != root.id })
        .attr("class", "node")
        .style("opacity", 0.001)
        .attr("transform", function(d) {
              return "translate(" + source.y0 + "," + source.x0 + ")";
        });

    // Enter any new nodes at the parent's previous position.
    nodeEnter.append("path").filter(function(d) { return d.numOfChildren > 0 && d.id != root.id })
        .attr("width", 9)
        .attr("height", 9)
        .attr("d", "M -3,-4, L -3,4, L 4,0 Z")
        .attr("class", function(d) { return "node "+d.type; } )
        .attr("transform", function(d) {
              if (d.children) {
                return "translate(-14, 0)rotate(90)";
              }
              else {
                return "translate(-14, 0)rotate(0)";
              }
            })
        .on("click", click);

    // Enter any new nodes at the parent's previous position.
    nodeEnter.append("rect").filter(function(d) { return d.id != root.id })
        .attr("width", 11)
        .attr("height", 11)
        .attr("y", -5)
        .attr("class", function(d) { return "node "+d.type; } );

// check box filled with 'x' or '+'
    nodeEnter.append("text")
        .attr("dy", 4)
        .attr("dx", 2)
        .attr("class", function(d) { return "node "+d.type+" text"; } )
        .text("x");

    nodeEnter.append("rect").filter(function(d) { return d.parent })
        .attr("width", 9)
        .attr("height", 9)
        .attr("x", 1)
        .attr("y", -4)
        .attr("class", "node select")
        .attr("style", function(d) { return "fill: "+boxStyle(d) })
        .on("click", check);

    nodeEnter.append("text")
        .attr("dy", 5)
        .attr("dx", 14)
        .attr("class", function(d) { return "node "+d.type+" text"; } )
        .text(function(d) { return d.Name; });

    // Transition nodes to their new position.
    nodeEnter.transition()
        .duration(duration)
        .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; })
        .style("opacity", 1);

    node.transition()
        .duration(duration)
        .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; })
        .style("opacity", 1)
        .select("rect");

    // Transition exiting nodes to the parent's new position.
    node.exit().transition()
        .duration(duration)
        .attr("transform", function(d) { return "translate(" + source.y + "," + source.x + ")"; })
        .style("opacity", 1e-6)
        .remove();

    // Stash the old positions for transition.
    nodes.forEach(function(d) {
          d.x0 = d.x;
          d.y0 = d.y;
      });
}

// Toggle children on click.
function click(d) {
    if (d.children) {
        d3.select(this).attr("translate(-14, 0)rotate(90)");
        d._children = d.children;
        d.children = null;
    } else if (d._children) {
        d.children = d._children;
        d._children = null;
    }
    update(d);
}

// Toggle check box on click.
function check(d) {
    d.Selected = !d.Selected;
    d3.select(this).style("fill", boxStyle(d));
}

function boxStyle(d) {
    return d.Selected ? "transparent" : "white";
}
}

var wherein = "chart";
var root = {
"name": "AUT-1",
"children": [
    {
        "name": "PUB-1","children": [
            {"name": "AUT-11","children": [
                {"name": "AFF-111"},
                {"name": "AFF-112"}
            ]},
            {"name": "AUT-12","children": [
                {"name": "AFF-121"}
            ]},
            {"name": "AUT-13","children": [
                {"name": "AFF-131"},
                {"name": "AFF-132"}
            ]},
            {"name": "AUT-14","children": [
                {"name": "AFF-141"}
            ]}
        ]
    },
    {
        "name": "PUB-2","children": [
            {"name": "AUT-21"},
            {"name": "AUT-22"},
            {"name": "AUT-23"},
            {"name": "AUT-24"},
            {"name": "AUT-25"},
            {"name": "AUT-26"},
            {"name": "AUT-27"},
            {"name": "AUT-28","children":[
                {"name": "AFF-281"},
                {"name": "AFF-282"},
                {"name": "AFF-283"},
                {"name": "AFF-284"},
                {"name": "AFF-285"},
                {"name": "AFF-286"}
            ]}
        ]
    },
    {"name": "PUB-3"},
    {
        "name": "PUB-4","children": [
            {"name": "AUT-41"},
            {"name": "AUT-42"},
            {"name": "AUT-43","children": [
                {"name": "AFF-431"},
                {"name": "AFF-432"},
                {"name": "AFF-433"},
                {"name": "AFF-434","children":[
                    {"name": "ADD-4341"},
                    {"name": "ADD-4342"},
                ]}
            ]},
            {"name": "AUT-44"}
        ]
    }
]
};

CSS:

.node {
font: 12px sans-serif;
fill: #ccebc5;
stroke: #7c9b75;
stroke-width: 1px;
}

.node circle {
fill: #fff;
stroke: steelblue;
stroke-width: 1.5px;
cursor: pointer;
}

.node rect {
width: 11px;
height: 11px;
cursor: pointer;
}

.node.select {
width: 9px;
height: 9px;
cursor: pointer;
fill: red;
stroke-width: 0px;
}

.node path {
width: 11px;
height: 11px;
cursor: pointer;
}

.node text Panel {
stroke: #08519c;
stroke-width: 0.5px;
}

.node text Cell {
stroke: #a50f15;
stroke-width: 0.5px;
}

.node.Root {
fill: #f7f7f7;
stroke: #505050;
stroke-width: 1.0px;
}

.node.Root.text {
fill: #505050;
stroke-width: 0px;
font-size: 10px;
font-family: sans-serif;
}

.node.Panel {
fill: #eff3ff;
stroke: #08519c;
stroke-width: 1.0px;
}

.node.Panel.text {
fill: #08519c;
stroke-width: 0px;
font-size: 12px;
font-family: sans-serif;
}

.node.Cell {
fill: #fee5d9;
stroke: #a50f15;
stroke-width: 1.0px;
}

.node.Cell.text {
fill: #a50f15;
stroke-width: 0px;
font-size: 12px;
font-family: sans-serif;
}

Upvotes: 0

Views: 3268

Answers (3)

Pierre
Pierre

Reputation: 943

Starting from the above code, one can make the following changes to design a partially collapsible tree. It starts with a collapsed tree, one click on the arrow expends a branch, clicks in check boxes select items. A new click on the arrow partly collapse the tree, selected items remain visible, a new click collapse it all. Selections are kept while collapsing/expending. enter image description here

Blocks of modified code:

// rotate the arrow up, down and third way down on expensing/collapsing
    node.select("path").attr("transform", function(d) {
            if (d.children) {
                if (!d._children) {
                    return "translate(-14, 0)rotate(90)";
                }
                else {
                    return "translate(-14, 0)rotate(30)";
                }
            }
            else {
                return "translate(-14, 0)rotate(0)";
            }
        });

// toggle between the three states
function click(d) {
    if (d.children) {
        if (!d._children) {
            // backup children
            d._children = d.children;
            // restrick to selected items
            d.children = marked = d.children.filter(function(d) { return d.Selected });
        }
        else {
            // partly collapsed -> collapse all
            d.children = null;
        }
    } else if (d._children) {
        d.children = d._children;
        d._children = null;
    }
    update(d);
}

Upvotes: 0

Pierre
Pierre

Reputation: 943

With the great help of Mark, issues are now solved and the corrected code is below. I replaced the x text in the check box with a path.

Further improvement - for me - will be to combine the arrow rotation to the node motion and then to allow partial collapsing and auto-collapsing as described above and may be add straight lines between node , might be ugly.

enter image description here

function drawIndentedTree(root, wherein) {

var width = 300, minHeight = 800;
var barHeight = 20, barWidth = 50;

var margin = {
        top: -10,
        bottom: 10,
        left: 0,
        right: 10
    }

var i = 0, duration = 200;

var tree = d3.layout.tree()
    .nodeSize([0, 20]);

var diagonal = d3.svg.diagonal()
    .projection(function(d) { return [d.y, d.x]; });

var svg = d3.select("#"+wherein).append("svg")
    .attr("width", width + margin.left + margin.right)
    .append("g")
    .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

// set initial coordinates
root.x0 = 0;
root.y0 = 0;

// collapse all nodes recusively, hence initiate the tree
function collapse(d) {
    d.Selected = false;
    if (d.children) {
        d.numOfChildren = d.children.length;
        d._children = d.children;
        d._children.forEach(collapse);
        d.children = null;
    }
    else {
        d.numOfChildren = 0;
    }
}
root.children.forEach(collapse);

update(root);

function update(source) {

    // Compute the flattened node list. TODO use d3.layout.hierarchy.
    var nodes = tree.nodes(root);

    height = Math.max(minHeight, nodes.length * barHeight + margin.top + margin.bottom);

    d3.select("svg").transition()
        .duration(duration)
        .attr("height", height);

    // Compute the "layout".
    nodes.forEach(function(n, i) {
          n.x = i * barHeight;
        });

    // Update the nodes…
    var node = svg.selectAll("g.node")
        .data(nodes, function(d) {
              return d.index || (d.index = ++i); });

    var nodeEnter = node.enter().append("g")
        .attr("class", "node")
        .style("opacity", 0.001)
        .attr("transform", function(d) {
              return "translate(" + source.y0 + "," + source.x0 + ")";
        });

    // Enter any new nodes at the parent's previous position.
    nodeEnter.append("path").filter(function(d) { return d.numOfChildren > 0 && d.id != root.id })
        .attr("width", 9)
        .attr("height", 9)
        .attr("d", "M -3,-4, L -3,4, L 4,0 Z")
        .attr("class", function(d) { return "node "+d.type; } )
        .attr("transform", "translate(-14, 0)")
        .on("click", click);

    node.select("path").attr("transform", function(d) {
            if (d.children) {
                return "translate(-14, 0)rotate(90)";
            }
            else {
                return "translate(-14, 0)rotate(0)";
            }
        });

    // Enter any new nodes at the parent's previous position.
    nodeEnter.append("rect").filter(function(d) { return d.id != root.id })
        .attr("width", 11)
        .attr("height", 11)
        .attr("y", -5)
        .attr("class", function(d) { return "node "+d.type; } );

    nodeEnter.append("path").filter(function(d) { return d.parent })
        .attr("width", 9)
        .attr("height", 9)
        .attr("d", "M -5,-5, L -5,6, L 6,6, L 6,-5 Z M -5,-5, L 6,6, M -5,6 L 6,-5")
        .attr("class", function(d) { return "node "+d.type; } )
        .attr("style", function(d) { return "opacity: "+boxStyle(d) })
        .attr("transform", "translate(5, 0)")
        .on("click", check);

    nodeEnter.append("text")
        .attr("dy", 5)
        .attr("dx", 14)
        .attr("class", function(d) { return "node "+d.type+" text"; } )
        .text(function(d) { return d.Name; });

    // Transition nodes to their new position.
    nodeEnter.transition()
        .duration(duration)
        .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; })
        .style("opacity", 1);

    node.transition()
        .duration(duration)
        .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; })
        .style("opacity", 1)
        .select("rect");

    // Transition exiting nodes to the parent's new position.
    node.exit().transition()
        .duration(duration)
        .attr("transform", function(d) { return "translate(" + source.y + "," + source.x + ")"; })
        .style("opacity", 1e-6)
        .remove();

    // Stash the old positions for transition.
    nodes.forEach(function(d) {
          d.x0 = d.x;
          d.y0 = d.y;
      });
}

// Toggle children on click.
function click(d) {
    if (d.children) {
        d3.select(this).attr("translate(-14, 0)rotate(90)");
        d._children = d.children;
        d.children = null;
    } else if (d._children) {
        d.children = d._children;
        d._children = null;
    }
    update(d);
}

// Toggle check box on click.
function check(d) {
    d.Selected = !d.Selected;
    d3.select(this).style("opacity", boxStyle(d));
}

function boxStyle(d) {
    return d.Selected ? 1 : 0;
}
}

Upvotes: 0

Mark
Mark

Reputation: 108512

I'll update my answer as I work through your questions.

  1. the root/start node is redrawn while expending a tree branch, resulting in overlapping the left and down arrow, see SCL line.

This is a classic example of d3's enter/update/exit. You have nodeEnter variable - what to draw on entering your data - this is the initially drawn elements. You then have node variable - this is all the already drawn stuff. When you toggle the arrow, you are acting on the nodeEnter hence you are re-appending a new path resulting in an overlap. Instead, just update the already existing path and change the transform:

node.select("path").attr("transform", function(d) {
    if (d.children) {
      return "translate(-14, 0) rotate(90)";
    } else {
      return "translate(-14, 0) rotate(0)";
    }
});

Example here.

Upvotes: 1

Related Questions