做数据大屏的朋友大概都有这种经历:产品经理说"整个 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 强太多了。
技术栈
- Three.js + WebGL2,辉光用的 UnrealBloomPass
- Zustand 管状态,选它因为轻量且支持组件外部读写 store ------ 引擎那边要直接拿配置,React Context 做不到这事
- gsap 做入场动画,持续动画靠 shader uniform 驱动
- Tailwind CSS v4 写 UI,
lib.css做作用域隔离。踩过坑:v4 的@import 'tailwindcss'会注入@layer base重置样式,把宿主项目样式搞乱了,后来改成只导入 utility 层才解决 - Vite 库模式打包,ESM + CJS 双格式
- 地图数据走阿里云 DataV GeoAtlas
项目发到 npm 了,https://www.npmjs.com/package/@lius1314/china-map-3d-designer。做大屏带 3D 地图的朋友可以试试,应该可以少踩一些坑。
如果你需要源码的话,可以去柳杉前端 公众号同名文章获取。