数据可视化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] 这样的单点坐标,再 forEach 时 ll 已经是数字 120.17,ll[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 返回值即可,注意根据实际值域调整映射比例。


@漏刻有时

相关推荐
定栓3 分钟前
Webpack迁到Vite第一节:模块系统迁移:require → import
运维·前端·webpack
小熊在忙fpga12 分钟前
N32G430芯片的flash表示
前端·javascript·html
Data analyse45618 分钟前
App埋点版本兼容的字段迁移校验
大数据·运维·服务器·前端·数据库·数据分析
todoitbo24 分钟前
本来只想给内网开个口子,Natapp 却多给了一道防护
前端·javascript·vue.js·内网穿透·网络防护·natapp
会灭火的程序员30 分钟前
SuperMap AgentX Server智能体接入WEB应用技术文档
前端·supermap·agentx·agentx server
CRMEB34 分钟前
商品 SKU 设计,从简单规格到多规格踩过什么
java·前端·数据库·开源
Chloe.Zz35 分钟前
一篇入门Agent
开发语言·前端·javascript
java1234_小锋1 小时前
【技术专题】HTML5 网页技术 - HTML5 表格标签
前端·html·html5
雪芽蓝域zzs1 小时前
JS 项目转 TS 项目(Vue3 + 若依这类后台项目)完整改造清单
前端·vue.js