格点填色渲染:色标、分级与等值线怎么做才专业

101 阅读21分钟

气象数据前端实战 · 第六篇(高阶渲染篇)。前情:篇 1 十行代码跑通地图、篇 2 三种报文一次讲明白、篇 3 跟着一条 SPECI(机场特殊观测报文)走完观测线、篇 4 把一份 TAF(机场预报报文)展开成时间线、篇 5 报文正在换成 XML。上一篇结尾留了一句话——站点是点,网格是面;这一篇兑现「面」。

天气从来不是散点。

站点图把 39 个机场画成圆点,告诉你「这里多少度、那里什么天气」——每个点都是一个机场的此刻。可真实的空气是连成一片的:冷空气从西北压过来是一整块,台风周围的雨带是一圈,高压向东南延展是一大片。要把「一片」画上地图,就得从点走到面——这就是格点填色。

先交代本篇的讲法。前五篇你用的都是封装好的组件:篇 1 那张站点图,用的是 metweave 工具箱里的高层接口。这一篇往下挖一层——要把面画得专业,光会调组件不够,得懂它脚下的渲染内核。好在这个内核是开源的:@metweave/grid 把色标、档位、等值线的算法全部做成了一颗颗直接调用的函数,摆在明面上。读完你不只会「用」,还能照着改出一套自己的。不写代码的读者照旧有位置:一、二、三、五节不需要代码背景,代码段照旧可以跳过,不亏。

全篇主角先请出来:海平面气压场——把各地气压统一折算到海平面高度的值,不然高原和沿海没法放在一张图上比。它色斑、等值线、高低压中心三样俱全,是「面」题材最标准的教具。文中示例数据全部出自真实预报场——一份 GFS(Global Forecast System,美国全球预报系统)公开预报,取自 NOMADS(NOAA 的数值预报数据公开分发系统),公有领域授权;中国域 280×208 格点,存储单位帕斯卡,显示折算成百帕。

先认识「场」:格点图画的到底是什么

把「面」变成计算机能处理的东西,靠的是场(field):在一定地理范围内,规则间隔的网格上每个点各有一个数值。温度场是每个格点一个温度,气压场是每个格点一个气压。

把它想象成一张巨大的 Excel 表:横排是经度(东西方向),竖排是纬度(南北方向)——不是 A、B、C 列号,是地球上的位置;格子里填的数,就是那个位置的观测或预报值。主角这份场表是 280 列 × 208 行,每个格子覆盖 0.25° × 0.25°,半开窗覆盖 55°N–3°N、70°E–140°E(格点中心自 55°N/70°E 起算)——中国及周边大片区域。有了这张表,计算机才知道哪块气压高、哪块气压低。

格点数据的来路通常有两种:GRIB2(世界气象组织的格点数据标准格式,各国气象机构交换预报场都用它)和 MWGRID(@metweave/grid 自带的轻量容器,把网格参数和数值数组打包成一个可直接解析的文件)。不管哪种来路,进渲染内核前都先归一成同一个对象:


import { parseGrid } from "@metweave/grid";

import { readFile } from "node:fs/promises";

  


const grid = parseGrid(await readFile("./prmsl_cn.mwgrid"));

  


grid.header.variable; // "PRMSL"(变量名)

grid.header.unit; // "Pa"(存储单位)

grid.header.grid; // 网格参数:280×208、0.25° 步距、左上角 (55°N, 70°E)

grid.header.stats; // 预先算好的分布统计:min / max / p10…p99

grid.header.meta; // 溯源行:哪个预报模式(如 GFS)、这次预报几点起跑、什么许可

grid.values; // Float32Array,58240 个数值(280×208),缺测是 NaN

先交代样例来路:prmsl_cn.mwgrid 不随 npm 包分发——开源仓里 pnpm gen:grid 一条命令就能生成(就是第七节那条转换链);下文引用的数值都是这份样例的真实输出,你也可以自己生成一份逐数对账。

这个对象叫 Grid。接下来所有功夫,都是把它的 values 数组变成你屏幕上的颜色和线条。全链路见下图:①② 是进内核前的事——来路与归一,刚才讲完;⑦ 是出内核后的事——交给宿主上图。中间 ③–⑥ 是内核本体:③④ 把档位口径和颜色契约立好,⑤ 逐像素执行取值→定档→着色,⑥ 另出一路取线。对应四步功夫:

