{T}

数据可视化案例

概述

本章将介绍如何使用 Three.js 创建 3D 数据可视化应用,涵盖柱状图、散点图、地理可视化、网络图等常见可视化类型。适用于数据分析展示、业务大屏、科学可视化等场景。

案例目标

创建一个综合性的 3D 数据可视化平台,具备以下特性:

  • 多种图表类型:柱状图、散点图、折线图、饼图
  • 地理可视化:3D 地图、热力图、路径动画
  • 网络图:节点关系可视化,支持力导向布局
  • 交互功能:悬停提示、点击筛选、缩放平移
  • 动画效果:数据变化动画、入场动画
  • 数据导入:支持 JSON、CSV 数据格式

系统架构

code
┌─────────────────────────────────────────────────────────────┐
│                        用户界面层 (UI)                        │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐       │
│  │ 图表选择器 │ │ 数据面板  │ │ 提示信息  │ │ 控制按钮  │       │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘       │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                      可视化组件层 (Charts)                    │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐       │
│  │ BarChart │ │ScatterPlot│ │  Map3D   │ │NetworkGraph│     │
│  │  柱状图   │ │  散点图   │ │  3D地图  │ │  网络图   │       │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘       │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                      工具层 (Utils)                           │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐                    │
│  │DataParser│ │ ColorScale│ │  Animation│                    │
│  │ 数据解析  │ │ 颜色映射  │ │   动画    │                    │
│  └──────────┘ └──────────┘ └──────────┘                    │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                     渲染引擎层 (Three.js)                     │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐       │
│  │  Scene   │ │  Camera  │ │ Renderer │ │ Controls │       │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘       │
└─────────────────────────────────────────────────────────────┘

图表类型说明

图表类型适用场景数据维度交互特性
柱状图 (BarChart)对比分析、排名展示1-2 维悬停高亮、点击筛选
散点图 (ScatterPlot)相关性分析、分布展示3-4 维缩放、旋转、颜色映射
3D 地图 (Map3D)地理数据、区域分析2+ 维区域高亮、数据钻取
网络图 (NetworkGraph)关系分析、拓扑展示节点+边节点拖拽、高亮关联

数据格式示例

柱状图数据格式

javascript
// 基础格式
const barData = [
  { label: '一月', value: 120 },
  { label: '二月', value: 150 },
  { label: '三月', value: 180 }
];

// 分组柱状图
const groupedBarData = [
  { label: 'Q1', values: { sales: 100, profit: 30, cost: 70 } },
  { label: 'Q2', values: { sales: 150, profit: 45, cost: 105 } },
  { label: 'Q3', values: { sales: 200, profit: 60, cost: 140 } }
];

散点图数据格式

javascript
// 3D 散点图
const scatterData = [
  { x: 10, y: 20, z: 15, value: 100, category: 'A' },
  { x: 25, y: 35, z: 20, value: 200, category: 'B' },
  { x: 15, y: 10, z: 30, value: 150, category: 'A' }
];

网络图数据格式

javascript
const networkData = {
  nodes: [
    { id: '1', label: '节点A', group: 1, size: 10 },
    { id: '2', label: '节点B', group: 2, size: 15 },
    { id: '3', label: '节点C', group: 1, size: 8 }
  ],
  edges: [
    { source: '1', target: '2', weight: 5 },
    { source: '1', target: '3', weight: 3 },
    { source: '2', target: '3', weight: 2 }
  ]
};

GeoJSON 地图数据格式

json
{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "properties": {
        "name": "区域名称",
        "value": 85.5
      },
      "geometry": {
        "type": "Polygon",
        "coordinates": [[[lng, lat], ...]]
      }
    }
  ]
}

项目结构

code
data-visualization/
├── index.html              # 主页面
├── css/
│   └── style.css          # 样式文件
├── js/
│   ├── main.js            # 主入口
│   ├── SceneManager.js    # 场景管理
│   ├── charts/
│   │   ├── BarChart.js    # 柱状图
│   │   ├── ScatterPlot.js # 散点图
│   │   ├── LineChart.js   # 折线图
│   │   └── PieChart.js    # 饼图
│   ├── geo/
│   │   ├── Map3D.js       # 3D地图
│   │   └── Heatmap.js     # 热力图
│   ├── network/
│   │   └── NetworkGraph.js# 网络图
│   └── utils/
│       ├── DataParser.js  # 数据解析
│       └── ColorScale.js  # 颜色映射
└── data/
    └── sample.json        # 示例数据

核心代码实现

1. 柱状图(BarChart.js)

javascript
import * as THREE from 'three';
import * as TWEEN from '@tweenjs/tween.js';

export class BarChart {
  constructor(scene, config = {}) {
    this.scene = scene;
    this.config = {
      width: config.width || 10,
      height: config.height || 5,
      depth: config.depth || 10,
      barWidth: config.barWidth || 0.8,
      barGap: config.barGap || 0.2,
      colors: config.colors || ['#4a90e2', '#50c878', '#f39c12', '#e74c3c'],
      animate: config.animate !== false,
      showLabels: config.showLabels !== false,
      showGrid: config.showGrid !== false,
      ...config
    };
    
    this.bars = [];
    this.labels = [];
    this.group = new THREE.Group();
    this.scene.add(this.group);
    
    if (this.config.showGrid) {
      this.createGrid();
    }
  }
  
