数据可视化Three.js 3D 地图实战:单文件原生实现省域区县拉伸建模

用纯原生 HTML/JS 重写了一套 Three.js 3D 地图。零构建、零依赖(除 Three.js CDN)、单文件直接扔到 PHPStudy 就能跑。本文完整记录技术选型、核心实现、以及一个导致地图完全不显示的 GeoJSON 嵌套层级 Bug 的排查全过程。

缘起

在做数据可视化产品线时,需要一个可复用的 3D 地图组件,从墨卡托投影到 ExtrudeGeometry 拉伸建模,再到光柱、粒子、CSS2D 标签,完整度很高。

  • 纯原生 HTML/JS
  • Three.js 用经典 <script> 标签引入(r128),不碰 ES Module importmap
  • Canvas 程序化生成全部纹理,不加载任何外部 PNG
  • 单文件交付,一个 HTML 搞定一切

最终效果:浙江省 90 个区县级行政区,3D 拉伸 + 光柱标记 + 呼吸光环 + 扫描线 + 上升粒子 + hover 高亮 + click 飞行聚焦。

技术架构

整体数据流很清晰:

arduino 复制代码
GeoJSON 数据
    │
    ▼
墨卡托投影 + 包围盒归一化
    │
    ▼
ExtrudeGeometry 拉伸建模(Shape → 3D Mesh)
    │
    ├──► 光柱标记
    ├──► 呼吸光环
    ├──► 扫描线
    ├──► 上升粒子
    └──► CSS2D 标签
    │
    ▼
OrbitControls 交互(hover 高亮 / click 飞行聚焦)

核心实现拆解

1. 墨卡托投影与坐标归一化

经纬度不能直接喂给 Three.js,得先转成平面坐标。用标准的墨卡托投影公式:

javascript 复制代码
function geoMercator(lng, lat) {
  var x = (lng * 20037508.34) / 180;
  var y = Math.log(Math.tan(((90 + lat) * Math.PI) / 360)) / (Math.PI / 180);
  y = (y * 20037508.34) / 180;
  return { x: x, y: y };
}

投影后的坐标范围很大(百万级),需要计算所有要素的包围盒,归一化到 ±40 单位范围内:

javascript 复制代码
var cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
var range = Math.max(maxX - minX, maxY - minY);
var scale = range / TARGET_SIZE;
// normalize(lng, lat) → { x, y } 落在 ±40 内

这样不管是哪个省、哪个国家,地图都会自动居中且尺寸一致。

2. ExtrudeGeometry 3D 拉伸建模

这是整个地图的核心。遍历 GeoJSON 的每个区县,将外环坐标转为 Three.js Shape,其余环作为孔洞(处理飞地/岛屿),通过 ExtrudeGeometry 拉伸成立体模型:

javascript 复制代码
coords.forEach(function(polygon) {
  polygon.forEach(function(ring, ringIdx) {
    if (ringIdx === 0) {
      // 第一个 ring = 外环
      var shape = new THREE.Shape();
      ring.forEach(function(ll, i) {
        var n = normalizer.normalize(ll[0], ll[1]);
        if (i === 0) shape.moveTo(n.x, n.y);
        else shape.lineTo(n.x, n.y);
      });
      // 其余 ring 作为孔洞(飞地/岛屿)
      for (var h = 1; h < polygon.length; h++) {
        var hole = new THREE.Path();
        polygon[h].forEach(function(ll) {
          var n = normalizer.normalize(ll[0], ll[1]);
          // ... hole.lineTo(n.x, n.y)
        });
        shape.holes.push(hole);
      }
      var geo = new THREE.ExtrudeGeometry(shape, {
        depth: MAP_DEPTH,
        bevelEnabled: true,
        bevelSegments: 2,
        bevelSize: 0.08
      });
    }
  });
});

顶面和侧面用不同材质------顶面亮色、侧面暗色,立体感立刻就出来了。

3. Canvas 程序化纹理(零图片依赖)

所有视觉纹理全部用 Canvas 2D API 动态绘制:

  • 光柱纹理:线性渐变 + 径向渐变,从底部亮白向上过渡到透明青色
  • 呼吸光环 :径向渐变圆环,配合 sin 函数做缩放 + 透明度脉冲
  • 底盘光圈:1024×1024 Canvas 绘制同心圆、虚线环、72 刻度线,缓慢旋转
  • 扫描线 :横向渐变条,沿 Y 轴 sin 往复运动,AdditiveBlending 混合
  • 上升粒子:Sprite + 径向渐变纹理,50 个粒子随机位置循环上升