00-渲染内核链鸟瞰图.png

取值 → 定档 → 着色 → 取线:每个像素先插值取一个场值,按档位切进某一档,每档填一种颜色铺成色斑;气压场这类再叠一圈等值线。顺带交代依赖关系:篇 1 站点图用的 @metweave/leaflet 包,其格点装配层内部用的正是 @metweave/grid——本篇讲的就是这层底下的东西。

一、色标:一档一色,颜色怎么搭才专业

01-色标对比图.png

同一份温度场,两种画法:左边从深蓝一路渐变到深红,右边冷暖两套色相、零度附近留白。第一眼看左图「更连续」;看十秒你会发现右图才能回答问题——哪块在零下、哪块暖得最凶,一眼定位。(两张图的范围也不同:左图按数据实际范围拉伸,右图用档案固定域 −40 到 40°C——这个差别第二节会用到。)

差别就在色标(ColorScale)。@metweave/grid 把一份专业色标拆成四个槽,各管一件事:

槽管什么
breaks档断点——把连续数值切成几档的边界
colors色带停靠色——每档按档中心在带上取色
transparentBins透明档——哪些档不画、透出底图
logScale对数开关——定档前要不要先取对数

搭色标不用从零手搓,每个要素(温度、气压这类气象变量)的档案里都配好了默认值,一个函数装配:


import { buildColorScale } from "@metweave/grid";

  


// tmpProfile:2 米温度的要素档案(第六节细说档案是什么)

// (等间隔档位用档案固定域,与传进来的这份场是谁无关;stats 在分位数模式才参与)

const scale = buildColorScale(tmpProfile, grid.header.stats);

  


scale.breaks; // [-36, -32, …, 32, 36]——每 4°C 一档,共 20 档

scale.colors; // 9 个停靠色(渐变杆上的色钉),冷蓝到暖红

四个槽里藏着四门「怎么才专业」的功夫,逐个说。

colors 决定读图成本。 档和色的接线规则先说清:每一档按自己的中心位置在色带上取一个色——档多钉少时相邻档取色相近,钉摆得巧,每档都能拿到纯色。专业色标的规矩:冷暖两套走向相反的色相——冷蓝暖红、中间浅色过渡,读者不看图例也能达成「蓝 = 冷、红 = 暖」的条件反射;最扎眼的颜色留给最要紧的档。现成的范例是模拟反射率(变量名 refc,模式算出来的雷达回波强度,单位 dBZ):美国国家气象局(NWS)的雷达色标——弱回波青绿,中强一路黄、橙,强回波红到紫——各国雷达图大多长这样,颜色梯度就是强度梯度,看一眼颜色就知道雨有多大。档案里这份色标还有个细节:16 个颜色每个写了两遍——成对重复让每一档的中心恰好钉在纯色上,档间不糊出过渡色。

transparentBins 解决「没数据的地方别硬画」。 降水率场里,「没降水」(零值)和「没数据」(缺测)是两回事:零值不值得上色,该透出底图,而不是糊一层「零值灰」。对流有效位能(CAPE,表征大气对流潜势的量,单位 J/kg)、降水率、反射率三个档案都把首档设成了透明——低于阈值的区域不参与视觉。

这里有个容易踩的细节:整体不透明度 opacity(渲染选项)不会复活透明档——图层调到 30%,透明档还是全透明。「这一档不画」是档位自己的决定,跟你调多淡无关;别指望调 opacity 把缺测区「逼」出来。

logScale 给「跨量级」的场。 降水率大部分时候每小时零点几毫米,暴雨时能到几十毫米,横跨两三个量级;线性定档,绝大多数档挤在数值底部,图上几乎看不出层次。先取对数再定档,0.1 / 1 / 5 / 10 mm/h 这几档就被拉开了。有个约定:断点仍按原值书写——图例上读到的还是 0.1、1、5、10 这些人类数字,对数变换只在渲染定档那一刻发生。断点必须为正(对数里没有零和负数的位置),配错了渲染前直接报错,不静默糊过去。

二、分级:同一场数据,三种切法

