做了这几年工程可视化,被问得最多的一个问题就是:断面图能不能用 ECharts 画?公路纵断面、河道疏浚断面、管线纵断面、地勘钻孔柱状图,这些在工程软件里司空见惯的东西,一旦要搬到 Web 端,很多人的第一反应是找 GIS 库或者专业绘图引擎。但实际做下来你会发现,ECharts 完全能扛住这类需求,而且对接交互、图表联动、样式定制都非常顺手。这篇文章就是把我在实际项目中用 ECharts 实现断面图的完整思路和踩坑记录整理出来,给同样有这类需求的朋友做个参考。
1. 断面图在工程里到底是什么,为什么选 ECharts
1.1 断面图的本质:不是画曲线,而是还原空间比例
断面图说白了就是沿着某条线“切一刀”,把切开后在垂直方向上的起伏、结构、层次画出来。道路设计里它叫纵断面图,横坐标是里程桩号,纵坐标是高程;河道疏浚里它反映河底地形和设计开挖线;地质勘察里它又是钻孔的分层柱状图,表达不同土层的厚度和分布。
很多新手第一次拿到需求,会觉得这不就是个折线图吗?两条线一叠就完事了。真做起来才发现完全不是一回事。断面图最核心的一个要求是:空间比例必须真实。横轴上相邻两个点的间距,必须真实反映它们在实际工程中的水平距离,不能因为 ECharts 的类目轴会均匀排布而把 10 米和 100 米的间距画成一样宽。这直接决定了坐标轴类型的选择。
另外断面图通常不是单一数据,至少两条线起步:原地面线和设计线。两条线之间的夹层区域就是填方区或挖方区,这是工程量的核心关注点。更复杂的还要叠加地下水位线、地层分界线、特征桩号标注、坡度标注。信息是分层的,不是一个 series 就能搞定。
1.2 为什么不用专业绘图库,反而选 ECharts
有朋友会问,画断面图用 CAD 或者专业岩土软件不香吗?香,但它们解决的是“出图”问题,不是“上 Web”的问题。工程报告系统、BIM 协同平台、在线审图工具,都需要在浏览器里展示断面,并且要求鼠标悬停能看详细数据、拖拽能缩放、还能跟平面图联动。这时候专业绘图引擎又显得太重,学习成本和包体积都扛不住。
ECharts 的优势在于它把交互、坐标轴、图例、tooltip、缩放全都替你封装好了,你只需要专注在数据组织和 series 配置上。而且它的生态足够成熟,遇到问题随便一搜都有答案。我用它做过道路纵断面、管线横断面、地勘柱状图,整体体验是:只要绕开几个关键的坑,ECharts 画断面图是完全够用的。下面就从坐标轴和数据组织开始讲。
2. 坐标轴和数据组织:先把“形”搞对
2.1 axis.type 怎么选:value 轴才是断面图的正解
ECharts 里坐标轴类型主要有 category、value、time 三种。画断面图,横轴里程轴必须用type: 'value',这是一个原则性问题。
用 category 轴的人,通常是觉得“里程桩号嘛,不就是一串标签吗”。但 category 轴的刻度是均匀分布的,不管两个测点之间实际距离是 5 米还是 50 米,在图上都会占同样的宽度。这样一来,真实的地形起伏会被彻底扭曲:明明是一段陡坡,画出来变成了缓坡;明明是一段长缓坡,画出来可能变成了陡坎。做工程的人对断面图的第一要求是“按比例”,category 轴天生做不到这一点。
value 轴则完全按数值大小来映射坐标,两点间距是 5 米就画 5 个单位的宽度,间距 50 米就画 50 个单位的宽度,比例关系天然正确。我一般这样配置:
xAxis: { type: 'value', name: '里程(m)', scale: true, min: 0, max: 320 }再看纵轴高程轴,同样用 value 轴,但有个细节:默认情况下 ECharts 会从 0 开始画,如果断面高程大致在 50 米左右,0 到 50 米的空白段会占到图面的一半以上,地形起伏就被压扁到几乎看不见。解决方案就是把scale设为true,让纵轴从数据最小值附近开始。
yAxis: { type: 'value', name: '高程(m)', scale: true }这是我做断面图最先学会的一招,很多人画出来“一条平线”其实不是数据没有起伏,而是没开 scale,把起伏压没了。
2.2 数据变成什么格式:点对数组和里程编排
数据组织的核心是:把每个测点整理成[里程, 高程]的坐标对。ECharts 的 line 系列原生支持这种[x, y]数据格式,用在 value 轴上正好一一对应。
const groundData = [ [0, 52.1], [30, 53.4], [65, 51.8], [100, 50.9], [120, 52.2], [160, 53.6], [200, 54.1], [250, 53.2], [300, 51.7] ];这里要特别提醒一点:数据必须按里程从小到大排序。ECharts 虽然不会因此报错,但线条会来回折返,断面图就乱成麻花了。如果原始数据来源是测绘仪器或者 Excel 表格,导入前最好先做一步排序,这种小坑我在项目里见过不止一次。
另外,同一对测点,不同专业的数据往往不是“对齐”的。比如地面线测了 9 个点,设计线的变坡点桩号可能只有 6 个。这完全没关系,value 轴不要求两条线的 x 坐标一一对应,ECharts 会自动在点之间做连线插值。这也是用 value 轴的另一个隐藏优势。
2.3 多信息叠加:原地面线、设计线、水位线怎么组织
断面图的信息是分层组织的。我常用的方案是每个数据层一个 series,通过name字段和legend联动:
- 原地面线:实测数据,实线,带半透明面积填充
- 设计线:理论数据,虚线,不加填充
- 地下水位线:实测或勘察数据,点划线
- 特征桩号、填挖分界点:用
markPoint或markLine标注
series 之间不冲突,颜色、线型、粗细都能单独控制。比较麻烦的是填挖方区域,ECharts 没有现成的“填充两条线之间”的能力,这是整个实现里最需要动脑子的地方,下一节我详细说。
3. 完整实操:道路纵断面图从数据到成图
3.1 搭建最小可运行配置
验证一个断面图方案是否成立,我习惯先从最小配置跑起来,确认坐标轴和数据没问题,再加交互和样式。下面的代码是在一个已有 DOM 容器container上初始化 ECharts 的最小配置:
const chart = echarts.init(document.getElementById('container')); const option = { tooltip: { trigger: 'axis', formatter: function(params) { let res = '里程 ' + Math.round(params[0].value[0]) + ' m<br/>'; params.forEach(function(item) { res += item.marker + item.seriesName + ':' + item.value[1].toFixed(2) + ' m<br/>'; }); return res; } }, legend: { data: ['原地面线', '设计线'] }, grid: { left: 50, right: 30, top: 60, bottom: 40 }, xAxis: { type: 'value', name: '里程(m)', scale: true }, yAxis: { type: 'value', name: '高程(m)', scale: true }, series: [ { name: '原地面线', type: 'line', data: groundData, symbol: 'circle', symbolSize: 4, lineStyle: { width: 2, color: '#5470c6' }, areaStyle: { color: 'rgba(84, 112, 198, 0.12)' } }, { name: '设计线', type: 'line', data: designData, symbol: 'none', lineStyle: { width: 2, type: 'dashed', color: '#c2574a' } } ] }; chart.setOption(option);这个配置跑起来,你已经得到一张基本能看的纵断面草图。tooltip 的 formatter 里params[0].value[0]是 x 轴里程,value[1]是 y 轴高程,这样悬停时能同时看到两套数据。
3.2 填挖方区域:两种常见做法的取舍
接下来是断面图的高频需求:把设计线和原地面线之间的区域标出来,区分填方和挖方。常见做法有两种,我用下来各有适用场景。
第一种是markArea分段标记。手动把整条里程分成若干个区间,每个区间标记为“填方”或者“挖方”。优点是代码量小,视觉直观;缺点是需要提前算好填挖分界点,而且markArea默认是矩形区域,会覆盖整个 y 轴高度,视觉上其实是一个半透明色带。真正落项目的时候,这个色带带一点透明度叠在图下面,倒也不算难看。
series.push({ name: '填挖区域', type: 'line', data: [], markArea: { silent: true, data: [ [ { name: '挖方', coord: [0, 0], itemStyle: { color: 'rgba(194, 87, 74, 0.18)' } }, { coord: [65, 0] } ], [ { name: '填方', coord: [65, 0], itemStyle: { color: 'rgba(46, 146, 89, 0.18)' } }, { coord: [200, 0] } ] ] } });第二种做法是自己计算填挖区域的多边形边界,然后用一个独立的填充系列画在图层里。逻辑是先求设计线与地面线的交点,再生成“地面线→设计线→地面线”的闭合多边形,填方和挖方各画一个填充区域。这种做法效果最接近专业出图,但代码要多不少,而且要写求交函数。
我个人的建议是:工期紧、区间固定、交点明确的场景,先用 markArea 顶上去;如果是要做工程量提取或者出正式图,就必须走多边形计算路线。很多项目后来都逃不过“算填挖面积”这一步,早做早省心。
3.3 特征桩号与关键点标注
道路纵断面图少不了桩号标注,比如起终点、变坡点、桥涵位置。这个用markLine和markPoint就能搞定。
比如在 K0+065 位置标注一个变坡点:
series[1].markPoint = { data: [ { coord: [65, 51.6], name: '变坡点' } ], symbol: 'pin', symbolSize: 30, label: { show: true, formatter: 'K0+065', position: 'top' } };markPoint的coord和 series 数据一样,也是[x, y]的形式,只要 x 坐标落在 value 轴范围内就能显示。用下来有个经验:桩号文字如果直接写在name里,悬停提示会带上完整前缀,比较啰嗦;我一般把name作为点位的说明,另用label.formatter控制图面上显示的文字,两者互不干扰。
3.4 填挖方工程量估算的补充思路
前面提到求交和多边形,这里补充一个可以直接用的简化思路。填挖面积本质上是两条折线围成的曲边多边形面积。如果不引入第三方几何库,可以用数值积分近似:在所有测点的里程区间上做梯形面积累加,超过某个阈值再分段计算两条线差值。
function computeFillCutArea(ground, design, type) { let area = 0; for (let i = 1; i < ground.length; i++) { // 当前区间两端差值 const d1 = design[i - 1][1] - ground[i - 1][1]; const d2 = design[i][1] - ground[i][1]; if ((d1 >= 0 && d2 >= 0) || (d1 <= 0 && d2 <= 0)) { if (type === 'fill' && d1 >= 0) { area += ((d1 + d2) / 2) * (ground[i][0] - ground[i - 1][0]); } else if (type === 'cut' && d1 <= 0) { area += ((Math.abs(d1) + Math.abs(d2)) / 2) * (ground[i][0] - ground[i - 1][0]); } } else { // 存在交点,需要分段拆开,这里简化为均值近似 const mid = Math.abs(d1) / (Math.abs(d1) + Math.abs(d2)); const xMid = ground[i - 1][0] + (ground[i][0] - ground[i - 1][0]) * mid; if (type === 'fill' && d1 >= 0) { area += (Math.abs(d1) / 2) * (xMid - ground[i - 1][0]); } if (type === 'fill' && d2 >= 0) { area += (Math.abs(d2) / 2) * (ground[i][0] - xMid); } if (type === 'cut' && d1 <= 0) { area += (Math.abs(d1) / 2) * (xMid - ground[i - 1][0]); } if (type === 'cut' && d2 <= 0) { area += (Math.abs(d2) / 2) * (ground[i][0] - xMid); } } } return area; }这套梯形法在测点密集的时候精度完全够用。配合前面说的填挖多边形填充,出来的图既能看又能计算,算是性价比很高的组合。
3.5 加交互:缩放、图例、数据区域选择
断面图放到 Web 上最大的优势就是能和平面图、属性面板联动。我的标配交互是dataZoom加legend。
dataZoom: [ { type: 'inside', xAxisIndex: 0, start: 0, end: 100 }, { type: 'slider', xAxisIndex: 0, start: 0, end: 100, height: 20, bottom: 10 } ]inside缩放支持滚轮操作,适合桌面端;slider滑块适合移动端触屏,两者叠加用户体验最好。缩放时要注意,纵轴不要跟着缩放变化,否则地形起伏会被不断拉伸,建议给 yAxis 固定一个合理的min/max或者保持scale: true,让 ECharts 自己维护比例,但不要同时缩放两根轴。
4. 进阶:地质分层断面图的实现思路
4.1 用堆积柱状图模拟地层分层
道路断面画完后,很多项目会继续提“地质断面”需求。这时数据结构完全变了:横轴不再是连续里程,而是一排钻孔编号;纵轴不再是高程,而是自孔口向下的深度或者分层厚度。专业软件里通常画成钻孔柱状图,ECharts 里最顺手的方案是堆积柱状图。
思路是这样:把每个钻孔视为一根柱子,每一层土作为一个 stack 系列,厚度作为柱子的数值,层层堆叠。因为地层是不连续的,某个钻孔可能缺失某一层,缺失层的数据填 0 就行。
const holes = ['ZK1', 'ZK2', 'ZK3', 'ZK4']; const layers = [ { name: '素填土', color: '#f4cba2', values: [2.5, 1.8, 0, 2.0] }, { name: '粉质黏土', color: '#c4a265', values: [4.5, 5.2, 6.1, 4.0] }, { name: '中砂', color: '#e6d37e', values: [6.0, 5.5, 4.2, 7.0] } ]; const series = layers.map(layer => ({ name: layer.name, type: 'bar', stack: 'total', barWidth: 40, data: layer.values, itemStyle: { color: layer.color } })); const option = { xAxis: { type: 'category', data: holes, name: '钻孔编号' }, yAxis: { type: 'value', name: '深度(m)', inverse: true }, series: series };4.2 关于纵轴方向和孔口高程的细节
注意上面代码里yAxis: { inverse: true }。地质柱状图习惯上深度从上往下增加,所以纵轴要翻转。如果你拿到的是分层底界高程而不是厚度,处理上要稍微绕一下:先把各层厚度算出来,再堆叠。
实际操作中我还会在每个钻孔顶部叠加一个透明的“孔口高程”系列,把地下水位线用一条线叠加在柱状图之上,这样一张图就同时表达了“深度”和“高程”两套信息。不过这种叠加对数据要求较高,如果只是汇报展示,深度的堆积柱状图已经足够清晰。
4.3 地层缺失和尖灭的处理
地质数据里“某层在某孔缺失”太常见了。前面说的填 0 是一种办法,但填 0 后两根柱子之间会出现肉眼可见的断层,这在图上是合理的,因为地层尖灭了。但如果连续多个钻孔都缺失同一层,legend 里就会有一层一直显示 0,看着很误导。
我的经验是:给缺失钻孔的数据填一个极小的值如 0.01,然后在 tooltip 的 formatter 里把这层判断成“缺失”,而不是显示 0.01m 这种怪数字。这样柱子依旧连续堆叠,不会出现莫名其妙的大缝,数据含义又表达准确。
5. 常见问题与排查技巧实录
5.1 图表闪烁:前端最常见的“薛定谔 bug”
“ECharts 闪烁”是搜索频率极高的话题,结合我自己的经历,闪烁原因基本绕不开下面几类,对照排查效率最高:
| 原因 | 表现 | 解决方案 |
|---|---|---|
| 重复初始化实例 | 页面反复跳动、闪烁 | 每次创建前echarts.dispose旧实例,或者复用初始化过的实例 |
| resize 被高频触发 | 窗口拖拽时抖动 | 对window.resize做 debounce,200ms 防抖后再chart.resize() |
| setOption 默认合并动画 | 数据更新时旧图闪一下再变新图 | 更新时用chart.setOption(option, { notMerge: true }) |
| 动画重复播放 | 每次更新都从零播放动画 | 关闭动画:animation: false或更新时animationDurationUpdate: 0 |
| 容器尺寸为 0 | 图表初始化时容器被隐藏,画布高度为 0 | 容器显示后再初始化,或初始化后调用一次chart.resize() |
| 数据和配置未深拷贝 | 同一个对象被复用,旧图配置被意外污染 | 用工具函数深拷贝配置对象,或者每次单独生成 option |
这里重点说下resize这个坑。做单页面应用的时候,侧边栏折叠、菜单展开、路由切换都可能导致容器尺寸变化,如果每次都直接调用chart.resize(),很容易触发多次重绘。我的标准写法是:
let resizeTimer = null; window.addEventListener('resize', function() { if (resizeTimer) { clearTimeout(resizeTimer); } resizeTimer = setTimeout(function() { chart.resize(); }, 200); });如果是实时刷新数据导致闪烁,比如每 3 秒从后端拉一次数据,问题的关键反而在动画上。连续高频更新还开着默认动画,图表每一帧都在过渡,看起来就像鳞片一样闪。这种情况直接关掉动画对性能提升立竿见影。
5.2 数据量大、文字多导致渲染卡顿
现在前端页面往往不止一个图表,旁边可能还有一长串用 markdown-it 渲染的说明文档、表格、报告正文。ECharts 图表本身是 canvas 绘制,和 markdown-it 的 DOM 渲染相互影响不大,但同页面同时执行大量 DOM 操作和 canvas 重绘时,浏览器主线程就会吃紧,表现是两个区域轮流卡顿。
针对 ECharts 本身的大数据量渲染,我的配置习惯是:
series: [{ type: 'line', sampling: 'lttb', progressive: 5000, animation: false }]sampling: 'lttb'用 LTTB 算法对线条降采样,在保形的前提下大幅减少绘制点;progressive开启渐进渲染,数据多时不会一次性把几千个点全部塞进绘图流程。
针对页面同时渲染大量 markdown 文本的情况,我的建议是把长文档拆开渲染,或者用requestIdleCallback分片处理,别把一串几十千字的文本一次性innerHTML塞进页面。这个思路跟 ECharts 的 progressive 其实是相通的:把大任务拆成小片,让浏览器有喘息的机会。
5.3 tooltip 和 label 太长怎么办
断面图的 tooltip 如果一股脑塞太多信息,比如每个点都显示几层地层、多种属性,弹窗宽得能盖住半个图。我是这样处理的:
- tooltip 里只保留核心字段:里程、原地面高程、设计高程、填挖值
- 大量文本放入点击事件后的详情面板,而不是塞在 tooltip 里
axisLabel使用interval和rotate避免坐标数字重叠
axisLabel: { interval: 2, rotate: 45 }如果实在要在 label 上显示很多文字,比如桩号加地名,我的做法是formatter里只显示桩号,地名放到 tooltip 的name里面。别在一行里塞超过十几个字符,否则不仅渲染慢,视觉上也是一团乱麻。
5.4 坐标轴数值对不上、比例失真
最后补充一个经常被忽略但很影响专业度的细节:纵轴的实际刻度和数据范围对不上。很多人画完断面图,发现图面上高差很大,旁边的数字却只有轻微变化,这是因为默认情况下 ECharts 会在scale: false时强制从 0 开始,把真实起伏“压缩”了。所以我一再强调yAxis.scale: true。
反过来,如果某个断面高差只有十几厘米,scale: true也会把这个微小起伏放大得看起来很夸张。这其实是断面图的“相对比例”问题,工程上是允许的,但汇报时要清楚标注高程基准。我的办法是在图上加一条基准线:
markLine: { silent: true, symbol: 'none', label: { formatter: '基准 50.00m' }, data: [{ yAxis: 50 }], lineStyle: { type: 'dotted', color: '#999' } }写在最后
实际做下来,ECharts 画断面图最大的门槛反而不是配置语法,而是你有没有把工程语义想清楚:坐标轴是否真实反映距离比例、多条线的叠加顺序是否合理、填挖区域怎么计算、大数据量怎么保性能。我自己的体会是,先花半小时把数据和坐标轴理清楚,比闷头调样式管用得多。上面这些配置和坑,都是我拿真实项目数据一步步试出来的,希望能帮你少踩几个。最后再分享一个小技巧:做任何复杂图表之前,先手动把数据画到方格纸上或者用一个最简单的散点图看一遍分布,很多时候问题在数据阶段就暴露了,根本轮不到调 ECharts。