以光柱纹理为例:

javascript 复制代码
function createPillarTexture() {
  var canvas = document.createElement('canvas');
  canvas.width = 128; canvas.height = 512;
  var ctx = canvas.getContext('2d');
  var grad = ctx.createLinearGradient(0, 512, 0, 0);
  grad.addColorStop(0, 'rgba(0,212,255,0.9)');
  grad.addColorStop(0.4, 'rgba(0,180,255,0.4)');
  grad.addColorStop(1, 'rgba(0,120,200,0)');
  ctx.fillStyle = grad;
  ctx.fillRect(32, 0, 64, 512);
  // ... 叠加径向光晕
  return new THREE.CanvasTexture(canvas);
}

代码量比直接贴 PNG 大,但换来了单文件可移植------不需要操心图片路径、CORS、打包配置。

4. 交互系统

Raycaster 拾取mousemove 时将屏幕坐标转为 NDC,与所有 provinceMeshes 求交,命中则改 emissive 发光。

飞行聚焦click 时用 easeOutQuart 缓动函数在 800ms 内将相机从当前位置插值到目标区县上方,比直接 lookAt 丝滑很多。

数据面板 :hover 时右上角 HTML 面板显示区县名、ADCODE、模拟数据值,带进度条动画。面板用 CSS backdrop-filter 做毛玻璃效果。

踩坑实录:一个 NaN 导致地图完全消失

这是本次开发最值得记录的部分。

初版写完打开页面------Loading 消失了,但 3D 区域一片空白。控制台报:

vbnet 复制代码
THREE.BufferGeometry.computeBoundingSphere():
Computed radius is NaN.
The "position" attribute is likely to have NaN values.

根因:GeoJSON MultiPolygon 坐标嵌套层级理解错误

GeoJSON 的 MultiPolygon 正确结构是三层嵌套

less 复制代码
coordinates = [ polygon, ... ]        // Level 0: MultiPolygon
polygon     = [ ring, ... ]           // Level 1: ring[0]=外环, 其余=孔洞
ring        = [ [x,y], [x,y], ... ]   // Level 2: 坐标点数组

但我写代码时多套了一层 forEach,把 ring 当成了 polygon 来处理:

javascript 复制代码
// ❌ 错误:多了一层嵌套
coords.forEach(multiPolygon => {
  multiPolygon.forEach(polygon => {
    var outer = polygon[0];  // polygon[0] 取到的是单个点 [x,y]!
    outer.forEach(ll => {
      // ll 此时是数字(经度或纬度),ll[0] = undefined → NaN
    });
  });
});

polygon[0] 取到的是 [120.17, 30.25] 这样的单点坐标,再 forEachll 已经是数字 120.17ll[0]undefined,传给投影函数就产生了 NaN。Three.js 拿到全是 NaN 的顶点数据,computeBoundingSphere 算不出包围球,整个几何体就"消失"了。

修复:去掉多余层级 + 防御性校验

修复后的代码:

javascript 复制代码
// ✅ 正确:coords → polygon → ring 三层
coords.forEach(function(polygon) {
  polygon.forEach(function(ring, ringIdx) {
    if (ringIdx === 0) {
      var shape = new THREE.Shape();
      ring.forEach(function(ll, i) {
        if (!ll || ll.length < 2) return;  // 防御性过滤
        var n = normalizer.normalize(ll[0], ll[1]);
        if (isNaN(n.x) || isNaN(n.y)) return;  // NaN 拦截
        if (i === 0) shape.moveTo(n.x, n.y);
        else shape.lineTo(n.x, n.y);
      });
    }
  });
});

同时排查并修复了三处相同问题:① buildMap 的 Shape 构建 ② makeNormalizer 包围盒计算 ③ fallback 中心点计算。

刷新页面,90 个区县从底部依次升起,地图正常显示。

教训

处理 GeoJSON 前,先 console.log 确认实际嵌套深度。

不同数据源(DataV.GeoAtlas、高德、天地图)的 Polygon/MultiPolygon 层级可能有差异,不能凭假设写遍历代码。NaN 是 Three.js 最常见的"静默杀手"------它不会直接报错崩溃,但会让几何体悄无声息地消失。在坐标入口加 isNaN() 防御性校验,能省很多排查时间。

功能清单