  setData(data) {
    // 清除旧数据
    this.clear();
    
    const { width, depth, barWidth, barGap, colors, animate } = this.config;
    
    // 计算布局
    const maxValue = Math.max(...data.map(d => d.value));
    const rows = Math.ceil(Math.sqrt(data.length));
    const cols = Math.ceil(data.length / rows);
    
    // 计算单元格大小
    const cellWidth = (width - barGap * (cols - 1)) / cols;
    const cellDepth = (depth - barGap * (rows - 1)) / rows;
    
    data.forEach((item, index) => {
      const row = Math.floor(index / cols);
      const col = index % cols;
      
      // 计算位置
      const x = -width / 2 + col * (cellWidth + barGap) + cellWidth / 2;
      const z = -depth / 2 + row * (cellDepth + barGap) + cellDepth / 2;
      
      // 计算高度
      const normalizedHeight = (item.value / maxValue) * this.config.height;
      
      // 创建柱子
      const geometry = new THREE.BoxGeometry(
        Math.min(barWidth, cellWidth * 0.8),
        normalizedHeight,
        Math.min(barWidth, cellDepth * 0.8)
      );
      
      // 创建材质
      const color = typeof colors[index % colors.length] === 'string'
        ? new THREE.Color(colors[index % colors.length])
        : colors[index % colors.length];
      
      const material = new THREE.MeshPhongMaterial({
        color: color,
        transparent: true,
        opacity: 0.9
      });
      
      const bar = new THREE.Mesh(geometry, material);
      bar.position.set(x, 0, z);
      
      // 动画效果
      if (animate) {
        bar.scale.y = 0.01;
        bar.position.y = 0;
        
        new TWEEN.Tween({ scale: 0.01, y: 0 })
          .to({ scale: 1, y: normalizedHeight / 2 }, 800)
          .delay(index * 50)
          .easing(TWEEN.Easing.Elastic.Out)
          .onUpdate((obj) => {
            bar.scale.y = obj.scale;
            bar.position.y = obj.y;
          })
          .start();
      } else {
        bar.position.y = normalizedHeight / 2;
      }
      
      // 存储数据
      bar.userData = {
        type: 'bar',
        index: index,
        data: item,
        originalColor: color.clone()
      };
      
      this.bars.push(bar);
      this.group.add(bar);
      
      // 创建标签
      if (this.config.showLabels) {
        this.createLabel(item.label, x, z, cellWidth);
      }
    });
    
    // 创建坐标轴标签
    this.createAxisLabels(maxValue);
  }
  
  createGrid() {
    const { width, height, depth } = this.config;
    
    // 底部网格
    const gridHelper = new THREE.GridHelper(
      Math.max(width, depth),
      Math.max(width, depth),
      0xcccccc,
      0xe0e0e0
    );
    gridHelper.position.y = 0;
    this.group.add(gridHelper);
    
    // Y轴刻度
    const tickCount = 5;
    for (let i = 0; i <= tickCount; i++) {
      const y = (i / tickCount) * height;
      
      // 刻度线
      const tickGeometry = new THREE.BufferGeometry();
      const tickVertices = new Float32Array([
        -width / 2, y, depth / 2,
        -width / 2 - 0.2, y, depth / 2
      ]);
      tickGeometry.setAttribute(
        'position',
        new THREE.BufferAttribute(tickVertices, 3)
      );
      
      const tickMaterial = new THREE.LineBasicMaterial({ color: 0x999999 });
      const tick = new THREE.Line(tickGeometry, tickMaterial);
      this.group.add(tick);
    }
  }
  
  createLabel(text, x, z, maxWidth) {
    // 使用 Canvas 创建文字纹理
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    canvas.width = 256;
    canvas.height = 64;
    
    context.fillStyle = '#333333';
    context.font = 'bold 24px Arial';
    context.textAlign = 'center';
    context.textBaseline = 'middle';
    
    // 文字换行处理
    this.wrapText(context, text, canvas.width / 2, canvas.height / 2, maxWidth * 20);
    
    const texture = new THREE.CanvasTexture(canvas);
    const material = new THREE.SpriteMaterial({
      map: texture,
      transparent: true
    });
    
    const sprite = new THREE.Sprite(material);
    sprite.position.set(x, -0.5, z);
    sprite.scale.set(2, 0.5, 1);
    
    this.labels.push(sprite);
    this.group.add(sprite);
  }
  
  wrapText(context, text, x, y, maxWidth) {
    const words = text.split('');
    let line = '';
    let testLine = '';
    let lineCount = 0;
    
    for (let i = 0; i < words.length; i++) {
      testLine = line + words[i];
      const metrics = context.measureText(testLine);
      
      if (metrics.width > maxWidth && i > 0) {
        context.fillText(line, x, y + lineCount * 30);
        line = words[i];
        lineCount++;
      } else {
        line = testLine;
      }
    }
    context.fillText(line, x, y + lineCount * 30);
  }
  
  createAxisLabels(maxValue) {
    const tickCount = 5;
    for (let i = 0; i <= tickCount; i++) {
      const value = (i / tickCount) * maxValue;
      const y = (i / tickCount) * this.config.height;
      
      this.createAxisLabel(value.toFixed(1), -this.config.width / 2 - 0.8, y);
    }
  }
  
  createAxisLabel(text, x, y) {
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    canvas.width = 128;
    canvas.height = 32;
    
    context.fillStyle = '#666666';
    context.font = '20px Arial';
    context.textAlign = 'right';
    context.textBaseline = 'middle';
    context.fillText(text, canvas.width - 10, canvas.height / 2);
    
    const texture = new THREE.CanvasTexture(canvas);
    const material = new THREE.SpriteMaterial({
      map: texture,
      transparent: true
    });
    
    const sprite = new THREE.Sprite(material);
    sprite.position.set(x, y, this.config.depth / 2);
    sprite.scale.set(1, 0.25, 1);
    
    this.labels.push(sprite);
    this.group.add(sprite);
  }
  
  highlight(index) {
    if (index >= 0 && index < this.bars.length) {
      this.bars[index].material.emissive = new THREE.Color(0x333333);
      this.bars[index].material.emissiveIntensity = 0.5;
    }
  }
  
  unhighlight(index) {
    if (index >= 0 && index < this.bars.length) {
      this.bars[index].material.emissive = new THREE.Color(0x000000);
      this.bars[index].material.emissiveIntensity = 0;
    }
  }
  
  updateData(newData) {
    // 带动画的数据更新
    const maxValue = Math.max(...newData.map(d => d.value));
    
    newData.forEach((item, index) => {
      if (this.bars[index]) {
        const targetHeight = (item.value / maxValue) * this.config.height;
        const bar = this.bars[index];
        
        new TWEEN.Tween({ height: bar.geometry.parameters.height })
          .to({ height: targetHeight }, 500)
          .easing(TWEEN.Easing.Quadratic.Out)
          .onUpdate((obj) => {
            bar.geometry.dispose();
            bar.geometry = new THREE.BoxGeometry(
              this.config.barWidth,
              obj.height,
              this.config.barWidth
            );
            bar.position.y = obj.height / 2;
          })
          .start();
      }
    });
  }
  