色标解决「每档什么颜色」,分级解决「档从哪来」。@metweave/grid 把三种定档算法都拆成了裸函数:


import {

equalStepBreaks,

quantileBreaks,

thresholdBreaks,

binIndexAt,

} from "@metweave/grid";

  


// ① 等间隔:[min, max] 均分 bins 档,返回中间的档界

equalStepBreaks(0, 50, 7);

// → [7.14, 14.29, 21.43, 28.57, 35.71, 42.86](6 个断点切出 7 档)

  


// ② 分位数:按数据自己的分布切,直读预先算好的分位数数组(p10…p99)

quantileBreaks(grid.header.stats, [0.1, 0.25, 0.5, 0.75, 0.9]);

  


// ③ 业务阈值:你说了算,原样收下

thresholdBreaks([0.1, 1, 5, 10]);

同一份气压场,三种切法得到三张观感完全不同的图——

02-同场三分级对照图.png

等间隔每档一样宽,不同时次的图可比(今天和下周都用同一套档界);分位数让每档按分位比例分走格点——中间的档装得最多,两端的档各装约一成,分布再偏的场也能层次分明,代价是档界跟着数据走、前后两版图不能直接对比;业务阈值则把行业惯例直接写进图里(02 图右幅是演示机制的示例——气压场并没有现成的色斑阈值惯例)。没有最好的切法,只有合不合用途的切法。 用分位数有两个小坑:一是 quantileBreaks 吐出的断点是存储单位原值,进色标要换算到显示单位——buildColorScale 已替你换好,手拼 ColorScale 时记得这一步(transparentBins 同理要传 Set 而不是数组);二是分位点只能取预计算好的 0 / 0.1 / 0.25 / 0.5 / 0.75 / 0.9 / 0.99 / 1 这几档,传别的直接报错。

切完之后,每个数值落进哪一档,由 binIndexAt 说了算。先记住:断点表只存档与档的分界,不含两端——首尾各有隐形的外延档兜底:


const breaks = [0.1, 1, 5, 10]; // 4 个断点,5 档

  


binIndexAt(0.05, breaks); // → 0(低于首断点,首档兜住)

binIndexAt(0.1, breaks); // → 1(档是左闭右开:含左边界不含右边界)

binIndexAt(3, breaks); // → 2

binIndexAt(10, breaks); // → 4(≥ 末断点,末档兜住)

binIndexAt(NaN, breaks); // → -1(缺测走透明,不上色)

低于首断点的归档 0,不低于末断点的归末档——加上缺测 NaN 返回 −1(走透明),所有输入都有一个确定的归宿。

三、档位的专业感从哪来

三种算法都在手上了,到底用哪种?答案不在算法里,在数据和行业惯例里。13 个要素档案的默认配置摊开看,规律非常整齐:

要素默认分级为什么是它
降水率 prate阈值 + 对数0.1/1/5/10 mm/h 是模式降水率显示的常用分档(惯例参考,非硬标准);跨量级需对数拉开
能见度 vis阈值0.4/0.8/1.5/3/5/10 km 参考航空低能见度相关阈值,档位近乎安全等级
模拟反射率 refc阈值5 dBZ 一档步进到 75,沿雷达气象惯例
对流有效位能 cape阈值1000/2500/4000 J/kg 对应惯用的对流潜势分档,首档透明收掉零值堆
温度 tmp、相对湿度 rh 等等间隔0–100%、−40–40°C 这类自然量纲均匀分布,固定域均分最稳
(任何要素皆可)分位数留给「没惯例可依、分布又怪」的场——13 个档案没有一个默认用它

看出规律了吗——档位不是随便定的,来源只有三种:业务阈值、数据分布、量纲特性。有行业惯例的,把惯例写进阈值,图直接对上行业标准;量纲天然均匀的,固定域等间隔,不同时次可比;两者都不沾的场,才轮到分位数按分布说话。专业感不在颜色好看,在档位契合场的物理与业务语义。

四、逐像素渲染:档怎么变成像素

定档和色标都齐了,最后一步是机械活:把每个像素填上颜色。renderToImageData 干的就是这个——一个纯函数,不碰浏览器、不碰 DOM,Node 里直接跑:


import { buildColorScale, renderToImageData, prmslProfile } from "@metweave/grid";

  


// grid:开头 parseGrid 出来的主角气压场

const scale = buildColorScale(prmslProfile, grid.header.stats); // 主角档案装配色标(机制第一节讲过)

  


const img = renderToImageData(grid, scale, {

convert: prmslProfile.toDisplay, // 存储单位(帕)→显示单位(百帕),默认恒等

opacity: 0.6, // 整体不透明度乘子,默认 1

// width: 800, // 输出宽度(像素),默认=网格原生分辨率

});

  


img.width; // 280

img.height; // 208

img.data; // Uint8ClampedArray,RGBA 四通道,行序北→南、行内西→东

它对每个像素做四步:映射——算出像素对应的网格小数坐标;插值——双线性插值取场值(周围四个格点按距离加权平均,不是就近抓一个硬凑);定档——convert 换算单位后 binIndexAt 落档(对数色标先取对数);取样——按档在色带上取色,连同透明度写进 RGBA。

「双线性插值 + 逐像素重采样」是色斑图平滑无网眼的原因。输出分辨率与网格分辨率解耦:放大(输出像素多于格点,所谓超采样)或缩小(少于,即降采样),插值都发生在场值层面——每个新像素都重新问「这个位置的真实场值是多少」,而不是把已有像素拉伸复制(拉伸复制才有马赛克)。实测这份 280×208 的气压场,原生分辨率十毫秒上下,800 像素宽八九十毫秒(因机器而异)。

两类像素恒为全透明,跟 opacity 无关:缺测 NaN(任何一个参与插值的格点缺测就按缺测处理——缺测邻居的「推测值」比空白更糟),以及命中 transparentBins 的档。

这套东西可复现:同样输入永远同样输出。想验证某个颜色为什么是它,沿「坐标 → 插值 → 定档 → 取色」四步徒手就能算到。包里还暴露了单点读值函数 interpolateBilinear——问北京(39.9°N, 116.4°E)的气压,它答 102130.8(帕,存储单位原值;÷100 即 1021.3 hPa),与逐像素插值完全同法,鼠标悬停读数就是它。

五、等值线:面之上为什么还要画线

色斑回答「这一片是多少」,但气压场、高度场这类,光有面不够——要线才专业。原因是个几何事实:等值线把「连续变化的场」变成「可数的线」,线疏的地方场值变化缓,线密的地方变化陡,一条线同时携带位置和梯度两层信息。

contoursOf 负责把线从场里捞出来:


import { contoursOf, centersOf } from "@metweave/grid";

  


const spec = { interval: 4, minorInterval: 2, highlighted: [] };

const lines = contoursOf(grid, spec, prmslProfile.toDisplay); // Pa→hPa

  


lines.length; // 19(这份样例的输出)

lines[0].value; // 1002——这条线的值

lines[0].kind; // "minor" / "major" / "highlighted" 三种线

lines[0].rings; // 闭合环族,每环是 [纬度, 经度] 点列,首尾相接(贴域边的线沿边界合拢)

lines[0].rings[0].length; // 977 个点绕一整圈

  


const centers = centersOf(grid, { convert: prmslProfile.toDisplay });

// 43 个中心:21 个低压 "L" + 22 个高压 "H",各带坐标和中心值

三个专业点展开说。

主线 + 加密线。 interval: 4 每 4 hPa 出一条主线,minorInterval: 2 再补一层每 2 hPa 的加密线——主线承担「读数值」,加密线承担「看形态」(这份样例实跑出主线 9 条、加密线 10 条)。这是专业天气图的常见画法之一:4 hPa 是国际主流,也有图用 2.5 hPa 单层(国内东亚地面图的传统),或等间隔、每 20 hPa 加粗一条。参数在你手上,判断标准只有一条:太稀看不清形态,太密糊成毛线团。这份场的域南端是热带洋面,气压梯度弱,线会天然稀疏——线少不是缺陷,是场平的诚实反映。

