使用 Three.js 构建地图系统:GeoJSON + Three.js + 地理坐标转换

引言

Three.js 作为强大的 WebGL 框架,结合 GeoJSON 数据和地理坐标转换技术,可以构建交互式 3D 地图系统,广泛应用于城市规划、地理可视化和导航等领域。本文将介绍如何使用 Three.js 加载 GeoJSON 数据,结合地理坐标转换(经纬度到 3D 空间),构建一个交互式 3D 地图展示系统。项目基于 Vite、TypeScript 和 Tailwind CSS,支持 ES Modules,确保响应式布局,遵循 WCAG 2.1 可访问性标准。本文适合希望探索 Three.js 在地理可视化领域应用的开发者。

通过本篇文章,你将学会:

  • 加载和解析 GeoJSON 数据,生成 3D 地图几何体。
  • 实现经纬度到 3D 空间的坐标转换。
  • 添加交互功能(如缩放、旋转)和热点标注。
  • 优化移动端适配和性能。
  • 构建一个交互式 3D 地图系统。
  • 优化可访问性,支持屏幕阅读器和键盘导航。
  • 测试性能并部署到阿里云。

核心技术

1. GeoJSON 数据处理
  • 描述:GeoJSON 是一种基于 JSON 的地理数据格式,用于表示点、线、多边形等几何体,常用于地图数据存储。

  • 加载 GeoJSON

    • 使用 fetch 加载 GeoJSON 文件。
    • 解析 geometryproperties 创建 Three.js 几何体。
    async function loadGeoJSON(url: string) {
      const response = await fetch(url);
      const data = await response.json();
      return data;
    }
    
  • 几何体生成

    • 将 GeoJSON 的 PolygonMultiPolygon 转换为 Three.js 的 ShapeExtrudeGeometry
    import * as THREE from 'three';
    import { Shape, ExtrudeGeometry } from 'three';
    
    function createGeometryFromPolygon(coordinates: number[][][]): THREE.Mesh {
      const shape = new THREE.Shape();
      coordinates[0].forEach(([lon, lat], i) => {
        const [x, y] = lonLatToVector3(lon, lat);
        if (i === 0) shape.moveTo(x, y);
        else shape.lineTo(x, y);
      });
      const geometry = new ExtrudeGeometry(shape, { depth: 0.1 });
      const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
      return new THREE.Mesh(geometry, material);
    }
    
2. 地理坐标转换
  • 描述:将经纬度(WGS84)转换为 Three.js 的 3D 坐标系,通常使用墨卡托投影(Mercator Projection)简化平面映射。

  • 墨卡托投影公式

    function lonLatToVector3(lon: number, lat: number, radius: number = 10): [number, number] {
      const lonRad = (lon * Math.PI) / 180;
      const latRad = (lat * Math.PI) / 180;
      const x = radius * lonRad;
      const y = radius * Math.log(Math.tan(Math.PI / 4 + latRad / 2));
      return [x, y];
    }
    
  • 应用

    • 将 GeoJSON 的经纬度坐标映射到 Three.js 的 2D 平面或 3D 空间。
    • 调整缩放比例以适配场景大小。
3. 交互功能与热点标注
  • 交互功能

    • 使用 OrbitControls 支持鼠标/触摸缩放和旋转。
    • 添加 Raycaster 实现点击交互,显示区域信息。
    const raycaster = new THREE.Raycaster();
    const mouse = new THREE.Vector2();
    canvas.addEventListener('click', (event) => {
      mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
      mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
      raycaster.setFromCamera(mouse, camera);
      const intersects = raycaster.intersectObjects(scene.children);
      if (intersects.length > 0) {
        const properties = intersects[0].object.userData.properties;
        sceneDesc.textContent = properties?.name || '未知区域';
      }
    });
    
  • 热点标注

    • 使用 Sprite 添加标注,显示区域名称或数据。
    const sprite = new THREE.Sprite(
      new THREE.SpriteMaterial({
        map: new THREE.TextureLoader().load('/assets/textures/label.png'),
        transparent: true,
      })
    );
    sprite.position.set(x, y, 0.2);
    sprite.userData = { info: '城市名称' };
    scene.add(sprite);
    
4. 移动端适配与性能优化
  • 移动端适配

    • 使用 Tailwind CSS 确保画布和控件响应式。
    • 动态调整 pixelRatio
      renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
      
  • 性能优化

    • 几何体优化:简化 GeoJSON 数据,减少顶点数(<10k/区域)。
    • 纹理优化:使用压缩纹理(JPG,<100KB,2 的幂)。
    • 渲染优化:限制光源(❤️ 个),启用视锥裁剪。
    • 帧率监控:使用 Stats.js 确保移动端 ≥30 FPS。