  clear() {
    this.bars.forEach(bar => {
      bar.geometry.dispose();
      bar.material.dispose();
      this.group.remove(bar);
    });
    this.bars = [];
    
    this.labels.forEach(label => {
      label.material.map.dispose();
      label.material.dispose();
      this.group.remove(label);
    });
    this.labels = [];
  }
  
  dispose() {
    this.clear();
    this.scene.remove(this.group);
  }
}

2. 散点图(ScatterPlot.js)

javascript
import * as THREE from 'three';

export class ScatterPlot {
  constructor(scene, config = {}) {
    this.scene = scene;
    this.config = {
      size: config.size || 10,
      pointSize: config.pointSize || 0.15,
      colors: config.colors || {
        low: '#3498db',
        medium: '#f39c12',
        high: '#e74c3c'
      },
      ...config
    };
    
    this.points = null;
    this.group = new THREE.Group();
    this.scene.add(this.group);
    
    this.createAxes();
  }
  
  createAxes() {
    const { size } = this.config;
    
    // X轴
    const xAxisGeometry = new THREE.BufferGeometry().setFromPoints([
      new THREE.Vector3(-size / 2, 0, 0),
      new THREE.Vector3(size / 2, 0, 0)
    ]);
    const xAxis = new THREE.Line(
      xAxisGeometry,
      new THREE.LineBasicMaterial({ color: 0x999999 })
    );
    this.group.add(xAxis);
    
    // Y轴
    const yAxisGeometry = new THREE.BufferGeometry().setFromPoints([
      new THREE.Vector3(0, -size / 2, 0),
      new THREE.Vector3(0, size / 2, 0)
    ]);
    const yAxis = new THREE.Line(
      yAxisGeometry,
      new THREE.LineBasicMaterial({ color: 0x999999 })
    );
    this.group.add(yAxis);
    
    // Z轴
    const zAxisGeometry = new THREE.BufferGeometry().setFromPoints([
      new THREE.Vector3(0, 0, -size / 2),
      new THREE.Vector3(0, 0, size / 2)
    ]);
    const zAxis = new THREE.Line(
      zAxisGeometry,
      new THREE.LineBasicMaterial({ color: 0x999999 })
    );
    this.group.add(zAxis);
  }
  
  setData(data, options = {}) {
    this.clear();
    
    const { size, pointSize, colors } = this.config;
    const xKey = options.x || 'x';
    const yKey = options.y || 'y';
    const zKey = options.z || 'z';
    const colorKey = options.color || 'value';
    
    // 计算数据范围
    const xExtent = this.getExtent(data, xKey);
    const yExtent = this.getExtent(data, yKey);
    const zExtent = this.getExtent(data, zKey);
    const colorExtent = this.getExtent(data, colorKey);
    
    // 创建点云几何体
    const geometry = new THREE.BufferGeometry();
    const positions = [];
    const colorsArray = [];
    
    data.forEach(point => {
      // 归一化坐标
      const x = this.normalize(point[xKey], xExtent, size);
      const y = this.normalize(point[yKey], yExtent, size);
      const z = this.normalize(point[zKey], zExtent, size);
      
      positions.push(x, y, z);
      
      // 计算颜色
      const normalizedValue = this.normalizeValue(point[colorKey], colorExtent);
      const color = this.getColor(normalizedValue, colors);
      colorsArray.push(color.r, color.g, color.b);
    });
    
    geometry.setAttribute(
      'position',
      new THREE.Float32BufferAttribute(positions, 3)
    );
    geometry.setAttribute(
      'color',
      new THREE.Float32BufferAttribute(colorsArray, 3)
    );
    
    // 创建点云材质
    const material = new THREE.PointsMaterial({
      size: pointSize,
      vertexColors: true,
      transparent: true,
      opacity: 0.8,
      sizeAttenuation: true
    });
    
    this.points = new THREE.Points(geometry, material);
    this.points.userData = {
      type: 'scatter',
      data: data
    };
    
    this.group.add(this.points);
  }
  
  getExtent(data, key) {
    const values = data.map(d => d[key]);
    return {
      min: Math.min(...values),
      max: Math.max(...values)
    };
  }
  
  normalize(value, extent, size) {
    return ((value - extent.min) / (extent.max - extent.min) - 0.5) * size;
  }
  
  normalizeValue(value, extent) {
    return (value - extent.min) / (extent.max - extent.min);
  }
  
  getColor(normalizedValue, colors) {
    // 根据值返回渐变颜色
    let color;
    if (normalizedValue < 0.33) {
      color = new THREE.Color(colors.low);
    } else if (normalizedValue < 0.66) {
      color = new THREE.Color(colors.medium);
    } else {
      color = new THREE.Color(colors.high);
    }
    
    return color;
  }
  
  clear() {
    if (this.points) {
      this.points.geometry.dispose();
      this.points.material.dispose();
      this.group.remove(this.points);
      this.points = null;
    }
  }
  
  dispose() {
    this.clear();
    this.scene.remove(this.group);
  }
}

3. 3D 地图(Map3D.js)

javascript
import * as THREE from 'three';

export class Map3D {
  constructor(scene, config = {}) {
    this.scene = scene;
    this.config = {
      scale: config.scale || 1,
      extrusion: config.extrusion || 0.5,
      color: config.color || 0x4a90e2,
      ...config
    };
    
    this.regions = [];
    this.group = new THREE.Group();
    this.scene.add(this.group);
  }
  
  async loadGeoJSON(url) {
    const response = await fetch(url);
    const geoJSON = await response.json();
    this.renderGeoJSON(geoJSON);
  }
  
