rkian
rkian

Reputation: 98

d3.js v4 dynamically add nodes to tidy tree

I am trying to do something similar to this question d3.js how to dynamically add nodes to a tree. However, I'm finding it really difficult to get any kind of solution working in v4 of d3.js.

I am building a tree from some preloaded json, and then i want to be able to add child nodes onto the tree when the user clicks on a node. The process of adding child nodes will involve a call to a REST service, which will return some json. Most of my code is based heavily on http://bl.ocks.org/mbostock/4339083 which is an expandable/collapsible tidy tree using v3 and https://bl.ocks.org/mbostock/9d0899acb5d3b8d839d9d613a9e1fe04 which is a non expandable/collapsible tidy tree using v4.

My code is here https://jsfiddle.net/rkian/3e73Lz6d/. I can draw the tree out no problem but I'm really having trouble working out how to add nodes to the tree. I suspect this:

  function click(d) {
    if (!d.children && !d._children) {
      var jsonChildren = $.parseJSON('...');
      d.data.children = jsonChildren;
      d.data._children = jsonChildren;
      updateTree(d);
    }

is key to it all but I'm just not able to work out how to implement it.

Upvotes: 0

Views: 2124

Answers (1)

rkian
rkian

Reputation: 98

There was something odd with the lineage variable (flat array to be turned into hierarchical structure) which I was trying to build my tree up from, when I turned it into a tree it changed the objects within it. I solved this by making a deep copy of the lineage. All of my code is here, I hope it helps someone:

var lineage = $.parseJSON('[{"id":739,"name":"Life","parent":null,"rank":9,"child_count":1},{"id":1,"name":"Eukaryota","parent":739,"rank":1,"child_count":3},{"id":43,"name":"Animalia","parent":1,"rank":2,"child_count":9},{"id":2,"name":"Archaeplastida","parent":1,"rank":2,"child_count":1},{"id":740,"name":"Plantae","parent":1,"rank":2,"child_count":0},{"id":417,"name":"Annelida","parent":43,"rank":3,"child_count":1},{"id":336,"name":"Arthropoda","parent":43,"rank":3,"child_count":6},{"id":228,"name":"Chordata","parent":43,"rank":3,"child_count":3},{"id":222,"name":"Cnidaria","parent":43,"rank":3,"child_count":4},{"id":328,"name":"Mollusca","parent":43,"rank":3,"child_count":1},{"id":548,"name":"Myzozoa","parent":43,"rank":3,"child_count":1},{"id":604,"name":"Nematoda","parent":43,"rank":3,"child_count":1},{"id":467,"name":"Platyhelminthes","parent":43,"rank":3,"child_count":2},{"id":275,"name":"Porifera","parent":43,"rank":3,"child_count":2},{"id":418,"name":"Polychaeta","parent":417,"rank":4,"child_count":3},{"id":419,"name":"Spionida","parent":418,"rank":5,"child_count":1},{"id":452,"name":"Sabellida","parent":418,"rank":5,"child_count":1},{"id":448,"name":"Terebellida","parent":418,"rank":5,"child_count":1},{"id":420,"name":"Spionidae","parent":419,"rank":6,"child_count":2},{"id":699,"name":"Boccardia","parent":420,"rank":7,"child_count":1},{"id":421,"name":"Polydora","parent":420,"rank":7,"child_count":1},{"id":700,"name":"Boccardia proboscidea","parent":699,"rank":8,"child_count":0}]');

$(document).ready(function() {  
  // WTF js.
  var lineage_flat;
  
	// Color manipulation functions and settings for the tree
	function shadeColor2(color, percent) {
		var f=parseInt(color.slice(1),16),t=percent<0?0:255,p=percent<0?percent*-1:percent,R=f>>16,G=f>>8&0x00FF,B=f&0x0000FF;
		return "#"+(0x1000000+(Math.round((t-R)*p)+R)*0x10000+(Math.round((t-G)*p)+G)*0x100+(Math.round((t-B)*p)+B)).toString(16).slice(1);
	}
	function blendColors(c0, c1, p) {
		var f=parseInt(c0.slice(1),16),t=parseInt(c1.slice(1),16),R1=f>>16,G1=f>>8&0x00FF,B1=f&0x0000FF,R2=t>>16,G2=t>>8&0x00FF,B2=t&0x0000FF;
		return "#"+(0x1000000+(Math.round((R2-R1)*p)+R1)*0x10000+(Math.round((G2-G1)*p)+G1)*0x100+(Math.round((B2-B1)*p)+B1)).toString(16).slice(1);
	}
  
  // Color variables
	var main_color = '#FFFFFF';
	var secondary_color = '#FFFFFF';
	var line_color = '#FFF793';
	var max_rank = lineage[lineage.length - 1]['rank'];

	// Used to assign colors to ranks
	function get_rank_colour(d) {
		rank = d.data.data.rank;
		if(rank == 9) {
			return main_color;
		}
		else {
			return blendColors(main_color, secondary_color, (d.data.rank / max_rank))
		}
	}
  
  // Draws a curve between two points
  function connector(d) {
    return 'M' + d.x + ',' + (d.y - 18) + 
      "C" + (d.x + d.parent.x) / 2 + "," + (d.y - 25) + 
      " " + (d.x + d.parent.x) / 2 + "," + (d.parent.y + 25) + 
      " " + d.parent.x + "," + (d.parent.y + 17);
  };   

	// Transition vars
	var duration = 500;

  // Get the width of the container element for the tree
	width = $('#svgcontainer').width();

	// Calculate the height required based on the number of node levels on the ancestry tree
	height = lineage[lineage.length - 1]['rank']* 150;

	// Set the svg element's height
	$('#lifetree').attr('height', height + 'px');

  // Select the svg element in the DOM
  var svg = d3.select("svg")

  // Insert a group container and move it 40 px to the right (to pad the tree contents in the svg container)
  var g = svg.append("g").attr("transform", "translate(40,40)");

  // Create and return a d3 tree object of the correct width and height, run the root hierarchy element through it
  var tree = d3.tree().size([width-200, height - 160]);


  var root = getTreeData(lineage);
  updateTree(root);

  // Get the data for the tree
  function getTreeData(json) {    
    // Save the flat lineage, we have to do this weird parse thing to make a deep copy
    lineage_flat = JSON.parse(JSON.stringify(json));
    
    // This seems to unflatten arrays of objects with parentIds and parents. Wish I'd known about it sooner.
    var dataTree = d3.stratify()
      .id(function(d){ return d.id; })
      .parentId(function(d){  return d.parent; })
      (JSON.parse(JSON.stringify(json)));

    // D3 requires a hierarchy object which then gets made into a tree
    var root = d3.hierarchy(dataTree);
    tree(root);
    
    // Normalize for fixed-depth, also we do some fancy transitions so save a copy of original xys
    root.each(function(d) { d.y = d.depth * 100; d.x0 = d.x; d.y0 = d.y; });
    return root;
  }

  function drawElements(node) {
    // Add circles above each node
    node.append("circle")
      .attr("r", 2)
      .attr("transform", function(d) { return "translate(0,-18)"; })
      .attr("class", "upper-circle")
      .style("stroke", get_rank_colour)
      .style("fill", get_rank_colour);

    // Add the circles below each node
    node.append("circle")
      .attr("r", 4)
      .attr("transform", function(d) { return "translate(0,16)"; })
      .attr("class", "lower-circle")
      .style("stroke", "#000000")
      .style("fill", function(d) {
        return d.data.data.child_count > 0 ? "#FFFFFF" : "#000000";
      });

    // Add text
    node.append("text")
      .attr("dy", 3)
      .style("fill", '#FFFFFF')
      .style("text-anchor", "middle")
      .text(function(d) {
        return d.data.data.name;
        if(d.data.rank == max_rank || d.data.name == "Life") {
          return d.data.name;
        }
        else if(d.children) {
          return d.data.name + ' (' + d.children.length + ")";
        }
        else {
          return d.data.name + ' (' + d.data.count + ")";
        }
      })
      .each(function(d) {
        d.textwidth = this.getBBox().width;
        d.textheight = this.getBBox().height;
      });

    // Add clickable background rectangle so it looks nicer
    node.insert("rect",":first-child")
      .style("fill", '#000000')
      .style("fill-opacity", function(d) {
          if(d.children || d.data.data.rank == max_rank) { return 0.5; }
          else { return 0.2; }
        }
      )
      .attr('height', function(d) { return d.textheight + 10; })
      .attr('width', function(d) { return d.textwidth + 10; })
      .attr("transform", function(d) {
        if(d.data.data.rank == 9) {
          return "translate(-" +  ((d.textwidth + 10) / 2) + ",-" +  ((d.textheight + 30) / 2) + ")";
        }
        return "translate(-" +  ((d.textwidth + 10) / 2) + ",-" +  ((d.textheight + 15) / 2) + ")";
      })
      .attr('rx', 10)
      .attr('ry', 10);
  }

  function updateTree(source, shallowestDepth = 0) {
    /* 
     * Nodes
     */
    // Data join with source data, keeping ids so it knows about the same nodes
    var node = g.selectAll(".node")
      .data(source.descendants() , function(d) { return d.data.id; });
    
    // Data enter, this starts doing things to all the new nodes
    var nodeEnter = node.enter()
      .append("g")
      .attr("class", function(d) { return "rank-" + d.data.data.rank + " node" + (d.children ? " node--internal" : " node--leaf"); })
      .attr("transform", function(d) {
        if(d.parent != null) {
          return "translate(" + d.parent.x + "," + d.parent.y + ")";
        }
        return "translate(" + d.x + "," + d.y + ")"; 
      })
      .on("click", click);
    
    // Add text + bg + circles to the nodes
    drawElements(nodeEnter);
    
    // Add pretty hover class for each taxon node
    $('g').hover(function() {
      $(this).children('rect').addClass('recthover');
    }, function() {
      $(this).children('rect').removeClass('recthover');
    });
    
    // Transition nodes to their new position.
    var nodeMerge = node.merge(nodeEnter).transition()
      .duration(duration)
      .attr('transform', function (d) {
        return 'translate(' + d.x + ',' + d.y + ')';
      });
    nodeMerge.select('rect', ':first-child').style("fill-opacity", function(d) {
      if(d.children || d.data.data.rank == max_rank) { return 0.5; }
      else { return 0.2; }
    });
      
    // Get the old elements for removal
    var oldNode = node.exit();
    
    // Find the shallowest depth in the old element, that's the parent
    oldNode.each(function(d) {
        var shallowestParent = d;
        do { shallowestParent = shallowestParent.parent; }
        while(shallowestParent.depth > shallowestDepth);
        d.shallowestParentX = shallowestParent.x;
        d.shallowestParentY = shallowestParent.y; 
    });
    
    // Transition the old nodes out
    var transitionedNodes = oldNode.transition()
      .duration(duration)
      .attr("transform", function(d) { 
        return "translate(" + d.shallowestParentX + "," + d.shallowestParentY + ")"; 
      });
    oldNode.selectAll('rect').transition()
      .style("fill-opacity", 0)
      .duration(duration/2)
    oldNode.selectAll('text').transition()
      .style("fill-opacity", 0)
      .duration(duration/2)
    oldNode.selectAll('circle').transition()
      .style("fill-opacity", 0)
      .duration(duration/3)
    transitionedNodes.remove();
    
    /* 
     * Links
     */
    var link = g.selectAll(".link")
      .data(source.descendants().slice(1).reverse(), function(d) { return d.data.id; })
    
    // Draw the links between nodes
    var linkEnter = link.enter()
      .insert("path",":first-child")
      .attr("class", "link")
      .style("stroke", function(d) {
        if(d.children) {
          return line_color;
        }
        return blendColors(main_color, secondary_color, (d.data.data.rank / max_rank))
      })
      .attr("d",  function (d) {
        var o = {x: d.parent.x0, y: d.parent.y0, parent: {x: d.parent.x0, y: d.parent.y0}};
        return connector(o);
      });
      
    // Transition links to their new position.
    var linkMerge = link.merge(linkEnter).transition()
      .duration(0)
      .attr('d', connector);
      
    // Style the links
    linkMerge.style("stroke", function(d) {
      if(d.children) {
        return line_color;
      }
      return blendColors(main_color, secondary_color, (d.data.data.rank / max_rank))
    })
    
    // Transition the old links out
    var oldLink = link.exit();
    oldLink.transition()
      .duration(duration/2)
      .attr("d", function(d) {
        var o = {x: d.x, y: d.y, parent: {x: d.x, y: d.y}};
        return connector(o);
      })
      .remove();
  }
  
  // Toggle children on click.
  function click(d) {
    // If the node does not have any pre-loaded children
    if (!d.children && !d._children) {
      var jsonPath = '/taxa/api/children/' + d.data.id;
      
      // Get the JSON lineage for it
      d3.json(jsonPath, function(error, json) {
        // Get the children
        children = json['children'];
        
        // Make a new lineage array, can't use the one previously stored because
        // of javascript variables mutability being weird
        new_lineage = [];
        lineage_flat.forEach(function(node, i) {
          recalcIndex = lineage.indexOf(node);
          // The node.rank 9 is in there because Life for some reason has rank 9
          // Basically we want to exclude all nodes of a lower rank than the one clicked
          if(node.rank > d.data.data.rank && node.rank != 9) {           
            // console.log(node); - we don't want these nodes
          }
          else { new_lineage.push(node); }
        });
        
        // Append the children to the new lineage
        children.forEach(function(child) {
          new_lineage.push(child);
        });
        
        // Javascript is weird. We need a deep copy of new_lineage
        temp = JSON.parse(JSON.stringify(new_lineage));
        new_lineage = JSON.parse(JSON.stringify(temp));
        
        // Turn it into a tree and update our svg
        root = getTreeData(new_lineage);
        updateTree(root, d.depth);
       });
    }
  }
});
.link {
  fill: none;
  stroke: orange;
  stroke-width: 1.5px;
	/* Transition. */
	-o-transition:.5s;
	-ms-transition:.5s;
	-moz-transition:.5s;
	-webkit-transition:.5s;
	/* ...and now for the proper property */
	transition:.5s;
}
.link:hover {
  fill: none;
  stroke: orange;
  stroke-width: 3px;
}
.link.warning{
  stroke: orange;
}
.lower-circle, .upper-circle {
  z-index: 1;
  stroke-width: 2px;
}
path.link {
  stroke-width: 3px;
}
.rank-9 .upper-circle {
  display: none;
}
.rank-9  text {
  font-size: 3em;
  font-weight: bold;
}
.rank-1 {
  font-size: 1.5em;
  font-weight: bold;
}

.rank-2 {
  font-size: 1em;
  font-weight: bold;
  margin-right: 20px;
  margin-left: 20px;
}
svg text {

}
.rank-3 {
  font-size: 1em;
}
#triangles {
  /*position: relative;
  top: -20px;
  margin-top: -50px;
  padding-top: 80px;

-webkit-transform: rotate(180deg);
-moz-transform: rotate(180deg);
-ms-transform: rotate(180deg);
-o-transform: rotate(180deg);
transform: rotate(180deg);*/
}
#svgcontainer {
  position: relative;
  padding-top: 80px;
