跟着一条 SPECI 走流水线:从电报到界面

系列第 3 篇 · 气象数据前端实战。第 2 篇我们把三种报文的格式与体系讲明白了,这一篇兑现当时的预告:看 metweave 怎么把实况的两种(METAR/SPECI)变成界面上的圆点和卡片。文中所有代码与输出都在 npm 0.2.1 上实测过,你抄下来就能跑。

先看一段真实发生过的历史。2026 年 10 月 1 日下午,广州白云机场(ZGGG)的发布频道是这样刷屏的------报文时刻是世界时,下面的 07 时即北京时 15 时:

text 复制代码
07:00 METAR ZGGG 010700Z 12004MPS 9999 -SHRA FEW020CB SCT043 35/26 Q1009 NOSIG

07:15 SPECI ZGGG 010715Z 13003MPS 090V150 9999 SHRA FEW020CB SCT043 35/25 Q1010 BECMG AT0745 TSRA SCT026CB BKN033

07:20 SPECI ZGGG 010720Z 14002MPS 090V190 9999 +SHRA SCT020CB SCT043 35/25 Q1010 BECMG AT0745 +TSRA

07:24 SPECI ZGGG 010724Z 23006G16MPS 200V260 8000 +SHRA SQ SCT020CB SCT043 34/25 Q1010 BECMG AT0745 +TSRA

07:47 SPECI ZGGG 010747Z 30008G13MPS 260V340 3000 +TSRA SQ SCT020CB SCT043 25/24 Q1011 WS R02L BECMG AT0815 TSRA

07:51 SPECI ZGGG 010751Z 02003MPS 340V040 2000 R19R/P2000 R19L/P2000 R20R/1700D R20L/1300D R21/1600D +TSRA SCT020CB SCT043 24/23 Q1010 BECMG AT0815 TSRA

(9999=10 公里及以上,能见度组的写法上限;G16=阵风 16 米/秒;SQ=飑,突发的强阵风。)

整点的例行报告(METAR)说:小阵雨------但留意云组里那朵 FEW020CB:积雨云从第一分钟就在场。然后天气自己按了加速键------从例行班算起的 51 分钟里连插五条特殊报告(SPECI) :阵雨、强阵雨、强阵雨带飑、雷暴带飑加风切变告警,最后一条把跑道视程也写了进来。盯着能见度那栏看更直观:9999 → 8000 → 3000 → 2000,一路跳水。上一篇文章讲过「天气不等整点,报文就不等」,这一串就是实证。报文尾上那半句趋势预告也不是废话------07:15 那条就写着 BECMG AT0745 TSRA(07:45 起转雷暴),07:47 的插播里雷暴如期而至,剧本应验了。(这 51 分钟里还夹着 07:30 半点的那班例行 METAR------那条里部分跑道视程已跌到 900 米,戏也很足,但主角另有其报,表中略去不引。篇 2 说过密班站「可以不再对外发 SPECI」------那是标准给的选项,国内密班站照发,所以你才看得到这串插播。)

这一篇的主角,就是 07:51 那条 SPECI------五组跑道视程(ICAO 附件三对 RVR 编报的数量上限是四条最重要的跑道,五条全编是国内多跑道大站的密编方言,别当普适模板去别站套)、一场强雷暴、两层云、一段趋势预报,一条报文里挤满了观测员当时最想说的话。我们跟着它走完 metweave 的整条流水线:电文进、界面出------解析、卡片、着色,每一站停下来看两眼,顺便告诉你每个环节的定制入口。上一篇你学会了读报文,这一篇看机器怎么读、又怎么翻译给人看。

一、先看全链:五个包各干什么

metweave 拆成五个包,主角报文要依次穿过它们------包、文件、方法的调用关系一张图看全:

  • @metweave/core:定契约------IR(中间表示)长什么样,是解析器和渲染器之间唯一的约定

  • @metweave/parser:干翻译------把 METAR/SPECI(以及 v0.2 起的 TAF)解析成 IR

  • @metweave/render:出卡片------零框架的纯 DOM 组件,人话卡片

  • @metweave/leaflet:上地图------圆点、悬停摘要、点击弹窗

  • metweave/sources :管取数------从公开渠道把 raw 报文拉回来(第 1 篇讲过,这篇不再展开)。注意它没有 @ 前缀:它不是独立包,是伞包 metweave 的子路径导入(import ... from "metweave/sources"),装一个伞包全都有