  renderGeoJSON(geoJSON) {
    this.clear();
    
    const { scale, extrusion, color } = this.config;
    
    // 计算边界
    const bounds = this.calculateBounds(geoJSON);
    
    geoJSON.features.forEach((feature, index) => {
      const geometry = feature.geometry;
      const properties = feature.properties;
      
      if (geometry.type === 'Polygon') {
        this.createPolygon(geometry.coordinates, bounds, scale, extrusion, properties, index);
      } else if (geometry.type === 'MultiPolygon') {
        geometry.coordinates.forEach(coords => {
          this.createPolygon(coords, bounds, scale, extrusion, properties, index);
        });
      }
    });
  }
  
  calculateBounds(geoJSON) {
    let minLng = Infinity, maxLng = -Infinity;
    let minLat = Infinity, maxLat = -Infinity;
    
    const processCoordinates = (coords) => {
      if (typeof coords[0] === 'number') {
        minLng = Math.min(minLng, coords[0]);
        maxLng = Math.max(maxLng, coords[0]);
        minLat = Math.min(minLat, coords[1]);
        maxLat = Math.max(maxLat, coords[1]);
      } else {
        coords.forEach(c => processCoordinates(c));
      }
    };
    
    geoJSON.features.forEach(feature => {
      processCoordinates(feature.geometry.coordinates);
    });
    
    return { minLng, maxLng, minLat, maxLat };
  }
  
  createPolygon(coordinates, bounds, scale, extrusion, properties, index) {
    const shape = new THREE.Shape();
    const { minLng, maxLng, minLat, maxLat } = bounds;
    
    // 转换坐标
    const projectCoords = (coords) => {
      const x = ((coords[0] - minLng) / (maxLng - minLng) - 0.5) * scale;
      const z = ((coords[1] - minLat) / (maxLat - minLat) - 0.5) * scale;
      return [x, z];
    };
    
    // 外环
    const outerRing = coordinates[0];
    outerRing.forEach((coords, i) => {
      const [x, z] = projectCoords(coords);
      if (i === 0) {
        shape.moveTo(x, z);
      } else {
        shape.lineTo(x, z);
      }
    });
    
    // 内环(孔洞)
    for (let i = 1; i < coordinates.length; i++) {
      const hole = new THREE.Path();
      coordinates[i].forEach((coords, j) => {
        const [x, z] = projectCoords(coords);
        if (j === 0) {
          hole.moveTo(x, z);
        } else {
          hole.lineTo(x, z);
        }
      });
      shape.holes.push(hole);
    }
    
    // 挤出几何体
    const geometry = new THREE.ExtrudeGeometry(shape, {
      depth: extrusion,
      bevelEnabled: false
    });
    
    // 根据属性计算颜色或高度
    const regionColor = this.getRegionColor(properties, index);
    const material = new THREE.MeshPhongMaterial({
      color: regionColor,
      transparent: true,
      opacity: 0.9
    });
    
    const mesh = new THREE.Mesh(geometry, material);
    mesh.rotation.x = -Math.PI / 2;
    mesh.position.y = 0;
    
    mesh.userData = {
      type: 'region',
      properties: properties,
      index: index
    };
    
    this.regions.push(mesh);
    this.group.add(mesh);
  }
  
  getRegionColor(properties, index) {
    // 可以根据属性值映射颜色
    if (properties.value) {
      // 使用颜色映射
      return this.valueToColor(properties.value);
    }
    
    // 默认使用索引生成不同颜色
    const hue = (index * 0.1) % 1;
    return new THREE.Color().setHSL(hue, 0.7, 0.5);
  }
  
  valueToColor(value) {
    // 值到颜色的映射
    const normalizedValue = Math.min(Math.max(value, 0), 1);
    const hue = (1 - normalizedValue) * 0.7; // 从蓝到红
    return new THREE.Color().setHSL(hue, 0.8, 0.5);
  }
  
  highlightRegion(index) {
    if (this.regions[index]) {
      this.regions[index].material.emissive = new THREE.Color(0x333333);
      this.regions[index].material.emissiveIntensity = 0.5;
    }
  }
  
  unhighlightRegion(index) {
    if (this.regions[index]) {
      this.regions[index].material.emissive = new THREE.Color(0x000000);
      this.regions[index].material.emissiveIntensity = 0;
    }
  }
  
  clear() {
    this.regions.forEach(region => {
      region.geometry.dispose();
      region.material.dispose();
      this.group.remove(region);
    });
    this.regions = [];
  }
  
  dispose() {
    this.clear();
    this.scene.remove(this.group);
  }
}

4. 网络图(NetworkGraph.js)

javascript
import * as THREE from 'three';

export class NetworkGraph {
  constructor(scene, config = {}) {
    this.scene = scene;
    this.config = {
      nodeSize: config.nodeSize || 0.2,
      edgeWidth: config.edgeWidth || 0.02,
      nodeColor: config.nodeColor || '#4a90e2',
      edgeColor: config.edgeColor || '#cccccc',
      layout: config.layout || 'force', // 'force' | 'circular' | 'random'
      ...config
    };
    
    this.nodes = [];
    this.edges = [];
    this.nodeMeshes = [];
    this.edgeMeshes = [];
    
    this.group = new THREE.Group();
    this.scene.add(this.group);
  }
  
  setData(nodes, edges) {
    this.clear();
    
    this.nodes = nodes;
    this.edges = edges;
    
    // 计算布局
    this.calculateLayout();
    
    // 创建边
    this.createEdges();
    
    // 创建节点
    this.createNodes();
  }
  
  calculateLayout() {
    const { layout } = this.config;
    const count = this.nodes.length;
    
    switch (layout) {
      case 'circular':
        this.circularLayout(count);
        break;
      case 'force':
        this.forceLayout();
        break;
      case 'random':
      default:
        this.randomLayout(count);
    }
  }
  
  circularLayout(count) {
    const radius = 5;
    this.nodes.forEach((node, i) => {
      const angle = (i / count) * Math.PI * 2;
      node.position = {
        x: Math.cos(angle) * radius,
        y: Math.sin(angle) * radius,
        z: 0
      };
    });
  }
  
