<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>打造 TaxMap-TW：用 Astro 做台灣所得地圖 - Bobo 的學思山丘</title><description>資料地圖（Choropleth）該用哪種色階？做台灣所得稅 TaxMap 時原本想用 viridis，最後換成 ColorBrewer 的 YlGnBu + Jenks 自然斷點。整理 7 種主流連續色階比較、為什麼紅綠對比是地雷、長尾分布怎麼分級、以及 opacity × 基底圖的隱藏陷阱。</description><link>https://bobochen.dev/</link><item><title>Cloudflare Pages 的 20,000 檔案上限：當「一頁一檔」撞牆，我把 TaxMap 搬到 Netlify</title><link>https://bobochen.dev/blog/cloudflare-pages-20000-file-limit-taxmap-netlify/</link><guid isPermaLink="true">https://bobochen.dev/blog/cloudflare-pages-20000-file-limit-taxmap-netlify/</guid><description>TaxMap-TW build 出 23,331 個檔案，撞上 Cloudflare Pages Free 方案「單次部署最多 20,000 檔」的硬限制，最後改用 Netlify 5 分鐘解決。記錄為什麼會爆檔案數（一頁一檔 × 7,750 村里）、三個方案的取捨，以及換平台其實是「換天花板不是拆天花板」——選靜態部署平台別只看單檔大小，檔案數才是隱形天花板。</description><pubDate>Sun, 31 May 2026 00:00:00 GMT</pubDate><content:encoded>買了 `bobochen.dev`，興沖沖想把我做的「台灣所得地圖」TaxMap 掛上自己的網域。我用 Cloudflare 管 DNS，理所當然想直接丟 Cloudflare Pages。結果 `pnpm build` 完一看 `dist/`——**23,331 個檔案**。然後我才想起一件大家選部署平台時很容易忽略的事。

## 背景：把 TaxMap 搬上自己的網域，理所當然選 Cloudflare Pages

最近把主網域 `bobochen.dev` 買下來後，開始把各個 side project 掛到子網域底下。TaxMap-TW（台灣所得地圖，一個用 Astro 做的純靜態站）要放到 `taxmap.bobochen.dev`。

因為我 DNS 本來就在 Cloudflare，最自然的選擇就是 Cloudflare Pages——同一個後台、免費、CDN 又快。我連 `astro.config.mjs` 的 `site` 都已經指好 `https://taxmap.bobochen.dev` 了，想說 build 完上傳就收工。

## 撞牆：build 出來 23,331 個檔案

部署前我習慣先看一下產物大小。一看傻眼：

- `dist/` 共 **880MB**
- **23,331 個檔案**

我第一個反應是查單檔大小有沒有超標（Cloudflare Pages 單檔上限 25 MiB），結果最大的檔案才 5.7MB（`villages.pmtiles`），離 25 MiB 遠得很。容量、單檔都沒問題。

但真正擋我的是另一條限制，引用官方文件：

&gt; Cloudflare Pages sites can contain up to 20,000 files（Free 方案）

而我有 23,331 個。如果硬上傳，會吃到這個錯誤訊息：

&gt; `Error: Pages only supports up to 20,000 files in a deployment.`

**擋我的不是 880MB，是「23,331」這個數字。** 檔案大小和檔案數量是兩個完全獨立的天花板。

## 為什麼會爆檔案數？「一頁一檔」的代價

TaxMap 是「每個村里一個頁面」的資料密集型靜態站。台灣大約有 **7,750 個村里**，而我為每個村里都預先生成了三樣東西：

| 內容 | 檔案數 | 大小 |
|------|-------|------|
| 村里頁 HTML | 7,747 | 272MB |
| 村里資料 JSON | 7,747 | 134MB |
| 村里 OG 社群分享圖 (PNG) | 7,750 | 466MB |

7,750 × 3 ≈ 23k 檔。光是給「分享你家村里所得」用的 OG 圖就 7,750 張。

這就是 SSG（靜態網站生成）「一頁一檔」模式的代價：內容一多，**檔案數會線性爆炸**。地圖、字典、商品目錄這類有大量 detail 頁的網站特別容易破萬。

## 我其實有三個選項

查清楚後，我整理出三條路：

1. **升級 Cloudflare Pages 付費方案** — Free 是 20,000 檔，但付費方案可到 **100,000 檔**（這是 2026 起的新上限，得設 `PAGES_WRANGLER_MAJOR_VERSION=4` 環境變數才會啟用），23,331 綽綽有餘。代價：要解鎖這個上限得開 Workers Paid，最低 **$5/mo**（注意不是網站那個 $20/mo 的 Pro 方案，當初我也一度被搞混）。
2. **砍檔案數** — 不預生成那 7,750 張 OG 圖，[改用 Worker 即時產生](/blog/cloudflare-worker-on-demand-og-satori-taxmap)，檔案數降到 ~15,600 就能上 Free 版。代價：要改 OG pipeline。這個選項其實最漂亮——它不是「砍功能」，而是「換一種方式提供同樣的 OG 圖」，而且順便連未來的頻寬問題一起解了（圖只在被請求時才生成，不用整批塞進 CDN）。當下我嫌它要動工，但坦白說它是技術上最對的解。
3. **換 Netlify** — Netlify 沒有 Cloudflare Pages 這種「單次部署檔案數」硬限制，免費版就吃得下。代價：DNS 在 Cloudflare、站台在 Netlify，變成跨平台管理。

我選了 **3**。理由很務實：這是個人 hobby 專案，為了它每月多付錢、或為了遷就平台去重構 OG pipeline，當下都嫌麻煩；而那 7,750 張 OG 圖正是 TaxMap「分享你家村里」的病毒傳播核心，我不想動它。加上我另一個子網域（typelate）本來就在 Netlify，搬過去算順手。

但我得先說清楚：**選 3 是「當下最快」的取捨，不是「客觀最優」。** 選項 2（改 Worker 即時生成）其實才是長期最乾淨的解，只是要動工；如果你很在意運維一致性，DNS、站台、Workers 全留在 Cloudflare 一個後台，那付那 $5/mo 留在單一平台，其實是完全合理的選擇——跨平台管理的隱性摩擦（兩套後台、兩套憑證、兩套帳單）不是零成本，只是不會出現在帳單上。

## 解法：搬到 Netlify，5 分鐘搞定

因為資料和 OG 圖都已經 commit 在 `public/`（`astro build` 會自動複製到 `dist/`），所以根本不用跑那些慢的資料抓取腳本，純 build 就好：

```bash
pnpm build                          # 純 astro build → dist/
netlify deploy --prod --dir=dist    # 直接上傳預先 build 好的 dist
```

中途遇到一次 `getaddrinfo ENOTFOUND api.netlify.com`——上傳到一半網路抖了一下。但 Netlify CLI 的部署是**增量**的，重跑一次它自動跳過已上傳的檔案、只續傳剩下的（23,330 → 22,455），第二次就成功了。

最後 `https://taxmap.bobochen.dev` 上線，Let&apos;s Encrypt 憑證也自動簽好。

### 但 Netlify 不是「沒有天花板」，只是換了一面牆

我得誠實補一句，免得你照抄踩雷：搬到 Netlify 不等於「解除限制」，比較像是「把擋路的那道牆換成另一道」。Cloudflare Pages 卡我的是檔案數，但它的**頻寬是沒有上限的**；Netlify 反過來——沒有檔案數硬限制，但**免費版頻寬是 100GB/月，超量大約每 100GB 收 $55**。而 TaxMap 那 7,750 張 OG 圖正是設計來「被瘋狂分享」的，萬一哪天真的病毒傳播，最先撞牆的反而是 Netlify 的頻寬，不是 Cloudflare。換句話說，我換掉的是「檔案數天花板」，但同時換上了一個「頻寬天花板」——對一個指望靠分享擴散的站來說，這個取捨其實很微妙。

還有一個更新的時效雷：Netlify 從 **2025-09** 起改成 credit-based 計費，免費版每月有 **300 credits 的硬上限**，連 deploy 本身都會扣點，而且一個專案超量會把**整個帳號**的服務一起暫停（連坐）。我另一個子網域 typelate 跟 TaxMap 同一個 Netlify 帳號，這種連坐風險其實被我放大了。所以「免費、5 分鐘、無痛」這句話是我 2025-09 之前的體感——**你的免費額度很可能跟我不一樣，動手前自己去對一遍當下的方案頁，別照抄我的數字。**

## 反思

選靜態網站部署平台，大家通常只盯著三個數字看：單檔大小、總容量、頻寬。這次提醒我還有第四個——檔案數上限——而它最容易被忽略。我要先界定清楚：檔案數對 TaxMap 這種「一頁一檔、上萬個 detail 頁」的[資料密集型站](/blog/pmtiles-http-range-request-single-file-tiles)才會這麼致命，一般部落格、產品官網根本碰不到兩萬檔，那種站更該盯的是頻寬跟 build 時間。各家的檔案數天花板也差很多：Cloudflare Pages Free 是 20,000、付費才到 100,000，Netlify 則沒有這種等級的硬卡點。重點是選平台前把這四個限制都對一遍，免得中途翻車。

回頭看，我這次是 build 完、deploy 前才想到去查檔案數，算是運氣好，在浪費一次失敗部署之前就攔下來了。理想上這種限制應該在「選平台的當下」就查清楚，而不是撞牆才回頭——這也是我後來幫整個 [TaxMap 專案做覆盤](/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls)時記下來的一條。至於最後選免費平台 5 分鐘搬完、沒去重構 OG pipeline 也沒付月費，這算不上什麼大道理，純粹是一個 side project 該有的鬆弛感：不是每個技術潔癖都值得當下還債，能用最小力氣讓站上線、功能一個沒少，往往就夠了。

另外兩個踩坑時學到的東西：我一度以為「換成 Cloudflare Workers Static Assets 就能逃」，結果 Workers 靜態資源也有自己的檔案數上限，不是無腦解；還有就是這次最反直覺的地方——擋住你的可能不是「太大」，而是「太多」，880MB 完全沒事，是 23,331 這個「數量」把我卡死的。

---

