d3.js v4 wacky link transition in collapsible tree

2019-03-21 10:23发布

问题:

If you play with the collapsible tree below you'll see that when you get to the end of the tree, and expand and collapse the nodes the lines are doing some wacky stuff and I am no entirely sure what drives the behavior or if my rewriting of enter link description here is completely off base. I went off a flat data structure and used stratify to transform it into a tree layout. The only issue so far is the line transitions...any thoughts?

var data = [{
    "name": "Hazer 5000",
    "parent": "CFO",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/stephen.jpg"
  }, {
    "name": "Employee 1",
    "parent": "Hazer 5000",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/cory.jpg"
  }, {
    "name": "Analytics Area",
    "parent": "Hazer 5000",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/matt.jpg"
  }, {
    "name": "Employee 2",
    "parent": "Hazer 5000",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/XinheZhang.jpg"
  }, {
    "name": "Employee 3",
    "parent": "Hazer 5000",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/craig.jpg"
  }, {
    "name": "Employee 4",
    "parent": "Hazer 5000",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/youri.jpg"
  }, {
    "name": "Intern 1",
    "parent": "Analytics Area",
    "img": ""
  }, {
    "name": "Inter 2",
    "parent": "Analytics Area",
    "img": ""
  }, {
    "name": "CFO",
    "parent": null,
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/Brett.jpg"
  }, {
    "name": "CPA",
    "parent": "CFO",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/Wes.jpg"
  }, {
    "name": "Matt's wife",
    "parent": "CPA",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/Amy_R.jpg"
  }, {
    "name": "Employee 5",
    "parent": "CPA",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/DavidBriley.jpg"
  }, {
    "name": "Employee 6",
    "parent": "CPA",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/BrittanyAllred_.jpg"
  }, {
    "name": "Employee 7",
    "parent": "CPA",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/Shea.jpg"
  }, {
    "name": "Employee 8",
    "parent": "Matt's wife",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/Mindy.jpg"
  }, {
    "name": "Employee 9",
    "parent": "Matt's wife",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/Jessica_Stacy.jpg"
  }, {
    "name": "Employee 10",
    "parent": "Matt's wife",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/FraleaneHudson.jpg"
  },{
    "name": "Employee 11",
    "parent": "Employee 9",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/MeganPierce_.jpg"
  },{
    "name": "Intern 3",
    "parent": "Employee 8",
    "img": ""
  }, {
    "name": "Intern 4",
    "parent": "Employee 8",
    "img": ""
  }

];

var margin = {top: 20, right: 120, bottom: 20, left: 120},
    width = 960 - margin.right - margin.left,
    height = 800 - margin.top - margin.bottom;

var i = 0,
    duration = 750,
    root;

var tree = d3.tree()
    .size([height, width]);

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


var stratify = d3.stratify()
  .id(function(d) {
    return d.name;//This position
  })
  .parentId(function(d) {
    return d.parent; //What position this position reports to
  });

var root = stratify(data);

root.each(function(d) {
  
    d.name = d.id; //transferring name to a name variable
    d.id = i; //Assigning numerical Ids
    i++;
  
  });

  root.x0 = height / 2;
  root.y0 = 0;

  function collapse(d) {
    if (d.children) {
      d._children = d.children;
      d._children.forEach(collapse);
      d.children = null;
    }
  }

  root.children.forEach(collapse);
  update(root);


d3.select(self.frameElement).style("height", "800px");

