阅读视图

发现新文章,点击刷新页面。

学习Three.js--基于GeoJSON绘制2D矢量地图

学习Three.js--基于GeoJSON绘制2D矢量地图

前置核心说明

开发目标

基于Three.js实现纯矢量2D地图,核心能力包括:

  1. 将GeoJSON地理数据(经纬度)转换为Three.js可渲染的平面图形;
  2. 支持墨卡托投影(经纬度→平面坐标)、地图居中缩放适配视口;
  3. 实现鼠标点击省份高亮(填充+边框变色);
  4. 纯2D交互(仅平移/缩放,禁用旋转),模拟传统地图体验。
  5. 开发效果如下

dc087832-635b-4da4-85d7-1d09b70b1e73.png

核心技术栈

技术点 作用
OrthographicCamera(正交相机) 实现无透视的2D效果(无近大远小),是2D地图的核心相机类型
墨卡托投影函数 将地理经纬度(lon/lat)转换为平面笛卡尔坐标(x/y)
Shape/ShapeGeometry 将GeoJSON的多边形坐标转换为Three.js可渲染的几何形状
Raycaster(射线检测) 实现鼠标点击与省份图形的交互(命中检测)
OrbitControls(轨道控制器) 自定义交互规则(禁用旋转,仅保留平移/缩放)
GeoJSON 行业标准地理数据格式,存储省份的多边形坐标信息

分步开发详解

步骤1:基础环境搭建(场景/相机/渲染器/控制器)

1.1 核心代码
// 存储所有省份Mesh,用于射线检测
const provinceMeshes = []; 

// 1. 场景初始化(地图容器)
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf8fafc); // 浅灰背景

// 2. 正交相机(核心!2D地图必须用正交相机)
const aspect = window.innerWidth / window.innerHeight; // 窗口宽高比
const frustumSize = 800; // 相机视口高度(控制地图初始显示范围)
const camera = new THREE.OrthographicCamera(
  -frustumSize * aspect / 2, // 左边界
  frustumSize * aspect / 2,  // 右边界
  frustumSize / 2,           // 上边界
  -frustumSize / 2,          // 下边界
  1,                         // 近裁切面(最近可见距离)
  1000                       // 远裁切面(最远可见距离)
);
camera.position.set(0, 0, 10); // 相机在Z轴,看向场景中心
camera.lookAt(0, 0, 0);

// 3. 渲染器(抗锯齿+高清适配)
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);

// 4. 轨道控制器(自定义交互规则)
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableRotate = false; // 禁用旋转(纯2D地图)
controls.enablePan = true;     // 启用平移(拖拽地图)
controls.enableZoom = true;    // 启用缩放(滚轮)
controls.zoomSpeed = 1.5;      // 缩放速度(适配体验)
1.2 关键参数解析
  • 正交相机参数:区别于透视相机(PerspectiveCamera),正交相机的视口是矩形,所有物体无论距离远近大小一致,完美适配2D地图;
  • frustumSize:控制相机视口高度,值越大地图初始显示范围越大;
  • 控制器配置:禁用旋转是2D地图的核心要求,避免视角倾斜。

步骤2:墨卡托投影函数(经纬度→平面坐标)

2.1 核心代码
// 墨卡托投影:将经纬度(lon/lat)转换为平面坐标(x/y)
function mercator(lon, lat) {
  const R = 6378137; // WGS84坐标系地球半径(米)
  const x = (lon * Math.PI / 180) * R; // 经度转弧度 × 地球半径
  const y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) * R; // 纬度投影公式
  return { x, y }; // y轴北为正,x轴东为正
}
2.2 原理说明
  • 墨卡托投影是地图领域的标准投影方式,将球形地球的经纬度转换为平面矩形坐标;
  • 经度(lon)范围:-180°180°,纬度(lat)范围:-90°90°;
  • 核心公式:
    • 经度转换:直接将角度转为弧度后乘以地球半径;
    • 纬度转换:通过正切+对数函数,解决纬度越靠近极点拉伸越大的问题。

步骤3:GeoJSON加载与全局边界计算

3.1 核心代码
let bounds = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };

// 异步加载GeoJSON并绘制地图
async function loadAndDrawMap() {
  // 1. 加载GeoJSON数据(本地文件)
  const response = await fetch('./china.json');
  const geojson = await response.json();

  // 2. 手动校正港澳位置(墨卡托投影后的偏移,单位:米)
  const SPECIAL_REGION_OFFSETS = {
    '香港特别行政区': { dx: 80000, dy: -60000 },
    '澳门特别行政区': { dx: 100000, dy: -80000 }
  };

  // 3. 遍历所有坐标,计算全局边界(用于地图居中缩放)
  geojson.features.forEach(feature => {
    traverseCoordinates(feature.geometry.coordinates, (lon, lat) => {
      const { x, y } = mercator(lon, lat);
      // 更新边界值(最小/最大x/y)
      bounds.minX = Math.min(bounds.minX, x);
      bounds.maxX = Math.max(bounds.maxX, x);
      bounds.minY = Math.min(bounds.minY, y);
      bounds.maxY = Math.max(bounds.maxY, y);
    });
  });

  // 4. 计算地图中心和缩放比例(适配视口)
  const centerX = (bounds.minX + bounds.maxX) / 2; // 地图中心X
  const centerY = (bounds.minY + bounds.maxY) / 2; // 地图中心Y
  const width = bounds.maxX - bounds.minX;         // 地图宽度
  const height = bounds.maxY - bounds.minY;        // 地图高度
  const scale = 700 / Math.max(width, height);     // 缩放比例(使地图适配视口)

  // 后续创建省份Shape...
}

// 递归遍历GeoJSON坐标(处理Polygon/MultiPolygon嵌套结构)
function traverseCoordinates(coords, callback) {
  if (typeof coords[0] === 'number') {
    // 基础情况:[lon, lat] 数组,执行回调
    callback(coords[0], coords[1]);
  } else {
    // 递归情况:嵌套数组,继续遍历
    coords.forEach(c => traverseCoordinates(c, callback));
  }
}
3.2 关键逻辑解析
  • 边界计算:遍历所有省份的所有坐标,得到地图的最小/最大x/y,用于后续居中;
  • 港澳偏移:解决GeoJSON中港澳坐标投影后位置偏差的问题;
  • 缩放比例700 / Math.max(width, height) 保证地图的最大维度适配视口(700为经验值,可调整);
  • 递归遍历坐标:GeoJSON的Polygon是单层数组,MultiPolygon是双层数组,需递归处理所有嵌套坐标。

步骤4:创建Shape与省份Mesh

4.1 核心代码
// 在loadAndDrawMap函数内,边界计算后执行:
geojson.features.forEach(feature => {
  const shapes = [];
  const provinceName = feature.properties.name;
  const offset = SPECIAL_REGION_OFFSETS[provinceName] || { dx: 0, dy: 0 };

  // 处理单个Polygon(如大多数省份)
  if (feature.geometry.type === 'Polygon') {
    const shape = createShape(feature.geometry.coordinates[0], centerX, centerY, scale, offset);
    if (shape) shapes.push(shape);
  } 
  // 处理MultiPolygon(如包含岛屿的省份:海南、浙江等)
  else if (feature.geometry.type === 'MultiPolygon') {
    feature.geometry.coordinates.forEach(polygon => {
      const shape = createShape(polygon[0], centerX, centerY, scale, offset);
      if (shape) shapes.push(shape);
    });
  }

  // 为每个Shape创建Mesh(填充)和Line(边框)
  shapes.forEach(shape => {
    // 1. 创建填充几何体
    const geometry = new THREE.ShapeGeometry(shape);
    // 2. 填充材质(蓝色,双面渲染)
    const material = new THREE.MeshBasicMaterial({
      color: 0x3b82f6,
      side: THREE.DoubleSide
    });
    // 3. 创建省份Mesh
    const mesh = new THREE.Mesh(geometry, material);
    
    // 关键:绑定省份信息到userData(交互时使用)
    mesh.userData = {
      provinceName: feature.properties.name,
      originalColor: 0x3b82f6,
      isHighlighted: false
    };

    scene.add(mesh);
    provinceMeshes.push(mesh); // 加入数组,用于射线检测

    // 4. 创建白色边框
    const borderGeo = new THREE.BufferGeometry().setFromPoints(shape.getPoints());
    const borderMat = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 });
    const border = new THREE.Line(borderGeo, borderMat);
    mesh.border = border; // 边框绑定到Mesh,方便后续修改颜色
    scene.add(border);
  });
});

