简介:seisplotjs 是一套面向地震数据处理与可视化的 JavaScript 模块库,适合具备前端基础、从事地震学或地球物理数据展示的开发者使用,用于解析地震波形、查询台站与事件信息并绘制交互式图表。整个库按功能拆分为多个子模块,涵盖数据链路协议、日期时间选择控件、经纬度距离计算,以及通过 FDSN 服务获取可用性、台站、事件与地震波形的查询接口;此外还包含基于 OregonDSP 的离散傅里叶变换与 d3 频谱绘制示例,便于按需组合并接入现有 Web 项目。压缩包共 184 个文件,以 83 个 js 源码和 60 个 html 示例页面为主,配 14 个 css 样式文件,并附 sac、mseed、sacpz、staxml 等地震数据样本及少量 Python 脚本,整体约 1.02MB,体量轻便。目前已有 320 人学习。读者可获得可直接运行的模块源码、覆盖多种访问与显示类型的示例页面,以及配套的测试波形数据,适合边读源码边对照示例,快速搭建地震数据查询与绘图原型。
1. 从一条地震波形说起:seisplotjs 到底解决什么问题
做过地震数据可视化的前端大概都碰到过这个场景:后端给了一段 SAC 或者 miniSEED 二进制,你需要在浏览器里把波形画出来,还要支持缩放、量取震相、叠加台站通道。用普通图表库硬套,光是采样点对齐、时间轴刻度、放大后重采样这几件事就能耗掉一整天,渲染稍微多一点也不够用。seisplotjs 就是冲这类需求来的一个 JavaScript 模块:解析地震数据格式、做数据处理、把波形画到页面上,一条链路包了下来。它适合做地震台网前端、科研数据展示面板、教学演示页面的人,尤其是想用纯前端方案落地、不想被重型桌面软件绑架的开发者,这套东西值得先跑通再谈改造。
2. 架构与目录:seisplotjs 的模块拆分与数据流向
seisplotjs 的设计思路不复杂,甚至有点“老派”:按职责切模块,每个模块只干一类事,模块之间靠数据对象传递,而不是靠全局状态。它的核心角色大致可以分成三层:数据接入层(读取不同格式的波形文件和元数据)、数据模型层(时间窗、台站、通道、震相这些对象)、渲染层(把模型画成 SVG 或 Canvas 上的曲线)。理解这三层,基本就掌握了这个库的骨架。
2.1 核心模块的职责边界
我一般把它拆成下面几块来看,读源码时也按这个顺序跳转,效率最高:
| 模块类别 | 主要职责 | 典型对象或函数 |
|---|---|---|
| 数据读取 | 解析 miniSEED、SAC、JSON 等格式,产出统一的时间序列 | 波形解析入口、台站元数据读取 |
| 数据模型 | 描述波形段、台站、通道、时间窗、震相 | 时间窗对象、台站对象、通道对象 |
| 数据处理 | 去均值、去趋势、滤波、重采样、旋转分量 | 滤波函数、重采样函数 |
| 绘制 | 时间轴、波形曲线、台站标注、震相标记 | 绘制器、绘图容器 |
| 交互 | 缩放、平移、光标读数、选区 | 事件绑定与状态维护 |
这张表不是摆设。实际改代码时,你会发现绝大多数需求都落在“某一层里加一个函数”,而不是跨层乱改。比如要加一个带通滤波的按钮,动的是数据处理层和交互层,数据模型和绘制层基本不碰。这种分层带来的好处是改动面可控,坏处是层与层之间的数据契约比较硬,早期的对象字段一旦定下来,后面加字段要考虑兼容。
2.2 从二进制到屏幕上一条曲线的完整数据流
这条链路是读源码的主线,捋顺了后面都轻松。大概是:拿到原始数据 → 解析成带采样率的时间序列 → 包成时间窗对象 → 按当前视口裁剪 → 送进绘制器 → 生成 SVG 路径或 Canvas 绘制指令 → 绑定缩放平移事件。
// 伪代码:一条典型的数据流,具体 API 名称以实际源码为准 const rawData = await fetch('/data/station.mseed').then(r => r.arrayBuffer()); // 1. 解析:把二进制转成时间序列对象,内部要处理采样率、起始时间、通道号 const seismogram = parseMiniSeed(rawData); // 2. 包装:把若干段波形装进一个时间窗,方便统一裁剪和绘制 const timeWindow = new TimeWindow([seismogram]); // 3. 处理:去均值、去趋势,避免波形整体偏移影响观感 const processed = removeMean(removeTrend(timeWindow)); // 4. 绘制:把时间窗交给绘图容器,容器内部按视口裁剪后生成路径 const plot = new SeismogramPlot(container, { timeWindow: processed }); plot.draw();逐段说明一下。第一段fetch拿arrayBuffer,这里不能用response.text(),二进制格式一旦按文本解码就彻底废了,这是新手最容易翻车的地方。第二段解析时要注意,miniSEED 是分记录(record)存储的,一条数据文件里可能包含多个连续或间断的记录,解析器需要把它们按时间顺序拼成完整序列,遇到时间不连续要切成多段。第三段去均值去趋势属于常规预处理,地震波形本身常有直流偏置,不去掉画出来会整体偏离基线。第四段绘制时,视口裁剪是性能关键,几千个采样点全量画没问题,但几十万点时必须先降采样或按像素聚合,否则浏览器直接卡死。
参数上要留意几个:采样率决定时间轴换算,起始时间决定横轴偏移,通道号决定曲线颜色和标签。这几个字段在解析阶段就必须是正确的,后面所有模块都依赖它们。
2.3 渲染层为什么选 SVG 而不是纯 Canvas
这个选择挺值得说。SVG 的好处是每个波形段是 DOM 节点,事件绑定天然支持,缩放时改viewBox就能实现,量取、标注、选中某一段都很方便,调试时还能直接在开发者工具里看到路径。代价是节点多了会慢,几十条通道叠加时 DOM 数量爆炸。
所以常见做法是混合:波形用 Canvas 画,交互热点和标注层用 SVG 盖在上面。这样既保住了渲染性能,又留住了交互便利。读源码时如果看到 Canvas 和 SVG 同时出现,不要觉得是历史包袱,这多半是有意的分层。
提示:如果你打算在自己的项目里只保留一种渲染方式,先测一下目标场景的通道数。少于 10 条通道,纯 SVG 足够;超过 30 条,Canvas 主绘加 SVG 标注层更稳。
3. 跑起来:seisplotjs 的环境搭建与第一个波形图
这一章讲落地。目标很明确:从空目录开始,装依赖、加载数据、画出第一条波形。整个过程不需要后端配合,用静态文件加一个本地服务器就够了。
3.1 环境准备与依赖安装
Node 版本建议 16 以上,构建工具用 Vite 或者 Rollup 都行,本项目是模块化输出,用 ES Module 引入最顺。依赖安装要注意两点:一是地震数据解析可能涉及压缩格式,需要额外的解码库;二是绘图本身不依赖重型框架,别顺手把整个 UI 库拉进来。
# 初始化项目 npm init -y # 安装运行时依赖(具体包名以项目 package.json 为准) npm install seisplotjs # 安装构建工具 npm install -D vite # 启动本地开发服务器 npx vite三条安装命令里,npm install seisplotjs之后要检查一下node_modules里的实际结构,有的发行包会拆成多个子包(解析、绘制、工具各一个),此时需要按子包分别引入。构建工具选 Vite 是因为它对 ESM 支持好,热更新快,改一行代码页面立刻刷新,调波形样式时省很多时间。
启动后浏览器访问本地端口,打开控制台看有没有 404 或解析错误。第一次运行最常见的失败不是代码写错,而是静态资源路径不对,波形文件没被正确托管。
3.2 加载数据并绘制第一条波形
下面这段是最小可运行示例,把加载、解析、绘制串起来:
<!-- index.html --> <div id="plot" style="width: 900px; height: 300px;"></div> <script type="module" src="/main.js"></script>// main.js import { parseMiniSeed, TimeWindow, SeismogramPlot } from 'seisplotjs'; async function drawFirstSeismogram() { const container = document.getElementById('plot'); // 读取本地波形文件,必须是二进制方式 const buffer = await fetch('/data/demo.mseed').then(res => res.arrayBuffer()); // 解析成时间序列,注意解析结果可能是数组(多段记录) const segments = parseMiniSeed(buffer); // 组装时间窗,统一管理起始时间和结束时间 const timeWindow = new TimeWindow(segments); // 创建绘图实例,传入容器和配置 const plot = new SeismogramPlot(container, { timeWindow, yAxisWidth: 60, // 左侧振幅轴宽度 showGrid: true, // 是否画网格线 lineColor: '#2b6cb0' // 波形颜色 }); plot.draw(); } drawFirstSeismogram().catch(err => { // 解析失败时打印原因,常见是格式不匹配或文件损坏 console.error('波形加载失败:', err); });逻辑上分四步。第一步fetch拿二进制,路径要和静态服务器的根目录对齐,Vite 默认把public目录作为静态资源根。第二步解析,返回的可能是数组,因为一条文件里可能有多段记录,直接用第一段会丢数据。第三步组装时间窗,这一步会计算整体时间范围,后续裁剪和坐标换算都依赖它。第四步创建绘图实例,配置项里yAxisWidth影响左侧留白,showGrid影响视觉密度,lineColor只是样式。
失败时的排查顺序建议是:先看控制台有没有 404,再看解析函数返回是不是空数组,最后看绘图容器的宽高是否为 0。容器高度为 0 是高频翻车点,div没给高度,画出来什么都没有,但代码不报错。
3.3 缩放、平移与光标读数的接线方式
基础波形出来后,下一步就是交互。这部分的核心是“状态变更后重绘”,而不是每次在旧图上叠新图。
// 交互接线示意:缩放时更新视口时间范围,然后重绘 let currentStart = timeWindow.start; let currentEnd = timeWindow.end; container.addEventListener('wheel', (event) => { event.preventDefault(); // 以光标位置为中心缩放,缩放因子控制在合理区间,避免一次跳太远 const factor = event.deltaY > 0 ? 1.2 : 0.8; const center = currentStart + (currentEnd - currentStart) / 2; const halfSpan = (currentEnd - currentStart) * factor / 2; currentStart = center - halfSpan; currentEnd = center + halfSpan; // 只更新绘图实例的视口,不重新解析数据 plot.setTimeRange(currentStart, currentEnd); plot.draw(); });这里的关键点是:缩放只改视口,不动原始数据。原始时间序列保持完整,绘制时按当前视口裁剪并按像素密度决定是否降采样。如果每次缩放都重新解析文件,那就真的卡成幻灯片了。缩放因子用 1.2 和 0.8 是我习惯的取值,滚轮手感比较线性,太小了要滚很多次,太大了容易一次跳过头。
光标读数一般做法是监听mousemove,把像素坐标反算成时间,再在数据里找最近的采样点。反算公式依赖当前视口起止时间和容器宽度,这三者必须同步更新,否则读数和波形对不上。
注意:缩放和平移时一定要
preventDefault,否则页面会跟着滚,交互体验极差。
4. 核心代码拆解:解析、预处理与绘制的关键实现
前面是跑通,这一章往源码里走。挑三个最能代表这个项目的点:格式解析的容错、波形预处理的顺序、绘制时的坐标换算。看懂了这三块,改起来心里有底。
4.1 解析器的容错设计:记录拼接与断点处理
地震数据文件不像 JSON 那样结构整齐,它按固定长度的记录块存储,块与块之间可能时间连续,也可能因为设备重启、传输丢失而断开。解析器如果不处理断点,画出来的波形会把两段不相干的数据硬连在一起,看起来像一次假地震。
// 记录拼接的简化逻辑,重点在断点判断 function assembleRecords(records) { const segments = []; let current = null; for (const rec of records) { const recStart = rec.startTime; const recEnd = recStart + rec.sampleCount / rec.sampleRate; if (!current) { current = { start: recStart, end: recEnd, samples: [...rec.samples], rate: rec.sampleRate }; continue; } // 允许极小的时间误差,浮点计算会带来微小漂移 const gap = recStart - current.end; const tolerance = 1 / current.rate / 2; if (Math.abs(gap) <= tolerance && rec.sampleRate === current.rate) { // 时间连续且采样率一致,直接拼接 current.samples.push(...rec.samples); current.end = recEnd; } else { // 存在断点或采样率变化,另起一段 segments.push(current); current = { start: recStart, end: recEnd, samples: [...rec.samples], rate: rec.sampleRate }; } } if (current) segments.push(current); return segments; }逐段看。函数输入是一组记录,输出是若干连续段。第一段初始化当前段。第二段算断点gap,这是核心:gap接近 0 说明连续,gap明显非 0 说明有间断。容差取半个采样周期,是因为时间戳精度和浮点运算会造成微小误差,严格等于 0 反而拼不上。第三段在采样率变化时也强制断开,因为采样率不同的两段没法直接首尾相接,混在一起会导致时间轴错乱。
这个设计里有个容易忽略的点:samples.push(...rec.samples)在样本量很大时会触发参数个数上限,如果一段有几万个点,用扩展运算符可能报错。稳妥写法是循环 push 或用concat。这是我在实际项目里踩过的坑,数据量小的时候完全看不出来。
提示:断点判断的容差不要设太大,否则真实的小间断会被误判为连续,画出来的波形在时间轴上会有轻微拉伸。半个采样周期是个靠谱的默认值。
4.2 预处理顺序:为什么去趋势必须在滤波之前
预处理函数单独看都很简单,顺序错了结果就完全不同。常见做法是:去均值 → 去趋势 → 滤波 → 重采样。这个顺序不是拍脑袋定的。
// 预处理流水线,顺序不能随意调换 function preprocess(segment, options) { let samples = segment.samples.slice(); // 拷贝,避免污染原始数据 // 1. 去均值:消除直流偏置 const mean = samples.reduce((a, b) => a + b, 0) / samples.length; samples = samples.map(v => v - mean); // 2. 去趋势:拟合并减去线性趋势,消除基线漂移 const n = samples.length; const slope = (samples[n - 1] - samples[0]) / (n - 1); samples = samples.map((v, i) => v - slope * i); // 3. 滤波:按配置做带通,滤掉高频噪声和长周期漂移 if (options.bandpass) { samples = bandpassFilter(samples, segment.rate, options.bandpass.low, options.bandpass.high); } // 4. 重采样:降采样前先滤波,避免混叠 if (options.targetRate && options.targetRate < segment.rate) { samples = resample(samples, segment.rate, options.targetRate); } return { ...segment, samples }; }第一步去均值,去掉的是常数偏移,计算简单但很关键。第二步去趋势,去掉的是线性漂移,如果先滤波再去趋势,滤波器会把趋势成分和信号混在一起,去掉趋势时可能连带削掉有用的低频信号。第三步滤波,带通上下限由配置决定,注意采样率必须满足奈奎斯特条件,上限不能超过采样率的一半,否则滤波器会失稳甚至输出 NaN。第四步重采样,必须放在滤波之后,这是信号处理的基本要求,先降采样再滤波,高频成分会折叠到低频,画出来的波形出现虚假的低频波动,看着像长周期信号,其实是混叠。
参数上,bandpass.low和bandpass.high要根据关注频段来定,看近震高频成分就设高一点,看远震长周期就设低一点。targetRate一般设成屏幕像素密度的两倍左右,再高也显示不出来。
4.3 绘制时的坐标换算:时间到像素的三步映射
绘制层最核心的是一个映射函数:给定时间和振幅,算出屏幕上的 x、y。这个映射看起来简单,写错一个符号整条波形就歪了。
// 坐标映射:时间 -> x,振幅 -> y function timeToX(time, viewStart, viewEnd, plotLeft, plotWidth) { const ratio = (time - viewStart) / (viewEnd - viewStart); return plotLeft + ratio * plotWidth; } function ampToY(amp, maxAmp, plotTop, plotHeight) { // SVG 坐标系 y 轴向下,所以要用高度减去归一化后的值 const normalized = amp / maxAmp; // 归一化到 [-1, 1] 附近 return plotTop + plotHeight / 2 - normalized * (plotHeight / 2); }timeToX做的是线性映射,注意viewEnd - viewStart不能为 0,缩放到极限时要限制最小跨度,否则除零得到 Infinity,曲线直接消失。ampToY里那个减号是重点,SVG 的 y 轴向下为正,而振幅向上为正,两者方向相反,不减就会上下颠倒。归一化用maxAmp而不是固定值,是为了让不同通道的波形都有合适的显示幅度。
实际绘制时还有一步裁剪:只保留x落在绘图区域内的点,区域外的点不生成路径。这一步行内人叫“视口裁剪”,不做的话路径长度爆炸,缩放时尤其明显。
注意:如果同一张图里叠加多条通道,
maxAmp要么各通道独立归一化,要么全局统一。独立归一化会让振幅不可比,全局统一可能让小振幅通道看不出波动,取舍看你的展示目标。
5. 常见问题排查:波形画不出来、缩放卡顿与时间轴错位
这一章是我攒下来的踩坑记录,都是实际调试时遇到的,按“现象 → 原因 → 解决”列出来,遇到对得上的直接抄。
5.1 页面空白,控制台无报错
现象:容器在,代码跑了,控制台干净,但页面上什么也没有。
原因:多数是容器宽高为 0。绘图库读取容器尺寸做布局,尺寸为 0 时所有坐标都算成 0,路径生成但不可见。另一个可能是 SVG 被创建在了错误节点下,或者被 CSS 的overflow: hidden裁掉了。
解决:给容器显式设置宽高,不要依赖 flex 撑开;绘制前打印容器clientWidth和clientHeight确认;检查绘制器的挂载目标是容器本身还是它内部的子节点。
5.2 波形整体偏移基线,或者一头高一头低
现象:曲线不在中间,或者有明显倾斜。
原因:没有做去均值和去趋势,原始数据的直流偏置和基线漂移原样画了出来。地震计的输出经常带偏置,这不是数据坏了。
解决:在解析之后、绘制之前插入预处理,至少做去均值和去趋势。如果只想看形态不想滤波,去趋势也不能省,否则缩放后倾斜会非常明显。
5.3 缩放几次后页面卡死
现象:初始波形正常,滚轮缩放几下滑动变慢,最后卡住。
原因:每次缩放都重新解析了文件,或者每次都全量重绘所有采样点。数据量大时,几万点乘以几十条通道,重绘成本线性上升。
解决:解析只做一次,结果缓存;缩放只更新视口参数并重绘;重绘前按像素密度降采样,一个像素列只保留一个最大值和一个最小值,这样波形包络还在,点数却少了一个数量级。
5.4 时间轴刻度对不上真实时间
现象:横轴标签显示的时间和数据文件的起始时间差了几秒甚至几分钟。
原因:时区处理不一致。解析器可能按 UTC 存储时间,而格式化函数按本地时区输出,两边差一个时区偏移。另一个可能是把采样点索引当成了时间直接换算。
解决:统一在 UTC 下计算,只在显示层做时区转换;时间换算一律走起始时间 + 索引 / 采样率,不要用索引乘固定间隔,浮点累积误差在长数据上会放大。
5.5 滤波后出现 NaN 或全零
现象:开启滤波后波形消失,控制台提示数值异常。
原因:滤波器的截止频率超过了奈奎斯特频率,也就是采样率的一半。参数配错或者重采样后忘了更新采样率,都会触发。
解决:加参数校验,截止频率上限取采样率 * 0.5 * 0.9;重采样后同步更新段对象里的采样率字段;滤波函数入口对输入做有效性检查,遇到非有限值直接返回原数据并给出警告,避免整条链路静默失败。
6. 二次开发:给 seisplotjs 加一个自定义震相标注层
改造点选震相标注,因为这个需求几乎每个地震数据展示项目都会提,而且它能同时体现“扩展绘制层”和“复用数据模型”两个技巧。目标是在波形上标出 P 波、S 波的到达时刻,支持点击选中、显示标签、随缩放同步移动。
思路是不改核心绘制器,新增一个独立图层。核心绘制器负责波形,标注层负责标记,两者共享同一个视口参数,缩放时一起重绘。这样做的好处是核心代码不动,升级库版本时冲突小。
// 震相标注层:独立于波形绘制器,共享视口参数 class PhaseMarkerLayer { constructor(svgRoot, getViewport) { this.group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); this.group.setAttribute('class', 'phase-markers'); svgRoot.appendChild(this.group); this.getViewport = getViewport; // 回调,返回当前视口的起止时间 this.markers = []; } setMarkers(markers) { // markers: [{ time, label, color }] this.markers = markers; } render() { const { start, end, left, width } = this.getViewport(); this.group.innerHTML = ''; // 清空后重绘,标记数量少时成本可接受 for (const m of this.markers) { // 视口外的标记直接跳过,省渲染 if (m.time < start || m.time > end) continue; const x = left + ((m.time - start) / (end - start)) * width; // 竖线标出到达时刻 const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); line.setAttribute('x1', x); line.setAttribute('x2', x); line.setAttribute('y1', 0); line.setAttribute('y2', '100%'); line.setAttribute('stroke', m.color || '#e53e3e'); // 文字标签标出震相名称 const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', x + 4); text.setAttribute('y', 16); text.textContent = m.label; // 点击选中,用于联动其他面板 line.addEventListener('click', () => this.onSelect?.(m)); this.group.appendChild(line); this.group.appendChild(text); } } }说明几个关键点。getViewport用回调而不是传值,是因为视口会随缩放变化,回调每次重绘时取最新值,不会拿到过期数据。render里先清空再重建,标记数量通常是个位数到几十个,成本可以接受;如果标记上千,就要改成增量更新,只移动已有节点。视口外跳过这一步不能省,否则缩放到局部时大量标记堆在可视区外,白白消耗渲染。
接入方式是在波形绘制后的同一帧里调render,缩放回调里也调一次,保证两者始终同步。选中回调onSelect可以用来联动右侧的震相列表,或者在地图上高亮对应台站。
验证方法很直接:加载一份带已知震相时刻的数据,缩放和平移,看标记是否始终贴在对应波峰或波谷上。如果缩放后偏移,多半是getViewport返回的start、end和波形绘制器用的不是同一组值,常见的错误是两个地方各存了一份视口状态。
| 改造点 | 改动范围 | 验证方式 |
|---|---|---|
| 新增标注层 | 只加新类,不改核心绘制器 | 缩放后标记是否贴合波形 |
| 选中联动 | 加回调,不动数据模型 | 点击标记能否触发外部逻辑 |
| 标记过滤 | 渲染函数内加判断 | 大量标记下滚动是否流畅 |
一个技巧:把标注数据存成时间戳而不是像素坐标。像素坐标在缩放后会失效,时间戳是稳定的,每次重绘重新换算即可。这个习惯我是在改一个台站面板时被坑出来的,当时把标记位置存成了 x 坐标,一缩放全部漂移,排查了半天才发现是坐标系用错了。
从那以后我每次做这类带缩放的图层,都强制走一遍“时间戳存数据、像素只在渲染时算”的流程,宁可多算一次换算,也不存派生值。希望帮到你。
本文还有配套的精品资源,点击获取