
简介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 idplot stylewidth: 900px; height: 300px;/div script typemodule 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 坐标一缩放全部漂移排查了半天才发现是坐标系用错了。从那以后我每次做这类带缩放的图层都强制走一遍“时间戳存数据、像素只在渲染时算”的流程宁可多算一次换算也不存派生值。希望帮到你。本文还有配套的精品资源点击获取