// 创建Shape:将GeoJSON多边形转换为Three.js Shape
function createShape(ring, centerX, centerY, scale, offset = { dx: 0, dy: 0 }) {
  if (ring.length < 3) return null; // 少于3个点无法构成多边形
  const shape = new THREE.Shape();
  // 转换所有点为平面坐标,并应用居中+缩放+偏移
  const points = ring.map(([lon, lat]) => {
    const { x, y } = mercator(lon, lat);
    const shiftedX = x + offset.dx;
    const shiftedY = y + offset.dy;
    return {
      x: (shiftedX - centerX) * scale, // 居中后缩放
      y: (shiftedY - centerY) * scale
    };
  });
  // 绘制Shape:移动到第一个点,依次连线
  shape.moveTo(points[0].x, points[0].y);
  for (let i = 1; i < points.length; i++) {
    shape.lineTo(points[i].x, points[i].y);
  }
  return shape;
}
4.2 关键逻辑解析
  • Shape创建THREE.Shape是Three.js的2D形状对象,通过moveTo+lineTo绘制多边形;
  • MultiPolygon处理:包含多个多边形的省份(如海南=海南岛+南海诸岛),需为每个多边形创建独立Shape;
  • userData绑定:Three.js的Object3D对象可通过userData存储自定义数据,这里绑定省份名称/原始颜色,是交互的核心;
  • 边框创建:通过shape.getPoints()获取Shape的顶点,创建Line实现边框效果。

步骤5:Raycaster射线检测(鼠标点击高亮)

5.1 核心代码
let highlightedProvince = null; // 记录当前高亮的省份

// 鼠标点击事件处理
window.addEventListener('click', onDocumentMouseDown, false);

function onDocumentMouseDown(event) {
  // 1. 将鼠标屏幕坐标转换为NDC(归一化设备坐标,范围-1~1)
  const mouse = new THREE.Vector2();
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

  // 2. 创建Raycaster(射线检测)
  const raycaster = new THREE.Raycaster();
  raycaster.setFromCamera(mouse, camera); // 设置射线:从相机到鼠标位置

  // 3. 检测射线与所有省份Mesh的相交
  const intersects = raycaster.intersectObjects(provinceMeshes);

  if (intersects.length > 0) {
    // 点击到省份:切换高亮
    const clickedMesh = intersects[0].object;

    // 取消之前的高亮
    if (highlightedProvince) {
      highlightedProvince.material.color.set(highlightedProvince.userData.originalColor);
      if (highlightedProvince.border) {
        highlightedProvince.border.material.color.set(0xffffff);
      }
      highlightedProvince.userData.isHighlighted = false;
    }

    // 高亮当前点击的省份
    clickedMesh.material.color.set(0xffd700); // 金色填充
    if (clickedMesh.border) {
      clickedMesh.border.material.color.set(0xff0000); // 红色边框
    }
    clickedMesh.userData.isHighlighted = true;
    highlightedProvince = clickedMesh;
    console.log('点击了:', clickedMesh.userData.provinceName);
  } else {
    // 点击空白处:取消所有高亮
    if (highlightedProvince) {
      highlightedProvince.material.color.set(highlightedProvince.userData.originalColor);
      if (highlightedProvince.border) {
        highlightedProvince.border.material.color.set(0xffffff);
      }
      highlightedProvince.userData.isHighlighted = false;
      highlightedProvince = null;
    }
  }
}
5.2 射线检测核心原理
  • NDC坐标转换:屏幕坐标(clientX/clientY)转换为归一化设备坐标(-1~1),是Raycaster的标准输入;
  • 射线创建raycaster.setFromCamera(mouse, camera) 生成从相机位置指向鼠标位置的射线;
  • 相交检测raycaster.intersectObjects(provinceMeshes) 返回射线与Mesh的相交结果,优先返回最近的Mesh;
  • 高亮逻辑:通过修改材质颜色实现高亮,利用userData存储原始颜色,保证切换回退。

步骤6:窗口适配与渲染循环

6.1 核心代码
// 窗口大小适配
window.addEventListener('resize', () => {
  const aspect = window.innerWidth / window.innerHeight;
  const frustumSize = 800;
  // 更新正交相机边界
  camera.left = -frustumSize * aspect / 2;
  camera.right = frustumSize * aspect / 2;
  camera.top = frustumSize / 2;
  camera.bottom = -frustumSize / 2;
  camera.updateProjectionMatrix(); // 必须更新投影矩阵
  renderer.setSize(window.innerWidth, window.innerHeight); // 更新渲染器尺寸
});

