ECharts GL 3D相机控制完全指南
文档类型: 深度技术文档
难度等级: ⭐⭐⭐⭐
源码版本: ECharts-GL 2.x
本文行数: 约520行
📋 目录
🎯 viewControl基础配置
完整配置示例
typescript
const option = {
grid3D: {
viewControl: {
// === 投影方式 ===
projection: 'perspective', // 'perspective' | 'orthographic'
// === 视角参数 ===
distance: 200, // 视距 (相机到目标距离)
alpha: 30, // 垂直旋转角度 (俯仰角) [-90, 90]
beta: 45, // 水平旋转角度 (方位角) [0, 360]
center: [0, 0, 0], // 目标中心点
// === 视角限制 ===
minAlpha: -90, // 最小俯仰角
maxAlpha: 90, // 最大俯仰角
minBeta: 0, // 最小方位角
maxBeta: 360, // 最大方位角
minDistance: 50, // 最小视距
maxDistance: 500, // 最大视距
// === 交互灵敏度 ===
rotateSensitivity: 1, // 旋转灵敏度
zoomSensitivity: 1, // 缩放灵敏度
panSensitivity: 1, // 平移灵敏度
// === 阻尼效果 ===
damping: 0.8, // 阻尼系数 [0, 1]
rotateMouseButton: 'left', // 旋转鼠标按键
panMouseButton: 'middle', // 平移鼠标按键
// === 自动旋转 ===
autoRotate: false,
autoRotateSpeed: 10, // 旋转速度 (秒/圈)
autoRotateAfterStill: 3, // 静止后多久开始旋转 (秒)
// === 鼠标控制开关 ===
mouseController: {
rotate: true, // 允许旋转
zoom: true, // 允许缩放
pan: true // 允许平移
},
// === 触摸控制 ===
touchController: {
rotate: true,
zoom: true,
pan: true
}
}
},
series: [{
type: 'scatter3D',
data: [[0, 0, 0], [1, 1, 1]]
}]
};1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
🖱️ 视角交互控制
透视投影 vs 正交投影
typescript
// 透视投影 (近大远小,真实感强)
const perspectiveOption = {
grid3D: {
viewControl: {
projection: 'perspective',
distance: 200
}
}
};
// 正交投影 (无透视变形,工程制图)
const orthographicOption = {
grid3D: {
viewControl: {
projection: 'orthographic',
orthographicSize: 150 // 正交视图大小
}
}
};1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
对比:
| 特性 | 透视投影 | 正交投影 |
|---|---|---|
| 真实感 | ✅ 强 | ❌ 弱 |
| 尺寸准确性 | ❌ 近大远小 | ✅ 保持一致 |
| 适用场景 | 可视化展示 | 工程设计 |
编程式视角控制
typescript
class CameraController {
private chart: echarts.ECharts;
constructor(container: HTMLElement) {
this.chart = echarts.init(container);
}
/**
* 设置视角
*/
setView(distance?: number, alpha?: number, beta?: number) {
this.chart.setOption({
grid3D: {
viewControl: {
...(distance !== undefined && { distance }),
...(alpha !== undefined && { alpha }),
...(beta !== undefined && { beta })
}
}
});
}
/**
* 俯视视角
*/
topView() {
this.setView(200, 90, 0);
}
/**
* 正视视角
*/
frontView() {
this.setView(200, 0, 0);
}
/**
* 侧视视角
*/
sideView() {
this.setView(200, 0, 90);
}
/**
* 等轴测视角
*/
isometricView() {
this.setView(200, 35.264, 45);
}
/**
* 重置视角
*/
resetView() {
this.setView(200, 30, 45);
}
}
// 使用
const controller = new CameraController(document.getElementById('chart')!);
// 绑定按钮
document.getElementById('top-btn')?.addEventListener('click', () => {
controller.topView();
});
document.getElementById('front-btn')?.addEventListener('click', () => {
controller.frontView();
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
🎬 动画与过渡效果
平滑视角切换
typescript
class SmoothCameraTransition {
private chart: echarts.ECharts;
private currentView: { distance: number; alpha: number; beta: number };
constructor(container: HTMLElement) {
this.chart = echarts.init(container);
this.currentView = { distance: 200, alpha: 30, beta: 45 };
}
/**
* 带动画切换到目标视角
*/
animateTo(targetView: Partial<typeof this.currentView>, duration: number = 1000) {
const startView = { ...this.currentView };
const target = { ...this.currentView, ...targetView };
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// 缓动函数 (easeInOutCubic)
const eased = progress < 0.5
? 4 * progress * progress * progress
: 1 - Math.pow(-2 * progress + 2, 3) / 2;
// 插值计算
this.currentView.distance = this.lerp(startView.distance, target.distance, eased);
this.currentView.alpha = this.lerp(startView.alpha, target.alpha, eased);
this.currentView.beta = this.lerp(startView.beta, target.beta, eased);
// 更新视角
this.chart.setOption({
grid3D: {
viewControl: {
distance: this.currentView.distance,
alpha: this.currentView.alpha,
beta: this.currentView.beta
}
}
});
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
private lerp(start: number, end: number, t: number): number {
return start + (end - start) * t;
}
/**
* 预设视角动画
*/
presetViews() {
// 鸟瞰视角
setTimeout(() => {
this.animateTo({ distance: 300, alpha: 80, beta: 0 }, 1500);
}, 0);
// 平视视角
setTimeout(() => {
this.animateTo({ distance: 200, alpha: 10, beta: 0 }, 1500);
}, 2000);
// 环绕视角
setTimeout(() => {
this.animateTo({ distance: 250, alpha: 30, beta: 180 }, 2000);
}, 4000);
}
}
// 使用
const transition = new SmoothCameraTransition(document.getElementById('chart')!);
transition.presetViews();1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
自动漫游路径
typescript
class CameraRoaming {
private chart: echarts.ECharts;
private path: Array<{ distance: number; alpha: number; beta: number }>;
private currentIndex: number = 0;
private animationId: number | null = null;
constructor(container: HTMLElement, path: any[]) {
this.chart = echarts.init(container);
this.path = path;
}
/**
* 开始漫游
*/
start(speed: number = 3000) {
this.playKeyframe(this.currentIndex, speed);
}
private playKeyframe(index: number, duration: number) {
if (index >= this.path.length) {
index = 0; // 循环播放
}
this.currentIndex = index;
const target = this.path[index];
this.animateTo(target, duration, () => {
this.playKeyframe(index + 1, duration);
});
}
private animateTo(target: any, duration: number, onComplete?: () => void) {
const startView = this.getCurrentView();
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = this.easeInOutQuad(progress);
const view = {
distance: this.lerp(startView.distance, target.distance, eased),
alpha: this.lerp(startView.alpha, target.alpha, eased),
beta: this.lerp(startView.beta, target.beta, eased)
};
this.chart.setOption({
grid3D: {
viewControl: {
...view,
animation: false // 禁用内置动画
}
}
});
if (progress < 1) {
this.animationId = requestAnimationFrame(animate);
} else {
onComplete?.();
}
};
this.animationId = requestAnimationFrame(animate);
}
private easeInOutQuad(t: number): number {
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
}
private lerp(start: number, end: number, t: number): number {
return start + (end - start) * t;
}
private getCurrentView() {
// 获取当前视角 (简化实现,实际应从chart读取)
return { distance: 200, alpha: 30, beta: 45 };
}
stop() {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
dispose() {
this.stop();
this.chart.dispose();
}
}
// 定义漫游路径
const roamingPath = [
{ distance: 200, alpha: 30, beta: 0 },
{ distance: 250, alpha: 45, beta: 90 },
{ distance: 300, alpha: 60, beta: 180 },
{ distance: 250, alpha: 45, beta: 270 },
{ distance: 200, alpha: 30, beta: 360 }
];
// 使用
const roaming = new CameraRoaming(document.getElementById('chart')!, roamingPath);
roaming.start(3000); // 每个关键帧3秒1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
🛠️ 自定义相机行为
限定视角范围
typescript
const restrictedOption = {
grid3D: {
viewControl: {
// 限制只能水平旋转
minAlpha: 30,
maxAlpha: 30,
// 限制旋转范围
minBeta: 0,
maxBeta: 180,
// 限制缩放范围
minDistance: 150,
maxDistance: 300
}
}
};1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
禁用特定交互
typescript
const interactionOption = {
grid3D: {
viewControl: {
// 只允许缩放,不允许旋转和平移
mouseController: {
rotate: false,
zoom: true,
pan: false
},
// 禁用触摸
touchController: {
rotate: false,
zoom: true,
pan: false
}
}
}
};1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
自定义鼠标行为
typescript
const customMouseOption = {
grid3D: {
viewControl: {
// 右键旋转
rotateMouseButton: 'right',
// 中键平移
panMouseButton: 'middle',
// 滚轮缩放
zoomSensitivity: 2 // 提高缩放灵敏度
}
}
};1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
💻 实战案例
案例1: 产品展示360°旋转
typescript
class ProductViewer {
private chart: echarts.ECharts;
private isAutoRotating: boolean = false;
constructor(container: HTMLElement, productData: any) {
this.chart = echarts.init(container);
this.renderProduct(productData);
this.setupControls();
}
private renderProduct(data: any) {
const option = {
grid3D: {
boxWidth: 200,
boxDepth: 200,
viewControl: {
distance: 250,
alpha: 20,
beta: 0,
autoRotate: false,
autoRotateSpeed: 20,
damping: 0.9
}
},
series: [{
type: 'surface',
data: data,
shading: 'realistic',
realisticMaterial: {
roughness: 0.4,
metalness: 0.7
}
}]
};
this.chart.setOption(option);
}
private setupControls() {
// 自动旋转按钮
document.getElementById('autorotate-btn')?.addEventListener('click', () => {
this.toggleAutoRotate();
});
// 重置视角
document.getElementById('reset-btn')?.addEventListener('click', () => {
this.resetView();
});
}
private toggleAutoRotate() {
this.isAutoRotating = !this.isAutoRotating;
this.chart.setOption({
grid3D: {
viewControl: {
autoRotate: this.isAutoRotating
}
}
});
}
private resetView() {
this.chart.setOption({
grid3D: {
viewControl: {
distance: 250,
alpha: 20,
beta: 0
}
}
});
}
dispose() {
this.chart.dispose();
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
案例2: 数据探索器 (键盘控制)
typescript
class DataExplorer {
private chart: echarts.ECharts;
private keys: Set<string> = new Set();
constructor(container: HTMLElement) {
this.chart = echarts.init(container);
this.setupKeyboardControls();
}
private setupKeyboardControls() {
window.addEventListener('keydown', (e) => {
this.keys.add(e.key.toLowerCase());
});
window.addEventListener('keyup', (e) => {
this.keys.delete(e.key.toLowerCase());
});
// 游戏循环
this.updateLoop();
}
private updateLoop() {
const speed = 5;
let changed = false;
const currentView = this.getCurrentView();
const newView = { ...currentView };
// WASD控制旋转
if (this.keys.has('w')) {
newView.alpha += speed;
changed = true;
}
if (this.keys.has('s')) {
newView.alpha -= speed;
changed = true;
}
if (this.keys.has('a')) {
newView.beta -= speed;
changed = true;
}
if (this.keys.has('d')) {
newView.beta += speed;
changed = true;
}
// QE控制缩放
if (this.keys.has('q')) {
newView.distance -= speed * 2;
changed = true;
}
if (this.keys.has('e')) {
newView.distance += speed * 2;
changed = true;
}
if (changed) {
this.chart.setOption({
grid3D: {
viewControl: newView
}
});
}
requestAnimationFrame(() => this.updateLoop());
}
private getCurrentView() {
return { distance: 200, alpha: 30, beta: 45 };
}
dispose() {
this.chart.dispose();
}
}
// 使用
const explorer = new DataExplorer(document.getElementById('chart')!);
console.log('使用WASD旋转,QE缩放');1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
🎯 最佳实践总结
✅ DO - 推荐做法
根据场景选择投影
typescript// 数据可视化 → 透视投影 projection: 'perspective' // 工程制图 → 正交投影 projection: 'orthographic'1
2
3
4
5添加阻尼提升体验
typescriptdamping: 0.8 // 平滑惯性运动1提供视角重置功能
typescript// 始终让用户能回到默认视角 resetView() { this.setView(200, 30, 45); }1
2
3
4
❌ DON'T - 避免做法
避免过度限制视角
typescript// ❌ 不好 - 限制太死 minAlpha: 30, maxAlpha: 30 // 无法调整俯仰角 // ✅ 好 - 合理限制 minAlpha: -60, maxAlpha: 901
2
3
4
5避免灵敏度过高
typescript// ❌ 不好 - 太敏感 rotateSensitivity: 5 // ✅ 好 - 适中 rotateSensitivity: 11
2
3
4
5
🔗 相关资源
✅ 3D-可视化模块完成!
