「 GoOcean海洋遊憩風險資訊-圖台 」 工作時經手的其中一個專案 「Goocean」,承辦為國家海洋研究院,這專案目的是為了讓民眾可以進一步觀測海域從事活動、颱風預測、模擬預測、海岸資訊等等圖層工具來解台灣的海域狀況,而這專案技術原本是由.NET MVC 架構所開發的,但近期由我們公司開始將架構轉為前後端分離模式,後端一樣維持.NET,則前端轉為 React 專案,而我負責的部分也就是遷移至 React,除了圖片看到的圖台頁面其他的介紹頁面也是交由我從第一階段進行架構轉移。
問題解決
實際遷移後我發現其他圖層在 API 層面資料都以毫秒單位取得,只有「模擬預測」相關圖層( 紅色匡選內白色點 ) 是以秒為單位取得,研究了一下才知道這種資料格式稱作 「NetCDF」,在 GIS 應用也常見也常用於氣象學、大氣科學資料表示等等,這種資料本身是一種具有自描述性且能儲存多維度資料的格式,而自描述性如同字面資料除了包含多維度表示且會有描述性欄位也在裡面。
我認為這種以秒為單位取得資料的間隔且資料龐大本身對前端就是一種災難,第一點不外乎就是使用體驗,第二點來自於資料會儲存在瀏覽器記憶體,這種瞬間暴增的方式如果使用者裝置無法負荷就會照成閃退,這邊還沒考慮到 Parse 資料時也使用到的記憶體,後端的話我覺得就是網路頻寬,資料大小需要使用的傳輸頻寬也隨著變大 ( 帳單也隨著成長 )。
為了解決這問題我查了相關文章,有篇文章提到如何將維度資料映射到圖片的 RGB 從而傳輸,則前端 AJAX 其實是去下載一張外觀看起來像彩色雜訊的圖片,剛好完全符合我的需求,於是也與主管討論並交由我研究此項目,實作方式也就如同文章提到的,將向量資料寫入 RGB 後再由前端解析出數值。
解析NetCDF格式中多時空維度風場資料之展示方式