function update(source) {

  // Compute the new tree layout.
  var nodes = tree(root).descendants(),
      links = nodes.slice(1);

  // Normalize for fixed-depth.
  nodes.forEach(function(d) { d.y = d.depth * 180; });

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

  // Enter any new nodes at the parent's previous position.
  var nodeEnter = node.enter().append("g")
      .attr("class", "node")
      .attr("transform", function(d) { return "translate(" + source.y0 + "," + source.x0 + ")"; })
      .on("click", click);

  nodeEnter.append("circle")
      .attr("r", 1e-6)
      .style("fill", function(d) { return d._children ? "lightsteelblue" : "#fff"; });

  nodeEnter.append("text")
      .attr("x", function(d) { return d.children || d._children ? -10 : 10; })
      .attr("dy", ".35em")
      .attr("text-anchor", function(d) { return d.children || d._children ? "end" : "start"; })
      .text(function(d) { return d.name; })
      .style("fill-opacity", 1e-6);

  // Transition nodes to their new position.
  var nodeUpdate = node.merge(nodeEnter).transition()
      .duration(duration)
      .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; });

  nodeUpdate.select("circle")
      .attr("r", 4.5)
      .style("fill", function(d) { return d._children ? "lightsteelblue" : "#fff"; });

  nodeUpdate.select("text")
      .style("fill-opacity", 1);

  // Transition exiting nodes to the parent's new position.
  var nodeExit = node.exit().transition()
      .duration(duration)
      .attr("transform", function(d) { return "translate(" + source.y + "," + source.x + ")"; })
      .remove();

  nodeExit.select("circle")
      .attr("r", 1e-6);

  nodeExit.select("text")
      .style("fill-opacity", 1e-6);


  // Update the links…
  var link = svg.selectAll("path.link")
      .data(links);
  
  // Transition links to their new position.
  link.transition()
      .duration(duration)
      .attr("d", connector);

  // Enter any new links at the parent's previous position.
  var linkEnter = link.enter().insert("path", "g")
                        .attr("class", "link")
                        .attr("d", function(d) {
        var o = {x: source.x0, y: source.y0, parent:{x: source.x0, y: source.y0}};
        return connector(o);
      });

  // Transition links to their new position.
  link.merge(linkEnter).transition()
      .duration(duration)
      .attr("d", connector);

  // Transition exiting nodes to the parent's new position.
  link.exit().transition()
      .duration(duration)
      .attr("d",  function(d) {
        var o = {x: source.x, y: source.y, parent:{x: source.x, y: source.y}};
        return connector(o);
      })
      .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) {
    d._children = d.children;
    d.children = null;
  } else {
    d.children = d._children;
    d._children = null;
  }
  update(d);
}

function connector(d) {
  return "M" + d.y + "," + d.x +
    "C" + (d.y + d.parent.y) / 2 + "," + d.x +
    " " + (d.y + d.parent.y) / 2 + "," + d.parent.x +
    " " + d.parent.y + "," + d.parent.x;
}
.node {
  cursor: pointer;
}

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

.node text {
  font: 10px sans-serif;
}

