数据可视化案例
概述
本章将介绍如何使用 Three.js 创建 3D 数据可视化应用,涵盖柱状图、散点图、地理可视化、网络图等常见可视化类型。适用于数据分析展示、业务大屏、科学可视化等场景。
案例目标
创建一个综合性的 3D 数据可视化平台,具备以下特性:
- 多种图表类型:柱状图、散点图、折线图、饼图
- 地理可视化:3D 地图、热力图、路径动画
- 网络图:节点关系可视化,支持力导向布局
- 交互功能:悬停提示、点击筛选、缩放平移
- 动画效果:数据变化动画、入场动画
- 数据导入:支持 JSON、CSV 数据格式
系统架构
plaintext
┌─────────────────────────────────────────────────────────────┐
│ 用户界面层 (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], ...]]
}
}
]
}项目结构
plaintext
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?: BarChartConfig | BarChart | 创建柱状图实例 |
setData(data) | data: BarData[] | void | 设置图表数据 |
updateData(newData) | newData: BarData[] | void | 动画更新数据 |
highlight(index) | index: number | void | 高亮指定柱子 |
unhighlight(index) | index: number | void | 取消高亮 |
clear() | - | void | 清除图表 |
dispose() | - | void | 销毁实例 |
ScatterPlot API
| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
setData(data, options) | data: ScatterData[], options?: PlotOptions | void | 设置散点数据 |
clear() | - | void | 清除散点 |
dispose() | - | void | 销毁实例 |
Map3D API
| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
loadGeoJSON(url) | url: string | Promise<void> | 加载 GeoJSON 数据 |
renderGeoJSON(geoJSON) | geoJSON: GeoJSON | void | 渲染 GeoJSON |
highlightRegion(index) | index: number | void | 高亮区域 |
unhighlightRegion(index) | index: number | void | 取消高亮 |
NetworkGraph API
| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
setData(nodes, edges) | nodes: Node[], edges: Edge[] | void | 设置网络数据 |
highlightNode(nodeId) | nodeId: string | void | 高亮节点 |
unhighlightNode(nodeId) | nodeId: string | void | 取消高亮 |
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: 推荐以下优化策略:
- 使用 InstancedMesh 渲染重复几何体
- 降低几何体精度,减少顶点数
- 使用 LOD 根据距离调整细节
- 分批加载 数据,避免一次性渲染
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: 优化建议:
- 简化 GeoJSON,减少坐标点数量
- 使用 TopoJSON 格式,文件更小
- 预计算边界,避免运行时计算
- 分块加载,按需渲染
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);