☰
ECharts饼图标签重叠解决:labelLayout与数据预处理
2026/11/3 15:43:22 网站建设 项目流程

做数据看板这几年,饼图被产品经理点名要加的次数,大概仅次于"数字给我做成滚动效果"。而饼图一旦超过 6 个扇区,标签就开始打架——文字叠在一起、引导线交叉成蜘蛛网、右边那块干脆被容器裁掉半截。很多人搜的时候会把库名打成 echars,搜出来的答案又大多只丢一句"设avoidLabelOverlap: true就好了",结果贴上去发现并没什么用。

这篇内容就是把 ECharts 饼图标签重叠这件事拆开讲:标签到底是怎么被摆到画布上的、内置开关的生效边界在哪、ECharts 5 里labelLayout这一层能做什么、数据侧怎么预处理、几何参数怎么算着改。不管你是刚接手第一个图表需求,还是已经在排查某个特定项目里的重叠问题,都能直接拿去套。文中所有代码都是可以直接贴进option里跑的完整片段,我按由浅入深的顺序排,你可以按自己卡住的那一层跳读。

1. 先看清重叠的形态,别急着改配置

1.1 四种重叠长得不一样,根因也不一样

我经手过的饼图标签问题,基本能归到四类,先分清自己遇到的是哪一类,能省掉一半试错。

第一类是同侧文字纵向堆叠。几个占比接近、在饼图上相邻的小扇区,它们的引导线拐出去以后,落点几乎在同一个 y 坐标上,文字直接压成一坨。这类问题的本质是标签落点计算时,多个扇区的中线角度差距太小,而标签高度是固定的,物理空间不够塞。

第二类是引导线交叉。左侧的线穿到右边去,或者两条线在中途打了个叉。这种情况通常出现在数据里同时存在"极大值"和"极小值"时,大扇区把可用角度吃掉,小扇区被挤到边角,线的折点算出来就互相穿过了。

第三类是标签超出容器。文字本身不重叠,但最右或最左那个标签的尾巴被裁掉,overflow: hidden的父容器一遮,只剩半个百分比。这是半径给太大、或者center位置没给标签留够边距导致的。

第四类是标签压住扇区本体。多见于环形图,position: 'inside'配了但环宽太窄,字比环还宽,于是从环里溢出来盖到饼上,或者盖到中心区域的标题上。

这四类的处理手段完全不重合。第一类要靠labelLayout的位移或者标签列对齐,第二类要动labelLine的折角参数,第三类要算半径和 center,第四类是环宽和overflow的事。上来就无脑调avoidLabelOverlap,大概率是白忙。

1.2 从 4.x 到 5.x,可用的手段变了

这里有个版本认知必须先建立,不然你会发现搜到的方案在自己项目里就是不生效。

avoidLabelOverlap这个开关在 ECharts 4 时代就有了,默认值就是true,它做的事情是把 outside 位置的标签做纵向避让并重算引导线。也就是说——你现在看到的重叠,是在这个开关已经打开的前提下依然发生的事情。它证明了这个内置算法的能力上限在哪。

ECharts 5 引入了labelLayout这一层配置,挂在 series 上(也可以作全局配置),提供hideOverlap、moveOverlap、draggable等更"暴力"也更可控的手段。这是解决重度重叠最有效的一层,但很多项目还停在 4.x,或者升级了却没意识到这个能力存在。

下面这张表是我自己整理的心智模型,先用它给自己的项目归个位:

版本 / 手段能力范围适用场景需要注意
avoidLabelOverlap(4.x+,默认开)纵向避让 + 引导线重算轻度重叠,5 个以内扇区只对position: 'outside'生效
minShowLabelAngle低于阈值角度的扇区不显示标签存在极小占比项阈值调大会丢信息,需要图例兜底
alignTo: 'edge'(5.x)标签统一对齐到画布边缘成列扇区多、希望排版整齐需要横向留白,会压缩饼图视觉面积
labelLayout.hideOverlap(5.x)隐藏真正重叠的标签密集扇区、移动端被隐藏的项必须靠 tooltip / 图例补
labelLayout.moveOverlap(5.x)纵向或横向平移错开中度重叠shiftY位移后引导线不会自动变好看
labelLayout.draggable(5.x)手动拖拽标签位置大屏固定展示、一次性排版用户交互后会丢失,需要持久化坐标

