我们做数据可视化时,玫瑰图(南丁格尔玫瑰图)一直是我特别偏爱的一种图表形态:它比饼图更有张力,扇区的半径差异能让用户一眼看出谁大谁小,美观度也在线。但用得多了就会发现一个尴尬的问题——普通玫瑰图只能表达一个维度的占比。角度表达分类、半径表达数值,虽然好看,可一旦数据里有两个维度,比如“品类”和“渠道”同时都要看百分比,单张玫瑰图就很难说清楚。我在处理某模拟项目X的经营数据时正好遇到这个需求,折腾了两种方案后找到了比较顺手的实现方式,这篇把思路、代码、踩坑过程一起整理出来,给同样在跟 ECharts 玫瑰图多维占比较劲的朋友做个参考。
很多人第一步就被绕晕在“二维数据”的定义上。这里说的二维数据,不是指 X 轴和 Y 轴两个数值轴,而是指“行维度 + 列维度”,也就是一个二维交叉表。例如下表:
| 品类 | 线上渠道 | 线下渠道 | 合计 |
|---|---|---|---|
| 食品 | 120 | 80 | 200 |
| 服饰 | 90 | 110 | 200 |
| 家电 | 60 | 140 | 200 |
| 美妆 | 150 | 50 | 200 |
这组数据里,每个格子有两个百分比身份:一个是占“品类合计”的百分比(行内占比),一个是占“渠道合计”的百分比(列内占比)。如果只用一张普通玫瑰图,往往只能展示“品类占总体的百分比”——这就是单维度视角。标题里说的“二维数据都体现所占百分比”,真正的诉求是:在同一张玫瑰图里,既要让读者看出品类的总体占比,又要能看出每个品类内部两个渠道分别贡献了多少百分比。两个维度都要有所体现,所以标题隐含的难点其实在“都”字上。
这篇博文会讨论几种落地方案,重点讲我最后使用的“主轴玫瑰图 + 扇区内堆叠分段”的组合实现方式,包括百分比计算、series 配置、label 格式化、交互避坑,保证你拿去就能用在真实项目里。
1. 为什么普通玫瑰图表达不了二维占比:先看懂角度、半径、颜色三种编码通道
玫瑰图本质上是用三种视觉通道编码数据:角度(或扇区在圆周上的跨度)、半径(扇区延伸的长度)、颜色。ECharts 中玫瑰图有两种常见模式:roseType: 'radius'时,扇区角度均分,半径大小代表数值大小;roseType: 'area'时,用面积表达数值,半径会根据开方关系自动调整。但无论哪种,默认都只有一组数据来源,也就是一个数组只对应一个数值字段,天然只能表达“分类 -> 数值”的一维占关系。
要表达二维数据,就得在同一图表里叠加第二层视觉通道。常见的手段有几种,各有取舍,我把它们放在一起做个对比,这也是我前期选型时反复斟酌的部分。
| 方案 | 实现难度 | 二维表达效果 | 可读性 | 适用场景 |
|---|---|---|---|---|
| 方案A:两张并排玫瑰图 | 低 | 能表达两个维度,但分离展示,没法在一个图里对照 | 一般 | 只需要对照,不需要关联 |
| 方案B:玫瑰图扇区内再做嵌套环 | 中 | 内外环分别代表不同维度,但面积容易误导 | 中 | 维度层级明确且不重叠 |
| 方案C:主玫瑰图 + 扇区内堆叠独立条 | 中高 | 第一维用扇区跨度表达,第二维用半径堆叠段表达 | 较好 | 行列交叉数据 |
| 方案D:旭日图替代玫瑰图 | 低 | 非常适合层级占比,但不是玫瑰图 | 高 | 品类多、想看父子结构 |
如果你只是想把两个维度放在同一张图上“都体现”,且不要求玫瑰图形态,方案D的旭日图其实最省事。但标题明确限定在玫瑰图,所以我主要推荐方案C——用“主轴扇区”承载第一维度百分比,再用“扇区内堆叠段”承载第二维度百分比。这种做法的好处是:轴向上你能看到“食品在所有品类中占 25%”,径向上你能看到“食品内部线上渠道占 60%、线下渠道占 40%”。
这里有个关键认知要先建立起来:玫瑰图里我们可以把半径方向“分段”。每一个扇区内部,通过stack属性把多个数据值叠在同一条半径上,各段的长短之比就自然形成了第二维度的百分比关系。ECharts 对这种“堆叠玫瑰图”是支持的,关键就在于 series 的配置方式,这也是下面一章的核心。
2. 核心实现方案:堆叠式玫瑰图的思路与数据预处理
选定方案后,第一步不是写配置,而是先把原始数据结构处理成 ECharts 能直接消费的数组。很多新手在这里犯错:直接拿二维表去填充 series,结果图的形状完全不对。我当时也是调了半天才意识到,玫瑰图 series 的每一项代表的是“分类下的一个具体系列”,而不是一个格子。
对上面的食品、服饰、家电、美妆数据,如果要展示“品类总占比 + 品类内渠道占比”,需要把数据改造成这种思路:
- 第一维度(分类占比):先对每个品类求和,得到每个品类的合计值,用于把角度拉开(可以让
roseType: 'area'自动处理角度和半径)。 - 第二维度(类内占比):在每个品类内部,把线上、线下两个渠道值作为同一扇区内沿半径方向堆叠的段。两个渠道相加等于品类合计,这样段之间的长度之比就是渠道百分比。
实际准备数据的 JavaScript 代码如下:
const rawData = [ { category: '食品', online: 120, offline: 80 }, { category: '服饰', online: 90, offline: 110 }, { category: '家电', online: 60, offline: 140 }, { category: '美妆', online: 150, offline: 50 }, ]; // 第一步:计算每个品类的合计值,用于主轴维度 const categories = rawData.map(d => d.category); const totalByCategory = rawData.map(d => d.online + d.offline); const grandTotal = totalByCategory.reduce((a, b) => a + b, 0); // 第二步:生成系列数据 // 系列一:线上渠道在各品类中的值 // 系列二:线下渠道在各品类中的值 const onlineData = rawData.map(d => d.online); const offlineData = rawData.map(d => d.offline);这里有一个很多人没意识到的问题:如果直接设置两个系列分别为 onlineData、offlineData,并开启同一个 stack 堆叠,玫瑰图确实会形成分段扇形,但主轴半径显示的总长度等于“线上 + 线下”的总和,这样品类之间的半径差距虽然在,但读者很难直接算出“美妆占总体的 25%”这个比例。所以我在实践里通常会事先计算出百分比,并且让 label 直接显示第一维度的百分比,而半径高度仍然用原始数值驱动。
接着是关键配置,直出可运行的 option 片段:
option = { color: ['#5B8FF9', '#61DDAA'], tooltip: { trigger: 'item', formatter: function(params) { // params.seriesName 是渠道名,params.name 是品类名 const categoryTotal = totalByCategory[categories.indexOf(params.name)]; const channelValue = params.value; // 第一维:品类占总体的百分比 const catPct = ((categoryTotal / grandTotal) * 100).toFixed(1); // 第二维:渠道占品类的百分比 const chPct = ((channelValue / categoryTotal) * 100).toFixed(1); return `${params.name}<br/>品类占比:${catPct}%<br/>${params.seriesName}:${channelValue}(占品类 ${chPct}%)`; } }, series: [ { name: '线上渠道', type: 'pie', roseType: 'radius', radius: ['20%', '90%'], center: ['50%', '50%'], stack: 'total', data: onlineData.map((val, idx) => ({ name: categories[idx], value: val })), label: { formatter: function(params) { const categoryTotal = totalByCategory[categories.indexOf(params.name)]; const catPct = ((categoryTotal / grandTotal) * 100).toFixed(1); return `${params.name}\n${catPct}%`; } } }, { name: '线下渠道', type: 'pie', roseType: 'radius', radius: ['20%', '90%'], center: ['50%', '50%'], stack: 'total', data: offlineData.map((val, idx) => ({ name: categories[idx], value: val })), label: { show: false } } ] };我解释一下为什么这样写。
roseType: 'radius'表示角度的跨度对所有扇区是相等的,半径与数值成正比。两个系列共用相同的stack: 'total',它们在同名 data 项中就会沿半径堆叠。也就是说,“线上食品”和“线下食品”这两段会在同一个扇区内沿半径拼接,拼完后总长度等于食品的合计值,这就实现了“扇区内堆叠分段”。
label 只显示在第一系列(线上渠道)上,显示的内容是品类占总体的百分比;第二系列关掉 label,避免同一品类内标签重复。tooltip 里我做了两层信息:品类占总体的百分比 + 该渠道占品类的百分比。这样一来,鼠标悬停时两个维度的百分比都有,标题的核心诉求就落在实处了。
3. 两种备选路线对比:为什么我没有选双环嵌套,也没直接上旭日图
第二节的方案是最终落地的,但落地之前我确实把双环嵌套图、旭日图都试了一圈,这里认真说说对比结论,因为很多朋友一搜“ECharts 玫瑰图二维数据”会被网上各种方案带偏。
3.1 备选路线一:双环嵌套玫瑰图
做法:内层玫瑰图展示品类占比,外层展示渠道占比,内外环通过radius区分,比如内层['0%', '45%'],外层['55%', '90%']。
优点:内外分层视觉上挺清晰,第一维和第二维一目了然。如果你只给领导看“总共哪几个品类占大头 + 每类渠道分布”,这个方案其实是最直观、最容易解释的。
缺点:一旦内外环扇区数量不一样,读者的视线很难对齐——内层某种品类对应外层哪些扇区,基本要对着图例找半天。更麻烦的是,如果想在悬停时同时联动显示两个维度的百分比,需要额外监听事件,无法靠 tooltip 自动完成。
我最终没选它,原因就是数据里渠道数量是 2,品类数量不确定,内外扇区数不一致导致的对齐困难是实际使用中很难接受的。
3.2 备选路线二:旭日图
旭日图(sunburst)几乎就是为多层占比而生的:根节点、一层节点、二层节点,天然覆盖“总体 -> 品类 -> 渠道”的层级关系,百分比也可以通过label自动格式化出来。而且 ECharts 对旭日图的支持非常成熟,代码也短。
但我还是没选。原因有三点:
- 旭日图的视觉重点是面积层级,适合“越细分的维度越靠近圆心”,而玫瑰图更有视觉冲击力,适合做经营大屏和报告首页;
- 标题明确是玫瑰图,用旭日图严格来说是“换图”,不仅改了视觉风格,很多环节都要重做;
- 玫瑰图本身可以做得很好看,在四五个品类、每类两三个子项的数据规模下,堆叠分段完全hold住。
3.3 备选路线三:扇区内独立柱子(自定义 series)
也有人会把玫瑰图的每个扇区内部再渲染成多个独立的迷你柱子,表示每个渠道的占比。这种做法视觉效果是堆叠分段方案的“非堆叠变体”,但它有个致命问题:柱子之间没有连接关系,读者很自然地把柱子长度当成独立数值,看不出“食品 120 + 80 = 200”的总量关系。而且 ECharts 原生不支持在玫瑰图扇区内部再画柱子,需要引入额外的custom系列或者图表混搭,复杂度飙升。
所以综合下来,堆叠玫瑰图在“同图表达两维百分比 + 保持玫瑰图形态 + 原生支持良好”这三个维度是最平衡的。
4. 想让“都体现”成立,label、tooltip、图例一个都不能少
这一章是实际操作里最容易被忽略但恰恰决定成败的部分。很多人把 series 配完后发现图上确实有分段了,但用户根本看不出来“百分比”在哪,问题通常出在 label、tooltip 和图例的联动策略上。
4.1 label 格式化:把两个维度的百分比都写到扇区上
第一节代码里的 label formatter 只写了品类占比,这是因为我担心扇区太小,文字太多会糊。但如果你的品类不多、扇区面积大,可以在 label 里同时输出两行信息:
label: { formatter: function(params) { const categoryTotal = totalByCategory[categories.indexOf(params.name)]; const channelValue = params.value; const catPct = ((categoryTotal / grandTotal) * 100).toFixed(1); const chPct = ((channelValue / categoryTotal) * 100).toFixed(1); if (params.seriesName === '线上渠道') { return `${params.name}\n占总体 ${catPct}%\n线上渠道占比 ${chPct}%`; } return ''; } }这样每个品类扇区上就会显示“食品 占总体 25% 线上渠道占比 60%”,两个维度的百分比都盖在图上。不过注意,线下渠道的占比不要重复显示,否则同品类两个系列叠加会让标签叠压得没法看。
4.2 tooltip:悬停时自动计算两种百分比
tooltip 的优势是交互式补充,不占用静态空间。我在第一节代码里已经写了 formatter,这里再强调两个细节:
- 一定要用
params.seriesName和params.name,因为玫瑰图堆叠后,tooltip 默认只会显示系列名 + 数值,不会帮你算百分比。 - 百分比计算要用“当前渠道值 / 品类合计”而不是“当前渠道值 / 总值”。后者算出来是渠道在总体中的占比,和“品类内部渠道占比”是两个概念,你要明确自己想展示哪一个维度。我在项目里两个都展示了,因为都是业务关心的。
4.3 图例联动:隐藏某渠道后百分比含义不能乱
ECharts 默认点击图例可以隐藏某个系列,这在普通柱状图、折线图里很安全。但在堆叠玫瑰图里隐藏一个系列会让你失去第二维度的完整性:如果你把“线下渠道”关掉,只有“线上渠道”的玫瑰图依然会渲染,扇区形状也依然存在,但半径只代表线上值,不再代表合计值。此时 label 里显示的“占总体 25%”依然正确,可“线上渠道占比 60%”就变成了“线上渠道单独占用半径的比例”,概念已经变了,用户极容易误读。
我的处理办法是:关闭图例的切换能力,或者在图例点击时重新计算并提示用户。如果业务上一定要允许隐藏某个渠道,我建议在 formatter 里动态判断当前有哪些系列可见,然后重新计算分母(可见渠道值之和),这样百分比才不会失真。实际项目中公司最终决定保留图例切换,所以我加了重算逻辑,代码大致是这样的:
legend: { selected: { '线上渠道': true, '线下渠道': true } },然后在 formatter 里用this拿不到的 legend 状态,就直接用一个全局变量visibleChannels维护,点击 legend 时更新它,再触发chart.setOption更新 tooltip。
这里必须加一句提醒:千万别把“两个系列堆叠后半径就是总和”当成永远不变的假设。一旦系列隐藏、数据更新、或动态下钻,这个假设就会崩,所有百分比都要跟着重新算。
5. 实战避坑记录:我在跑通这个图时踩过的五个具体坑
代码能跑通只是第一步,图表上线前还要处理一堆视觉和交互细节。下面五个问题是我真实遇到过的,按踩坑顺序写出来,每个都给了解决思路,你大概率也会碰上其中一个。
5.1 radius 范围导致内外圈间距过大/过小
我第一次配radius: ['10%', '80%'],发现内圈留白过大,整个玫瑰图显得空洞。后来统一改成['20%', '85%']之后视觉效果好了很多。原因很简单:玫瑰图的半径起始位置决定分段起点,太小会让内圈拥挤,太大则让扇区面积被压缩。实际调到 20%~85% 是我在四个品类下的经验值,如果品类多,可以适当把内圈下压到 15%,给 label 留空间。
5.2 小数值品类标签互相覆盖
当某个品类占比很低,比如只有 3%,它的扇区半径很短,两个渠道的分段高度也有限,此时 label 默认配置会把文字甩出扇区边缘,和其他扇区的标签叠在一起。ECharts 的label.overflow在这个场景帮不上太大忙,我最终用了两个措施组合:
- 把
minAngle设成 5,强制扇区最小角度,避免太扁的扇区; - 对占比小于 5% 的品类不做完整 label,只在 tooltip 中展示,静态图上显示它“占比过小,悬停查看”。
5.3 面积模式(roseType: 'area')带来的体积误导
尝试第一种方案时我用了roseType: 'area',期望通过面积表现数值。结果发现第二维度堆叠段在面积模式下半径关系是非线性的(ECharts 内部会开方来让面积可比),两个渠道在原数值上相差 3 倍,但视觉上面积差远不止 3 倍。这会导致“线下渠道 140 是线上 60 的两倍多”被脑补成三倍以上,业务方看了直皱眉。最后还是切回roseType: 'radius',保证半径与数值线性对应。
5.4 扇区排序:默认顺序影响读者阅读趋势
ECharts 会按数据数组顺序绘制扇区,如果排序不讲究,最常见的“从大到小”趋势就出不来。我在真实项目里先按品类合计值降序排序,再把两个渠道的 series data 按相同顺序排列。这样从 12 点钟方向开始,扇区半径递减,读者一眼就能看出头部品类。排序代码很简单:
const sortedIndices = rawData .map((d, idx) => ({ idx, total: d.online + d.offline })) .sort((a, b) => b.total - a.total) .map(item => item.idx); const categoriesSorted = sortedIndices.map(idx => categories[idx]); const onlineSorted = sortedIndices.map(idx => onlineData[idx]); const offlineSorted = sortedIndices.map(idx => offlineData[idx]);5.5 动态更新时 setOption 的 merge 坑
项目里有个筛选器,切换时间段后要更新数据。我一开始直接chart.setOption(newOption),结果发现图例状态、已选中的高亮状态经常被重置,很影响体验。查了文档才想起来,ECharts 的 setOption 默认是合并模式,但如果你直接传整个新的 option,它内部会用新的 series 数组替换旧的,图例选中态随之丢失。正确做法是只更新数据部分:
chart.setOption({ series: [ { data: onlineSorted }, { data: offlineSorted } ] });这样合并模式下其他配置(label、tooltip、legend 状态)都会保留,更新成本也低。
6. 进一步扩展:三维、多级和第二维度不是渠道时的处理思路
堆叠玫瑰图解决“维度二”的方式有一个隐性前提:第二个维度是“可堆叠的数值拆分”,比如渠道、品类下的子品牌。如果第二维度不是数值拆分,而是几个分隔属性,比如“各品类下不同生命周期的客户数”,本质还是拆分,所以照样适用。但如果你要显示三个维度,这套方案就需要继续扩展。
我在另一个展示项目里试过三层堆叠:第一维度品类占比,第二维度渠道占比,第三维度新老客占比。实现上无非再加一个 series,并让它们共享同一个 stack 名称,然后按顺序堆叠。但实际发现:
- 扇区内的堆叠段数超过 3 段,半径方向的空间会非常拥挤,两个渠道已经让 label 无处安放,再加一段只能全部交给 tooltip;
- 颜色数量也随之增加,许多人分辨不出相近色,图例的可读性下降。
所以我的建议是:如果第二维度只有 2~3 个取值,用堆叠玫瑰图非常合适;超过 3 个,就换旭日图或者柱状图加分组的形式,别硬撑。
另外,如果你的业务里有“总计占比”之外的第二百分比维度,比如“目标完成率”,那就不太适合放进堆叠玫瑰图里。百分比不参与堆叠结构时,放在 label 或 tooltip 里用富文本展示会更好,强行加到玫瑰图上只会让图形编码通道混乱。
还有一个很多人问过的点:能不能让点击某个品类扇区后,自动展开成该类别的子维度玫瑰图?ECharts 原生没有这个能力,但完全可以通过chart.on('click', params => {...})监听扇区点击,再动态更换 option 数据,把当前品类变成主轴,类别内渠道数据变成新的玫瑰图。我在真实项目里就做了这个下钻,用动画过渡animationDurationUpdate调到 500ms,体验很顺滑。这个扩展的代码量不大,核心就是复用第一节的数据处理思路,只是把“主维度”从“品类”换成你点击的品类,“子维度”从“渠道”换成更细的子品牌或门店。
myChart.on('click', function(params) { if (params.seriesName === '线下渠道' || params.seriesName === '线上渠道') { // 参数是渠道系列里的扇区,参数.name 是品类 const category = params.name; // 模拟获取该品类下更细分的数据并更新图表 myChart.setOption({ series: [ { data: getCategoryDetail(category).map(d => ({ name: d.sub, value: d.value })) } ] }); } });点击下钻后再加一个“返回上级”按钮,就能组成一个简单的两级下钻链路,这在做经营分析大屏时非常实用。
7. 如何判断你的场景真正需要这张二维玫瑰图
写到最后,我想说一个更偏经验层面的判断:不是所有二维占比场景都适合用玫瑰图,有时候越好看的图越容易误导用户。我在选型时给自己定了三条判断标准,你可以直接拿去套用:
- 第一维度的分类数量:如果超过 8 个分类,玫瑰图的扇区会变得极窄,角度区分度下降,这时用柱状图 + 内部堆叠条更合适。
- 第二维度的取值数量:如果超过 3 个,堆叠段在半径方向上太拥挤,换成旭日图或者分组柱状图更清晰。
- 受众关注的是“总体占比”还是“结构占比”:如果只关注总体占比,单层玫瑰图就够了;如果两者都要且要同屏展示,才需要堆叠玫瑰图。如果受众只关注结构占比,那用堆叠百分比条形图可能比玫瑰图更好说明问题。
我在真实项目里用它展示公司“品类 x 渠道”季度复盘数据,几种数据产品的阅读效率对比下来,玫瑰图在页面中间作为视觉焦点确实能吸引注意力,但表格和右侧的明细列表依然承载了大量具体数字。可视化图表的价值在于让人先看到结构、再聚焦到异常,不要指望一张图把所有数字都讲清楚。
如果你照着这篇的代码和思路实现了一遍,我估计你已经清楚什么叫“玫瑰图体现二维数据百分比”了。最后再分享一个我在实践中摸索出来的小经验:尽量把两个维度的百分比都提前算好放进数据对象里,而不是在 formatter 里临时计算。因为临时计算逻辑散落在各处,后面加筛选条件、改口径、做导出时极易漏掉某处,预计算虽然冗余一点,但至少保证任何一次渲染和导出看到的口径是一致的。这一点,在数据口径频繁变化的业务场景里,真的能帮你少挨几次骂。