☰
seisplotjs 浏览器端地震波形解析与 SVG 可视化实践
2026/10/29 14:39:30 网站建设 项目流程

简介:seisplotjs 是一套面向地震数据前端开发者的 JavaScript 模块集合,重点解决地震波形的解析、处理与可视化问题,适合已具备一定 JavaScript 基础、希望接入 FDSN Web 服务或构建地震数据展示页面的开发者。其功能覆盖数据链路协议、日期时间选择、经纬度距离计算、台站与事件查询、波形数据获取、FFT 频谱计算及基于 d3 的绘图等环节,子模块划分清晰,便于按需引入。压缩包共 184 个文件,约 1.02MB,以 83 个 JavaScript 源码、60 个 HTML 示例页面和 14 个 CSS 样式文件为主,并附带 SAC、miniSEED、SACPZ、StationXML 等地震数据样例及少量 Python、JSON 配置,可用于理解模块组织、对照示例调试和验证解析流程。目前已有 320 人学习下载。读者可从中获得可运行的模块源码、分主题的 HTML 演示页面和配套样式,以及可用于 FFT 与波形绘制的样例数据,适合用作地震数据 Web 展示、FDSN 服务接入和频谱分析的学习参考。

1. 一条 4096 字节的定长记录,卡住了多少前端

浏览器里跑地震数据处理,听着像伪需求,直到你真的去接一份原始波形:波形是定长记录加差分压缩的二进制,台站元数据是另一份 XML,事件目录又是第三份 XML,三份文件的时间基准、量纲、编码方式全不一样。用 JavaScript 把这三件事串起来、再把波形画进 DOM 的库不多,seisplotjs 就是专门干这个的模块集合——解析二进制波形记录、解析台站与事件元数据、提供一套基于 SVG 的波形绘制组件,外加围绕这些数据的切片、滤波、缩放交互。它适合两类人:做台网数据看板、教学演示、野外数据快速回看的前端;以及手里已经有一批波形文件、想不装本地工具就在浏览器里先看一眼形状的工程师。

2. 拆开这个模块集:三类解析器、一层数据模型、一层绘制组件

2.1 解析层为什么必须是三份文件

很多人第一次接触地震波形,会默认“波形文件里应该有台站名、有量纲、有采样率,解析完就能画”。实际情况是,一份完整的观测数据在这套体系里被拆成三个独立文件,而且经常挂在三个不同的服务接口后面。

第一份是波形本身,miniSEED 格式。它是定长记录的:每条记录严格 4096 字节,前 48 字节是固定头段,后面接若干块(blockette),再往后才是数据区。头段里带台网码、台站码、位置码、通道码这四元组,带本条记录的起始时间、采样率因子与乘数、采样点数、记录序号;数据区通常是 Steim-1 或 Steim-2 差分压缩的整型序列。解析器实际干三件事:按 4096 字节切分记录、解出数据区的整型数组、按起始时间把多条记录拼成一条连续序列。这三步里最容易翻车的是第三步,后面避坑章会细说。

第二份是台站元数据,StationXML。它是一棵三层树:Network → Station → Channel,Channel 下面挂 Response,Response 里有 InstrumentSensitivity、Stage、Coefficients、单位。这份文件决定了你手里那串整型数组到底代表什么物理量——是速度、加速度还是原始 counts,换算系数全在这里。把它漏掉,纵轴就只能标“counts”,写报告时没人认。

第三份是事件目录,QuakeML。结构是 event → origin(发震时刻、经纬度、深度)→ magnitude。它不是画波形必需的,但要把 P 波到时的参考线、把某次事件和波形对齐,就得靠它。

三份文件收齐,解析层才算完整。seisplotjs 把这三类解析器放在同一个模块集合里,好处是它们共用一套时间对象和一套坐标约定;如果你从三个不同来源各找一个库拼起来,光是时间精度和时区就能对半天。

2.2 中间那层对象:Seismogram 和时间轴

