简介:这是一套面向地震数据处理与可视化开发者的 JavaScript 模块资源,适合具备前端基础、希望在地震数据展示场景中复用现成模块的中级学习者。压缩包共184个文件、约1.02MB,以83个 JavaScript 源码和60个 HTML 示例页为主,辅以 CSS、SAC 数据样例及少量 Python、JSON 与配置文件,方便边读源码边运行示例。内容按功能拆分为 FDSN 台站、事件与波形查询,DataLink WebSocket 协议,日期时间选择控件,经纬度距离计算,OregonDSP 傅里叶变换和基于 d3 的频谱绘图等子模块,可帮助读者理解地震数据从请求、解析、处理到展示的完整链路。已有320人学习,适合用作小型地震数据可视化页面搭建及模块化源码参考。
1. 从一堆二进制波形说起:seisplotjs 到底解决什么问题
前年帮某实验室的同事做一个台网值班看板,对方给了一块移动硬盘,里面是几个月的连续波形和一份台站元数据,要求是「打开网页就能看,别装软件」。桌面端工具能画图,但嵌不进 Web 系统;如果从零开始写,光是解析二进制记录头就够折腾很久。seisplotjs 正好处在这个位置——它是一组用 JavaScript 写的地震数据处理模块,把解析、处理、绘制拆成可以单独复用的几层,miniSEED 的二进制记录、StationXML 台站元数据、QuakeML 事件目录都能读进内存,整理成统一对象后再交给浏览器画成 SVG。它适合做 Web 端波形展示、台站状态监控、教学演示的前端开发;如果你只想在命令行里批量做数值处理,这套东西反而不顺手。
2. 骨架拆解:模块分层、依赖选型和一份能看懂的目录树
拿到一个包,我习惯先看package.json和src目录,不急着import。seisplotjs 并不是一个只提供单个工具函数的库,而是按「数据模型、格式解析、图形渲染、数值计算」切开的四层结构。这个边界定得清不清楚,直接决定你能不能在 Node 里跑批量预处理、在浏览器里只加载渲染那部分、在服务端只引解析器。分层不清的库,往往引入一个绘图函数就顺带把整个 DOM 相关代码拖进 Node 环境,进程会直接报错。
2.1 四层模块与各自的调用边界
先把模块清单摆出来,后面所有代码都落在这张表上:
| 模块 | 职责 | 是否依赖 DOM | 典型入口 |
|---|---|---|---|
| core | 数据模型与格式解析 | 否 | Seismogram、StationXML、QuakeML、时间工具 |
| plot | 波形图、日震图、粒子图渲染 | 是 | Seismograph、SeismographConfig、Helicorder |
| d3ext | 补 d3 缺的图形能力 | 是 | 时间轴刻度、缩放行为封装 |
| traveltime | 震相走时计算 | 否 | 走时曲线、理论到时表 |
core 层是最值得先读的,它定义了一个叫 Seismogram 的对象,把「一段波形」抽象成台网、台站、位置码、通道码、起始时间、采样率、采样点数组这几样东西。所有格式解析器最终都产出这个对象,所有绘图模块只消费这个对象,格式和渲染就此解耦。这个设计的好处很直接:你从 miniSEED 读、从自己的后端接口读、从别人给的 JSON 读,只要最后拼成 Seismogram,上面的图就能画。
plot 层依赖 d3 做比例尺和路径生成,也依赖浏览器提供的 SVG 能力。我第一次在 Node 脚本里require了 plot 下的文件,报的是document is not defined,这个错很典型,说明你越界了——预处理好数据之后应该序列化出去,不要试图在服务端画图。
2.2 依赖清单:d3、时间库、DOMParser 各自扛什么
选型上它没走 React 那条路,而是直接用 d3 管图形。理由不难理解:波形是典型的数据驱动图形,一条曲线对应一个数组,缩放平移改的是比例尺的定义域,d3 的 scale 和 axis 天生就是干这个的;换成虚拟 DOM 做 diff,每帧都要重建几万个点的属性,反而更慢。d3 在这里主要用三样东西:scaleLinear/scaleTime做数值到像素的映射,axisBottom画时间轴刻度,zoom接管滚轮和拖拽手势。
时间处理单独抽了一层。地震数据的时间全是 UTC,而Date对象在展示时会按浏览器本地时区渲染,这个错位是新手翻车重灾区,后面避坑章节会细讲。XML 解析靠浏览器原生的DOMParser,不引第三方 XML 库,代价是命名空间处理要自己小心。
打包产物一般给两种:ESM 给现代构建工具,UMD 给<script>直接引入。我在一个老项目里用过 UMD 版本,页面里挂一个全局变量就能调,不用改整套构建配置。
提示:只用解析功能的话,优先按模块路径单独引入 core 下的文件,别整包引入,打包体积能小一大截。
2.3 目录结构与构建产物
实际拆包时看到的目录大致是这么组织的,我按理解画出来:
seisplotjs/ ├── src/ │ ├── core/ │ │ ├── seismogram.js # 波形对象与时间/索引换算 │ │ ├── stationxml.js # 台站元数据解析与索引 │ │ ├── quakeml.js # 事件目录解析 │ │ ├── miniseed.js # 二进制记录解析 │ │ ├── moment.js # UTC 时间工具 │ │ └── util.js # 通用小函数 │ ├── plot/ │ │ ├── seismograph.js # 单台/多台波形图 │ │ ├── seismographconfig.js # 绘图参数对象 │ │ ├── helicorder.js # 24 小时日震图 │ │ └── particles.js # 粒子运动图 │ ├── d3ext/ │ └── traveltime/ ├── dist/ # 构建产物,ESM + UMD └── package.json看这个结构能得出两个判断。第一,解析器和渲染器是平行的两个目录,说明作者有意让它们互不引用,core 里不该出现任何document、window。第二,seismographconfig.js单独成文件,说明绘图参数被抽成了纯数据对象,而不是散落在绘图类里的一堆字段——这一点对二次开发特别友好,你可以在配置文件里存一套视图模板,换个页面直接复用。
2.4 最小引入:一个页面把一条波形画出来
想快速验证环境通不通,先别碰真实数据,手写一条正弦波当假数据,跑通渲染链路再说:
// 引入整包,生产环境建议按需引入 import * as sp from 'seisplotjs'; // 构造一条 60 秒、100Hz 的假波形,避开真实数据的解析问题 const sampleRate = 100; const y = new Float32Array(60 * sampleRate); for (let i = 0; i < y.length; i++) { y[i] = Math.sin(2 * Math.PI * 1.5 * i / sampleRate); // 1.5Hz 正弦 } const seis = new sp.core.Seismogram({ networkCode: 'XX', stationCode: 'AAA', locationCode: '', channelCode: 'HHZ', startTime: new Date(Date.UTC(2024, 0, 1, 0, 0, 0)), sampleRate, y, }); const graph = new sp.plot.Seismograph(document.getElementById('plot')); graph.seisData = [seis]; graph.draw();这段代码的意义在于把问题分两段排查。如果假数据能画出来,说明引入方式、DOM 容器、d3 版本都没问题,后面波形是空的、歪的、平的时候,就只需要怀疑解析环节。Seismogram的构造参数里,startTime必须是Date对象且语义上是 UTC,sampleRate单位是 Hz,y用Float32Array比普通数组省内存,长波形差距很明显。
3. 解析链路:miniSEED 记录头、StationXML 索引与 Seismogram 内存模型
这一章是整篇里最硬的部分。地震数据的麻烦不在于数值计算,而在于元数据:波形文件里只有采样点,通道的增益、方位角、采样率、有效时间范围全在另一份 XML 里,两边要靠「台网.台站.位置.通道」这个四元组对齐。对不齐,画出来的图要么是直线,要么时间轴整体偏移。
3.1 miniSEED:从 ArrayBuffer 里把记录头和采样点拆开
miniSEED 是定长头加数据区的结构,标准定长头 48 字节,字段按大端序排列。用DataView逐字段读,比用字符串切片稳:
/** * 解析一条 miniSEED 记录的定长头 * @param {DataView} dv 指向单条记录的视图 */ function parseMiniSeedHeader(dv) { // 站台标识:定长 ASCII,尾部可能带空格 const readAscii = (off, len) => { let s = ''; for (let i = 0; i < len; i++) s += String.fromCharCode(dv.getUint8(off + i)); return s.trim(); }; const net = readAscii(18, 2); const sta = readAscii(8, 5); const loc = readAscii(13, 2); const cha = readAscii(15, 3); // BTIME 结构:年(2) 年内第几天(2) 时 分 秒 未用(1) 万分之一秒(2) const year = dv.getUint16(20, false); const doy = dv.getUint16(22, false); const hour = dv.getUint8(24); const min = dv.getUint8(25); const sec = dv.getUint8(26); const frac = dv.getUint16(28, false); // 单位 1/10000 秒 // 只有年积日,没有月份,必须用 Date.UTC 反推 const startTime = new Date( Date.UTC(year, 0, 1) + (doy - 1) * 86400000 + hour * 3600000 + min * 60000 + sec * 1000 + Math.round(frac / 10) ); const numSamples = dv.getUint16(30, false); // 采样率 = 因子 × 乘子,任一项为负时是除法关系 const factor = dv.getInt16(32, false); const mult = dv.getInt16(34, false); let sampleRate; if (factor > 0 && mult > 0) sampleRate = factor * mult; else if (factor > 0 && mult < 0) sampleRate = factor / -mult; else if (factor < 0 && mult > 0) sampleRate = mult / -factor; else sampleRate = 1 / (factor * mult); return { net, sta, loc, cha, startTime, numSamples, sampleRate }; }逻辑上有三处容易写错。第一处是Date.UTC(year, 0, 1)加年积日,这里的 0 表示一月,如果写成getMonth那种从 1 开始的思路,整条数据会偏一天。第二处是小数秒,字段单位是万分之一秒,除以 10 换算毫秒要四舍五入,不取整的话后续和时间轴刻度对齐时会出现半毫秒级的错位,肉眼看不出但缩放后会暴露。第三处是采样率,因子和乘子都可能是负数,表示的是除法,我见过有人只写了乘法分支,结果 40Hz 的数据读出来变成 -40,图直接倒过来。
读完头再取采样点:
// beginning of data 字段为 0 时,数据区从 48 字节开始 const dataOffset = dv.getUint16(44, false) || 48; const y = new Float32Array(numSamples); for (let i = 0; i < numSamples; i++) { // 每个采样点是 4 字节有符号整数,单位是 counts y[i] = dv.getInt32(dataOffset + i * 4, false); }采样点是整数计数(counts),不是物理量。这一点决定了后面绘图的振幅模式:不同通道的 counts 量级能差好几个数量级,混在一张图上必须做归一化或者固定量程。
3.2 StationXML:一次遍历建索引,别在循环里查
台站元数据是一棵树:Network 下挂 Station,Station 下挂 Channel,Channel 下是各种响应参数。新手常犯的错是每处理一条波形就去 XML 里querySelector一遍,几百条数据下来页面就卡住了。正确姿势是一次遍历建 Map,键用四元组拼接:
/** * 把 StationXML 拍平成 Map,键为 NET.STA.LOC.CHA * 注意:不要用 querySelector,带默认命名空间的 XML 匹配不到 */ function indexStationXML(xmlText) { const doc = new DOMParser().parseFromString(xmlText, 'application/xml'); const channels = new Map(); const text = (el, tag) => { const n = el.getElementsByTagName(tag)[0]; return n ? n.textContent.trim() : null; }; const nets = doc.getElementsByTagName('Network'); for (const net of nets) { const netCode = net.getAttribute('code'); for (const sta of net.getElementsByTagName('Station')) { const staCode = sta.getAttribute('code'); for (const chan of sta.getElementsByTagName('Channel')) { const loc = chan.getAttribute('locationCode') || ''; const cha = chan.getAttribute('code'); const key = `${netCode}.${staCode}.${loc}.${cha}`; channels.set(key, { net: netCode, sta: staCode, loc, cha, lat: parseFloat(text(chan, 'Latitude')), lon: parseFloat(text(chan, 'Longitude')), elev: parseFloat(text(chan, 'Elevation')), sampleRate: parseFloat(text(chan, 'SampleRate')), start: text(chan, 'StartDate'), end: text(chan, 'EndDate'), // 通道可能仍在用,此处为 null }); } } } return channels; }参数说明几个点。getElementsByTagName在 XML 文档上不做 CSS 选择器那套命名空间前缀解析,而querySelector会,所以这里用前者是刻意的。locationCode缺省时属性值可能是空串也可能是--,取值后要统一成空串,不然后面拼 key 永远对不上。EndDate为空表示通道还在服役,不要当成解析失败丢掉。
3.3 Seismogram 内存模型:时间与索引的来回换算
波形对象的核心不是数组,而是「起点时间 + 采样率 + 数组」这个三元组能唯一确定任意采样点的时刻。所有交互——框选、缩放、鼠标悬停读数——本质都是在这两种坐标之间换算:
class Seismogram { constructor({networkCode, stationCode, locationCode, channelCode, startTime, sampleRate, y}) { this.networkCode = networkCode; this.stationCode = stationCode; this.locationCode = locationCode; this.channelCode = channelCode; this.startTime = startTime; // Date,语义为 UTC this.sampleRate = sampleRate; // Hz this.y = y; // Float32Array } get endTime() { // 注意是 length-1,最后一帧的时刻不是 length/sr return new Date(this.startTime.getTime() + (this.y.length - 1) / this.sampleRate * 1000); } timeToIndex(t) { return Math.round((t.getTime() - this.startTime.getTime()) / 1000 * this.sampleRate); } slice(t0, t1) { const i0 = Math.max(0, this.timeToIndex(t0)); const i1 = Math.min(this.y.length, this.timeToIndex(t1)); return new Seismogram({...this, startTime: this.timeAt(i0), y: this.y.slice(i0, i1)}); } timeAt(index) { return new Date(this.startTime.getTime() + index / this.sampleRate * 1000); } }endTime用length - 1是个细节,写成length / sr会让相邻时段的数据在拼接时重叠一帧,连续波形拼出来会有毛刺。timeToIndex用了Math.round,框选取样时结果稳定;如果换成Math.floor,在窗口右边界会少取一个点,日震图上表现为每行末尾缺一小段。
3.4 处理链:去均值、去趋势、简单滤波
绘图之前通常要做几步预处理,常见做法是去均值、去线性趋势,需要看低频的话再叠一级滤波:
// 去均值:消除直流偏置,否则曲线整体偏离基线 function demean(y) { let sum = 0; for (let i = 0; i < y.length; i++) sum += y[i]; const mean = sum / y.length; const out = new Float32Array(y.length); for (let i = 0; i < y.length; i++) out[i] = y[i] - mean; return out; } // 去线性趋势:用首尾均值估斜率,够用且 O(n) function detrend(y) { const n = Math.min(1000, Math.floor(y.length / 10)); let head = 0, tail = 0; for (let i = 0; i < n; i++) head += y[i]; for (let i = y.length - n; i < y.length; i++) tail += y[i]; const slope = (tail / n - head / n) / (y.length - n); const out = new Float32Array(y.length); for (let i = 0; i < y.length; i++) out[i] = y[i] - slope * i; return out; }去趋势这里用首尾各取 1000 点估斜率,是工程上的折中,精度不如最小二乘但对可视化完全够。真正需要严格处理时,库里的滤波实现更靠谱,手写 IIR 很容易把相位搞乱,波形看着变漂亮了,其实到时全偏了。
4. 绘制链路:从 SeismographConfig 参数到 SVG 路径的完整过程
数据进了内存,接下来是把它变成像素。这一层的设计思路是「配置与渲染分离」:SeismographConfig是个纯数据对象,负责描述「画成什么样」,Seismograph负责把这份描述执行到 DOM 上。分开的好处是视图状态可以序列化存起来,做多视图联动或者视图模板时特别省事。
4.1 SeismographConfig 的关键参数
调图九成时间花在改这几个参数上,先列清楚:
| 参数 | 作用 | 常见取值与说明 |
|---|---|---|
| amplitudeMode | 振幅量程策略 | fixed 固定量程便于横向比较;individual 每条自适应 |
| timeScale | 时间窗偏移与缩放 | {offset, scale},联动多图时共享同一个对象 |
| markerFlag | 是否显示时间游标 | 交互读数时打开 |
| 每行高度 | 单条波形占用的像素高度 | 多台堆叠时按容器高度均分 |
| 是否显示台站标签 | 左侧标签栏 | 台站多时建议改成短码 |
amplitudeMode是最容易出问题的参数。默认自适应看着舒服,但几条波形放一起时,一条安静台站的噪声会被放大到和远处大震一样的视觉幅度,读图的人会误判。做台阵对比时我一般强制fixed,量程取所有通道的最大绝对值,宁可小信号看起来平一点。
4.2 绘制流程:比例尺、路径、坐标轴三段式
不管上层封装成什么样,落到 SVG 上永远是三步。下面这段是剥掉封装后的核心逻辑,理解了它,调库的时候就知道每个参数最终影响哪一步:
import * as d3 from 'd3'; /** * 把一条波形画到指定 SVG 分组里 * @param {SVGElement} g 波形分组 * @param {Seismogram} seis 波形对象 * @param {number} width 绘图区宽度 * @param {number} height 单行高度 */ function renderTrace(g, seis, width, height) { // 1. 比例尺:时间 -> x,振幅 -> y(y 轴翻转,SVG 原点在左上) const x = d3.scaleTime() .domain([seis.startTime, seis.endTime]) .range([0, width]); const extent = d3.extent(seis.y); const y = d3.scaleLinear() .domain([extent[0], extent[1]]) .range([height, 0]) .nice(); // 2. 路径:逐点映射,点数多时按像素宽度抽稀 const step = Math.max(1, Math.floor(seis.y.length / width)); const line = d3.line() .x((d, i) => x(seis.timeAt(i * step))) .y(d => y(d)); const sampled = Array.from(seis.y.filter((_, i) => i % step === 0)); d3.select(g).selectAll('path.trace') .data([sampled]) .join('path') .attr('class', 'trace') .attr('fill', 'none') .attr('stroke', '#2b6cb0') .attr('stroke-width', 1) .attr('d', line); // 3. 坐标轴:只在最底下一行画时间轴,其余行共享 d3.select(g).selectAll('g.axis') .data([null]) .join('g') .attr('class', 'axis') .attr('transform', `translate(0,${height})`) .call(d3.axisBottom(x).ticks(6).tickFormat(d3.utcFormat('%H:%M:%S'))); }三段各有讲究。比例尺这里y的 range 写成[height, 0]是反的,因为 SVG 坐标系 y 向下增长,写反了波形会上下颠倒,我第一次画出来以为数据坏了,查了半天。路径这一步做了抽稀,step保证每个像素列最多取一个点,一万个点以上的波形不抽稀,浏览器渲染会明显掉帧。坐标轴用d3.utcFormat而不是d3.timeFormat,这一条是血泪经验,后面避坑章节展开。
4.3 多台堆叠与日震图:把 24 小时切成行
单条波形好办,真正的显示压力来自两种图。一种是多台堆叠,十几个台站纵向排列共享同一时间轴,做法是给每个台站分配一个<g>并平移translate(0, i * rowHeight),时间比例尺对象共用一份。另一种是日震图,把一天的数据按 30 分钟切成 48 行,每行画一段,行内时间轴隐藏,只在左侧标小时刻度。
日震图的分行逻辑不难,麻烦在跨天边界。一小时的记录可能跨两条记录文件,甚至跨年积日,拼接时要用endTime和下一段的startTime做空隙检测,中间有断记就留空白,不要硬接——硬接会把停摆期间的噪声拉成一条虚假的斜线,值班的人很容易误判成事件。
注意:日震图每行点数建议压到 1000 以内,48 行乘上去就是几万个路径节点,超过这个量浏览器滚动会明显发涩。
4.4 交互:缩放与多图联动
缩放靠 d3 的 zoom 行为接管滚轮和拖拽,核心是把事件里的transform换算成时间比例尺的新定义域,然后重算路径的d属性。多图联动的关键不是同时改所有图,而是让所有图共享同一个timeScale对象,改一处,其余图在下一次重绘时自然跟上。
实际做的时候有个坑:如果在 zoom 回调里对每张图都做一次完整重绘,十几个台站滚一下要几百毫秒。常见的优化是拖动过程中只改<g>的transform做位移,zoomend事件触发后再做一次真正的数据抽稀重绘,视觉上就顺了。
5. 避坑清单:跨域、时区、命名空间与性能的 5 个排查记录
这一章按「现象、原因、解决」来写,全是我自己踩过的。
5.1 波形画出来是一条直线
现象:数据加载成功,控制台没报错,图上只有一条水平的细线。
原因:采样点单位是 counts,不同通道量级从几十到几百万不等。如果amplitudeMode设成fixed且量程沿用了另一条大振幅通道的值,小信号就会被压成一条线;另一种情况是数据本身带很大的直流偏置,去均值没做。
解决:先打印d3.extent(seis.y)看数值范围,量级差超过三个数量级就说明需要按通道归一化。把amplitudeMode临时改成individual验证一下,如果立刻正常,就确认是量程问题。
5.2 时间轴整体差 8 小时
现象:图上显示 08:00 有事件,实际记录是 00:00。
原因:miniSEED 里的时间是 UTC,而d3.timeFormat和Date的字符串方法默认按浏览器本地时区渲染,一加一减正好差一个时区偏移。
解决:所有格式化和坐标轴刻度统一换成 UTC 版本,d3.utcFormat、d3.utcScale。另外存startTime时别用new Date('2024-01-01 00:00:00')这种不带时区的字符串,它会被当成本地时间,用Date.UTC(...)显式构造。
5.3 StationXML 解析出来是空的
现象:getElementsByTagName('Network')返回长度为 0,但文件明明有内容。
原因:StationXML 根节点带默认命名空间,部分解析路径下按标签名直查会失配;另一个可能是文件开头有 BOM,DOMParser报解析错误但被忽略了。
解决:先检查doc.querySelector('parsererror')是否非空。命名空间问题改用getElementsByTagName或getElementsByTagNameNS,别用querySelector('Network')。BOM 的话用text.replace(/^\uFEFF/, '')先洗一遍。
5.4 请求波形时报跨域
现象:本地页面调数据接口,控制台报 CORS 相关错误,Network 面板里请求是红的。
原因:数据服务没开Access-Control-Allow-Origin,浏览器直接拦掉。这个错在fetch里表现为一个笼统的网络错误,不点开 Network 面板很难定位。
解决:确认服务端响应头;本地开发可以起一个代理把请求转发出去;如果对方只支持 JSONP 那就没辙,得走服务端中转。
5.5 台站一多页面就卡死
现象:5 个台站流畅,20 个台站拖动时间轴时掉到个位数帧率。
原因:每条波形几万个点全部生成路径节点,SVG 节点数上去了,浏览器布局和重绘都吃不消。
解决:按像素抽稀,保证每个像素列最多一个点;拖动时只改 transform,松手后再重绘;台站超过 30 个改用分页或者按需加载时间窗。
6. 二次开发:给自有后端写一个数据源适配器
真实的改造需求通常不是改绘图,而是接数据。单位内部的数据服务往往有自己的接口约定,返回的既不是 miniSEED 也不是标准 XML,而是一坨自定义 JSON。这时候不要去改解析器,正确做法是在「数据源」和「Seismogram」之间加一层适配器,把外部格式翻译成 core 层的对象,上面的渲染代码一行不用动。
6.1 适配器的写法与边界
/** * 把内部接口返回的 JSON 转成 Seismogram 数组 * 约定:resp.data 为 counts 数组,resp.meta 含台站与时间信息 */ async function fetchFromInternalApi({net, sta, cha, start, end}) { const url = `/api/waveform?net=${net}&sta=${sta}&cha=${cha}` + `&start=${start.toISOString()}&end=${end.toISOString()}`; const resp = await fetch(url).then(r => r.json()); return resp.data.map(seg => new Seismogram({ networkCode: seg.meta.net, stationCode: seg.meta.sta, locationCode: seg.meta.loc || '', channelCode: seg.meta.cha, // 后端给的是毫秒时间戳,必须显式转 Date startTime: new Date(seg.meta.startMs), sampleRate: seg.meta.sr, y: Float32Array.from(seg.samples, v => v - seg.meta.bias), // 顺手扣掉偏置 })); }边界要守住两条:适配器只做格式翻译,不做滤波、不做重采样,那些属于处理链,混在一起以后复用不了;返回的必须是 core 层的标准对象,不能让绘图模块感知到内部接口的存在。
6.2 验证顺序:先断言字段,再看图
改造完别急着画图。先写一段断言,把头字段和已知值对一遍:
const list = await fetchFromInternalApi({net: 'XX', sta: 'AAA', cha: 'HHZ', start, end}); const s = list[0]; console.assert(s.sampleRate > 0, '采样率异常'); console.assert(Math.abs(s.endTime - s.startTime) > 0, '时长异常'); console.assert(s.y.length === Math.round( (s.endTime - s.startTime) / 1000 * s.sampleRate) + 1, '点数与采样率不自洽');第三条断言最关键,它能一次性抓出时间单位错、采样率错、丢帧这三类问题。我以前图省事跳过这步直接画图,结果图上看着正常,一放大就发现每段拼接处有 0.1 秒的缝隙,回头查了两天才发现是后端把秒当毫秒传了。
6.3 一点经验
改造这类库,最省时间的做法是先把「假数据」的完整链路跑通,再逐步换成真实数据源,每换一步跑一次断言。从那以后我每次接新数据源,都先写一个只打印头字段的小脚本跑通,再动手写渲染代码,前面多花的二十分钟,往往能省下后面半天的排查。希望帮到你。
本文还有配套的精品资源,点击获取