提示:先确认项目锁定的 ECharts 版本号,再决定走哪条路。我在一个老项目里花了半小时调labelLayout,最后发现依赖里写死的是 4.9,配置压根不认,控制台也不报错——这类静默失效最耗时间。

2. 标签是怎么被摆到画布上的:搞懂机制再调参

2.1 一个标签从扇区中线到文字落点的完整旅程

想调得准,得知道 ECharts 内部把一个标签放出去要经过几步。我按自己的理解拆成四步,虽然不是源码逐行对应,但对定位问题足够了。

第一步,算扇区中线角度。饼图从起始角度(startAngle,默认 90 度)开始,按clockwise方向依次铺开每个扇区,取每个扇区的角度中点作为标签的"出发方向"。这个角度是所有后续计算的基准。

第二步,沿中线向外延伸出第一个折点。延伸距离由labelLine.length控制。这个折点决定了引导线第一段的长度,也就是标签离饼图有多远。

第三步,在折点处做一次水平或垂直的直角弯,走出第二段线,长度由labelLine.length2控制。这一步决定了标签最终是往左靠还是往右靠。

第四步,在第二段的末端放置文字,并根据label.align决定文字是左对齐、右对齐还是居中。右侧的标签一般右对齐到线端点往左延伸,左侧的标签左对齐往右延伸。

真正产生重叠的是第四步——前三个步骤都是独立计算的,每个扇区各算各的,彼此之间没有任何"协商"。avoidLabelOverlap做的事情,就是在第四步之后加了一道"事后协调":把同侧落点太近的标签沿纵向推开,然后重新连引导线。

理解了这一点就知道,重叠不是算法有 bug,而是"先各算各的,再统一调解"这个架构本身在极端数据下必然失效。

2.2 avoidLabelOverlap 的能力天花板在哪

avoidLabelOverlap: true的调解逻辑,核心是纵向平移。它会把落在同一侧的标签按 y 坐标排序,然后从上往下逐个检查最小间距,不够就往下推。推完之后重新计算引导线的折点。

这个策略在标签数量少、扇区角度分布相对均匀时效果很好,但有三条硬边界:

一是它只做纵向调解,不做横向。如果你的标签文字特别长(比如带了长名称 + 数值 + 单位),左右两侧的标签列本身宽度就超过了可用空间,纵向推得再开也没用,最后还是会被容器裁掉。

二是它需要引导线来"接住"位移。位移幅度一旦超过labelLine.length2的合理范围,引导线会变得非常陡,视觉上就是一堆斜线糊在一起,比重叠还难看。这时候要同步把length2加长。

三是它对"扇区数远超可用高度"的情况无解。假设容器高度 300px,标签行高 18px,理论上一侧最多塞 16 行。如果单侧有 20 个扇区要显示标签,无论怎么推都会重叠。这种情况下唯一正确的做法是减少标签数量,而不是继续调避让参数。

这条数学关系值得记一下:单侧可容纳标签数 ≈ 容器高度 ÷ (标签行高 + 最小行间距)。我一般按行间距 6px 来估,也就是一行大约占 20~24px。这个数字决定了你的饼图在给定容器下最多能承载多少个 outside 标签,超出就必须换策略。

2.3 minShowLabelAngle 与 minAngle:两个名字很像但作用完全不同

这两个参数被混淆的频率高得离谱,我一开始也栽过。

minAngle是给扇区本体用的,它保证每个扇区至少占多少度,防止极小值扇区细得看不见(0 度就是完全不可见)。默认 0。设成 1 或 2 度,能让 0.1% 的项也有个能看见的细条。

minShowLabelAngle是给标签用的,低于这个角度的扇区不显示标签。默认 0,也就是全部显示。这个才是解决重叠的正经参数。

