You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在D3.js中实现点击节点展开子节点的交互式效果?

D3.js 点击节点展开辐射式子节点实现方案

问题背景

我是D3.js新手,正在制作带有交互节点的可视化效果,要求点击节点时展开其子节点。目前已实现节点的交互式展示并添加了点击事件,但不清楚如何实现子节点展开功能。我在点击函数中使用data.children数据并传入d3.hierarchy设置为根节点,却不知如何展开数据。我想要实现中心节点周围向外辐射子节点的效果,结构如下:

child   child
     \     /
      node
        |
      child 

我发现d3.tree是水平树结构,不符合需求,附上我的实现代码:

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>
  );
}

实现步骤与代码修改建议

1. 用React状态管理节点数据

在组件中添加状态变量,控制当前展示的节点集合,确保数据更新时触发视图更新:

import { useState } from 'react';

export default function ThirdTab(): React.MixedElement {
  const ref = useRef();
  const viewportDimension = getViewportDimension();
  // 初始化节点数据为annotationData
  const [graphNodes, setGraphNodes] = useState(annotationData);
  // 声明全局变量复用模拟实例和连线组
  let simulation: d3.Simulation<any, any>;
  let linksGroup: d3.Selection<SVGGElement, unknown, HTMLElement, any>;

  // ... 后续代码
}

2. 重构D3渲染逻辑为可复用函数

将节点渲染、力模拟初始化逻辑拆分为独立函数,方便数据更新时重新调用:

function renderGraph() {
  const width = viewportDimension.width - 150;
  const height = viewportDimension.height - 230;
  const svg = d3.select(ref.current);

  // 获取或创建缩放组和节点组
  const zoomG = svg.select('.zoom-group') || svg.attr('width', width).attr('height', height).append('g').attr('class', 'zoom-group');
  const g = zoomG.select('.node-group') || zoomG.append('g').attr('class', 'node-group').attr('transform', `translate(500,280) scale(0.31)`);
  
  // 初始化连线组(仅第一次创建)
  if (!linksGroup) {
    linksGroup = g.append('g').attr('class', 'links');
  }

  // 数据绑定:用name作为唯一标识,避免重复渲染
  const nodes = g.selectAll('.dotContainer').data(graphNodes, d => d.name);

  // 移除已删除的节点(如需收起功能可扩展)
  nodes.exit().remove();

  // 新增节点组
  const newGroups = nodes.enter()
    .append('g')
    .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;
        })
    );

  // 添加节点圆形及点击事件
  newGroups.append('circle')
    .attr('class', 'dot')
    .attr('r', 20)
    .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) {
      // 避免重复添加子节点
      if (data._childrenAdded) return;
      // 处理子节点,设置初始位置偏移父节点
      const childNodes = data.children?.map(child => ({
        ...child,
        parent: data,
        x: data.x + (Math.random() - 0.5) * 80,
        y: data.y + (Math.random() - 0.5) * 80
      })) || [];
      // 更新节点集合
      setGraphNodes(prev => [...prev, ...childNodes]);
      // 标记节点已展开
      data._childrenAdded = true;
    });

  // 添加节点文本
  newGroups.append('text')
    .attr('x', d => d.x)
    .attr('y', d => d.y + 40)
    .text(d => d.name)
    .style('text-anchor', 'middle')
    .style('fill', '#555')
    .style('font-family', 'Arial')
    .style('font-size', 15);

  // 合并新旧节点组
  const allGroups = nodes.merge(newGroups);

  // 生成父子连线数据
  const links = graphNodes.filter(d => d.parent).map(d => ({
    source: d.parent,
    target: d
  }));

  // 更新连线
  const link = linksGroup.selectAll('line').data(links);
  link.exit().remove();
  link.enter()
    .append('line')
    .attr('stroke', '#ccc')
    .attr('stroke-width', 2)
    .merge(link);

  // 初始化或更新力模拟
  if (!simulation) {
    simulation = d3.forceSimulation()
      .force('center', d3.forceCenter(width / 2, height / 2))
      .force('charge', d3.forceManyBody().strength(-150)) // 负力让节点分散
      .force('collide', d3.forceCollide().radius(60))
      .force('link', d3.forceLink(links).distance(100).strength(0.8)); // 父子节点吸引力
  } else {
    simulation.nodes(graphNodes)
      .force('link')?.links(links) // 更新连线数据
      .alpha(0.3) // 重启模拟时设置alpha值,增强动画效果
      .restart();
  }

  // Tick事件更新节点和连线位置
  simulation.on('tick', function() {
    allGroups.select('circle')
      .attr('cx', d => d.x)
      .attr('cy', d => d.y);
    allGroups.select('text')
      .attr('x', d => d.x)
      .attr('y', d => d.y + 40);
    link
      .attr('x1', d => d.source.x)
      .attr('y1', d => d.source.y)
      .attr('x2', d => d.target.x)
      .attr('y2', d => d.target.y);
  });
}

3. 调整useEffect依赖触发渲染

修改useEffect,监听graphNodes和视口尺寸变化,自动调用渲染函数:

useEffect(() => {
  renderGraph();
}, [viewportDimension.width, viewportDimension.height, graphNodes]);

4. 关键说明

  • 避免重复展开:通过给节点添加_childrenAdded标记,防止重复点击添加子节点
  • 力参数优化:forceManyBody设为负强度实现节点排斥,forceLink添加父子节点吸引力,达成辐射分布效果
  • React状态联动:用graphNodes状态管理数据,确保数据更新时自动触发视图重绘

内容的提问来源于stack exchange,提问作者Eric Cortez

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.05 13:40:45