d3.jsd3tree

How can I expand child nodes when a node is clicked in d3?


I am new to d3 and I'm a trying to make a visualization with interactive nodes where each node can be clicked. When the node is clicked it should expand to show child nodes. I was able to get all the nodes to display interactively and I added an on click event, but I am not sure how I can get the child nodes to expand on click.

I am using the data from data.children in the onclick function and passing it to d3.hierarchy to set the data as the root. I am just not sure how to expand the data.

I am looking to make something like this where the circle node is in the center and the child nodes expand around it/outwards.


   child   child
     \     /
      node
        |
      child 

Does anyone have any suggestions on how I could achieve this? I found d3.tree in the docs but that is more of a horizontal tree structure.

export default function ThirdTab(): React.MixedElement {
  const ref = useRef();
  const viewportDimension = getViewportDimension();

  useEffect(() => {
    const width = viewportDimension.width - 150;
    const height = viewportDimension.height - 230;

    const svg = d3
      .select(ref.current)
      .style('width', width)
      .style('height', height);

    const zoomG = svg.attr('width', width).attr('height', height).append('g');

    const g = zoomG
      .append('g')
      .attr('transform', `translate(500,280) scale(0.31)`);

    svg.call(
      d3.zoom().on('zoom', () => {
        zoomG.attr('transform', d3.event.transform);
      }),
    );

    const nodes = g.selectAll('g').data(annotationData);

    const group = nodes
      .enter()
      .append('g')
      .attr('cx', width / 2)
      .attr('cy', height / 2)
      .attr('class', 'dotContainer')
      .style('cursor', 'pointer')
      .call(
        d3
          .drag()
          .on('start', function dragStarted(d) {
            if (!d3.event.active) simulation.alphaTarget(0.03).restart();
            d.fx = d.x;
            d.fy = d.y;
          })
          .on('drag', function dragged(d) {
            d.fx = d3.event.x;
            d.fy = d3.event.y;
          })
          .on('end', function dragEnded(d) {
            if (!d3.event.active) simulation.alphaTarget(0.03);
            d.fx = null;
            d.fy = null;
          }),
      );

    const circle = group
      .append('circle')
      .attr('class', 'dot')
      .attr('r', 20)
      .attr('cx', d => d.x)
      .attr('cy', d => d.y)
      .style('fill', '#33adff')
      .style('fill-opacity', 0.3)
      .attr('stroke', '#b3a2c8')
      .style('stroke-width', 4)
      .attr('id', d => d.name)
      .on('click', function click(data) {
        const root = d3.hierarchy(data.children);
        const links = root.links();
        const nodes = root.descendants();

        console.log(nodes);
      });

    const label = group
      .append('text')
      .attr('x', d => d.x)
      .attr('y', d => d.y)
      .text(d => d.name)
      .style('text-anchor', 'middle')
      .style('fill', '#555')
      .style('font-family', 'Arial')
      .style('font-size', 15);

    const simulation = d3
      .forceSimulation()
      .force(
        'center',
        d3
          .forceCenter()
          .x(width / 2)
          .y(height / 2),
      )
      .force('charge', d3.forceManyBody().strength(1))
      .force(
        'collide',
        d3.forceCollide().strength(0.1).radius(170).iterations(1),
      );

    simulation.nodes(annotationData).on('tick', function () {
      circle
        .attr('cx', function (d) {
          return d.x;
        })
        .attr('cy', function (d) {
          return d.y;
        });

      label
        .attr('x', function (d) {
          return d.x;
        })
        .attr('y', function (d) {
          return d.y + 40;
        });
    });
  }, [viewportDimension.width, viewportDimension.height]);

  return (
    <div className="third-tab-content">
      <style>{`
      .tooltip {
        position: absolute;
        z-index: 10;
        visibility: hidden;
        background-color: lightblue;
        text-align: center;
        padding: 4px;
        border-radius: 4px;
        font-weight: bold;
        color: rgb(179, 162, 200);
    }
   `}</style>
      <svg
        ref={ref}
        id="annotation-container"
        role="img"
        title="Goal Tree Container"></svg>
    </div>
  );
}

Node image