*官方限制文件：[Cloudflare Pages Limits](https://developers.cloudflare.com/pages/platform/limits/)*</content:encoded><media:content url="https://bobochen.dev/_astro/cover.KZinsJzY.webp" medium="image"/><category>Cloudflare Pages</category><category>Netlify</category><category>部署</category><category>Astro</category><category>靜態網站</category><enclosure url="https://bobochen.dev/_astro/cover.KZinsJzY.webp" length="0" type="image/png"/></item><item><title>把 7,750 張 OG 圖改成 Cloudflare Worker 即時生成：Satori at the edge</title><link>https://bobochen.dev/blog/cloudflare-worker-on-demand-og-satori-taxmap/</link><guid isPermaLink="true">https://bobochen.dev/blog/cloudflare-worker-on-demand-og-satori-taxmap/</guid><description>Cloudflare Worker 即時生成 OG 圖的設計稿：用 workers-og（Satori + resvg-wasm）取代 TaxMap-TW 預先生成的 7,750 張村里圖，預生檔案數從 7,750 降到 0、騰出空間搬回 Cloudflare Pages Free。含中文字型、Cache API 與 Workers Free CPU 上限踩雷。</description><pubDate>Sun, 31 May 2026 00:00:00 GMT</pubDate><content:encoded>&gt; ⚠️ 開頭先誠實說：這篇是**設計與 How-to 指南**，不是已上線的復盤——它正是[上一篇](/blog/cloudflare-pages-20000-file-limit-taxmap-netlify)我「逃避」掉的那個重構。等真的做完，我會再寫一篇實戰版。

## 起點：上一篇 7,750 張預先生成 OG 圖留下的尾巴

[上一篇](/blog/cloudflare-pages-20000-file-limit-taxmap-netlify)講到 TaxMap 因為「每個村里預先生成 1 張 OG 圖 × 7,750 = 7,750 個檔案」，把 `dist/` 頂破 Cloudflare Pages 的 20,000 檔上限，最後搬去 Netlify。

但其實有個更漂亮的解法：**根本不要預先產生那 7,750 張圖**。改成「有人要分享某個村里時，才即時生成那一張，然後快取在邊緣」。

而且這招有個甜美的副作用——OG 圖從 7,750 個檔案變成 **0 個**，`dist/` 從 23,331 降到 ~15,584，**反而塞得回 Cloudflare Pages Free 版**。繞了一圈又回得去。

## 核心概念：on-demand OG

預先生成（build-time）vs 即時生成（runtime）的差別：

| 比較項目 | Build-time（現在）| Runtime（這篇要做的）|
|---|---|---|
| 何時產圖 | build 時一次生 7,750 張 | 第一次有人請求才生那一張 |
| 檔案數 | +7,750 | **0**（不進 `dist/`）|
| 新增村里 | 要重 build | 自動就有 |
| 首次延遲 | 無（已是檔案）| 有（首次渲染，之後吃快取）|
| 冷啟動 | 無 | 有（Worker 冷啟 + WASM 初始化）|
| 額外執行成本 | 無（純靜態檔） | 多一個 Worker（可能要 Workers Paid）|
| 外部相依 | build 完就定版 | 線上要 fetch 字型、村里 JSON、Cache API |
| 爬蟲可靠度 | 100%（檔案永遠在） | 看快取命中，首次被分享是冷渲染 |
| 可驗證性 | CI build 階段就能驗、壞了不會上線 | 失敗移到線上請求路徑，要靠監控才看得到 |

做法：一個 Worker 掛在 `/og/v/:code.png`，第一次被請求時 render 出 PNG、寫進 edge cache，之後都走快取。

## 技術棧：workers-og（Satori + resvg-wasm）

關鍵是 [`workers-og`](https://github.com/kvnang/workers-og) 這個套件——專為 Cloudflare Workers 設計的 OG 產生器，API 仿 `@vercel/og`，底層是：

- [Satori](https://github.com/vercel/satori)：把 HTML/CSS → SVG（不需瀏覽器）
- `@resvg/resvg-wasm`：把 SVG → PNG

**為什麼不直接用 `@vercel/og`？** 因為它的 WASM 打包方式在 Cloudflare Worker 上會出錯。`workers-og` 就是為了解決這件事而生，還額外支援用 HTML 字串（透過 Worker 的 `HTMLRewriter` 解析），不用寫 JSX。

底層的 Satori + resvg 其實就是我在 build-time 產 OG 圖時用的那套，差別只在這次搬到了 Worker 上跑。Satori 本身怎麼把 HTML/CSS 變成 OG 圖、為什麼是它而不是 puppeteer，我之前寫過一篇 build-time 的 Satori + resvg 教學；想先看「到底要不要自己生 OG 圖、有哪幾種做法」的，可以看 OG 圖自動生成的三種方案比較。這篇談的 runtime 生成，本質上是同一套引擎換個執行時機。

### 3 分鐘快速上手

```bash
npm create cloudflare@latest og-worker   # 建一個 Worker 專案
cd og-worker
npm install workers-og
```

最小可動範例（`src/index.ts`）：

```ts
import { ImageResponse } from &quot;workers-og&quot;;

export default {
  async fetch(request: Request) {
    const html = `
      &lt;div style=&quot;display:flex;width:100%;height:100%;
                  align-items:center;justify-content:center;
                  background:#0b1220;color:white;font-size:72px;&quot;&gt;
        哪里最有錢 · TaxMap
      &lt;/div&gt;`;
    return new ImageResponse(html, { width: 1200, height: 630 });
  },
};
```

```bash
npx wrangler dev          # 本機跑起來
# 開 http://localhost:8787 就看到一張 1200×630 的 PNG
```

`ImageResponse` 收 HTML 字串（或 JSX），回一個 body 是 PNG 的 `Response`。就這麼直接。

## 套用到 TaxMap 的設計

把村里資料、字型、快取串起來（設計草稿）：

```ts
import { ImageResponse } from &quot;workers-og&quot;;

let fontCache: ArrayBuffer | null = null;
async function getFont() {
  // ⚠️ 中文字型必須「手動」載入並塞給 Satori，它不會自己抓
  if (!fontCache) {
    const r = await fetch(&quot;https://taxmap.bobochen.dev/fonts/NotoSansTC-Bold.woff&quot;);
    fontCache = await r.arrayBuffer();
  }
  return fontCache;
}

export default {
  async fetch(request: Request, env: unknown, ctx: ExecutionContext) {
    const url = new URL(request.url);
    const code = url.pathname.split(&quot;/&quot;).pop()?.replace(&quot;.png&quot;, &quot;&quot;);

    // 1) 先查 edge cache，命中就直接回（每個村里只 render 一次）
    const cache = caches.default;
    const hit = await cache.match(request);
    if (hit) return hit;

    // 2) 手動 fetch 村里資料（Satori 在 Worker 內建抓取會「默默失敗」）
    const data = await fetch(
      `https://taxmap.bobochen.dev/data/villages/${code}.json`
    ).then((r) =&gt; r.json());

    // 3) render
    const img = new ImageResponse(
      `&lt;div style=&quot;display:flex;flex-direction:column;width:100%;height:100%;
                   padding:80px;background:#0b1220;color:#fff;
                   font-family:&apos;Noto Sans TC&apos;;&quot;&gt;
         &lt;div style=&quot;font-size:40px;color:#9ca3af;&quot;&gt;${data.county}${data.town}&lt;/div&gt;
         &lt;div style=&quot;font-size:96px;font-weight:700;&quot;&gt;${data.name}&lt;/div&gt;
         &lt;div style=&quot;font-size:64px;margin-top:auto;&quot;&gt;中位數所得 ${data.median} 萬&lt;/div&gt;
       &lt;/div&gt;`,
      {
        width: 1200,
        height: 630,
        fonts: [{ name: &quot;Noto Sans TC&quot;, data: await getFont(), weight: 700 }],
      }
    );

    // 4) 寫進 cache，回傳
    const res = new Response(img.body, img);
    res.headers.set(&quot;Cache-Control&quot;, &quot;public, max-age=31536000, immutable&quot;);
    ctx.waitUntil(cache.put(request, res.clone()));
    return res;
  },
};
```

這段是設計骨架，刻意省了錯誤處理好讓主幹清楚——但正式版不能這樣留：`getFont()` 的 `fetch`、村里 JSON 的 `fetch`、`r.json()` 任何一步失敗，現在都會直接讓整個請求 500，而且這正是 build-time 沒有的失敗模式（build 階段抓不到字型，build 就紅燈了，根本上不了線；改成 runtime 之後，這些錯誤全被推到「使用者請求當下」才爆）。實作時這裡每一步都要包 try/catch，並準備一張 fallback OG 圖（純文字、不依賴外部資料的版本），抓不到資料時至少回得出一張像樣的圖，而不是讓爬蟲拿到 500。

## 踩雷預告（先研究過、實作時會遇到的）

1. **別用 `@vercel/og`**：WASM 打包不相容 Worker，改用 `workers-og`。
2. **中文字型要手動塞**：非拉丁字型 Satori 不會自己載，要自己把 Noto Sans TC 的 WOFF buffer 餵給 `fonts`。WOFF ~1.4MB，但 Satori 只會 subset 實際用到的字，輸出 PNG 約 30KB。**注意字型格式只能餵 TTF / OTF / WOFF，Satori 不支援 WOFF2**——很多現成 CDN 字型預設給的是 .woff2，直接拿來會解析失敗，要找 .woff 版或自己轉一份。
3. **Satori 內建抓圖會默默失敗**：在 Worker 裡，圖片要自己 `fetch` 轉成 base64 data URL，不要靠 Satori 內部抓。
4. **Worker CPU 時間**：resvg-wasm render 吃 CPU，要靠 edge cache 讓每張只算一次。
5. **Cache API 在 `*.workers.dev` 根本不會運作**：這是整套成本攤平的地雷。`caches.default` 只在 **custom domain**（或 Pages Functions）上才真的快取，掛在預設的 `xxx.workers.dev` 網域時 `cache.put` 是 **靜默 no-op**——`cache.match` 永遠 miss，每一次請求都重新 render 一張圖，「每個村里只算一次」的前提整個落空。所以這個 OG Worker **一定要掛在自己的 custom domain**（例如 `taxmap.bobochen.dev`），不能只用快速上手那個 `localhost:8787` / workers.dev 就上線。上面快速上手叫你開 `localhost:8787` 只是看渲染對不對，不代表 cache 有在運作。
6. **Workers Free 每次 invocation 只有 10ms CPU**：Satori + resvg-wasm 第一次把 CJK PNG rasterize 出來，CPU 時間常常遠超過 10ms。如果跑在 Workers **Free**，這張首圖很可能直接被 runtime 中止——圖生不出來，也就寫不進快取，下一個請求又從頭再撞一次。所以要分清楚兩件事：**TaxMap 主站的靜態 `dist/` 回 Cloudflare Pages Free 沒問題**（純靜態託管），但**負責即時渲染的這個 OG Worker，很可能得開 Workers Paid（$5/mo）才扛得住第一次冷渲染**。下面「搬回 Free」講的是前者，別把它誤讀成「連渲染都免費」。

## 反思

### 技術面

這是個經典的 **build-time vs runtime** 取捨，而且不是 runtime 完勝。Build-time 換來的是零冷啟動、不用多付 Worker 錢、build 完就定版沒有外部相依、爬蟲 100% 抓得到、壞了在 CI 階段就會被擋下來——這些 runtime 全部要重新賺。Runtime 換來的是零檔案、新村里自動就有，代價是多一個 Worker、首次渲染延遲，還有一整類 build-time 不存在的「線上才會炸」失敗模式：fetch 字型、fetch 村里 JSON、Cache API 命中率，任何一個出包都是使用者請求當下才發現。

所以我不會說它「幾乎是必然」。我的門檻是這樣：如果頁面數會持續長大到撞檔案數上限、而且大多數頁面其實沒人會去分享（OG 圖很長尾），那 runtime 才划算——用「只渲染真的被分享到的那幾張」換掉「無差別預生幾千張」。反過來，如果頁面數可控、或熱門頁面就那幾個，build-time 的零延遲跟可驗證性還是比較省心。

而且要對「首次延遲」很誠實。社群平台的爬蟲（Facebook、Threads、LINE）抓 OG 圖是同步的、有 timeout、而且基本上不重試。偏偏 cache miss 那一次——也就是這個村里第一次被分享出去的那一刻——正好是最慢的冷渲染。第一個願意幫你分享的人，很可能就是看不到預覽圖的那個人，這對想靠分享擴散的站來說格外諷刺。要救有幾條路：用 sitemap 或 build 後跑一輪預熱、把已知熱門的村里在 build 時先 warm 進快取、對爬蟲走 stale-while-revalidate（先回舊圖、背景重算）。但說到底，**如果一張圖幾乎都是「第一次被分享」時才被要求，那 build-time 預先準備好其實更安全**——這也是我還沒急著把它換掉的原因。

還有快取本身也別想得太美。`caches.default` 是 **per-PoP** 的，每個邊緣節點各自一份、而且會被驅逐。所以「每個村里只 render 一次」嚴格說是「每個村里、在每個 PoP、在還沒被驅逐之前各 render 一次」；長尾村里散在各地、又久久才被看一次，命中率會很低，等於反覆 cache miss 反覆重算。真要做到全球只算一次，得把結果落到 R2 或 KV 這種持久層，而不是只靠 edge cache。

### 心態面

上一篇我為了快速上線，選了「換平台」這個 5 分鐘解法，把重構記成 TODO。這篇是把那張 TODO 攤開來「先想清楚怎麼做」——**先設計、再實作**，而不是一頭栽進去寫 code 才發現中文字型載不進來。光是先查清楚 Cache API 在 workers.dev 不運作、Free 版 10ms CPU 這兩個雷，預期就能省下實作時撞牆乾耗的半個下午——當然這還只是假設，真的動手做完才知道準不準。

### 有趣發現

最爽的是那個全循環：**把 OG 改成即時生成，預生檔案數掉到 20,000 以下，TaxMap 的靜態站就能搬回 Cloudflare Pages Free**。當初逼我搬家的限制，用對的架構就能繞回去。

不過先別太得意。所謂「搬回 Free」是把帳算在靜態託管那一塊——真實的代價是架構從「一坨靜態檔」拆成「Pages 靜態站 + 一個獨立 OG Worker（很可能還得 Workers Paid）」兩塊東西。檔案數歸零的同時，我換來的是兩套部署、Worker 的字型/快取/錯誤處理要長期顧、出事時要分清楚是哪一邊。本質上是用「長期維運複雜度」去換「檔案數歸零」，不是無痛的勝利。值不值得，要看我願不願意長期養這個 Worker——這也是我到現在還只把它停在設計稿、沒真的動手的原因。

---

**這個系列其他文章**：前情是 [Cloudflare Pages 的 20,000 檔案上限：我把 TaxMap 搬到 Netlify](/blog/cloudflare-pages-20000-file-limit-taxmap-netlify)（就是它逼出這篇的重構）；整個專案怎麼蓋、踩了哪些坑的全紀錄在 [打造 TaxMap-TW 完整心得：6 個技術決策、踩了 4 個坑](/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls)。

*參考：[workers-og](https://github.com/kvnang/workers-og)、[Satori](https://github.com/vercel/satori)、[6 Pitfalls of Dynamic OG on Cloudflare Workers](https://dev.to/devoresyah/6-pitfalls-of-dynamic-og-image-generation-on-cloudflare-workers-satori-resvg-wasm-1kle)*</content:encoded><media:content url="https://bobochen.dev/_astro/cover.D0Dg35Zl.webp" medium="image"/><category>Cloudflare Workers</category><category>Satori</category><category>OG Image</category><category>Astro</category><category>邊緣運算</category><enclosure url="https://bobochen.dev/_astro/cover.D0Dg35Zl.webp" length="0" type="image/png"/></item><item><title>資料地圖該用哪種色階？viridis、YlGnBu 與 ColorBrewer 實戰指南</title><link>https://bobochen.dev/blog/data-map-color-scale-viridis-ylgnbu/</link><guid isPermaLink="true">https://bobochen.dev/blog/data-map-color-scale-viridis-ylgnbu/</guid><description>資料地圖（Choropleth）該用哪種色階？做台灣所得稅 TaxMap 時原本想用 viridis，最後換成 ColorBrewer 的 YlGnBu + Jenks 自然斷點。整理 7 種主流連續色階比較、為什麼紅綠對比是地雷、長尾分布怎麼分級、以及 opacity × 基底圖的隱藏陷阱。</description><pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate><content:encoded>最近在做 [TaxMap-TW](https://github.com/bobo52310/TaxMap-TW)：一張顯示全台灣 7,747 個村里所得稅統計的互動地圖。要為每個 polygon 上色時，第一個問題就讓我卡了一小時：

**「Choropleth 地圖該用哪種色階？」**

我以為這是 30 秒就能決定的設計問題。結果發現裡面有色盲議題、ColorBrewer 業界標準、長尾分布陷阱、以及跟基底圖的 opacity 互動，每一個都是踩過才知道的坑。

這篇整理那一小時學到的東西。

## 資料地圖的色階到底在解什麼問題

Choropleth（音「克羅羅普列斯」，沒人念得對）就是「**用顏色表達數值**」的地圖：每個區域填一個顏色，深淺對應到該區的某個統計量。

聽起來很簡單，但你的每一個選擇都在影響讀者：

- **色相**（hue）：用紅、藍、綠表達不同的情緒
- **明度漸層**：表達數值大小
- **分級方式**：把連續數值切成幾個顏色區間
- **透明度**：跟下層地圖怎麼疊
- **色階方向**：低→高還是高→低

每個選擇都可能讓讀者看到「不存在的趨勢」或「忽略真實的差異」。這不是美感問題，是**會不會誤導讀者**的問題。

## 七種主流色階快速比較

直接拿我整理出的比較表：

| 色階 | 視覺風格 | 適合場景 | 陷阱 |
|------|---------|---------|------|
| **Viridis** | 紫→藍→綠→黃 | 學術、中性嚴肅 | 不夠吸睛 |
| **YlGnBu** | 淺黃→綠→深藍 | 公部門報告、所得 / 教育 | 深色端容易跟水體混 |
| **YlOrRd** | 黃→橘→紅 | 媒體、社群、視覺衝擊 | 紅色易被解讀為「壞」 |
| **Plasma / Magma** | 黑→紫→紅→黃 | 深色模式、有質感 | 需要深底搭配 |
| **Greys** | 淺灰→深灰 | 極簡、副地圖 | 訊息密度低 |
| **Red-Green** | 紅→黃→綠 | ❌ 不推薦 | 8% 男性紅綠色盲無法區分 |
| **Diverging (RdBu)** | 紅←白→藍 | 雙向偏離（如「相對於平均」） | 不適合單向資料 |

前五個是 **sequential（單向）色階**，適合「越多越深」這種單方向遞增的資料；最後一個是 **diverging（雙向）色階**，適合表達相對於某個中心點的偏離（例如選舉得票率相對於 50%）。

選錯類型會給錯誤的暗示——例如用 diverging 的紅藍色階畫所得，會讓讀者以為「藍色那邊比紅色那邊好或壞」，但其實只有「多」跟「少」。

## Viridis：學術派的最愛

Viridis 是 Python `matplotlib` 在 2015 年導入的色階，後來變成科學論文的視覺標配。它的三個特性：

1. **感知均勻（perceptually uniform）**：你眼睛看到的「色差」跟資料本身的「數值差」是線性對應的
2. **色盲友善**：deuteranopia（綠色盲）跟 protanopia（紅色盲）讀者也能正確分辨
3. **黑白列印也能用**：因為亮度本身就是漸層

姊妹色階 plasma、inferno、magma 也都有這三個特性，只是色相不同。如果你不知道該選什麼，預設用 viridis 不會錯。

但 viridis 不是沒有代價。對一般民眾來說它看起來有點「太學術」，而且整條色帶偏暗——低值端是深紫，疊到淺色底圖上反而比高值端的黃還搶眼，跟「越多越深」的直覺剛好相反，第一次看的人容易把低值區誤判成重點。如果你的網站受眾是公務員、媒體、社群讀者，YlGnBu 或 YlOrRd 這種低值夠淺、明度從淺到深單調遞增的色階，會更符合他們的視覺習慣。

## YlGnBu：ColorBrewer 的經典

[ColorBrewer](https://colorbrewer2.org/) 是 Penn State 的 Cynthia Brewer 教授在 2002 年釋出的色階庫，原本是給地圖設計師用的，現在幾乎所有 GIS / 視覺化工具（D3.js、Leaflet、Tableau、QGIS）都內建了它。

ColorBrewer 提供三類色階：

- **Sequential**：YlGn、YlOrRd、Blues、Reds、Greens 等，適合單向遞增
- **Diverging**：RdBu、PiYG、BrBG 等，適合雙向偏離
- **Qualitative**：Set1、Pastel1 等，適合類別資料（無序）

YlGnBu（黃→綠→藍）是 sequential 類別裡最常被選用的之一，理由是：

- 黃色端夠亮、藍色端夠深，**對比範圍大**
- 沒有用紅色，**避免「紅 = 壞」的情緒誤導**
- 公部門報告書的視覺習慣，給人「冷靜、客觀、財經」的感覺

我最後選 YlGnBu 的原因，就是「所得稅地圖」這個題材本身就需要冷靜感。如果是傳染病熱點圖，我會改用 YlOrRd 來強調「警示」。

## 為什麼紅綠色階是地雷

`#ff0000` 紅 → `#00ff00` 綠，這是直覺裡「**壞→好**」的對比。很多人做地圖第一個會想到的就是這個配色。

但根據統計，**全球約 8% 的男性是紅綠色盲**（女性約 0.5%）。他們看到的紅色跟綠色都會變成偏黃褐色，幾乎無法區分。

這代表如果你的地圖讀者裡每 12 個男生就有 1 個看不出來，你做的所有視覺化都失效了。

更糟的是：紅綠色階的「紅 = 壞」是西方文化習慣。在台灣，紅色常代表喜慶；中國股市則「紅漲綠跌」。**同樣顏色在不同文化代表相反意義**，這也是要避開的坑。

說到底，紅綠最大的問題不是「紅綠」這兩個顏色本身，而是它**只靠色相去編碼數值、明度卻幾乎沒變**——色盲讀者一旦分不出色相，就什麼資訊都拿不到。真正該守的原則是：不要只靠色相編碼，要保證**明度單調遞增**，這樣就算把地圖印成黑白、或讀者是色盲，深淺順序還是讀得出來。如果你真的需要雙向表達（高於平均 / 低於平均），也不必硬用紅綠——ColorBrewer 的 RdYlBu、BrBG 這類 diverging 色階就是設計成色盲安全的，兩端明度也夠分。

**簡單原則：除非你有非常強的理由（例如就是要表達 +/− 雙向），否則 sequential 色階不要用紅綠對比；要雙向就挑色盲安全的 diverging。**

## 分級方法：等距分級會殺死你的資料

選好色階後還有第二個決定：怎麼把連續數值切成 5 個顏色區間？常見方法：

| 方法 | 切法 | 適用 |
|------|------|------|
| **等距（Equal Interval）** | 從 min 到 max 平均切 | 均勻分布的資料 |
| **分位數（Quantile）** | 每級剛好佔 20% | 想讓地圖顏色均勻 |
| **自然斷點（Jenks）** | 演算法找資料的群聚邊界 | 學術正統、保留分布 |
| **標準差** | ±1σ、±2σ 切 | 突顯異常值 |
| **手動斷點** | 自訂門檻 | 有編輯觀點 |

我原本想用等距分級，「最公平、最直覺」嘛。但攤開台灣所得稅資料一看：

- 台北市松山區中華里中位數：**98.4 萬元**（全國前段）
- 偏鄉村里中位數：**30-40 萬元**

2025 年公布的 112 年度統計裡，台北松山中華里以**平均所得 526.6 萬元**登頂全台最富里，擠下蟬聯 5 年榜首的新竹關新里。但要注意 526.6 萬是「平均數」，會被里內極少數高所得家戶整個拉上去——同一個中華里的「中位數」其實只有 98.4 萬。一個里內 mean 比 median 高出五倍，本身就是長尾的訊號。

這就是經典的**長尾分布**。如果你用等距分級，把上限拉到那種平均破 500 萬的村里，那 90% 的村里就會全部擠在最低色階——整張地圖看起來就是「一片米黃 + 一個刺眼藍點」，幾乎沒有區分度。

**這是台灣公開資料視覺化的經典踩坑。**

解法是 **Jenks 自然斷點**：演算法會找出資料的「天然群聚邊界」，讓組內變異最小、組間變異最大。視覺上既不會被尾部極端值搞爛、也不會被分位數的均勻切法掩蓋分布本身的特徵。

但 Jenks 不是無代價的勝利。它的斷點是「跟著這份資料算出來的」，所以換一份資料就會跳——我做單一年度的靜態地圖沒事，但如果你要做跨年比較、或資料會定期更新，每次重算斷點會讓同一個值的顏色一直變，讀者沒辦法把兩張地圖疊著看；斷點也常落在像 47.3 萬這種難讀的非整數上，圖例不好寫；資料量大時演算法本身也吃計算資源。需要跨年比較或動態更新時，我反而會改用一組固定的手動斷點，犧牲一點貼合度換「同一個值永遠同一個顏色」。

順帶一提，被我跳過的**分位數**也不是只有缺點。它保證每一級剛好塞進 1/5 的村里，每個顏色都有足夠樣本、不會出現某一級全空，而且因為切法跟絕對數值脫鉤，反而最適合講「相對排名」（你家這個里贏過全台幾成）。它的代價是會把分布的形狀抹平——明明差很多的兩個里可能同色、差一點點的卻被切到不同級。所以這比較像「想突顯分布形狀就 Jenks、想講相對位置就分位數」的取捨，不是誰絕對贏。

ColorBrewer + Jenks 是我這次的選擇，QGIS 內建分級和 Axis Maps 的 cheatsheet 也都把這組當預設起點。

## 真實踩到的坑：opacity × 基底圖

色階跟分級都選定了，本以為大功告成。結果 MapLibre 跑起來——polygons 看不見。

