做資料大屏的朋友大概都有這種經歷:產品經理說「整個 3D 地圖效果」,然後你就開始找參考、寫 shader、調燈光、搞邊界線流光……三四天過去總算能看了,換個專案,同樣的活從頭再來。
我之前連著做了三個帶 3D 地圖的大屏,每次都幾乎從零開始。第三個專案寫到一半我就煩了 —— 這種重複的工作到底能不能抽出來做個復用?
所以就做了 @lius1314/china-map-3d-designer。
簡單說就是一個 React 元件,基於 Three.js 渲染 3D 中國地圖,自帶一個可視化設計器。你在設計器裡把效果調好,匯出一個 JSON,到你的大屏專案裡一行程式碼引入,效果一模一樣。












SDK 對外就暴露了一個 ChinaMap3DDesigner。傳 editable={false} 就是純渲染,只有 3D 畫布,直接塞大屏裡用:
tsx 代碼解讀複製代碼<ChinaMap3DDesigner editable={false} mapData={myData} />
就這麼一行。傳 editable={true} 的話會帶上一整套設計器 UI —— 圖層樹、屬性面板、資料編輯、工具列什麼的都有。
設計器 UI 的佈局我改過好幾版。最開始屬性面板是固定側欄,但很快發現不行 —— 側欄一佔 300px,畫布被壓小了,你在小畫布上調出來的效果,放到全螢幕大屏上完全不是那個味。後來改成抽屜式面板,浮在畫布上面,畫布始終是完整尺寸,調參數的時候拉出來看一眼就行。
用的是阿里雲 DataV GeoAtlas 的公開介面,執行時 fetch GeoJSON,不需要打包地圖檔案。這個服務挺穩的,我用了大半年沒出過問題。就是部署到服務上會訪問不了就是了,需要自己使用 json 資料。
拿到 GeoJSON 之後第一步是投影。GeoJSON 的座標是經緯度,直接用會有面積變形問題,高緯度地區會被拉得特別大。所以先用 Web 墨卡托把緯度轉一下:
ts 代碼解讀複製代碼const mercY = (lat: number) =>
(Math.log(Math.tan(Math.PI / 4 + (lat * D2R) / 2)) / D2R);
然後歸一化。這步很關鍵 —— 全國和廣東省的經緯度範圍差了十幾倍,不歸一化的話每下鑽到一個省相機就得重新調,根本沒法通用。歸一化之後所有區域在場景裡都是同樣大小,一套相機參數吃天下。
載入做了多源回退,DataV 有兩個 CDN,主站掛了自動切備用。之前有一次 DataV 主站維護了半小時,備用位址自動頂上了,使用者完全無感。
頂面用 THREE.Shape + ShapeGeometry,沒啥特別的。一個省可能有多個多邊形(浙江沿海一堆島嶼),也可能帶孔洞,分別生成 geometry 最後用 mergeGeometries 合成一個,保證每個省只有一個 draw call。
側面是手搓的 BufferGeometry,沿邊界輪廓從頂面到底部拉伸出一圈垂直三角面。
這裡說個我踩過的坑。側面的 UV 的 u 座標,必須按邊界的累計弧長來算。我第一次寫的時候圖省事用了線性插值,結果流光動畫跑起來一頓一頓的,有的地方飛快有的地方幾乎不動。我盯著螢幕看了大半天,反覆檢查 shader 邏輯,最後才定位到是 UV 的問題 —— 邊界上相鄰兩個點的間距差異很大,線性插值導致 UV 分布不均勻。後來改成按實際弧長累計,動畫就絲滑了。
邊界線用的 Line2。順便吐槽一下 Three.js 原生的 LineBasicMaterial,linewidth 這個屬性在大部分顯卡上都是廢的,不管你設 1 還是 10 都只畫 1px,因為走的是 ANGLE 的老舊路徑。Line2 是 fat line 方案,用 mesh 模擬線寬,真正能用。
流光效果是透過 onBeforeCompile 往 LineMaterial 的 shader 裡注入程式碼實現的。在片段著色器裡用 vLineDistance(線段累計距離)算流光頭的位置,輸出高亮色。好處是不需要額外 geometry,直接在現有材質上改。
這個演算法我覺得是整個專案裡最有意思的部分。
問題是:GeoJSON 裡每個省的邊界是獨立存的,相鄰兩省共享的那段邊界出現了兩次。但做外輪廓發光的時候,我需要知道哪些邊是「最外面」的。
做法很直覺 —— 把每條邊按無序端點做 hash(A→B 和 B→A 算同一條),統計出現次數。只出現一次的就是外輪廓邊。內部共享邊必然出現兩次嘛,這是拓撲的基本性質。然後建鄰接表把邊首尾串成鏈,就得到了完整的外輪廓路徑。
拿到外輪廓之後能幹很多事:畫外邊界線、生成外輪廓側面、做整體掃描動畫。說實話這段程式碼寫完之後我自己挺得意的,不到 100 行但解決了個挺頭疼的問題。
整個場景的視覺由一個 AppConfig 物件控制,分了 9 個圖層:global、baseScene、topface、sideface、border、scatter、flyline、bar、label。加起來 200 多個參數。
聽著嚇人,實際上在屬性面板裡是按圖層分組的,每次就調一個圖層的幾個滑桿,改完即時看效果。我盡量讓參數名足夠直白,bloomStrength 就是輝光強度,flowSpeed 就是流光速度,不用翻文件。
配置透過 Zustand store 管理。這裡有個我很滿意的功能:復原重做。每次改配置自動入歷史堆疊(最多 60 步),Ctrl+Z 直接回退。調配色方案的時候特別爽 —— 試了五六種顏色都不滿意?連按幾下 Ctrl+Z 就回去了。
不想自己調參數的話,內建了 10 套主題可以直接用:科技藍、深海波光、暗夜霓虹、賽博金、極光夜空、血色月光、極地冰晶、矩陣綠光、日落餘暉、簡約亮色。我個人最喜歡深海波光和暗夜霓虹,一個沉穩一個炫酷,給客戶示範基本這兩個輪著用就夠了。矩陣綠光也挺有意思,駭客任務那個味。
每套主題的實作只寫和預設值不同的部分,用 patch 函數做深度合併:
ts 代碼解讀複製代碼build: () => patch(defaultConfig(), {
global: { background: "#070310", bloomStrength: 0.15 },
baseScene: { type: "hex", hexColor: "#2a0f4d", hexGlowColor: "#e26bff" },
})
一套主題幾十行程式碼,新增一套十分鐘搞定。
地圖下方的底座區域很佔畫面,做好了特別出效果。我做了 8 種底座類型,全部純 shader 實現 —— 網格、星空、水面、六邊形蜂巢、星雲、水面倒影、飛輪底盤、能量場。
水面倒影和飛輪底盤花的心思最多。水面倒影用自訂反射 shader,帶波浪畸變,能映射天空顏色。飛輪底盤是同心旋轉光環加 3D 圓環體,有點機械龐克的感覺。這兩個底座切換的時候視覺效果差異很大,建議都試試。
粒子系統單獨做了 5 種效果:漂浮、光柱、雨滴、雪花、火花。雨滴和雪花做得比較細,雨滴有傾斜角度和落地濺射,雪花有飄落擺動和自轉。參數都能在面板裡調,你可以把雨滴調成暴雨模式也可以調成毛毛雨,隨你。
性能方面,雨滴用的 InstancedMesh,800 個實例幀率完全不掉。
ts 代碼解讀複製代碼interface MapDataInput {
regions?: RegionDataItem[]; // 各省數值,按 name 匹配
flyLines?: FlyLineItem[]; // 自訂飛線
labels?: LabelItem[]; // 標籤文字
clickCards?: ClickCardItem[]; // 懸浮卡片
}
regions 傳進去,散點位置、柱圖高度自動算。不過名稱匹配這個設計有利有弊 —— 好處是接入簡單,壞處是「廣東省」寫成「廣東」就匹配不上。後面打算加個模糊匹配,但現在先這樣吧。
clickCards 挺好玩的。滑鼠懸浮到某個省上面彈出資訊卡片,可以配指標列表也能直接傳 HTML。給客戶示範的時候視覺效果拉滿,上次給甲方示範這個功能的時候對方直接說「就這個」。
飛線預設自動以最高值城市為中心向周圍發散。有自己的 OD 資料就傳 flyLines 覆蓋掉。
單擊觸發回呼,你的業務程式碼拿去聯動別的圖表。雙擊觸發下鑽,載入子級地圖,麵包屑自動追加一級。
下鑽有個細節:已經是最末級(區縣級)的話,雙擊彈 toast 提示「已是最末級區域」。這個是我踩坑之後加的 —— 之前沒處理,測試的時候點了一個區縣什麼反應都沒有,我以為頁面卡死了,瘋狂重新整理。下鑽和麵包屑都可以關掉,有些大屏就展示個全國不需要下鑽,直接 enableDrillDown={false} 完事。
設計器裡調完的參數,工具列一鍵匯出 JSON,包含 config 和 data 兩部分。下次把這個 JSON 丟給 initialConfig 和 initialData,場景精確還原。
整個工作流就是:打開設計器 → 選主題 → 微調 → 匯出 → 丟到大屏專案裡。我自己用下來還挺順的,比每次從零寫 shader 強太多了。
lib.css 做作用域隔離。踩過坑:v4 的 @import 'tailwindcss' 會注入 @layer base 重置樣式,把宿主專案樣式搞亂了,後來改成只導入 utility 層才解決專案發到 npm 了,https://www.npmjs.com/package/@lius1314/china-map-3d-designer。做大屏帶 3D 地圖的朋友可以試試,應該可以少踩一些坑。
如果你需要原始碼的話,可以去柳杉前端公眾號同名文章獲取。