Solution

  •   useEffect(() => {
        const width = viewportDimension.width - 150;
        const height = viewportDimension.height - 230;
    
        const svg = d3
          .select(ref.current)
          .style('width', width)
          .style('height', height);
    
        const zoomG = svg.attr('width', width).attr('height', height).append('g');
    
        const g = zoomG
          .append('g')
          .attr('transform', `translate(500,280) scale(0.31)`);
    
        svg.call(
          d3.zoom().on('zoom', () => {
            zoomG.attr('transform', d3.event.transform);
          }),
        );
    
        const nodes = g.selectAll('g').data(annotationData);
    
        const simulation = d3
          .forceSimulation(annotationData)
          .force(
            'center',
            d3
              .forceCenter()
              .x(width / 2)
              .y(height / 2),
          )
          .force('charge', d3.forceManyBody().strength(1));
    
        const group = nodes
          .enter()
          .append('g')
          .attr('x', d => d.x)
          .attr('y', d => d.y)
          .attr('id', d => 'container' + d.index)
          .attr('class', 'dotContainer')
          .style('white-space', 'pre')
          .style('cursor', 'pointer')
          .call(
            d3
              .drag()
              .on('start', function dragStarted(d) {
                if (!d3.event.active) simulation.alphaTarget(0.03).restart();
                d.fx = d.x;
                d.fy = d.y;
              })
              .on('drag', function dragged(d) {
                d.fx = d3.event.x;
                d.fy = d3.event.y;
              })
              .on('end', function dragEnded(d) {
            
                if (!d3.event.active) simulation.alphaTarget(0.03);
                d.fx = null;
                d.fy = null;
              }),
          );
    
        simulation.on('tick', function () {
          group.attr('transform', function (d) {
            return 'translate(' + d.x + ',' + d.y + ')';
          });
          children
            .attr('x', function (d) {
              return d.x;
            })
            .attr('y', function (d) {
              return d.y;
            });
        });
    
        simulation.force(
          'collide',
          d3.forceCollide().strength(0.1).radius(170).iterations(10),
        );
    
        let currentlyExpandedNode;
        let currentNode;
    
        const circle = group
          .append('circle')
          .attr('class', 'dot')
          .attr('id', d => {
            return 'circle' + d.index;
          })
          .attr('r', 20)
          .attr('cx', d => d.x)
          .attr('cy', d => d.y)
          .style('fill', '#33adff')
          .style('fill-opacity', 0.3)
          .attr('stroke', 'gray')
          .style('stroke-width', 4)
          .on('click', function click(data) {
            currentNode = d3.select(`#container${data.index}`);
    
            if (currentlyExpandedNode) {
              d3.selectAll('.child').remove();
              d3.selectAll('#child-text').remove();
            }
    
            currentlyExpandedNode = data;
    
            const pie = d3
              .pie()
              .value(() => 1)
              .sort(null);
    
            const circlex1 = +currentNode.select(`#circle${data.index}`).attr('cx');
            const circley1 = +currentNode.select(`#circle${data.index}`).attr('cy');
    
            const children = currentNode
              .selectAll('line.child')
              .data(pie(data.children))
              .enter()
              .append('line')
              .attr('stroke', 'gray')
              .attr('stroke-width', 1)
              .attr('stroke-dasharray', '5 2')
              .attr('class', 'child')
              .attr('x1', circlex1) // starting point
              .attr('y1', circley1)
              .attr('x2', circlex1) // transition starting point
              .attr('y2', circley1)
              .transition()
              .duration(300)
              .attr('x2', circlex1 + 62) // end point
              .attr('y2', circley1 - 62);
    
            const childrenCircles = currentNode
              .selectAll('circle.child')
              .data(data.children)
              .enter()
              .append('circle')
              .attr('class', 'child')
              .attr('cx', () => circlex1 + 70)
              .attr('cy', () => circley1 - 70)
              .attr('r', 10)
              .style('fill', '#b3a2c8')
              .style('fill-opacity', 0.8)
              .attr('stroke', 'gray')
              .style('stroke-width', 2);
    
            children.each(childData => {
              currentNode
                .append('text')
                .attr('x', () => circlex1 + 80)
                .attr('y', () => circley1 - 100)
                .text(childData.data.name)
                .attr('id', 'child-text')
                .style('text-anchor', 'middle')
                .style('fill', '#555')
                .style('font-family', 'Arial')
                .style('font-size', 15);
            });
    
          });
    
        group
          .append('text')
          .attr('x', d => d.x)
          .attr('y', d => d.y + 50)
          .text(d => d.name)
          .style('text-anchor', 'middle')
          .style('fill', '#555')
          .style('font-family', 'Arial')
          .style('font-size', 15);
    
        const children = group.selectAll('.child-element');
      }, [viewportDimension.width, viewportDimension.height]);`