ECharts 箱图组件的小提琴图与须线模式设计:从原始点 KDE 到分位数轮廓
箱图是一个很克制的图表。它只给你五数概括:下须、Q1、中位数、Q3、上须。信息密度高、对比能力强,但也很容易把真实分布压扁。
同样的 Q1、Median、Q3,背后可能是单峰、双峰、偏态、长尾,甚至是几个离散平台。于是我们想在通用箱图组件里增加一个可选的 Violin Plot 背景层,让用户在保留箱图摘要能力的同时,看到分布形状。
这件事看起来像一个纯前端渲染问题:ECharts 不是已经有 custom series 吗?ECharts v6 生态里也有 @echarts-x/custom-violin,直接接上不就好了?
真正做下来才发现,难点不在“能不能画出小提琴”,而在于:一个通用图表组件要用什么数据契约表达分布、如何不和散点开关耦合、如何在大数据场景下保持稳定成本,以及怎样把须线语义讲清楚。
这篇文章整理这次设计过程里最有价值的部分,尽量抽离成通用组件视角,不绑定任何具体业务行业。
一、先说结论
最终方案不是升级 ECharts v6,也没有接入 @echarts-x/custom-violin,而是:
- 后端在
showViolin=true时,为每个 box 返回固定的p1 ~ p99共 99 个分位值。 - 前端从分位函数反推相对密度,生成每个 box 的小提琴轮廓。
- 使用当前 ECharts 5 的原生
customseries 绘制静默polygon背景层。 - 箱图主体仍然使用 ECharts 原生
boxplotseries,且保持为第一个基础 series。 - 小提琴图与
showPoints/showRawPoints完全解耦,不依赖散点数据。 - 须线模式新增
Tukey (1.5×IQR)和Full Range (Min-Max)两种口径,但不改变 Q1、Median、Q3 的统计方式。
整体数据流是这样:
这条路线牺牲了“插件开箱即用”的便利,换来的是更稳定的数据契约、更小的响应体积、更可控的前端成本,以及不必为一个叠加层升级整套 ECharts 生态。
二、为什么不是直接用 ECharts v6 的小提琴方案
我们最开始当然也试过官方 / 生态插件路线。ECharts v6 + @echarts-x/custom-violin 的 demo 能很快画出效果,大概是这样的数据形式:
它的核心假设很明确:你给它原始点,它内部做 KDE 或类似密度估计,然后画出轮廓。
这个假设在 demo 里很自然,但放到一个生产级通用箱图组件里就开始拧巴。
2.1 我们手里稳定存在的是统计量,不是全量原始点
箱图主链路通常会稳定返回:
| 数据 | 用途 |
|---|---|
[lower, q1, median, q3, upper] | ECharts boxplot 五元组 |
min/max/p1/p5/p10/p90/p95/p99/avg/std | 统计项、tooltip、辅助分析 |
| outlier points | 可选展示 Tukey 须线外点 |
| raw/sample points | 可选展示原始或抽样点 |
全量原始点并不适合作为默认响应。它可能很大,可能受分页或上限控制,也可能经过视觉抽样。更重要的是,原始点展示开关本来只服务于“散点可视化”,不应该反过来决定“小提琴图是否准确”。
如果为了小提琴图强行打开 raw points,会得到一个很坏的耦合:
这条链路有两个问题。
第一,视觉抽样不等于密度保持抽样。很多抽样算法是为了“屏幕上不要太挤”,它会尽量保留空间覆盖,而不是保留概率密度。拿这样的点去做 KDE,小提琴形状可能看起来很顺,但统计语义并不可靠。
第二,点数上限会泄漏到小提琴图。比如页面总点数限制是 10000,每个 box 抽样 200 个点。box 少时还凑合,box 多、长尾明显、多峰明显时,小提琴图就会受到抽样点不足和分页上限的共同影响。
一个分布背景层不应该依赖“当前页面愿意显示多少散点”。
2.2 ECharts v6 升级不是局部成本
@echarts-x/custom-violin 面向 ECharts v6。若当前组件体系已经稳定运行在 ECharts 5,那么为了一个叠加层升级版本,会引入不成比例的回归范围:
- 所有图表 option 的兼容性要重新验证。
- 已有封装、主题、导出、DataZoom、图例联动都要回归。
- 内部图表包若声明不兼容 ECharts 6,还要一起迁移。
- 报表渲染、Canvas/SVG、图片导出也要重新验。
这不是“加一个小提琴图”的成本,而是“升级整套图表运行时”的成本。
2.3 插件路线在组件化场景里还有布局风险
原型阶段还踩到过一个很典型的坑:为了让 violin 画在箱体后面,如果把 violin custom series 插到 series[0],原有箱图链路里一些“首个基础 series 就是主箱图”的历史约定会被破坏。
直接后果可能是:
- 散点分类带宽算错。
- DataZoom 后重建 series 时读错主数据结构。
- 有效绘图区高度异常,画布正常但箱图只挤在中间一段。
- custom series 未显式声明
xAxisIndex、yAxisIndex、encode时,ECharts 自动推断参与布局,风险更高。
这些问题不是说官方方案“不好”,而是提醒我们:在一个已有复杂 option builder 的通用组件里,叠加层必须遵守已有 series 顺序、坐标轴绑定和交互契约。否则一个背景层会把主图的布局语义带偏。
三、三种路线的取舍
探索阶段我们把方案拆成了三类。
| 方案 | 数据来源 | 优点 | 问题 |
|---|---|---|---|
| A. 原始点 + 插件 KDE | outlier + raw/sample points | 前端实现快,插件负责密度 | 依赖抽样点,受点数上限影响,通常需要 ECharts v6 |
| B. 固定分位数组 + 自绘轮廓 | 每个 box 的 p1-p99 | 响应体积稳定,语义可控,不依赖散点 | 前端要自己实现密度近似和 polygon |
| C. 后端返回更多原始点 | 为 violin 单独提高点数上限 | 能继续走 KDE | 网络、内存、浏览器成本明显增加 |
最终选择 B。关键原因不是“B 最好画”,而是它最适合通用组件:
showViolin=false时完全不增加成本。showViolin=true时每个 box 固定多 99 个数字。- 小提琴图不依赖 raw points,不受散点开关和页面点数上限影响。
- 服务端统计口径和箱图本身保持一致。
- 前端复杂度随 box 数量线性增长,且上界稳定。
这个决策背后的原则是:数据可视化组件的扩展,优先设计数据契约,再选择渲染技术。
四、后端契约:固定 p1-p99,而不是把算法参数暴露给前端
最终响应里,每个 box 新增一个可选字段:
正式数组固定为 99 个值:
| 下标 | 含义 |
|---|---|
0 | p1 |
1 | p2 |
| ... | ... |
98 | p99 |
这里有几个刻意设计。
4.1 分位级别固定,不由前端传
前端不能传“我要哪些分位”。原因很朴素:
- 避免动态 SQL 拼接和注入风险。
- 保证响应数组长度固定,前端逻辑简单。
- 缓存 key 更稳定。
- 后续升级可以做版本化,不会出现同一个字段在不同请求里长度不同。
通用写法可以理解成这样:
如果系统不能承受精确分位的成本,也可以评审后切成近似分位函数。但这必须是一个明确的统计口径变更,不能偷偷替换。因为小提琴图不是装饰,它会影响用户对分布的判断。
4.2 只在开启时计算
showViolin=false 时,SQL 不生成密集分位数组,响应也不带这个字段。这样历史图表、历史缓存和默认渲染都不会变重。
这条边界非常重要。很多图表增强最后变慢,不是因为功能本身慢,而是因为默认路径也背上了新功能的成本。
4.3 聚合场景要讲清语义
如果一个 box 本身已经是多个子组聚合后的结果,那么 p1-p99 也要沿用原来的聚合语义。
例如已有聚合方式是:
avg:每个统计值逐项平均。q2:每个统计值取中位数。
那分位数组就应该逐元素聚合:
这表达的是“子组分位曲线的逐元素平均或中位数”,不是“把所有子组原始点合并后重新算一条总体分位曲线”。
这句话必须写进设计和说明里。否则用户看到小提琴轮廓,很容易误以为它永远代表全量原始分布。
五、前端算法:从分位函数反推密度
拿到 p1 ~ p99 后,前端要把它变成小提琴宽度。
连续分布里,分位函数 Q(p) 和概率密度 f(x) 有一个近似关系:
直觉上很好理解:如果相邻分位值挤得很近,说明很多概率质量集中在很窄的数值范围里,密度就高,小提琴应该更宽;如果相邻分位值隔得很远,说明这一段更稀疏,小提琴就更窄。
5.1 输入先严格校验
前端首先只接受一种数据:
- 必须是数组。
- 长度必须是 99。
- 每个值必须是有限数字。
- 数组必须非降序。
伪代码如下:
单个 box 的 violin 数据坏了,只跳过这个 box 的小提琴,不影响整张图。通用组件里这种降级很重要:叠加层不能比主图更脆弱。
5.2 重复值不能直接 1 / ΔQ
真实数据里经常会出现重复分位值。比如很多数据卡在同一个阈值附近,p20 ~ p35 可能都是同一个值。
如果直接算:
就会遇到除零,产生 Infinity 或尖刺。
更稳的做法是对每个分位点向左右寻找最近的“有效不同值”,再计算邻域密度:
epsilon 不应该是固定常量,而应该跟 p1-p99 的跨度和数值量级有关。否则小数范围和大数范围会出现完全不同的浮点噪声表现。
5.3 平滑、归一化与尾部收口
密度序列会做一个轻量移动平均,例如 [1, 2, 3, 2, 1],再按每个 box 内的最大密度归一化到 [0, 1]。
这里的宽度表达的是 单个 box 内部的相对密度,不是样本量大小。也就是说,两个 box 的小提琴都画到最大宽度,不代表它们样本数一样,只代表各自内部最密集的位置。
尾部只覆盖 p1-p99,并在 p1、p99 处把宽度收为 0:
为什么不延伸到 min/max?因为少量极端值很容易拉出一条细长尾巴,让小提琴图喧宾夺主。我们的定义是:
- violin 表达中心 98% 分布轮廓。
- 极端尾部由箱须和离群点表达。
- 如果用户选择 Full Range 须线,min/max 由箱须表达。
这样小提琴图和箱图各司其职。
六、ECharts custom series:只做静默背景层
渲染层没有使用插件,而是追加一个原生 custom series。
核心 option 形态:
几个细节比代码本身更重要。
6.1 主箱图必须仍是第一个基础 series
已有箱图、散点、DataZoom、多层 X 轴、导出逻辑,可能已经默认“第一个基础 series 是 boxplot”。因此 violin 不能为了背景层效果插到 series[0]。
正确做法是:
boxplotseries 仍先 push,z: 2。- violin series 后 push,但
z: 1。 - 视觉上 violin 在后面,数据结构上主箱图仍在原位。
这是一个很典型的组件维护经验:视觉层级不一定等于 series 顺序。
6.2 显式绑定坐标轴和 encode
custom series 如果没有明确坐标系、坐标轴和 encode,ECharts 会进行自动推断。在简单 demo 里这很方便,在复杂组件里却容易让叠加层参与不该参与的布局、缩放和 tooltip 推断。
所以我们显式写:
这相当于告诉 ECharts:它只是一层画在主坐标系上的静默图形。
6.3 宽度跟随 DataZoom
polygon 点位通过 api.coord 和 api.size 计算:
api.size([1, 0]) 会随 DataZoom 后的类目带宽变化而变化,所以小提琴宽度能自然跟随缩放。再加一个像素上限,是为了避免 box 很少时小提琴过宽,压住箱体。
6.4 颜色和图例状态复用主箱图
小提琴不是新的 legend item。它跟随对应 box 的颜色和图例状态:
- legend disabled:不画对应 violin。
- legend transparent:violin opacity 降低。
- 有 violin 的 box,箱体填充改成半透明,箱线保持不透明。
这样用户仍然认为它是“箱图的一部分”,而不是另一个并列系列。
七、须线模式:Tukey 与 Full Range 不是纯视觉开关
小提琴图做完后,另一个紧挨着的问题是须线口径。
很多用户对箱图的“顶端横线”有两种期待:
- 标准统计箱图:须线到 Tukey fence 范围内最远真实点,范围外点作为 outlier。
- 业务全范围箱图:须线直接到实际 min/max。
这两个都合理,但它们不是同一种图。于是组件增加 Whisker Range:
| 模式 | 五元组 | 点语义 |
|---|---|---|
Tukey (1.5×IQR) | [lowerWithinFence, Q1, Median, Q3, upperWithinFence] | fence 外点可作为 outliers |
Full Range (Min-Max) | [min, Q1, Median, Q3, max] | 不再存在 Tukey outlier points |
重点是:Full Range 不是“把须线画长一点”这么简单。它会改变点和统计项的语义。
7.1 Full Range 下隐藏 Show Points
Show Points 原本表示展示 Tukey 须线外点。到了 Full Range,须线已经是 min/max,所有有效值都在须线范围内,再展示“Tukey 须线外点”会造成语义冲突。
所以 Full Range 下:
Show Points隐藏且不生效。- 后端即使收到
showPoints=true也要强制关闭。 Outlier Count不再展示,或返回 0。- Tooltip 里的
upper/lower改成Max/Min。
这是一条经验:当一个配置改变统计定义时,UI、tooltip、统计项和后端防御都要一起变。
7.2 Show Raw Points 仍然可以保留
Show Raw Points 不是 Tukey outlier,它表示展示每个 box 的原始或抽样观测点。因此 Full Range 下它仍然可以存在。
但这里又有一个坑。
很多后端实现会按 Tukey fence 把点分成两桶:
在 Tukey 模式下,这很自然:
但 Full Range + Show Raw Points 下,如果简单写成:
就会产生三个问题:
- 长尾数据里 outer bucket 可能很多,绕过抽样后点数暴涨。
- 抽样只发生在 inner bucket,视觉分布反而不均匀。
- min/max 端点可能被抽样丢掉,须线指向的位置没有散点。
最后的修法是:
也就是“极值正常参与抽样,缺了才后补”,而不是“极值全部免抽样”。因为极值处可能有大量重复值,如果把所有 min/max 点都摘出来保留,仍然可能造成点数爆炸。
7.3 小提琴图与须线模式保持独立
小提琴图表达 p1-p99 的分位轮廓,须线表达两种边界口径。它们可以组合:
| 须线模式 | 小提琴 | 展示语义 |
|---|---|---|
| Tukey | 关闭 | 标准箱图,可展示 outliers |
| Tukey | 开启 | 标准箱图 + 中心 98% 分布轮廓 |
| Full Range | 关闭 | Min-Max 箱图,不展示 Tukey outliers |
| Full Range | 开启 | Min-Max 箱图 + 中心 98% 分布轮廓 |
不要因为用户切换须线模式就自动关闭 violin。它们是正交配置,只是共同消费同一批统计数据。
八、组件层面的几个经验
这次功能不是一个“画法技巧”,更像一次图表组件契约整理。最后沉淀下来的经验大概有这些。
8.1 不要让可视开关偷换数据契约
Show Points、Show Raw Points、Show Violin Plot 看起来都是 checkbox,但它们的语义不同:
Show Points:展示 Tukey outlier。Show Raw Points:展示原始或抽样观测点。Show Violin Plot:展示分布轮廓。
如果为了省事让 violin 依赖 raw points,后续所有点展示策略都会影响 violin 的可信度。组件会越来越难解释。
8.2 叠加层必须是静默和可降级的
violin 是增强层,不是主图。它应该满足:
- 不接管 tooltip。
- 不接管 click。
- 不影响 brush/highlight。
- 不新增 legend 项。
- 单个 box 数据异常时跳过,不阻断主图。
- 关闭时不改变主图 option 和响应成本。
增强层越“安静”,主组件越稳定。
8.3 统计口径要显示在界面语言里
当用户选择 Full Range 时,tooltip 继续写 upper/lower 就会误导。更好的文案是:
同理,Outlier Count 在 Full Range 下也应该隐藏或置零,因为此时“不展示 Tukey outlier”是模式定义的一部分。
8.4 组件代码里要保护历史约定
这次有效绘图区异常的根因很有代表性:一个新 custom series 改变了 series[0] 的含义。
对长期演进的图表组件来说,很多约定没有写在类型里,而是散落在布局、缩放、散点偏移、导出逻辑中。新增 series 时要先问:
- 有没有代码默认第一个 series 是主图?
- 有没有逻辑从主 series 的 data 推断类目带宽?
- DataZoom 后会不会重建 series?
- 导出和报表是否走同一个 option builder?
小功能要尊重旧结构,这是组件维护里很朴素也很重要的纪律。
8.5 “不是 KDE”要说清楚
分位数组反推出来的是密度近似,不是原始点 KDE。它有清晰边界:
- 它来自固定 99 个分位点。
- 它表达 p1-p99 的中心分布。
- 它会平滑重复值和平台。
- 它不表达样本量。
- 它不展示极端 min/max 尾部。
这并不降低它的价值。对很多大数据箱图组件来说,一个稳定、低成本、口径一致的分位轮廓,比一个依赖抽样点的漂亮 KDE 更可靠。
九、最终的通用方案模板
如果要在自己的 ECharts 箱图组件里实现类似能力,我会建议按这个模板落地。
9.1 数据契约
9.2 渲染原则
9.3 验证清单
| 类型 | 必测项 |
|---|---|
| 数据契约 | 缺字段、长度错误、NaN、Infinity、非单调数组 |
| 密度算法 | 正态、偏态、双峰、重复值、全相等退化 |
| 渲染 | DataZoom、图例 disabled/transparent、Y 轴范围、导出 |
| 交互 | tooltip、brush、highlight 不被 custom series 接管 |
| 须线模式 | Tukey outlier、Full Range min/max、raw points 抽样 |
| 兼容 | 历史配置无新字段时默认旧行为 |
十、结语
这次箱图增强最有意思的地方,是它一开始看起来像“找个小提琴图插件”,最后却变成了“重新定义一个通用箱图组件如何表达分布”。
插件方案没有错。对于原始点可控、数据量适中、ECharts v6 已经稳定落地的项目,直接用原始点 KDE 是很自然的选择。
但在一个更偏工程化、更强调复用和稳定成本的组件里,我更愿意把小提琴图设计成:
- 服务端给稳定分位契约。
- 前端做轻量、可解释的密度近似。
- ECharts custom series 只负责画一个安静的 polygon。
- 须线模式通过 UI 和后端共同保证统计语义。
很多时候,组件设计的“魔法”不是把图画出来,而是让每个开关、每段数据、每个视觉元素都能被解释,并且在数据变大、场景变复杂、历史配置继续存在时,仍然站得住。