  randomLayout(count) {
    this.nodes.forEach(node => {
      node.position = {
        x: (Math.random() - 0.5) * 10,
        y: (Math.random() - 0.5) * 10,
        z: (Math.random() - 0.5) * 10
      };
    });
  }
  
  forceLayout() {
    // 简化的力导向布局
    // 实际项目中可以使用 d3-force 或其他力导向库
    
    // 初始化随机位置
    this.randomLayout(this.nodes.length);
    
    // 迭代优化
    const iterations = 100;
    for (let i = 0; i < iterations; i++) {
      this.forceIteration();
    }
  }
  
  forceIteration() {
    const repulsion = 1;
    const attraction = 0.01;
    
    // 节点间斥力
    this.nodes.forEach((node1, i) => {
      this.nodes.forEach((node2, j) => {
        if (i !== j) {
          const dx = node1.position.x - node2.position.x;
          const dy = node1.position.y - node2.position.y;
          const dz = node1.position.z - node2.position.z;
          const distance = Math.sqrt(dx * dx + dy * dy + dz * dz) || 0.1;
          
          const force = repulsion / (distance * distance);
          
          node1.position.x += (dx / distance) * force;
          node1.position.y += (dy / distance) * force;
          node1.position.z += (dz / distance) * force;
        }
      });
    });
    
    // 边的引力
    this.edges.forEach(edge => {
      const source = this.nodes.find(n => n.id === edge.source);
      const target = this.nodes.find(n => n.id === edge.target);
      
      if (source && target) {
        const dx = target.position.x - source.position.x;
        const dy = target.position.y - source.position.y;
        const dz = target.position.z - source.position.z;
        
        source.position.x += dx * attraction;
        source.position.y += dy * attraction;
        source.position.z += dz * attraction;
        
        target.position.x -= dx * attraction;
        target.position.y -= dy * attraction;
        target.position.z -= dz * attraction;
      }
    });
  }
  
  createNodes() {
    const { nodeSize, nodeColor } = this.config;
    
    this.nodes.forEach(node => {
      const geometry = new THREE.SphereGeometry(nodeSize, 16, 16);
      const material = new THREE.MeshPhongMaterial({
        color: node.color || nodeColor,
        transparent: true,
        opacity: 0.9
      });
      
      const mesh = new THREE.Mesh(geometry, material);
      mesh.position.set(
        node.position.x,
        node.position.y,
        node.position.z
      );
      
      mesh.userData = {
        type: 'node',
        data: node
      };
      
      this.nodeMeshes.push(mesh);
      this.group.add(mesh);
      
      // 添加标签
      if (node.label) {
        this.createNodeLabel(node);
      }
    });
  }
  
  createNodeLabel(node) {
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    canvas.width = 256;
    canvas.height = 64;
    
    context.fillStyle = '#333333';
    context.font = '24px Arial';
    context.textAlign = 'center';
    context.textBaseline = 'middle';
    context.fillText(node.label, canvas.width / 2, canvas.height / 2);
    
    const texture = new THREE.CanvasTexture(canvas);
    const material = new THREE.SpriteMaterial({
      map: texture,
      transparent: true
    });
    
    const sprite = new THREE.Sprite(material);
    sprite.position.set(
      node.position.x,
      node.position.y + this.config.nodeSize + 0.2,
      node.position.z
    );
    sprite.scale.set(1, 0.25, 1);
    
    this.group.add(sprite);
  }
  
  createEdges() {
    const { edgeWidth, edgeColor } = this.config;
    
    this.edges.forEach(edge => {
      const source = this.nodes.find(n => n.id === edge.source);
      const target = this.nodes.find(n => n.id === edge.target);
      
      if (source && target) {
        const points = [
          new THREE.Vector3(
            source.position.x,
            source.position.y,
            source.position.z
          ),
          new THREE.Vector3(
            target.position.x,
            target.position.y,
            target.position.z
          )
        ];
        
        const geometry = new THREE.BufferGeometry().setFromPoints(points);
        const material = new THREE.LineBasicMaterial({
          color: edge.color || edgeColor,
          transparent: true,
          opacity: 0.6
        });
        
        const line = new THREE.Line(geometry, material);
        line.userData = {
          type: 'edge',
          data: edge
        };
        
        this.edgeMeshes.push(line);
        this.group.add(line);
      }
    });
  }
  
  highlightNode(nodeId) {
    const index = this.nodes.findIndex(n => n.id === nodeId);
    if (index >= 0 && this.nodeMeshes[index]) {
      this.nodeMeshes[index].material.emissive = new THREE.Color(0x333333);
      this.nodeMeshes[index].material.emissiveIntensity = 0.5;
    }
  }
  
  unhighlightNode(nodeId) {
    const index = this.nodes.findIndex(n => n.id === nodeId);
    if (index >= 0 && this.nodeMeshes[index]) {
      this.nodeMeshes[index].material.emissive = new THREE.Color(0x000000);
      this.nodeMeshes[index].material.emissiveIntensity = 0;
    }
  }
  
  clear() {
    this.nodeMeshes.forEach(mesh => {
      mesh.geometry.dispose();
      mesh.material.dispose();
      this.group.remove(mesh);
    });
    this.nodeMeshes = [];
    
    this.edgeMeshes.forEach(mesh => {
      mesh.geometry.dispose();
      mesh.material.dispose();
      this.group.remove(mesh);
    });
    this.edgeMeshes = [];
  }
  
  dispose() {
    this.clear();
    this.scene.remove(this.group);
  }
}

5. 数据解析器(DataParser.js)

javascript
export class DataParser {
  static parseJSON(data) {
    if (typeof data === 'string') {
      return JSON.parse(data);
    }
    return data;
  }
  
  static parseCSV(csvString, options = {}) {
    const lines = csvString.trim().split('\n');
    const headers = lines[0].split(options.delimiter || ',');
    
    return lines.slice(1).map(line => {
      const values = line.split(options.delimiter || ',');
      const obj = {};
      
      headers.forEach((header, index) => {
        const value = values[index];
        // 尝试转换为数字
        obj[header.trim()] = isNaN(value) ? value.trim() : parseFloat(value);
      });
      
      return obj;
    });
  }
  
