用 JavaScript 解析 miniSEED 地震波形:seisplotjs 前端可视化实践 简介seisplotjs 是一套面向地震数据开发者的 JavaScript 工具库核心功能包括地震波形解析、数据清洗与可视化绘图通过 fdsndataselect、fdsnevent、fdsnstation 等子模块可分别向 FDSN 服务请求波形、地震目录与台站信息配合 OregonDSP 完成离散傅里叶变换再用 d3 将频谱或波形渲染成图。对于需要快速搭建地震数据展示页、教学示例或科研原型的前端工程师和地球科学相关技术人员这套库能把数据获取、计算和绘图链路串在一起。压缩包共 184 个文件约 1.02MB其中 83 个 js 文件构成各功能模块60 个 html 文件提供可运行示例14 个 css 负责页面样式另有一些 sac、mseed 测试数据和 Python 脚本辅助验证。目录按子模块拆分适合按需查阅和二次开发。已有 320 人学习下载借助内置示例可以较快掌握从 FDSN 查询、数据解析到 FFT 与绘图的完整流程。1. 用JavaScript在浏览器里解析地震波形seisplotjs能做什么前端要处理地震数据多数人的第一反应是后端用Python解析好再丢给前端画折线图。但有了seisplotjs这个JavaScript模块解析miniSEED二进制、做滤波、绘制波形都可以直接在浏览器里完成。某台网中心需要一个波形预览页后端放了一批按小时切割的miniSEED文件前端希望选中时间段就能看台站波形还能拖动缩放、标记震相。seisplotjs就是围绕这条链路设计的解析层负责从字节流里读出台站、时间与采样值处理层负责去均值、去趋势和带通滤波绘制层用SVG把波形呈现出来。三件事串成一条从文件到可视化的完整前端管道。适合做台网工具、地震速报或波形数据可视化平台的开发者。2. 模块架构拆解解析、处理、绘制三层如何协同2.1 三层模块划分与数据流整体设计不是一个大类包揽全部功能而是按数据结构拆成三层。第一层负责解析输入是ArrayBuffer字节流输出是内部统一的数据对象第二层负责处理输入输出都是同一种波形对象做去均值、去趋势、滤波等变换第三层负责可视化接受处理后的波形对象生成SVG节点附带时间轴和振幅轴。每一层只依赖上层传下来的数据结构不反向耦合。// 伪代码示意数据管道 const rawBytes await fetch(url).then(r r.arrayBuffer()); const seismograms parseMiniseed(rawBytes); // 字节 - 对象数组 const seg seismograms[0]; const cleaned seg.removeMean().removeTrend(); // 预处理 const filtered cleaned.filterBandpass({ cornerLow: 1, cornerHigh: 15 }); plotWaveform(#app, filtered); // 对象 - SVG每一层只依赖上层传下来的数据结构不反向耦合。解析层不需要关心滤波参数怎么传绘制层也不关心二进制字节偏移位。这样设计的直接好处是如果哪天要把SVG渲染换成Canvas只要保持波形对象这个接口不变绘制层整体重写就行。实际项目里我见过不少把二进制解析和UI绘制耦合在一起的写法改一个字节偏移就要连带改DOM操作排查起来很痛苦。这里的cornerLow和cornerHigh是带通滤波的截止频率单位是Hz。短周期地震仪的数据一般取1到15Hz作为分析区间如果做长周期面波分析可以降到0.01Hz量级。数值需要根据台站仪器响应和你要分析的震相来调整不能一概而论。2.2 目录结构与核心类展开源码目录典型的组织方式是解析、处理、绘制三个包加上一个核心包seisplotjs/ src/ parse/ miniseed.js // miniSEED帧解析 seedlink.js // SeedLink实时流协议 time.js // 时间转换工具 processing/ filter.js // 巴特沃斯滤波 detrend.js // 去均值、去趋势 plot/ waveform.js // 波形SVG生成 axis.js // 坐标轴与缩放 core/ seismogram.js // 核心数据类 package.jsonSeismogram这个类就是数据管道的中枢。它内部保存一维采样数组、采样率、起始时间、台站名和通道名。所有解析模块最终都返回这个类的实例所有处理函数也都接收这个类的实例。它本质上是数据元数据的封装而不是把时间戳放进数组的每个元素里这在内存上比较省。有个容易被忽略的点core层里还有几个小的辅助类负责处理时间区间和台站位置这类元信息。解析SeedLink实时流时时间不是一次性给全的而是按包到达顺序追加。如果Seismogram设计成不可变对象每次追加采样都会整体复制性能反而更差。所以源码里的做法是解析阶段允许追加采样处理阶段才转成不可变视图。2.3 时间体系二进制头里最容易出错的部分地震数据的时间戳比普通业务开发遇到的要麻烦。miniSEED固定头里存的是B.Time格式年、月、日、时、分、秒秒还带小数部分用微秒精度存储。解析时要先把它换算成统一的时间基准一般转成自1970年起的毫秒数或微秒数后续滤波、绘图的时间轴才有一致性。常见的转换逻辑如下function parseBTime(dv, offset) { // 从DataView的offset位置读取10字节的B.Time格式 const year dv.getUint16(offset, false); // 2字节年份 const dayOfYear dv.getUint16(offset 2, false); // 年积日 const hour dv.getUint8(offset 4); const minute dv.getUint8(offset 5); const second dv.getUint8(offset 6); const microsec dv.getUint32(offset 6, false) 0xFFFFFF; // 按年积日推算月份与日期 const date new Date(Date.UTC(year, 0, 0)); date.setUTCDate(date.getUTCDate() dayOfYear); date.setUTCHours(hour, minute, second); return date.getTime() Math.floor(microsec / 1000); }有几个必须强调的细节年份和年积日是大端序的16位整数读取时不能忘了第二个参数传false否则整数会反过来时间直接错位。小时、分、秒是单字节直接取值。最后6字节的低24位是微秒所以用 0xFFFFFF遮罩。微秒转毫秒时直接整除1000有余数就丢弃这对波形展示精度没有影响但做震相标注时应该保留微秒字段我后面绘图时会用原始微秒值校正刻度。new Date(Date.UTC(year, 0, 0))的含义是取上一年的最后一天然后加上年积日天数得到当前日期用setUTCDate保证不经过本地时区避免不同开发机时区设置导致结果偏移。团队里如果有人把这段的UTC换成本地时间波形就会整体漂移这个坑在避坑章节会再展开。3. 核心模块走读一条记录从字节流到波形的完整链路3.1 解析miniSEED固定头先读头再读采样miniSEED是目前最通用的地震波形交换格式文件由若干固定长度的帧组成每帧前48字节是固定头记录台站、通道、起始时间、采样率、采样点数等关键信息。解析器第一步就是按偏移量读取这些字段。固定头布局可以整理成一张参数表偏移长度字段说明02记录序号大端无符号16位35台站代码ASCII字符串83通道代码ASCII字符串2010起始时间B.Time格式422采样点数无符号16位442采样率因子需配合乘数因子推导471编码格式3/4/10等对应代码function parseFixedHeader(dv) { // miniSEED固定头固定48字节按偏移量读取 const h {}; h.sequenceNumber dv.getUint16(0, false); // 记录序号 h.station textDecoder(dv, 3, 5); // 台站代码 h.channel textDecoder(dv, 8, 3); // 通道代码 h.startTime parseBTime(dv, 20); // 起始时间 h.sampleCount dv.getUint16(42, false); // 采样点数 h.sampleRate parseSampleRate(dv); // 采样率派生 h.encodingFormat dv.getUint8(47); // 数据编码格式 return h; }采样率这一项值得单独解释。固定头第44字节是采样率因子第45字节是乘数因子实际采样率由这两个值按公式推导不是直接读一个整数。当因子为负数时含义是每个采样点的秒数的倒数常见的处理是取绝对值后求倒数。如果台站数据质量差这个字段可能为0后续章节会专门讲这个坑。textDecoder是对TextDecoder的一个简单封装用于把字节序列转成ASCII字符串。台站代码和通道代码宽度是固定的用TextDecoder解码后记得trim()去空格因为写入时可能用空格补齐了5字节或3字节。3.2 采样值的多种编码与解压分支miniSEED的采样值不是裸的整数序列而是按编码格式压缩存储的。固定头最后一个字节标识压缩格式常见的有16位整数、32位整数、STEIM2压缩。解压逻辑必须按照编码格式走不同分支解析器一般维护一张格式对应表读头时确定格式读体时分发给对应解压函数。function decodeSamples(dv, header) { switch (header.encodingFormat) { case 3: // 16位整数 return decodeInt16(dv, header); case 4: // 32位整数 return decodeInt32(dv, header); case 10: // STEIM2压缩 return decodeSteim2(dv, header); default: throw new Error(Unsupported format: ${header.encodingFormat}); } }遇到不认识的编码格式最好直接抛异常而不是按默认方式硬解。我刚开始接触这类数据时没检查编码格式就把STEIM2的压缩字节直接当普通整数读画出来的波形像锯齿噪声一度怀疑数据坏了后来对照原始记录才发现是解码分支走错了。解析器的异常处理做到遇到未知格式明确报错比吞掉异常然后画出错误波形要友好得多因为后者会让你把无辜的数据当成问题。顺手再提一句参数解码函数里的header.sampleCount决定要解出多少个采样值解压循环要严格按这个数截断多了少了都会影响后续滤波的采样率假设。3.3 去均值与去趋势处理层的两个基础操作拿到采样数组后画图之前要先做预处理。最小处理是去均值和去趋势。去均值就是去掉直流偏移去趋势是拟合一条直线再减掉消除仪器响应造成的基线漂移。实现不复杂关键在于运算后的数组长度不变、时间基准不变。function removeTrend(samples) { const n samples.length; if (n 2) return samples.slice(); // 最小二乘拟合直线 let sumX 0, sumY 0, sumXY 0, sumXX 0; for (let i 0; i n; i) { sumX i; sumY samples[i]; sumXY i * samples[i]; sumXX i * i; } const slope (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX); const intercept (sumY - slope * sumX) / n; return samples.map((y, i) y - (slope * i intercept)); }这里用map生成新数组避免修改原始数据。处理层遵循不可变优先原则想保留原始波形做对比时原始采样数组还在。对于几百万个采样点sumX * sumY这类累加可能出现较大数值JavaScript的Number类型能覆盖到常见的整数范围但要注意滤波后数据已经变成浮点累加时误差会慢慢放大。实际我一般会先用一次遍历计算均值并做一次预减法把数值范围缩小再做直线拟合能明显改善数值稳定性。滤波函数也是同样的思路内部做的是前向加反向的零相位滤波避免只做前向滤波带来的相位延迟。地震波形分析很看重到时准确性相位延迟会直接导致震相读取偏差零相位处理几乎是必需的。3.4 波形绘制从采样数组到SVG路径绘制层拿到处理后的波形对象要做两件事把时间序列映射到SVG坐标系的x轴把振幅映射到y轴然后生成折线路径。这里不需要在每个采样点建独立DOM节点而是把所有点合成一条polyline的points属性几千个点也只占用一个节点。function plotWaveform(container, seg, width, height) { const { samples, startTime, sampleRate } seg; const delta 1000 / sampleRate; // 每个采样点间隔单位毫秒 const yMax maxAbs(samples); // 遍历求最大绝对值 const step Math.max(1, Math.floor(samples.length / 2000)); const points []; for (let i 0; i samples.length; i step) { const x (i * delta / 1000) / ((samples.length - 1) * delta / 1000) * width; const y height / 2 - (samples[i] / yMax) * (height / 2 - 8); points.push(${x.toFixed(2)},${y.toFixed(2)}); } const svg document.createElementNS(http://www.w3.org/2000/svg, svg); svg.setAttribute(width, width); svg.setAttribute(height, height); const poly document.createElementNS(http://www.w3.org/2000/svg, polyline); poly.setAttribute(points, points.join( )); poly.setAttribute(stroke, #1a5fb4); poly.setAttribute(fill, none); svg.appendChild(poly); container.appendChild(svg); }这里有两个容易踩坑的参数。step是抽稀步长SVG绘制在3000个点以内很流畅超过2万点会明显掉帧抽稀到2000个点以内能保住交互流畅度。抽稀后的波形视觉上与原始波形基本一致对预览场景完全够用。yMax用最大绝对值做对称归一化既让波形充满绘制区域又不会因为个别尖峰毛刺把正常振幅压扁。要注意不能直接用Math.max(...samples)这种展开写法几百万个采样点会导致调用栈超限应该用一次遍历累积最大值或者用reduce。4. 跑起来Node脚本批处理与浏览器渲染两条路径4.1 构建与依赖seisplotjs本身是纯ES6模块可以直接在Node.js环境运行浏览器端需要经过打包器转换。安装依赖后核心的解析、滤波代码都通过import引入。浏览器端打包配置做一个最小可用的// bundler.config.js module.exports { entry: ./src/index.js, output: { filename: seisplotjs-bundle.js, library: Seisplotjs // 挂到window.Seisplotjs上 }, mode: development, devtool: source-map };library参数很关键决定浏览器里怎么访问这套模块。设为Seisplotjs后页面上window.Seisplotjs就是模块的命名空间方便在非框架页面里直接用script标签引入。如果页面本身是基于现代框架的工程把模块源码直接放进工程的src目录按ES6方式import就好不需要打全局包。模块本身不依赖大型运行时打包后体积主要来自滤波算法和SVG绘制工具这两部分。做生产构建时建议开启压缩并且把source-map关掉能明显减小体积。真遇到体积敏感的页面可以按需只引入parse或plot的入口文件而不是整个命名空间。4.2 Node脚本批量解析一份台站数据我习惯在开发阶段先用Node脚本验证解析结果再上页面。下面这段脚本读一个miniSEED文件解析后打印台站和通道信息做一轮预处理import fs from node:fs; import { parseMiniseed, Seismogram } from seisplotjs; const fileData fs.readFileSync(station.mseed); const segs parseMiniseed(fileData); // 返回Seismogram数组 for (const seg of segs) { const cleaned seg.removeMean().removeTrend(); const filtered cleaned.filterBandpass({ cornerLow: 1, cornerHigh: 15 }); console.log({ station: cleaned.station, channel: cleaned.channel, startTime: new Date(cleaned.startTime).toISOString(), sampleCount: cleaned.samples.length, rms: rms(filtered.samples) // 滤波后的均方根值 }); }脚本输出可以用来比对各台站间的通断状态。比如某台站波形幅值长期偏低滤波后的RMS值掉到正常水平的一成以下基本可以推测仪器灵敏度出了问题。这个场景里filterBandpass返回的是新对象原数据不受影响RMS计算使用滤波后的数据不会统计到仪器本身的直流漂移。这段脚本需要在Node环境以ESM方式运行。如果工程package.json里的type不是module就把它改成module或者把脚本后缀改成.mjs否则会报Cannot use import statement outside a module。这是起步阶段最常见的报错先确认这个再排查其他问题。4.3 浏览器渲染从二进制到页面波形浏览器端路径比Node多一步数据要能通过fetch取回来而且需要ArrayBuffer类型才能交给解析器。下面是完整的最小页面逻辑script srcdist/seisplotjs-bundle.js/script script async function loadWaveform(url, containerId) { const res await fetch(url); const buf await res.arrayBuffer(); const segs Seisplotjs.parseMiniseed(buf); if (!segs.length) { throw new Error(no seismogram parsed); } const cleaned segs[0].removeMean().removeTrend(); const filtered cleaned.filterBandpass({ cornerLow: 1, cornerHigh: 15 }); Seisplotjs.plotWaveform(document.getElementById(containerId), filtered); } loadWaveform(/data/station.mseed, waveform); /script执行这个页面之前要确认静态服务器上station.mseed能被正常访问。arrayBuffer()解析不受Content-Type影响真正会翻车的是跨域数据文件放在另一台数据服务器上而页面和接口不同源浏览器会直接拦截响应。开发阶段我在打包器配置里加代理把/data路径转发到数据服务器生产环境则要求网关对数据服务加允许跨域的响应头。还有一个经验fetch大数据文件时最好带上超时控制和错误提示台网数据服务偶尔会慢用户看到空白页面第一反应是代码坏了。我的做法是在loadWaveform外层包一层超时包装器超时后显示明确的加载失败信息而不是让请求挂在那里。5. 避坑记录五个解码细节踩过才知道不是玄学5.1 波形整体偏移时区基准不统一现象台站波形在页面上整体向右偏移了8个小时但幅值形状完全正常。原因miniSEED固定头里的时间信息设计上使用UTC但部分台站数据在生成环节写成了本地时间。解析器不知情统一按UTC解释导致实际时间比真实时间晚了8小时。注意看偏移量整整8小时基本是时区问题如果偏移是分钟级的可能是B.Time解析的某一位读错了。解决在解析层加一个可配置的时区偏移参数遇到已知存在时区问题的台站读取头时手动加偏移再做时间轴映射。排查时先打印一条记录的解析时间和原始日志时间做对比确认偏移量是整小时还是带分钟避免盲目加偏移。我的习惯是把这个参数放进台站配置表而不是写死进代码因为每个台站的数据来源可能不一样。5.2 采样率字段为0时间轴直接消失现象绘制时x轴坐标为NaN波形在SVG里看不见控制台报Infinity。原因低质量台站数据在写入文件时采样率字段缺失按公式推导出采样率为0计算采样点间隔时出现除零错误。解决解析器在采样率为0时不做默认兜底而是把sampleRate标记为空值并在数据对象上暴露一个timeRange接口允许外部显式传入时间区间。绘图时如果没有采样率就按外部传入时间区间均匀分布采样点。这个设计比强行假设一个采样率更安全猜错采样率会让时间轴失真比不画更麻烦。5.3 大文件把页面卡死采样点数量和数据量不成正比现象点击加载一个整月的波形文件页面假死数秒随后内存暴涨。原因一次性把所有帧读进内存生成大量波形对象SVG节点数量随采样点线性增长。这里有两重压力一是内存翻倍二进制缓冲区、解码后的数字数组、SVG节点各存一份二是主线程阻塞解析和绘制都在主线程执行。解决按时间窗分段解析。页面层先展示可用时间段列表用户选择时间窗后用fetch的范围请求只取对应记录解析后抽稀到2000点以内再绘制。对于必须全量处理的场景把解析放进web worker避免阻塞主线程渲染。我的验证方法是打开任务管理器观察内存曲线如果加载一份数据内存涨了超过原始文件的10倍就该考虑分段方案了。5.4 STEIM2压缩数据画出来是乱码现象波形看起来像高频毛刺不是正常地震信号幅值还特别大。原因固定头编码格式为STEIM2时数据是压缩存储的直接按16位整数读取得到的是压缩码流不是真实振幅值。字节流的含义完全变了画出来自然是乱码。解决解析前必须检查编码格式。完整做法是维护编码格式分支表对STEIM2先按标准解压算法还原成整数序列再进入后续处理。排查时的口诀是先看固定头最后一个字节再决定怎么读体。乱码波形大概率是这一步漏了不是数据本身有问题。我在代码里遇到未知编码格式会直接抛异常宁可停住也不画出错误波形。5.5 跨域请求被拦浏览器安全策略现象页面能正常打开fetch请求在Network面板里显示红色Console报跨域错误。原因数据文件所在的服务和页面不同源且没有返回允许跨域的响应头。解决开发阶段用打包器的代理选项把/data代理到数据服务写进配置文件后团队其他人也能一键起服务。这个方案能快速验证解析和绘制逻辑不依赖后端配合。生产环境要求网关或数据服务统一加Access-Control-Allow-Origin响应头并按需配置允许的请求方法。临时调试时可以本地起一个静态服务器让页面和数据保持同源先跑通解析流程再处理跨域。6. 二次开发思路把它接进你的台站波形监控页把seisplotjs接进一个真实的监控页面我第一次的做法是直接调用plotWaveform完事结果台站一多SVG节点数量直线上升交互明显卡顿。后来我做了一次改造保留解析层和处理层不变只替换绘制层。核心思路是把SVG的polyline换成Canvas的路径绘制因为Canvas绘制的结果是画布上的像素不会产生成百上千的DOM节点。改造后的绘制函数大概是这样的function plotWaveformCanvas(canvas, seg, width, height) { const ctx canvas.getContext(2d); const { samples, sampleRate } seg; const delta 1000 / sampleRate; const yMax maxAbs(samples); const step Math.max(1, Math.floor(samples.length / width)); ctx.clearRect(0, 0, width, height); ctx.beginPath(); for (let i 0; i samples.length; i step) { const x (i * delta) / ((samples.length - 1) * delta) * width; const y height / 2 - (samples[i] / yMax) * (height / 2 - 8); i 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.stroke(); }这次替换让页面在同时渲染20个台站时依然能保持流畅交互。替换的关键是复用原有的坐标映射公式只改API不改计算逻辑这样滤波结果和到时位置不会因为渲染方案变化而偏移。如果你想加震相拾取功能也可以基于这套坐标映射反算鼠标位置对应的时间和振幅值不需要改动解析层。验证改造是否引入了偏差我保留了一份带人工标记的样本数据将某历史事件的标准波形文件解析后用SVG方式和Canvas方式分别渲染叠加比对坐标再核对初动时刻是否一致。从那以后我每次在页面里改动绘制逻辑都强制把比对流程走一遍确认振幅和时间轴没有偏移后才合并。希望帮到你。本文还有配套的精品资源点击获取