辐射状布局下使用image类型节点,多次连续展开节点,控制台稳定报错Uncaught TypeError: Cannot read properties of undefined (reading 'src')
Describe the bug / 问题描述
报错截图:
options配置代码: ` const graphOptions = useMemo(() => { console.log('graphOptions重新计算', directoryData); // 脑图树 Mindmap(需配合 autoFit: 'center', 使用) // const layoutConfig = { // type: 'mindmap', // direction: 'H', // preLayout: false, // preventOverlap: true, // getHeight: () => 32, // getWidth: () => 32, // getVGap: () => 16, // getHGap: () => 72, // };
const layoutConfig = {
type: 'dendrogram', // 布局类型:dendrogram | tree | radial,默认值dendrogram
radial: true, // 是否按照辐射状布局。若 radial 为 true,建议 direction 设置为 LR 或 RL。
direction: 'RL', // 布局方向:LR | RL | TB | BT | H | V,默认值RL(从右到左,根右子左)
preLayout: true,
};
return {
autoFit: 'center', // 是否自动适应,view | center
autoResize: true, // 是否自动调整画布大小,默认值false
zoom: 1, // 缩放比例,默认值1
data: directoryData,
compact: true,
layout: layoutConfig,
// defaultExpandLevel: props.level,
defaultExpandLevel: 2, // 默认展开层级,若不指定或指定0,将展开全部
containerStyle: {
backgroundImage: `url(${tupuBackImg})`,
backgroundSize: 'cover', // center:完整显示;cover:会被裁减
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center', // 居中
},
node: {
type: 'image',
// type: 'circle',
style: {
src: (model) => {
const depth = model?.depth || 0;
if (model?.isDirectory === false) {
return pointIcon;
}
const depthImgMap = {
0: subjectIcon,
1: level1Icon,
2: level2Icon,
3: level3Icon,
4: level4Icon,
5: pointIcon,
};
return depthImgMap[depth];
},
fill: (model) => {
// 根据节点深度设置不同的背景色
const depth = model?.depth || 0;
if (model?.isDirectory === false) {
return '#DF951F'; // 考点背景色
}
const colorMap = {
0: '#6554EA', // 根节点 - 紫色
1: '#4873F1', // 第一层 - 蓝色
2: '#1FA5F2', // 第二层 - 浅蓝色
3: '#10CDBF', // 第三层 - 青色
4: '#72C05F', // 第四层 - 绿色
};
return colorMap[depth] || '#DF951F'; // 默认为考点颜色
}, // 节点背景色
labelText: (d) => d.directoryName, // 节点文案从directoryName字段取值
labelPlacement: (model) => {
// 根节点文案放在中间,其他节点放在下面
return model?.depth === 0 ? 'center' : 'bottom';
}, // 节点文案位置:'left'|'right'|'top'|'bottom'|'center'|'left-top'|'left-bottom'|'right-top'|'right-bottom'|'top-left'|'top-right'|'bottom-left'|'bottom-right'|[number,number]
labelFill: (model) => {
// 根节点使用黑色,其他节点使用灰色
return model?.depth === 0 ? '#FFFFFF' : '#B2B2B2';
}, // 节点文案颜色
labelFontSize: (model) => {
// 根节点使用16px,其他节点使用10px
return model?.depth === 0 ? 18 : 10;
}, // 节点文案字体大小
labelLineHeight: 1.5, // 节点文案行高
labelFontWeight: 'bold', // 节点文案字体粗细
labelFontFamily: 'SourceHanSansCN, SourceHanSansCN', // 节点文案字体
labelWordWrap: true, // 节点文案是否自动换行
labelMaxWidth: 100, // 节点文案最大宽度
labelBackground: false, // 节点文案是否显示背景
labelBackgroundFill: 'blue', // 节点文案背景颜色
labelPadding: [4, 8], // 节点文案内边距
size: (model) => {
const depth = model.depth || 0;
switch(depth) {
case 0: // 根节点
return 80;
case 1: // 第一层
return 60;
case 2: // 第二层
return 40;
default: // 其他层级,包括考点
return 40;
}
},
halo: true, // 节点光环效果,如果节点是图片,就是图片边框
haloStrokeOpacity: 0.25, // 节点光环边框透明度
},
// 自定义节点状态样式
state: {
// 选中状态样式
selected: { // 配合graph.setElementState(target.id, 'selected');使用
fill: 'yellow', // 选中时的填充色
stroke: 'blue', // 选中时的边框色
lineWidth: 2, // 选中时的边框宽度
shadowColor: '#ff4d4f', // 选中时的阴影颜色
shadowBlur: 10, // 选中时的阴影模糊度
},
// 悬停状态样式
hover: {},
// 高亮状态样式
highlight: {}
},
// 自定义节点动画
animation: {
enable: true,
enter: 'fade',
exit: 'fade'
}
},
edge: {
style: {
stroke: 'gray',
lineWidth: 1, // 线条宽度
lineDash: [5, 5], // 虚线样式,[5, 5] 表示 5 个像素的实线,5 个像素的空格
// border: '2px dashed red',
},
},
// zoomRange: [0.5, 2], // 允许缩小到50%和放大到200%
behaviors: (existingBehaviors) => {
return [
...existingBehaviors,
// 'collapse-expand', // 展开收起,使用图片节点会有问题,组件未兼容
{
type: 'collapse-expand',
align: true, // 确保展开/收起时节点位置正确
trigger: 'click', // 点击触发展开收起
onExpand: (id) => {
console.log('onExpand', id);
async () => {
try {
console.log('开始布局');
// 节点展开完成后触发布局
await graphRef.current.layout({
...layoutConfig,
// animation: {
// duration: 300,
// easing: 'ease-out' // 使用更自然的缓动函数
// }
});
} catch (error) {
console.error('布局失败:', error);
} finally {
// stopRender();
console.log('布局完成');
}
}
},
onCollapse: (node) => {
console.log('onCollapse', node);
}
},
{
type: 'zoom-canvas',
trigger: ['pinch'], // 双指捏合手势
sensitivity: 2, // 降低灵敏度,缩放变化更平缓
// 暂未生效
origin: [599, 199, 0], // Zoom with the viewport center as the origin
},
'drag-element', // 拖动节点或组合
'fix-element-size', // 将元素大小固定为指定值
'hover-activate-neighbors', // 悬停激活相邻节点
'auto-adapt-label', // 自动调整标签位置
// 'optimize-viewport-transform', // 优化视图变换性能,跟focus-element一起用会导致边延迟出现,所以注释掉
// {
// type: 'focus-element', // 快速将关注的节点或边居中显示
// animation: {
// duration: 1800, // 聚焦动画时长,默认值1500
// easing: 'ease-in-out', // 动画缓动函数,默认值ease-in-out
// },
// enable: (event) => {
// // 只对节点启用聚焦,边不聚焦
// // return event.target.type === 'node';
// return true; // 节点或边都启用聚焦
// },
// },
];
},
plugins: () => {
return [
{
type: 'tooltip', // 悬停时显示元素详细信息
style: {
'.tooltip': {
background: '#fff',
zIndex: 1000,
visibility: 'hidden',
}
},
trigger: 'hover', // 悬停触发
// enable: (event) => event.targetType === 'node', // 仅在节点上启用
enable: false,
enterable: true, // 允许鼠标进入 tooltip 区域而不触发隐藏
position: 'top', // 提示框位置:top | bottom | left | right | top-left | top-right | bottom-left | bottom-right
offset: [10, 10], // 提示框偏移量
getContent: (event, items) => {
console.log('悬浮触发提示框:', event, items);
const nodeData = items[0] || {};
console.log('悬浮节点数据', nodeData);
const isPoint = nodeData.isDirectory === false;
// 如果是考点节点,显示自定义Modal样式
if (isPoint) {
return `
<div style="
font-size: 16px;
font-family: SourceHanSansCN, SourceHanSansCN;
font-weight: bold;
margin-bottom: 20px;
color: #222222;
text-align: center;
">考点详情</div>
<div style="
border: 1px solid #EFF0F2;
margin-bottom: 20px;
min-width: 390px;
"></div>
<div style="
margin-bottom: 24px;
color: #666;
">
<div style="
display: flex;
align-items: flex-start;
margin-bottom: 20px;
">
<div style="flex-shrink: 0; font-size: 14px;">考点:</div>
<div style="
font-weight: bold;
color: #000;
font-size: 14px;
">${nodeData.directoryName}</div>
</div>
<div style="
display: flex;
align-items: flex-start;
">
<div style="flex-shrink: 0; font-size: 14px;">考频:</div>
<div style="
display: flex;
flex-wrap: wrap;
">${
(function() {
const years = nodeData.pointYear && nodeData.pointYear.length > 0 ? nodeData.pointYear : [];
if(years.length === 0) return `<div style="flex-shrink: 0; font-size: 14px;">暂无数据</div>`;
return years.map(year =>
`<div style="
display: flex;
align-items: center;
justify-content: center;
margin-right: 8px;
margin-bottom: 8px;
border-radius: 4px;
border: 1px solid rgba(77,142,251,0.5);
font-family: SourceHanSansCN, SourceHanSansCN;
font-weight: 500;
font-size: 12px;
color: #4D8EFB;
line-height: 18px;
text-align: center;
width: 55px;
height: 24px;
">${year}</div>`
).join('');
})()
}</div>
</div>
</div>
<button
style="
width: 100%;
padding: 8px 16px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
"
onclick="alert('考点闯关功能触发'); document.querySelector('.g6-tooltip').style.display='none'"
>
考点闯关
</button>
`;
}
// 普通节点显示简单提示
return `<div style="padding: 8px 12px;">${nodeData.directoryName}</div>`;
},
title: '', // 提示框标题
},
]
},
/**
* 当图初始化完成后(即 new Graph() 之后)执行回调
*/
onInit: (graph) => {
console.log('onInit-图实例已初始化:', graph);
// Before rendering starts
graph.on(GraphEvent.BEFORE_RENDER, () => {
console.log('onInit-图实例开始渲染...');
// Show loading indicator
// showLoadingIndicator();
});
// After rendering completes
graph.on(GraphEvent.AFTER_RENDER, () => {
// 保存图谱实例引用
graphRef.current = graph;
console.log('onInit-图实例渲染完成-图节点:', graph.getData());
});
},
/**
* 当图渲染完成后(即 graph.render() 之后)执行回调
*/
onReady: (graph) => {
console.log('onReady-图实例已渲染:', graph);
// 保存图谱实例引用
graphRef.current = graph;
// const center = graphRef.current?.getCanvasCenter();
// console.log('视口中心坐标:', center);
// 监听节点点击
graph.on(NodeEvent.CLICK, (evt) => {
const { target } = evt;
const nodeId = target.id;
const nodeData = graph.getNodeData(nodeId);
const nodeStates = graph.getElementState(nodeId);
if (nodeId === activeTab) {
// navigate('/stretch/examknowledgemap/');
// 获取单个节点
const node = graph.getNodeData(nodeId);
// console.log('节点位置:', nodeId.style.x, nodeId.style.y);
}
console.log('单击节点数据:', nodeData);
console.log('单击节点状态:', nodeStates);
});
// 监听节点双击
graph.on(NodeEvent.DBLCLICK, async (evt) => {
const { target } = evt;
// const nodeId = target.id;
// const nodeData = graph.getNodeData(nodeId);
// const nodeStates = graph.getElementState(nodeId);
// await graph.clear();
// startRender();
// await graph.layout();
// stopRender();
// console.log('双击节点数据:', nodeData);
// console.log('双击节点状态:', nodeStates);
});
// 布局开始前
graph.on(GraphEvent.BEFORE_LAYOUT, () => {
// setGraphLoading(true);
// setDirectoryData({});
console.log('布局计算开始...');
});
// 布局完成后
graph.on(GraphEvent.AFTER_LAYOUT, () => {
console.log('布局计算完成');
// setGraphLoading(false);
});
// 监听边悬停
// graph.on(EdgeEvent.POINTER_OVER, (evt) => {
// });
},
/**
* 当图销毁后(即 graph.destroy() 之后)执行回调
*/
onDestroy: (graph) => {
console.log('onDestroy-图实例已销毁:', graph);
// stopRender();
},
};
}, [directoryData]);`
组件消费代码
<MemoizedDendrogram {...graphOptions} />
Reproduction link / 复现链接
No response
Steps to Reproduce the Bug or Issue / 重现步骤
多次连续展开收起节点
Version / 版本
🆕 2.x
OS / 操作系统
- [x] macOS
- [ ] Windows
- [ ] Linux
- [ ] Others / 其他
Browser / 浏览器
- [x] Chrome
- [ ] Edge
- [ ] Firefox
- [ ] Safari (Limited support / 有限支持)
- [ ] IE (Nonsupport / 不支持)
- [ ] Others / 其他
- 为了好看,节点使用图片,很常见吧?
- 图片节点支持展开收起,也很常见吧?
Hi @wyssyw! I'm Dosu and I’m helping the ant-design-charts team.
是的,节点用图片在图可视化和 UI 设计里都很常见,很多库(包括 Ant Design 的 Tree 组件和 ant-design-charts 的 RadialTreeGraph/Dendrogram)都支持这种做法,通常通过设置节点类型为 image 并指定图片地址来实现美观效果。相关讨论
节点展开收起(collapse-expand)也是树图/知识图谱等场景的标准交互,用户普遍期望图片节点也能正常支持这个功能。相关文档
不过 ant-design-charts 2.x 目前确实有一些已知 bug,比如频繁展开收起图片节点时,src 可能为 undefined 导致报错(Cannot read properties of undefined (reading 'src')),这和节点数据或 src 计算逻辑有关,库本身没有内置防护。相关问题
总结:图片节点和展开收起都很常见,但 ant-design-charts 现有实现在极端场景下可能会有兼容性问题,建议确保 src 始终有效,或关注后续版本修复进展。
To reply, just mention @dosu.
How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other
而且设置defaultExpandLevel的层级小于实际数据的层级,展开收起还会有抖动问题(默认的circle节点也有这个问题)
你反馈的“抖动”问题确实存在于 ant-design-charts 2.x 的树图/辐射图组件(无论是 image 还是 circle 节点),当 defaultExpandLevel 小于实际数据层级时,展开/收起节点会导致布局多次刷新,节点位置出现跳动。这不是你代码的问题,而是库本身的实现限制:expand/collapse 逻辑会直接触发 layout 重算,但没有动画平滑、队列防抖或批量处理,导致视觉上“抖动”明显,且官方目前没有文档、issue 或 PR专门修复这个问题。
根本原因是:节点的 collapsed 状态只根据 depth 和 defaultExpandLevel 判断,展开/收起时会立即刷新布局,但没有做动画过渡或状态同步优化,深层数据初始收起后再展开,布局算法会多次调整节点位置,造成跳动现象(和节点类型无关)相关源码分析。
目前没有官方 workaround。如果需要缓解,可以尝试:
- 关闭动画(animation: { enable: false }),减少视觉跳动,但无法根治;
- defaultExpandLevel 设置为实际最大层级,避免初始收起;
- 展开/收起时手动延迟 layout 或批量处理,减少连续刷新。
这个问题属于 ant-design-charts 的通用限制,建议关注后续版本或社区动态。如果有更复杂的交互需求,可能需要自定义布局或动画逻辑。
To reply, just mention @dosu.
How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other