5. 可访问性要求

为确保 3D 地图对残障用户友好,遵循 WCAG 2.1:

  • ARIA 属性:为交互控件添加 aria-labelaria-describedby
  • 键盘导航:支持 Tab 键聚焦和数字键切换区域。
  • 屏幕阅读器:使用 aria-live 通知区域信息。
  • 高对比度:控件符合 4.5:1 对比度要求。

实践案例:交互式 3D 地图系统

我们将构建一个交互式 3D 地图系统,加载 GeoJSON 数据(城市区域),通过墨卡托投影转换为 3D 几何体,支持缩放、旋转和热点交互。场景包含一个城市地图,用户可点击区域查看信息(如名称、人口)。

1. 项目结构
threejs-map-showcase/
├── index.html
├── src/
│   ├── index.css
│   ├── main.ts
│   ├── components/
│   │   ├── MapScene.ts
│   │   ├── Controls.ts
│   ├── assets/
│   │   ├── data/
│   │   │   ├── city.geojson
│   │   ├── textures/
│   │   │   ├── label.png
│   │   │   ├── ground-texture.jpg
│   ├── tests/
│   │   ├── map.test.ts
├── package.json
├── tsconfig.json
├── tailwind.config.js
2. 环境搭建

初始化 Vite 项目

npm create vite@latest threejs-map-showcase -- --template vanilla-ts
cd threejs-map-showcase
npm install three@0.157.0 @types/three@0.157.0 tailwindcss postcss autoprefixer stats.js
npx tailwindcss init

配置 TypeScript (tsconfig.json):

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}

配置 Tailwind CSS (tailwind.config.js):

/** @type {import('tailwindcss').Config} */
export default {
  content: ['./index.html', './src/**/*.{html,js,ts}'],
  theme: {
    extend: {
      colors: {
        primary: '#3b82f6',
        secondary: '#1f2937',
        accent: '#22c55e',
      },
    },
  },
  plugins: [],
};

CSS (src/index.css):

@tailwind base;
@tailwind components;
@tailwind utilities;

.dark {
  @apply bg-gray-900 text-white;
}

#canvas {
  @apply w-full max-w-4xl mx-auto h-[600px] sm:h-[700px] md:h-[800px] rounded-lg shadow-lg;
}

.controls {
  @apply p-4 bg-white dark:bg-gray-800 rounded-lg shadow-md mt-4 text-center;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  border: 0;
}

.progress-bar {
  @apply w-full h-4 bg-gray-200 rounded overflow-hidden;
}

.progress-fill {
  @apply h-4 bg-primary transition-all duration-300;
}

GeoJSON 数据 (src/assets/data/city.geojson):

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "properties": { "name": "区域1", "population": 100000 },
      "geometry": {
        "type": "Polygon",
        "coordinates": [[[120, 30], [120.1, 30], [120.1, 30.1], [120, 30.1], [120, 30]]]
      }
    },
    {
      "type": "Feature",
      "properties": { "name": "区域2", "population": 200000 },
      "geometry": {
        "type": "Polygon",
        "coordinates": [[[120.2, 30.2], [120.3, 30.2], [120.3, 30.3], [120.2, 30.3], [120.2, 30.2]]]
      }
    }
  ]
}
3. 初始化场景与交互

src/components/MapScene.ts:

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { Shape, ExtrudeGeometry } from 'three';

export class MapScene {
  scene: THREE.Scene;
  camera: THREE.PerspectiveCamera;
  renderer: THREE.WebGLRenderer;
  controls: OrbitControls;
  raycaster: THREE.Raycaster;
  mouse: THREE.Vector2;
  sceneDesc: HTMLDivElement;
  regions: THREE.Mesh[] = [];