// 渲染循环(持续渲染场景)
function animate() {
  requestAnimationFrame(animate); // 浏览器刷新率同步
  controls.update(); // 更新控制器(阻尼/平移/缩放)
  renderer.render(scene, camera); // 渲染场景
}
animate();

// 启动地图加载
loadAndDrawMap().catch(err => console.error('加载失败:', err));
6.2 关键注意点
  • 投影矩阵更新:正交相机参数修改后,必须调用camera.updateProjectionMatrix()使修改生效;
  • 渲染循环:Three.js需要持续调用renderer.render()才能显示画面,requestAnimationFrame保证帧率稳定。

核心方法/参数速查表

1. 核心类/函数参数

类/函数 关键参数 说明
OrthographicCamera left/right/top/bottom/near/far 正交相机边界,near/far控制可见距离
mercator(lon, lat) lon(经度)、lat(纬度) 返回{x,y}平面坐标,R=6378137(地球半径)
traverseCoordinates(coords, callback) coords(GeoJSON坐标)、callback(遍历回调) 递归处理嵌套坐标,回调参数为lon/lat
createShape(ring, centerX, centerY, scale, offset) ring(多边形坐标环)、centerX/Y(地图中心)、scale(缩放)、offset(偏移) 返回THREE.Shape,实现坐标居中+缩放
Raycaster.setFromCamera(mouse, camera) mouse(NDC坐标)、camera(相机) 创建从相机到鼠标的射线

2. 交互核心参数

参数 作用
mesh.userData 存储省份名称/原始颜色/高亮状态,交互时读取
intersects[0].object 射线检测命中的第一个Mesh(最近的省份)
controls.enableRotate 禁用旋转(false),保证2D地图体验

完整优化代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>Three.js 中国地图 - 2D矢量版</title>
  <style>
    body {
      margin: 0;
      overflow: hidden;
      background: #f8fafc;
    }
    /* 可选:添加加载提示 */
    .loading {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      font-size: 18px;
      color: #666;
    }
  </style>
</head>
<body>
  <div class="loading">加载地图中...</div>