本篇的代码片段都在篇 1 的项目里按节顺序接着写,变量互相延续。装包账先算清:篇 1 装过 leaflet、metweave、@metweave/leaflet,本篇再补两个单包------npm i @metweave/parser @metweave/render;或者干脆都从伞包拿:metweave 伞包 re-export 了 core/parser/render 三包,import { parse, renderCard } from "metweave" 一样能用,只有 @metweave/leaflet 的地图件必须单独装。

第 1 篇你已经用十行代码把全链跑通了。那篇文章回答的是「怎么跑」,这一篇回答「里面发生了什么」------因为一旦你想改点什么(换个语言、换个颜色、多接一种报文),就得知道每一站给了你哪些把手。

二、第一站·解析:IR 是张体检单

把主角喂给解析器:

ts 复制代码
import { parse } from "@metweave/parser";

  


const speci =

"SPECI ZGGG 010751Z 02003MPS 340V040 2000 R19R/P2000 R19L/P2000 " +

"R20R/1700D R20L/1300D R21/1600D +TSRA SCT020CB SCT043 24/23 " +

"Q1010 BECMG AT0815 TSRA";

  


const ir = parse(speci);

返回的 ir 就是 IR(Intermediate Representation,中间表示)------你可以把它理解成一张体检单:报文里的每个组在单子上各有一栏,栏里是体检科目的数值。截几个关键栏给你看(真实输出,只删了不影响理解的字段):

ts 复制代码
ir.kind; // "speci" ------ 类型位:这条是特殊报告

ir.station; // "ZGGG"

ir.time; // { day: 1, hour: 7, minute: 51 } ------ UTC

ir.flags; // { auto: false, corrected: false }

ir.visibility;

// { kind: "value", value: { value: 2000, unit: "m", exact: true } }

ir.runwayVisualRange?.value?.[2];

// { runway: "20R", value: 1700, unit: "m", trend: "down" }

ir.weather?.value?.[0];

// { intensity: "+", descriptor: "TS", phenomena: ["RA"] } ------ 强雷暴伴雨

ir.clouds?.elements?.[0];

// { amount: "SCT", heightFt: { value: 2000 }, convective: "CB" }

ir.trends; // [{ kind: "becmg", raw: "BECMG AT0815 TSRA" }]

ir.warnings; // [] ------ 干净,无告警

这是控制台风格的读法:DevTools 控制台或 Node REPL 里逐行敲就有回显,写进 .ts 文件要自己 console.log。想在代码里取能见度数值,得剥两层------ir.visibility.kind === "value" 时,ir.visibility.value.value 是 2000。

有四个设计值得停下来看一眼,它们都出自「电报是个百年老协议,脏是常态」这个现实:

其一,三态而不是两态。 用体检单的话说:有的栏印着数值;有的栏印着「测不到」------设备自己招了,这是信息,不是错误;还有的栏压根没印,这项没测。落到代码里,就是每个要素栏都包着一层 { kind: ... }:value 是有值,missing 是显式缺测 (比如风向组的 /////KT),整栏不存在则是 undefined(组省略)。三态缺测是上一篇文章「斜杠语言」的工程侧落点:/// 会被原样收进体检单,而不是被当成空值吞掉。

其二,一切产物带原文坐标。 每个数值里都藏着 span(比如能见度 2000 的 { start: 36, end: 40 })------指向它在 raw 报文里的字符区间。卡片上「能见度 2000 米」这行字,可以精确反查到电码原文的那四个字符。翻译不敢说「信达雅」,但敢让你逐字对账。

其三,不认识的组不静默。 未知组、可疑磨损统统进 warnings[] 出声,绝不悄悄丢弃。主角这条很干净(warnings: []),但你在真实数据里泡久了会见到气压组抄成 Q10054 的磨损噪声------解析器不采信这个数值:判缺测、出一条 warning 把原码指给你看,装没看见是不礼貌的。

其四,三个独立的位,不是一个「类型」字段。 上一篇文章里你分别见过这三个标记(类型词、AUTO、COR),这里把它们立成三个互相独立的位:kind(METAR/SPECI)、flags.auto(自动站)、flags.corrected(更正报)。IR 把这份独立性原样保留------一条自动站发的、更正过的 SPECI,三个位同时为真,不存在「类型」字段装不下的情况。

三、插一段:更正报 COR

正说到那几个标记位,就顺手把 corrected 位点亮一次。METAR 发错了字怎么办?发一份带 COR 的更正报。取一条北京的真实更正报:

text 复制代码
METAR ZBAA 080330Z COR 30003MPS 270V330 9999 -RA NSC 20/15 Q1022 NOSIG

(例文里的 NSC 是「无重要云」,第二篇第七节见过。)

ts 复制代码
const cor = parse("METAR ZBAA 080330Z COR 30003MPS 270V330 9999 -RA NSC 20/15 Q1022 NOSIG");

cor.flags.corrected; // true

两个细节。第一,中国站的 COR 惯例放在时组之后(080330Z COR),美国站常放报头(METAR COR ...)------位置有惯例不写死,解析器两种都收。第二,注意分寸:观测线的修正是 COR(发错了改字);你上一篇文章认识的 AMD(天气变了重写一份)是 TAF 预报的修正族------那属于下一篇的领地,这里只点一句。

corrected: true 落到卡片上,就是一枚琥珀色的「更正报」徽标------与「例行报告」并挂,身份一眼可辨。

四、第二站·卡片:电码怎么变人话

体检单是给程序看的,人是看不懂 { descriptor: "TS", phenomena: ["RA"] } 的。渲染层把它翻译成人话卡片:

ts 复制代码
import { renderCard } from "@metweave/render";

  


const card = renderCard(ir, { raw: true }); // DOM 元素,挂哪都行;卡片样式内建不用引 CSS,但需要浏览器环境(要操作真实 document)

document.querySelector("#card-slot")!.append(card); // 页面里先放一个 <div id="card-slot">;! 是 TS 的「我保证它存在」断言

主角的卡片顶部挂着一枚「特殊天气报告」徽标------这是 SPECI 与 METAR 在卡片上唯一的身份区别,其余栏目完全同构。这个徽标有个小故事:早期版本曾给 SPECI 同时挂「例行报告 + 特殊天气报告」两枚徽章,内部复评时被毙了------SPECI 本来就不是例行报告,双挂等于自我矛盾。现在的规则很简单:类型徽章二选一。徽标措辞「特殊天气报告」对齐的是 AP-117 口径------早期版本写的是「特殊报告」四个字,后来照着规范改了过来。

往下走,每行一个科目,全部来自刚才那张体检单:

  • 风组一行:20° 3 mps(风向在 340° 与 040° 间变动)------风向多变扇区翻译成了括号补语

  • 跑道视程(RVR)一行五条:跑道 20R:1700 米,趋势下降------R20R/1700D 里那个 D 后缀(下降趋势)没有丢,P2000 的「高于 2000 米」也是如实翻译(超量程是量程的事,不是数值)。RVR 组的编报有门槛:主导能见度或任一跑道 RVR 低于 1500 米才编(ICAO 附件三 4.3.6 的口径)------这就是 07:15--07:47 那四条插播都没有 RVR 组、07:51 突然五组的原因:那时能见度虽还有 2000 米,R20L 已经跌到 1300

  • 天气一行:强雷暴伴雨(飞行威胁大)------红色标注,危险项升档

  • 云层一行:疏云,云底约 610 米(CB 积雨云:雷暴、冰雹、强颠簸风险)------CB 的风险注释是卡片里少数带「解释性」的文字,它说的是这类云的物理属性,不是天气研判

每一行都能反向对账:卡片底部的 RAW 对照区(raw: true 开启)把原始电码贴在虚线框里,缺测和告警组高亮,悬停有解释;行内的电码片段点一下(悬停会先点亮同组两侧),浮出「原码 → 含义」的转换说明气泡,底部带规范依据行。「翻译有没有骗你」这个问题,设计上就留了验算通道。

五、卡片的双语与单位

同一张体检单,换个语言出卡:

ts 复制代码
renderCard(ir, { locale: "en" }); // 英文卡

renderCard(ir, { locale: "zh", heightUnit: "ft" }); // 中文卡,云底用英尺

英文卡不只是换个词表。对同一层云(SCT020CB),中文卡写「云底约 610 米」,英文卡写 base 2000 ft------610 米是换算值,2000 英尺是原码直读 。报文原生编码就是英尺,中国民航口径用米,所以缺省策略是「中文给米、英文给英尺」,heightUnit 可以强行统一。这呼应上一篇文章的「单位跟组走」:解析层保真原码,显示层按读者习惯,两边都不撒谎。悬停在任何云底数值上,都会给出米/英尺的双语对照与换算说明。

六、第三站·着色:圆点的档位从哪来

第 1 篇地图上那颗红圆点,档位是怎么判出来的?把判据函数直接调出来问:

ts 复制代码
import { metarTierOf, TIER_COLORS } from "@metweave/leaflet";

  


metarTierOf(ir); // "poor" ------ 红档

TIER_COLORS.poor; // "#d05656"

四档全名是 good(绿)/ caution(琥珀)/ poor(红)/ unknown(灰)。口径全表收拢在这(第 1 篇给过完整版,这里重贴一份------收藏点,值得占你一块屏):

档 命中任一即入档
🟢 良好 其余情况(含 CAVOK 好天气简报)
🟠 注意 主导能见度 1.5--5 km;BKN/OVC 云层及垂直能见度(VV)1000--3000 ft;降水(含阵雨 SHRA);阵风 15--25 m/s
🔴 差 主导能见度 <1.5 km;BKN/OVC 云层及垂直能见度(VV)<1000 ft;雷暴族(TS 描述符,含 TSRA 与邻近 VCTS);冻降水族(FZ 描述符:冻雨 FZRA、冻毛毛雨 FZDZ、冻雾 FZFG 等);冰雹/火山灰/强降水;积雨云(CB/TCU);阵风 ≥25 m/s;跑道关闭(报文内跑道状态组)
⚪ 未知 该站无观测(NIL)或关键要素缺测------宁可 unknown 也不给绿灯

注意云底两行的 BKN/OVC 限定 :FEW/SCT 不参与云底判档------密云才谈得上「盖」,疏云不算(FEW002 不会因为「云低」入档)。天空整片被遮住、报不出云层时,电码会用 VV(垂直能见度)组顶上------它就是那种天气下的云幕高(ceiling)等价物,按同一把尺参与判档。风的口径也交代一句:判据只看阵风的量级,不含侧风分量------侧风是跑道尺度的概念,和 RVR 一样不混进全场档位;开场那串里闪过的 SQ(飑)和 WS R02L(02L 跑道风切变告警),这一版同样不参与判档,增补在产品路线里。然后拿主角逐条对号,看它为什么是红:

命中项 档位
+TSRA 强雷暴(TS 描述符) 红
SCT020CB 积雨云(CB) 红
能见度 2000 m(1.5--5 km 区间) 琥珀
跑道视程、趋势组 不参与判档(RVR 是跑道尺度,与主导能见度口径不同)

多档命中取最差,所以是红。注意第四行那条「不参与」------判据刻意只看 METAR 主导能见度,不拿 RVR 混算,口径不同的东西混在一起会得出看着严谨、实则错误的结论。

这套判据不是一次拍脑袋定稿的。0.1.2 版本有过一次公开修订:一位签派员读者------航空公司运行控制里负责放行航班的人------复核指出,冻降水原来放琥珀档不妥------「对运行场景,冻降水危害与雷暴同级」,于是升红(按 FZ 描述符整族生效,冻雾 FZFG 也在内);能见度分档同时弃用了参考美国民间飞行天气分档(Marginal VFR,边际目视飞行条件)换算来的边界值,改用 1500/5000 米口径------1500 是规范里的实门槛(SPECI 触发档、RVR 编报门槛都用它),5000 是自选值,量级参考国内常用能见度界限,取舍理由判据文档可查。判据本身也有自己的变更日志 ------这也顺带回答了那个常见问题:这四档对应哪条官方标准?不对应。它是 metweave 自拟的扫视启发式,每条有出处、文档可查,但不能拿来当运行判据 ------页面告诉你「哪片天空在变坏」,飞不飞得成是另一门课的事。真想按自己的标准重分档?metarTierOf 是导出的纯函数,拿 IR 自己写一版判据完全合法;把判据变成图层和卡片可直接注入的选项,也在产品路线里(时间表不承诺)。

七、你的定制入口

「开箱即用」不等于「黑箱」。沿着流水线,每个站都留了把手(本节写给要动代码的人------飞友读者直接跳下一节看地图,完全不亏):

  • 卡片 :locale / heightUnit 刚见过;className 给宿主附加类名;stationTitle 挂站点全名(IR 里只有四字码,库不捏造它不知道的名字,全名来自你的站点元数据);utcOffsetMinutes 控制观测时刻显示时区(缺省 UTC,中文卡传 480 显示北京时)

  • 着色 :TIER_COLORS 整表导出------你的图例、面板、列表色点直接引用它,别自抄色值(两套色系并存是真实发生过的事故;四档色值:good #3aa657 / caution #e0a13c / poor #d05656 / unknown #8a94a0);四档档位名同源直读,图例文字不用自拼字符串。品牌色接入就是一条 CSS 覆写------圆点的 CSS 类是 mw-dot-poor / mw-dot-caution / ...,但圆点底色是行内样式,普通选择器压不过,记得带 !important:

css 复制代码
/* 品牌色覆写四档圆点(示例:品牌蓝系) */

.mw-dot-good { background: #0ea5e9 !important; }

.mw-dot-caution { background: #6366f1 !important; }

.mw-dot-poor { background: #1d4ed8 !important; }

.mw-dot-unknown { background: #94a3b8 !important; }
  • 上图 :addMetarLayer 的 conditionColors 开关(第 1 篇见过)、popup、卡片选项透传(card)、Leaflet 弹窗参数透传(popupOptions)

  • 告警 :卡片告警行带 mw-info / mw-warning / mw-error 严重度类,下游 CSS 可按级定向着色

八、收拢:把 SPECI 画上地图

流水线走完了,把这一小时的两条报文一起画上图------07:00 的例行报告,和 07:51 的主角(完整可跑样板在仓库 examples/,篇 1 文末给过一条命令起):

ts 复制代码
import * as L from "leaflet";

import "leaflet/dist/leaflet.css"; // 别漏:不引 CSS 地图不渲染(篇 1 踩过的坑)

import { parse } from "@metweave/parser";

import { addMetarLayer } from "@metweave/leaflet";

  


const zggg = [23.392, 113.307]; // 白云机场,WGS-84------39 站坐标不必手抄:metweave/stations-cn 子路径导出 CN_STATIONS,坐标站名现成

const map = L.map("map").setView(zggg, 11); // 地图容器 <div id="map">,建图与底图配法照篇 1 样板

  


await addMetarLayer(map, [

{ report: parse("METAR ZGGG 010700Z 12004MPS 9999 -SHRA FEW020CB SCT043 35/26 Q1009 NOSIG"),

position: zggg, title: "ZGGG 例行 07:00" },

{ report: speci, position: zggg, title: "ZGGG 特殊 07:51" }, // speci:§二 定义的 07:51 主角报文

], { conditionColors: true });

实测下来,同站两条报文就是两个圆点、同坐标叠放、各自挂各自的卡片,图层不做合并。这是「不静默」哲学的又一次延续------合并意味着替你决定「留哪条」,而 07:00 和 07:51 各自有自己的事实。同坐标叠放的代价是鼠标只能点到最上面那颗------想看 07:00 那条,键盘 Tab 过去,或把两条的 position 故意错开几像素;图层不替你散点,理由同上,怎么摆是你的事。如果你的场景只想看最新一条,自己排个序取末条(三行代码的事),决定权在你。

addMetarLayer 的返回值也别扔------它返回可整体移除的图层组(LayerGroup):整点换班、SPECI 插播到达时,remove 掉旧组、带着新报文重建一次,就是最朴素的刷新姿势。实况页面的「活」,全靠这一进一出。

键盘与读屏也都通:Tab 走到圆点,Enter 或 Space 开卡片,Esc 关;圆点给读屏软件配了档位语音标签(aria-label,「天气差」这样的中文短语)------颜色传达的档位信息,读屏用户一样拿得到。这套交互语言在 v0.2 里和 TAF 卡片统一成了同一套契约。

九、收个尾

跟着 07:51 那条 SPECI,我们从一份五跑道雷暴电报走到了地图上的一颗红圆点:解析层不撒谎(三态缺测、原文坐标、出声告警),渲染层会翻译(人话卡片、双语单位、RAW 对账),着色层有出处(判据文档化、修订留痕)。观测线的两种报文到此走完。

实况是现在时,预报是将来时。下一篇文章见 TAF:跨日有效期怎么展开、BECMG/TEMPO/PROB 怎么画成时间线、一份 30 小时的「剧本」怎么逐小时回放------v0.2 已经把这些能力发到了 npm 上。

YaoJaro · 独立开发者,正在造 metweave------把公开气象数据编织成可嵌入产品的图。

系列会一直写下去,欢迎在掘金关注 @MetWeave。

相关推荐
Helix2502 小时前
Chrome 开发者工具进阶:长截图、网络调试与性能分析
前端·chrome·chrome devtools·使用技巧·性能分析·开发者工具·长截图
liangshanbo12152 小时前
面试题:Webpack 的 publicPath 有什么作用?
前端·webpack·node.js
mjhcsp2 小时前
DeepSeek V4.1 Flash 批量处理效能实测
java·前端·数据库
CHEEVEN_QY3 小时前
新能源汽车电池托盘FSW量产的工艺稳定性控制
java·前端·汽车
Alice-YUE3 小时前
前端 × AI:从 Cursor 到 Transformer,一份完整认知路径
前端·人工智能·transformer·ai编程·前端开发·cursor
Code_Solitude4 小时前
C语言:关于二维数组的作业总结
java·c语言·前端
acd120094 小时前
前端随笔:数据明明变了,Vue 页面就是不更新
前端·javascript·vue.js
传人6 小时前
悬停旋转放大和位移效果如何写
前端·javascript·css
zhangzeyuaaa6 小时前
Ruby 方法参数完全指南:默认值、可变参数与关键字参数
前端·python·ruby