  static async loadFile(url, type = 'json') {
    const response = await fetch(url);
    const text = await response.text();
    
    switch (type) {
      case 'json':
        return this.parseJSON(text);
      case 'csv':
        return this.parseCSV(text);
      default:
        return text;
    }
  }
  
  static transformData(data, transform) {
    return data.map(item => {
      const transformed = {};
      
      for (const [key, value] of Object.entries(transform)) {
        if (typeof value === 'function') {
          transformed[key] = value(item);
        } else if (typeof value === 'string') {
          transformed[key] = item[value];
        }
      }
      
      return transformed;
    });
  }
  
  static aggregateData(data, groupBy, aggregate) {
    const groups = new Map();
    
    data.forEach(item => {
      const key = item[groupBy];
      
      if (!groups.has(key)) {
        groups.set(key, []);
      }
      
      groups.get(key).push(item);
    });
    
    const result = [];
    
    groups.forEach((items, key) => {
      const aggregated = { [groupBy]: key };
      
      for (const [field, operation] of Object.entries(aggregate)) {
        switch (operation) {
          case 'sum':
            aggregated[field] = items.reduce((sum, item) => sum + item[field], 0);
            break;
          case 'avg':
            aggregated[field] = items.reduce((sum, item) => sum + item[field], 0) / items.length;
            break;
          case 'count':
            aggregated[field] = items.length;
            break;
          case 'max':
            aggregated[field] = Math.max(...items.map(item => item[field]));
            break;
          case 'min':
            aggregated[field] = Math.min(...items.map(item => item[field]));
            break;
        }
      }
      
      result.push(aggregated);
    });
    
    return result;
  }
}

6. 主入口文件(main.js)

javascript
import * as THREE from 'three';
import { SceneManager } from './SceneManager.js';
import { BarChart } from './charts/BarChart.js';
import { ScatterPlot } from './charts/ScatterPlot.js';
import { Map3D } from './geo/Map3D.js';
import { NetworkGraph } from './network/NetworkGraph.js';
import { DataParser } from './utils/DataParser.js';

class DataVisualization {
  constructor() {
    this.container = document.getElementById('app');
    this.currentChart = null;
    
    this.init();
  }
  
  async init() {
    // 初始化场景
    this.sceneManager = new SceneManager(this.container);
    
    // 初始化图表
    this.charts = {
      bar: new BarChart(this.sceneManager.scene),
      scatter: new ScatterPlot(this.sceneManager.scene),
      map: new Map3D(this.sceneManager.scene),
      network: new NetworkGraph(this.sceneManager.scene)
    };
    
    // 初始化交互
    this.initInteraction();
    
    // 初始化UI
    this.initUI();
    
    // 加载默认数据
    await this.loadSampleData();
    
    // 开始渲染
    this.animate();
  }
  
  initInteraction() {
    this.raycaster = new THREE.Raycaster();
    this.mouse = new THREE.Vector2();
    
    this.container.addEventListener('mousemove', this.onMouseMove.bind(this));
    this.container.addEventListener('click', this.onClick.bind(this));
  }
  
  onMouseMove(event) {
    const rect = this.container.getBoundingClientRect();
    this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
    this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
  }
  
  onClick(event) {
    this.raycaster.setFromCamera(this.mouse, this.sceneManager.camera);
    
    const intersects = this.raycaster.intersectObjects(
      this.sceneManager.scene.children,
      true
    );
    
    if (intersects.length > 0) {
      const object = intersects[0].object;
      
      if (object.userData.data) {
        this.showDataInfo(object.userData.data);
      }
    }
  }
  
  initUI() {
    // 创建控制面板
    const controls = document.createElement('div');
    controls.id = 'controls';
    controls.innerHTML = `
      <div class="chart-selector">
        <button data-chart="bar">柱状图</button>
        <button data-chart="scatter">散点图</button>
        <button data-chart="map">地图</button>
        <button data-chart="network">网络图</button>
      </div>
      <div id="data-info" class="hidden"></div>
    `;
    
    this.container.appendChild(controls);
    
    // 绑定事件
    controls.querySelectorAll('button[data-chart]').forEach(button => {
      button.addEventListener('click', () => {
        this.switchChart(button.dataset.chart);
      });
    });
  }
  
  async loadSampleData() {
    // 柱状图数据
    const barData = [
      { label: '一月', value: 120 },
      { label: '二月', value: 150 },
      { label: '三月', value: 180 },
      { label: '四月', value: 220 },
      { label: '五月', value: 280 },
      { label: '六月', value: 350 }
    ];
    
    this.charts.bar.setData(barData);
    this.currentChart = 'bar';
  }
  
  switchChart(chartType) {
    // 清除当前图表
    Object.values(this.charts).forEach(chart => chart.clear());
    
    // 切换到新图表
    this.currentChart = chartType;
    
    switch (chartType) {
      case 'bar':
        this.loadBarData();
        break;
      case 'scatter':
        this.loadScatterData();
        break;
      case 'map':
        this.loadMapData();
        break;
      case 'network':
        this.loadNetworkData();
        break;
    }
  }
  
  loadBarData() {
    const data = [
      { label: 'A', value: 100 },
      { label: 'B', value: 150 },
      { label: 'C', value: 120 },
      { label: 'D', value: 180 }
    ];
    this.charts.bar.setData(data);
  }
  
  loadScatterData() {
    const data = [];
    for (let i = 0; i < 100; i++) {
      data.push({
        x: Math.random() * 100,
        y: Math.random() * 100,
        z: Math.random() * 100,
        value: Math.random() * 100
      });
    }
    this.charts.scatter.setData(data);
  }
  
  async loadMapData() {
    await this.charts.map.loadGeoJSON('/data/china.json');
  }
  