解析完不是直接丢给绘图组件,中间有一层数据模型。核心是 Seismogram 对象,它承载的东西比看上去多:采样值数组(整型或浮点)、采样率、起始时间、四元组标识,以及一个可选的仪器灵敏度引用。

这里有个容易忽略的设计点:时间不是用原生Date表示的。JS 的Date只有毫秒精度,而地震数据的采样间隔经常是 5ms、2ms 甚至 1ms,用毫秒做累加会漂。库内部用了一套微秒精度的自定义时间对象,做时间加减、比较、格式化都走它。你在业务代码里凡是碰到“起始时间 + 采样点数 / 采样率”这种计算,最好也用这套对象,别图省事转成Date,转一次就是一次精度损失,长序列上误差会肉眼可见。

另一个设计点是小端序与类型。miniSEED 的数据区解出来是 32 位整型,合并时会转成浮点数组。如果你的处理链条里要做多次滤波和重采样,尽量在转成浮点之后做,别在整型阶段做除法,截断误差会累积。

2.3 绘制层为什么选 SVG 而不是 Canvas

这个库的波形绘制走的是 SVG:每一道波形是一条<path>,横轴是时间,纵轴是量纲。选 SVG 的理由很实在——矢量、可被 CSS 控制、可以直接序列化成文件塞进报告、鼠标命中检测天然由 DOM 提供,缩放平移时不用自己维护一套坐标反算。

代价也很明确:点太多会撑爆。一个<path>的d属性是一长串坐标字符串,你往里面塞二十万个点,浏览器光解析这段字符串就要几百毫秒,之后每次重绘都在重复这个开销。所以正确的做法是绘图前按像素列做 min/max 抽稀:屏幕宽度 1200 像素,就只输出 1200 组(最小值、最大值),路径用折线在两者之间上下摆动,视觉上和高密度绘制几乎没差别,性能差一个数量级。这个抽稀不是库替你兜底的,得自己在数据进组件之前做,或者在配置里打开对应的抽稀开关。

2.4 选型边界:什么时候不该用它

说清楚它能干什么,也得说清楚它不该干什么。

需要完整信号处理栈的(谱分析、反卷积求矩张量、台阵聚束),别指望在浏览器里做完,老老实实把原始数据丢到服务端算,前端只负责展示结果。

需要一次渲染几百上千道的,SVG 这条路走不通,要么换 WebGL,要么服务端出图,前端只贴图片。

数据是别的二进制封装格式的,得先找到对应解析器再谈绘制,这个库的解析层只覆盖它自己那套格式族。

反过来,如果你的场景是“给定一个时间窗和四元组,把波形抓下来、做基本预处理、画成可交互的图、导出成图件”,那它正好卡在这个位置上,自己从零写一遍至少要处理记录拼接、时间精度、抽稀这三块,都是细活。

3. 从零跑通一条波形:请求、解析、合并、挂载

3.1 页面骨架与组件注册

先把最小页面搭出来。注意<sp-seismograph>是个自定义元素,必须在脚本里被注册过才有效,否则浏览器只会把它当成一个未知标签,页面上什么都不显示——这个坑我在第5章还会提一次,因为它太常见了。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>波形回看</title> <style> /* 组件默认高度很小,不给高度会以为没画出来 */ #wave { display: block; width: 100%; height: 320px; } </style> </head> <body> <div id="toolbar"> <select id="chan"> <option value="BHZ">BHZ(宽带,40Hz)</option> <option value="HHZ">HHZ(高增益,100Hz)</option> </select> <button id="load">加载</button> <span id="status"></span> </div> <sp-seismograph id="wave"></sp-seismograph> <script type="module" src="./main.js"></script> </body> </html>

骨架里有两处是刻意的:给组件显式高度,以及留一个#status用来打状态。前者避免“以为是数据问题其实是容器高度为零”,后者在调试阶段比console.log好用,因为你能直接看到请求参数和返回长度。

