javascripttypescriptd3.js

D3.js Hierarchy Tree recreates entire tree when updating


I tried to recreate the example in the page while using Typescript. The tree is created, however whenever I click to collapse/expand it the transition seems to recreate the entire tree instead of only adding / removing the nodes from the "clicked" parent.

Using the trick of holding alt while clicking I can see that a new tree is created "from" where I am clicking and the "current" tree is then removed/sucked into the node that I am clicking on.

I wasn't able to quickly think on a better way to provide the code for the example, so here is all of it. TreeNodeData being just a type I've created for the exercise:

export interface TreeNodeData {
name: string;
fill?: string;
children?: TreeNodeData[];
value?: number;
id?: string | number;
}

Code here:

const root = d3.hierarchy(data);
const dx = 40;
const dy = (this.width - this.marginRight - this.marginLeft) / (1 + root.height);

// Define the tree layout and the shape for links.
const tree = d3.tree().nodeSize([dx, dy]);

const diagonal: d3.Link<any, d3.HierarchyLink<TreeNodeData>, d3.HierarchyLink<TreeNodeData>> = d3.linkHorizontal<d3.HierarchyLink<TreeNodeData>, d3.HierarchyLink<TreeNodeData>>()
.x(d => d.y!)
.y(d => d.x!);


// Create the SVG container, a layer for the links and a layer for the nodes.
const svg = d3.create("svg")
.attr("width", this.width)
.attr("height", this.height)
.attr("viewBox", [-this.marginLeft, -this.marginTop, this.width, dx])
.attr("style", "max-width: 100%; height: auto; font: 20px sans-serif; user-select: none;");

const gLink = svg.append("g")
.attr("fill", "none")
.attr("stroke", "#555")
.attr("stroke-opacity", 0.4)
.attr("stroke-width", 1.5);

const gNode = svg.append("g")
.attr("cursor", "pointer")
.attr("pointer-events", "all");        

const update = (event: KeyboardEvent | MouseEvent | null, source: d3.HierarchyNode<TreeNodeData>) => {
  const duration = event?.altKey ? 2500 : 250; // hold the alt key to slow down the transition
  const nodes = root.descendants().reverse();
  const links = root.links();

  // Compute the new tree layout.
  tree(root);        

  let left = root;
  let right = root;
  root.eachBefore(node => {
    if(node.x !== undefined){
      if(left.x !== undefined){
        if (node.x < left.x) left = node;
      }
      if(right.x !== undefined){
        if (node.x > right.x) right = node;
      }              
    }
  });

  const height = right.x! - left.x! + this.marginTop + this.marginBottom;


  const transition = svg.transition()
  .duration(duration)
  .attr("height", height)
  .attr("viewBox", [-this.marginLeft, left.x! - this.marginTop, this.width, height].join(" "));

  if (window.ResizeObserver) {
      transition.tween("resize", null);
  } else {
      const tweenFactory = () => {
          // This is the "factory" function.
          svg.dispatch("toggle"); // Perform your action here.
          // The factory MUST return an "interpolator" function.
          return () => {
          };
      };
      transition.tween("resize", tweenFactory);
  }


  const node = gNode.selectAll<SVGGElement, d3.HierarchyNode<TreeNodeData>>("g")
  .data(nodes, (d) => {
    if (d && d.id !== undefined) {
      return d.id;
    }
    // You might need to handle cases where d.id is missing based on your data structure.
    // Returning a fallback value or throwing an error might be appropriate.
    return String(Math.random()); // Example fallback: a unique string
  });

  // Enter any new nodes at the parent's previous position.
  const nodeEnter = node.enter().append("g")
  .attr("transform", d => `translate(${source.y0},${source.x0})`)
  .attr("fill-opacity", 0)
  .attr("stroke-opacity", 0)
  .on("click", (event, d: d3.HierarchyNode<TreeNodeData>) => {
    d.children = d.children ? undefined : d._children;
    update(event, d);
  });              

  nodeEnter.append("circle")
  .attr("r", 2.5)
  .attr("fill", d => d._children ? "#555" : "#999")
  .attr("stroke-width", 10);

  nodeEnter.append("text")
  .attr("dy", "0.31em")
  .attr("x", (d : d3.HierarchyNode<TreeNodeData>) => d._children ? -6 : 6)
  .attr("text-anchor", d => d._children ? "end" : "start")
  .text((d : d3.HierarchyNode<TreeNodeData>) => d.data.name)
  .attr("stroke-linejoin", "round")
  .attr("stroke-width", 3)
  .attr("stroke", "white")
  .attr("paint-order", "stroke");


  // Transition nodes to their new position.
  const nodeUpdate = node.merge(nodeEnter).transition(transition)
  .attr("transform", d => `translate(${d.y},${d.x})`)
  .attr("fill-opacity", 1)
  .attr("stroke-opacity", 1);

  // Transition exiting nodes to the parent's new position.
  const nodeExit = node.exit().transition(transition).remove()
  .attr("transform", d => `translate(${source.y},${source.x})`)
  .attr("fill-opacity", 0)
  .attr("stroke-opacity", 0);

  // Update the links…
  const link = gLink.selectAll("path")
  .data(links, (d : d3.HierarchyLink<TreeNodeData>) => d.target.id!); //  non-null assertion do caralho

  // Enter any new links at the parent's previous position.
  const linkEnter = link.enter().append("path")
  .attr("d", d => {
    return diagonal({ source: source, target: source });
  });

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

  // Transition exiting nodes to the parent's new position.
  link.exit().transition(transition).remove()
  .attr("d", d => {
    return diagonal({ source: source, target: source })});

  // Stash the old positions for transition.
  root.eachBefore(d => {
  d.x0 = d.x;
  d.y0 = d.y;
  });      

}

// Do the first update to the initial configuration of the tree — where a number of nodes
// are open (arbitrarily selected as the root, plus nodes with 7 letters).
  root.x0 = dy / 2;
  root.y0 = 0;
  root.descendants().forEach((d : d3.HierarchyNode<TreeNodeData>, i) => {
    d._children = d.children;
    if (d.depth && d.data.name.length !== 7) d.children = undefined;
  });

  update(null, root);    




document.querySelector("body")?.appendChild(svg.node()!);

Solution

  • Here is the problem in your code:

    const node = gNode.selectAll<SVGGElement, d3.HierarchyNode<TreeNodeData>>("g")
      .data(nodes, (d) => {
        if (d && d.id !== undefined) {
          return d.id;
        }
        // You might need to handle cases where d.id is missing based on your data structure.
        // Returning a fallback value or throwing an error might be appropriate.
        return String(Math.random()); // Example fallback: a unique string
      });
    

    Since id is optional and may be missing, it's probably randomised on each call and thus nodes have no persistent identification

    Change your code to:

    .data(nodes, d => d.id)
    

    and make sure id is mandatory