我用的是 [OpenFreeMap](https://openfreemap.org) 的 positron 主題：一張極淺的灰底地圖。fill-opacity 設 0.7（直覺值），結果 YlGnBu 的淺色端（`#ffffcc` 黃白）幾乎跟灰底融成一體，深色端（`#253494` 海軍藍）又跟海面顏色撞色——這正是前面比較表裡標的「YlGnBu 深色端容易跟水體混」那個陷阱，被我親身撞上了。

我沒有因為這個陷阱就放棄 YlGnBu，因為「冷靜客觀」對所得題材還是太合適。比較划算的是直接緩解撞色：把 positron 底圖的水體圖層換成更淺或偏灰的顏色、跟深藍拉開明度差，再幫每個 polygon 加一條極細的白色邊框，深色里之間就不會糊在一起、也不會跟海連成一片。fill-opacity 最後拉到 **0.85** 才整體看得清楚——但又會壓掉一些路名標籤。這就是真實場景才會踩到的互動：色階 × 基底圖 × opacity 是綁在一起的，不能分開選。

**經驗法則**：

- 淺底圖（positron / light）：fill-opacity 0.8–0.9
- 深底圖（dark matter）：用 plasma / magma + opacity 0.7
- 衛星圖底：避免低明度色階，用 viridis 反而清楚

## 反思

弄了一小時下來，最大的轉變是我終於不再把色階當「挑哪個比較好看」的事。實際做一張會被別人看的地圖才懂，每個選擇都在無聲地對讀者說「這裡多、那裡少、這裡異常、那裡正常」，選錯就是在誤導——它是「不要誤導」的問題，不是美感問題。

至於怎麼選，我的順序是先抄 ColorBrewer 再依場景判斷例外。Cynthia Brewer 花了二十年讓那些色階通過色盲、印刷、感知測試，沒道理自己重調一遍；但「直接抄」也有抄不動的時候——要做連續漸層而不是分級、要配深色模式、或品牌色有指定，這幾種情況就得自己再調，ColorBrewer 只是個夠好的起點，不是終點。

另一個只有真的跑過才知道的事，是「色階 × 基底圖 × opacity」綁在一起。設計稿上看好好的色階，疊到真實地圖才發現淺色端融進底圖、深色端撞水面，一定要在實際的 MapLibre / Leaflet 環境跑一次，光看 ColorBrewer 預覽不準。長尾資料則別用等距分級——台灣的所得、房價、人口、營收幾乎都是長尾，等距會把它們壓成「一片米色 + 幾個亮點」，用 Jenks 看分布形狀、用分位數看相對排名都比等距好。

&gt; 後記：這篇最後選的 YlGnBu，後來我又換掉了。做到比較後期、開始認真面對「一般民眾看得懂嗎」這件事，我把色階整個改成 OrRd（暖色、7 級、用對數絕對門檻分桶），原因和我怎麼把「選色階」這一個決策拆成兩個獨立的軸，寫在 [為什麼我把所得地圖色階從 viridis 換成 OrRd](/blog/orrd-warm-palette-two-decisions-framework)。所以這篇的結論請當成「當時的我」的選擇，不是定案。

---

完整的 TaxMap-TW 程式碼會放在 [GitHub repo](https://github.com/bobo52310/TaxMap-TW)。

接下來其實還沒輪到 PMTiles。底圖本身要選哪家服務，是上色之前就得先決定的事——[下一篇](/blog/openfreemap-maptiler-base-map-comparison)會先比 OpenFreeMap、MapTiler、Mapbox 這幾個 Web 地圖底圖服務怎麼選。至於怎麼把這 7,747 個村里 polygon（簡化後 5.6 MB 的 GeoJSON）封裝成單檔 PMTiles、再用 HTTP Range 按需讀取，留到 [PMTiles 那篇](/blog/pmtiles-http-range-request-single-file-tiles)再細講。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.B1NMUiEQ.webp" medium="image"/><category>資料視覺化</category><category>地圖</category><category>MapLibre</category><category>ColorBrewer</category><category>色彩</category><enclosure url="https://bobochen.dev/_astro/cover.B1NMUiEQ.webp" length="0" type="image/png"/></item><item><title>OpenFreeMap vs MapTiler vs Mapbox：6 個 Web 地圖底圖服務怎麼選？</title><link>https://bobochen.dev/blog/openfreemap-maptiler-base-map-comparison/</link><guid isPermaLink="true">https://bobochen.dev/blog/openfreemap-maptiler-base-map-comparison/</guid><description>做台灣所得稅地圖選底圖時，發現業界標準 Mapbox 免費額度只有 5 萬次/月。整理 OpenFreeMap、MapTiler、Mapbox、NLSC 等 6 種主流服務的免費額度、token、style 比較，以及為什麼公民科技專案選了 2024 年才上線的 OpenFreeMap。</description><pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate><content:encoded>&gt; **沒有 vector tile、style spec 的基本概念？**
&gt; 建議先看 [Web 地圖底圖是什麼？vector vs raster、tile pyramid、style spec 一次搞懂](/blog/web-map-tile-basics-vector-raster-style)。

最近在做 [TaxMap-TW](https://github.com/bobo52310/TaxMap-TW)（台灣 7,747 個村里所得稅地圖），村里界、色階都選好之後，我以為「底圖」會是 5 分鐘就解決的小問題。

結果光是底圖服務的選擇就讓我研究了一個下午。

主要的卡點是：**業界標準的服務每月有額度上限，但我做的是公民科技專案，未來流量我控制不了**。如果某天上熱搜流量爆掉、剛好那個月超額被收錢，那真的會傻眼。

這篇整理我評估的 6 種主流 Web 地圖底圖服務，最後選了 [OpenFreeMap](https://openfreemap.org/)（2024 才出現的新興服務）的理由。

## 選底圖服務（Mapbox、MapTiler…）為什麼是個問題

那天下午一開始我其實很輕鬆，想說底圖不就挑個好看的接上去就好。真正讓我卡住的是這件事：地圖網站上看到的「地圖本身」（道路、地名、地形）跟我疊在上面的所得資料（村里 polygon、色階）是兩層東西。

- **底圖**：道路網、地名、邊界 — 由地圖服務商提供
- **資料層**：我自己的村里標記、polygon、色塊 — 我自己畫

底圖服務商要把全球 OSM 資料切成幾百 GB 的 tiles、放到 CDN、提供 API，成本不低，所以業界普遍採「免費額度 + 超量收費」模式。我打開 Mapbox 定價頁那一刻才意識到：原來「免費」是有條件的，而我這個專案的條件偏偏最差——流量我完全控制不了。

對個人 / 公民科技專案來說，常見的選擇困境是：

- **業界標準（Mapbox）**：品質最高，免費額度 5 萬次 map loads/月
- **MapTiler**：免費額度是 10 萬次 API requests + 5,000 次 map sessions/月，中文支援好（注意計費單位跟 Mapbox 不一樣，下面會解釋）
- **自架**：完全免費、流量完全自主，但要 100GB+ 儲存 + 維運
- **政府服務（NLSC）**：免費、台灣地名/門牌/行政界線的在地化是所有選項裡最準的，但官方 style 偏舊、不好直接拿來做資料視覺化
- **新興服務**：聽過但不敢用，怕停服

## OpenFreeMap vs MapTiler vs Mapbox：6 種底圖服務比較

我把每家的定價頁、文件、官方 demo 都點過一輪，整理成這張對照表：

| 服務 | 費用 | Token | 中文標籤 | 部署 |
|------|------|-------|----------|------|
| **[OpenFreeMap](https://openfreemap.org/)** | 完全免費無限 | 不需要 | 一般 | 0 |
| **[MapTiler](https://www.maptiler.com/)** | 10 萬 requests + 5,000 sessions/月免費 | 需要 | 中文好 | 0 |
| **[Stadia Maps](https://stadiamaps.com/)** | 20 萬次/月免費 | 需要 | 一般 | 0 |
| **[Mapbox](https://www.mapbox.com/)** | 5 萬次/月免費 | 需要 | 中文好 | 0 |
| **[CARTO](https://carto.com/)** | 免費試用（額度請以官網為準）| 需要 | 一般 | 0 |
| **[NLSC 國土測繪](https://maps.nlsc.gov.tw/)** | 政府免費 | 不需要 | 在地化最好 | 0 |
| **自架** | $0 | 不需要 | 看你怎麼接 | 100GB+ |

「部署」這欄是我比較關心的：除了自架，所有方案都是「用他們的 hosted endpoint」，本質上零部署。差別只在註冊、token、流量上限。

要特別提醒一件事：**這些「免費額度」的計費單位其實不一樣，不能直接比大小**。Mapbox 算的是 map loads（地圖載入次數），MapTiler 同時有 API requests（每次抓 tile / style 都算）和 map sessions（一次地圖瀏覽算一個 session）兩種計量——它的 sessions 只有 5,000/月，反而比 Mapbox 的 5 萬 map loads 還低。所以「MapTiler 額度比 Mapbox 高」這句話其實站不住，得看你的使用情境是被哪個單位卡住。CARTO 的免費條件這幾年也改過幾次（從早期的固定額度變成試用 + PAYG），要用之前最好自己去官網確認一次當下的數字。

## 為什麼選 OpenFreeMap

最後我選了 [OpenFreeMap](https://openfreemap.org/)。它是 2024 年才上線的服務，由作者（GitHub 帳號 hyperknot）一個人營運，tile 跑在他自己租的 Hetzner dedicated server 上（用 Round-Robin DNS 撐負載），Cloudflare 則贊助頻寬、R2 拿來存 tile。經費來自 [GitHub Sponsors](https://github.com/sponsors/hyperknot) 的社群捐款。

選擇理由：

**1. 完全免費無流量上限**

對公民科技 / 個人專案最重要的條件。MapTiler、Mapbox 都會在熱門時刻變成「等等我要付錢嗎」的焦慮源。OpenFreeMap 把那種「上熱搜當天會不會收到帳單」的焦慮直接拿掉了。

不過得誠實講：**免費不等於沒風險，只是把風險換了一種**。付費服務你超額會被收錢，但你跟它有付費關係、它有義務給你 SLA；免費的公共 endpoint 你跟它沒有任何契約，它哪天要對特定 referer 限流、改政策、甚至收手不做，你都只能接受（OpenFreeMap 官方其實就有針對 referer 做過流量限制）。所以這裡換到的不是「零風險」，是「把超額帳單的風險，換成可用性與政策的風險」。如果你真的怕爆量、又不想被別人的政策綁住，**唯一的根治解是 self-host**——流量完全自主，代價是你得自己扛 100GB+ 的儲存跟維運。我這次沒走那條路，是因為這是個下班做的專案，我寧可賭供應商風險，也不想多養一台機器。

**2. 零註冊、零 token**

幾秒鐘就能接上。我做的是開源專案，要求每個 fork 的人都去申請 MapTiler token 太麻煩。

**3. 基礎設施靠得住，但要清楚它是誰在撐**

它的 tile 靠 Cloudflare 贊助頻寬、R2 存放，所以下載速度跟全球可達性其實滿穩。但要老實說：它不是「Cloudflare 託管」，背後是一個人租 Hetzner 機器在跑。我選它不是因為「Cloudflare 罩著就不會掛」，而是因為對一個公民科技專案，這個穩定度已經夠用——真要怕它哪天掛掉，後面我會講解法。

**4. 風格夠用**

官方現在提供 positron、bright、liberty、dark、fiord（含 3D）等幾種 style，我最常用的是 positron——經典的灰白底色，給彩色 polygon 留空間，很適合資料視覺化。

當然它也有缺點：相對新（2024 年起）、中文標籤沒 MapTiler 細緻、單人營運加上靠 GitHub Sponsors 撐經費，哪天作者沒力或贊助斷掉就有停服風險。但對 MVP / 個人專案，這些都不是阻擋條件——前提是你想清楚萬一它掛了，你能多快換掉它。

中文標籤這點我要多講一句，因為對台灣專案它可能是 deal-breaker。OpenFreeMap 的 OSM 標籤是有中文沒錯，但偏鄉的小地名、巷弄、行政界線的完整度，跟 MapTiler 或 NLSC 比是有差的——大概就是「縣市、主要道路、知名地標看得到，但越往細節越空」。我的地圖是看村里所得色塊、底圖只是襯底，所以這個差距我吃得下；但如果你做的是要讓使用者照著底圖找路、認在地地名的應用，這個落差就足以讓你改選 MapTiler，甚至認真考慮把 NLSC 疊進來。

## 3 分鐘接上 OpenFreeMap

從零到 [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) + OpenFreeMap 跑起來，三步驟：

**1. 裝套件**

```bash
pnpm add maplibre-gl
```

**2. 初始化地圖**

只要一行 style URL，剩下都跟一般 MapLibre 用法一樣：

```ts
import maplibregl from &apos;maplibre-gl&apos;;
import &apos;maplibre-gl/dist/maplibre-gl.css&apos;;

const map = new maplibregl.Map({
  container: &apos;map&apos;,
  style: &apos;https://tiles.openfreemap.org/styles/positron&apos;,
  center: [121.0, 23.7],   // Taiwan center
  zoom: 7,
});
```

最常用的幾個 style URL（另外還有 `dark`、`fiord` 等）：

- `positron` — 灰白底色（推薦做資料視覺化）
- `bright` — 完整彩色（看起來像 Google Maps）
- `liberty` — 經典 OSM 風格

**3. attribution**

OpenFreeMap 要求標示來源（也是 OSM 授權的要求）。MapLibre 預設會自動讀 style 的 attribution，所以不用額外做事。

如果要客製 attribution：

```ts
const map = new maplibregl.Map({
  // ...
  attributionControl: { compact: true },
});
```

這樣地圖右下角會出現一個小 `(i)`，點開才會展開完整來源。

## 反思

研究這個的過程踩到一個我自己的迷思：**「業界標準 = 最佳選擇」**。Mapbox 是地圖領域的業界標準，所以一開始我幾乎想都沒想就要選它。但 5 萬次/月的免費額度，對一個未來不知道會多少流量的開源專案，是個沉重的決策。

這讓我反思：技術選型其實有兩個維度

1. **技術成熟度** — Mapbox &gt; MapTiler &gt; OpenFreeMap
2. **商業模式 fit** — 對公民科技/個人專案，OpenFreeMap &gt; MapTiler &gt; Mapbox

如果是企業專案、有預算、流量可預期，Mapbox 還是最佳選擇。但如果是「想做就做、不確定會不會紅、寧可服務醜一點也不想付錢」這種專案，業界標準反而是錯的選擇。

其實這兩端中間還有路可走，不是只能二選一。比較務實的策略是**先用免費服務起步、流量真的起來再升級**：OpenFreeMap 接得快，等哪天爆量或想要更穩，再換成付費的 MapTiler / Mapbox，或乾脆自架——因為 style URL 只是一行設定，遷移成本很低。而 self-host 也別只當成「太麻煩」一句帶過：它是唯一能讓你**流量完全自主、不被任何供應商政策綁住**的選項，正好對應我開頭最焦慮的那件事。我這次沒選它，是清楚地拿「自己維運一台機器」去換「不用煩流量」，不是因為它不好。

另一個收穫是：**新興的開源/免費服務（OpenFreeMap、Stadia 接手 Stamen）值得試**。老實說我自己一開始也是看到「2024 才上線、沒聽過」就想直接跳過，後來逼自己把官方 demo 點開、把 style 接到地圖上跑一遍，才發現它對我的需求剛剛好。這些服務通常是社群驅動、目標是補上業界標準的缺口，對小型專案、prototype、學生作業就是寶藏。但「值得試」不等於「閉著眼睛賭」——我會試一個新服務的前提是：我承受得起它哪天突然掛掉，而且我有一條半天內能換掉它的退路。如果是公司要長期維運、不能斷的系統，我就不會拿它當底圖。

最有趣的發現是 **OpenFreeMap 的營運模式**：一個人租機器、Cloudflare 贊助頻寬、靠 GitHub Sponsors 收社群捐款，本質上是「我幫全世界省流量費，部分人回饋一點就夠我跑下去」。我喜歡這種商業模式之外的可能性，但也沒天真地把全部身家押上去——我留的後路是：TaxMap 的 style URL 是一行設定，真要換成 MapTiler 或自架，半天就能搬完。賭一個新服務，前提是你算得出「它掛掉的那天」要付出多少。

## 系列其他文章

- **想完全繞過底圖選型？** → [PMTiles 取代傳統 tile server：HTTP Range Request 的單檔魔術](/blog/pmtiles-http-range-request-single-file-tiles)
- **底圖術語完全不懂？** → [Web 地圖底圖是什麼？vector vs raster、tile pyramid、style spec 一次搞懂](/blog/web-map-tile-basics-vector-raster-style)
- **底圖之後是色階** → [資料地圖該用哪種色階？viridis、YlGnBu 與 ColorBrewer 實戰指南](/blog/data-map-color-scale-viridis-ylgnbu)
- **整個專案的決策全貌** → [打造 TaxMap-TW 完整心得：6 個技術決策、踩了 4 個坑](/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls)</content:encoded><media:content url="https://bobochen.dev/_astro/cover.C-xVGqG8.webp" medium="image"/><category>WebGIS</category><category>地圖</category><category>MapLibre</category><category>OpenFreeMap</category><category>MapTiler</category><enclosure url="https://bobochen.dev/_astro/cover.C-xVGqG8.webp" length="0" type="image/png"/></item><item><title>Web 地圖底圖是什麼？vector vs raster、tile pyramid、style spec 一次搞懂</title><link>https://bobochen.dev/blog/web-map-tile-basics-vector-raster-style/</link><guid isPermaLink="true">https://bobochen.dev/blog/web-map-tile-basics-vector-raster-style/</guid><description>Web 地圖底圖到底是什麼？這篇用最白話的方式把 vector tile vs raster tile、tile pyramid（z/x/y 金字塔）怎麼設計、style spec 為什麼存在一次講清楚，順便聊 raster 和 vector 各自適合的場景。讀完再回去看其他地圖技術文章會順非常多。</description><pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate><content:encoded>剛開始碰 Web 地圖時，我看技術文章常常卡在一些術語：vector tile、raster tile、tile pyramid、style spec、z/x/y、PMTiles、MBTiles... 每一個拆開都不難，但混在一起就會頭很大。

寫完 [TaxMap-TW](https://github.com/bobo52310/TaxMap-TW) 後回頭整理，發現 Web 地圖底圖其實就是三個核心概念：**tile**、**format**、**style**。把這三個搞懂，其他術語都是周邊。

這篇給沒碰過 GIS 的讀者鋪底，把這三件事說清楚。

## 第一個觀念：Web 地圖底圖為什麼要切成 tile？

打開 Google Maps，你拖、你縮、它都很順。但全球地圖資料動輒幾百 GB，瀏覽器不可能一次下載完。

解法：**把地圖切成方格**，需要哪一塊才下載哪一塊。這個方格就叫 **tile**。

每個 tile 通常是 256×256 或 512×512 像素的小圖。瀏覽器看你縮放到哪個 zoom level、視窗範圍涵蓋哪些 tile，就只下載那幾張。

縮小看全世界 → 1 張 tile 就夠
放大到台北街道 → 視窗內有 4-8 張 tile

這個機制讓 Web 地圖能用「漸進式載入」處理 PB 等級的資料。

## Tile pyramid（瓦片金字塔）：z/x/y 是什麼？

你常常會看到地圖圖磚的 URL 長這樣：

```text
https://tiles.example.com/{z}/{x}/{y}.pbf
```

`z` 是 zoom level（縮放等級），`x`、`y` 是該 zoom 下的座標。

整個地圖被組織成「金字塔」結構：

- **z = 0**：整個地球切成 1 張 tile（1×1 = 1 張）
- **z = 1**：切成 4 張（2×2）
- **z = 2**：切成 16 張（4×4）
- ...
- **z = 18**：切成 687 億張（262144×262144）

每多一個 zoom level，tile 數量乘 4。z=18 大概是看清楚門牌號碼的等級。

對地圖服務商來說，這個金字塔是固定的世界座標，所有人共享同一套切法。你開發地圖時，瀏覽器自動算出「現在這個視窗在 z=10 需要 (532, 411) 這張 tile」，然後請求那個 URL。

## 第二個觀念：vector tile vs raster tile

Tile 有兩種主要格式，差別決定了你的地圖能做什麼。

### Raster tile（點陣圖磚）

每張 tile 是一張預先渲染好的 **PNG / JPG 圖片**。瀏覽器拿到就直接貼上去，沒有運算成本，連最老的瀏覽器都吃得下。代價是樣式被烤進圖裡了：顏色改不了、標籤關不掉，文字放大會糊（因為早就被點陣化），而且每換一種風格就得整套重切，存儲很快就爆。

不過我要先澄清一件事，因為我自己一開始也搞錯：raster 不是「過時」，它是「不同需求」。有些東西本來就只能用 raster——衛星影像、空拍圖、地形暈渲這類本身就是像素的資料，沒有 geometry 可以拿來向量化；要列印或匯出成圖檔時，也是點陣圖最直接。基礎建設上 raster 也最簡單，一個資料夾擺一堆 PNG、丟到任何靜態 host 就能跑，不需要 WebGL、不需要 style JSON。Leaflet 這種老牌、輕量的函式庫原生就吃 raster。

所以老牌地圖服務（Google Maps 早期版本、傳統 OSM tile server）用 raster，不代表它們「劣等」，只是當年那個需求 raster 就夠了。

### Vector tile（向量圖磚）

每張 tile 是 **geometry + properties 的二進位資料**（通常 `.pbf` Protocol Buffer 格式），不是圖片。瀏覽器拿到資料後即時渲染，所以可以動態改顏色、隱藏特定圖層，文字永遠清晰（在客戶端用向量字型正確繪製），而且同一套 tile 可以套用無限多種風格。這幾個優點是我選 vector 的主因。

但 vector 不是免費午餐，這點當初的我也低估了。它要用 WebGL，老瀏覽器不支援，渲染還要吃客戶端的 CPU/GPU——這兩點是課本都會講的。實務上真正咬人的是另外幾件：你得載一包 MapLibre GL JS（壓縮後也有兩三百 KB），外加 glyphs（字型切片）和 sprites（圖示集），這些都算進首屏成本，比直接貼 PNG 重得多；行動裝置上持續的 GPU 渲染也比較耗電。如果你想自己切 vector tile（不是用現成服務），tippecanoe 那套工具鏈和 schema 設計的門檻也比烤一堆 PNG 高一截。最後是中文專案逃不掉的坑：CJK 字型切成 glyphs 後檔案動輒幾十 MB，字型 fallback 沒設好就會看到一堆豆腐方塊。

所以我的結論是：如果你只是要在頁面上放一張不會動的底圖，用 vector 其實是殺雞用牛刀，raster 反而省事；vector 的價值要在「需要動態換樣式、疊資料層」時才真的兌現。

近 5 年的主流地圖服務（Mapbox、MapTiler、OpenFreeMap）多半預設 vector tile，但這是「主流方向」不是「raster 被淘汰」——兩者是分工，各自守著不同的場景。

**簡單記憶法**：raster 是「拍照」，vector 是「設計稿」。設計稿可以改顏色，照片不行；但要的就是一張照片時，硬套設計稿流程反而麻煩。

## 第三個觀念：style spec — 同一份 tile，無限種風格

既然 vector tile 是 geometry + properties，那要怎麼決定「這條路畫多粗」、「這個 polygon 上什麼色」？

答案是 **style spec** — 一份 JSON 規格，描述每個圖層怎麼畫。

[MapLibre Style Spec](https://maplibre.org/maplibre-style-spec/)（從 Mapbox GL JS 衍生）是業界標準。一個典型的 style 長這樣：

```json
{
  &quot;version&quot;: 8,
  &quot;sources&quot;: {
    &quot;openmaptiles&quot;: {
      &quot;type&quot;: &quot;vector&quot;,
      &quot;url&quot;: &quot;https://tiles.openfreemap.org/planet&quot;
    }
  },
  &quot;layers&quot;: [
    {
      &quot;id&quot;: &quot;water&quot;,
      &quot;type&quot;: &quot;fill&quot;,
      &quot;source&quot;: &quot;openmaptiles&quot;,
      &quot;source-layer&quot;: &quot;water&quot;,
      &quot;paint&quot;: {
        &quot;fill-color&quot;: &quot;#a1d6f2&quot;
      }
    },
    {
      &quot;id&quot;: &quot;road&quot;,
      &quot;type&quot;: &quot;line&quot;,
      &quot;source&quot;: &quot;openmaptiles&quot;,
      &quot;source-layer&quot;: &quot;transportation&quot;,
      &quot;paint&quot;: {
        &quot;line-color&quot;: &quot;#ffffff&quot;,
        &quot;line-width&quot;: 1
      }
    }
  ]
}
```

每個 `layer` 對應 vector tile 裡的一種圖層（water、road、building...），定義怎麼畫。

換 style URL = 換樣式。底圖資料完全一樣，但灰白底色、深色模式、夜景樣式都是換 style 達成的。

不過「無限種風格」有個前提常被忽略：你只能畫 tile 裡有的東西。style 是在既有的 geometry + properties 上塗色，tile 沒切進去的圖層（例如某個 POI 類別、某條行政界線），style 再怎麼寫也變不出來——那種時候只能回頭重切一份 tile。所以 style 給你的是「呈現自由」，不是「資料自由」。

OpenFreeMap 最常用的是 positron（灰白）、bright（彩色）、liberty（OSM 經典）三種預設 style，另外還有 dark、fiord 等選擇；當然你也完全可以自己寫一份 style JSON 丟給 MapLibre 用。

## 一個典型的 Web 地圖管線

理解了 tile、format、style 之後，現代 Web 地圖的完整管線就清楚了：

```text
OSM 原始資料 (PB 等級)
    ↓ 切 tile
Vector tiles ({z}/{x}/{y}.pbf)
    ↓ 由 tile server / CDN serve
你的瀏覽器
    ↓ MapLibre 讀取 tile + 套用 style
畫在 canvas 上的地圖
    ↓ 你加上自己的資料層（標記、polygon...）
完整的應用
```

對開發者來說，要做決定的點是：

1. **Tile 從哪來**：用第三方服務（OpenFreeMap / MapTiler / Mapbox）？自架？
2. **Style 用什麼**：服務商預設？自己寫？
3. **怎麼顯示**：MapLibre / Leaflet（原生只支援 raster，要吃 vector 需外掛）/ deck.gl？

大致上，「Web 地圖底圖」這個題目的自由度就落在這三件事上——當然細節還有很多，但抓住這三條主軸就不太會迷路。

## 三個延伸場景

**場景 A：你只是要在網站上放一張可拖拉的地圖**

最簡單：用 [Leaflet](https://leafletjs.com/) + OSM raster tiles。10 行 code 搞定。

**場景 B：你要做資料視覺化（choropleth、heatmap）**

進階：MapLibre + vector tile 服務（OpenFreeMap 或 MapTiler）+ 自己加資料層。

**場景 C：你要做完全離線、或客製化到底的地圖**

最進階：自架 tile server，或者用 [PMTiles](/blog/pmtiles-http-range-request-single-file-tiles) 把 tile 打包成單檔。

TaxMap-TW 我走的是場景 B：要在底圖上疊 7,747 個村里的所得 choropleth，又想要灰白底圖不搶戲、之後還能換深色模式，所以選了 MapLibre + OpenFreeMap 的 vector tile，自己加一層 GeoJSON 資料層。每個場景對 tile、style、顯示函式庫的選擇都不一樣，先想清楚自己在哪一格，後面的決定才不會亂套。

## 收尾

寫到這裡，前面的術語應該都能 mapping 回三個核心概念：

- **z/x/y URL** = tile 在金字塔的座標
- **PBF / MBTiles / PMTiles** = vector tile 不同的封裝格式
- **Style URL** = 怎麼把 vector tile 變成你看到的地圖
- **WebGL** = vector tile 需要的渲染技術

老實說這三個概念我當初前後摸了快兩個晚上才串起來，但串起來之後再看任何一篇地圖技術文章，那些術語就各自歸位、不再嚇人了。如果要我給個偏好：大多數人其實不需要急著鑽 vector 的細節，先用場景 A 的 raster 把地圖跑起來、確定真的需要動態樣式了，再升級到 vector 也不遲。

## 系列其他文章

- → [PMTiles 取代傳統 tile server：HTTP Range Request 的單檔魔術](/blog/pmtiles-http-range-request-single-file-tiles)
- → [OpenFreeMap vs MapTiler vs Mapbox：6 個 Web 地圖底圖服務怎麼選？](/blog/openfreemap-maptiler-base-map-comparison)
- → [資料地圖該用哪種色階？viridis、YlGnBu 與 ColorBrewer 實戰指南](/blog/data-map-color-scale-viridis-ylgnbu)
- → [打造 TaxMap-TW 完整心得：6 個技術決策、踩了 4 個坑](/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls)</content:encoded><media:content url="https://bobochen.dev/_astro/cover.ixw25Swj.webp" medium="image"/><category>WebGIS</category><category>地圖</category><category>入門</category><category>MapLibre</category><category>OpenStreetMap</category><enclosure url="https://bobochen.dev/_astro/cover.ixw25Swj.webp" length="0" type="image/png"/></item><item><title>PMTiles 取代傳統 tile server：HTTP Range Request 的單檔魔術</title><link>https://bobochen.dev/blog/pmtiles-http-range-request-single-file-tiles/</link><guid isPermaLink="true">https://bobochen.dev/blog/pmtiles-http-range-request-single-file-tiles/</guid><description>PMTiles 把上千萬個 tile 打包成單一檔案，靠 HTTP Range Request 讓瀏覽器只讀需要的部分，沒有 tile server、丟到 S3 就能用。整理它的設計、跟 MBTiles 的差異、何時不該用，以及在 TaxMap-TW 怎麼用 tippecanoe 產一個。</description><pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate><content:encoded>&gt; **沒有底圖、tile、vector tile 的基本概念？**
&gt; 建議先看 [Web 地圖底圖是什麼？vector vs raster、tile pyramid、style spec 一次搞懂](/blog/web-map-tile-basics-vector-raster-style)。

在 [上一篇底圖比較](/blog/openfreemap-maptiler-base-map-comparison) 提到「自架需要 100GB+ 儲存」，我把自架直接排除了。但其實有第三條路：**PMTiles**。

PMTiles 是 [Protomaps](https://protomaps.com/) 出品的新型 tile 格式。它的核心想法很瘋狂：

**把上千萬個 tile 打包成 1 個檔案，丟到 S3 / R2 / Cloudflare Pages，瀏覽器靠 HTTP Range Request 只讀需要的位元組。**

沒有 tile server、沒有 Docker、沒有資料庫。你可以把它想成「靜態檔案上的 tile 服務」。

[TaxMap-TW](https://github.com/bobo52310/TaxMap-TW) 的 7,747 個村里 polygon 就是這樣上線的：一個 5.7 MB 的 `.pmtiles` 檔放在 Cloudflare Pages，瀏覽器只讀當前視窗需要的 chunks。整個地圖服務沒有任何 server。

這篇拆解 PMTiles 的設計，以及實戰怎麼產一個。

## 為什麼 PMTiles 想取代傳統 tile server

傳統做法是這樣的：

1. 用 [tippecanoe](https://github.com/felt/tippecanoe) 把 GeoJSON 切成 vector tiles，輸出成 MBTiles（一個 SQLite 檔，內含上萬條 tile blob）
2. 跑一個 tile server（[tileserver-gl](https://github.com/maptiler/tileserver-gl)、[martin](https://github.com/maplibre/martin)）讀 MBTiles
3. tile server 暴露 `/{z}/{x}/{y}.pbf` HTTP endpoint
4. 瀏覽器透過 MapLibre 請求 tile

這個架構的痛點：

- **需要 server**：得有一台機器跑 24/7
- **需要 SQLite 隨機讀**：tile server 要把 MBTiles 開著、不停讀
- **冷啟動慢**：Cloud Run / Lambda 第一次請求 tile 要載入整個 SQLite
- **延展性與成本**：流量大時 server 變瓶頸

對小型 / 靜態網站來說，光是「為了底圖而養一台 server」就讓很多人卻步。

不過先把話說公道：tile server 的這些「麻煩」反過來也是它的能力。它能即時更新——資料一改、下一次請求就拿到新 tile，不用重切；能在伺服器端依參數過濾，回傳不同子集；能依登入身分或權限產出不同的 tile。這些都是把資料封進一個靜態檔的 PMTiles 做不到的。所以它不是「過時」，而是換了一組取捨。下面講的痛點，是針對「資料不常變、不需要動態查詢」的場景。

## PMTiles 的核心想法

PMTiles 直接把問題拆掉：先把所有 tile（z/x/y 索引到 pbf binary）排成一個檔案、前面附上一份目錄（directory），然後把這個檔案丟到任何支援 HTTP Range Request 的地方（S3、R2、Cloudflare Pages、GitHub Pages 都行）。之後瀏覽器需要某個 `(z, x, y)` 的 tile 時，會先讀目錄查到那塊 tile 的 byte offset 和 length，再發一個像 `Range: bytes=12345-67890` 的請求，CDN 就只回那段 binary，MapLibre 直接吃。

整個流程沒有任何 server。`.pmtiles` 就是個靜態檔，CDN 加速、邊緣節點、cache 全都能用——前提是 host 真的支援 Range Request。它得在回應帶 `Accept-Ranges: bytes`、對 `Range` 請求回 `206 Partial Content`，跨網域用時還要設好 CORS。S3、R2、Cloudflare Pages、GitHub Pages 都 OK，但自架 nginx 或某些 CDN 設定不見得預設開。上線前用 `curl -I -H &quot;Range: bytes=0-99&quot;` 看一眼回的是不是 206，最省事。

### HTTP Range Request 是什麼？

HTTP/1.1 早就有的功能（規格出自 1997 年的 RFC 2068，後來併入 1999 年的 RFC 2616），但很多人沒用過。

瀏覽器送出：

```http
GET /villages.pmtiles HTTP/1.1
Host: tiles.example.com
Range: bytes=12345-67890
```

伺服器回應：

```http
HTTP/1.1 206 Partial Content
Content-Range: bytes 12345-67890/5998619
Content-Length: 55546

&lt;那段 binary&gt;
```

關鍵點：**伺服器只回那個範圍**，不會把整個 6 MB 檔案傳給你。

這個能力一直存在，PMTiles 只是聰明地用它來實作「單檔 tile 服務」。

## PMTiles vs MBTiles 比較

| 項目 | MBTiles | PMTiles |
|------|---------|---------|
| 格式 | SQLite（gzip 壓縮的 tile blob） | 自訂二進位 (header + directory + tile blobs) |
| 需要 server | 是（tile-server-gl / martin） | 否 |
| 部署 | server + SQLite 檔 | 一個 .pmtiles 檔 + 靜態 host |
| 客戶端讀取 | 透過 server HTTP API | 瀏覽器直接 HTTP Range |
| Cloud-native | 不友善（要 server 跑 SQLite） | 完美（純靜態檔） |
| 工具支援 | 老牌、生態廣 | 較新但 tippecanoe 直接支援 |
| 適合場景 | 自架 tile 服務、企業內部 | 靜態網站、無 server、低成本 |

對 Cloudflare Pages、Vercel、GitHub Pages 這類只能放靜態檔、不能跑 server 的託管，PMTiles 幾乎是唯一不用另外架服務就能上 vector tile 的選項。

## PMTiles 的限制：什麼時候不該用

寫到這裡好像 PMTiles 全面碾壓，但它換來的好處是有代價的。Protomaps 官方自己就有一篇〈[You Might Not Want PMTiles](https://docs.protomaps.com/pmtiles/cloud-storage#you-might-not-want-pmtiles)〉，我自己用下來，下面幾個情況我不會選它：

- **資料常變動**：PMTiles 是不可變的單一檔。改一個村里的幾何，就得整包重切、重傳、讓 CDN 重新快取。TaxMap 的村里界一年才更新一次，這成本可以接受；如果是每天甚至每小時更新的資料，傳統 tile server 的「改了就生效」反而省事。
- **需要動態查詢或權限控管**：PMTiles 出去的是固定的一份檔，沒辦法依使用者身分、登入狀態回不同內容。要「付費才看高解析、依角色給不同圖層」這種需求，還是得有一層 server 把關。
- **巨型資料集**：檔案越大、目錄越深，一次 tile 請求可能要先讀好幾跳目錄才找到 byte offset，Range Request 的來回次數跟頻寬都會上去。幾 MB 到幾百 MB 很舒服，但到了幾十 GB 的全球底圖等級，就要認真評估 CDN 命中率與請求數，不是無腦丟上去就好。

簡單說：**資料靜態、規模中小、不需要伺服器端邏輯時，PMTiles 很香；反過來就回去用 tile server。**

## 3 步驟產一個 PMTiles

完整實戰：把一份 GeoJSON 變成可以丟 CDN 的 `.pmtiles`。

### 1. 裝 tippecanoe

[tippecanoe](https://github.com/felt/tippecanoe) 是把 GeoJSON 切成 vector tile 的 CLI。原來是 Mapbox 出的，但他們從 2020 停更，現在用社群維護的 felt fork：

```bash
brew install tippecanoe
# 或從 source build
git clone https://github.com/felt/tippecanoe.git
cd tippecanoe &amp;&amp; make -j &amp;&amp; sudo make install
```

&gt; 注意 Mapbox 原版 `tippecanoe` 已 2020 年停更，felt fork 是現役版本。它的 `--output` 看副檔名自動決定格式，`.mbtiles` 和 `.pmtiles` 兩種都能直接吐——所以同一份 GeoJSON 想換格式，只要改輸出檔名就好。

### 2. 從 GeoJSON 產 PMTiles

最簡單的命令：

```bash
tippecanoe \
  --output=villages.pmtiles \
  --layer=villages \
  --minimum-zoom=6 \
  --maximum-zoom=13 \
  --detect-shared-borders \
  --coalesce-densest-as-needed \
  --extend-zooms-if-still-dropping \
  --force \
  villages.geojson
```

幾個關鍵 flag：

- `--minimum-zoom / --maximum-zoom`：要切哪些 zoom level。z=6 看全國，z=13 看里街道
- `--detect-shared-borders`：相鄰 polygon 共用邊界，省 50% 大小
- `--coalesce-densest-as-needed`：低 zoom 自動合併小 polygon 避免破圖
- `--extend-zooms-if-still-dropping`：高 zoom 還有特徵會自動延伸

跑 30 秒，吐出 5.7 MB 的 `villages.pmtiles`。

### 3. 放到 CDN + 在 MapLibre 接

把 `villages.pmtiles` 丟到 `public/data/geometry/villages.pmtiles`，部署到 Cloudflare Pages。

前端裝 [pmtiles npm](https://github.com/protomaps/PMTiles)：

```bash
pnpm add pmtiles
```

接到 MapLibre：

```ts
import maplibregl from &apos;maplibre-gl&apos;;
import { Protocol } from &apos;pmtiles&apos;;

// 註冊 pmtiles:// protocol handler
const protocol = new Protocol();
maplibregl.addProtocol(&apos;pmtiles&apos;, protocol.tile);

const map = new maplibregl.Map({
  container: &apos;map&apos;,
  style: &apos;https://tiles.openfreemap.org/styles/positron&apos;,
  center: [121.0, 23.7],
  zoom: 7,
});

map.on(&apos;load&apos;, () =&gt; {
  map.addSource(&apos;villages&apos;, {
    type: &apos;vector&apos;,
    url: &apos;pmtiles:///data/geometry/villages.pmtiles&apos;,
    promoteId: &apos;VILLCODE&apos;,  // 把 properties.VILLCODE 提升為 feature.id
  });

  map.addLayer({
    id: &apos;villages-fill&apos;,
    type: &apos;fill&apos;,
    source: &apos;villages&apos;,
    &apos;source-layer&apos;: &apos;villages&apos;,
    paint: {
      &apos;fill-color&apos;: &apos;#41b6c4&apos;,
      &apos;fill-opacity&apos;: 0.7,
    },
  });
});
```

關鍵在 `pmtiles://` 這個自訂 protocol。MapLibre 看到 `pmtiles://...` 開頭的 URL 會交給 `Protocol.tile` handler，handler 用 HTTP Range Request 讀取 PMTiles。

&gt; 上面 `fill-color` 我先寫死成 YlGnBu 的青色 `#41b6c4` 只是為了把幾何畫出來。所得地圖真正的色階後來改成了 OrRd，原因見 [為什麼我把所得地圖色階從 viridis 換成 OrRd](/blog/orrd-warm-palette-two-decisions-framework)。

對 MapLibre 來說，這就像是個正常的 vector tile source。但實際上瀏覽器只下載當前視窗需要的幾個 chunks（首屏約 200 KB），不是整個 6 MB。

## PMTiles 在 TaxMap-TW 的實戰

[TaxMap-TW](https://github.com/bobo52310/TaxMap-TW) 是個剛上線的台灣所得稅地圖，全台 7,747 個村里 polygon 都用 PMTiles 上線。

實際整合的踩雷：

**1. 從 MOI 下載的 Shapefile 是 Big5 編碼**

[內政部國土測繪中心](https://maps.nlsc.gov.tw/) 的村里界 shapefile zip 內含中文檔名，macOS 預設 `unzip` 會崩潰。改用 macOS native `ditto`：

```bash
ditto -x -k villages-1130928.zip villages-shp/
```

**2. tippecanoe 的 `--read-parallel` 會把 5.6 MB 的 GeoJSON 切碎解析錯誤**

我的 GeoJSON 一行一個 feature（mapshaper 輸出格式）。`--read-parallel` 想平行讀但會在某些行切錯。**單線程讀更穩**，30 秒也不算慢。

**3. `promoteId` 是必須的**

預設情況下 tippecanoe 產出的 tile 不會把 properties 的 ID 欄位提升到 feature.id。這會導致 MapLibre 的 `setFeatureState`（用來做 hover 反白）失效。在 source 設定加 `promoteId: &apos;VILLCODE&apos;` 即可。

**4. Mapshaper 簡化要用 `keep-shapes`**

把 NLSC 50 MB 的 shapefile 簡化到 5.6 MB GeoJSON，預設會「丟掉太小的 polygon」。但每個村里都要保留，所以要加 `-simplify 5% keep-shapes`，太小的村里也會被保留。

## 反思

讓我印象最深的是 Range Request 這個東西本身。它出自 1997 年的 RFC 2068、躺在 HTTP 規格裡快三十年，PMTiles 在 2021 年初發表、那年 FOSS4G 上正式亮相後，GIS 圈才比較多人這樣用它來做單檔 tile 服務。倒不是「沒人想到」，而是要等 R2 這類便宜物件儲存、CDN 對 Range 的普遍支援、瀏覽器端讀 PMTiles 的函式庫這幾個條件同時成熟，這套玩法才划算。我那天接完 `pmtiles://` 看到地圖跑起來、整個專案連一台 server 都沒有，確實愣了一下——原來繞了一圈，答案是 HTTP 本來就會的事。

架構上 PMTiles 是「移除中間層」：傳統 tile 架構有 4 層（origin DB → tile server → CDN → browser），它直接拿掉 tile server 那層（origin file → CDN → browser）。少一層確實少一個故障點，但代價是原本集中在 server 那層的快取策略、可觀測性、錯誤處理也一起沒了，這些複雜度其實是轉嫁到了客戶端和 CDN 設定上，只是不再是「我要顧的一台機器」而已。

成本面，PMTiles + Cloudflare 的零成本全端組合對個人專案、低中流量、用量落在免費額度內的情況，幾乎可以做到月付 $0：TaxMap-TW 的 OpenFreeMap 底圖、PMTiles polygon、SSG 靜態頁、CDN 流量加起來就是 $0。但 $0 不是物理定律——Range Request 一樣算請求數和頻寬，真的爆紅、流量衝破免費額度，帳單照樣會來。

&gt; 後記：這篇講的「TaxMap 放在 Cloudflare Pages」其實是當時的狀態。後來「一頁一檔」的 SSG 頁面撞到 Cloudflare Pages 的 2 萬檔上限，我把站搬到了 Netlify，過程寫在 [Cloudflare Pages 的 20,000 檔案上限](/blog/cloudflare-pages-20000-file-limit-taxmap-netlify)。PMTiles 這套單檔做法本身沒變，變的是放它的平台。

如果你有「想做地圖但被 server 嚇退」的點子，週末花一兩個小時，從 GeoJSON 到丟上 CDN 是真的跑得完的——只是記得先確認你的資料是不是適合走靜態這條路。

## 系列其他文章

- → [Web 地圖底圖是什麼？vector vs raster、tile pyramid、style spec 一次搞懂](/blog/web-map-tile-basics-vector-raster-style)
- → [OpenFreeMap vs MapTiler vs Mapbox：6 個 Web 地圖底圖服務怎麼選？](/blog/openfreemap-maptiler-base-map-comparison)
- → [資料地圖該用哪種色階？viridis、YlGnBu 與 ColorBrewer 實戰指南](/blog/data-map-color-scale-viridis-ylgnbu)
- → [打造 TaxMap-TW 完整心得：6 個技術決策、踩了 4 個坑](/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls)</content:encoded><media:content url="https://bobochen.dev/_astro/cover.DCTaMbj6.webp" medium="image"/><category>WebGIS</category><category>地圖</category><category>PMTiles</category><category>MapLibre</category><category>protomaps</category><enclosure url="https://bobochen.dev/_astro/cover.DCTaMbj6.webp" length="0" type="image/png"/></item><item><title>從 PDF / CSV 到 JSON：政府開放資料的 ETL 實戰</title><link>https://bobochen.dev/blog/tw-gov-open-data-csv-etl-fia-tax/</link><guid isPermaLink="true">https://bobochen.dev/blog/tw-gov-open-data-csv-etl-fia-tax/</guid><description>做 TaxMap-TW 時清理財政部所得稅 CSV 踩到 6 個坑：民國年 vs 西元年命名陷阱、schema 跨年漂移、BOM / 引號變化、「合計」與「其他」過濾、村里名罕用字 mojibake、早期年度只有 PDF 沒 CSV。記錄這些政府開放資料的真實樣貌與 ETL 處理 pattern。</description><pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate><content:encoded>做 [TaxMap-TW](https://github.com/bobo52310/TaxMap-TW) 最讓我意外的不是地圖、不是色階、是資料清理。

我以為「政府開放資料」就是去 [data.gov.tw](https://data.gov.tw/) 抓 CSV → 解析 → 完成。實際做才發現：

- 民國年 vs 西元年命名混淆
- 同一個 dataset 不同年度 schema 不一樣
- BOM、引號規則跨年漂移
- 表格裡藏著「合計」「其他」要過濾
- 村里名有罕用字 mojibake 跨資料集對不起來
- 早期年度只有 PDF，沒 CSV

每一條單看都不是技術難題，但全部疊起來，地圖本身兩個晚上就會動了，光把這份資料弄乾淨我花了快一週。

這篇整理我在處理財政部所得稅 CSV 時踩到的 pattern，這些 pattern 在不少政府開放資料上都能類推（至少 CSV / 下載式 dataset 這一類）。

## 陷阱 1：民國年 vs 西元年

財政部的所得稅 CSV 檔名長這樣：

```text
111_165-9.csv
112_165-9.csv
113_165-9.csv
```

`111`、`112` 是民國年。**但這個民國年指的是「所得發生年」，不是「發布年」或「申報年」。**

舉例：

| 檔名 | 民國年 | 所得發生 | 申報 | 初步核定 | 正式核定 |
|------|--------|----------|------|---------|---------|
| `111_165-9.csv` | 111 | 2022 | 2023/5 | 2024/6 | 2025/4 |
| `112_165-9.csv` | 112 | 2023 | 2024/5 | 2025/6 | **2026/4/30** |
| `113_165-9.csv` | 113 | 2024 | 2025/5 | **預估 2026/6-7** | 預估 2027/4 |

所以「2024 年的資料」是模糊的：

- 「2024 年發布的」= 民國 111（income year 2022）的核定版？還是民國 112 的初步核定版？
- 「income year 2024」= 民國 113 = 還沒發布

寫程式時要明確：**所有公開介面都用西元年（income year），內部存民國年只用在組 URL**。

```typescript
const ROC_OFFSET = 1911;
const rocToCe = (rocYear: number) =&gt; rocYear + ROC_OFFSET;
const fiaCsvUrl = (rocYear: number) =&gt;
  `https://www.fia.gov.tw/WEB/fia/ias/ias${rocYear}/${rocYear}_165-9.csv`;
```

這樣 user-facing 永遠是「2022 年所得」，build script 才用 `111` 組 URL。

## 陷阱 2：Schema 跨年漂移

抓下 11 年的 CSV（民國 101–111）後做了第一個 sanity check：每個檔頭幾個 bytes：

```text
101: e9 84 89 (鄉鎮市區)
105: ef bb bf e9 84 89 (BOM + 鄉鎮市區)
106: 22 e9 84 89 (引號 + 鄉鎮市區)
110: 22 ef bb bf e7 b8 a3 (引號 + BOM + 縣市別)
111: 22 ef bb bf e7 b8 a3 (引號 + BOM + 縣市別)
```

三件事跨年變了：

1. **BOM**：民國 101–104 沒 BOM，民國 105 起有 BOM
2. **引號**：民國 101–105 欄位無引號，民國 106 起加上雙引號
3. **欄位名**：民國 101–110 的第一欄叫「鄉鎮市區」，民國 111 改叫「縣市別」（但實際資料一樣，都是「臺北市松山區」這種拼接字串）

實際資料一致只是 header label 換名，所以解析時不能用「欄位名匹配」這種精準方法。**用欄位順序、忽略 BOM、忽略引號變化**：

```typescript
import Papa from &apos;papaparse&apos;;

const csv = await readFile(csvPath, &apos;utf-8&apos;);
// 跨年安全：strip BOM 若存在
const trimmed = csv.charCodeAt(0) === 0xfeff ? csv.slice(1) : csv;

const parsed = Papa.parse(trimmed, {
  header: false,  // ← 不要用 header: true，跨年欄位名不一樣
  skipEmptyLines: true,
  transform: v =&gt; v.trim(),
});

// 直接用 row[0], row[1] 索引取值
for (const row of parsed.data) {
  const cityTownship = row[0];  // 「臺北市松山區」
  const village = row[1];        // 「中華里」
  const mean = Number(row[4]);   // 平均所得
}
```

關鍵點：在這個 dataset 裡，欄位順序比欄位名穩定。政府資料的 schema 命名隨時可能改，但欄位順序通常不太動（因為改順序會破壞既有使用者）。

不過「信任欄位順序」不是免費的午餐，它有自己的失效情境。欄位名匹配遇到改名會直接報錯、逼你正視；位置索引遇到中間插一欄、刪一欄、或某年悄悄把兩欄對調，`row[4]` 還是讀得出一個數字，只是默默讀錯——這種**靜默取錯**比 header 對不上更危險，因為它不會炸，會一路把錯的值算進統計。我自己跨年漂移那段就是最好的反例：欄位名才剛被證明會跨年改，憑什麼相信順序就一定不改？

所以我的折衷是「位置索引 + 輕量驗證」：解析時照樣用 `row[0]`、`row[1]` 取值，但每個檔開頭加一道 sanity check，確認欄位數和關鍵欄的型別跟我預期的一致，對不上就讓 build 直接 fail，而不是默默產出錯誤資料。

```typescript
const EXPECTED_COLS = 10; // 這個 dataset 固定 10 欄
for (const row of parsed.data) {
  // 欄位數變了 → 大概率插欄/刪欄/重排，立刻停下來重新核對 schema
  if (row.length !== EXPECTED_COLS) {
    throw new Error(`欄位數異常：預期 ${EXPECTED_COLS}，實際 ${row.length}`);
  }
  // 該是數字的欄位卻不是數字 → 順序可能跑掉了
  if (Number.isNaN(Number(row[4]))) {
    throw new Error(`row[4] 應為平均所得數字，實際拿到「${row[4]}」`);
  }
}
```

位置索引的賭注是「順序穩定」，驗證就是替這個賭注買的保險：賭對了幾乎零成本，賭錯了它會在第一個檔就尖叫，而不是讓你三個月後才發現某年的平均所得整欄錯位。

## 陷阱 3：「合計」「其他」要過濾

逐列檢視資料後發現：

```csv
&quot;臺北市松山區&quot;,&quot;中崙里&quot;,&quot;1439&quot;,&quot;2452035&quot;,&quot;1704&quot;,&quot;817&quot;,&quot;356&quot;,&quot;1902&quot;,&quot;5899.26&quot;,&quot;346.20&quot;
&quot;臺北市松山區&quot;,&quot;自強里&quot;,&quot;3029&quot;,&quot;4355839&quot;,&quot;1438&quot;,&quot;666&quot;,&quot;285&quot;,&quot;1629&quot;,&quot;3517.94&quot;,&quot;244.63&quot;
...
&quot;臺北市松山區&quot;,&quot;其他&quot;,&quot;451&quot;,&quot;340002&quot;,&quot;754&quot;,&quot;498&quot;,&quot;268&quot;,&quot;1016&quot;,&quot;818.87&quot;,&quot;108.62&quot;
&quot;臺北市松山區&quot;,&quot;合計&quot;,&quot;69082&quot;,&quot;98796332&quot;,&quot;1430&quot;,&quot;729&quot;,&quot;310&quot;,&quot;1639&quot;,&quot;4770.51&quot;,&quot;333.57&quot;
&quot;臺北市大安區&quot;,&quot;和平里&quot;,&quot;...&quot;,&quot;...&quot;,&quot;...&quot;,&quot;...&quot;,&quot;...&quot;,&quot;...&quot;,&quot;...&quot;,&quot;...&quot;
```

每個鄉鎮市區的最後兩列是 `其他` 和 `合計`。**這兩列不是村里**：

- 「其他」：該鄉鎮中歸不到任何里的納稅單位
- 「合計」：該鄉鎮的小計（所有里 + 其他的加總）

如果不過濾，會被當成「2 個額外的村里」算進統計，總數就會錯。每個檔 ~387 個合計 + ~406 個其他要過濾掉。

```typescript
const SKIP_VILLAGES = new Set([&apos;合計&apos;, &apos;其他&apos;]);

for (const row of parsed.data.slice(1)) {  // skip header row
  const village = row[1];
  if (SKIP_VILLAGES.has(village)) continue;
  // 真正的村里
}
```

過濾掉後民國 111 剩 7,748 個村里。這是 FIA 這份資料過濾後可對應到的村里數，與內政部公布的全台村里口徑量級一致（官方數字本身會逐年微調，不是固定值），不是什麼「官方公布的全台村里總數」——別把這份稅務資料的計數直接當成戶政權威數字。

## 陷阱 4：拼接字串 split

第一欄是「臺北市松山區」這種拼接字串。要拆成「縣市」+ 「鄉鎮市區」兩欄。

我本來以為要寫個正則或字典 lookup。實際看資料：

```text
南投縣、嘉義市、嘉義縣、基隆市、宜蘭縣、屏東縣、
彰化縣、新北市、新竹市、新竹縣、桃園市、澎湖縣、
臺中市、臺北市、臺南市、臺東縣、花蓮縣、苗栗縣、
連江縣、金門縣、雲林縣、高雄市
```

**全部 22 個縣市都是 3 個中文字**（最後一字為「市」或「縣」）。

```typescript
function splitCityTownship(combined: string) {
  return {
    city: combined.slice(0, 3),
    township: combined.slice(3),
  };
}
```

3 行解決。但要老實說：`slice(0, 3)` 能成立，純粹是「現在這 22 個縣市剛好都是 3 個字」這個巧合。它不是定律——萬一哪天行政區改名、或這份資料混進別的縣市格式，這刀就會切歪，而且一樣是默默切歪。所以我把這個巧合當「可利用但要驗證」的規律，配一張白名單 assert 當安全網：

```typescript
const KNOWN_CITIES = new Set([
  &apos;臺北市&apos;, &apos;新北市&apos;, &apos;桃園市&apos;, &apos;臺中市&apos;, &apos;臺南市&apos;, &apos;高雄市&apos;,
  &apos;基隆市&apos;, &apos;新竹市&apos;, &apos;嘉義市&apos;, &apos;新竹縣&apos;, &apos;苗栗縣&apos;, &apos;彰化縣&apos;,
  &apos;南投縣&apos;, &apos;雲林縣&apos;, &apos;嘉義縣&apos;, &apos;屏東縣&apos;, &apos;宜蘭縣&apos;, &apos;花蓮縣&apos;,
  &apos;臺東縣&apos;, &apos;澎湖縣&apos;, &apos;金門縣&apos;, &apos;連江縣&apos;,
]); // 22 個

function splitCityTownship(combined: string) {
  const city = combined.slice(0, 3);
  if (!KNOWN_CITIES.has(city)) {
    throw new Error(`切出非預期縣市「${city}」，原字串：${combined}`);
  }
  return { city, township: combined.slice(3) };
}
```

規律性是政府資料的隱藏資產，多看幾個 row 比寫 fancy 解析器有用；但「規律」和「保證」是兩件事，能利用就順手加一個 assert 把假設釘死，免得哪天規律破了你還蒙在鼓裡。

## 陷阱 5：跨資料集 JOIN 的罕用字 mojibake

最痛苦的是這個。

我要把 FIA 的「縣市|鄉鎮|村里」資料，跟 NLSC 國土測繪中心的村里界 GeoJSON（用 `VILLCODE` 索引）對起來。

理論上「臺北市松山區中華里」在兩邊都該存在。實際做 JOIN：

```typescript
// 7,747 個 NLSC codified villages
// 7,721 matched FIA
// 26 不 match
```

99.66% 命中率。剩下 26 個是這種：

```text
嘉義市|西區|磚𥕢里     ← FIA 用「𥕢」
嘉義市|西區|磚磘里     ← NLSC 用「磘」

臺南市|安南區|塩埕里  ← FIA 用「塩」
臺南市|安南區|鹽埕里  ← NLSC 用「鹽」（標準字）
```

兩個資料集對同一個村里用了不同的「異體字」或「PUA private use area」字。

實際上 [kiang/salary](https://github.com/kiang/salary) 維護一份手動對照表來處理這些，但對我這個「在地圖上塗色」的 MVP 來說 99.66% 已經很夠。

不過這個「夠」是看用途的，不是通則。我這裡每個村里只是地圖上一塊獨立色塊，缺 26 個就是 26 塊顯示「無資料」，不影響其他村里——這種場景 0.34% 缺漏可以接受。但如果今天是要把全台所得**加總**、算各縣市佔比、判斷「某村里有沒有達到某門檻」這類資格判定、或要拿去做學術統計，那 0.34% 就不能放著：缺的不是隨機 26 個，而是**系統性地漏掉用罕用字/異體字的偏鄉里**，等於你的缺漏剛好集中在某一類地區，會讓加總和分佈悄悄偏掉。用途越接近「精確數字」，這 0.34% 越不可妥協。

處理方式：

```typescript
const villages: Record&lt;string, VillageMeta&gt; = {};
for (const feature of geojson.features) {
  const compositeKey = `${COUNTYNAME}|${TOWNNAME}|${VILLNAME}`;
  const hasFiaMatch = fiaStats.has(compositeKey);
  villages[VILLCODE] = {
    code: VILLCODE,
    // ...
    hasStats: hasFiaMatch,
  };
}
```

把「有沒有對應 FIA 資料」存進 master JSON，前端遇到沒對應的村里就顯示「目前無資料」。

在「顯示用」的場景下，我寧可承認資料的不完美、誠實標記缺漏，也不硬塞模糊匹配演算法去猜——猜錯一個村里的所得，比老實說「無資料」傷害更大。但這句話別當成「永遠別追 100%」的鐵則：當缺的那 0.34% 會被拿去加總、判定資格或做統計時，該補的對照表還是得補（或至少把缺漏清單攤開讓人知道），而不是用「承認不完美」當不處理的藉口。

## 陷阱 6：早期年度只有 PDF

研究 agent 一開始給我的 URL pattern 是：

```text
https://www.fia.gov.tw/WEB/fia/ias/ias{民國年}/{民國年}_165-9.csv
```

說「民國 88 (1999) 到民國 112 (2023) 都有」。實測卻發現：

```text
ROC 101-111 (2012-2022): HTTP 200 ✓
ROC 88-100 (1999-2011):  HTTP 404 ✗
ROC 112 (2023):          HTTP 404 ✗
```

實際上 CSV 只覆蓋 11 年。早期年度官方只有 PDF；2023 年（民國 112）核定版剛在 2026/4/30 公布但 CSV 還沒上架。

這直接砍掉了「1999-2023 共 25 年」這個 scope。我把專案調整為 MVP 只做 2012-2022。

後來我學乖了：政府資料的「公開」常常分好幾種介面（CSV、XLSX、HTML、PDF、API），而且不同年度開放的介面還不一樣。研究 agent 給的 description 寫得再篤定，規劃前我都先 curl 一輪把每年實際拿不拿得到檔案掃過，再決定 scope，省得做到一半才發現有四年根本沒 CSV。

## 政府開放資料 ETL 的 6 條通用 pattern

整理我這次學到的政府開放資料 ETL 通則：

1. **公開介面用西元年** — 民國年只在組 URL 時用
2. **不信任 header** — 用欄位順序、處理 BOM 與引號變化
3. **掃描異常列** — 「合計」「其他」「小計」要過濾
4. **規律性是寶藏** — 多看幾個 row 通常有 dirty 但穩定的 pattern
5. **承認資料不完美（看用途）** — 顯示用場景，99% 命中 + 標記 1% missing 比強塞演算法可靠；但要拿去加總、判定資格或做統計時，那 1%（且常系統性漏掉偏鄉罕用字）就得補齊
6. **規劃前先 curl** — 不同年度公開不同介面，description 不可信

如果這六條只能記一條，我會記第 2 條的延伸版：別相信任何人（包括研究 agent、包括 description、包括上一年的自己）對這份資料長相的描述，自己 curl 下來、自己印出前幾個 byte、自己用 assert 把假設釘住。這次大半的坑，本質都是「我以為它長這樣，它其實長那樣」。

也得老實承認：不是所有鍋都甩給上游。回頭看，這些坑有一半是我自己一開始沒先替整條管線立一份「資料契約」——把「應該幾欄、哪欄是數字、村里數量級、哪些值要過濾」這些預期先寫成 schema validation 擺在最前面，後面任何一年違約就立刻 fail。我是踩了三四個坑之後才補上這層驗證的，要是一開始就建，至少能少跑幾趟「產出怪數字 → 回頭 debug」的冤枉路。我也沒法保證這套打法在每一份台灣政府開放資料上都成立——我的樣本就 FIA 這一份（API、XML、奇怪編碼的資料各有各的故事）——但至少 CSV / 下載式 dataset 這一類，照這六條走能少踩很多坑。

## 系列其他文章

資料清乾淨之後，接下來就是「怎麼把這些村里排名、怎麼畫成地圖」，沿著「清理 → 排名 → 視覺化 → 復盤」的脈絡：

- → [競爭排名 vs 密集排名 vs 百分位：地圖標籤的 ranking 設計](/blog/competition-vs-dense-ranking-map-labels)
- → [PMTiles 取代傳統 tile server：HTTP Range Request 的單檔魔術](/blog/pmtiles-http-range-request-single-file-tiles)
- → [打造 TaxMap-TW 完整心得：6 個技術決策、踩了 4 個坑](/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls)
- → [Web 地圖底圖是什麼？vector vs raster、tile pyramid、style spec 一次搞懂](/blog/web-map-tile-basics-vector-raster-style)
- → [OpenFreeMap vs MapTiler vs Mapbox：6 個 Web 地圖底圖服務怎麼選？](/blog/openfreemap-maptiler-base-map-comparison)</content:encoded><media:content url="https://bobochen.dev/_astro/cover.CZr_Hb3I.webp" medium="image"/><category>資料工程</category><category>ETL</category><category>開放資料</category><category>TypeScript</category><category>Papa Parse</category><enclosure url="https://bobochen.dev/_astro/cover.CZr_Hb3I.webp" length="0" type="image/png"/></item><item><title>競爭排名 vs 密集排名 vs 百分位：地圖標籤的 ranking 設計</title><link>https://bobochen.dev/blog/competition-vs-dense-ranking-map-labels/</link><guid isPermaLink="true">https://bobochen.dev/blog/competition-vs-dense-ranking-map-labels/</guid><description>7,748 個村里，最大排名居然不是 7,748？做 TaxMap-TW 排名功能才發現「排名」有 5 種演算法，差別都在同分怎麼處理。比較 Competition、Dense、Standard 等的適用情境，說明地圖標籤為什麼選 Competition Ranking。</description><pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate><content:encoded>[TaxMap-TW](https://github.com/bobo52310/TaxMap-TW) 的每個村里詳細頁都顯示「全國第 #58 / 7,748」這種排名。

我以為這是 5 分鐘的事：

```typescript
sorted.sort((a, b) =&gt; b.value - a.value);
const rank = sorted.findIndex(v =&gt; v.code === target.code) + 1;
```

寫完跑一下 verify 腳本，發現一個怪事：

```text
Max national rank == village count (7,748): expected 7748, got 7744 ✗
```

最大排名是 **7,744**，不是 7,748。為什麼？

挖下去發現「排名」其實有 5 種演算法，差別都在「同分怎麼處理」。這篇整理 5 種 ranking 演算法、選擇邏輯、以及為什麼地圖視覺化選 Competition Ranking。

## 排名為什麼會少 4 名？

先確認問題：

```typescript
const lowest = Object.entries(r.rankings)
  .map(([k, v]) =&gt; ({ key: k, rank: v.median.national.rank }))
  .sort((a, b) =&gt; b.rank - a.rank)
  .slice(0, 5);
// [
//   { key: &apos;新北市|石碇區|碧山里&apos;, rank: 7744 },
//   { key: &apos;宜蘭縣|大同鄉|土場村&apos;, rank: 7744 },
//   { key: &apos;澎湖縣|馬公市|新復里&apos;, rank: 7744 },
//   { key: &apos;臺南市|南區|荔宅里&apos;, rank: 7743 },
//   { key: &apos;新北市|瑞芳區|碩仁里&apos;, rank: 7742 },
// ]
```

3 個村里**並列 7744 名**。他們的中位數所得都是 0（沒有任何納稅單位申報）。

照「直觀」排名應該是 7746、7747、7748 — 但實際是 7744、7744、7744。

這就是 **Competition Ranking** 在「並列最後一名」時的行為：**並列共享一個排名，剩下的名次被「跳過」**。

## 5 種 ranking 演算法

維基百科把排名演算法分成 5 種。用 `[100, 90, 90, 80, 70]` 當範例：

### 1. Standard Ranking（也稱 Ordinal Ranking，1, 2, 3, 4, 5）

&gt; 維基百科把這個序列正名為 Ordinal ranking。我這裡沿用程式裡寫的 Standard 名稱，但它對應的就是維基的 Ordinal。

```typescript
sorted.indexOf(target) + 1; // 比物件參考而非值，同分也各自拿到唯一名次
```

簡單按 index 排，每個人都拿到唯一名次、永遠剛好 1 到 N。代價是同分時誰排前面取決於 sort 的細節 —— 如果你只用 `value` 排，同一個值可能拿到 2 或 3，結果不穩定。

❌ 沒定義 tie-breaker 時不適合公開展示，因為名次會跟著排序實作飄。
✅ 但只要補一個次要排序鍵（村里代碼、名稱字典序），它就變 deterministic，而且是「需要強制唯一名次」時的正解 —— 像分頁、產生穩定的 URL、做有序匯出，你就是不想看到並列。我自己的看法：Ordinal 不是壞演算法，只是它的「壞」全來自你沒把 tie-breaker 想清楚。

### 2. Competition Ranking (1, 2, 2, 4, 5)

同分共享名次，**下一個名次跳過**（&quot;olympic ranking&quot;）。

```typescript
function competitionRank(entries: { key: string; value: number }[]) {
  const sorted = [...entries].sort((a, b) =&gt; b.value - a.value);
  const ranks = new Map&lt;string, number&gt;();
  let lastValue: number | null = null;
  let lastRank = 0;
  for (let i = 0; i &lt; sorted.length; i++) {
    const { key, value } = sorted[i];
    const rank = value === lastValue ? lastRank : i + 1;
    ranks.set(key, rank);
    lastValue = value;
    lastRank = rank;
  }
  return ranks;
}
```

✅ 公平、直覺、廣泛使用於體育（奧運、F1）。
❌ 最大排名可能 &lt; N（並列尾巴會壓縮）。

### 3. Modified Competition Ranking (1, 3, 3, 4, 5)

同分共享，**但跳過的是「前面」**（同分組共享後面的名次）。

```text
[100, 90, 90, 80, 70]
  1    3    3    4    5
```

✅ 跟 Competition 一樣公平，但對「並列第二」的視覺感更強（不是 2、2，而是 3、3）。
❌ 直覺上有點怪，少用。

### 4. Dense Ranking (1, 2, 2, 3, 4)

同分共享，**下一個名次不跳過**。

```typescript
function denseRank(entries: { key: string; value: number }[]) {
  const sorted = [...entries].sort((a, b) =&gt; b.value - a.value);
  const ranks = new Map&lt;string, number&gt;();
  let lastValue: number | null = null;
  let currentRank = 0;
  for (const { key, value } of sorted) {
    if (value !== lastValue) currentRank++;
    ranks.set(key, currentRank);
    lastValue = value;
  }
  return ranks;
}
```

✅ 最大排名 = 唯一值的數量。「全國第 X 級」這種概念上更貼。
❌ 不直覺：你拿第 5 名可能其實是第 10 個人（前面有人並列）。

Dense 不是 Competition 的次等品，它有自己最對味的場景：**當重複值很多、或你想傳達的是「分到第幾級」而不是「絕對名次」時，Dense 更合適**。比方價格分 A/B/C/D 級、遊戲段位、或像我這個案例如果中位數所得只切成 5 段級距，Dense 給的「第幾級」反而比 Competition 的稀疏名次更好讀。我自己的取捨很簡單：使用者腦中想的是「我在第幾名」就用 Competition，是「我在哪一級」就用 Dense。

### 5. Fractional Ranking (1, 2.5, 2.5, 4, 5)

同分組分享「平均排名」。

```text
[100, 90, 90, 80, 70]
  1   2.5  2.5  4    5
```

✅ 統計學正統（用於 Mann-Whitney U test 等非參數檢定）。
❌ 出現小數，user-facing 不好看（「你是第 2.5 名」🤔）。

## SQL 對照

SQL 有對應的 window function，方便理解：

| 演算法 | SQL Window Function |
|--------|---------------------|
| Standard | `ROW_NUMBER()` |
| Competition | `RANK()` |
| Modified Competition | （無原生支援）|
| Dense | `DENSE_RANK()` |
| Fractional | （無原生支援）|

如果你用 PostgreSQL / MySQL，`RANK()` 就是 Competition Ranking。

## TaxMap-TW 為什麼選 Competition Ranking

對「展示給使用者看」這個場景，候選是 Competition vs Dense。我選了 Competition：

**1. 跟體育、考試成績一致**

讀者看到「第 58 名 / 7,748」會自動套用「奧運排名」的直覺。這是 Competition Ranking 的語意。

**2. 同分共享給予正確的「相對位置」**

如果 3 個村里中位數都是 0，他們的「位置」就是相同的，應該共享同一個排名。Dense Ranking 反而會說他們是「第 N 級」，但這層抽象對地圖讀者意義不大。

**3. 「跳過名次」這件事其實 OK**

verify 腳本第一次失敗讓我以為這是 bug，但仔細想：**沒有「第 7745 名、7746 名、7747 名」這 3 個位置是合理的** — 因為有 3 個人並列 7744。

我把 verify 腳本的斷言從 `===` 改成 `&lt;=`：

```typescript
check(
  `${y} max national rank ≤ village count (${count})`,
  maxRank &lt;= count,
);
```

**Competition 的代價我也認**：最大排名 &lt; N 這件事，第一眼確實會讓人懷疑「是不是漏算了」（我自己就被 verify 腳本嚇到一次）；而且如果底部並列的村里很多 —— 像有一大票里中位數都是 0 —— Competition 會把它們全壓到同一個名次，名次的尾段等於被「壓扁」，看不出彼此差異。我覺得在這個案例可以接受，是因為那批所得 0 的村里本來就「沒有差異可言」，給它們同一名次反而誠實；要是換成一份重複值很密、又需要在尾段分出高下的資料，我就會回頭選 Dense。

## 跨年「比去年變動」的計算

排名穩定後，下一個需求是「比去年上升 / 下降幾名」（YoY delta）：

```typescript
const deltaYoY = previousYearRank - currentYearRank;
// 正值 = 名次上升（rank 數字變小）
// 負值 = 名次下降
// 0 = 持平
// null = 去年無資料
```

注意正負號：rank 是「越小越好」，所以 delta = 去年 - 今年。

實際範例（中華里 2022 vs 2021）：

```text
2021 全國中位數第 85 名
2022 全國中位數第 58 名
Delta = 85 - 58 = +27（上升 27 名）
```

UI 顯示：↑ 27（綠色 chip）。

**邊界情況**：

- 去年該村里無資料 → delta = null（顯示 &quot;—&quot;）
- 今年該村里無資料 → 不顯示卡片
- delta = 0 → 顯示「持平」（不是 ↑0 也不是 ↓0）

## 排名 vs 百分位 — 兩個都要

地圖視覺化常會搞混「排名」和「百分位」這兩個概念：

**排名**（rank）：絕對位置，用於展示

&gt; 你的村里中位數所得 95 萬，全國第 58 名 / 7,748

**百分位**（percentile）：分布位置，用於色階分級

&gt; 你的村里中位數所得 95 萬，落在全國第 80 百分位（前 20% 的村里）

我一開始想偷懶，打算用排名直接驅動色階 —— 結果地圖糊成一片才想起這兩個根本不能混用：

- 詳細頁的大數字 → 用排名
- 地圖上的色塊 → 用百分位（quintile 分 5 級對應 5 種顏色）

百分位適合視覺化的理由是 **單調性**：第 50 百分位以下的村里色塊都比第 50 以上淺。但如果用排名，第 100 名和第 7000 名的色塊差異會被視覺壓縮（因為 1-7748 是線性的，但人眼的「淺到深」感受不是線性）。

不過用 quantile（等樣本數分桶）分級也不是沒代價：它保證每一級村里數量相等，但會把「值很接近、卻剛好跨桶邊界」的兩個村里塗成不同顏色，也會把「值差很多、卻擠在同一桶」的村里塗成同色 —— 所得這種長尾分布尤其明顯，最高那一桶可能從 200 萬一路涵蓋到 500 萬。所以分桶到底用 quantile 還是改用絕對門檻，本身就是另一個決策，我在色階那篇有完整討論。

色階分級的詳細討論在 [這篇色階文章](/blog/data-map-color-scale-viridis-ylgnbu)；至於最後我為什麼把色階從 viridis 換成 OrRd、怎麼把分桶和選色拆成兩個獨立決策，寫在 [為什麼我把所得地圖色階從 viridis 換成 OrRd](/blog/orrd-warm-palette-two-decisions-framework)。

## 反思

本來估 5 分鐘搞定的功能，最後花了一個晚上讀維基百科的 ranking 條目、再回頭把 verify 腳本改一遍。讓我意外的不是排名很難，而是它根本不是一件事 —— `sort` 完寫個 index 的時候，我以為「排名」就是排名，完全沒意識到自己已經默默選了 Ordinal、還沒處理同分。

這種「以為是常識、其實是默認選擇」的感覺很熟悉，做色階分級那次也一模一樣：當時把資料丟進 quantile 分 5 級就覺得理所當然，後來才發現「要不要等樣本數分桶」本身就是個有後果的決定。兩次都是同一個教訓 —— 看起來最沒爭議的步驟，往往藏著你沒注意到自己做了的選擇。也因為這樣，我現在會刻意把「展示給使用者的數字」和「拿去算的數字」分開：詳細頁的大數字用 rank 求直覺，地圖色塊用 percentile 求單調，硬要共用一個 metric 只會兩邊都將就。

最有趣的觀察是 SQL window function 命名：`RANK()` 是 Competition，`DENSE_RANK()` 才是 Dense。也就是 SQL 標準把沒前綴的 `RANK()` 對應到 Competition。當然這不代表所有領域都這樣 —— 統計檢定預設用 Fractional、有些排行榜要的是 Dense —— 但至少在資料庫的世界，「排名」沒特別說明時通常指 Competition。

## 系列其他文章

- → [Web 地圖底圖是什麼？vector vs raster、tile pyramid、style spec 一次搞懂](/blog/web-map-tile-basics-vector-raster-style)
- → [OpenFreeMap vs MapTiler vs Mapbox：6 個 Web 地圖底圖服務怎麼選？](/blog/openfreemap-maptiler-base-map-comparison)
- → [從 PDF / CSV 到 JSON：政府開放資料的 ETL 實戰](/blog/tw-gov-open-data-csv-etl-fia-tax)
- → [資料地圖該用哪種色階？viridis、YlGnBu 與 ColorBrewer 實戰指南](/blog/data-map-color-scale-viridis-ylgnbu)
- → [為什麼我把所得地圖色階從 viridis 換成 OrRd：把「一個決策」拆成「兩個獨立軸」](/blog/orrd-warm-palette-two-decisions-framework)
- → [打造 TaxMap-TW 完整心得：6 個技術決策、踩了 4 個坑](/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls)</content:encoded><media:content url="https://bobochen.dev/_astro/cover.DhzV_Pt2.webp" medium="image"/><category>資料視覺化</category><category>演算法</category><category>Ranking</category><category>SQL</category><category>TypeScript</category><enclosure url="https://bobochen.dev/_astro/cover.DhzV_Pt2.webp" length="0" type="image/png"/></item><item><title>打造 TaxMap-TW 完整心得：6 個技術決策、踩了 4 個坑</title><link>https://bobochen.dev/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls/</link><guid isPermaLink="true">https://bobochen.dev/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls/</guid><description>TaxMap-TW（台灣所得稅地圖）月成本 $0、4-5 天從零上線的完整復盤：底圖、色階、PMTiles、Astro 6 SSG、FIA 直拓、Competition Ranking 共 6 個技術決策，與 macOS unzip Big5、MapLibre 容器尺寸卡 0×0 等 4 個踩過的坑。</description><pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate><content:encoded>起點其實只是一個很自私的問題：我想知道我家那個里，所得在全台排第幾。查不到現成的，就乾脆自己做一個。

[TaxMap-TW](https://github.com/bobo52310/TaxMap-TW) 後來做成了全台 7,747 個村里的所得稅地圖（11 年資料、4 個指標、雙維度排名、YoY 變動、歷年折線圖、搜尋），月成本 $0，從零到上線大概花了 4-5 天。

這篇是整個系列的收尾，回顧 6 個關鍵技術決策、踩過的 4 個坑，還有事後回頭看才想通的幾件事。

## TaxMap-TW 的 6 個技術決策

每個決定都有對應的詳細文章。這篇是 index。

### 1. 底圖：OpenFreeMap

&gt; 詳細：[OpenFreeMap vs MapTiler vs Mapbox：6 個 Web 地圖底圖服務怎麼選？](/blog/openfreemap-maptiler-base-map-comparison)

候選：Mapbox（業界標準、5 萬次/月免費）vs MapTiler（10 萬 requests 或 5,000 sessions/月）vs OpenFreeMap（無限免費）vs NLSC（政府服務）。

選 OpenFreeMap。理由：**對公民科技/個人專案，「免費無上限」比「業界標準」更重要**。

不過要誠實講 OpenFreeMap 的代價：它沒有 SLA、目前是單人維運、沒有商業等級的 backup、可選 style 也少。所以這個選擇只在「掛掉幾小時也沒人會死」的低營運風險場景成立——像這種個人 / 公民科技專案。如果是公司產品、有營收綁在地圖可用性上，我會老實付錢給 MapTiler 或 Mapbox 換 SLA。

### 2. 色階：YlGnBu + Jenks 自然斷點

&gt; 詳細：[資料地圖該用哪種色階？viridis、YlGnBu 與 ColorBrewer 實戰指南](/blog/data-map-color-scale-viridis-ylgnbu)

候選：viridis（學術主流）vs YlGnBu（公部門報告風）vs YlOrRd（媒體吸睛）。

選 YlGnBu + Jenks。理由：**色階要配合資料的長尾分布**，台灣所得高度集中於少數里，等距分級會變「90% 同色 + 幾顆紅點」。

（後記：這是當時的決策，但上線後我又把色階整個換掉了，從 YlGnBu 改成 OrRd、Jenks 改成對數絕對門檻，原因見 [為什麼我把所得地圖色階從 viridis 換成 OrRd](/blog/orrd-warm-palette-two-decisions-framework)。）

### 3. 切片：PMTiles

&gt; 詳細：[PMTiles 取代傳統 tile server：HTTP Range Request 的單檔魔術](/blog/pmtiles-http-range-request-single-file-tiles)

候選：傳統 tile server（要 24/7 機器）vs PMTiles（單檔 + HTTP Range Request）。

選 PMTiles。理由：**用 HTTP 標準繞過「需要 server」的傳統假設**，丟靜態託管就能用，$0 月支出。

要補一個適用邊界：PMTiles 的甜蜜點是**不常變動的資料**。村里界一年才更新一次，封裝成單檔最划算。如果你的資料頻繁更新、或需要動態查詢 / 即時篩選 tile，傳統 tile server 還是有它的位置——PMTiles 是在「靜態場景」取代 tile server，不是全面取代。

（後記：當時我以為「丟 Cloudflare Pages」就完美收工了，結果上線後撞到 CF Pages 單站 2 萬檔上限——光是 OG 圖就爆量——最後整站搬到 Netlify，過程見 [Cloudflare Pages 的 20,000 檔案上限](/blog/cloudflare-pages-20000-file-limit-taxmap-netlify)。）

### 4. 框架：Astro 6 SSG

候選：Next.js（SSR + ISR）vs Astro（SSG）vs Vue/Nuxt vs SvelteKit。

選 Astro 6 SSG。理由：**這個專案 100% static**（5 個固定路由 + 22 個縣市頁 + 7,747 個里詳細頁 + 1 個排行榜，全部 build time 預產）。Astro 對 SSG 場景比 Next.js 更輕、更快。最重要的是它有 **island architecture**：地圖元件 `client:only`、其他全部純 SSG，bundle 超小。

但 SSG 不是沒代價：資料一更新就得整站重 build，這個專案因為村里資料一年才動一次，重 build 的成本可以忽略，所以 fit。如果是天天變的資料，Next.js 的 ISR（增量靜態再生）就是為這種場景存在的，那時候我會選 Next.js 而不是硬撐 SSG。說到底還是場景 fit，不是 Astro 一定贏 Next.js。

7,774 個頁面 build 時間 7.5 秒。

### 5. 資料：直接從 FIA 拓

&gt; 詳細：[從 PDF / CSV 到 JSON：政府開放資料的 ETL 實戰](/blog/tw-gov-open-data-csv-etl-fia-tax)

候選：fork 既有專案的整理過 CSV vs 自己從 FIA 抓原始檔。

選自己抓。理由：**乾淨的資料故事**，加上 schema 跨年漂移、合計過濾、罕用字 mojibake 都得自己處理，這個過程也是學習。

但要把這個 trade-off 講清楚，別讓人誤會「自己拓一定比較好」：如果你的目標是**快速產出**、把東西做出來給人用，fork 別人整理好的 CSV 才是務實選擇，可以省掉好幾小時的 ETL 苦工。我選自己拓，是因為這是個人專案、沒有交付壓力，付得起這個「多花 3 小時換乾淨資料」的奢侈。換成接案或有 deadline，我大概會直接 fork。

### 6. 排名：Competition Ranking

&gt; 詳細：[競爭排名 vs 密集排名 vs 百分位：地圖標籤的 ranking 設計](/blog/competition-vs-dense-ranking-map-labels)

候選：Standard / Competition / Modified Competition / Dense / Fractional 共 5 種。

選 Competition Ranking。理由：跟奧運排名一致，使用者直覺；同分共享是公平的處理；「跳過名次」雖然會讓最大排名 &lt; 總數，但這是正確行為。

## 4 個踩過的坑

### 坑 1：NLSC zip 內含 Big5 編碼檔名

[內政部國土測繪中心](https://maps.nlsc.gov.tw/) 提供的村里界 shapefile 是 zip 內含中文檔名。**macOS 預設 `unzip` 會崩潰**，因為它把檔名當 UTF-8 解析但裡面是 Big5。

```text
checkdir error: cannot create villages-shp/ß¯®Ω¨…æ˙•vπœ∏Í_113
Illegal byte sequence
```

解法：用 macOS 內建的 `ditto`，它會處理 legacy filename charsets：

```bash
ditto -x -k villages-1130928.zip villages-shp/
```

Linux 上 `unzip -O cp950` 也可以指定編碼——但要注意 `-O` 不是上游 Info-ZIP 的預設選項，是 Debian / Ubuntu 打的 patch 才有，其他發行版（或自己編譯的版本）不一定吃這個 flag。

政府服務的 zip 到 2026 年還是常用 Big5 檔名，這件事我已經學會不要再期待它會改了，手邊備好 `ditto` 跟支援 `-O` 的 `unzip` 就是。

### 坑 2：MapLibre 強制把容器設為 position:relative

我的 HTML 結構是：

```html
&lt;section class=&quot;relative h-[70vh]&quot;&gt;
  &lt;div id=&quot;map&quot; class=&quot;absolute inset-0&quot;&gt;&lt;/div&gt;
&lt;/section&gt;
```

預期：`absolute inset-0` 讓 `#map` 填滿父 section。

實際：**MapLibre 把 `#map` 強制設為 `position: relative`**（為了 attribution / control 的內部定位），讓 `absolute inset-0` 整組失效。

```text
mapW: 902, mapH: 0  ← 0 高度！
```

地圖渲染進一個 0 高度的容器。沒任何錯誤訊息。

解法：別用 `absolute inset-0`，直接用 `h-full w-full`：

```html
&lt;section class=&quot;relative h-[70vh]&quot;&gt;
  &lt;div id=&quot;map&quot; class=&quot;h-full w-full&quot;&gt;&lt;/div&gt;
&lt;/section&gt;
```

這個坑教我一件事：第三方 widget 常常會偷改容器 style，而且不報錯。後來只要遇到「東西不顯示但 console 一片乾淨」，我都先打開 `getComputedStyle()` 看實際算出來的樣式，比盯著 console error 有用太多。

### 坑 3：Astro hydration 後容器尺寸變動，MapLibre 卡 0×0

跟坑 2 相關但更陰險。

修好容器高度後，地圖底圖偶爾還是渲染失敗。canvas 大小正確、`isStyleLoaded()` 為 true、attribution 顯示出來，但 **0 個 tile request**。

挖下去發現：MapLibre 在 `new Map()` 時記錄容器尺寸，**後續容器尺寸變動不會自動觸發 tile fetch**。Astro 的 island hydration 會在 map 建立後重新算 layout，導致 map 內部的 viewport 仍是 0×0。

解法：在 constructor 後排幾個 resize：

```typescript
const forceResize = () =&gt; this.map.resize();
requestAnimationFrame(forceResize);
setTimeout(forceResize, 100);
setTimeout(forceResize, 500);
```

醜，但 work。

**Lesson**：第三方 widget + SSG/island 框架的整合常有「初始化時機」問題。一個 hack-y 但穩定的解法（多次 resize）比追求乾淨更實用。

### 坑 4：tippecanoe `--read-parallel` 切碎 GeoJSON 解析錯誤

跑 tippecanoe 把 GeoJSON 變 PMTiles 時遇到：

```text
villages.simplified.geojson:1247: Found ] at top level
villages.simplified.geojson:1040: Reached EOF without all containers being closed
```

奇怪，我的 GeoJSON 結構明明是正確的，第 1247 行也不會有問題。

挖下去發現是 `--read-parallel` 把 5.6 MB 的 GeoJSON 切成多個 chunk 平行解析。我的 GeoJSON 是「一行一個 feature」的格式（mapshaper 預設輸出），平行讀會在某些行切錯。

解法：拿掉 `--read-parallel`。單線程讀 30 秒，不算慢。

這裡的教訓是：CLI 工具的「快速 mode」flag 通常偷偷假設了某種輸入格式。`--read-parallel`、`--parallel`、`--fast` 這類 flag 一出問題，我現在的反射動作是先回到單線程版本確認，是工具的鍋還是我資料的鍋，一試就知道。

## 整個專案的總結

**規模**：

- 5 個 routes + 7,774 個 SSG 頁面（5 + 22 + 7,747）
- 11 年 × 4 指標 × 2 維度 = 88 組排名 pre-compute
- 5.7 MB PMTiles + 17 MB stats JSON + 39 MB rankings JSON
- 月成本 $0（OpenFreeMap + PMTiles + 靜態託管全免費）

**時間**：

- 規劃 + 研究：1 天（含底圖、色階、PMTiles 三個技術選型）
- 資料管線：1 天（FIA fetcher + stats + rankings + verify）
- 地圖 + UI：1.5 天（5 個頁面 + 地圖互動 + 搜尋 + 圖表）
- 部署 + 收尾：0.5 天
- 總計 4-5 天

**事後回頭看才想通的幾件事**：

做完這個專案，我最有感的一個轉變是：我以前會問「哪個技術比較好」，現在會先問「在我這個場景哪個比較 fit」。Mapbox 是業界標準，但 5 萬次/月對開源專案太緊；自架 tile server 技術上完全可行，但對一個要 $0 月支出的專案來說，PMTiles 的單檔方案就是更對；FIA CSV 自己拓比 fork 別人多花 3 小時，可是換來乾淨的資料故事。沒有一個決定是純技術考量。不過要補一句：這套「看 fit 不看絕對好壞」的邏輯有個前提，就是這些維度都還能妥協。有一個維度我完全不讓步，就是稅務數字本身的正確性，這個錯了整個地圖就沒有存在意義，所以資料管線那關我做了最多 verify。

第二件事是新興工具真的值得試。OpenFreeMap（2024）、PMTiles（2022）、felt/tippecanoe（接手 Mapbox 棄子），這些都是 2-3 年前還不存在或不成熟的東西。技術圈的習慣是等成熟再用，但小型個人專案剛好是試新東西最划算的場域，賭錯了也只是我自己多花幾天。我會這樣賭，正是因為它低風險；同樣這幾個工具如果要進公司的生產系統，我會先盯著它們的維運狀態和 backup 方案再說，不會這麼隨性。

第三件事比較像心態：「不完美但可用」是公民科技的常態。99.66% 的村里 JOIN 率、80% 的功能、$0 月成本，這個組合對開源 / 公民科技其實已經夠用了。追求 100% 完美常常會把預算和熱情都耗光，做不到上線那一步。但「夠用」也要分維度，前面講的稅務正確性就不在「夠用就好」的範圍內。

## 系列全集

整個系列總共 10 篇，按推薦閱讀順序：

1. **入門基礎** → [Web 地圖底圖是什麼？vector vs raster、tile pyramid、style spec 一次搞懂](/blog/web-map-tile-basics-vector-raster-style)
2. **底圖選型** → [OpenFreeMap vs MapTiler vs Mapbox：6 個 Web 地圖底圖服務怎麼選？](/blog/openfreemap-maptiler-base-map-comparison)
3. **資料管線** → [從 PDF / CSV 到 JSON：政府開放資料的 ETL 實戰](/blog/tw-gov-open-data-csv-etl-fia-tax)
4. **色階設計** → [資料地圖該用哪種色階？viridis、YlGnBu 與 ColorBrewer 實戰指南](/blog/data-map-color-scale-viridis-ylgnbu)
5. **排名演算法** → [競爭排名 vs 密集排名 vs 百分位：地圖標籤的 ranking 設計](/blog/competition-vs-dense-ranking-map-labels)
6. **進階技術** → [PMTiles 取代傳統 tile server：HTTP Range Request 的單檔魔術](/blog/pmtiles-http-range-request-single-file-tiles)
7. **整體復盤** → 本文
8. **色階再進化** → [為什麼我把所得地圖色階從 viridis 換成 OrRd：把「一個決策」拆成「兩個獨立軸」](/blog/orrd-warm-palette-two-decisions-framework)
9. **搬站踩坑** → [Cloudflare Pages 的 20,000 檔案上限：TaxMap-TW 為什麼搬到 Netlify](/blog/cloudflare-pages-20000-file-limit-taxmap-netlify)
10. **OG 圖生成** → [用 Cloudflare Worker 按需生成 OG 圖：Satori + resvg 為 TaxMap-TW 產 7,750 張預覽圖](/blog/cloudflare-worker-on-demand-og-satori-taxmap)

&gt; 本文（第 7 篇）寫的是「當時上線那一刻的決策」。第 8、9 篇記錄的是上線之後我又改掉的兩件事——色階換成 OrRd、託管從 Cloudflare Pages 搬到 Netlify——所以前面決策段落裡你會看到對應的後記註記。

GitHub repo：[TaxMap-TW](https://github.com/bobo52310/TaxMap-TW)</content:encoded><media:content url="https://bobochen.dev/_astro/cover.QyDoHDUQ.webp" medium="image"/><category>Postmortem</category><category>專案總結</category><category>Astro</category><category>MapLibre</category><category>公民科技</category><enclosure url="https://bobochen.dev/_astro/cover.QyDoHDUQ.webp" length="0" type="image/png"/></item><item><title>為什麼我把所得地圖色階從 viridis 換成 OrRd：把「一個決策」拆成「兩個獨立軸」</title><link>https://bobochen.dev/blog/orrd-warm-palette-two-decisions-framework/</link><guid isPermaLink="true">https://bobochen.dev/blog/orrd-warm-palette-two-decisions-framework/</guid><description>做 TaxMap-TW 色階換三次，最後選 OrRd 不是因為好看，而是把「色階」拆成用色 (hue) + 分桶 (binning) 兩個獨立決策重新組裝。對數絕對門檻（30/50/80/130/200/350 萬）+ OrRd 7 級 = 跨年顏色穩定、中產區段視覺最寬、outlier 自然分出。</description><pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate><content:encoded>做 [TaxMap-TW](https://github.com/bobo52310/TaxMap-TW) 兩週，色階換了三次：**YlGnBu → viridis → OrRd**。最後選 OrRd 不是因為「比較好看」，而是因為我把「一個決策」拆成了「兩個獨立軸」。

之前寫過一篇選 YlGnBu 的理由（系列第 1 篇 [《資料地圖該用哪種色階？》](/blog/data-map-color-scale-viridis-ylgnbu)），結論在實際做下來被自己推翻了。這篇是那篇的下一章。

## 背景：7,747 個村里的所得色階怎麼選

TaxMap-TW 是全台 7,747 個村里、11 年 × 4 指標可切換的所得熱度地圖。每個 polygon 要根據選定的年度與指標上色，選色階時我一開始把它當成「挑色卡」的問題：

- 第 1 次選 **YlGnBu**（淺黃 → 深藍）：理由是「ColorBrewer 業界標準、色盲友善、公部門感」
- 第 2 次換 **viridis**（紫 → 藍 → 綠 → 黃）：聽說 matplotlib 標準、感知均勻最科學
- 第 3 次定案 **OrRd**（淺米 → 深紅）：使用者一句「viridis 不好看」之後重新思考

問題不在於哪一個「比較對」，而是我一直把「色階」當成單一決策。

## 發現過程

### viridis 被打臉的那一刻

從 YlGnBu 換到 viridis 的理由很學術：

- Perceptually uniform（明度線性變化）
- 色盲友善（Daltonization safe）
- matplotlib 預設、學界共識

換完跑 `pnpm dev`，使用者打開首頁，第一句話：「**顏色不好看。**」

我愣了一下，因為這在我認知裡是「最對」的選擇。viridis 的 perceptual uniformity 是真本事——要精讀數值、要在連續漸層上分辨細微差距，它確實比 OrRd 強。但回頭看截圖 — 紫色 → 黃色的漸層在一般民眾眼裡確實很疏遠，沒有「熱度地圖」的直覺。問題不是 viridis 不好，而是它的優化目標（精讀、跨媒介穩定）跟我這張地圖的目標（讓一般人一眼看懂熱度）不一致。那一秒我才真正聽進去。

### 找原型：kiang/salary 怎麼做？

我問使用者：「要不要參考一下其他人怎麼選的？」最快的途徑是看 [kiang/salary](https://github.com/kiang/salary)（江明宗的村里所得地圖，TaxMap-TW 的精神祖父）的程式碼。

翻到 `docs/map/main.js` 找到一個 `ColorBar(value)` function：

```javascript
function ColorBar(value) {
  if (value == 0)            return &quot;rgba(255,255,255,0.6)&quot;   // 白
  else if (value &lt;= 300)     return &quot;rgba(254,232,200,0.6)&quot;
  else if (value &lt;= 400)     return &quot;rgba(253,212,158,0.6)&quot;
  else if (value &lt;= 500)     return &quot;rgba(253,187,132,0.6)&quot;
  else if (value &lt;= 700)     return &quot;rgba(252,141,89,0.6)&quot;
  else if (value &lt;= 900)     return &quot;rgba(239,101,72,0.6)&quot;
  else if (value &lt;= 1100)    return &quot;rgba(215,48,31,0.6)&quot;
  else if (value &lt;= 1300)    return &quot;rgba(179,0,0,0.6)&quot;
  else if (value &lt;= 1500)    return &quot;rgba(127,0,0,0.6)&quot;
  else                       return &quot;rgba(64,0,0,0.6)&quot;        // 最深
}
```

短短 11 行，但裡面有**兩個獨立決策被疊在一起**：

1. **用色**：ColorBrewer **OrRd 9 級**（橙紅熱度）
2. **分桶**：**絕對門檻**（300/400/500/700/900/1100/1300/1500 千元，hard-coded）

我之前的選擇是：

1. 用色：YlGnBu / viridis（隨意換）
2. 分桶：**quintile**（程式自動算每年的 20/40/60/80 分位數）

**Ah ha。我一直把兩個獨立決策當成一個決策。**

### 拆開看兩個軸

把它們攤開：

| 軸 | 選項 | 影響 |
|---|---|---|
| **用色** | YlGnBu / viridis / OrRd / RdYlBu / Cividis... | 視覺氣質、色盲友善度、情緒聯想 |
| **分桶** | quintile / equal-interval / Jenks / 手動 / **log 絕對** | 跨年穩定度、outlier 處理、視覺均勻度 |

quintile 看起來很合理（每年自動切 20%），但對「跨年看趨勢」是災難 — 因為門檻每年變，**同樣的 100 萬，在 2012 是綠色、在 2022 可能是黃色**。使用者拖年度滑桿時整張地圖會閃色，卻看不出真正的改變。

絕對門檻沒這個問題，但要面對「outlier 怎麼處理」：kiang 的 9 級在 &gt;1500 千元（150 萬）就封頂，但松山中華里 2022 中位數 526 萬，跟 200 萬的里會被染成同一個深紅，看不出差別。

## 解法：OrRd 7 級 + 對數絕對門檻

把兩個決策獨立優化：

**用色：OrRd 7 級**

- 暖紅本來就是「熱度地圖」的視覺直覺
- 單色 sequential 不會像 RdYlBu 雙向被讀成「好 / 壞」
- kiang 已驗證好看
- 從 OrRd 9 級手挑 7 色子集（不是 ColorBrewer 官方 OrRd[7]），比 9 級少 2 級，視覺不擁擠

這裡要誠實補一句：OrRd 是單色順序色階，色盲友善度其實不如 viridis（viridis 整條對各類色覺缺陷都安全，這是它最硬的優點，前面拿這點打它其實是我選邊站後的雙標）。我把色盲友善降為次要考量，是因為這張地圖的受眾是一般民眾、且色階靠「明度由淺到深」也能讀出高低——而且 ColorBrewer 仍把 OrRd 標為 colorblind-safe，不是裸奔。如果受眾換成需要精準辨色的專業使用者，這個取捨我會重做。

**分桶：對數絕對門檻**

- 倍率 ~1.5×：30 / 50 / 80 / 130 / 200 / 350 萬（單位千元為 300 / 500 / 800 / 1300 / 2000 / 3500）
- 所得本來就 log-normal 分布，log 倍率切桶最自然
- 中產區段（50–130 萬）佔最寬視覺空間
- Outlier（&gt;350 萬）自己一個頂色，中華里 526 萬獨立深紅，跟 200 萬區分得開

實作很短：

```typescript
// src/components/MapClient.ts
// 注意：這不是 ColorBrewer 官方 OrRd[7]（官方首色 #fef0d9、末色 #990000）。
// 我是從 OrRd 9 級手挑出 7 色子集，跳掉中間兩階讓對比更開。
const ORRD_7 = [
  &apos;#fee8c8&apos;, // &lt;30 萬
  &apos;#fdd49e&apos;, // 30–50
  &apos;#fdbb84&apos;, // 50–80
  &apos;#fc8d59&apos;, // 80–130
  &apos;#ef6548&apos;, // 130–200
  &apos;#d7301f&apos;, // 200–350
  &apos;#7f0000&apos;, // &gt;350
] as const;

const ORRD_BREAKS = [300, 500, 800, 1300, 2000, 3500]; // 千元
```

paint expression 把每個 VILLCODE 對應到 stats、查 bucket 後上色：

```typescript
private classifyValue(value: number): number {
  for (let i = 0; i &lt; ORRD_BREAKS.length; i++) {
    if (value &lt; ORRD_BREAKS[i]) return i;
  }
  return ORRD_BREAKS.length; // 6 = &gt;350 萬最深紅
}
```

整段比 quintile 版簡單 — 因為門檻是常數，不再需要每次切換指標都重算 Jenks。

## 具體數據 / 結果

跟 kiang 比的改進：

| | kiang | TaxMap-TW |
|---|---|---|
| 用色 | OrRd 9 級 | OrRd 7 級 |
| 分桶數 | 9 級 + 0 | 7 級 + 無資料灰 |
| 最高門檻 | &gt;150 萬一個色 | &gt;350 萬一個色 |
| 中產解析度 | 50–130 萬擠在 2 級 | 50–130 萬攤開到 3 級 |
| 中華里 526 萬 | 跟 200 萬同色 | 獨立深紅 |
| 跨年穩定 | ✓（絕對門檻） | ✓（絕對門檻） |
| 透明度 | 0.6 半透明 | 0.85 較飽和 |

跟原本自己 quintile 版的差距更大 — 之前拖滑桿整張地圖在閃色，現在拖滑桿**只有「真正有變化的里」會升降顏色**，視覺訊號終於跟資料變化對齊。

**這個方案的代價我也得說清楚：**一組固定門檻（30/50/80/130/200/350 萬）是照「綜所稅中位數」的量級調的，但我有 4 個指標可切（中位數、平均、各分位）。平均數那檔的數值分布跟中位數不一樣，套同一組門檻時，低收入村里會偏擠在前一兩桶，解析度沒有中位數那檔漂亮——嚴格講每個指標該有自己的一組門檻，我為了「跨指標也用同一把尺」偷懶用一組，這是已知的妥協。另外那個「&gt;350 萬獨立深紅」聽起來解決了 kiang 的封頂問題，其實只是把 cap 從 150 萬往後推到 350 萬而已；中華里 526 萬跟假設某天冒出個 900 萬的里，還是會被染成同一個 `#7f0000`。outlier 永遠有，我只是把封頂線移到「現階段資料碰不到」的地方，不是真的解決了它。

## 反思

### 技術面

**Choropleth 色階是兩個獨立決策，不要疊在一起想：**

- **用色（hue / palette）**：解決「視覺氣質」與「色盲友善」
- **分桶（binning）**：解決「跨年穩定度」與「outlier 處理」

下次再看到任何 dataviz 設計問題卡住，先問自己：「**這真的是一個決策嗎？還是兩個被綁在一起？**」

**log 倍率 binning 在「對的前提下」是被低估的選擇——但它有前提：**

它對我這個場景好用，是因為剛好滿足三個條件：資料是 log-normal 分布、全正值、而且有公認的 anchor（所得級距、loan tier 那種大家認得的數字）。對應地：

- 適合 log-normal 且全正的資料：所得、檔案大小、網站流量、收入級距、地震規模
- 比 quintile 多了「絕對意義」（100 萬永遠是同色），所以能跨年比
- 比 equal-interval 不會被 outlier 拉爆
- 比手動 thresholds 容易解釋（「倍率 1.5x 切」一句話講完）

但只要前提不成立就別硬套：資料含 0 或負值（log 直接爆）、近常態分布（log 反而把中間擠扁）、你要看的是相對排名而非絕對值、或資料偏態到大量村里全擠進同一桶——這些情況絕對門檻都會輸。而 quintile 也不是只有缺點：它保證每個色階都分到差不多的樣本數，做「單年、單指標的分布快照」時，這個「每色都有料」的特性其實比絕對門檻好看也好讀。我換掉它純粹是因為 TaxMap 的核心是「跨年拉滑桿看趨勢」，不是因為 quintile 本身爛。

**抄前人的程式碼遠比從零推快：**

- kiang 11 行 `ColorBar()` 包含了他幾年累積的設計判斷
- 我看一眼就拿到「絕對門檻」的關鍵 insight
- 但我沒照抄，看完後問「他這樣做的痛點在哪？我能不能改進？」 — 結果改成 7 級 + log 倍率 + 更高的 cap

### 心態面

**「好看」是合法的產品需求。** 我一開始把使用者「顏色不好看」當成主觀偏好，差點要去說服他 viridis 才是正確答案。回頭看那是傲慢 — 一張公開的地圖是給一般民眾看的，不是給 matplotlib 用戶。學術上對 ≠ 產品上對。前提是「好看」不能踩到底線：可及性（色盲能讀）跟正確性（顏色沒有誤導數值）是不能用美感換的，這次只是色盲友善降為次要、沒被犧牲掉。

**「matplotlib 預設」不是中立選擇，是別人的優化目標。** viridis 是 2015 年 matplotlib 為「科學論文 print 出來在黑白紙上、影印後、色盲眼裡都讀得到」設計的——這目標很值得尊敬，只是它跟 web choropleth 的痛點不重疊。所以不是「viridis 不該用」，是「它的優化目標不該無條件套到我的場景」。盲目套用業界標準，等於把別人的優化目標當成自己的。

**少嘴砲、先打開別人的程式碼。** 我花了一小時跟使用者來回討論色階理論，最後實際解決問題的關鍵 insight，是花 30 秒讀 kiang 11 行 javascript 拿到的。但讀前人 code 要帶批判——它包的是「他的痛點」的答案，不一定是你的，所以我看完是問「他為什麼這樣切？我的場景哪裡不一樣？」，而不是整段抄走。

### 有趣發現

**ColorBrewer 名字超難記但網站超實用。** [colorbrewer2.org](https://colorbrewer2.org/) 是 Cynthia Brewer 教授 2002 年做的 choropleth 配色工具，內建 35+ 個色階分 3 類（sequential / diverging / qualitative）。每個都有色盲模擬、列印友善、影印友善的標記。地圖配色 99% 用得到，但因為網站做得很學術老派，常被忽略。

**換成暖紅之後，沒人再問「這顏色代表什麼」。** viridis 版我得跟使用者解釋「紫是低、黃是高」；OrRd 版打開來，他直接就說「喔右邊那塊比較紅就是有錢的」。紅＝熱＝高這個聯想在我這個使用者身上是零成本的——我不敢說它跨文化都成立，但對這張給台灣民眾看的地圖夠用了。

**對數倍率 1.5× 不是亂猜的，是公部門統計手冊的「level set」傳統。** Loan / income tiers 常切 30/50/80/130/200/300/500 萬 — 看起來只是「順手挑的數字」，其實是平均所得倍率累積的結果。我 TaxMap-TW 用 30/50/80/130/200/350，差不多一脈相承。

## 寫在最後

如果這兩週只留一句給未來的自己，那會是：色階卡住的時候，我反覆換的多半是「色卡」，但真正卡住我的是「分桶」。先把這兩件事拆開，再決定「我這張圖到底要跨年比、還是看單年分布」，色階就不再是玄學了。至於要不要抄前人的 OrRd——抄判斷，不抄結論。

## 參考連結

- [TaxMap-TW GitHub](https://github.com/bobo52310/TaxMap-TW)
- [kiang/salary 原型](https://github.com/kiang/salary)
- [ColorBrewer 2.0](https://colorbrewer2.org/)
- 這篇承接的決策起點：[資料地圖該用哪種色階？viridis、YlGnBu 與 ColorBrewer 實戰指南](/blog/data-map-color-scale-viridis-ylgnbu)（當初選 YlGnBu 的理由，這篇把它推翻了）
- 同樣是「視覺優先 vs 教科書正解」的權衡：[地圖標籤密集時，competition ranking 還是 dense ranking？](/blog/competition-vs-dense-ranking-map-labels)
- 系列總結：[打造 TaxMap-TW 完整心得：6 個技術決策、踩了 4 個坑](/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls)</content:encoded><media:content url="https://bobochen.dev/_astro/cover.Ckn3tKnv.webp" medium="image"/><category>資料視覺化</category><category>地圖</category><category>MapLibre</category><category>ColorBrewer</category><category>Choropleth</category><enclosure url="https://bobochen.dev/_astro/cover.Ckn3tKnv.webp" length="0" type="image/png"/></item></channel></rss>