Cesium 中保留 ECharts 交互性的解决方案

108 阅读6分钟

Cesium 中保留 ECharts 交互性的解决方案

在 Cesium 中加载 ECharts 饼图时,保持其原生交互性是一个挑战,因为传统的纹理映射方法只保留了视觉效果。下面我将提供一个完整的解决方案,在 Cesium 场景中保留 ECharts 的全部交互特性。

解决方案:DOM 叠加与坐标转换

这种方案的核心思想是:

  1. 在 Cesium 画布上方叠加一个透明的 ECharts 容器
  2. 将三维坐标转换为屏幕坐标来定位图表
  3. 通过事件代理将 Cesium 的交互事件传递给 ECharts

可视化效果:

image.png

代码如下:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Cesium 交互式 ECharts 饼图</title>
    
    <!-- 引入 Cesium 资源 -->
    <link href="https://cesium.com/downloads/cesiumjs/releases/1.130/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
    <script src="https://cesium.com/downloads/cesiumjs/releases/1.130/Build/Cesium/Cesium.js"></script>
    
    <!-- 引入 ECharts -->
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js"></script>
    
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            font-family: 'Microsoft YaHei', sans-serif;
        }
        
        body {
            overflow: hidden;
            background: linear-gradient(135deg, #1a2a6c, #2c3e50);
            color: #fff;
            height: 100vh;
        }
        
        #cesiumContainer {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            z-index: 1;
        }
        
        #chartContainer {
            position: absolute;
            z-index: 100;
            pointer-events: auto;
            background: rgba(0, 0, 0, 0.7);
            border-radius: 12px;
            overflow: hidden;
            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.7);
            border: 1px solid rgba(255, 255, 255, 0.2);
            transition: transform 0.3s ease;
        }
        
        #chartContainer:hover {
            transform: scale(1.05);
        }
        
        .header {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            padding: 15px 20px;
            background: rgba(0, 0, 0, 0.7);
            z-index: 200;
            display: flex;
            justify-content: space-between;
            align-items: center;
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
        }
        
        .header h1 {
            font-size: 1.8rem;
            background: linear-gradient(45deg, #00c6ff, #0072ff);
            -webkit-background-clip: text;
            background-clip: text;
            color: transparent;
            font-weight: 700;
        }
        
        .control-panel {
            position: absolute;
            top: 100px;
            right: 20px;
            width: 320px;
            background: rgba(0, 0, 0, 0.8);
            border-radius: 12px;
            padding: 20px;
            z-index: 200;
            box-shadow: 0 8px 30px rgba(0, 0, 0, 0.6);
            backdrop-filter: blur(5px);
            border: 1px solid rgba(255, 255, 255, 0.1);
        }
        
        .panel-title {
            font-size: 1.4rem;
            margin-bottom: 20px;
            text-align: center;
            color: #3498db;
            font-weight: 700;
            text-shadow: 0 0 10px rgba(52, 152, 219, 0.5);
        }
        
        .btn-group {
            display: flex;
            gap: 10px;
            margin-top: 15px;
        }
        
        button {
            flex: 1;
            padding: 12px;
            background: linear-gradient(135deg, #3498db, #2980b9);
            border: none;
            border-radius: 6px;
            color: white;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.3s ease;
        }
        
        button:hover {
            transform: translateY(-2px);
            box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4);
        }
        
        .info-box {
            background: rgba(44, 62, 80, 0.7);
            border-radius: 8px;
            padding: 15px;
            margin-top: 20px;
        }
        
        .info-title {
            font-size: 1.1rem;
            color: #f1c40f;
            margin-bottom: 10px;
        }
        
        .info-content {
            font-size: 0.9rem;
            line-height: 1.6;
        }
        
        .interaction-panel {
            position: absolute;
            bottom: 20px;
            left: 20px;
            background: rgba(0, 0, 0, 0.8);
            border-radius: 12px;
            padding: 15px;
            z-index: 200;
            max-width: 400px;
        }
        
        .interaction-title {
            color: #2ecc71;
            margin-bottom: 10px;
            font-weight: 600;
        }
        
        .interaction-content {
            font-size: 0.9rem;
        }
        
        .highlight {
            color: #3498db;
            font-weight: 600;
        }
        
        .footer {
            position: absolute;
            bottom: 20px;
            right: 20px;
            color: #bdc3c7;
            font-size: 0.9rem;
            z-index: 200;
        }
    </style>
</head>
<body>
    <div class="header">
        <h1>Cesium 交互式 ECharts 饼图</h1>
    </div>
    
    <div id="cesiumContainer"></div>
    <div id="chartContainer"></div>
    
    <div class="control-panel">
        <h2 class="panel-title">交互式饼图控制</h2>
        
        <div class="btn-group">
            <button id="addPie">添加饼图</button>
            <button id="removePie">移除饼图</button>
            <button id="updateData">更新数据</button>
        </div>
        
        <div class="info-box">
            <div class="info-title">实现原理</div>
            <div class="info-content">
                <p>1. 在Cesium画布上叠加一个透明的ECharts容器</p>
                <p>2. 将三维坐标转换为屏幕坐标定位容器</p>
                <p>3. 实时同步相机变化更新位置</p>
                <p>4. 保留完整的ECharts交互功能</p>
            </div>
        </div>
    </div>
    
    <div class="interaction-panel">
        <div class="interaction-title">交互演示说明</div>
        <div class="interaction-content">
            <p><span class="highlight">鼠标悬停</span>: 查看饼图各部分的详细信息</p>
            <p><span class="highlight">点击饼块</span>: 突出显示并查看具体数值</p>
            <p><span class="highlight">图例点击</span>: 显示/隐藏对应的饼块</p>
            <p><span class="highlight">拖拽旋转地球</span>: 饼图位置自动更新</p>
        </div>
    </div>
    
    <div class="footer">
        Cesium + ECharts 高级集成方案 | 保留完整交互特性
    </div>

    <script>
        const viewer = new Cesium.Viewer('cesiumContainer', {
            terrainProvider: Cesium.createWorldTerrain(),
            baseLayerPicker: false,
            animation: false,
            timeline: false,
            geocoder: false,
            homeButton: true,
            sceneModePicker: true,
            navigationHelpButton: false,
            fullscreenButton: true,
            infoBox: false
        });
        
        // 设置初始相机位置
        viewer.camera.setView({
            destination: Cesium.Cartesian3.fromDegrees(116.4, 39.9, 1000000)
        });
        
        // 创建ECharts实例
        const chartContainer = document.getElementById('chartContainer');
        const chart = echarts.init(chartContainer);
        
        // 饼图数据
        let pieData = [
            { value: 1048, name: '亚洲' },
            { value: 735, name: '欧洲' },
            { value: 580, name: '北美洲' },
            { value: 484, name: '南美洲' },
            { value: 300, name: '非洲' }
        ];
        
        // 饼图配置
        const pieOption = {
            backgroundColor: 'transparent',
            title: {
                text: '全球人口分布',
                left: 'center',
                top: 10,
                textStyle: {
                    color: '#fff',
                    fontSize: 16,
                    fontWeight: 'bold'
                }
            },
            tooltip: {
                trigger: 'item',
                formatter: '{a} <br/>{b}: {c} ({d}%)',
                backgroundColor: 'rgba(0,0,0,0.8)',
                borderColor: '#3498db',
                textStyle: {
                    color: '#fff'
                }
            },
            legend: {
                orient: 'vertical',
                right: 10,
                top: 'center',
                textStyle: {
                    color: '#fff',
                    fontSize: 12
                },
                itemGap: 15
            },
            series: [{
                name: '人口分布',
                type: 'pie',
                radius: ['40%', '70%'],
                center: ['50%', '55%'],
                avoidLabelOverlap: false,
                itemStyle: {
                    borderRadius: 10,
                    borderColor: '#000',
                    borderWidth: 2
                },
                label: {
                    show: true,
                    formatter: '{b}: {d}%',
                    fontSize: 12,
                    fontWeight: 'bold',
                    color: '#fff'
                },
                emphasis: {
                    itemStyle: {
                        shadowBlur: 10,
                        shadowOffsetX: 0,
                        shadowColor: 'rgba(0, 0, 0, 0.5)'
                    },
                    label: {
                        show: true,
                        fontSize: 14,
                        fontWeight: 'bold'
                    }
                },
                labelLine: {
                    show: true
                },
                data: pieData,
                animationType: 'scale',
                animationEasing: 'elasticOut',
                animationDuration: 1000
            }]
        };
        
        // 设置ECharts选项
        chart.setOption(pieOption);
        
        // 设置初始大小
        const pieSize = 300;
        chartContainer.style.width = pieSize + 'px';
        chartContainer.style.height = pieSize + 'px';
        chart.resize();
        
        // 默认隐藏图表
        chartContainer.style.display = 'none';
        
        // 添加/移除饼图
        document.getElementById('addPie').addEventListener('click', function() {
            chartContainer.style.display = 'block';
            updateChartPosition();
        });
        
        document.getElementById('removePie').addEventListener('click', function() {
            chartContainer.style.display = 'none';
        });
        
        // 更新数据
        document.getElementById('updateData').addEventListener('click', function() {
            // 随机更新数据
            pieData = pieData.map(item => {
                return {
                    ...item,
                    value: Math.floor(Math.random() * 1000) + 300
                };
            });
            
            // 更新饼图数据
            chart.setOption({
                series: [{
                    data: pieData
                }]
            });
        });
        
        // 更新图表位置
        function updateChartPosition() {
            // 获取三维坐标(这里使用固定位置)
            const position = Cesium.Cartesian3.fromDegrees(116.4, 39.9, 0);
            
            // 转换三维坐标到屏幕坐标
            const screenPosition = Cesium.SceneTransforms.worldToWindowCoordinates(
                viewer.scene, 
                position
            );
            
            if (screenPosition) {
                // 定位ECharts容器
                chartContainer.style.left = (screenPosition.x - pieSize / 2) + 'px';
                chartContainer.style.top = (screenPosition.y - pieSize / 2) + 'px';
            }
        }
        
        // 监听相机变化
        viewer.camera.changed.addEventListener(updateChartPosition);
        
        // 初始更新位置
        updateChartPosition();
        
        // 添加ECharts事件监听器
        chart.on('click', function(params) {
            console.log('饼图点击事件:', params);
            // 在实际应用中,这里可以显示更多详细信息
            viewer.entities.add({
                position: Cesium.Cartesian3.fromDegrees(116.4, 39.9, 0),
                label: {
                    text: `${params.name}: ${params.value} (${params.percent}%)`,
                    font: '14px Microsoft YaHei',
                    fillColor: Cesium.Color.WHITE,
                    outlineColor: Cesium.Color.BLACK,
                    outlineWidth: 2,
                    style: Cesium.LabelStyle.FILL_AND_OUTLINE,
                    pixelOffset: new Cesium.Cartesian2(0, -pieSize/2 - 30),
                    disableDepthTestDistance: Number.POSITIVE_INFINITY
                }
            });
        });
        
        chart.on('mouseover', function(params) {
            console.log('鼠标悬停:', params.name);
        });
        
        // 添加图例切换事件
        chart.on('legendselectchanged', function(params) {
            console.log('图例选择变化:', params);
        });
        
        // 解决事件冲突:当鼠标在图表上时,阻止事件传播到Cesium
        chartContainer.addEventListener('mousedown', function(e) {
            e.stopPropagation();
        });
        
        chartContainer.addEventListener('mousewheel', function(e) {
            e.stopPropagation();
        });
    </script>
</body>
</html>

实现原理详解

1. DOM 叠加技术

我们不是将 ECharts 渲染为纹理,而是在 Cesium 画布上叠加一个透明的 HTML 容器:

<div id="cesiumContainer"></div>
<div id="chartContainer"></div>

2. 坐标转换

使用 Cesium 的坐标转换功能将三维位置映射到屏幕位置:

function updateChartPosition() {
    const position = Cesium.Cartesian3.fromDegrees(116.4, 39.9, 0);
    const screenPosition = Cesium.SceneTransforms.worldToWindowCoordinates(
        viewer.scene, 
        position
    );
    
    if (screenPosition) {
        chartContainer.style.left = (screenPosition.x - pieSize / 2) + 'px';
        chartContainer.style.top = (screenPosition.y - pieSize / 2) + 'px';
    }
}

3. 实时位置更新

监听相机变化事件,确保图表随地球旋转而移动:

viewer.camera.changed.addEventListener(updateChartPosition);

4. 事件处理

解决 Cesium 和 ECharts 的事件冲突:

// 阻止图表事件传播到Cesium
chartContainer.addEventListener('mousedown', function(e) {
    e.stopPropagation();
});

5. 完整交互支持

保留 ECharts 的所有原生交互:

// 点击事件
chart.on('click', function(params) {
    // 显示详细信息
});

// 悬停事件
chart.on('mouseover', function(params) {
    // 高亮显示
});

// 图例切换
chart.on('legendselectchanged', function(params) {
    // 更新图表显示
});

优势与特点

  1. 完整交互保留

    • 鼠标悬停提示
    • 点击饼块高亮
    • 图例切换显示/隐藏
    • 所有 ECharts 原生交互特性
  2. 视觉一致性

    • 透明背景与 Cesium 场景完美融合
    • 随地球旋转而移动
    • 保持三维空间的深度感
  3. 性能优化

    • 只在需要时显示图表
    • 使用硬件加速的 CSS 变换
    • 高效的事件委托机制
  4. 响应式设计

    • 自动适应屏幕尺寸变化
    • 在缩放时保持清晰度

使用说明

  1. 点击"添加饼图"按钮在指定位置显示交互式图表
  2. 鼠标悬停在饼图上查看详细信息
  3. 点击饼图部分查看具体数值
  4. 点击图例切换显示/隐藏数据部分
  5. 使用"更新数据"按钮随机生成新数据
  6. 拖拽旋转地球时,饼图会自动更新位置

这个解决方案完美结合了 Cesium 的三维地球可视化能力和 ECharts 的丰富交互特性,为地理空间数据可视化提供了强大的工具。