series: [{ type: 'pie', radius: ['35%', '62%'], // 扇区最小角度,避免极小值彻底看不见 minAngle: 2, // 角度小于 3 度的扇区不显示标签 minShowLabelAngle: 3, avoidLabelOverlap: true, label: { show: true, position: 'outside', formatter: '{b} {d}%' }, labelLine: { show: true, length: 12, length2: 18 } }]

不过minShowLabelAngle有一个明显的坑:它是按角度切的,不是按占比。在扇区数量少的时候,一个 1% 的项可能对应 3.6 度,而扇区数量多的时候,同样 1% 只有 0.36 度。所以这个阈值不能拍脑袋写死,得根据当前数据的项目数量反推。

我的经验算法是:把期望的阈值角度反推成占比。假设你想隐藏占比低于 2% 的项,当前有 N 个数据项,那么对应角度 = 360 × 2% = 7.2 度(与 N 无关,因为占比就是角度占比)。但如果直接用这个值,当 N 很大时(比如 30 项),2% 的项本身已经很难看清了,切掉是合理的;当 N 很小(比如 6 项),2% 的项其实还挺明显,切掉会丢信息。

所以更稳的做法是用数据侧的合并替代角度过滤,也就是先把小项合成"其他",再画图。这个在第 4 节展开。

3. labelLayout 这一层:真正能止住重灾的配置

3.1 hideOverlap 与 moveOverlap 怎么选

labelLayout是 ECharts 5 给的一层"标签排版后处理"能力,挂在 series 上。它是在所有标签位置算完之后再执行的一道工序,所以能直接改动最终落点。

series: [{ type: 'pie', radius: ['32%', '60%'], center: ['50%', '52%'], avoidLabelOverlap: true, label: { show: true, position: 'outside', formatter: '{b}\n{d}%', lineHeight: 16 }, labelLine: { show: true, length: 10, length2: 16, smooth: 0.2 }, labelLayout: { // 真正重叠的标签直接隐藏 hideOverlap: true, // 沿 y 轴平移错开 moveOverlap: 'shiftY' }, data: [/* ... */] }]

hideOverlap: true的效果最"干净":重叠的标签直接不画。它的优先级判定是按数据顺序来的,排在前面(也就是data数组里靠前)的会被保留,后面的被隐藏。这一点很关键——它意味着你可以通过排序来控制哪些标签活下来,把重要的项排在前面。

moveOverlap: 'shiftY'是让重叠的标签沿纵向推开。它比hideOverlap温和,信息不丢,但推开的后果是标签会偏离自己引导线的原始终点,视觉上文字和线的对应关系变弱。在avoidLabelOverlap已经开着的条件下,moveOverlap能处理掉一部分"避让算法没敢推"的残余情况。

我的实际选择标准:面向运营的看板用hideOverlap+ 图例、tooltip 兜底,因为排版干净、视觉上不脏;面向数据分析的场景用moveOverlap: 'shiftY',因为一个数都不能丢。两者也可以同时开,hideOverlap会先干掉实在没救的,剩下的交给我shiftY。不过说实话,同时开的收益不明显,我更多是二选一。

3.2 alignTo: 'edge' 这个低调但好用的排版开关

label.alignTo是 5.x 加的一个参数,取值'none'、'edge'、'labelLine',默认'none'。

它的核心价值是把所有标签对齐到画布的左右边缘,形成整齐的两列。这种排法在扇区数量多的时候特别有效——因为标签不再各自贴着自己的引导线终点,而是统一往边缘靠,纵向的间距由对齐器统一分配,重叠概率大幅下降。

series: [{ type: 'pie', radius: ['30%', '55%'], center: ['50%', '50%'], label: { show: true, position: 'outside', alignTo: 'edge', // 标签距离画布边缘的距离 edgeDistance: '6%', // 文字与引导线末端的间距 distanceToLabelLine: 6, formatter: params => `${params.name} ${params.percent}%` }, labelLine: { show: true, length: 12, length2: 30, // 折角最小值限制,减少尖锐的折线 minTurnAngle: 60, smooth: 0.3 }, avoidLabelOverlap: true, labelLayout: { hideOverlap: true } }]

用alignTo: 'edge'有个必须接受的代价:饼图的可视半径会被压缩得更小,因为边缘要给标签列留位置。edgeDistance设太大会把饼挤成一小坨,设太小标签还是会贴边被裁。我一般从'8%'开始试,往下调到刚好不裁切为止。

另外minTurnAngle这个参数值得单独说。它限制引导线折角的锐利程度,大于这个角度(度数,从水平线算起)的折线会趋向平缓。设成 60~70 能明显减少尖锐的"V 形"引导线,视觉上顺眼很多。默认值 90,基本上不会生效,所以经常被忽略。

3.3 draggable:手动排版这个口子只有少数场景值得开

labelLayout.draggable: true允许用户拖动标签,拖完会有labellayout事件抛出坐标。这个能力很适合大屏,因为大屏的排版是一次性的、展示性大于交互性,找个人手工把标签拖整齐,比写一堆参数划算。

myChart.on('labellayout', params => { if (params.type === 'dragged' || params.type === 'dragend') { // params.draggedPoint / params.labelRect 可以拿来持久化 console.table({ index: params.dataIndex, x: params.labelRect.x, y: params.labelRect.y }); } });

需要注意三件事。第一,拖拽只在非position: 'outside'的常规布局下比较自然,outside 标签拖完引导线不会跟着重画,可能出现"线和字分家"。第二,拖拽结果不会自动保存,刷新就回到初始位置,要自己把{dataIndex, x, y}存下来,下次初始化时通过labelLayout的返回函数回填。第三,移动端的手势和页面滚动会打架,draggable在触屏上体验并不好。

我的实际判断是:如果一份图表需要开着给用户拖,说明这个饼图的数据维度已经过多了。更该做的是换个图表类型,或者做筛选。只在董事会汇报那种"一次画好、后续复用同一份数据"的场景下,我会真的去用draggable。

4. 数据侧预处理:一半的重叠其实是数据没整理

4.1 小占比合并成"其他"的阈值怎么定

做到第 3 节,你会发现配置已经调到极限了,重叠还是有增无减。这时候该回头看看数据本身——大概率是数据项数量超过图表能承载的上限了。

饼图这个图表类型有个硬性的可读性上限。业界讨论里比较一致的说法是超过 6~7 个扇区,人眼就很难比较了,标签更是排不开。所以配置之前先做数据裁剪,投入产出比比调参数高得多。

我的阈值规则是分层的:

/** * 合并小占比项为「其他」 * @param {Array} raw 原始数据 [{name, value}] * @param {number} maxSlices 最多保留的扇区数 * @param {number} minRatio 低于该占比的项强制合并 */ function mergeSmallSlices(raw, maxSlices = 6, minRatio = 0.03) { const total = raw.reduce((s, i) => s + i.value, 0); if (!total) return []; // 先按值降序 const sorted = [...raw].sort((a, b) => b.value - a.value); const keep = []; const merge = []; sorted.forEach((item, idx) => { const ratio = item.value / total; // 前 maxSlices - 1 个且占比达标的保留 if (idx < maxSlices - 1 && ratio >= minRatio) { keep.push(item); } else { merge.push(item); } }); // 如果一个都没合并,直接返回 if (!merge.length) return keep; const mergedValue = merge.reduce((s, i) => s + i.value, 0); return [ ...keep, { name: '其他', value: mergedValue, // 把被合并的明细存起来给 tooltip 用 _mergedItems: merge } ]; }

几个参数选择的理由:maxSlices = 6是因为我实测下来 6 个扇区的标签在 400px 高的容器里能排得比较舒服;minRatio = 0.03是经验值,低于 3% 的项在饼图上角度不到 11 度,文字标签基本要打架,合并掉更划算。这两个数不是定死的,容器高度大可以放宽到 8 和 0.02,手机端要收紧到 5 和 0.05。

合并之后一定要做的一件事是在 tooltip 里把被合并的明细展开。不然用户看到"其他 12%",点进去什么信息都没有,这个信息就被彻底丢了。

tooltip: { trigger: 'item', formatter: params => { const { name, value, percent, data } = params; if (data._mergedItems && data._mergedItems.length) { const detail = data._mergedItems .map(i => `<div style="opacity:.8">${i.name}:${i.value}</div>`) .join(''); return `<b>${name}</b> ${value}(${percent}%)<hr/>${detail}`; } return `<b>${name}</b> ${value}(${percent}%)`; } }

4.2 排序策略:为什么降序比按业务顺序好

数据顺序对饼图的影响比想象中大得多。因为饼图是从startAngle(默认 90 度,也就是 12 点方向)顺时针铺开的,扇区在圆周上的位置完全由数据顺序决定。

按值降序排列有三个好处。第一,大扇区集中在上半圈,视觉重心稳定,不会因为某个小项位置变化导致整个图"重心晃动"。第二,小扇区被统一挤到末尾,在圆周上形成一段连续的"密集区",用minShowLabelAngle或者hideOverlap一次性处理掉就行,不用东一块西一块地处理。第三,hideOverlap按数据顺序决定优先级,降序排列意味着保留的永远是大项,这个语义是正确的。

如果业务方坚持按固定顺序(比如按渠道优先级),那就别用 outside 标签了,改用position: 'inside'加图例,或者干脆换条形图。这种坚持和数据可视化可读性之间的冲突,我一般会拿一张对比图给对方看,说服成功率挺高的。

还有一个细节:如果存在合并出来的"其他",它必须放在最后一位,不能参与排序被排到前面去,否则视觉上"其他"混在大项中间会很突兀。

4.3 数据源不可控时的两级兜底

有些场景数据是接口直出的,前端拿不到控制权——比如各分区的占比,可能某天突然冒出来 20 个细分项。这种时候需要做二级兜底。

第一级是渲染前的动态裁剪:把上面的mergeSmallSlices挂到数据处理的入口,maxSlices按容器尺寸动态算。容器高度可以用chart.getHeight()拿到,然后maxSlices = Math.max(4, Math.floor(height / 60))这样估。

第二级是渲染后的降级:在setOption之后监听finished事件,检查一下实际渲染出来的标签有没有越界,如果是超窄容器,直接把标签全关掉,靠图例 + tooltip 承载信息。

myChart.on('finished', () => { const h = myChart.getHeight(); const w = myChart.getWidth(); // 极窄场景直接放弃 outside 标签 if (w < 380 || h < 260) { myChart.setOption({ series: [{ label: { show: false }, labelLine: { show: false } }] }, { lazyUpdate: false }); } });

这个兜底看起来"糙",但它能在小屏、分栏布局、折叠面板这些尺寸不可控的场景下保住底线——至少不会出现一坨黑色文字糊在一起。我在几个后台系统里都留了这道保险。

5. 几何层面的腾挪:半径、圆心、环宽与引导线三参数

5.1 半径收缩的估算:留多少才够

标签被裁,绝大多数情况是半径给太满了。饼图的radius是相对容器短边的百分比,'70%'意味着半径占了短边的 70%,剩下的 30% 分到两侧只剩 15% 给标签,对中文标签来说通常不够。

一个粗略的估算公式:需要的横向留白 ≈ 标签最长字符数 × 字号 × 0.6 + 引导线长度 + 边距。中文按全角算,一个字符宽度约等于字号,所以更保守的算法是"最长字符数 × 字号"。

假设容器宽 600px,标签最长是"华北区-直营渠道"7 个汉字,字号 12px,那么单侧文字宽约 84px。加上labelLine.length(12px)+length2(18px)+ 边距 10px,单侧总共需要约 124px。两侧就是 248px,剩下 352px 给饼图直径,也就是 176px 半径。容器短边如果是 400px,那radius最大值就是176/400 = 44%。

也就是说,一个 600×400 的容器,如果标签是 7 个汉字,半径最多给到 44% 才不会裁切。这个数字比很多人习惯写的 65%~70% 小得多。这也是为什么"饼图看起来不够大"和"标签被裁"往往是一对矛盾——它们抢的是同一块空间。

如果觉得饼太小不好看,我的做法是换成环形图。环形图的内圈不承载信息,但能显著降低视觉重量,让小半径看起来不憋屈。同时可以把中心区域利用起来放总数或者标题。

5.2 labelLine 的三段结构怎么调

labelLine看起来只有一个length,实际上它由三段组成:从扇区边缘到折点的第一段(length)、折点到文字的第二段(length2),以及文字本身占位。真正决定标签能不能排开的是第二段。

调整的原则很明确:

  • 第一段(length)短一点,8~15px 就够,太长会让标签离饼太远,视觉上"飘"。它的作用只是把标签拉出扇区,别让它压在图上看不清。
  • 第二段(length2)可以长一些,20~40px,但它不是越长越好。这一段决定了标签的横向偏移量,太长会把标签推向容器边缘。真正需要的是"让所有标签的落点尽量落在同一个 x 坐标上",这样纵向排布才能整齐。这正是alignTo: 'edge'在做的事,所以如果用了alignTo,length2就不需要给太大。
  • smooth控制线的平滑度,0 到 1 之间。设 0.2~0.3 会让折角变成小圆角,视觉上柔和很多,但不会改变布局,纯审美参数。
labelLine: { show: true, length: 10, length2: 24, smooth: 0.25, // 折角不要比 60 度更尖锐 minTurnAngle: 60, lineStyle: { width: 1, // 引导线用低对比度色,避免抢视觉 color: 'rgba(140, 148, 160, 0.6)' } }

引导线的颜色这个细节值得一提。默认引导线颜色跟着扇区走,多色扇区加起来就是一道彩虹色的放射状线条,视觉噪音非常大。统一成一个低对比度的灰色,标签区域立刻清爽。这是我改过收益最高的一个"审美参数",几乎零成本。

5.3 环形图与玫瑰图的特殊处理

环形图(radius: ['40%', '70%']这种双值形式)的标签处理和普通饼图基本一致,但有一个额外选项:position: 'inside'。

在环宽足够的情况下(外半径减内半径大于 40px 左右),把短标签放在环内是非常稳的方案,因为它完全不受圆周方向拥挤的影响——只要环够宽,每个扇区都能放得下。但一旦环宽不足,文字就会溢出到饼图上,比 outside 标签还难看。

判断标准很直接:环宽(像素)= 容器短边 × (外半径 - 内半径) / 2。假设短边 400px,radius: ['40%', '70%'],环宽 = 400 × 0.3 / 2 = 60px。60px 能放两行 12px 的字(带间距),勉强够。如果内半径再大一点,['55%', '70%'],环宽只剩 30px,一行都放不下,就别考虑 inside 了。

南丁格尔玫瑰图(roseType: 'area'或'radius')的问题更严重,因为扇区半径不一样,标签的起始位置在圆周上是错落的,avoidLabelOverlap的分层避让对它效果打折。这类图的实践建议是:直接关掉 outside 标签,用图例 + tooltip。玫瑰图本身的信息表达已经足够密集,加标签是负担。

// 玫瑰图的稳妥配置 series: [{ type: 'pie', roseType: 'radius', radius: ['18%', '68%'], label: { show: false }, labelLine: { show: false }, emphasis: { label: { show: true, position: 'outside', formatter: '{b}\n{d}%', fontSize: 14 } } }]

这个配置的思路是"平时不显示,hover 才显示当前项"。它把标签数量和扇区数量解耦了——任何时刻最多只有一个标签,重叠在物理上就不可能发生。这个模式我在多个扇区的场景下反复用,是性价比最高的方案之一。

6. 排查链路实录:从"图糊了"到定位到具体参数

6.1 一次完整的二分定位过程

说个具体的排查过程,比结论更有参考价值。

某个内部数据看板的分区占比图,产品反馈"右边一坨看不清"。拿到手第一件事是看数据:14 个分区,占比从 28% 到 0.6% 不等,差距 40 倍。这已经踩了前面说的"数据项数超上限"这一条。

但我没有直接改数据,先做隔离验证,确认是数据问题还是配置问题。做法是造一份匀质数据(6 项,占比都在 10%~20% 之间),替换后重渲染。结果标签排得整整齐齐,一个重叠都没有。这就确认了配置本身没问题,问题出在数据分布上。

第二步,恢复真实数据,只改一个参数——把avoidLabelOverlap关掉。重叠明显加重,但重叠加重的方向和开着的时候一致。这一步确认了内置避让在工作,只是不够用。

第三步,加labelLayout: { hideOverlap: true }。重叠消失了,但有 5 个标签被隐藏,右侧只剩零星几个。这是预期结果,说明重叠的标签确实有 5 个在物理上排不开。

第四步,加数据侧合并(maxSlices: 7,minRatio: 0.03)。14 项变成 7 项,加上hideOverlap,标签全部显示且无重叠。

整个链路走下来不到二十分钟,关键是把"配置问题"和"数据问题"分开了。如果一开始就去调labelLine.length2,会在参数里泡一下午。

我把这个链路整理成一张对照表,遇到类似情况可以直接查:

现象先查什么定位方法常见结论
同侧文字纵向堆叠单侧扇区数与容器高度数一下单侧标签数,乘 22px 对比容器高度超出容量,需合并数据
引导线交叉成网labelLine.length是否过短临时把 length 调到 40 观察折点太靠近饼图,需加大
最外侧标签被裁radius与容器宽度临时把 radius 改成'40%'观察半径过大,需收缩
标签压在扇区上position是否为 inside改成 outside 观察环宽不足,inside 不适用
配置改了没反应ECharts 版本号打印echarts.version版本不支持该参数
只有少数几个标签消失hideOverlap是否开启关掉观察hideOverlap 在按序裁决

6.2 resize、字体加载与服务端渲染这三个补刀点

前面讲的是布局参数,还有三个容易被忽略的时刻会让标签又重新重叠起来。

第一个是 resize。图表从宽屏缩到分栏布局时,radius的百分比不变,但可用像素变了,原本够用的留白突然不够。解决办法是用option.media做响应式配置,或者监听resize事件动态算 radius。

const chart = echarts.init(dom); const baseOption = { /* ... */ }; function applyResponsive() { const w = chart.getWidth(); // 窄容器:环缩小、标签对齐边缘、隐藏重叠 const narrow = w < 480; chart.setOption({ series: [{ radius: narrow ? ['38%', '58%'] : ['32%', '66%'], label: { alignTo: narrow ? 'none' : 'edge', edgeDistance: narrow ? 4 : '6%', fontSize: narrow ? 11 : 12 }, labelLayout: { hideOverlap: true } }] }); } window.addEventListener('resize', () => { chart.resize(); applyResponsive(); });

需要注意的是,setOption在合并模式下对series数组是按索引合并的,如果 series 结构变化(比如动态增减),必须确保索引一致,否则会出现配置错位。稳妥起见我会在setOption的第二参数里显式传{ notMerge: false, replaceMerge: ['series'] },按需处理。

第二个是字体加载。如果页面用了自定义字体,图表初始化时字体可能还没加载完,ECharts 测量文本宽度用的是当时的字体,算出来的标签宽度是错的。等字体加载完,标签实际变宽了,但布局没有重算,于是又开始重叠。

处理方式很直接:在document.fonts.ready之后再初始化,或者字体加载完后调用一次chart.resize()触发重排。

if (document.fonts && document.fonts.ready) { document.fonts.ready.then(() => { chart.resize(); chart.setOption(option); }); }

第三个是服务端渲染。5.x 之后支持在无 DOM 环境渲染成 SVG 字符串。这时候文本测量依赖显式传入的字体信息,如果服务端和浏览器端的fontFamily、fontSize不一致,量出来的宽度会有偏差,hideOverlap的裁决结果就会不同——同一份数据,服务端渲染出来的图少了两个标签,浏览器端却全在。

解决办法是在 option 的顶层textStyle里显式写死fontFamily和fontSize,让两端用同一套度量参数。这个坑我在一次导出 PDF 的需求里踩过,当时排查了很久才意识到是字体度量的问题。

const option = { textStyle: { // 显式指定,保证 SSR 与 CSR 度量一致 fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 }, series: [/* ... */] };

6.3 长文本与富文本标签的两个细节

还有一类重叠不来自位置,来自文本本身——标签文字太长,一行撑破了可用宽度。

label.overflow有三个取值:'truncate'(截断加省略号)、'break'(按词换行)、'breakAll'(按字符换行)。中文场景下'breakAll'最实用,因为它不管词边界,按字符硬断,不会出现"华北区-"换行再"直营"的怪现象。

label: { show: true, position: 'outside', width: 80, overflow: 'breakAll', lineHeight: 15, formatter: '{b}\n{d}%' }

注意width一旦设置,overflow才会生效。不设width的话,overflow是无意义的——因为它没有约束边界。这个细节文档里写得不算显眼,很多人配了overflow不生效就是这个原因。

另一个是formatter返回富文本。富文本标签里可以给不同片段设置不同样式,但要小心行高。富文本的rich配置里如果给某个片段设了fontSize,它会把这一行撑高,进而影响整个标签的高度——而标签高度正是纵向排布的依据。所以富文本标签里,我会显式给每个 rich 片段配height和lineHeight,让总高度可控。

label: { formatter: params => `{name|${params.name}}\n{val|${params.percent}%}`, rich: { name: { fontSize: 12, lineHeight: 15, height: 15, color: '#3d4452' }, val: { fontSize: 13, fontWeight: 600, lineHeight: 17, height: 17, color: '#1f2633' } } }

这样每个标签的总高度固定为 32px,纵向排布的估算就有了确定值,前面那个"容器高度 ÷ 22px"的算法也能用得更准。

7. 我在实际项目里踩出来的几条判断

调了这么多轮,有几条判断是反复被验证的,写出来供参考。

第一,先问这个饼图非画不可吗。超过 8 个分类的占比数据,横向条形图的可读性完胜饼图,标签重叠问题根本不会出现。我现在的做法是先按饼图配置,如果数据项超过 8 个,就直接给一版条形图对比图放在旁边,多数情况下对方会选条形图。这不是偷懒,是把精力放在真正需要可视化质量的地方。

第二,不要指望单一参数解决问题。标签重叠是"数据分布 × 容器尺寸 × 布局参数"三者共同作用的结果,任何单点调整都只是缓解。真正干净的方案通常是三层叠加:数据侧合并到 7 项以内、几何侧把半径收缩到合理范围、布局侧开hideOverlap兜底。缺任何一层,换个数据就又出问题。

第三,把"标签一定够"当成一个不可能达成的目标。只要扇区数和容器尺寸的比值超过阈值,就一定有标签要被牺牲。与其纠结怎么让全显示,不如提前决定牺牲哪些——用降序排序让大项优先保留,用minShowLabelAngle或合并让小项主动退出,用 tooltip 保证退出后的信息可达。这个思路我觉得比"想办法全显示"更务实。

第四,多准备一份 data 的_raw副本。合并、排序这些操作如果直接改原数组,后续的 tooltip 展开、下钻、导出都会拿到被污染的数据。我现在的习惯是在数据入口就map一份带_mergedItems的副本,原数组保持不动,只在setOption时用处理后的版本。这个习惯帮我避免过好几次"导出 Excel 数据对不上"的返工。

最后分享一个我常用的小技巧:调参阶段先临时把容器的背景设成半透明色块,把radius画到'90%'这种夸张值,你能一眼看到标签的边界在哪儿、被裁到哪里、哪几条引导线在交叉。等看清了再往回收到正常值,比盲调快得多。这个手法在遇到"标签看起来没重叠但就是别扭"的情况时特别有用,因为问题往往藏在画布边缘之外。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询