ant-design-charts icon indicating copy to clipboard operation
ant-design-charts copied to clipboard

辐射状布局下使用image类型节点,多次连续展开节点,控制台稳定报错Uncaught TypeError: Cannot read properties of undefined (reading 'src')

Open wyssyw opened this issue 2 months ago • 4 comments

Describe the bug / 问题描述

报错截图:

Image

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 / 其他

wyssyw avatar Oct 22 '25 10:10 wyssyw

  1. 为了好看,节点使用图片,很常见吧?
  2. 图片节点支持展开收起,也很常见吧?

wyssyw avatar Oct 22 '25 10:10 wyssyw

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  Chat with Dosu Join Discord Share on X

dosubot[bot] avatar Oct 22 '25 10:10 dosubot[bot]

而且设置defaultExpandLevel的层级小于实际数据的层级,展开收起还会有抖动问题(默认的circle节点也有这个问题)

wyssyw avatar Oct 23 '25 08:10 wyssyw

你反馈的“抖动”问题确实存在于 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  Chat with Dosu Join Discord Share on X

dosubot[bot] avatar Oct 23 '25 09:10 dosubot[bot]