3.2 请求波形与元数据

数据来源自己定,只要接口能按“四元组 + 时间窗”返回二进制波形就行。下面这段只做两件事:拼 URL、拿ArrayBuffer。

// main.js import * as sp from 'seisplotjs'; // 换成你自己的数据服务地址,参数名按服务端约定改 const BASE = '/api/dataselect'; function buildUrl({ net, sta, loc, cha, start, end }) { const q = new URLSearchParams({ net, sta, loc, cha, starttime: start, // 必须 UTC,形如 2024-01-01T00:00:00 endtime: end, }); return `${BASE}?${q.toString()}`; } async function fetchWaveform(params) { const url = buildUrl(params); const resp = await fetch(url); if (!resp.ok) { throw new Error(`HTTP ${resp.status} ${resp.statusText} @ ${url}`); } const buf = await resp.arrayBuffer(); if (buf.byteLength === 0) { throw new Error('空响应,检查四元组和时间窗'); } // miniSEED 记录是 4096 字节对齐的,长度不是 4096 的整数倍就说明被截断了 if (buf.byteLength % 4096 !== 0) { console.warn(`返回长度 ${buf.byteLength} 不是 4096 的整数倍,可能被代理截断`); } return buf; }

参数说明:net/sta/loc/cha是四元组,loc允许为空字符串但要显式传,不能省略成undefined;starttime/endtime一律用 UTC,不要带时区偏移,很多服务端对带偏移的字符串解析行为和你想的不一样。buf.byteLength % 4096这个检查很土但很有效,中间有反向代理或者网关改了响应体长度,波形就会莫名其妙只画出一半。

3.3 解析记录并合并成序列

拿到ArrayBuffer之后,解析和合并是两步,别混在一起写,因为排错时你需要看中间结果。