<script type="module">
  import * as THREE from 'https://esm.sh/three@0.174.0';
  import { OrbitControls } from 'https://esm.sh/three@0.174.0/examples/jsm/controls/OrbitControls.js';

  // 全局变量:存储所有省份Mesh(用于射线检测)
  const provinceMeshes = [];
  // 全局变量:记录当前高亮的省份
  let highlightedProvince = null;
  // 全局变量:地图边界(用于居中缩放)
  let bounds = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };

  // ========== 1. 初始化基础环境 ==========
  // 场景:所有元素的容器
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0xf8fafc); // 浅灰背景

  // 正交相机:2D地图核心(无透视)
  const aspect = window.innerWidth / window.innerHeight;
  const frustumSize = 800; // 相机视口高度(控制初始显示范围)
  const camera = new THREE.OrthographicCamera(
    -frustumSize * aspect / 2,
    frustumSize * aspect / 2,
    frustumSize / 2,
    -frustumSize / 2,
    1, 1000
  );
  camera.position.set(0, 0, 10); // 相机在Z轴,看向场景中心
  camera.lookAt(0, 0, 0);

  // 渲染器:抗锯齿+高清适配
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(window.devicePixelRatio);
  document.body.appendChild(renderer.domElement);

  // 轨道控制器:自定义2D交互规则
  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableRotate = false; // 禁用旋转(纯2D)
  controls.enablePan = true;     // 启用平移(拖拽)
  controls.enableZoom = true;    // 启用缩放(滚轮)
  controls.zoomSpeed = 1.5;      // 缩放速度适配

  // ========== 2. 墨卡托投影函数 ==========
  /**
   * 墨卡托投影:经纬度转平面坐标
   * @param {Number} lon - 经度(-180~180)
   * @param {Number} lat - 纬度(-90~90)
   * @returns {Object} {x, y} 平面坐标(米)
   */
  function mercator(lon, lat) {
    const R = 6378137; // WGS84地球半径
    const x = (lon * Math.PI / 180) * R;
    const y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) * R;
    return { x, y };
  }

  // ========== 3. GeoJSON加载与绘制 ==========
  /**
   * 递归遍历GeoJSON坐标(处理Polygon/MultiPolygon嵌套)
   * @param {Array} coords - GeoJSON坐标数组
   * @param {Function} callback - 遍历回调(lon, lat)
   */
  function traverseCoordinates(coords, callback) {
    if (typeof coords[0] === 'number') {
      callback(coords[0], coords[1]);
    } else {
      coords.forEach(c => traverseCoordinates(c, callback));
    }
  }

  /**
   * 创建Three.js Shape(多边形)
   * @param {Array} ring - 单个多边形坐标环
   * @param {Number} centerX - 地图中心X
   * @param {Number} centerY - 地图中心Y
   * @param {Number} scale - 缩放比例
   * @param {Object} offset - 位置偏移(dx, dy)
   * @returns {THREE.Shape|null} 形状对象
   */
  function createShape(ring, centerX, centerY, scale, offset = { dx: 0, dy: 0 }) {
    if (ring.length < 3) return null; // 最少3个点构成多边形
    const shape = new THREE.Shape();
    const points = ring.map(([lon, lat]) => {
      const { x, y } = mercator(lon, lat);
      const shiftedX = x + offset.dx;
      const shiftedY = y + offset.dy;
      // 居中+缩放:转换为相机视口坐标
      return {
        x: (shiftedX - centerX) * scale,
        y: (shiftedY - centerY) * scale
      };
    });
    // 绘制Shape
    shape.moveTo(points[0].x, points[0].y);
    for (let i = 1; i < points.length; i++) {
      shape.lineTo(points[i].x, points[i].y);
    }
    return shape;
  }

  /**
   * 加载GeoJSON并绘制地图
   */
  async function loadAndDrawMap() {
    try {
      // 1. 加载GeoJSON数据
      const response = await fetch('./china.json');
      const geojson = await response.json();

      // 2. 港澳位置偏移(校正投影偏差)
      const SPECIAL_REGION_OFFSETS = {
        '香港特别行政区': { dx: 80000, dy: -60000 },
        '澳门特别行政区': { dx: 100000, dy: -80000 }
      };

      // 3. 遍历所有坐标,计算全局边界
      geojson.features.forEach(feature => {
        traverseCoordinates(feature.geometry.coordinates, (lon, lat) => {
          const { x, y } = mercator(lon, lat);
          bounds.minX = Math.min(bounds.minX, x);
          bounds.maxX = Math.max(bounds.maxX, x);
          bounds.minY = Math.min(bounds.minY, y);
          bounds.maxY = Math.max(bounds.maxY, y);
        });
      });

      // 4. 计算地图中心和缩放比例
      const centerX = (bounds.minX + bounds.maxX) / 2;
      const centerY = (bounds.minY + bounds.maxY) / 2;
      const width = bounds.maxX - bounds.minX;
      const height = bounds.maxY - bounds.minY;
      const scale = 700 / Math.max(width, height); // 适配视口

      // 5. 遍历每个省份,创建Shape和Mesh
      geojson.features.forEach(feature => {
        const shapes = [];
        const provinceName = feature.properties.name;
        const offset = SPECIAL_REGION_OFFSETS[provinceName] || { dx: 0, dy: 0 };

        // 处理Polygon(单多边形)
        if (feature.geometry.type === 'Polygon') {
          const shape = createShape(feature.geometry.coordinates[0], centerX, centerY, scale, offset);
          if (shape) shapes.push(shape);
        }
        // 处理MultiPolygon(多多边形,如海南)
        else if (feature.geometry.type === 'MultiPolygon') {
          feature.geometry.coordinates.forEach(polygon => {
            const shape = createShape(polygon[0], centerX, centerY, scale, offset);
            if (shape) shapes.push(shape);
          });
        }

        // 为每个Shape创建填充和边框
        shapes.forEach(shape => {
          // 填充Mesh
          const geometry = new THREE.ShapeGeometry(shape);
          const material = new THREE.MeshBasicMaterial({
            color: 0x3b82f6,
            side: THREE.DoubleSide
          });
          const mesh = new THREE.Mesh(geometry, material);

          // 绑定自定义数据(交互用)
          mesh.userData = {
            provinceName: provinceName,
            originalColor: 0x3b82f6,
            isHighlighted: false
          };

          scene.add(mesh);
          provinceMeshes.push(mesh);

          // 白色边框
          const borderGeo = new THREE.BufferGeometry().setFromPoints(shape.getPoints());
          const borderMat = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 });
          const border = new THREE.Line(borderGeo, borderMat);
          mesh.border = border;
          scene.add(border);
        });
      });

      // 隐藏加载提示
      document.querySelector('.loading').style.display = 'none';
    } catch (err) {
      console.error('地图加载失败:', err);
      document.querySelector('.loading').textContent = '加载失败,请检查GeoJSON文件';
    }
  }

  // ========== 4. 鼠标点击交互(Raycaster) ==========
  /**
   * 鼠标点击事件处理:省份高亮
   * @param {MouseEvent} event - 鼠标事件
   */
  function onDocumentMouseDown(event) {
    // 1. 转换鼠标坐标为NDC(-1~1)
    const mouse = new THREE.Vector2();
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

    // 2. 创建射线检测
    const raycaster = new THREE.Raycaster();
    raycaster.setFromCamera(mouse, camera);

    // 3. 检测与省份Mesh的相交
    const intersects = raycaster.intersectObjects(provinceMeshes);

    if (intersects.length > 0) {
      // 点击到省份:切换高亮
      const clickedMesh = intersects[0].object;

      // 取消之前的高亮
      if (highlightedProvince) {
        highlightedProvince.material.color.set(highlightedProvince.userData.originalColor);
        highlightedProvince.border.material.color.set(0xffffff);
        highlightedProvince.userData.isHighlighted = false;
      }

      // 高亮当前省份
      clickedMesh.material.color.set(0xffd700); // 金色填充
      clickedMesh.border.material.color.set(0xff0000); // 红色边框
      clickedMesh.userData.isHighlighted = true;
      highlightedProvince = clickedMesh;
      console.log('点击省份:', clickedMesh.userData.provinceName);
    } else {
      // 点击空白处:取消所有高亮
      if (highlightedProvince) {
        highlightedProvince.material.color.set(highlightedProvince.userData.originalColor);
        highlightedProvince.border.material.color.set(0xffffff);
        highlightedProvince.userData.isHighlighted = false;
        highlightedProvince = null;
      }
    }
  }
  window.addEventListener('click', onDocumentMouseDown, false);

  // ========== 5. 窗口适配与渲染循环 ==========
  // 窗口大小变化适配
  window.addEventListener('resize', () => {
    const aspect = window.innerWidth / window.innerHeight;
    camera.left = -frustumSize * aspect / 2;
    camera.right = frustumSize * aspect / 2;
    camera.top = frustumSize / 2;
    camera.bottom = -frustumSize / 2;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });

  // 渲染循环
  function animate() {
    requestAnimationFrame(animate);
    controls.update(); // 更新控制器
    renderer.render(scene, camera); // 渲染场景
  }
  animate();

  // 启动地图加载
  loadAndDrawMap();
</script>
</body>
</html>

总结与扩展建议

核心总结

  1. 2D地图核心:正交相机(OrthographicCamera)是实现无透视2D效果的关键,区别于透视相机;
  2. 坐标转换:墨卡托投影是地理数据可视化的基础,需掌握经纬度→平面坐标的转换逻辑;
  3. GeoJSON处理:递归遍历嵌套坐标、计算全局边界是地图居中缩放的核心;
  4. 交互实现Raycaster射线检测是Three.js实现鼠标点击交互的标准方式,userData是存储自定义数据的最佳实践;
  5. 性能优化:复用几何体/材质、减少不必要的顶点数,可提升大地图的渲染性能。

扩展建议

  1. 添加省份标签:基于省份中心坐标创建CSS2DLabel,显示省份名称;
  2. hover高亮:监听mousemove事件,实现鼠标悬浮高亮;
  3. 数据可视化:根据省份数据(如GDP、人口)动态修改填充颜色;
  4. 层级优化:为南海诸岛等小区域单独缩放,提升显示效果;
  5. 性能优化:使用BufferGeometry替代ShapeGeometry,减少内存占用;
  6. 地图交互增强:添加缩放限制(最小/最大缩放)、地图复位按钮。
❌