.link {
  fill: none;
  stroke: #ccc;
  stroke-width: 1.5px;
<script src="https://d3js.org/d3.v4.min.js"></script>

回答1:

Looks like an issue with the keys used for the link's data binding. I'm referring to the fact that if you call .data(links) without a 2nd argument, d3 uses i as the key when it computes the enter/update/exit sets, and so when you expand/collapse the same link can have different i values.

I believe I got it working, and all I did was add that 2nd param as a function that constructs a unique link id by combining the node's id with its parent node's id.

I.e. instead of this

var link = svg.selectAll("path.link")
  .data(links)

I made it this

var link = svg.selectAll("path.link")
  .data(links, function(link) { var id = link.id + '->' + link.parent.id; return id; });

Try it out:

var data = [{
    "name": "Hazer 5000",
    "parent": "CFO",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/stephen.jpg"
  }, {
    "name": "Employee 1",
    "parent": "Hazer 5000",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/cory.jpg"
  }, {
    "name": "Analytics Area",
    "parent": "Hazer 5000",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/matt.jpg"
  }, {
    "name": "Employee 2",
    "parent": "Hazer 5000",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/XinheZhang.jpg"
  }, {
    "name": "Employee 3",
    "parent": "Hazer 5000",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/craig.jpg"
  }, {
    "name": "Employee 4",
    "parent": "Hazer 5000",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/youri.jpg"
  }, {
    "name": "Intern 1",
    "parent": "Analytics Area",
    "img": ""
  }, {
    "name": "Inter 2",
    "parent": "Analytics Area",
    "img": ""
  }, {
    "name": "CFO",
    "parent": null,
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/Brett.jpg"
  }, {
    "name": "CPA",
    "parent": "CFO",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/Wes.jpg"
  }, {
    "name": "Matt's wife",
    "parent": "CPA",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/Amy_R.jpg"
  }, {
    "name": "Employee 5",
    "parent": "CPA",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/DavidBriley.jpg"
  }, {
    "name": "Employee 6",
    "parent": "CPA",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/BrittanyAllred_.jpg"
  }, {
    "name": "Employee 7",
    "parent": "CPA",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/Shea.jpg"
  }, {
    "name": "Employee 8",
    "parent": "Matt's wife",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/Mindy.jpg"
  }, {
    "name": "Employee 9",
    "parent": "Matt's wife",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/Jessica_Stacy.jpg"
  }, {
    "name": "Employee 10",
    "parent": "Matt's wife",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/FraleaneHudson.jpg"
  },{
    "name": "Employee 11",
    "parent": "Employee 9",
    "img": "https://s3-us-west-2.amazonaws.com/s.cdpn.io/t-342/MeganPierce_.jpg"
  },{
    "name": "Intern 3",
    "parent": "Employee 8",
    "img": ""
  }, {
    "name": "Intern 4",
    "parent": "Employee 8",
    "img": ""
  }

];

var margin = {top: 20, right: 120, bottom: 20, left: 120},
    width = 960 - margin.right - margin.left,
    height = 800 - margin.top - margin.bottom;

var i = 0,
    duration = 750,
    root;

var tree = d3.tree()
    .size([height, width]);

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


var stratify = d3.stratify()
  .id(function(d) {
    return d.name;//This position
  })
  .parentId(function(d) {
    return d.parent; //What position this position reports to
  });

var root = stratify(data);

root.each(function(d) {
  
    d.name = d.id; //transferring name to a name variable
    d.id = i; //Assigning numerical Ids
    i++;
  
  });

  root.x0 = height / 2;
  root.y0 = 0;

  function collapse(d) {
    if (d.children) {
      d._children = d.children;
      d._children.forEach(collapse);
      d.children = null;
    }
  }

  root.children.forEach(collapse);
  update(root);


d3.select(self.frameElement).style("height", "800px");

function update(source) {

  // Compute the new tree layout.
  var nodes = tree(root).descendants(),
      links = nodes.slice(1);

  // Normalize for fixed-depth.
  nodes.forEach(function(d) { d.y = d.depth * 180; });

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

  // Enter any new nodes at the parent's previous position.
  var nodeEnter = node.enter().append("g")
      .attr("class", "node")
      .attr("transform", function(d) { return "translate(" + source.y0 + "," + source.x0 + ")"; })
      .on("click", click);

  nodeEnter.append("circle")
      .attr("r", 1e-6)
      .style("fill", function(d) { return d._children ? "lightsteelblue" : "#fff"; });

  nodeEnter.append("text")
      .attr("x", function(d) { return d.children || d._children ? -10 : 10; })
      .attr("dy", ".35em")
      .attr("text-anchor", function(d) { return d.children || d._children ? "end" : "start"; })
      .text(function(d) { return d.name; })
      .style("fill-opacity", 1e-6);

  // Transition nodes to their new position.
  var nodeUpdate = node.merge(nodeEnter).transition()
      .duration(duration)
      .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; });

  nodeUpdate.select("circle")
      .attr("r", 4.5)
      .style("fill", function(d) { return d._children ? "lightsteelblue" : "#fff"; });

  nodeUpdate.select("text")
      .style("fill-opacity", 1);

  // Transition exiting nodes to the parent's new position.
  var nodeExit = node.exit().transition()
      .duration(duration)
      .attr("transform", function(d) { return "translate(" + source.y + "," + source.x + ")"; })
      .remove();

  nodeExit.select("circle")
      .attr("r", 1e-6);

  nodeExit.select("text")
      .style("fill-opacity", 1e-6);


  // Update the links…
  var link = svg.selectAll("path.link")
      .data(links, function(link) { var id = link.id + '->' + link.parent.id; return id; });
  
  // Transition links to their new position.
  link.transition()
      .duration(duration)
      .attr("d", connector);

  // Enter any new links at the parent's previous position.
  var linkEnter = link.enter().insert("path", "g")
                        .attr("class", "link")
                        .attr("d", function(d) {
        var o = {x: source.x0, y: source.y0, parent:{x: source.x0, y: source.y0}};
        return connector(o);
      });

  // Transition links to their new position.
  link.merge(linkEnter).transition()
      .duration(duration)
      .attr("d", connector);

  // Transition exiting nodes to the parent's new position.
  link.exit().transition()
      .duration(duration)
      .attr("d",  function(d) {
        var o = {x: source.x, y: source.y, parent:{x: source.x, y: source.y}};
        return connector(o);
      })
      .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) {
    d._children = d.children;
    d.children = null;
  } else {
    d.children = d._children;
    d._children = null;
  }
  update(d);
}

function connector(d) {
  return "M" + d.y + "," + d.x +
    "C" + (d.y + d.parent.y) / 2 + "," + d.x +
    " " + (d.y + d.parent.y) / 2 + "," + d.parent.x +
    " " + d.parent.y + "," + d.parent.x;
}
.node {
  cursor: pointer;
}

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

.node text {
  font: 10px sans-serif;
}

.link {
  fill: none;
  stroke: #ccc;
  stroke-width: 1.5px;
<script src="https://d3js.org/d3.v4.min.js"></script>