/*-webkit-transform: rotate(180deg);
-moz-transform: rotate(180deg);
-ms-transform: rotate(180deg);
-o-transform: rotate(180deg);
transform: rotate(180deg);*/
}
#svgparent-disablethis {
  background: transparent url('static/img/carousel/1.jpg' 0 0 no-repeat );

  background: -moz-linear-gradient(top, rgba(255,255,255,0) 100%, rgba(130,91,0,1) 0%);   /* FF3.6+ */
  background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(255,255,255,0)), color-stop(100%,rgba(130,91,0,1))); /* Chrome,Safari4+ */
  background: -webkit-linear-gradient(top, rgba(255,255,255,0) 0%,rgba(130,91,0,1) 100%); /* Chrome10+,Safari5.1+ */
  background: -o-linear-gradient(top, rgba(255,255,255,0) 0%,rgba(130,91,0,1) 100%); /* Opera 11.10+ */
  background: -ms-linear-gradient(top, rgba(255,255,255,0) 0%,rgba(130,91,0,1) 100%); /* IE10+ */
  background: linear-gradient(to bottom, rgba(255,255,255,0) 0%,rgba(130,91,0,1) 100%); /* W3C */
  filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#00ffffff', endColorstr='#825b00',GradientType=0 ); /* IE6-9 */
}

svg { width: 100%; height: auto; }


rect {
	/* Transition. */
	-o-transition:.2s;
	-ms-transition:.2s;
	-moz-transition:.2s;
	-webkit-transition:.2s;
	/* ...and now for the proper property */
	transition:.2s;


}
text {
  cursor: pointer;
}

.lower-circle {
	cursor: pointer;
}
.lower-circle:hover {
	fill: #FFF !important;
}
.recthover {
	fill: #FFEC1C !important;
}
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>

  <div id="svgparent">
    <div id="svgcontainer">
      <svg id='lifetree'></svg>
    </div>
  </div>

Upvotes: 0

Related Questions