高低压中心。 centersOf 用滑窗极值检测找出局部最低、局部最高(低压 L、高压 H 中心),配上中心数值就是天气图上那些 L、H 标注。检测自带吸收半径(默认 16 个格点,0.25° 网格即 4°):相邻并列极值合并为一个中心,免得一个系统标出三五个 L。43 个看着多?原始模式场直接检测就是这样——实际产品常先平滑一步或调大吸收半径,中心数就回到天气图上熟悉的量级。图上最扎眼的是青藏高原一带的高压簇和密线团:海平面气压是把地面气压折算到海平面的值,高原几千米的抬升量会顶出虚假高压,这是折算的固有代价(第六节地面气压单设档案,正是为了绕开它)。

特值线。 highlighted 数组点名「这几条线给我画醒目点」。先补一块地基:气象图不只看地面,也看高空——高空按气压分层,500 hPa 大约是 5 公里上下高度的那一层,「500 hPa 高度场」记录的就是这一层在各处有多高(单位 gpm,位势米)。两个现成例子:温度档案点名了 0——冰点线,冬季图上是雨雪相态的分界参考;500 hPa 高度档案点名了 5880——夏季气象图上最受关注的一条线,国内业务常简称「588 线」。5880 这个数,恰落在副热带高压——夏季西伸至大陆上空的大块暖性高压——边缘常年活动的区间,气象图惯用它作视觉锚点标记这个系统的范围。本篇只讲这条线怎么画、怎么醒目;它往南进还是往北退、对应什么天气,那是预报员的事,图不管。(03 图是气压场,没有点名特值线;第七节你会跑出一张带 0°C 冰点线的温度图——console 输出里多出的那 1 条就是它。)

底层一句话:等值线追踪用的是 d3-contour,整个 @metweave/grid 唯一的运行时外部依赖——取环这种几何活是几十年的成熟算法,没必要自己重造。缺测的处理原则和渲染侧一致:绝不给缺测编数据——色斑侧全透明,等值线侧绕开缺测孔走,不硬穿。

03-等值线图.png

色斑 + 等值线 + 中心标注叠起来,气压场才算「专业制图」意义上的完整。

六、每要素一套档案:把专业感写进配置

读到这里你可能已经注意到:色标、档位、等值线画法,每讲一个要素都要另配一套。@metweave/grid 的答案是把这套配置固化成要素档案——包里 13 个要素(2m 温度 tmp、2m 露点 dpt、相对湿度 rh、对流有效位能 cape、降水率 prate、能见度 vis、模拟反射率 refc、海平面气压 prmsl、500hPa 高度 gh、地面气压 pres、850hPa 温度 t850、总云量 tcdc、10m 风 wind),每个要素一个文件,色标、默认档位、量纲换算、等值线画法、风向杆规格全部声明在自己档案里——改哪儿只改这一处。

为什么不能一套配置走天下?因为要素之间连「长得像的」都不同:海平面气压和地面气压是两个档案——后者要覆盖青藏高原,极高山区的格点,地面气压能低到 500 hPa 出头,拿海平面气压的 984–1044 hPa 域去套,高原整片糊成同一档。反过来,984 的下限到台风季会被深低压击穿——域是死的,要追台风就下探,域在你手里。单位更是五花八门:气压存帕、温度存开尔文、降水率存「千克每平方米每秒」,各自在档案里声明 toDisplay 换算,一处声明处处生效。

工程上的好处是加法式扩展:新增一个要素,就是新增一个档案文件,声明变量名、单位、色带、档位——渲染内核一行不改,既有要素的渲染像素一个不变(仓里的快照测试锁着这件事:渲染结果逐像素存档比对,一动就红)。你不需要「学会改引擎」,只需要「会写一份配置」。

七、端到端:跑出你的第一张格点图

收个完整的:从一份 GRIB2 原始文件,到色斑加等值线全部产出。环境只要 Node 20+,装一个包:


npm install @metweave/grid

数据两条路:随开源仓发布的公开样例 corpus/grid/gfs-0p25-tmp-2m-20261004-f000.grib2(约 0.5 MB,全球 0.25° 温度场,在仓库 corpus/grid/ 目录,clone 或单文件下载皆可,离线跑);或者去 NOMADS 现拉——nomads.ncep.noaa.gov 的 filter_gfs_0p25 页面,按起报时次勾选要素和子域下载。下面这段照抄可跑:


import { readFile } from "node:fs/promises";