功能 状态 说明
3D 拉伸地图 ✅ 完成 90 个区县 ExtrudeGeometry 建模,顶面/侧面双材质
墨卡托投影 ✅ 完成 经纬度→墨卡托→归一化,自动居中缩放
入场动画 ✅ 完成 90 个区县从底部依次升起(错峰 15ms),easeOutCubic
光柱标记 ✅ 完成 交叉光柱 + 底部光点 + 呼吸光环,Canvas 纹理
CSS2D 标签 ✅ 完成 区县名称随 3D 位置渲染,hover 放大高亮
Hover 高亮 ✅ 完成 Raycaster 拾取,emissive 发光,信息面板弹出
Click 聚焦 ✅ 完成 easeOutQuart 缓动飞行到目标区县
自动旋转 ✅ 完成 按钮开关,用户交互后自动暂停
底盘光圈 ✅ 完成 同心圆 + 虚线 + 刻度,缓慢旋转
扫描线 ✅ 完成 横向光带沿 Y 轴 sin 往复运动
上升粒子 ✅ 完成 50 个 Sprite 粒子随机分布、循环上升
边框线 ✅ 完成 每个区外环 LineLoop 青色描边
FPS 统计 ✅ 完成 右下角实时帧率 + 区县计数
重置视角 ✅ 完成 一键回到初始相机位置
真实数据接入 ⏳ 待做 当前 value 为模拟值,需对接业务 API
飞线动效 ⏳ 待做 区县之间的迁徙/连接飞线(贝塞尔曲线 + 纹理动画)
区域下钻 ⏳ 待做 点击区县加载下级 GeoJSON(乡镇/街道级)

开发时间线

时间 节点 说明
09:56 需求提出 参考掘金文章生成 Three.js 3D 地图
10:00 初版(中国地图) ES Module importmap 方式,加载阿里云 DataV 全国 GeoJSON
10:06 需求调整 改用本地浙江省 GeoJSON + 纯原生 HTML,写入 PHPStudy 目录
10:09 第二版完成 Three.js r128 script 标签,883 行,31.3KB
10:11 Bug 反馈 地图空白,控制台 NaN 错误
10:11 Bug 修复 修正 MultiPolygon 坐标嵌套层级,增至 909 行 / 32.5KB
12:16 日志整理 归档技术决策、Bug 根因和经验教训

从需求提出到 Bug 修复,实际开发时间约 15 分钟。

几点经验

1. 参考 ≠ 照搬。 理解核心原理(墨卡托投影、ExtrudeGeometry、Raycaster)后,用原生 JS 重写反而更轻量------少了 Vite 构建、npm 依赖、node_modules,一个 HTML 文件扔到任何静态服务器都能跑。

2. Canvas 纹理是单文件方案的关键。 虽然代码量比贴 PNG 大,但零图片依赖意味着不用操心路径、CORS、打包。对于需要快速交付的演示场景,这个 trade-off 很值。

3. 性能要留意。 90 个区县的 ExtrudeGeometry 顶点数不少,老旧设备可能掉帧。后续优化方向:

  • BufferGeometryUtils.mergeGeometries() 合并几何体减少 draw call
  • 重复元素用 InstancedMesh
  • LOD(Level of Detail)分级渲染

4. 数据还是模拟的。 当前光柱高度和面板数值是 hash 生成的随机值,仅用于视觉演示。对接真实业务数据时,把 provinceData[fIdx].value 替换为 API 返回值即可,注意根据实际值域调整映射比例。


@漏刻有时

相关推荐
会说话的番茄2 小时前
AI 满嘴跑火车怎么办?给它配个"小抄"
前端·aigc
计算机魔术师2 小时前
AI 权力集中辩论实录:从「token 工厂」到「唯一幸存者」
前端
Full Stack Developme2 小时前
跨站请求伪造 (CSRF) 是什么 设计及工作原理
前端·okhttp·csrf
MoSTChillax2 小时前
UI规范设计:从视觉到交互的全面指南
前端·ui·产品经理·设计规范·vibecoding
雪碧聊技术3 小时前
Vue + Element Plus 实现文本溢出显示省略号及悬浮提示
前端·javascript·css·vue.js·文本溢出省略
eric-sjq3 小时前
0.6B 前端生成模型本地部署实战:消费级显卡跑通 WanlyFrontend 全流程
前端·本地部署
小妖6664 小时前
设置了 box-sizing: border-box; 不管用,下边框还是被挤压没了
前端·css·html
雪隐4 小时前
个人电脑玩AI-16让5060 Ti给你打工——5060Ti 16G 跑 MiniMax-Music-3:从下载到 60s 出歌的全流程
前端·人工智能·后端
CAD老兵5 小时前
让 AI Coding Agent 直接访问 CAD 文档:GitMCP 实战指南
前端·人工智能·github