如何在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
相关产品推荐
相关产品推荐

