简介: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字节是固定头,记录台站、通道、起始时间、采样率、采样点数等关键信息。解析器第一步就是按偏移量读取这些字段。固定头布局可以整理成一张参数表:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 2 | 记录序号 | 大端无符号16位 |
| 3 | 5 | 台站代码 | ASCII字符串 |
| 8 | 3 | 通道代码 | ASCII字符串 |
| 20 | 10 | 起始时间 | B.Time格式 |
| 42 | 2 | 采样点数 | 无符号16位 |
| 44 | 2 | 采样率因子 | 需配合乘数因子推导 |
| 47 | 1 | 编码格式 | 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 src="dist/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方式分别渲染,叠加比对坐标,再核对初动时刻是否一致。从那以后我每次在页面里改动绘制逻辑,都强制把比对流程走一遍,确认振幅和时间轴没有偏移后才合并。希望帮到你。
本文还有配套的精品资源,点击获取