打造 TaxMap-TW 完整心得:6 個技術決策、踩了 4 個坑
本篇是「打造 TaxMap-TW:用 Astro 做台灣所得地圖」系列的第 7 / 10 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。
起點其實只是一個很自私的問題:我想知道我家那個里,所得在全台排第幾。查不到現成的,就乾脆自己做一個。
TaxMap-TW 後來做成了全台 7,747 個村里的所得稅地圖(11 年資料、4 個指標、雙維度排名、YoY 變動、歷年折線圖、搜尋),月成本 $0,從零到上線大概花了 4-5 天。
這篇是整個系列的收尾,回顧 6 個關鍵技術決策、踩過的 4 個坑,還有事後回頭看才想通的幾件事。
TaxMap-TW 的 6 個技術決策
每個決定都有對應的詳細文章。這篇是 index。
1. 底圖:OpenFreeMap
候選:Mapbox(業界標準、5 萬次/月免費)vs MapTiler(10 萬 requests 或 5,000 sessions/月)vs OpenFreeMap(無限免費)vs NLSC(政府服務)。
選 OpenFreeMap。理由:對公民科技/個人專案,「免費無上限」比「業界標準」更重要。
不過要誠實講 OpenFreeMap 的代價:它沒有 SLA、目前是單人維運、沒有商業等級的 backup、可選 style 也少。所以這個選擇只在「掛掉幾小時也沒人會死」的低營運風險場景成立——像這種個人 / 公民科技專案。如果是公司產品、有營收綁在地圖可用性上,我會老實付錢給 MapTiler 或 Mapbox 換 SLA。
2. 色階:YlGnBu + Jenks 自然斷點
候選:viridis(學術主流)vs YlGnBu(公部門報告風)vs YlOrRd(媒體吸睛)。
選 YlGnBu + Jenks。理由:色階要配合資料的長尾分布,台灣所得高度集中於少數里,等距分級會變「90% 同色 + 幾顆紅點」。
(後記:這是當時的決策,但上線後我又把色階整個換掉了,從 YlGnBu 改成 OrRd、Jenks 改成對數絕對門檻,原因見 為什麼我把所得地圖色階從 viridis 換成 OrRd。)
3. 切片:PMTiles
候選:傳統 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 檔案上限。)
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 拓
候選:fork 既有專案的整理過 CSV vs 自己從 FIA 抓原始檔。
選自己抓。理由:乾淨的資料故事,加上 schema 跨年漂移、合計過濾、罕用字 mojibake 都得自己處理,這個過程也是學習。
但要把這個 trade-off 講清楚,別讓人誤會「自己拓一定比較好」:如果你的目標是快速產出、把東西做出來給人用,fork 別人整理好的 CSV 才是務實選擇,可以省掉好幾小時的 ETL 苦工。我選自己拓,是因為這是個人專案、沒有交付壓力,付得起這個「多花 3 小時換乾淨資料」的奢侈。換成接案或有 deadline,我大概會直接 fork。
6. 排名:Competition Ranking
候選:Standard / Competition / Modified Competition / Dense / Fractional 共 5 種。
選 Competition Ranking。理由:跟奧運排名一致,使用者直覺;同分共享是公平的處理;「跳過名次」雖然會讓最大排名 < 總數,但這是正確行為。
4 個踩過的坑
坑 1:NLSC zip 內含 Big5 編碼檔名
內政部國土測繪中心 提供的村里界 shapefile 是 zip 內含中文檔名。macOS 預設 unzip 會崩潰,因為它把檔名當 UTF-8 解析但裡面是 Big5。
checkdir error: cannot create villages-shp/߯®Ω¨…æ˙•vπœ∏Í_113
Illegal byte sequence
解法:用 macOS 內建的 ditto,它會處理 legacy filename charsets:
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 結構是:
<section class="relative h-[70vh]">
<div id="map" class="absolute inset-0"></div>
</section>
預期:absolute inset-0 讓 #map 填滿父 section。
實際:MapLibre 把 #map 強制設為 position: relative(為了 attribution / control 的內部定位),讓 absolute inset-0 整組失效。
mapW: 902, mapH: 0 ← 0 高度!
地圖渲染進一個 0 高度的容器。沒任何錯誤訊息。
解法:別用 absolute inset-0,直接用 h-full w-full:
<section class="relative h-[70vh]">
<div id="map" class="h-full w-full"></div>
</section>
這個坑教我一件事:第三方 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:
const forceResize = () => 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 時遇到:
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 篇,按推薦閱讀順序:
- 入門基礎 → Web 地圖底圖是什麼?vector vs raster、tile pyramid、style spec 一次搞懂
- 底圖選型 → OpenFreeMap vs MapTiler vs Mapbox:6 個 Web 地圖底圖服務怎麼選?
- 資料管線 → 從 PDF / CSV 到 JSON:政府開放資料的 ETL 實戰
- 色階設計 → 資料地圖該用哪種色階?viridis、YlGnBu 與 ColorBrewer 實戰指南
- 排名演算法 → 競爭排名 vs 密集排名 vs 百分位:地圖標籤的 ranking 設計
- 進階技術 → PMTiles 取代傳統 tile server:HTTP Range Request 的單檔魔術
- 整體復盤 → 本文
- 色階再進化 → 為什麼我把所得地圖色階從 viridis 換成 OrRd:把「一個決策」拆成「兩個獨立軸」
- 搬站踩坑 → Cloudflare Pages 的 20,000 檔案上限:TaxMap-TW 為什麼搬到 Netlify
- OG 圖生成 → 用 Cloudflare Worker 按需生成 OG 圖:Satori + resvg 為 TaxMap-TW 產 7,750 張預覽圖
本文(第 7 篇)寫的是「當時上線那一刻的決策」。第 8、9 篇記錄的是上線之後我又改掉的兩件事——色階換成 OrRd、託管從 Cloudflare Pages 搬到 Netlify——所以前面決策段落裡你會看到對應的後記註記。
GitHub repo:TaxMap-TW