async function loadSeismogram(params) { const buf = await fetchWaveform(params); // 第一步:切成一条条 DataRecord const records = sp.miniseed.parseDataRecords(buf); console.log('记录条数', records.length); if (records.length === 0) { throw new Error('没有解析出任何记录'); } // 第二步:按时间拼成连续序列 const seis = sp.miniseed.mergeDataRecords(records); // 第三步:自检,这一步别省 const durSec = seis.y.length / seis.sampleRate; console.log('采样率', seis.sampleRate, '点数', seis.y.length, '时长(s)', durSec.toFixed(2)); console.log('起始时间', seis.startTime.toString()); console.log('幅度 min/max', Math.min(...seis.y.slice(0, 5000)), Math.max(...seis.y.slice(0, 5000))); return seis; }

逻辑说明:parseDataRecords只做字节到记录的翻译,不关心时间顺序,也不去重;mergeDataRecords才负责排序、剔除重复记录、把相邻记录的数据区首尾相接。参数上没有太多可调的,真正需要你自己判断的是合并后的自检结果——y.length / sampleRate应该接近你请求的时间窗长度,差得多就说明中间有 gap 或者记录被丢了。幅度那两行只取前 5000 个点来算,是为了避开Math.min(...)在大数组上爆栈。

提示:不同分包版本里解析器的命名空间和导出名可能略有出入,sp.miniseed这类路径按你实际安装后的类型定义核对一遍,别照抄。

3.4 挂载组件与交互配置

组件挂载只有一行:把 Seismogram 数组赋给自定义元素的属性。配置项用对象的形式传进去,具体字段名各版本之间有差异,下面的字段语义是稳定的,名字按你装的版本核对。

const waveEl = document.querySelector('#wave'); const statusEl = document.querySelector('#status'); document.querySelector('#load').addEventListener('click', async () => { const cha = document.querySelector('#chan').value; statusEl.textContent = '加载中…'; try { const seis = await loadSeismogram({ net: 'XX', sta: 'AAA', loc: '00', cha, start: '2024-01-01T00:00:00', end: '2024-01-01T00:10:00', }); // 传给组件的是一个数组,多道叠加就多放几个 Seismogram waveEl.seismograms = [seis]; statusEl.textContent = `已加载 ${seis.y.length} 点`; } catch (err) { statusEl.textContent = `失败:${err.message}`; console.error(err); } });

配置对象里值得关注的几组:

配置语义常见字段形态说明
去均值布尔开关每道减去自身均值,纵轴才能以零为中心
去趋势布尔开关去掉仪器漂移的线性项,长窗数据几乎必开
带通滤波开关 + 低频截止 + 高频截止高频截止不要超过采样率的 0.4 倍
抽稀开关 + 目标像素数点数远超容器宽度时必开
时间轴格式格式化函数或精度选项短窗用“分:秒”,长窗用“时:分”
道间距数值多道叠加时控制垂直间距,太挤会糊成一团

组件自带鼠标拖拽框选时间窗、双击复位这类交互,一般不需要自己写。真正需要自己接的是外部联动:比如上面有个时间滑块,滑块一动就改组件的时间窗,这时候改的是配置里的时间范围,不是重建整个组件——重建会丢掉缩放状态,用户会骂人。

4. 预处理参数怎么定:顺序、截止频率、量纲

4.1 四步的顺序不能乱

一段原始波形到能看的图,中间通常四步:去均值 → 去趋势 → 尖灭(taper)→ 滤波。这四步的顺序是有物理依据的,不是随便排的。

去均值必须最先做。整型 counts 往往带几千的直流偏置,如果先滤波,这个偏置会被滤波器当成一个巨大的零频分量处理,输出直接失真。

去趋势紧随其后。仪器温漂、长周期倾斜表现为一个缓慢的线性项,不去掉的话,带通滤波的低频端会把它切成一段虚假的长周期波动,看起来像“地震信号”,其实是仪器在漂。

尖灭必须在滤波之前。所有基于频域实现的滤波器都隐含一个假设:输入序列是周期性的。你的序列首尾值如果不相等,这个跳变在频域里就是一片宽带能量,滤完之后两端会出现幅度远超真实信号的摆动,俗称振铃。尖灭的做法是把首尾各一小段(通常 5%)乘上余弦窗,让端点平滑归零。

滤波放最后,而且滤波后要裁掉两端各一个过渡带长度,因为那段本来就不干净。

顺序写反的典型症状:先滤波后去均值,波形整体上下平移;先滤波后尖灭,两端出现假的脉冲;不做尖灭直接滤波,两端发疯。这三种我都在别人的代码里见过。

4.2 带通滤波的四个参数怎么定

带通滤波实际只有四个东西要定:低截止、高截止、阶数、实现方式。

低截止取决于你想看什么周期的信号。看地方震(震中距几十公里,主频几赫兹)用 1Hz 高通就够;看远震面波(周期 20 秒以上)得低到 0.01Hz;做背景噪声互相关,通常卡在 0.05–0.3Hz 这个区间。低截止定得比信号主频还高,你会把信号本身滤掉,图上看不出任何东西,然后开始怀疑数据。

高截止受奈奎斯特限制。采样率 100Hz,奈奎斯特 50Hz,高截止最多取到 20Hz 左右,留出过渡带。取到 45Hz 看似“尽量保留信息”,实际上滤波器在接近奈奎斯特的地方相位响应极差,输出是一堆相位失真的毛刺。经验值是高截止不超过采样率的 0.4 倍,保守一点 0.3 倍更稳。

阶数决定滚降陡不陡。二阶(一个二阶节)通常够用,四阶开始相位失真明显,做波形形态分析(比如量振幅、看到时)的时候不建议超过四阶。要做零相位滤波就得正反各滤一次,等效阶数翻倍,这一点在比对振幅时容易忘记。

参数典型取值定错的后果
低截止0.01 / 0.05 / 1 Hz(按目标周期)取高了信号被滤没,图上只剩噪声底
高截止≤ 0.4 × 采样率接近奈奎斯特时相位失真、毛刺
阶数2 阶(形态分析)/ 4 阶(去噪优先)太高导致振铃、到时偏移
尖灭长度5% 序列长度太短压不住端点,太长吃掉有效信号
// 用配置对象驱动,避免自己手写滤波链 const cfg = new sp.seismographconfig.SeismographConfig(); cfg.doRMean = true; // 去均值 cfg.doRTrend = true; // 去趋势 cfg.doBandpassFilter = true; cfg.bandpassLowCutoff = 0.05; // Hz cfg.bandpassHighCutoff = 8.0; // Hz,采样率 40Hz 时留足过渡带 cfg.doMinMax = true; // 按像素列抽稀 waveEl.seismographConfig = cfg; waveEl.seismograms = [seis];

这段代码改的是显示配置,改完要重新赋值seismograms才会触发重绘。字段名以你安装版本的配置类定义为准,语义就是上表那几项。

4.3 从 counts 到物理量:响应校正那一步

不做响应校正,纵轴是 counts;做了,纵轴才是 m/s 或 m/s²。差别在报告里是能不能用的区别。

最省事的做法是只除仪器灵敏度:拿 StationXML 里的 InstrumentSensitivity 值(单位 counts/(m/s)),把波形除一遍。误差在通带内通常在几个百分点,做形态展示够了。

严格的做法是频域反卷积:把整条响应(每个 Stage 的增益、零点极点、单位)在频域展开,逐频点相除。这里有个必须处理的坑——响应在低频和高频端趋近于零,直接相除等于把这些频段的噪声放大几十倍,输出会变成一堆低频巨幅漂移。所以反卷积必须带水位(water level)限制和频率预滤波,把响应幅值低于峰值的某个比例(常用 0.1 或 0.05)的频段直接置零。这不是可选优化,是必做步骤。

单位换算上还有个小陷阱:StationXML 里不同通道的单位不统一,有的写M/S,有的写M/S**2,有的干脆写COUNTS。写死除以灵敏度而不读单位,横向对比两个通道的振幅时会得出完全错误的结论。

4.4 多道叠加前先统一采样率

三通道一起画,最常见的错是直接把三个 Seismogram 塞进数组。BHZ 是 40Hz,HHZ 是 100Hz,点数差 2.5 倍,组件按时间轴对齐后你会发现三道在时间上越走越偏,或者某一道被拉长。

正确做法是先重采样到同一采样率,再叠加。重采样方向选“降”不选“升”:都降到最低的那个采样率,避免插值引入虚假高频。降采样前必须先做抗混叠低通,截止频率设为目标采样率的 0.4 倍,否则高于新奈奎斯特的频段会折叠回来,变成一堆看起来像真实信号的低频波动。这个混叠效应在波形上极难识别,一旦写进结论就是错的。

另外注意起始时间对齐。三道数据即使同一时间窗请求,实际返回的起始采样点可能差半个采样周期,叠加时会有细微错位。做三分量粒子运动轨迹或者偏振分析时,这点错位会直接毁掉结果,叠之前把起始时间统一到较晚的那一个。

5. 避坑与排查:把波形画成直线或者画崩页面的五种情况

5.1 现象:图出来了,但只有一条水平直线

先别怀疑解析器,按顺序查三件事。

第一,看y的 length。如果是 0,mergeDataRecords返回了个空对象,组件画出来的自然是一条贴着零轴的线。空的原因多半在请求层,见 5.4。

第二,看y的均值。整型 counts 带几千的直流偏置很常见,纵轴自适应到 [-500, 5000] 之后,幅度几十的真实信号被压成一条线。解决是打开去均值,或者手动减一遍。

第三,看纵轴的取值范围有没有被手动写死。有些配置里能指定纵轴上下限,之前有人调试时写死了 ±1000,换成另一批数据后振幅是 ±20000,全被裁到边界,看起来就是两条平行直线。

还有一种更隐蔽的:数据本身是好的,但你画的通道是垂直分量,而那个时段只有背景噪声。换成水平分量或者换个时间段对照一下,别在噪声上死磕。

5.2 现象:波形中间断开,或者请求 10 分钟只画出 3 分钟

现象是时间轴上出现一段空白,或者整条波形只覆盖了请求窗口的一小部分。

原因分两类。一类是真实的记录中断——台站断记、传输丢包,这段时间本来就没数据,画成空白是正确行为,不该“修复”。另一类是拼装错误:miniSEED 记录是定长的,一个记录装不下就分成多条,如果解析后按原始顺序直接拼接而不按起始时间排序,遇到记录乱序(网络传输常见)就会把时间序列拼成锯齿状。

排查方法是打印每条记录的起始时间和结束时间,按时间排序后逐对比较:后一条的起始时间减去前一条的结束时间,超过半个采样周期就是 gap,负数就是重叠。

const recs = sp.miniseed.parseDataRecords(buf) .sort((a, b) => a.startTime.toDate().getTime() - b.startTime.toDate().getTime()); for (let i = 1; i < recs.length; i++) { const prevEnd = recs[i - 1].endTime.toDate().getTime(); const curStart = recs[i].startTime.toDate().getTime(); const gapMs = curStart - prevEnd; if (Math.abs(gapMs) > 1) { console.warn(`记录 ${i - 1}→${i} 间隔 ${gapMs} ms`); } }

重叠记录(gapMs 为负)不能简单丢弃后一条,因为重叠部分的数据可能来自不同的传输批次,数值未必一致。稳妥做法是保留时间戳更晚的那批,或者只取不重叠的尾段。这一步做不干净,后面滤波会在拼接点炸出一根尖刺。

5.3 现象:一开滤波,波形两端就开始大幅摆动

这是振铃,几乎每次都和尖灭有关。

第一种情况是完全没做尖灭。频域滤波隐含周期性假设,序列首尾值不等就产生宽带泄漏,滤完两端各出现一段指数衰减的振荡,幅度可能比真实信号还大。解决办法是滤波前做 5% 长度的余弦尖灭。

第二种情况是尖灭太短。序列很长但只尖灭了 20 个点,过渡太陡,等效于又引入了一次跳变。尖灭长度跟序列长度走,不是固定点数。

第三种情况是阶数太高配零相位滤波。正反各滤一次等效阶数翻倍,四阶变八阶,群延迟虽然抵消了,但两端瞬态被放大。做形态分析时把阶数降回二阶,振铃基本消失。

还有一种容易被误判成振铃的现象:滤波后整条波形缓慢上下漂。这不是振铃,是低截止设得太低、把去趋势没去干净的残余长周期项放进来了。把低截止往上提一档再看。

5.4 现象:明明有数据,接口却返回空

按出现频率排,四个原因。

位置码。请求里写loc=00,但那个台站的通道位置码其实是空的,服务端严格匹配就返回空。反过来也成立。排查办法是先把位置码留空试一次,能拿到数据再逐字符对。

通道码大小写。bhz和BHZ在多数服务端是两个不同的东西,前者直接查不到。

时间格式。带了时区偏移、带了毫秒、用了本地时间没转 UTC,任何一种都可能让服务端认为这是个非法区间而返回空体。统一用YYYY-MM-DDTHH:MM:SS的 UTC 格式。

时间窗反向。starttime晚于endtime,有些服务端不报错,直接返回 0 字节。这类错误在拼接 URL 的时候特别容易出,因为模板字符串不会替你校验。

排查顺序建议是:先用最简单的四元组和时间窗在浏览器地址栏直接访问接口,看到字节流再回代码里对参数。跳过这一步直接调代码,你会在 JS 里绕很久。

5.5 现象:点几次缩放之后页面越来越卡

表现是刚开始很流畅,反复缩放、切换通道十几次之后,滚动开始掉帧,最后无响应。

根因通常是重绘时没有清理旧节点。每次更新seismograms都往容器里追加一棵新的 SVG 子树,旧的还挂在 DOM 上,只是被盖住了。十次之后页面上有十棵完整的波形树,浏览器的布局计算量线性增长。

处理办法是在重新赋值前把容器清空,或者确认组件本身是否已经做了替换而不是追加。判断方法很简单:打开开发者工具看元素面板,数一下<svg>的个数,正常应该是一个。

第二个原因是没做抽稀。一份 10 分钟、100Hz 的数据是 6 万个点,三通道就是 18 万个坐标对,直接写进d属性,一次重绘要几百毫秒。按像素列做 min/max 抽稀之后,点数降到容器宽度量级,重绘就是几毫秒的事。这个优化在调试阶段容易被忽略,因为短时间窗下看不出问题,一换到长窗就原形毕露。

6. 进阶:把波形图变成能交付的图件

6.1 导出矢量文件而不是截图

组件画的是 SVG,那就别用截图工具截。直接序列化 DOM 节点,补上命名空间和显式宽高,存成.svg文件,丢进文档里放大不糊,后期还能改颜色。

function exportSvg(el, filename = 'waveform.svg') { const svg = el.querySelector('svg'); if (!svg) throw new Error('容器里没有 svg,先确认组件已渲染'); const clone = svg.cloneNode(true); clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); // 序列化时样式表不会跟过去,把关键样式内联 clone.setAttribute('width', svg.clientWidth); clone.setAttribute('height', svg.clientHeight); clone.style.background = '#fff'; const text = new XMLSerializer().serializeToString(clone); const blob = new Blob([text], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }

关键在cloneNode之后补xmlns和width/height——不补命名空间,很多看图软件打开是空白;不补宽高,某些渲染器会按默认 300×150 裁切。样式表不会跟着序列化走,所以颜色、字号这些要么内联到元素上,要么在 clone 里手动加一段<style>。

6.2 用固定数据做渲染回归

波形渲染最怕的是“改了一行配置,图看起来差不多但其实是错的”。稳妥做法是准备一份固定的迷你数据(几十条记录足够),跑一遍导出,把 SVG 里每条路径的d属性拼起来算个哈希,存成基准值。以后每次改代码都跑一遍比对,哈希变了就人工看一眼差异在哪。

比哈希更细的做法是导出 PNG 做逐像素比对,但要设一个容差,因为不同浏览器的抗锯齿实现不一样,逐像素严格相等会一直误报。阈值一般设在千分之几的像素差异比例,超过就拦下来。

这套回归不解决“图画得对不对”,只解决“图有没有悄悄变”。两者是两件事,别混。

6.3 批量出图时先量一下单张耗时

需要一次出几十张图的时候,别在同一个页面上循环渲染再逐个导出。正确姿势是开一个隐藏容器,渲染一张、导出、清空、再渲染下一张,中间用requestAnimationFrame让出主线程。连续同步渲染会阻塞 UI,看起来像卡死。

出图前先量单张耗时:渲染开始打时间戳,导出完成再打一个,跑五张取平均。超过 300ms 就要考虑减少抽稀目标点数或者缩短时间窗,因为批量场景下总耗时是线性叠加的。

还有个小细节:批量导出时文件名要带四元组和时间窗,别用递增序号。事后要找某一张图,序号毫无意义。

6.4 一点私人教训

我最早把这套流程接进一个内部看板时,图一直画不对,折腾了大半天,最后发现问题在容器高度——组件挂在一个height: auto的 div 里,渲染出来高度是 0,看起来就像“数据没加载”。从那以后我每次接新的绘图组件,第一件事都是给容器写死一个高度,第二件事是把y.length打到页面上,两个数字对了再谈波形形状对不对。这个习惯帮我省掉了很多次在解析器里瞎找 bug 的时间。

希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询