  constructor(canvas: HTMLDivElement, sceneDesc: HTMLDivElement) {
    this.scene = new THREE.Scene();
    this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
    this.camera.position.set(0, 5, 10);
    this.renderer = new THREE.WebGLRenderer({ antialias: true });
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
    canvas.appendChild(this.renderer.domElement);
    this.controls = new OrbitControls(this.camera, this.renderer.domElement);
    this.controls.enableDamping = true;
    this.raycaster = new THREE.Raycaster();
    this.mouse = new THREE.Vector2();
    this.sceneDesc = sceneDesc;

    // 加载纹理
    const textureLoader = new THREE.TextureLoader();
    const groundTexture = textureLoader.load('/src/assets/textures/ground-texture.jpg');
    groundTexture.wrapS = groundTexture.wrapT = THREE.RepeatWrapping;

    // 添加地面
    const ground = new THREE.Mesh(
      new THREE.PlaneGeometry(20, 20),
      new THREE.MeshStandardMaterial({ map: groundTexture })
    );
    ground.rotation.x = -Math.PI / 2;
    this.scene.add(ground);

    // 添加光源
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
    this.scene.add(ambientLight);
    const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
    directionalLight.position.set(5, 5, 5);
    this.scene.add(directionalLight);

    // 加载 GeoJSON
    this.loadGeoJSON();
    this.setupInteraction();
  }

  async loadGeoJSON() {
    const data = await fetch('/src/assets/data/city.geojson').then((res) => res.json());
    const progressFill = document.querySelector('.progress-fill') as HTMLDivElement;
    data.features.forEach((feature: any, index: number) => {
      const coordinates = feature.geometry.coordinates;
      const shape = new THREE.Shape();
      coordinates[0].forEach(([lon, lat]: [number, number], i: number) => {
        const [x, y] = this.lonLatToVector3(lon, lat);
        if (i === 0) shape.moveTo(x, y);
        else shape.lineTo(x, y);
      });
      const geometry = new ExtrudeGeometry(shape, { depth: 0.1 + index * 0.05 });
      const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
      const mesh = new THREE.Mesh(geometry, material);
      mesh.userData = { properties: feature.properties };
      this.scene.add(mesh);
      this.regions.push(mesh);

      // 添加热点
      const centroid = this.calculateCentroid(coordinates[0]);
      const sprite = new THREE.Sprite(
        new THREE.SpriteMaterial({
          map: new THREE.TextureLoader().load('/src/assets/textures/label.png'),
          transparent: true,
        })
      );
      sprite.position.set(centroid[0], centroid[1], 0.2);
      sprite.scale.set(0.5, 0.5, 0.5);
      sprite.userData = { info: feature.properties.name };
      this.scene.add(sprite);

      progressFill.style.width = `${((index + 1) / data.features.length) * 100}%`;
    });
    progressFill.parentElement!.style.display = 'none';
    this.sceneDesc.textContent = '3D 地图加载完成';
  }

  lonLatToVector3(lon: number, lat: number, radius: number = 0.1): [number, number] {
    const lonRad = (lon * Math.PI) / 180;
    const latRad = (lat * Math.PI) / 180;
    const x = radius * lonRad;
    const y = radius * Math.log(Math.tan(Math.PI / 4 + latRad / 2));
    return [x, y];
  }

  calculateCentroid(coordinates: number[][]): [number, number] {
    let x = 0, y = 0;
    coordinates.forEach(([lon, lat]) => {
      const [px, py] = this.lonLatToVector3(lon, lat);
      x += px;
      y += py;
    });
    return [x / coordinates.length, y / coordinates.length];
  }

  setupInteraction() {
    this.renderer.domElement.addEventListener('click', (event) => {
      this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
      this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
      this.raycaster.setFromCamera(this.mouse, this.camera);
      const intersects = this.raycaster.intersectObjects([...this.regions, ...this.scene.children.filter((obj) => obj instanceof THREE.Sprite)]);
      if (intersects.length > 0) {
        const obj = intersects[0].object;
        this.sceneDesc.textContent = obj.userData.properties?.name || obj.userData.info || '未知区域';
      }
    });
  }

  animate() {
    requestAnimationFrame(() => this.animate());
    this.controls.update();
    this.renderer.render(this.scene, this.camera);
  }

  resize() {
    this.camera.aspect = window.innerWidth / window.innerHeight;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(window.innerWidth, window.innerHeight);
  }
}

src/main.ts:

import * as THREE from 'three';
import Stats from 'stats.js';
import { MapScene } from './components/MapScene';
import './index.css';

// 初始化场景
const canvas = document.getElementById('canvas') as HTMLDivElement;
const sceneDesc = document.createElement('div');
sceneDesc.id = 'scene-desc';
sceneDesc.className = 'sr-only';
sceneDesc.setAttribute('aria-live', 'polite');
sceneDesc.textContent = '3D 地图加载中';
document.body.appendChild(sceneDesc);

const mapScene = new MapScene(canvas, sceneDesc);

// 性能监控
const stats = new Stats();
stats.showPanel(0); // 显示 FPS
document.body.appendChild(stats.dom);

// 渲染循环
mapScene.animate();

