1. 先理清思路任意数据要画成什么样的正态分布图上个月帮同事搭一个质量分析页面原始数据是产线采集的一批尺寸测量值需求里写得很简单把数据的正态分布图画出来。我当时以为无非是找个现成模板改改结果真正动手才发现Echarts本身其实没提供“正态分布图”这种图表类型所有工作都落在了数据处理上。所谓“正态分布图”通常不是指单独一条线而是把两样东西叠加在一起看一是真实数据的分布形态一般用直方图表达二是理论上的正态分布曲线也就是我们常说的钟形曲线。两条内容叠在同一个坐标系里才能直观看出“这批数据到底有多接近正态”“偏离发生在哪个区间”。Echarts作为纯前端可视化库本身只负责渲染但它的多系列叠加机制非常适合干这件事。适合看这篇文章的人我觉得有三类一类是刚接触Echarts、想把统计图表做得更专业的开发者一类是做数据可视化大屏需要把“分布类指标”落到页面上的前端同学还有一类是经常做数据分析、但又不想每次打开SPSS或者Python的人——用Echarts直接在网页里出图快而且能交互。先把“任意数据”四个字拆开。数据可能来自Excel表格可能来自后端接口一次返回的几百上千条记录也可能就是前端自己模拟生成的一堆数值。不管来源如何只要是一组连续型数值我们就有办法算均值、标准差、分组做直方图最后套上正态曲线。关键是建立一条统一的数据处理管线原始数据进来清洗、统计、分组、计算密度、生成曲线点最后交付给Echarts渲染。这篇文章就按这条管线一步步写。1.1 正态分布图里到底画了什么先复习一下概率论里那个钟形曲线。正态分布的概率密度函数是f(x) 1 / (σ * sqrt(2π)) * exp(-((x - μ)²) / (2σ²))其中 μ 是均值决定曲线的中心位置σ 是标准差决定曲线的胖瘦。σ 越小曲线越高越瘦说明数据越集中在均值附近σ 越大曲线越矮越宽说明数据越分散。直方图的纵轴和曲线纵轴要想可比必须统一成“概率密度”也就是每个区间内的样本占比除以区间宽度保证所有柱子和曲线围出来的总面积都等于1。我见过不少新手直接拿频数当直方图的纵轴然后硬把正态曲线叠加进去结果数值量级相差巨大曲线几乎被压成一条直线。这一步是第一个大坑后面我会详细讲怎么算。1.2 为什么选Echarts而不是其他工具Excel和Python都能画正态分布图但放到网页项目里就不太合适了。Echarts的好处有三个一是生态成熟社区里有大量案例可以抄柱状图、折线图、饼图、地图都有现成模板二是配置项灵活bar系列和line系列可以在同一个option里叠加而且支持tooltip联动、区域缩放、大屏自适应三是依赖轻纯前端就能搞定后端不需要返回图片只需要返回原始数据前端随时可以重新计算和渲染。如果你只想看一张静态图用Python的matplotlib当然也行。但在网页平台上尤其是要接到可视化大屏里Echarts几乎是绕不开的选择。后面我给出的方案就是“前端拿原始数据自己算统计量再交给Echarts渲染”整个过程不依赖后端算法服务。2. 数据才是主角统计计算与直方图分组细节很多人在Echarts配置上花了大把时间结果图形不对最后发现均值算错了或者标准差用了总体标准差而不是样本标准差。画正态分布图Echarts只是最后一步渲染真正的核心在前面的统计计算。2.1 均值、方差和标准差到底该用哪个n均值很简单所有数据相加除以数量。标准差有两种算法差别在于分母是 n 还是 n-1总体标准差σ sqrt(Σ(xi - μ)² / n) 样本标准差s sqrt(Σ(xi - x̄)² / (n-1))如果你的数据是从一个更大的总体里抽样得到的比如从生产线上抽了200个产品做测量这200个只是样本那么建议用 n-1 的样本标准差这也是Excel里STDEV.S函数的默认行为。如果数据本身就是全量统计比如这一周所有订单的处理时长那用总体标准差也说得过去。实际项目里大部分观测数据都属于抽样场景所以我习惯默认用 n-1。JavaScript实现起来很简单// 计算均值 function calcMean(arr) { if (!arr.length) return 0; return arr.reduce((sum, v) sum v, 0) / arr.length; } // 计算样本标准差分母 n-1 function calcStd(arr) { const mean calcMean(arr); const sumSq arr.reduce((sum, v) sum Math.pow(v - mean, 2), 0); return Math.sqrt(sumSq / (arr.length - 1)); }2.2 直方图分组组数不是拍脑袋定的直方图是“任意数据”分布的可视化基础把连续数值切成若干个区间统计每个区间里有多少数据。切几组很有讲究切太少会丢失分布细节切太多又会因为噪声产生锯齿。常用的经验公式有两个Sturges公式k ceil(log2(n) 1) 平方根选择k ceil(sqrt(n))n 是样本量。比如 n2000Sturges 给出 kceil(10.971)12组平方根选择给出 kceil(44.72)45组。建议在两个值之间取一个数据整体平滑需求高就取小值想看到更多细节就取大值。我常用的是 Sturges 加一个手动系数比如乘以1.5之后再看效果微调。还有一个容易踩的边界问题当某个数据点正好落在两个区间的分界线上时用 Math.floor((v - min) / binWidth) 计算索引最后一个最大值会落到越界索引需要单独处理。function buildHistogram(arr, binCount) { const min Math.min(...arr); const max Math.max(...arr); const binWidth (max - min) / binCount; const bins new Array(binCount).fill(0); arr.forEach((v) { let idx Math.floor((v - min) / binWidth); if (idx binCount) idx binCount - 1; if (idx 0) idx 0; bins[idx]; }); // 转换成密度频率 / 组距 return bins.map((count, i) { const x0 min i * binWidth; const x1 min (i 1) * binWidth; return { x0, x1, count, density: count / arr.length / binWidth }; }); }直方图纵轴用密度而不是频数这一步非常关键。 density count / 总样本数 / 组距这样所有柱子的面积加起来等于1与正态曲线的面积量纲一致叠加以后才对得上。2.3 正态曲线数据的生成方式曲线不用真的画无限多个点在数据范围内均匀取样200到300个点计算每个点的概率密度值就可以保证平滑。取样太少曲线会有折线感取样太多占内存也没必要。function normalPDF(x, mean, std) { if (std 0) return 0; const exponent -Math.pow(x - mean, 2) / (2 * Math.pow(std, 2)); return Math.exp(exponent) / (std * Math.sqrt(2 * Math.PI)); } function buildNormalCurve(min, max, mean, std, sampleCount 200) { const step (max - min) / sampleCount; const points []; for (let i 0; i sampleCount; i) { const x min i * step; points.push([x, normalPDF(x, mean, std)]); } return points; }2.4 数据清洗不可跳过“任意数据”往往不干净。从接口拿回来的数组可能存在 null、undefined、字符串数字混排的情况。计算均值之前至少要做一次过滤function cleanNumericArray(arr) { return arr .map((v) (typeof v number ? v : Number(v))) .filter((v) Number.isFinite(v)); }过滤完如果发现数组长度比原来少了很多说明数据源质量有问题最好在页面上提个醒。还有一点容易忽略如果原始数据是分类标签而不是数值比如“优、良、中、差”那就不能直接画正态分布图需要先做编码映射这属于数据预处理的范畴和画图本身无关。3. 上手实现从原始数据到Echarts正态分布图这部分的最终效果是页面上一个图表下方是半透明的直方图柱子表示真实数据的分布上方叠一条平滑的红色曲线表示理论正态分布。鼠标悬停时tooltip同时显示柱子所在区间的实际密度和理论密度。3.1 准备一份模拟示例数据为了演示方便我用Box-Muller变换生成2000个接近正态分布的数值模拟“一批订单的处理时长数据”实际项目里你直接替换成接口返回的数组就行。const rawData []; for (let i 0; i 2000; i) { const u1 Math.random(); const u2 Math.random(); const z Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); rawData.push(50 z * 8); // 均值约50标准差约8 }Box-Muller变换是生成正态随机数的经典方法利用两个均匀分布的随机数换算出标准正态分布值再通过线性变换搬到目标均值和标准差。这段代码只是为了凑出演示数据日常项目中直接用真实数据即可。3.2 统计计算与直方图组织示例接下来把前面的函数串起来const cleaned cleanNumericArray(rawData); const mean calcMean(cleaned); const std calcStd(cleaned); const bucketCount Math.ceil(Math.log2(cleaned.length) 1) * 1.5; const bins buildHistogram(cleaned, Math.ceil(bucketCount)); const min Math.min(...cleaned); const max Math.max(...cleaned); const curve buildNormalCurve(min, max, mean, std, 200);这里有一点需要提醒buildHistogram里的最小值、最大值是直接从原始数据算出来的但正态曲线的定义域实际上是从负无穷到正无穷。为了和直方图对齐我只在 [min, max] 范围内画曲线如果数据两头还有大量理论面积说明数据可能不是正态的或者样本量太小这个视觉对比本身就是一种判断。3.3 组装Echarts配置项Echarts的option核心是series数组。第一项是bar第二项是line两条 series 共用同一个xAxis和yAxis。xAxis用type: value而不是category因为数据是连续数值yAxis用概率密度单位就是“每单位数值区间的概率”。const chart echarts.init(document.getElementById(distributionChart)); chart.setOption({ tooltip: { trigger: axis, confine: true, formatter: function (params) { const barPoint params.find((p) p.seriesType bar); const linePoint params.find((p) p.seriesType line); let html ; if (barPoint) { html 实际分布密度 Number(barPoint.data[1]).toFixed(4) br/; } if (linePoint) { html 正态曲线密度 Number(linePoint.data[1]).toFixed(4); } return html || ; } }, legend: { data: [实际分布, 正态曲线] }, grid: { left: 64, right: 24, top: 56, bottom: 44 }, xAxis: { type: value, name: 数值区间, axisLabel: { formatter: function (value) { return Number(value).toFixed(2); } } }, yAxis: { type: value, name: 概率密度, axisLabel: { formatter: function (value) { return Number(value).toFixed(3); } } }, series: [ { name: 实际分布, type: bar, data: bins.map((b) [(b.x0 b.x1) / 2, Number(b.density.toFixed(6))]), barWidth: 99%, itemStyle: { color: rgba(64, 158, 255, 0.4) } }, { name: 正态曲线, type: line, data: curve, smooth: true, showSymbol: false, lineStyle: { width: 2, color: #ff6b81 }, areaStyle: { color: rgba(255, 107, 129, 0.08) } } ] });这里有几个细节值得说。barWidth设置成99%是为了让柱子和柱子之间几乎没有缝隙视觉上更接近直方图的形态。如果柱子太宽或太窄可以使用barGap和barCategoryGap调整但在value轴上通常直接控制barWidth就行。曲线的areaStyle透明度调得很低这样既能看出曲线下方的面积又不至于遮住柱子。曲线数据在有数值变化的地方密度可能超过0.06而直方图个别柱子密度可能冲到0.1以上两组数据都处于同一量纲下图表的可视化对比才有意义。3.4 接入异步数据原生js、jquery、ajax和Echarts配合实际项目里数据很少直接写死在前端。最常见的方式是用Ajax从后端拿JSON数组然后在回调里初始化图表。用jQuery写起来很快$(function () { $.ajax({ url: /api/distribution, type: GET, dataType: json, success: function (res) { const values res.data.map((item) item.value); const cleaned cleanNumericArray(values); // 在这里执行统计计算然后 setOption renderDistributionChart(cleaned); }, error: function (err) { console.error(获取数据失败, err); } }); }); function renderDistributionChart(data) { const chart echarts.init(document.getElementById(distributionChart)); chart.setOption(buildDistributionOption(data)); window.addEventListener(resize, function () { chart.resize(); }); }这里有几个容易踩的点必须等DOM渲染完成再init。如果容器写在页面底部而你在head里的script里直接调用init容器宽度是0整个图就显示不出来。Ajax回调完成后数据如果是纯字符串先用cleanNumericArray转成数字。不要相信后端返回的字段类型。如果页面是单页应用路由切换销毁页面时一定要调用chart.dispose()否则Echarts实例会一直占着内存大屏项目尤其容易遇到卡顿。3.5 数据不是正态时怎么办“任意数据”并不保证都是正态的。如果算出来偏度、峰度偏离明显直方图会明显偏一边比如一大批订单处理时长往往右偏大多数单子很快少数单子很慢。此时硬套正态曲线意义不大。有三个处理思路对数变换对原始数据取ln或log再画直方图和正态曲线图像往往更接近正态。Box-Cox变换用公式 y (x^λ - 1) / λλ不等于0调整数据形态λ可以通过最大似然估计得到。前端实现起来稍重但思路不复杂。核密度估计(KDE)不假设任何分布形态用高斯核函数直接估计密度曲线适合任何形状的数据。简单实现只需要遍历样本点叠加高斯核function gaussianKernel(u) { return Math.exp(-0.5 * u * u) / Math.sqrt(2 * Math.PI); } function kde(samples, queryPoints, bandwidth) { return queryPoints.map((x) { const sum samples.reduce((acc, v) acc gaussianKernel((x - v) / bandwidth), 0); return [x, sum / (samples.length * bandwidth)]; }); }KDE的好处是不需要假设正态坏处是bandwidth参数会影响曲线平滑度需要根据数据量手动调试。对于普通的可视化需求先看直方图形状再用正态曲线做参考通常已经足够。4. 高频问题排查换行、坐标轴刻度、饼图偏移与Vue3适配任何图表项目都不会一帆风顺。这里把我在实际开发里碰到过的、以及网上社区里高频出现的问题整理成一份速查很多问题虽然不是正态分布图专属但做混合大屏时一定会遇到。4.1 tooltip自动换行不生效Echarts的tooltip默认支持HTML字符串但有些同学习惯在formatter里拼\n结果发现换行不生效。原因很简单Echarts渲染tooltip时默认使用HTML结构\n不是HTML换行标签。正确做法是用br/formatter: function (params) { return ( 均值区间 params[0].axisValueLabel br/ 实际密度 params[0].data[1].toFixed(4) ); }如果用的是富文本rich配置也可以在里面定义带padding的样式块。另外当tooltip内容过长、图表在屏幕边缘时加一个confine: true可以防止tooltip溢出画布范围这是我在大屏项目里最常用的配置之一。4.2 折线图x轴刻度过密、标签重叠正态分布图的x轴是value轴当数据范围很大、取样点很多时x轴的刻度标签容易叠成一团。处理方式有三个方向用axisLabel.interval配合rotate旋转标签比如interval: 2, rotate: 30。设置axisLabel.hideOverlap: trueEcharts会自动隐藏重叠的标签。如果数据点是category轴可以自己控制interval跳过部分刻度不要直接把全部点塞给坐标轴。使用dataZoom也是常用手段给图表底部加一个缩放条用户放大看局部区间时正态曲线和直方图都会跟着变化交互体验好很多。4.3 饼图labelLine末尾小圆点偏移虽然这篇在讲正态分布图但同一个页面通常不止一张图。饼图的labelLine末尾小圆点偏移问题经常被误认为是Echarts的bug。其实多半是labelLine.length2太长导致引导线末端离文本太远圆点看着像脱节了。另一个原因是label的宽度不够文本自动折行后圆点位置对不齐。我的处理习惯是labelLine: { length: 12, length2: 8 }, label: { formatter: {name|{b}}\n{value|{c} {d}%}, rich: { name: { width: 70, overflow: truncate }, value: { width: 90, fontSize: 12 } } }设置好label宽度和overflow之后引导线的终点点位就会稳定。4.4 Vue3项目里pxtorem对Echarts不生效这个话题在社区里很火。现象是Vue3项目加了postcss-pxtorem发现页面里的px都转成了rem唯独Echarts图表尺寸和内部文字大小没变化。原因是Echarts默认用canvas渲染canvas里的像素坐标是JavaScript绘制出来的根本不会经过postcss处理。pxtorem作用的是CSS样式文件对canvas内部的绘制像素无效这是预期行为不是bug。解决办法不用绕弯路容器div的宽高可以用vw、百分比、rem来写Echarts init后会自动适配容器CSS像素。监听resize事件调用chart.resize()让canvas跟随容器变化。大屏整体缩放时给最外层容器加transform: scale比强行把Echarts内部尺寸转rem靠谱得多。Echarts本身也有devicePixelRatio概念高DPI屏上会自动适配清晰度这属于内置能力不需要额外处理。4.5 常见问题速查表我把上面这些问题整理成表格方便直接对照问题现象常见原因处理方式tooltip换行不生效使用了\n而不用HTML标签改用br/或用formatter拼HTMLx轴刻度标签重叠数据点多没有控制间隔使用interval、rotate、hideOverlap饼图labelLine圆点偏移length2过长或label宽度不足调length/length2设置label宽度和overflowpxtorem对Echarts无效canvas内部像素不走CSS转换容器尺寸用rem/百分比内部数值原样使用初始化后图表宽度为0容器隐藏或未插入DOM等容器可见后再init或调用resize大数据量渲染卡顿曲线和柱子点数过多开启sampling、large先聚合直方图4.6 Echarts中柱状图入门基础不可跳过很多同学看到“第1关echarts中柱状图的绘制”的热搜词说明大家都在从最简单的图起步。确实柱状图是组合图表的基础理解了bar系列怎么工作叠加line系列只是加一个数组的事。但有几个基础点值得认真看一下category轴和value轴的区别、stack堆叠的作用、barWidth和barGap的关系。我见过不少人在value轴上直接塞category字符串结果图表完全错位。建议把柱状图单独做一遍后再做组合图基础稳了后面的正态分布图才能少踩坑。5. 从单图到大屏的落地经验画好一张正态分布图只是开始实际项目里它往往只是数据大屏的某个角落。要把这张图放进完整的大屏体系还有几个问题需要考虑。5.1 从Echarts社区找参考的正确姿势Echarts社区和官方示例里有大量现成的图表模板从地图到饼图从柱状图到复杂叠加大屏应有尽有。找参考的正确姿势不是直接复制代码而是先看清它的数据结构。很多社区案例的data是写死的模拟数据拿到项目里第一件事是理解字段含义再把自己的数据填充进去。其次要看它用了哪些组件比如dataZoom、markLine、visualMap这些才是模板的精髓。我习惯把社区案例拆成三部分看数据准备逻辑、option配置逻辑、自适应处理逻辑。拆完之后自己手动重写一遍而不是复制粘贴这样以后遇到奇怪需求时才不会懵。5.2 大屏布局和尺寸适配如果页面是多张图表组成的大屏直接给每张图单独写死宽度是灾难。建议大屏最外层容器用百分比布局图表容器高度固定或者用calc计算然后统一监听resize调用所有图表实例的resize方法。还有一种常用做法设计稿是1920*1080实际屏幕可能是各种比例。用transform: scale对整体缩放scale值等于实际宽度除以设计稿宽度再配合translate做居中。这样做的好处是图表内部字体、间距都不用动坏处是如果屏幕比例和设计稿差距过大上下或左右会留白。大屏项目里我优先选百分比布局加局部缩放而不是全屏scale。5.3 地图、折线、饼图混合场景下的组件协作一张大屏往往同时包含地图、折线图、饼图、柱状图。Echarts的地图组件需要先注册GeoJSON数据然后才能使用series-map再叠加散点图展示地区维度的观测值。折线图适合看趋势饼图适合看占比柱状图适合看对比而正态分布图适合看数据质量。这几种图混在一起时需要注意配色统一、坐标系对齐、tooltip风格一致。我的建议是维护一份全局主题对象把颜色、字体、背景色集中管理setOption时按主题生成。这样不同图表之间风格能统一后期换肤也方便。5.4 从一张图到一个可复用的统计组件最后想强调一个思路正态分布图不应该是孤立的页面代码完全可以封装成可复用组件。输入是一组数值数组输出是一个渲染好的图表统计计算全部放纯函数里便于单元测试Echarts实例的生命周期由组件统一管理。这样不管是接订单数据、测量数据还是用户行为数据换一份数据源就能直接用。我实际使用时的习惯是先在一个测试页面里把统计函数调试好打印均值、标准差和分组密度确认数值没有问题之后再封装成组件最后才接大屏数据。图形长得不对90%是数据算错了而不是Echarts配置写错了。这个习惯帮我省下大量排查时间如果你也经常跟图表打交道可以从这期内容开始试试这条路。