  loadNetworkData() {
    const nodes = [
      { id: '1', label: 'Node 1' },
      { id: '2', label: 'Node 2' },
      { id: '3', label: 'Node 3' },
      { id: '4', label: 'Node 4' },
      { id: '5', label: 'Node 5' }
    ];
    
    const edges = [
      { source: '1', target: '2' },
      { source: '1', target: '3' },
      { source: '2', target: '3' },
      { source: '3', target: '4' },
      { source: '4', target: '5' }
    ];
    
    this.charts.network.setData(nodes, edges);
  }
  
  showDataInfo(data) {
    const infoPanel = document.getElementById('data-info');
    infoPanel.classList.remove('hidden');
    infoPanel.innerHTML = `<pre>${JSON.stringify(data, null, 2)}</pre>`;
  }
  
  animate() {
    requestAnimationFrame(this.animate.bind(this));
    
    this.sceneManager.controls.update();
    this.sceneManager.renderer.render(
      this.sceneManager.scene,
      this.sceneManager.camera
    );
  }
  
  dispose() {
    Object.values(this.charts).forEach(chart => chart.dispose());
    this.sceneManager.dispose();
  }
}

// 启动应用
const app = new DataVisualization();

最佳实践

1. 性能优化

javascript
// 使用 InstancedMesh 渲染大量相同几何体
const geometry = new THREE.SphereGeometry(0.1, 8, 8);
const material = new THREE.MeshPhongMaterial();
const mesh = new THREE.InstancedMesh(geometry, material, 10000);

// 更新实例矩阵
const matrix = new THREE.Matrix4();
for (let i = 0; i < 10000; i++) {
  matrix.setPosition(Math.random() * 10, Math.random() * 10, Math.random() * 10);
  mesh.setMatrixAt(i, matrix);
}

// 使用 LOD 减少远处物体细节
const lod = new THREE.LOD();
lod.addLevel(highDetailGeometry, 0);
lod.addLevel(mediumDetailGeometry, 10);
lod.addLevel(lowDetailGeometry, 50);

2. 颜色映射

javascript
// 使用色阶映射数据值
class ColorScale {
  constructor(colors) {
    this.colors = colors.map(c => new THREE.Color(c));
  }
  
  getColor(value) {
    // value: 0-1
    const index = value * (this.colors.length - 1);
    const lower = Math.floor(index);
    const upper = Math.ceil(index);
    const t = index - lower;
    
    const color = new THREE.Color();
    color.lerpColors(this.colors[lower], this.colors[upper], t);
    
    return color;
  }
}

const scale = new ColorScale(['#3498db', '#f39c12', '#e74c3c']);
const color = scale.getColor(0.5); // 获取中间色

3. 数据更新动画

javascript
// 平滑过渡动画
function animateValue(from, to, duration, onUpdate) {
  const startTime = Date.now();
  
  function update() {
    const elapsed = Date.now() - startTime;
    const progress = Math.min(elapsed / duration, 1);
    const eased = easeOutCubic(progress);
    const current = from + (to - from) * eased;
    
    onUpdate(current);
    
    if (progress < 1) {
      requestAnimationFrame(update);
    }
  }
  
  update();
}

function easeOutCubic(t) {
  return 1 - Math.pow(1 - t, 3);
}

API 接口说明

BarChart API

方法参数返回值说明
constructor(scene, config)scene: THREE.Scene, config?: BarChartConfigBarChart创建柱状图实例
setData(data)data: BarData[]void设置图表数据
updateData(newData)newData: BarData[]void动画更新数据
highlight(index)index: numbervoid高亮指定柱子
unhighlight(index)index: numbervoid取消高亮
clear()-void清除图表
dispose()-void销毁实例

ScatterPlot API

方法参数返回值说明
setData(data, options)data: ScatterData[], options?: PlotOptionsvoid设置散点数据
clear()-void清除散点
dispose()-void销毁实例

Map3D API

方法参数返回值说明
loadGeoJSON(url)url: stringPromise<void>加载 GeoJSON 数据
renderGeoJSON(geoJSON)geoJSON: GeoJSONvoid渲染 GeoJSON
highlightRegion(index)index: numbervoid高亮区域
unhighlightRegion(index)index: numbervoid取消高亮

NetworkGraph API

方法参数返回值说明
setData(nodes, edges)nodes: Node[], edges: Edge[]void设置网络数据
highlightNode(nodeId)nodeId: stringvoid高亮节点
unhighlightNode(nodeId)nodeId: stringvoid取消高亮
clear()-void清除图形
dispose()-void销毁实例

配置参数详解

柱状图配置

javascript
const barChartConfig = {
  // 尺寸配置
  width: 10,           // 图表宽度
  height: 5,           // 图表高度
  depth: 10,           // 图表深度
  
  // 柱子配置
  barWidth: 0.8,       // 柱子宽度
  barGap: 0.2,         // 柱子间距
  
  // 样式配置
  colors: ['#4a90e2', '#50c878', '#f39c12', '#e74c3c'],
  
  // 功能开关
  animate: true,       // 启用入场动画
  showLabels: true,    // 显示标签
  showGrid: true,      // 显示网格
  
  // 交互配置
  hoverColor: 0xffff00,  // 悬停高亮色
  onClick: (bar, data) => {}  // 点击回调
};

散点图配置

javascript
const scatterPlotConfig = {
  // 尺寸配置
  size: 10,            // 坐标范围
  pointSize: 0.15,     // 点大小
  
  // 颜色配置
  colors: {
    low: '#3498db',    // 低值颜色
    medium: '#f39c12', // 中值颜色
    high: '#e74c3c'    // 高值颜色
  },
  
  // 渐变模式
  colorMode: 'gradient', // 'gradient' | 'category'
  
  // 交互配置
  enableTooltip: true,
  enableSelection: true
};

3D 地图配置

javascript
const map3DConfig = {
  // 尺寸配置
  scale: 1,            // 缩放比例
  extrusion: 0.5,      // 挤出高度
  
  // 样式配置
  color: 0x4a90e2,     // 基础颜色
  opacity: 0.9,        // 透明度
  
  // 颜色映射
  colorByValue: true,  // 根据数值映射颜色
  colorScale: ['#3498db', '#f39c12', '#e74c3c'],
  
  // 交互配置
  enableHighlight: true,
  enableTooltip: true
};

网络图配置