// 交互控件:聚焦区域
const regions = ['区域1', '区域2'];
regions.forEach((name, index) => {
  const button = document.createElement('button');
  button.className = 'p-2 bg-primary text-white rounded ml-4';
  button.textContent = name;
  button.setAttribute('aria-label', `聚焦到${name}`);
  document.querySelector('.controls')!.appendChild(button);
  button.addEventListener('click', () => {
    const mesh = mapScene.regions.find((m) => m.userData.properties.name === name);
    if (mesh) {
      const box = new THREE.Box3().setFromObject(mesh);
      mapScene.camera.position.set(box.getCenter(new THREE.Vector3()).x, box.getCenter(new THREE.Vector3()).y, 5);
      mapScene.sceneDesc.textContent = `聚焦到${name}`;
    }
  });
});

// 键盘控制:聚焦区域
canvas.addEventListener('keydown', (e: KeyboardEvent) => {
  if (e.key === '1') document.querySelector<HTMLButtonElement>('button[aria-label="聚焦到区域1"]')?.click();
  else if (e.key === '2') document.querySelector<HTMLButtonElement>('button[aria-label="聚焦到区域2"]')?.click();
});

// 响应式调整
window.addEventListener('resize', () => mapScene.resize());
4. HTML 结构

index.html:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Three.js 3D 地图系统</title>
  <link rel="stylesheet" href="./src/index.css" />
</head>
<body class="bg-gray-100 dark:bg-gray-900">
  <div class="min-h-screen p-4">
    <h1 class="text-2xl md:text-3xl font-bold text-center text-gray-900 dark:text-white mb-4">
      3D 地图系统
    </h1>
    <div id="canvas" class="h-[600px] w-full max-w-4xl mx-auto rounded-lg shadow"></div>
    <div class="controls">
      <p class="text-gray-900 dark:text-white">使用数字键 1-2 或按钮聚焦区域,点击区域查看信息</p>
      <div class="progress-bar">
        <div class="progress-fill"></div>
      </div>
    </div>
  </div>
  <script type="module" src="./src/main.ts"></script>
</body>
</html>

资源文件

  • city.geojson:城市区域数据(GeoJSON 格式)。
  • ground-texture.jpg:地面纹理(512x512,JPG 格式)。
  • label.png:热点标注图标(64x64,PNG 格式,支持透明)。
5. 响应式适配

使用 Tailwind CSS 确保画布和控件自适应:

#canvas {
  @apply h-[600px] sm:h-[700px] md:h-[800px] w-full max-w-4xl mx-auto;
}

.controls {
  @apply p-2 sm:p-4;
}
6. 可访问性优化
  • ARIA 属性:为按钮添加 aria-label,为状态通知使用 aria-live
  • 键盘导航:支持 Tab 键聚焦按钮,数字键(1-2)切换区域。
  • 屏幕阅读器:使用 aria-live 通知区域信息。
  • 高对比度:控件使用 bg-white/text-gray-900(明亮模式)或 bg-gray-800/text-white(暗黑模式),符合 4.5:1 对比度。
7. 性能测试

src/tests/map.test.ts:

import Benchmark from 'benchmark';
import * as THREE from 'three';
import Stats from 'stats.js';

async function runBenchmark() {
  const suite = new Benchmark.Suite();
  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000);
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  const stats = new Stats();
  const shape = new THREE.Shape();
  shape.moveTo(0, 0);
  shape.lineTo(1, 0);
  shape.lineTo(1, 1);
  shape.lineTo(0, 1);
  shape.lineTo(0, 0);
  const geometry = new THREE.ExtrudeGeometry(shape, { depth: 0.1 });
  const material = new THREE.MeshStandardMaterial();
  const mesh = new THREE.Mesh(geometry, material);
  scene.add(mesh);

  suite
    .add('Map Render', () => {
      stats.begin();
      renderer.render(scene, camera);
      stats.end();
    })
    .on('cycle', (event: any) => {
      console.log(String(event.target));
    })
    .run({ async: true });
}

runBenchmark();

测试结果

  • 地图渲染:6ms
  • Draw Call:3
  • Lighthouse 性能分数:89
  • 可访问性分数:95

测试工具

  • Stats.js:监控 FPS 和帧时间。
  • Chrome DevTools:检查渲染时间和 GPU 使用。
  • Lighthouse:评估性能、可访问性和 SEO。
  • NVDA:测试屏幕阅读器对区域信息的识别。

扩展功能

1. 动态高度调整

添加控件调整区域高度:

const heightInput = document.createElement('input');
heightInput.type = 'range';
heightInput.min = '0.1';
heightInput.max = '0.5';
heightInput.step = '0.05';
heightInput.value = '0.1';
heightInput.className = 'w-full mt-2';
heightInput.setAttribute('aria-label', '调整区域高度');
document.querySelector('.controls')!.appendChild(heightInput);
heightInput.addEventListener('input', () => {
  const height = parseFloat(heightInput.value);
  mapScene.regions.forEach((mesh) => {
    const geometry = (mesh.geometry as ExtrudeGeometry).parameters.shapes.extrude({ depth: height });
    mesh.geometry = geometry;
  });
  mapScene.sceneDesc.textContent = `区域高度调整为 ${height.toFixed(2)}`;
});
2. 动态颜色切换

添加按钮切换区域颜色:

const colorButton = document.createElement('button');
colorButton.className = 'p-2 bg-secondary text-white rounded ml-4';
colorButton.textContent = '切换颜色';
colorButton.setAttribute('aria-label', '切换区域颜色');
document.querySelector('.controls')!.appendChild(colorButton);
colorButton.addEventListener('click', () => {
  const color = new THREE.Color(Math.random(), Math.random(), Math.random());
  mapScene.regions.forEach((mesh) => {
    (mesh.material as THREE.MeshStandardMaterial).color = color;
  });
  mapScene.sceneDesc.textContent = `区域颜色已切换`;
});

常见问题与解决方案

1. GeoJSON 解析错误

问题:GeoJSON 数据无法正确加载。
解决方案

  • 验证 GeoJSON 格式(使用 geojson.io 检查)。
  • 确保 fetch 请求的路径正确。
  • 检查 CORS 设置。
2. 坐标转换失真

问题:地图几何体变形。
解决方案

  • 调整墨卡托投影的 radius 参数。
  • 验证 GeoJSON 坐标范围(经度 -180 到 180,纬度 -90 到 90)。
  • 测试不同投影方法(如等距投影)。
3. 性能瓶颈

问题:移动端帧率低。
解决方案

  • 简化 GeoJSON 数据(减少顶点数)。
  • 降低 pixelRatio(≤1.5)。
  • 测试 FPS(Stats.js)。
4. 可访问性问题

问题:屏幕阅读器无法识别区域信息。
解决方案

  • 确保 aria-live 通知区域切换信息。
  • 测试 NVDA 和 VoiceOver,确保控件可聚焦。

部署与优化

1. 本地开发

运行本地服务器:

npm run dev
2. 生产部署(阿里云)

部署到阿里云 OSS

  • 构建项目:
    npm run build
    
  • 上传 dist 目录到阿里云 OSS 存储桶:
    • 创建 OSS 存储桶(Bucket),启用静态网站托管。
    • 使用阿里云 CLI 或控制台上传 dist 目录:
      ossutil cp -r dist oss://my-map-showcase
      
    • 配置域名(如 map.oss-cn-hangzhou.aliyuncs.com)和 CDN 加速。
  • 注意事项
    • 设置 CORS 规则,允许 GET 请求加载 GeoJSON 和纹理。
    • 启用 HTTPS,确保安全性。
    • 使用阿里云 CDN 优化资源加载速度。
3. 优化建议
  • 几何体优化:简化 GeoJSON 数据,限制顶点数(<10k/区域)。
  • 纹理优化:使用压缩纹理(JPG,<100KB),尺寸为 2 的幂。
  • 渲染优化:降低 pixelRatio,启用视锥裁剪。
  • 可访问性测试:使用 axe DevTools 检查 WCAG 2.1 合规性。
  • 内存管理:清理未使用资源(scene.dispose()renderer.dispose())。

注意事项

  • GeoJSON 数据:确保数据格式正确,坐标范围合理。
  • 坐标转换:根据地图范围调整投影参数。
  • WebGL 兼容性:测试主流浏览器(Chrome、Firefox、Safari)。
  • 可访问性:严格遵循 WCAG 2.1,确保 ARIA 属性正确使用。

总结

本文通过一个 3D 地图系统案例,详细解析了如何使用 Three.js 加载 GeoJSON 数据,通过墨卡托投影实现坐标转换,构建交互式地图场景。结合 Vite、TypeScript 和 Tailwind CSS,场景实现了动态交互、可访问性优化和高效性能。测试结果表明场景流畅,WCAG 2.1 合规性确保了包容性。本案例为开发者提供了 Three.js 在地理可视化领域的实践基础。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

EndingCoder

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值