實作過程
我們就用 node.js 來實作生成的過程及前端解析過程, 整件事講白一點就是:把海流/風場的「方向和強度」這種浮點數字,塞進一張圖片的紅色和綠色通道裡傳給前端;前端再把顏色讀回來還原成數字,用來推動畫面上的粒子。 中間那張 PNG 不是給人看的,它只是一個免費又快速的壓縮容器。 之所以划算,是因為我們的網格是 376 × 426 = 160,176 個點,每個點還有東西向(U)和南北向(V)兩個數值:
- 用 JSON 傳:每個浮點數寫成文字大約 18 個字元,光是文字就 2MB 起跳,瀏覽器還得用
JSON.parse一個字一個字解析。 - 用 PNG 傳:一個點壓成一個像素(4 bytes),壓縮後通常只剩一兩百 KB。 順帶的好處是,這張圖之後可以直接丟給 WebGL 當貼圖,要把粒子運算搬到 GPU 也不用改資料格式。
後端代碼
後端是一支很單純的 Express 服務,開兩個端點:/api/netcdf-info 回傳換算需要的 header,/api/netcdf-image 回傳編碼好的 PNG。核心只有兩個函式。
/** 從 JSON 中取出 U/V 並組出 header + 合併資料 */
function parseNetcdfWind(netcdf = []) {
const uEntry = netcdf.find(
(w) => w.header?.parameterNumberName?.toLowerCase() === U_PARAM_NAME,
);
const vEntry = netcdf.find(
(w) => w.header?.parameterNumberName?.toLowerCase() === V_PARAM_NAME,
);
if (!uEntry || !vEntry) throw new Error('缺少 U 或 V 分量');
const { nx, ny, la1, la2, lo1, lo2 } = uEntry.header;
const uData = uEntry.data;
const vData = vEntry.data;
const n = nx * ny;
const data = [];
let minU = Infinity,
maxU = -Infinity;
let minV = Infinity,
maxV = -Infinity;
for (let i = 0; i < n; i++) {
const u = uData[i];
const v = vData[i];
data.push([u, v]);
if (u != null && !Number.isNaN(u)) {
minU = Math.min(minU, u);
maxU = Math.max(maxU, u);
}
if (v != null && !Number.isNaN(v)) {
minV = Math.min(minV, v);
maxV = Math.max(maxV, v);
}
}
const header = { nx, ny, la1, la2, lo1, lo2, minU, maxU, minV, maxV };
return { header, data };
}
/** 依 header + data([[u,v], ...])產生 PNG buffer */
async function dataToPng(header, data) {
const { nx, ny, minU, maxU, minV, maxV } = header;
const uSize = maxU - minU || 1;
const vSize = maxV - minV || 1;
const clamp = (v) => Math.max(0, Math.min(255, Math.round(v)));
const buffer = Buffer.alloc(nx * ny * 4);
for (let j = 0; j < ny; j++) {
for (let i = 0; i < nx; i++) {
const idx = (j * nx + i) * 4;
const [u, v] = data[j * nx + i] || [0, 0];
buffer[idx] = clamp(((u - minU) / uSize) * 255); // R = 東西向分量
buffer[idx + 1] = clamp(((v - minV) / vSize) * 255); // G = 南北向分量
buffer[idx + 2] = 0; // B 保留
buffer[idx + 3] = u === 0 && v === 0 ? 0 : 255; // A = 陸地遮罩
}
}
return sharp(buffer, { raw: { width: nx, height: ny, channels: 4 } })
.png()
.toBuffer();
}生成過程
拆成三個動作來看:
第一步,把 U 和 V 配對起來,順便量一下範圍。 原始 JSON 是一個陣列,裡面每一項是一個變數(wx / us 是東西向分量、wy / vs 是南北向分量)。程式先用 parameterNumberName 把這兩項找出來,逐點配成 [u, v],同時記下四個數字:minU、maxU、minV、maxV。
這四個數字很關鍵,它們是解碼字典。圖片裡只存了「相對位置」,如果前端不知道 0~255 對應到什麼範圍,R=128 到底代表 0.3 還是 3.0 m/s 根本無從得知。所以才需要另外開一支 /api/netcdf-info 把它們送出去。
第二步,把數值攤平成 0~255 寫進像素。 用的是最單純的線性映射:
| 通道 | 存什麼 | 公式 |
|---|---|---|
| R | 東西向分量 U | (u - minU) / (maxU - minU) × 255 |
| G | 南北向分量 V | (v - minV) / (maxV - minV) × 255 |
| B | 目前沒用,先留著 | 0 |
| A | 有沒有資料 | 有資料 255,陸地 0 |
這裡的 Alpha 通道不是拿來做透明的,是被借用來當陸地遮罩。陸地上沒有流速,U 和 V 都是 0,就標成 A = 0,讓前端知道這一格要跳過。 | ||
第三步,交給 sharp 包成 PNG。 sharp(buffer, { raw: {...} }).png() 只是把這塊原始 RGBA 資料塞進 PNG 容器。這裡有個前提很重要:PNG 是無損格式,寫進去什麼 byte、讀出來就是什麼 byte。整套做法能成立完全靠這點,換成 JPEG 立刻報廢,因為 JPEG 的有損壓縮會把顏色改掉,等於把數值改掉。 |
前端代碼
前端是一個 NetCDFRenderer class,負責「讀圖 → 還原數值 → 跑粒子動畫」。以下是解析相關的核心片段。
// 1. 下載圖片並取出像素陣列
loadImageData() {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = "anonymous"; // 後端必須回 CORS header
image.onload = () => {
const c = document.createElement("canvas");
c.width = image.naturalWidth;
c.height = image.naturalHeight;
const ctx = c.getContext("2d");
ctx.drawImage(image, 0, 0);
resolve(ctx.getImageData(0, 0, c.width, c.height).data);
};
image.onerror = () => reject(new Error("Failed to load image"));
image.src = this.imageUrl;
});
}
// 2. 把像素還原成 U/V 並組成二維網格
async init(header, data) {
this.grid = [];
if (this.imageUrl) {
data = await this.loadImageData();
const uSize = header.maxU - header.minU;
const vSize = header.maxV - header.minV;
let index = 0;
for (let j = 0; j < header.ny; j++) {
const row = [];
for (let i = 0; i < header.nx; i++) {
const alpha = data[index + 3];
if (alpha === 0) {
row.push(null); // 陸地,後續跳過
} else {
const u = (data[index] / 255) * uSize + header.minU;
const v = (data[index + 1] / 255) * vSize + header.minV;
row.push([u, v]);
}
index += 4;
}
this.grid.push(row);
}
}
// ...建立隨機粒子、啟動動畫
}
// 3. 任意浮點座標的取值:雙線性插值
getUV(x, y) {
const x0 = Math.floor(x);
const y0 = Math.floor(y);
if (x0 === x && y0 === y) return this.getGrid(x0, y0) || [0, 0];
const g00 = this.getGrid(x0, y0), g10 = this.getGrid(x0 + 1, y0);
const g01 = this.getGrid(x0, y0 + 1), g11 = this.getGrid(x0 + 1, y0 + 1);
if (!g00 || !g10 || !g01 || !g11) return [0, 0]; // 碰到陸地
return this.bilinearInterpolation(x - x0, y - y0, g00, g10, g01, g11);
}
bilinearInterpolation(x, y, g00, g10, g01, g11) {
const rx = 1 - x, ry = 1 - y;
const a = rx * ry, b = x * ry, c = rx * y, d = x * y;
return [
g00[0] * a + g10[0] * b + g01[0] * c + g11[0] * d,
g00[1] * a + g10[1] * b + g01[1] * c + g11[1] * d,
];
}
// 4. 每一幀:先讓畫布整體變暗做拖尾,再把粒子往前推一步
render() {
const ctx = this.ctx;
const pre = ctx.globalCompositeOperation;
ctx.globalCompositeOperation = "destination-in";
ctx.globalAlpha = this.fadeAlpha; // 0.92,越低尾巴越長
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
ctx.globalCompositeOperation = pre;
ctx.globalAlpha = 0.8;
this.movePoints((p) => {
const color = this.getColorBySpeed(p.magnitude);
if (color === null) return; // 速度太小或無效,不畫
const start = this.getCanvasPos(p.x, p.y);
const end = this.getCanvasPos(p.tx, p.ty);
ctx.beginPath();
ctx.strokeStyle = color;
ctx.moveTo(start[0], start[1]);
ctx.lineTo(end[0], end[1]);
ctx.stroke();
});
}解析過程
第一步,用 canvas 當開罐器。 瀏覽器沒有「直接讀 PNG 的像素」這種 API,標準做法是先把圖畫進一個看不見的 canvas,再用 getImageData 把像素撈出來。撈到的是一個 Uint8ClampedArray,四個一組平鋪:[R0, G0, B0, A0, R1, G1, B1, A1, ...]。
這裡有個容易踩到的坑:圖片是跨網域載入的,一定要設 crossOrigin = "anonymous",而且後端要回 CORS header。否則 canvas 會被標記為「被污染」,getImageData 會直接丟安全性錯誤。
第二步,把顏色換回數值。 就是後端公式的反運算,用的是 /api/netcdf-info 拿到的那組 min/max:
u = (R / 255) × (maxU - minU) + minU
v = (G / 255) × (maxV - minV) + minV同時檢查 Alpha,是 0 就記成 null,代表這格是陸地。最後把一維像素陣列摺成二維的 grid[y][x],方便後面用座標取值。
第三步,用雙線性插值填補格子之間的空隙。 粒子在畫面上是連續移動的,它的位置幾乎不可能剛好落在網格點上。所以 getUV 會取周圍四個格子,依照粒子離每個角落的遠近算出加權平均——離哪個角落近,那個角落的影響就大。這樣流場看起來才會滑順,而不是一格一格跳。
第四步,粒子動畫。 每個粒子記著起點、終點、剩餘壽命和當下速度。每一幀做三件事:問流場「這裡該往哪走」、把粒子推一步、畫出這一步的線段。壽命歸零或飄出邊界就重新隨機投放,避免粒子全部堆積在同一處。
拖尾效果用的是 canvas 的合成模式技巧:每幀先用 destination-in 搭配 0.92 的透明度把整張畫布「乘上 0.92」,舊軌跡就會指數衰減成尾巴,而不是硬生生被清掉。
生成圖片
後端實際會產出兩種圖,用途完全不同:
一、除錯用的灰階圖。 每個變數各自輸出一張,把數值依 min/max 映射到 0~255,R = G = B。這種圖不參與任何運算,純粹是給人眼看的——可以直接確認資料長相正常(看得出海岸線、渦流結構),順便驗證「先經度再緯度」的排列方向有沒有搞反。資料轉換這種東西肉眼是最快的驗證方式。
二、真正上線用的 U + V 合成圖。 就是前面說的 R 存東西向、G 存南北向那張。它看起來會像一團彩色雜訊,完全看不出是海流圖,因為它本來就不是給人看的,是一個偽裝成圖片的數值容器。
最後補一個限制:8 bit 只有 256 階,換算解析度是 (max - min) / 255。以海流 ±1.5 m/s 為例,每一階大約是 0.0118 m/s。這個誤差就是為什麼前端要設 noDataThreshold = 0.02——把量化誤差造成的「假流速」濾掉,不然陸地邊緣會冒出一堆不該存在的粒子。如果之後需要更高精度,可以把目前閒置的 B 通道拿來當低位元組,做成 16 bit 編碼。
最終效益
看到下圖我們把之前直接傳輸 JSON 的方式替換成傳輸圖片格式,可以發現整個 API 取得時間整整下降了 x 倍。 todo
NetCDF 資料格式
NetCDF 本身是一種二進制資料,下方看到的是經由後端轉換過欄位也精簡過的樣子,可以看到 la1、lo1 表示的是網格範圍,這範圍所覆蓋的區域其實就是整個台灣區域,上方的展示圖片能看到整個底圖所覆蓋範圍。 NetCDF | NSF Unidata
// 模擬預測資料-風向 資料大小 2MB 起跳
{
"stops": null,
"hs": null,
"wind": {
"wind": [
{
"header": {
"ny": 426, // 緯度(Y軸)方向的網格點數,共 426 格
"nx": 376, // 經度(X軸)方向的網格點數,共 376 格
"forecastTime": 0, // 預報時效,0 代表此時段為分析場或初始時間
"parameterNumber": 2, // 氣象參數代碼(WMO 標準中通常代表風速分量)
"parameterCategory": 2, // 氣象參數分類(2 通常代表動力量學或動量 Momentum)
"lo2": 124.5, // 網格範圍的終點經度(最東側:124.5°E)
"la2": 26.5, // 網格範圍的終點緯度(最北側:26.5°N)
"lo1": 117, // 網格範圍的起點經度(最西側:117.0°E)
"la1": 18, // 網格範圍的起點緯度(最南側:18.0°N)
"dy": -0.019953051643192488, // 緯度解析度(每格網格的南北間距,單位:度)
"dx": 0.0199468085106383, // 經度解析度(每格網格的東西間距,單位:度)
"parameterNumberName": "wx", // 變數簡稱,此處 wx 指的是風場在 X 軸(東西向)的分量
"parameterUnit": null, // 變數單位,此處為 null(實務上風速通常為 m/s 或 knots)
"refTime": "2026-08-08 23:00"// 預報基準時間(資料生成的起始時間點)
},
"data": [
13.046998023986816,
13.064914703369141,
13.08283805847168,
13.100754737854004,
13.116026878356934,
13.128487586975098,
13.140944480895996,
13.153406143188477,
13.165861129760742,
13.178321838378906,
13.180700302124023,
13.177028656005859,
// ... 後續依據「先經度再緯度」的順序排列,總計會有 376 * 426 個數值