ECharts玫瑰图实战:二维数据占比计算与配置避坑指南 玫瑰图这个东西说新不算新南丁格尔当年就用它来向英国军方展示战地医院的死亡原因数据。放到ECharts里它就是type: pie配上roseType的一类变体。真正麻烦的是很多人在项目里只会把玫瑰图当“好看一点的饼图”用数据丢进去图出来了但一旦遇到“这是一张二维交叉表要同时体现每个交叉格的所占百分比”这种需求就开始卡壳二维表怎么摊百分比怎么算准图例一筛选为什么百分比变了我这两年做报表类项目这些问题被反复问过索性把ECharts玫瑰图处理二维数据占比的完整思路和实操代码整理出来。这篇文章适合谁看已经会渲染基础ECharts图表、但还没把玫瑰图真正用透的人以及手里正拿着一张“行是区域、列是产品”的二维数据表想用一张图直观展示每格占总体的百分比、又不想被业务追问面积比例失真的同学。不聊虚的前端原理全程可复现照着抄就行。1. 玫瑰图到底在表达什么1.1 玫瑰图和饼图的底层区别饼图对所有扇区使用统一半径靠圆心角去表达占比角度越大占比越高简单直接。但数据项一多比例小的值在饼图里会被压缩到几乎看不见标签也容易挤成一团。玫瑰图改变了这个规则它先把总角度平均分给每个分类然后让半径跟着数值走数值越大花瓣伸得越长。南丁格尔当初设计这种图本质上就是为了让占比很小但很关键的死亡数字“能被看见”。这一点带来的视觉优势非常明显量级差异被放大了。两个数据分别是100和10在饼图里角度是90°和9°在radius模式的玫瑰图里半径比是10比1面积比接近100比1冲击力完全不是一个量级。但代价也很直接radius模式下扇区面积并不等于数值占比。如果你拿它去给客户讲“百分比”遇到较真的业务方对方一眼就能看出面积比例对不上。所以ECharts又提供了area模式扇区圆心角按数值占比分配半径也做相应换算最后每个扇区的面积严格正比于数值。这一正一反就是玫瑰图用来表达百分比时最容易被忽视的分水岭。1.2 “二维数据”在玫瑰图里怎么落位“二维数据”这个说法在业务场景里通常分两种形态。第一种是交叉表行是一个维度列是另一个维度交叉处是数值。比如“华东-产品A”的销量是320。这种数据进玫瑰图最直接的办法是把二维表摊平成一维数组每个单元格变成一片花瓣花瓣名用“行名-列名”拼接花瓣大小跟随数值占比在标签上精确标出。这种方式实现简单适合快速出图代价是丢失了一部分行列间的位置结构。第二种是层级数据一个父分类下面挂着若干子分类比如区域之下还有产品线。这种需求更适合嵌套玫瑰图外层玫瑰展示父分类之间的占比内层玫瑰逐项展示子分类在各自父分类里的占比。两个维度的信息同时出现在一张图里信息密度高真正做到“二维数据都体现所占百分比”。先分清你手里是交叉表还是层级数据再往下处理数据才不会跑偏。2. 二维数据的前处理与百分比计算2.1 交叉表摊平一行一列一个花瓣假设业务方给的数据长这样区域产品A产品B产品C华东32018090华南21012055华北14010035这不是ECharts能直接吃的结构得先摊平。我用一个双重循环把它转成{name, value}数组顺手把全局总和一并算出来。const matrix [ [区域, 产品A, 产品B, 产品C], [华东, 320, 180, 90], [华南, 210, 120, 55], [华北, 140, 100, 35] ]; const pieData []; let total 0; for (let i 1; i matrix.length; i) { for (let j 1; j matrix[0].length; j) { const value Number(matrix[i][j]); total value; pieData.push({ name: ${matrix[i][0]} - ${matrix[0][j]}, value: value }); } }这里有个容易忽略的点矩阵里的数字可能是字符串尤其数据来自Excel或后端接口时最好先Number()或parseFloat处理一下否则ECharts虽然能画tooltip和百分比计算却可能在类型上出幺蛾子。百分比计算我习惯自己再循环一轮不是为了炫技而是因为后续很多定制需求都要拿到精确值比如只显示两位小数、给特定分类做标记等。pieData.forEach(item { item.percent (item.value / total * 100).toFixed(2); });2.2 层级数据内外圈各算各的账如果数据带层级例如按区域分组每个区域内又分产品线思路就完全不同。const nestedData [ { name: 华东, children: [ { name: 产品A, value: 320 }, { name: 产品B, value: 180 } ] }, { name: 华南, children: [ { name: 产品A, value: 210 }, { name: 产品B, value: 120 } ] } ];外层玫瑰需要的是每个大类的总和const outerData nestedData.map(group { const groupTotal group.children.reduce((sum, child) sum child.value, 0); return { name: group.name, value: groupTotal }; });内层玫瑰需要的是每个小类的独立值const innerData []; nestedData.forEach(group { const groupTotal group.children.reduce((sum, child) sum child.value, 0); group.children.forEach(child { innerData.push({ name: ${group.name} - ${child.name}, value: child.value, parentPercent: child.value / groupTotal }); }); });内圈标签的百分比通常希望表达“该小类在父类中的占比”而不是“占全图总量”。这个比例必须用child.value / groupTotal自己算好不能指望ECharts的{d}自动给你因为{d}的分母是整个series的数据总和在多层结构里很容易算出和业务期待不一致的数。2.3 百分比计算最容易翻车的地方我见过好几个人把内圈玫瑰的{d}%直接当成“父类内占比”去汇报。举一个具体例子华东总额500华南总额330产品A在华东是320在华南是210。如果把所有小类放进同一个seriesECharts算出的“华东-产品A”占比是320除以830约38.6%但按照父类占比产品A在华东内部应该是320除以500整整64%。两个数字差得很远业务口径完全不一样。所以什么时候用{d}什么时候自己算判断标准就一条那个百分比的分子分母到底是什么。搞清楚分母计算就不会错。3. ECharts 玫瑰图核心配置逐项拆解3.1 最基础的玫瑰图配置长什么样一个可用的基础玫瑰图配置并不复杂真正决定它是不是“玫瑰图”的是series里的roseType。option { tooltip: { trigger: item, formatter: {b}: {c} ({d}%) }, legend: { bottom: 0, icon: circle }, series: [ { name: 占比分析, type: pie, radius: [20%, 70%], roseType: area, data: pieData, label: { formatter: {b}\n{d}% }, labelLine: { length: 12, length2: 8, smooth: true } } ] };我比较建议radius一定写成区间形式比如[20%, 70%]而不是单独一个70%。区间形式会把玫瑰图变成环形中间留出来的空白区域可以放标题、合计值或者说明文字整体视觉干净很多单一数值样式在图例多、花瓣多时标签重叠的概率会更高。3.2 roseType 参数的选择逻辑ECharts玫瑰花图的roseType取值就两个radius和area。模式圆心角分配半径表现面积与数值关系适合场景radius平均分配半径随数值线性变化面积不严格等比强调排名、拉开差距area按数值占比分配随数值调整面积严格等比表达真实占比、做业务汇报之前有人问为什么说area模式是“严格等比”。原因是ECharts在area模式下先按数值占比分配扇形角度同时调整半径保证每个扇区面积与数值占比保持一致。这个模式下你看到的花瓣面积大小就是数值比例大小用来表达“所占百分比”最不容易误导人。如果你的业务只是想要一个“放大极值差异”的冲击力图表选radius更有冲劲但标题里明确要求体现百分比我默认推荐area。3.3 label 配置显示百分比的三套方案显示百分比有三种常见写法。第一套直接用{d}占位符label: { formatter: {b}\n{d}% }最快ECharts会基于当前系列数据总和自动计算占比。缺点是小数位格式受内部实现控制你很难强制它只显示一位或两位小数。第二套用formatter函数label: { formatter: params { return ${params.name}\n${params.percent.toFixed(1)}%; } }params.percent仍然是ECharts按系列总和算出来的但你可以自由控制小数位也可以对数值为0、被图例隐藏等特殊情况单独处理。这套在单系列玫瑰图里最常用。第三套完全使用自己预计算的数据label: { formatter: params { const pct params.data.percent ?? 0; return ${params.name}\n${pct}%; } }这套适合嵌套玫瑰图、固定分母百分比、自定义业务口径的场景。就像前面说的“占父类比例”只有这个方式能精确控制因为ECharts内部根本不知道你的父子层级关系。3.4 tooltip、图例、颜色这些细节tooltip里{d}%同样能显示占比但tooltip默认显示的百分比会跟随图例筛选变化。比如隐藏了某个分类后剩余分类的百分比会重新归一化。这在大多数场景下是期望行为但如果你希望tooltip恒定展示“占最初全量数据”的百分比就得自己在formatter里读预先算好的percent字段。图例位置也会影响玫瑰图可读性。我一般把legend放底部或右侧icon设成circle而不是默认方块。玫瑰图本身视觉已经很满方块图例会加重噪声。颜色方面玫瑰图花瓣数量偏多千万别用太细的渐变色相邻花瓣的底色要能清晰区分。花瓣超过8个时直接使用预处理好的10色或12色分类调色板或给每个分类绑定itemStyle.color保证颜色在每次渲染时都稳定一致。4. 完整实战二维交叉表玫瑰图从零到一4.1 页面骨架与数据预处理直接给一个可以复制到本地运行的完整页面按二维交叉表演示所有数据预处理逻辑都写在页面里方便观察数据是怎么进入ECharts的。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleECharts 玫瑰图 · 二维数据占比/title script srchttps://cdn.jsdelivr.net/npm/echarts5.4.3/dist/echarts.min.js/script style #chart { width: 100%; height: 600px; } /style /head body div idchart/div script const matrix [ [区域, 产品A, 产品B, 产品C], [华东, 320, 180, 90], [华南, 210, 120, 55], [华北, 140, 100, 35] ]; const pieData []; let total 0; for (let i 1; i matrix.length; i) { for (let j 1; j matrix[0].length; j) { const value Number(matrix[i][j]); total value; pieData.push({ name: ${matrix[i][0]} - ${matrix[0][j]}, value: value }); } } pieData.forEach(item { item.percent (item.value / total * 100).toFixed(2); }); const chart echarts.init(document.getElementById(chart)); chart.setOption({ title: { text: 区域 × 产品 销售占比, left: center, textStyle: { fontSize: 16 } }, tooltip: { trigger: item, formatter: params { return ${params.name}br/销量${params.value}br/占比${params.data.percent}%; } }, legend: { bottom: 0, icon: circle }, series: [ { name: 销售占比, type: pie, radius: [20%, 70%], roseType: area, data: pieData, label: { formatter: params { return ${params.name}\n${params.data.percent}%; } }, labelLine: { length: 12, length2: 8 }, emphasis: { scaleSize: 8 } } ] }); window.addEventListener(resize, () chart.resize()); /script /body /html这个页面打开后你会看到9片花瓣每片花瓣的名称是“区域-产品”拼接label上是百分比。因为用的是area模式花瓣面积和数值占比大致吻合。4.2 怎么看效果怎么验收结果页面跑起来之后先别急着截图建议按三件事验收。第一视觉面积比例是否符合业务预期。随机挑两个值比如320和100面积比应该约等于3.2倍。如果肉眼发现某个大片面积明显异常优先怀疑roseType被改成了radius或者数据没有被正确归一化。第二每个标签的百分比相加是否约等于100%。由于用了两位小数各项相加可能略高于或低于100%这算正常现象。如果业务要求“总和严格等于100%”可以保留一位小数或者让最大值的百分比用100减去其余项之和来兜底。第三图例是否能正确联动。点击图例隐藏“华东-产品B”会发现剩余花瓣的占比重新归一化到100%tooltip里的percent字段也跟着变。这是ECharts的默认联动逻辑表示“当前可见集合中的占比”不是bug。4.3 数据更新时怎么刷新实际项目中图表数据不可能是死的。每次接口返回新的二维表最稳妥的做法是重新生成pieData然后只更新series.data。function updateChart(newMatrix) { const newPieData buildPieData(newMatrix); chart.setOption({ series: [ { data: newPieData } ] }); }这里有一个容易踩的坑如果旧数据的分类集合和新数据不完全一致直接setOption时ECharts默认走merge策略会把旧分类和新分类合并保留那些新数据里已经不存在的老花瓣。如果你不想要这种“合并保留”的效果需要给setOption传第三个参数true或者先把series.data置空再塞新数据。我自己的习惯是数据结构可能变化时setOption第三个参数直接传true整体替换配置避免残留分类干扰视觉效果。5. 常见问题与排查技巧实录5.1 标签叠成一片怎么办玫瑰图花瓣多或者名称长的时候标签重叠是最常见的翻车现场。我的处理顺序是先检查radius区间是不是太窄。比如[0%, 55%]这种花瓣面积被撑得很大标签只能挤在最外沿。把最外层radius降到50%以下给标签留出扇区外部的空间通常能缓解。再调整labelLine的长度。引导线太短标签会缩回扇区内部太长又会和别的花瓣标签交叉。一般length设成10到20length2设成5到15具体微调。最后还不行给label加rotate让文字倾斜排列或者调用label.layout: none跳过ECharts自带的智能布局引擎。label.layout: none在某些版本里能根治顽固性重叠但代价是标签位置不再自动避让需要你手动通过labelLine控制位置。经验是扇区超过10个后不要强求所有标签都显示完整名称可以考虑只在图例里保留名称花瓣标签只显示百分比数字观感干净很多。5.2 百分比小数位总是不可控如果直接用{d}%显示的小数位在不同ECharts版本里不完全一致部分版本默认不保留小数。遇到需要精确到小数点后一位或两位的报表一定要用formatter函数自己toFixed。formatter: params ${params.name}\n${params.percent.toFixed(1)}%注意params.percent在ECharts 5里是0到1之间的小数不是百分数。直接toFixed(1)得到的是0.3这种东西需要最后拼接%符号时把值乘以100。如果你用的是预计算percent字段要确认它已经是“百分比数值”还是“比值”避免显示出0.32%这种低级错误。5.3 图例筛选后百分比“变”了这个不是bug是ECharts的正常行为。图例隐藏某个分类后tooltip和label里的{d}会基于当前可见数据重新计算所以剩余分类的占比会增大。很多初学者会误以为代码出了问题。判断逻辑很简单如果业务要求“图例筛选不改变显示占比”就必须在formatter里使用预先算好的、分母为全局总数的percent字段。如果业务希望看到“在过滤后集合中的占比”用{d}反而更正确。两种需求没有谁优谁劣只是分母口径不一样。5.4 数据量太小花瓣不见了某项数值是1、另一项是3000时数值小的花瓣在area模式下会细到接近一条线。ECharts提供了两个应对手段。一个是minAngle设置扇区最小角度。比如minAngle: 3即使数值再小扇区角度也不会小于3度至少还能让人看到这块位置存在。另一个是minShowLabelAngle小于该角度的扇区不显示label避免细花瓣的标签互相挤压。两个参数配合使用可以让小值“可感知”又不至于污染整个标签布局。不过一旦设置minAngle面积的严格等比性就会被破坏正式占比汇报时要跟业务说明这是为了可读性做的微调。5.5 图表更新后残留旧花瓣这就是前面提到的setOption默认merge策略导致的问题。排查方法很简单打开浏览器控制台调用实例的getOption()方法如果series.data里出现了新数据里没有的分类基本就是这个原因。解决办法是更新时显式声明整体替换chart.setOption({ series: [{ data: newData }] }, true);第二个参数设为true表示notMerge整个配置会按新结构重新构建旧数据残留就不会出现。副作用是因为整体替换需要确保newData对应的label、radius等配置仍然完整否则样式会丢失。折中方案是每次更新时把整个option重新生成一遍再塞进去虽然多写几行代码但心智负担最小。6. 扩展嵌套玫瑰图与交互下钻6.1 嵌套玫瑰图真正把两个维度同时画出来前文说的交叉表摊平方案本质上是“两维变一维”。如果想把两个维度同时保留在图上嵌套玫瑰图是更好的解法。实现思路是叠两个pie系列一个画外圈表示第一维度占比另一个画内圈表示第二维度在每个第一维度分组内的占比。chart.setOption({ series: [ { name: 区域占比, type: pie, radius: [8%, 28%], roseType: area, data: outerData, label: { formatter: {b}\n{d}% } }, { name: 产品线明细, type: pie, radius: [34%, 70%], roseType: area, data: innerData, label: { formatter: {b}\n{d}% }, itemStyle: { opacity: 0.85 } } ] });两个系列默认都居中半径互不重叠视觉上就是一个同心环。外层用来感知大分类的整体结构内层用来查看大分类下的产品明细占比。内层数据多时颜色容易和外层混淆可以给内层加个半透明opacity这样内层花瓣看起来更有“内嵌感”。内圈label的百分比建议使用前面算好的parentPercent来展示“占父类比例”而不是直接{d}否则两种维度的百分比口径不统一看图的人容易被误导。6.2 点击花瓣下钻到第二维度另一个我经常在项目里用的交互点击外层玫瑰图的某个大分类内圈数据动态切换成该大分类下的明细占比。比如点击“华东”内圈变成华东内部产品A、B、C的占比。这样一张图就变成可交互的下钻报表。chart.on(click, params { const seriesIndex params.seriesIndex; if (seriesIndex 0) { const group nestedData.find(d d.name params.name); if (!group) return; const children group.children; const groupTotal children.reduce((sum, c) sum c.value, 0); const targetData children.map(child ({ name: child.name, value: child.value, percent: (child.value / groupTotal * 100).toFixed(2) })); chart.setOption({ series: [ {}, { name: params.name 内部占比, data: targetData, label: { formatter: {b}\n{d}% } } ] }); } });注意setOption时给外层系列保留{}占位防止系列顺序错乱。这种交互的价值在于报表放在大屏或看板上时不需要反复操作页面就能完成从全局概览到局部明细的探索演示效果加分。6.3 移动端和大屏适配的小经验移动端屏幕窄玫瑰图里的标签基本都会重叠。我的做法是radius区间改用百分比形式比如[12%, 62%]并在resize回调中根据图表实际宽度动态缩小标签字号。大屏则相反分辨率高radius可以给到[15%, 75%]labelLine加长一些让标签尽量往外走整体视觉更开阔。适配时别忽略tooltip的触发方式。触屏上没有鼠标hover建议在移动端把tooltip.triggerOn设置为click否则用户手指点花瓣时看不到提示信息。最后聊点个人的手感。玫瑰图好看是真好看但它的“好看”很容易掩盖一个事实如果面积比例和百分比对不上再好看的图都是误导工具。我自己踩过几次坑后基本养成了固定套路先确认数据形态是交叉表还是层级结构再决定是摊平做成单层玫瑰图还是嵌套然后统一口径把所有要用到的百分比在数据预处理阶段就计算好最后才去写option配置。按这个顺序走报表不仅视觉能打数字也能经得起业务方追问。再分享一个小技巧在玫瑰图环形中间放一个合计数值整个图的信息说服力会立刻上一个台阶。