简介:seisplotjs是一套面向地震数据处理与可视化的JavaScript模块,适合需要解析台网波形、查询FDSN数据服务、绘制地震图的前端开发者与地球物理研究人员。压缩包内共184个文件,体积约1.02MB;其中83个js文件构成核心逻辑,60个html文件为可运行示例页面,14个css文件负责样式展示,另外还打包了sac、sacpz、mseed等真实测试数据,以及少量python脚本、json配置和说明文档,方便直接对照工程结构学习。资源学习热度为319人。模块设计按功能拆分:数据链路WebSocket协议、日期时间选择器、经纬度距离计算、FDSN可用性/注册表/事件/站点/波形数据查询,并借助OregonDSP完成FFT变换,再用d3绘制频谱与波形。通过阅读源码与示例,可以快速理解各子模块的调用接口,掌握从原始地震数据到交互式可视化图表的完整处理链路,适合在该领域做原型开发、教学演示或进一步扩展。
1. 浏览器里解析地震数据,为什么需要seisplotjs
第一次在浏览器里加载台站波形时,我把miniSEED文件当文本读出来,看到一堆二进制乱码,才意识到地震数据格式和JSON完全是两个世界。seisplotjs是专门解决这个问题的Javascript模块:它在浏览器端直接解析、处理并绘制地震数据,让前端不再依赖后端渲染波形图。它能让你把原始SEED/miniSEED数据变成可交互的波形、频谱和震相标记,适合做台网可视化、波形展示、地震科普工具甚至轻量级实时监控的开发者。接下来我会按真实项目里最常见的落地路径,把流程、参数和踩过的坑完整讲一遍。
2. seisplotjs的模块拆解与数据流:从miniSEED到屏幕像素
2.1 模块分成哪四块
seisplotjs不是“大而全”的绘图库,它更像一组面向地震学数据流的工具集。我一般把它拆成四块来看,这样能帮你判断它到底解决了哪些问题,哪些事还得自己写。
第一块是格式解析。地震学领域最常见的格式是miniSEED,它是二进制记录块,包含固定头、可变头、采样率编码和整型/浮点数据子格式。解析这类格式看起来不复杂,但处理时间重叠、记录块分包、Steim压缩算法时,细节多到让人抓狂。seisplotjs把这层封装好了,你不需要手写字节偏移。除了miniSEED,它还支持SAC、QuakeML事件标记和StationXML台站响应,常用的几种数据源基本覆盖全。
第二块是信号处理。去均值、去趋势、高低通滤波、旋转(ZNE转ZRT)这些操作,在原始数组上做并不难,但难的是处理边界条件:滤波后的瞬态响应、单位换算、时间对齐。seisplotjs把处理步骤封装成对trace对象的方法,链式调用很直观。
第三块是绘图。它基于浏览器canvas或SVG画波形,自带时间轴和震相标记的支持,也能处理交互缩放。不过要说明的是,seisplotjs的绘图组件在不同版本里差异较大,有的版本更像个底层解析器,绘图交给Chart.js或ECharts也行。我的经验是:如果项目只需要展示简单波形,用它自带的组件最快;如果需要复杂交互,可以只用它的解析和信号处理部分,绘图自研。
第四块是辅助查询。它提供了一些工具方法,帮你拼FDSN风格的事件服务、台站服务和波形服务URL,并把返回的XML/JSON解析成对象。这部分能省很多拼接字符串的功夫,也避免URL编码踩坑。
这种拆法还有个好处:你可以按需引入。用Vite或Webpack打包时,只把用到的那几个文件打进产物,不会让整个库的体积拖慢首页。
2.2 数据流:fetch-parse-filter-plot
无论你从本地拖入一个.mseed文件,还是从远端数据中心按时间窗拉取波形,seisplotjs遵循的都是一条清晰的链路。我通常把它表达成四个环节:
- 获取:通过HTTP拿到ArrayBuffer,或者前端上传文件得到ArrayBuffer;
- 解析:调用
Seismogram.parse把二进制变成有数学意义的对象; - 处理:对trace做去均值、滤波、旋转等操作;
- 绘制:把处理后的数据数组映射到canvas像素,并把时间轴、震相线覆盖在上面。
用一个极简伪代码表达:
// fetch:拿到二进制数据 const resp = await fetch('https://example.com/waveform.mseed'); const buffer = await resp.arrayBuffer(); // parse:解析成一个“地震图”对象 const seismogram = await Seismogram.parse(buffer); // 取其中一条连续trace处理 const trace = seismogram.traces[0].removeMean().filterHighpass(1); // plot:把处理后的数据交给绘图函数 drawWaveform(trace, canvas);这里最容易被忽略的是raw字节的获取方式。fetch出来后如果先调用了resp.text(),再想转回ArrayBuffer就会丢字节,必须直接arrayBuffer()。解析出的seismogram下会有一个traces数组,每条trace有data(Float32Array或Float64Array)、samplerate、startTime、endTime这几个关键属性。后续所有处理都是围绕trace展开。
调用Seismogram.parse时要记住两个惯例:第一,解析是异步的,多数版本返回Promise,别把它当成同步函数;第二,如果文件里包含多个台站、多个通道的数据,traces会拆成多条,那就要通过元数据里的“台网-台站-通道”去筛出你关心的那条trace。
2.3 为什么把处理放在浏览器而不是后端
早期很多地震可视化系统是后端先用Python或Java把波形渲染成静态PNG,再推给前端。这样架构简单,但交互弱:用户想放大一段,得重新请求一张图;想看滤波前后的对比,得等后端异步任务跑完。把解析和处理搬到浏览器后,这种问题消失了——波形数据全部留在本地,用户拖缩放条、调通带频率,秒级响应。
更关键的是物理量换算。台站原始记录是地面运动速度,但用户有时想要加速度或位移。频域积分/微分如果放在后端做,前端拿到的只是结果数组;而在浏览器端做,你可以随时重新积分、重新滤波,UI上多一个下拉框就能实现“速度/加速度/位移”切换。
但也要知道边界。浏览器内存不是无限的,把一段一个月的连续波形全拉到前端,不仅网络慢,内存也撑不住。所以用seisplotjs的项目通常会做分层拉取:先取低采样率的概览数据,用户放大到分钟级再取完整数据。这个策略后面的避坑章里我会专门讲。
3. 在项目里接入seisplotjs:最小运行示例
3.1 两种引入方式:npm与CDN
接入seisplotjs前要先确定项目的构建方式。当前前端项目大多基于Vite、Webpack或React脚手架,适合用npm安装并做模块化引入。在命令行里执行:
npm install seisplotjs然后在代码中按需导入:
import { Seismogram } from 'seisplotjs';这里要注意,不同版本的库导出结构不一样。有的版本导出Seismogram这个类,有的把解析函数挂在外层。最稳妥的做法是装完包后打开node_modules/seisplotjs下的package.json,看exports字段里有哪些模块入口,再确定你的导入路径。
如果只是写一个临时HTML页面做验证,不想搭构建工具,也可以走<script type="module">的方式从CDN加载。比如:
<script type="module"> import { Seismogram } from 'https://some-cdn.example/seisplotjs.esm.js'; // 之后按模块方式使用 </script>但我不建议生产环境这么干。CDN版本通常是一个整包ESM文件,体积大,也难以tree-shaking;一旦CDN地址变动或网络不稳定,整个页面就白屏。本地npm+打包器能按需引包,还有利于版本锁定。
3.2 连接台网数据服务并解析波形的最小代码
下面这段代码是我在真实项目里“从事件查询一路到波形解析”的骨架。它假设你已经有一个带事件目录和波形服务的上游接口,接口风格类似FDSN标准,但台网和台站请替换成你自己的配置。
// 1. 查询某个时间窗内的事件 const eventUrl = buildEventUrl({ startTime: '2024-01-01T00:00:00Z', endTime: '2024-01-02T00:00:00Z', minMag: 5 }); const eventXml = await (await fetch(eventUrl)).text(); const quake = parseQuakeMl(eventXml); const origin = quake.events[0].origins[0]; // 取第一个事件的第一个震源解 // 2. 查询台站通道信息 const stationUrl = buildStationUrl({ network: 'XX', station: 'TEST', channel: 'BHZ', level: 'channel' }); const stationXml = await (await fetch(stationUrl)).text(); const channel = parseStationXml(stationXml).channels[0]; // 3. 按事件时间和台站信息抓取miniSEED const waveformUrl = buildWaveformUrl({ network: 'XX', station: 'TEST', channel: 'BHZ', startTime: origin.time.toISOString(), endTime: new Date(origin.time.getTime() + 1800 * 1000).toISOString() }); const mseedBuffer = await (await fetch(waveformUrl)).arrayBuffer(); const seismogram = await Seismogram.parse(mseedBuffer); // 4. 取出我们要的那条trace const trace = seismogram.traces.find(t => t.channelCode === 'BHZ'); console.log(trace.samplerate, trace.startTime.toISOString(), trace.data.length);这段代码里的buildEventUrl、buildStationUrl、buildWaveformUrl、parseQuakeMl、parseStationXml这些函数,在seisplotjs里都有对应的工具,具体名字以你用的版本为准。它的价值在于把“拼URL”和“解析XML”这两步变得可靠,你不需要自己去手写查询字符串编码。
参数上要注意三点:
channel: 'BHZ'只取垂直向通道;如果写BH*会拿到BHZ、BHN、BHE三通道,流量翻三倍,但方便画三分量。level: 'channel'表示台站服务只返回通道级元数据,如果把级别调成station,XML里不含采样率信息,后续计算时间轴会困难。- 波形时间窗我截取了发震后30分钟,因为一次远震事件的主要震相通常在半小时内到达。如果你的目标是本地小震,时间窗可以缩到5分钟。
3.3 网络-台站-通道三件套怎么定
这个三件套看似简单,实际最容易被用户问懵。真实台网里,同一个台站可能有BHZ、HHZ、EHZ并存,它们的频带和采样率不同,展示出来的物理意义也不同。我自己的习惯是给下拉框里的通道做优先级排序:HHZ > BHZ > EHZ。现代宽频带台站通常用HH*(高频)或BH*(宽带中增益),老台站才有EHZ这种短周期通道。
当用户选了“网络-台站”之后,前端应该先请求一次台站服务,把可用通道列表拉下来再填进通道下拉框,而不是写死一个BHZ。不然碰到某个台站只有HHH分量,你画出来的图就是一条空线。如果通道列表里同时有BHZ和HHZ,要默认选采样率更高的那个,而不是第一个。因为地震预警里P波到时精度很大程度上由采样率决定,100Hz和40Hz在图上差不了太多,但自动读数时差异明显。
还有一点:不要忽略通道方向。BHZ是垂直向,BHN是南北向,BHE是东西向。如果你做旋转到ZRT坐标系,前端必须知道三个分量的方位角,这些信息在StationXML的azimuth字段里。所以台站解析不能只拿采样率,最好把azimuth和dip一并存起来。
4. 绘制与处理地震数据:从波形到滤波参数的完整链路
4.1 画一个能动的波形图
拿到trace后,最直接的做法是把它画到一个canvas上。seisplotjs如果自带绘图组件,我会优先用它,因为它已经处理好“时间轴刻度”和“极小值压成竖线”这些细节。但如果你只用了它的解析模块,想自己画波形,下面这段代码是一个可靠起点:
function drawTrace(canvas, trace) { const ctx = canvas.getContext('2d'); const W = canvas.width; const H = canvas.height; const data = trace.data; const n = data.length; // 计算振幅最大值,用于纵向缩放 let maxAbs = 0; for (let i = 0; i < n; i++) { const absVal = Math.abs(data[i]); if (absVal > maxAbs) maxAbs = absVal; } if (maxAbs === 0) maxAbs = 1; // 防止除零 ctx.clearRect(0, 0, W, H); ctx.beginPath(); const yMid = H / 2; const yScale = H * 0.8 / (2 * maxAbs); for (let i = 0; i < n; i++) { const x = (i / (n - 1)) * W; const y = yMid - data[i] * yScale; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.strokeStyle = '#333'; ctx.lineWidth = 1; ctx.stroke(); }这段代码最大的问题是当trace有几十万个点时,循环里执行大量lineTo,页面会卡顿。所以实际使用时,你要先判断数据点数,再决定是否降采样。常见做法是当点数超过canvas宽度像素数的两倍时,每Math.floor(n / (W * 2))个点取一个极值点(最大值和最小值分别采样),这样绘出来的波形包络视觉上几乎不失真。
另一个要注意的是trace.data里的单位。原始miniSEED通常以“counts”为单位,不同台站仪器的灵敏度不同,直接画出来的振幅没有物理意义。如果需要展示物理量,要把counts除以灵敏度因子,这一步在通道级元数据里可以拿到。
4.2 在图上标P波S波震相
波形图只有曲线还不够,专业用户要在图上看到P波、S波到时。地震学里P波、S波的理论到时通常由速度模型计算得到,前端拿到的是几个时间字符串。绘制时,只需要把“时间”换算成“像素x坐标”:
function timeToX(timeMs, trace, width) { const startMs = trace.startTime.getTime(); const endMs = trace.endTime.getTime(); return ((timeMs - startMs) / (endMs - startMs)) * width; } // 假设理论P波到时为pArrival,S波到时为sArrival ctx.strokeStyle = '#2196F3'; ctx.beginPath(); ctx.moveTo(timeToX(pArrival.getTime(), trace, W), 0); ctx.lineTo(timeToX(pArrival.getTime(), trace, W), H); ctx.stroke(); ctx.strokeStyle = '#F44336'; ctx.beginPath(); ctx.moveTo(timeToX(sArrival.getTime(), trace, W), 0); ctx.lineTo(timeToX(sArrival.getTime(), trace, W), H); ctx.stroke();这里有个容易忽略的细节:时间的时区。trace.startTime、理论到时都应当统一按UTC存储,如果直接调用getHours(),浏览器会返回本地时间,导致震相线整体偏移。我的习惯是全程用Date对象,只显式格式化为ISO字符串给用户看。
当UI要求用户能手动修正震相位置时,我一般会在canvas上做事件监听,记录鼠标按下到抬起后的横向偏移,然后用这个偏移量去调整arrial time并重新画线。这个交互看起来简单,但需要把“像素偏移”转换成“毫秒偏移”:deltaMs = deltaPx * (durationMs / width)。
4.3 滤波与去均值的参数怎么调
处理数据的目的是去掉噪声,突出感兴趣的频段。常见的处理链是:
let trace = rawTrace.removeMean(); trace = trace.removeTrend(); trace = trace.filterHighpass(0.5); trace = trace.filterLowpass(8);removeMean去掉的是信号里的直流分量,如果不做,后续低通滤波后曲线会整体抬高一个大台阶。removeTrend去掉的是基线漂移,通常由于仪器温度变化引起,线性回归后把趋势减掉。
滤波参数选取要看研究目标:
- 地方震(震中距几十公里)P波频率较高,常用1~10Hz带通;
- 远震(数千公里)体波主频在0.5~2Hz,用0.5~5Hz;
- 长周期面波研究用到0.01~0.1Hz,数据必须来自宽频带台站。
高通滤波的频率不能设得过高,否则会削掉P波低频成分,导致初至不清晰;低通滤波频率也不宜太低,否则会丢失高频震相信息。一个初步经验是目标频段带宽至少要有2倍倍频程,比如研究地方震用1Hz高通、8Hz低通,这是一个相对通用的组合。
滤波后还会出现一个“伪影”:头尾震荡。这来自IIR滤波器的瞬态响应。规避办法是滤波前给数据两端各外接一段长度为滤波器阶数倍的常数或零值,滤波后再裁掉。有些seisplotjs版本内部做了稳态处理,但如果你看到波形前100个点出现大幅摆动,就不要怀疑数据了,先想想是不是滤波前没做去均值。
4.4 长波形的降采样与性能优化
当一次画出多个通道的波形,性能挑战会迅速放大。我踩过一次:同时请求某台阵20个台站的三分量BHZ波形,每个通道约600秒100Hz采样,总共120万点,直接for循环绘制,浏览器卡了近3秒。
后来我改成三步:
- 先按通道对数据做“概览”索引。只取每个时间窗口(比如每秒)内的最大最小值,形成两个数组,绘制时用这两个数组画填充区域或竖线。
- 只有当用户放大到窗口内点数小于1万时,才重绘原始采样数据。
- 所有canvas重绘都放进
requestAnimationFrame,避免在连续的鼠标滚轮事件里重复绘制。
此外还可以把解析和滤波放到Web Worker里,避免阻塞UI。这个方案后面避坑章会给出代码。性能调优不是seisplotjs自带的能力,但它提供的trace对象足够简单,让你可以自由做优化。
5. seisplotjs避坑指南:5条来自真实项目的踩坑记录
5.1 时间轴不对:UTC与本地时区
现象:从接口返回的startTime写的是2024-01-01T10:00:00Z,但画图时坐标轴显示成了本地时间,波形位置和事件标签全偏了8小时。
原因:台网标准时间统一用UTC,但显示组件默认调用toLocaleString(),它会自动转成用户本地时区。如果前端组件里再次调用new Date(isoString)并格式化,就可能出现三重偏移。
解决:所有时间对象在内部存储和计算时都保持UTC,只在显示刻度时显式格式化。
// 错误示范:toLocaleString会自动转本地时区 ctx.fillText(time.toLocaleString(), x, y); // 正确做法:用UTC字符串,显示时再决定要不要加“Z” ctx.fillText(time.toISOString(), x, y);如果UI要显示“北京时间”,再手动加上8小时,不要依赖toLocaleString的默认行为。
5.2 采样率不一致导致波形错位
现象:三条通道的分量分别来自不同采样率,前端用同一个数组索引对齐,结果S波峰在一个图上位于25%位置,另一个图上跑到了75%位置。
原因:trace.data是离散采样点数组,数组索引对应时间必须乘以1 / samplerate。不同采样率的通道索引差值会随采样点累积,到1000个点时可能已经偏了2秒。
解决:绘制和计算时以“绝对时间”为基准,把数组索引转成时间戳:
function sampleToTime(trace, index) { return trace.startTime.getTime() + (index / trace.samplerate) * 1000; }所有对比、震相拾取都基于这个函数,不要直接用trace.data[i]和trace.data[j]做算术。
5.3 滤波后数据头尾出现尖峰
现象:对一段噪声信号做高通滤波,滤波结果的前200个点和尾部都出现了大幅摆动,看起来很“震”,但这在原始数据里并不存在。
原因:经典IIR滤波器对数据起始段有瞬态响应,因为滤波器的状态变量初始为零,需要几个时间常数才能收敛。滤波器阶数越高、截止频率越低,瞬态越长。
解决:给滤波器的输入数据两端加“过渡带”,用一个pad函数延长数组:
const padLen = Math.round(trace.samplerate * 5); // 5秒填充 const padded = new Array(trace.data.length + padLen * 2).fill(0); padded.set(trace.data, padLen); const filtered = filt(padded); trace.data = filtered.subarray(padLen, padLen + trace.data.length);如果你在seisplotjs里找不到pad参数,就直接把绘制窗口内缩,不显示滤波后前5%的数据。对大多数展示场景,这个方法简单有效。
5.4 内存爆掉:一次性加载一个月波形
现象:用户选了某台站一个月的BHZ数据,浏览器标签页直接崩溃,控制台报“Out of memory”。
原因:一个月连续波形按40Hz采样约1亿个点,即使使用Float32Array也有约400MB,再加上时间计算和canvas缓存,很容易超过普通电脑的浏览器内存限制。
解决:我采用“概览图+选区拉取”策略,先请求低采样率的概览数据(例如1Hz),当用户放大到10分钟粒度时再去请求原始采样率数据。具体做法是:
// 先请求低采样率概览 const lowRateUrl = buildWaveformUrl({ start, end, sampleRate: 1 }); // 当用户点击放大后,再请求原始采样率 const highRateUrl = buildWaveformUrl({ start: newStart, end: newEnd });很多台网服务支持sampleRate参数,或者可以通过先下载再抽稀。如果服务不支持,宁可分段拉取,也不要一次性加载超过10分钟的100Hz波形到浏览器。
5.5 fetch请求被CORS拦死
现象:在本地直接用file://打开HTML,调用远程台网数据接口,控制台报CORS policy错误;有人因此以为seisplotjs有Bug。
原因:浏览器安全策略限制跨域请求,远程接口如果没有返回Access-Control-Allow-Origin头,前端永远无法拿到数据。很多地震数据服务出于安全考虑,默认只允许特定域访问,或者根本不开启CORS。
解决:开发阶段不要用file://,本地起一个静态服务器,并在构建工具里配置代理。
// vite.config.js 常见配置 export default { server: { proxy: { '/api': { target: 'https://your-data-service.example', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } } };这样前端请求/api/...就变成同源请求,绕开CORS。如果服务端支持JSONP,也可以试试,但JSONP只能用于GET且无法传大二进制,不是主流方案。
6. 进阶:把seisplotjs接到你的地图与实时系统里
如果你已经跑通了上面的最小链路,下一步就是把它封装成一个可复用的波形组件。我的推荐做法是做一个“波形卡片”,对外只暴露一个方法:load({ network, station, channel, start, end }),内部负责请求、解析、处理、绘制和销毁。这样不管是放在地图弹窗、事件详情页,还是聊天工具里,都能直接使用。
地图联动是我做得最多的场景。用户在Leaflet或Mapbox地图上点击台站圆点,弹窗里出现波形卡片。台站多时,我只加载可视区域内的台站波形,当用户移动地图或缩放时,用abort取消未返回的请求,避免过期响应覆盖新数据。这个交互听起来简单,但“请求取消”一定要做,否则用户快速点两个台站,前一个台站的波形可能晚到并覆盖后一个。
实时系统里,常见方式是后端用WebSocket推送最新的miniSEED片段,前端每收到一个片段就追加到trace尾部。追加时要注意:如果直接用concat来拼接数组,每次都是O(n)复制,几百条片段后页面就会卡死。我维护一个环形缓冲区,将每个通道的连续数据放在固定大小的Float32Array里,用写指针和读指针控制有效区间。这个方案的优点是内存恒定,缺点是要自己处理跨缓冲区边界的时间轴换算。
最后说一个真实教训:我最早用seisplotjs做实时监控时,没有释放旧trace的引用,每换一次台站就累积一块内存,监听了几小时后标签页内存占用连续增长。后来我强制在切换台站时执行trace.data = null,并把canvas宽度重置一次,内存才稳定下来。这件事让我认识到,seisplotjs解析出的对象虽然好用,但GC压力全在JS堆上,数据量大时必须主动释放。
希望这篇能帮你绕过我走过的弯路,快速在浏览器端把地震数据用起来。
本文还有配套的精品资源,点击获取