看到这个标题你应该也在做类似的需求一张玫瑰图里要展示两个维度的数据而且两个维度都要体现各自所占的百分比。这个场景在实际业务里太常见了——比如某公司报表里既要看“各产品线销售额占总销售额的比例”又要看“各区域销售额占总销售额的比例”再比如每个产品既要展示“销量占比”又要展示“利润占比”。我用 ECharts 把这类需求拆成了三种可落地的做法从简单到进阶都有代码可以直接抄坑也已经帮你们踩过了。先说结论ECharts 的玫瑰图本质上是pie系列的变体默认情况下只有一个value通道在驱动图形想让它同时表达两个维度的百分比必须自己设计编码方式。下面按“原理 → 方案 → 踩坑”的顺序展开。1. 玫瑰图的百分比编码逻辑先把两个“维度”说清楚1.1 玫瑰图的两个视觉通道角度与半径玫瑰图之所以叫玫瑰图是因为它在饼图的基础上多了一个“半径”视觉通道。饼图里一个扇区只靠圆心角或者说弧长表达数值玫瑰图则允许半径也随数据变化于是同一个值可以同时通过“角度大小”和“半径长短”两条通道被感知。ECharts 里控制这个行为的就是roseType字段它有两个取值很多人一开始会搞混radius扇区的圆心角展现数据的百分比半径展现数据的大小。翻译成大白话就是角度照常按 value 占比分配同时每个扇区的半径也按 value 缩放。大值会在角度和半径两个方向上同时被放大视觉效果非常“膨胀”强弱对比极其明显。area所有扇区的圆心角相等把 360 度均分给每个数据项仅通过半径展现数据大小。为了不让面积失真底层会让半径的平方与 value 成正比这样扇区面积才真正与 value 成正比。在 label 里ECharts 提供了{d}模板变量直接显示“当前项占当前系列的百分比”。这里有个非常容易踩的坑{d}只针对本系列里那一个 value 计算百分比。如果你想展示的百分比来自另一个字段{d}就完全不适用必须自己在数据里提前算好。1.2 “二维占比”到底指什么为什么饼图搞不定“二维数据都体现所占百分比”这句话在实际需求里通常指两种情况。第一种是两个并列的分类维度一份销售数据既按产品线聚合又按区域聚合。产品线的五个品类各有销售额占比区域的五个大区也各有销售额占比。你希望一张图上同时看到这两组占比。第二种是同一个分类下的两个指标字段每个产品有销售额也有利润额。你想让每个产品扇区既表达“销售额占总销售额的百分比”又表达“利润占总利润的百分比”。这两种情况饼图都搞不定。饼图只有角度一个通道硬塞两个百分比进去必然打架。玫瑰图虽然多了半径通道但默认情况下角度和半径由同一个 value 驱动也不会自动把第二个字段变成百分比。所以实现的关键是选择一种编码策略让两个维度各就各位拆成两层、分别映射、或者通过标签与颜色补充。后面的三个方案就对应这三条路。1.3 拿到数据后先算好两个维度的百分比无论用哪个方案都建议在进入 ECharts 之前先把两个维度的百分比算好存进data的每个数据项里。这样 tooltip、label、颜色映射都能直接取用也避免在 formatter 里做重复运算。function withPercent(list) { const total list.reduce((sum, item) sum item.value, 0); return list.map(item ({ ...item, percent: ((item.value / total) * 100).toFixed(2) })); }这里有个细节必须和业务确认两个百分比各自基于谁求和。如果产品线总和与区域总和来自同一批原始数据那两边加起来都等于 100%如果两个维度来自不同统计口径比如“销售额占比”按整年算“利润占比”按季度算那两组百分比就不要在同一张图里强行比较否则读者会误以为它们共享同一个分母。我接手过好几个需求都是因为没确认口径图表做完才发现产品线百分比加起来 100%区域百分比加起来却只有 80%当场返工。2. 方案一内外双层玫瑰图一层承载一个维度2.1 构图思路与半径区间设计如果两个维度是并列关系比如“产品线占比”和“区域占比”最直观的做法就是画两层嵌套玫瑰图内层玫瑰展示维度 A 的百分比外层玫瑰展示维度 B 的百分比两个系列共用同一个圆心。这种做法本质上是把两个独立的 pie/rose 系列叠在同一个center上通过不同的radius区间把内外层隔开。半径区间建议这样设计内层[0%, 42%]外层[52%, 88%]中间留出 10% 左右的间隙是为了给外层扇区的起始处留出视觉呼吸空间同时避免内外层的labelLine和标签互相遮挡。半径用百分比而不是像素是为了在不同尺寸的容器里都能自适应缩放大屏小屏都不至于变形。2.2 完整配置代码与注释直接上代码我用一组模拟业务数据举例。const rawProduct [ { name: 智能硬件, value: 3200 }, { name: 企业服务, value: 2100 }, { name: 云产品, value: 1500 }, { name: 数字营销, value: 980 }, { name: 线下方案, value: 420 } ]; const rawRegion [ { name: 华东, value: 3400 }, { name: 华北, value: 2600 }, { name: 华南, value: 1900 }, { name: 西南, value: 700 }, { name: 西北, value: 400 } ]; function withPercent(list) { const total list.reduce((sum, item) sum item.value, 0); return list.map(item ({ ...item, percent: ((item.value / total) * 100).toFixed(2) })); } const productData withPercent(rawProduct); const regionData withPercent(rawRegion); option { tooltip: { trigger: item, formatter: (params) { const item params.data; return ${params.seriesName}br/${item.name}${item.value}占总${item.percent}%; } }, color: [#5470c6, #91cc75, #fac858, #ee6666, #73c0de], legend: { bottom: 0, itemWidth: 14, itemHeight: 14 }, series: [ { name: 产品线销售占比, type: pie, radius: [0%, 42%], center: [50%, 46%], roseType: area, data: productData, label: { show: true, position: inside, color: #fff, formatter: {b}\n{d}% }, labelLine: { show: false }, itemStyle: { borderRadius: 4 } }, { name: 区域销售占比, type: pie, radius: [52%, 88%], center: [50%, 46%], roseType: area, data: regionData, label: { show: true, position: outside, formatter: {b}\n{d}% }, labelLine: { length: 12, length2: 8 }, itemStyle: { borderRadius: 4 } } ] };几个关键点说明一下roseType: area下每个系列内部所有扇区角度均分半径表达数值大小。内层五块各占 72 度不会出现某个扇区角度特别大、挤压其它扇区的情况观感比较整齐。内层标签用inside放到扇区内部外层标签用outside配labelLine引线指出去。这样内外层标签各占各的空间不容易撞车。tooltip 里我用了自己算好的item.percent而不是params.percent因为params.percent返回的是字符串形式的百分比带小数位数不可控自己存一份更稳。如果你希望内外层都有“角度的占比感”把两个系列的roseType都改成radius也行。但要注意radius模式下大值扇区会在角度和半径上双重放大外层区域的大品类会显得特别夸张视觉上会误导读者对真实差距的判断。我的经验是并列比较多个分类时优先用area突出单一强项时可以考虑radius。2.3 标签和图例怎么摆才不打架双层玫瑰最常见的翻车现场就是标签乱飞。内层五个标签、外层五个标签再加上图例一旦容器宽度不够互相压得惨不忍睹。我的处理顺序是先给内外层定好 label 位置策略内层一律inside外层一律outside不要混。外层标签开启avoidLabelOverlap: trueECharts 会自动把重叠的标签错开。如果错开后还是挤用labelLayout: { hideOverlap: true }让重叠的标签自动隐藏鼠标悬停时再看完整信息。图例放到底部两个系列的图例靠legend.data显式指定顺序和名称避免默认合并导致分不清哪层是哪层。legend: { data: [产品线销售占比, 区域销售占比], bottom: 0 }3. 方案二让角度和半径分别编码两个维度的占比3.1 核心思路一个扇区回答两个问题第二种需求更硬核同一批分类每个分类有两个百分比比如“销量占比”和“利润占比”而且两个都想用图形通道直接表达。这时候默认的单个的 pie 系列做不到因为角度和半径都由同一个 value 驱动。我的做法是反过来利用一个特性每个 pie 系列都可以单独设置startAngle和radius。那我干脆为每个数据项单独生成一个 pie 系列用角度编码维度 A 的百分比用半径编码维度 B 的百分比。于是一个扇区就同时回答了“占比 A 是多少”和“占比 B 是多少”两个问题。具体逻辑维度 A 的百分比乘以 3.6换算成扇区角度 span。维护一个累计角度变量每个扇区的startAngle从上一块结束的地方接着算。每个系列里必须放两个数据项第一个是可见扇区value 等于 span第二个是透明占位扇区value 等于360 - span把剩余角度“吃掉”。为什么必须有占位扇区因为 pie 系列如果只有一个数据项它会画满整个 360 度。只有塞进两个数据ECharts 才会按比例只画前面那一段角度。3.2 逐个扇区生成 series 的实现const palette [#5470c6, #91cc75, #fac858, #ee6666, #73c0de]; const raw [ { name: 智能硬件, shareA: 32, shareB: 58 }, { name: 企业服务, shareA: 26, shareB: 80 }, { name: 云产品, shareA: 18, shareB: 45 }, { name: 数字营销, shareA: 14, shareB: 95 }, { name: 线下方案, shareA: 10, shareB: 30 } ]; let angleCursor 0; const series raw.map((item, index) { const span item.shareA * 3.6; // 维度A的百分比转角度 const startAngle angleCursor 90; // 加90让第一块从12点钟方向起手 angleCursor span; return { name: item.name, type: pie, startAngle: startAngle, radius: [0%, item.shareB %], // 维度B的百分比转半径 data: [ { name: item.name, value: span, itemStyle: { color: palette[index], borderRadius: 4 }, label: { show: true, position: outside, formatter: item.name \nA: item.shareA % B: item.shareB % } }, { value: 360 - span, itemStyle: { color: transparent }, tooltip: { show: false }, emphasis: { disabled: true }, label: { show: false } } ] }; }); option { tooltip: { trigger: item, formatter: function (params) { return params.name br/销量占比 params.data.shareA % br/利润占比 params.data.shareB %; } }, series: series };跑起来之后你会看到扇区的角度跨度严格反映销量占比一个占了 32% 就张开 115 度扇区的半径长度严格反映利润占比利润占比 95% 的扇区明显比 45% 的长出一大截。这种视觉编码非常直接适合向管理层汇报“哪些产品卖得多却不赚钱”“哪些产品赚得多但卖得少”这类交叉分析。3.3 透明占位扇区的坑与规避这个方案最大的隐患就在透明占位扇区上。第一占位扇区虽然看不见但它仍然是一个完整的图形会参与鼠标事件。如果不做处理鼠标滑到空白区域时会莫名其妙弹出一个空 tooltip。所以必须在占位数据项上同时设置tooltip: { show: false }和emphasis: { disabled: true }两个都要写。第二多个 series 重叠后存在渲染顺序问题。ECharts 按 series 数组顺序绘制后面的先绘制会被后绘制的覆盖。在这个方案里每个扇区的可见部分正好从它的startAngle开始后面的系列又恰好从同一角度边界起手所以正常渲染时可见扇区总是压在早期系列的透明占位上鼠标交互不会错乱。这个我实测过但如果你调整了数据顺序或角度累计逻辑一定要重新验证一遍悬停手感。第三span为 0 的数据项要过滤掉。当一个分类的维度 A 占比是 0 时扇区角度是 0占位扇区变成整个 360 度的透明圆它会把底下所有真实扇区的鼠标事件全挡住即使设置了tooltip.show: false视觉上也会损失交互。我的建议是在进入图表前就filter掉这类数据或者在业务侧把它们归并到“其它”。另外提醒一句这个方案对数据语义有前提维度 A 的百分比必须能构成完整圆周也就是各分类 shareA 之和要等于 100。如果 shareA 是独立标量、加起来不等于 100扇区之间就会出现缺口或重叠图会变得很难解释。4. 方案三单图主占比 标签/颜色副占比4.1 用富文本标签同时展示两个百分比如果两个维度之间有主次关系比如主图想表达“销量占比”利润占比只是辅助信息那没必要搞复杂的双编码。一个普通玫瑰图把第二个百分比写进 label 就行。const rawData [ { name: 智能硬件, sales: 3200, profitRate: 28.5 }, { name: 企业服务, sales: 2100, profitRate: 22.0 }, { name: 云产品, sales: 1500, profitRate: 35.2 }, { name: 数字营销, sales: 980, profitRate: 18.7 }, { name: 线下方案, sales: 420, profitRate: 12.4 } ]; const totalSales rawData.reduce((sum, item) sum item.sales, 0); const data rawData.map(item { const salesPercent ((item.sales / totalSales) * 100).toFixed(2); return { ...item, salesPercent: salesPercent }; }); option { tooltip: { trigger: item, formatter: (params) { const d params.data; return d.name br/销量占比 d.salesPercent % br/利润占比 d.profitRate %; } }, series: [{ type: pie, roseType: radius, radius: [12%, 68%], data: data, label: { formatter: function (params) { return {name| params.name }\n{share|销量占比 params.data.salesPercent % · 利润占比 params.data.profitRate %}; }, rich: { name: { fontSize: 14, fontWeight: bold, color: #333 }, share: { fontSize: 12, color: #666, lineHeight: 18 } } } }] };这里用了rich富文本把第一行分类名和第二行百分比数据做成不同样式视觉层级更清晰。roseType: radius让销量占比同时通过角度和半径表达图形本身已经回答了“主占比”问题标签只是锦上添花。4.2 用 visualMap 把第二个占比映射到颜色如果觉得标签不够直观还可以把第二个维度映射成颜色让读者一眼看出“利润占比高的是暖色、低的是冷色”。ECharts 的visualMap组件支持对数据项施加颜色映射关键是它默认读取data.value的第一个数值。要映射第二个百分比需要把value改成数组。const data rawData.map(item ({ name: item.name, value: [item.sales, item.profitRate], // 第一个数给饼图第二个数给 visualMap salesPercent: ((item.sales / totalSales) * 100).toFixed(2), profitRate: item.profitRate })); option { visualMap: { show: true, type: continuous, min: 10, max: 40, dimension: 1, // 取 value 数组里的第二个维度 inRange: { color: [#91cc75, #fac858, #ee6666] }, itemHeight: 100, left: 10, bottom: 10, text: [利润占比高, 利润占比低] }, tooltip: { formatter: (params) { const d params.data; return d.name br/销量占比 d.salesPercent %br/利润占比 d.profitRate %; } }, series: [{ type: pie, roseType: radius, radius: [12%, 68%], data: data }] };这里pie系列会取value[0]销量计算角度和半径visualMap通过dimension: 1读取value[1]利润率决定扇区颜色。一图三通道角度看销量占比半径看销量规模颜色看利润率。如果你的页面上还有其它图形记得给visualMap加seriesIndex: [0]避免它去染别的图。4.3 三种方案的适用场景怎么选写到这里把三个方案放在一起对比一下场景推荐方案原因两个并列分类维度都要展示占比如“产品线 vs 区域”方案一双层嵌套玫瑰图一层一个维度互不干扰实现最简单同一批分类有两个百分比且都希望图形强表达方案二角度/半径双编码一个扇区同时回答两个问题适合交叉分析主次分明第二个维度辅助阅读即可方案三标签/颜色补充改动最小信息密度可控最不容易出错我的建议是默认先考虑方案三因为信息层级清晰只有当业务明确要求“两个占比都要用图形一眼看出来”时再上方案一或方案二。方案二虽然效果炫但透明占位扇区对数据质量要求高维护成本也高。5. 实操排查与配置速查5.1 常见问题与排查实录把我在实际开发中被问到最多、自己也踩过的坑整理成一张表按频率排现象根本原因处理方式{d}%显示的百分比和业务口径对不上{d}只按当前系列 value 算占比不认你自定义的字段formatter 里改用params.data.percent等自定义字段内外层玫瑰间距太小标签重叠两个 series 的 radius 区间没有留足安全距离内层最大半径与外层最小半径之间至少留 8%~10%图例分不清哪层是哪层两个系列默认共用 legend名称混在一起用legend.data显式列出系列名鼠标停在空白处弹空 tooltip方案二中透明占位扇区参与了事件占位项设置tooltip.show:false和emphasis.disabled:true小屏上标签挤成一团默认标签全部显示没有避让开avoidLabelOverlap必要时labelLayout.hideOverlap某个扇区突然消失数据里有 0 值或负值过滤 0 值负值先归一化或用“其它”归并双 roseType 视觉误导radius模式下角度和半径双重放大差异对比类场景改area强调单点强项再用radius还有一个特别容易忽视的点tooltip 的显示精度。ECharts 自带的params.percent返回的是浮点字符串可能显示成32.00000000000001%这种鬼样子。我习惯在数据预处理阶段就toFixed(2)存成 number所有展示统一从这个字段取既保证精度一致也让后续如果换组件、导出图片时数据口径一致。5.2 常用参数速查表参数取值作用series.typepie玫瑰图本体就是一个 pieseries.roseTyperadius/area角度半径双放大 / 面积正比表达series.radius70%或[0%,70%]外半径 / 内半径外半径百分比自适应series.center[50%,46%]圆心位置可为百分比或像素series.startAngle0~360起始角度加 90 让起始位置到 12 点钟label.formatter{b}\n{d}%或函数分类名、百分比或自定义富文本visualMap.dimension数字索引指定映射 value 数组的第几个维度labelLayout.hideOverlaptrue自动隐藏重叠标签emphasis.disabledtrue关闭某个数据项的悬停高亮最后总结一下我个人的实操体会玫瑰图这类“花哨”图形视觉冲击力强但也最容易因为编码混乱让读者误读数据。做二维百分比需求时先问清楚两个维度是并列关系还是主次关系再决定用哪种方案比直接埋头写配置重要得多。等你把三个方案都跑通一遍后面再遇到“三个维度都要展示”的需求思路也会清晰很多——无非是多套一个环、多映射一个视觉通道而已。