ECharts旭日图进阶:多层占比可视化的配置与工程实践
做数据可视化的人都知道饼图和环形图是占比分析最常用的图表。但只要数据一旦有了层级这两类图就会立刻捉襟见肘。举个例子老板想看全公司销售额还想看清楚家电类目下的大家电、以及大家电里的空调这样一个三级从属关系你用饼图怎么做拆成三张饼图拼一块信息割裂硬塞进一张饼图视觉上直接失控。ECharts的旭日图就是在这种场景下最有价值的方案它把多个层级的占比关系放进同一个同心圆结构里从内到外一层一层展开既保留了饼图读占比的直觉又支持向下钻取的层级浏览方式。这篇文章我会把旭日图从数据格式、核心配置、样式调整到真实项目里的坑全部过一遍适合已经会用ECharts画饼图、折线图但想扩展树形可视化能力的同学。1. 旭日图是怎么解决饼图解决不了的问题的1.1 饼图的局限与旭日图的对应关系饼图的设计思路很简单整个饼代表100%每个扇形角度代表一个类目占比。这个模型只有一个维度因为你只有一层“切片”不可能在一个切片的内部再切出子切片。环形图多留了一个中间空洞但本质上还是单层占比。旭日图换了个思路把多个层级的扇形围绕同一个圆心一圈一圈往外叠。最内圈是第一层紧贴着的外圈是第二层再往外是第三层。每个扇区的角度大小依然代表数值占比从上往下看的时候第一层和第二层的扇形角度是严格对齐的所以你可以一眼看出某一个二级类目在它的一级父类目里占了多少。这种结构本质上就是把多张饼图叠在一起但通过共享圆心和角度让它们之间的从属关系一目了然。ECharts的旭日图底层使用的是树形数据一个节点的children就是下一层扇形数值如果不指定会由所有子孙节点的值累加而来。这就是它和普通环形图在数据模型上最本质的区别。1.2 旭日图适合什么不适合什么旭日图最强的地方是“层级占比 下钻浏览”。我实际用到过的场景包括电商后台的商品类目销售占比分析从一级类目下钻到三级类目磁盘或云存储空间分析看哪个目录占了空间点进去又能看子目录分布广告投放的渠道层级分析比如投放到平台、账号、计划、创意四个层级问卷结果的树状汇总先看整体选项分布再看不同分组下的差异。但它不适合所有场景。旭日图本质上是静态的占比视图不适合展示时间序列变化。如果你的数据节点特别多比如一个根节点下有几十个兄弟节点外层扇形会非常细碎标签基本挤不下。也不用硬上旭日图这时候矩形树图往往会更合理。层级超过四五层的旭日图可读性会断崖式下降越往外圈的扇区越窄文字几乎没法放。2. 先跑通一个最小旭日图实例2.1 看懂旭日图的数据结构旭日图接的数据是一棵嵌套树。先看一个最直接的例子var data [ { name: 家电, children: [ { name: 大家电, children: [ { name: 冰箱, value: 120 }, { name: 洗衣机, value: 80 } ] }, { name: 小家电, children: [ { name: 电饭煲, value: 60 }, { name: 吸尘器, value: 40 } ] } ] } ];这里只给最底层的“叶子节点”配置了valueECharts会自动把冰箱、洗衣机的数值加总成大家电的扇形角度再把大家电和小家电加起来成为家电这个根节点的角度。这是旭日图最标准、也最不容易出错的写法。如果你非要在父节点上也设置value比如给“大家电”加上80那这个80会被单独渲染成一段扇区视觉上会看到大家的子节点没法正好填满根节点的范围而是多出一圈“自身值”。这种写法偶尔有特殊用途但绝大多数情况下不是你要的效果。所以我建议默认只在叶子节点上给值父节点的值让ECharts自己算。2.2 最小页面完整代码把上面的数据塞进一个HTML页面里用CDN加载ECharts 5最短就能跑起来!DOCTYPE html html langzh-CN head meta charsetUTF-8 title旭日图最小示例/title script srchttps://cdn.jsdelivr.net/npm/echarts5/dist/echarts.min.js/script /head body div idchart stylewidth: 700px; height: 500px;/div script var chart echarts.init(document.getElementById(chart)); var data [ { name: 家电, children: [ { name: 大家电, children: [ { name: 冰箱, value: 120 }, { name: 洗衣机, value: 80 } ] }, { name: 小家电, children: [ { name: 电饭煲, value: 60 }, { name: 吸尘器, value: 40 } ] } ] } ]; chart.setOption({ series: [ { type: sunburst, radius: [0, 90%], data: data } ] }); /script /body /html这里只有一个关键配置type: sunburst。radius: [0, 90%]表示从圆心开始一直延伸到容器宽高较小值的90%第二个值是外半径如果改成[20%, 90%]中间会留出一个空心环形效果更像环形旭日图。display的时候标签默认会在扇区内部显示因为例子中没有设置labelECharts会用默认策略展示。2.3 levels配置项逐层控制每圈的样式旭日图里最常用的一个配置就是levels它是数组下标0代表最内层1代表第二层以此类推。每一层都可以单独设置半径边界、颜色、边框、标签等。这样你就不用给每个数据节点单独写itemStyle了。series: [ { type: sunburst, data: data, levels: [ { r: 30%, // 第一圈的半径 itemStyle: { borderColor: #fff, borderWidth: 2 } }, { r: 60%, itemStyle: { borderColor: #fff, borderWidth: 2 } }, { r: 90%, label: { rotate: radial } } ] } ]用r直接指定每一层的半径可以精确控制每一层的宽窄。比如你想让最内层窄一点、外层宽一点就能通过levels实现。还可以在某一层指定color数组比如第二层想用一组浅色第三层用一组深色就把颜色写进对应level里比在数据项里逐项指定颜色省事得多。2.4 角度、方向和排序三个容易被忽略的细节startAngle默认值是90表示第一块扇区从正上方起始然后顺时针展开。如果你从后端拿到的分类顺序有业务含义比如想从左边开始读就把它改成0。clockwise默认true也就是顺时针排列。如果设成false扇区会逆时针排中文阅读场景下其实不一定好但有些雷达图风格的仪表盘会用到。sort默认是按数据原始顺序画。想让大块排前面、小块沉淀在后面可以设成sort: desc也可以传一个函数自定义比较逻辑。我在实际项目里通常都会设成desc这样第一眼就能看到最大的分类在哪个位置。这三个配置都很小但直接影响用户读图的第一体验。尤其是扇区多的时候大的分块如果不排在一起视觉上会显得散乱。3. 让旭日图真正好用标签、颜色和下钻3.1 tooltip和label格式化显示完整路径旭日图的扇区本身就嵌套着层级如果你只在扇区里写当前节点的名字用户往往不知道这个扇区属于哪个一级类目。ECharts的旭日图事件参数里给了treePathInfo里面保存着从根节点到当前节点的完整路径格式化提示框时可以直接用。tooltip: { trigger: item, formatter: function (params) { var path params.treePathInfo.map(function (item) { return item.name; }).join( / ); return path br/数值 params.value; } }这样鼠标悬停到任何一个扇区时提示框会展示类似“家电 / 大家电 / 冰箱”这样的完整路径而不是一个孤零零的“冰箱”。百分比也是高频需求。需要注意百分比的分母应该是全部叶子节点的值总和而不是当前扇区的祖父节点值。如果在formatter里想算分母最好在外部维护一个全局total变量别每次在formatter里递归整棵树性能太差。var totalValue 300; // 外面算好 label: { show: true, formatter: function (params) { return params.name \n (params.value / totalValue * 100).toFixed(1) %; } }3.2 配色别让颜色淹没层级关系旭日图的颜色体系比普通饼图难处理因为同一层级的多个扇区要有区分度但不同层级之间还要有视觉层级关系。一个稳妥的做法是用levels为每一层设置不同的色系偏好。例如第一层用中性灰第二层用一组低饱和度彩色第三层用更高饱和度的彩色。这样能形成“内灰外彩、越深越重”的视觉引导。levels: [ { r: 22%, color: [#5b6b7a] }, { r: 55%, color: [#6fa8dc, #f2c079, #a6c58f, #e58b8b] }, { r: 88%, color: [#3f739c, #b97749, #5f7e46, #a04a4a] } ]如果你想表达“数值越大颜色越深”就是连续色阶可以提前写好一个映射函数在转树形数据时给每个叶子节点设置itemStyle.color。这种方案不适合用全局调色板因为大扇区需要饱和度高、小扇区需要颜色浅否则视觉重心会失衡。3.3 点击下钻从父级进入子级旭日图默认没有“点外层自动放大地内层”的功能。你可以设置nodeClick: rootNode让点击非最内层扇区时快速回退到根节点展示。但如果要真正做下钻体验我习惯自己监听点击事件把点击到的节点重新作为根节点重新setOption。var allData [ /* 完整的树形数据 */ ]; var originOption { series: [{ type: sunburst, data: allData }] }; chart.setOption(originOption); chart.on(click, function (params) { if (!params.treePathInfo) return; var path params.treePathInfo.map(function (item) { return item.name; }); var node findNode(allData[0], path, 0); if (node node.children) { chart.setOption({ series: [{ data: [node] }] }, true); // notMerge让旧层级不残留 } }); function findNode(node, path, index) { if (!node) return null; if (index path.length - 1) return node; if (!node.children) return null; var childName path[index 1]; for (var i 0; i node.children.length; i) { var child node.children[i]; if (child.name childName) { return findNode(child, path, index 1); } } return null; }返回根节点时我用一个按钮或者点击中央区域重新调用chart.setOption(originOption)。下钻时别忘了notMerge参数如果不加ECharts默认的merge模式会把旧数据残留下来导致外层仍然有之前级别的扇形排查起来还挺隐蔽的。这个坑我至少踩过两次。3.4 动态数据刷新和加载态如果是管理后台旭日图的数据通常来自接口。请求回来之后需要整体替换数据我的标准写法是chart.showLoading(); fetch(/api/category-stat) .then(function (res) { return res.json(); }) .then(function (data) { chart.hideLoading(); chart.setOption({ series: [{ data: data }] }, true); });前端展示的时候如果用户切换了筛选条件最好直接notMerge: true避免旧层级残留。另外一个容易忽略的问题是容器尺寸变化。仪表盘页面经常要适配窗口缩放一定要记得监听resizewindow.addEventListener(resize, function () { chart.resize(); });如果旭日图放在一个动态折叠的侧边栏旁边容器从隐藏状态变成显示时chart.resize()会失效最好在展开动画完成后再调用一次。4. 从扁平数据构造树形结构真实项目的必经之路4.1 后端返回的数据往往是扁平的大多数后端接口不会直接给你嵌套树。它们返回的通常是类似这样的数组[ { path: 家电/大家电/冰箱, value: 120 }, { path: 家电/大家电/洗衣机, value: 80 }, { path: 家电/小家电/电饭煲, value: 60 }, { path: 家电/小家电/吸尘器, value: 40 } ]你需要自己把它转换成旭日图能认识的树形结构。下面是一个按路径分隔符构建树的函数function buildTree(rows, separator) { separator separator || /; var root { name: 全部, children: [] }; rows.forEach(function (row) { var parts row.path.split(separator); var currentNode root; parts.forEach(function (name, index) { if (!name) return; var nextNode null; for (var i 0; i currentNode.children.length; i) { if (currentNode.children[i].name name) { nextNode currentNode.children[i]; break; } } if (!nextNode) { nextNode { name: name, children: [] }; currentNode.children.push(nextNode); } if (index parts.length - 1) { nextNode.value row.value; } currentNode nextNode; }); }); return root; }注意一点路径分隔符不要和产品名里的特殊字符冲突。如果分类名称本身可能包含“/”比如“男装/女装”这种带斜杠的类目你要提前约定好分隔符或者让后端直接返回嵌套JSON否则拆分路径时会出现数据错乱。4.2 转换完数据后别忘排序buildTree构建出来的children顺序完全依赖接口返回顺序。为了让展示稳定并且尊重“大扇区靠前”的阅读习惯我通常会在渲染前对所有层级的children按value降序排一遍function sortTree(node) { if (node.children) { node.children.sort(function (a, b) { return getValue(b) - getValue(a); }); node.children.forEach(sortTree); } } function getValue(node) { if (node.value ! null) return node.value; if (node.children) { return node.children.reduce(function (sum, item) { return sum getValue(item); }, 0); } return 0; }注意getValue是递归求和如果树很深、叶子很多每次排序都递归一遍会有性能开销。如果数据量不大比如几千个节点这点成本可以忽略如果数据量很大建议转换时就把每个节点的总数值缓存到node.__total上排序只读这个字段。4.3 小扇区的合并策略旭日图的视觉逻辑和饼图一样节点越多越难读。当某个层级的子节点数量超过20个外圈扇区就非常窄标签基本无法显示。这时候我会在数据层预先做聚合把value小于某个阈值的子节点合并成一个“其他”节点。阈值根据总值的比例来定比如小于总和的1%就合并。function mergeSmallSlices(root, threshold) { mergeLevel(root, threshold); return root; } function mergeLevel(node, threshold) { if (!node.children) return; var rest []; var restValue 0; var kept node.children.filter(function (child) { var v getNodeValue(child); if (v threshold) { restValue v; rest.push(child); return false; } return true; }); if (rest.length 0) { kept.push({ name: 其他, value: restValue, children: [] }); } node.children kept; node.children.forEach(function (child) { mergeLevel(child, threshold); }); }这样处理后的旭日图会更清爽大扇区的占比关系不会被一堆细碎零件干扰。需要注意的是合并后的“其他”可能包含多个不相关的子项所以tooltip里最好只显示一个汇总值不要试图展开合并节点。5. 我踩过的坑和排查思路5.1 父节点给了value外层扇形对不齐这是旭日图最容易让人困惑的一个坑。很多从矩形树图转过来的同学习惯每层都填value结果发现外层子扇区加起来怎么都不等于父扇区角度还以为是颜色遮住了。其实问题就在于父节点自带的value会额外生成一段扇区。解决办法很简单只给叶子节点赋值。如果数据结构里父节点也必须传value那就在前端构建树时把父节点的value全都去掉或者复制一份数据只保留叶子节点的value。5.2 标签重叠严重用minAngle和rotate旭日图的小扇区多了以后标签文字会互相压住。这里有两条路线第一设置label.minAngle低于这个角度的扇区不显示标签。比如10度以下的扇区就不显示文字只靠hover去看tooltip。label: { show: true, minAngle: 15, rotate: radial }第二设置label.rotate。radial会让文字沿半径方向排文字像从圆心向外发散tangential会让文字沿切线方向排适合环形结构。我测试下来中文类目名在半径较窄的外圈用radial好一些看起来像扇区自带的方向感。如果项目是移动端屏幕小标签基本放不下。建议直接用label: { show: false }然后用tooltip替代展示底部可以再加一个当前扇区路径的联动信息。5.3 大数据量渲染卡顿旭日图底层还是Canvas渲染几千个节点问题不大但如果上万个扇区特别是频繁hover和动画时会出现明显卡顿。我的处理优先级是先合并小扇区把节点数量压到500个以内关闭动画animation: false尤其在动态刷新时关闭阴影、降低边框宽度减少每帧绘制压力如果必须要展示海量节点别用旭日图改矩形树图。这里有一个实际案例我之前做日志系统的存储分析原始数据有8000多个文件目录旭日图上外层扇区密密麻麻缩到最小也看不清。合并小扇区到800个节点之后渲染和交互都流畅了而且老板只看大目录合并小目录不影响决策。5.4 数据更新的坑merge模式残留ECharts的setOption默认是merge模式。对于普通折线图merge模式很好用更新部分数据不影响其他配置但对旭日图这种树形结构老树的数据节点会残留在画面上出现“幽灵扇区”。我的习惯是只要旭日图的数据来自异步接口或者用户切换了维度就直接传第三个参数true强制notMergechart.setOption(newOption, true);如果只更新某个节点的数值整个树形结构没变也尽量不要用merge去单独改一个节点容易引发子节点顺序和扇区角度错乱。宁可整体重建数据也不要做局部patch。5.5 一个隐藏的细节根节点名称在路径里有时候我们把根节点命名为“全部”或“总览”那么tooltip路径会变成“全部 / 家电 / 大家电 / 冰箱”。如果觉得“全部”冗余可以在formatter里用slice(1)去掉第一个路径项。formatter: function (params) { var path params.treePathInfo.map(function (item) { return item.name; }).slice(1).join( / ); return path br/ params.value; }这个细节不大但能避免展示信息里的“废话”在给前端评审看效果时也会更讨喜。6. 旭日图的选型边界和视觉设计建议6.1 旭日图、矩形树图、树图怎么选很多人问我“旭日图能展示层级树图也能展示层级到底有什么区别”我一般给出这样的判断标准图表类型最擅长不擅长旭日图多层级的占比结构、扇区下钻、环形视觉节点太多、层级过深矩形树图大规模树形数据用面积代表数值体现不好“层级流转”的感觉横向树图展示父子关系、分支结构不强调数值占比饼图/环形图单层级清晰占比任何层级结构简单说如果主要诉求是“占比”优先考虑旭日图和矩形树图如果主要诉求是“关系结构”优先考虑树图如果只有一层数据老老实实用饼图。旭日图相比矩形树图还有一个优势它天然有一个视觉焦点从圆心向外一圈圈扩散适合站在大屏前快速讲解。矩形树图则信息密度更高适合放在数据报表页里让人慢慢看。6.2 视觉设计原则我用旭日图做项目的时候会遵守三条原则第一层级颜色要有规律。最内层不要颜色过深之外每一层再逐渐加重。否则用户看到的第一眼会分不清哪层是父级、哪层是子级。第二高亮和暗态要协调。ECharts旭日图默认hover时的emphasis效果还可以但如果想突出某个扇区可以用itemStyle.emphasis.color单独指定。不想高亮时周边扇形太暗就别把secondaryItemStyle设得太黑透明度降一档就够了。第三边框是隔开扇区的关键。扇区之间留一圈borderColor会让层级更清晰。通常设成和背景色一致的白色边框粗细2到3像素比较合适。没有边框的话同层级的扇区之间颜色靠得很近视觉上容易连成一片。6.3 我在实际落地中的体会旭日图看着炫但它不是万能的。这两年我在项目里用过不下十次真正交付效果好的是两类场景一类是决策大屏上的类目占比下钻另一类是存储资源按照目录层级分析。这两种场景都符合“从宏观到微观”的浏览路径先看大局点进去看细节。旭日图的交互正好匹配这样的心智模型。反过来只要业务方说“我想所有层级都平铺出来”也就是不强调逐级下钻那我会建议换矩形树图。强行把二十个三级类目塞进旭日图视觉上确实会出片但读数和理解成本都上来了最后用户还是点两下就放弃了。最后分享一个实用小技巧因为旭日图的数据是树形结构ECharts的legend组件默认对它不大友好我一般不在旭日图上挂图例。需要图例的话自己用HTML在容器下方生成一个简单的色块列表样式更可控也不占图表Canvas的绘制时间。这些细节都是产品上线后从用户反馈里一点点总结出来的希望这篇文章能帮你少走几段弯路。