javascript
const networkGraphConfig = {
  // 节点配置
  nodeSize: 0.2,       // 节点大小
  nodeColor: '#4a90e2',// 节点颜色
  
  // 边配置
  edgeWidth: 0.02,     // 边宽度
  edgeColor: '#cccccc',// 边颜色
  
  // 布局配置
  layout: 'force',     // 'force' | 'circular' | 'random'
  
  // 力导向参数
  force: {
    repulsion: 1,      // 斥力强度
    attraction: 0.01,  // 引力强度
    iterations: 100    // 迭代次数
  },
  
  // 标签配置
  showLabels: true
};

使用示例

创建动态柱状图

javascript
const barChart = new BarChart(scene, {
  width: 8,
  height: 4,
  colors: ['#ff6b6b', '#4ecdc4', '#45b7d1']
});

// 设置初始数据
barChart.setData([
  { label: '产品A', value: 100 },
  { label: '产品B', value: 150 },
  { label: '产品C', value: 80 }
]);

// 动态更新数据
setInterval(() => {
  const newData = [
    { label: '产品A', value: Math.random() * 200 },
    { label: '产品B', value: Math.random() * 200 },
    { label: '产品C', value: Math.random() * 200 }
  ];
  barChart.updateData(newData);
}, 3000);

加载地理数据

javascript
const map3D = new Map3D(scene, {
  scale: 10,
  extrusion: 0.3,
  colorByValue: true
});

// 加载 GeoJSON
await map3D.loadGeoJSON('/data/china.json');

// 添加交互
map3D.regions.forEach((region, index) => {
  region.addEventListener('click', () => {
    showRegionDetail(region.userData.properties);
  });
});

创建交互式网络图

javascript
const networkGraph = new NetworkGraph(scene, {
  layout: 'force',
  nodeSize: 0.15
});

// 设置数据
networkGraph.setData(
  [
    { id: '1', label: '用户A', group: 1 },
    { id: '2', label: '用户B', group: 2 },
    { id: '3', label: '用户C', group: 1 }
  ],
  [
    { source: '1', target: '2', weight: 5 },
    { source: '1', target: '3', weight: 3 }
  ]
);

// 节点悬停效果
raycaster.onNodeHover((node) => {
  networkGraph.highlightNode(node.id);
  showTooltip(node.label);
});

常见问题解答

Q1: 大数据量时性能下降怎么办?

A: 推荐以下优化策略:

  1. 使用 InstancedMesh 渲染重复几何体
  2. 降低几何体精度,减少顶点数
  3. 使用 LOD 根据距离调整细节
  4. 分批加载 数据,避免一次性渲染
javascript
// 使用 InstancedMesh 渲染大量点
const geometry = new THREE.SphereGeometry(0.1, 8, 8);
const material = new THREE.MeshPhongMaterial();
const mesh = new THREE.InstancedMesh(geometry, material, 10000);

// 更新位置
const matrix = new THREE.Matrix4();
data.forEach((point, i) => {
  matrix.setPosition(point.x, point.y, point.z);
  mesh.setMatrixAt(i, matrix);
});
mesh.instanceMatrix.needsUpdate = true;

Q2: 如何实现数据平滑过渡动画?

A: 使用 Tween.js 或自定义插值:

javascript
// 使用 Tween.js
import * as TWEEN from '@tweenjs/tween.js';

function animateBarHeight(bar, fromHeight, toHeight) {
  new TWEEN.Tween({ height: fromHeight })
    .to({ height: toHeight }, 500)
    .easing(TWEEN.Easing.Quadratic.Out)
    .onUpdate((obj) => {
      bar.scale.y = obj.height / originalHeight;
      bar.position.y = obj.height / 2;
    })
    .start();
}

// 在渲染循环中更新 TWEEN
function animate() {
  requestAnimationFrame(animate);
  TWEEN.update();
  renderer.render(scene, camera);
}

Q3: 地图加载速度慢如何优化?

A: 优化建议:

  1. 简化 GeoJSON,减少坐标点数量
  2. 使用 TopoJSON 格式,文件更小
  3. 预计算边界,避免运行时计算
  4. 分块加载,按需渲染
javascript
// 简化 GeoJSON 坐标
function simplifyCoordinates(coords, tolerance = 0.001) {
  return simplify(coords, tolerance, true);
}

// 使用 Web Worker 后台处理
const worker = new Worker('geojson-processor.js');
worker.postMessage({ geoJSON, simplify: true });
worker.onmessage = (e) => {
  map3D.renderGeoJSON(e.data);
};

Q4: 颜色映射不准确怎么办?

A: 确保数据正确归一化并使用合适的色阶:

javascript
class ColorScale {
  constructor(colors, domain = [0, 1]) {
    this.colors = colors.map(c => new THREE.Color(c));
    this.domain = domain;
  }
  
  getColor(value) {
    // 归一化到 0-1
    const t = (value - this.domain[0]) / (this.domain[1] - this.domain[0]);
    const clampedT = Math.max(0, Math.min(1, t));
    
    // 插值计算颜色
    const index = clampedT * (this.colors.length - 1);
    const lower = Math.floor(index);
    const upper = Math.ceil(index);
    const blend = index - lower;
    
    const color = new THREE.Color();
    color.lerpColors(this.colors[lower], this.colors[upper], blend);
    return color;
  }
}

// 使用示例
const scale = new ColorScale(
  ['#3498db', '#f39c12', '#e74c3c'],
  [0, 1000]  // 数据范围
);
const color = scale.getColor(500); // 获取中间色

Q5: 如何实现图表响应式布局?

A: 监听容器尺寸变化并重新渲染:

javascript
// 使用 ResizeObserver 监听尺寸变化
const resizeObserver = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const { width, height } = entry.contentRect;
    
    // 更新相机
    camera.aspect = width / height;
    camera.updateProjectionMatrix();
    
    // 更新渲染器
    renderer.setSize(width, height);
    
    // 重新计算图表布局
    if (currentChart) {
      currentChart.resize(width, height);
    }
  }
});

resizeObserver.observe(container);

相关链接