import { decodeGrib2, fieldToGrid, cropGrid, CN_WINDOW } from "@metweave/grid/convert";

import {

buildColorScale, renderToImageData, contoursOf, tmpProfile,

} from "@metweave/grid";

  


// ① 解码 GRIB2 → 裁到中国域(280×208)

const buf = await readFile("./gfs-0p25-tmp-2m-20261004-f000.grib2");

const field = decodeGrib2(buf)[0];

const grid = cropGrid(fieldToGrid(field, { variable: "TMP", level: "2m", unit: "K" }), CN_WINDOW);

  


// ② 档案装配色标 → 逐像素渲染

const scale = buildColorScale(tmpProfile, grid.header.stats);

const img = renderToImageData(grid, scale, { convert: tmpProfile.toDisplay });

  


// ③ 等值线(档案自带画法:每 4°C 一条主线 + 0°C 冰点线加亮)

const lines = contoursOf(grid, tmpProfile.contours, tmpProfile.toDisplay);

  


console.log(img.width, img.height, lines.length); // 280 208 12

动手前知道一件事就好:自研解码器目前只认 GRIB2 的 5.2/5.3 两类数据表述模板——GFS 0.25° 恰是这一类,其他机构的数据源打包方式不同,先拿真实文件试解一步。img.data 是 RGBA 像素(行序北→南,正好是地图「上北下南」的画布行序),lines 里每条线带 value、kind 和经纬度闭合环。画上屏只剩一层窗户纸,也是唯一容易绊脚的地方:环是经纬度,不是像素——画到自己的 canvas,得先写一个「经纬度 → 像素」的线性映射(lo0/la0 是左上角起算的经纬度、di/dj 是经纬两个方向的步距,都在 grid.header.grid 里:x = (lon − lo0) / di × width、y = (la0 − lat) / dj × height),等值线环用同一映射落点描线。不想自己写?交给 @metweave/leaflet 的 addGridLayer / addContourLayer,投影、叠层、缩放全包——开源仓 examples/ 的格点面板(?element=prmsl 直达单要素)就是这条链的完整参考。更深的地理投影话题——坐标系换算、底图匹配——不在本篇范围,交给地图引擎。

八、你的定制入口

内核全开源、全纯函数,每个环节都有明确的动手处:

你想改动手处
换色带覆写档案 colorStops,或装配后改 scale.colors
换分级法equalStepBreaks / quantileBreaks / thresholdBreaks 任选,替换档案默认
调阈值thresholdBreaks([0.1, 1, 5, 10]) 换成你的业务数字
加特值线contoursOf(gh, ghProfile.contours)——5880 已在 gh 档案里点名;自定义就传 highlighted: [你的值]
叠风向杆气象里风拆成东西、南北两分量(u/v)存储——windSpeedGrid 合成风速场,windBarbsOf(u, v, { step: 10, calmThreshold: 1 }) 出杆位序列(地图上表示风向的小杆,杆指来向、横杠越多风越大)
调图面浓淡renderToImageData(grid, scale, { opacity: 0.5 })

一句话收拢:这篇你带走的是一组可复用的渲染零件——buildColorScale、thresholdBreaks、renderToImageData、contoursOf。它们不绑定任何地图库,能拼进你自己的项目。

写在最后

从站点散点到格点填色,「点 → 面」这一程走完了。回顾四步:色标决定颜色会不会说话,分级决定档位有没有道理,逐像素渲染把场变成图,等值线给面加上梯度和中心——拆开来就这四件事,每一件都是可复刻的纯函数。

还剩最后一层没拆:本篇画的每一张图,数据都不是凭空来的——是数据管道从公开数据源按时次抓回来、解码、裁域、打包的产物。系列下一篇拆这条管道:《从零跑一个气象数据管道:抓取、清洗、更新节奏》——系列的基建篇。

下一篇见。


  • 仓库:github.com/yaojaro/met…(Gitee 同步镜像 gitee.com/yaojaro/met…)

  • npm:@metweave/grid(MIT 协议;本篇全部代码实测于 npm 0.3.0)

  • 本篇示例数据均为公开数据源真实产物:GFS 公开预报场(NOMADS 免费下载,美国政府公有领域授权),随仓样例见 corpus/grid/

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


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