返回笔记首页

ECharts 复杂图表实现完整指南

主题配置

一、自定义图表类型

1.1 简历描述模板

项目经验:业务指标可视化系统 - 自定义图表开发

负责开发业务指标可视化系统中的自定义图表组件,实现了漏斗分析图、桑基图、关系图等 10+ 复杂图表类型。通过自定义渲染逻辑和交互行为,满足了业务部门的特殊可视化需求。核心工作包括:

  • 基于 ECharts 自定义系列类型,实现了业务特色的漏斗转化图,支持多阶段转化率计算和异常节点标注
  • 开发了用户行为路径桑基图,可视化展示 100 万+ 用户的行为流转,通过数据聚合和渐进式渲染解决了性能瓶颈
  • 实现了组织架构关系图,支持节点拖拽、层级展开收起、关系线高亮等交互功能
  • 封装了图表配置生成器,将复杂的业务逻辑转换为 ECharts 配置,降低了使用门槛

1.2 SOP 标准回答

面试官:你做过哪些复杂的自定义图表?

我们当时有个业务指标分析的需求,产品经理希望能看到用户从注册到下单整个流程的转化情况,还要能标注出每个环节的流失原因。标准的漏斗图满足不了这个需求,所以我就基于 ECharts 自定义了一个增强版的漏斗转化图。

首先我分析了业务需求,发现关键是要展示两类信息:一是各阶段的转化数据,二是流失节点的原因分析。我的设计思路是用漏斗图展示主流程,在流失率高的节点旁边用气泡图展示流失原因分布。

技术实现上,我使用了 ECharts 的 custom 系列类型。在 renderItem 函数里,我自己计算每个漏斗块的形状和位置,还添加了渐变色和阴影效果。对于流失原因气泡,我用了另一个 custom 系列,根据流失占比动态计算气泡大小和位置。

有个技术难点是交互逻辑。用户点击某个漏斗块时,要高亮显示相关的流失原因气泡,同时右侧面板要展示详细数据。我通过监听图表的 click 事件,结合 ECharts 的 dispatchAction API 实现了这个效果。

另外为了提升性能,我对数据做了预处理和缓存。因为流失原因数据量很大,如果每次都重新计算会很慢。我在数据加载时就把所有计算结果缓存起来,图表交互时直接读缓存,响应速度提升了好几倍。

这个图表上线后,业务团队反馈说终于能直观地看到转化问题在哪里了,比之前的表格和简单图表有用多了。后来我又基于这个思路做了好几个自定义图表,都得到了不错的反馈。

面试官:桑基图如何处理大数据量的性能问题?

我们的桑基图要展示上百万用户的行为路径,一开始直接渲染,浏览器直接卡死了。我从两个方向来优化这个问题。

第一个是数据层面的优化。我做了几件事:

  1. 数据聚合 - 把相同路径的用户合并统计,原本 100 万条数据聚合后只剩下几千条
  2. 阈值过滤 - 只展示用户量大于某个阈值的路径,小流量路径归到"其他"类别
  3. 层级限制 - 默认只展示前 3 层节点,更深的层级通过点击展开

第二个是渲染层面的优化。我实现了渐进式渲染:

  1. 首次加载只渲染主干路径,用 loading 效果占位
  2. 然后用 requestIdleCallback 在浏览器空闲时逐步渲染其他路径
  3. 对于视口外的节点延迟渲染,滚动到可视区域时再加载

还有一个关键优化是使用 Canvas 模式而不是 SVG。因为节点和连线数量太多,SVG 的 DOM 操作成本很高。切换到 Canvas 后,渲染性能提升了 5 倍以上。

最后我还加了一个智能采样功能。当用户缩小视图时,自动降低渲染精度,提升交互流畅度;放大时再渲染完整细节。

经过这些优化,原本需要 10 秒才能加载完的图表,现在 1 秒内就能展示出来,而且交互也很流畅。

1.3 难点与亮点分析

难点 1:自定义渲染逻辑的复杂性

问题:Custom 系列需要手动计算每个图形元素的位置、大小、样式,代码复杂度高,容易出错。

解决方案:

  • 封装了图形计算工具库,提供常用图形的位置和尺寸计算方法
  • 使用坐标转换工具,在数据坐标和像素坐标之间快速转换
  • 实现了图形组件库,预定义常用图形的渲染逻辑

技术亮点:

  • 抽象出可复用的渲染单元,降低自定义图表的开发成本
  • 使用装饰器模式扩展图形功能,保持代码清晰
  • 实现了图形状态管理,支持悬浮、选中等交互状态
难点 2:复杂交互逻辑的实现

问题:需要实现节点拖拽、关系线高亮、层级展开等复杂交互,而 ECharts 原生支持有限。

解决方案:

  • 使用 ECharts 的事件系统,监听鼠标和触摸事件
  • 实现了自定义的拖拽管理器,处理拖拽开始、移动、结束的完整流程
  • 通过 graphic 组件实现辅助元素,如拖拽手柄、展开按钮等

技术亮点:

  • 实现了事件委托机制,提升大量节点时的事件处理性能
  • 支持多点触控,适配移动端操作
  • 实现了操作历史记录,支持撤销和重做
难点 3:数据转换与配置生成

问题:业务数据结构复杂,需要转换为 ECharts 能识别的格式,转换逻辑容易出错。

解决方案:

  • 实现了数据适配器模式,针对不同数据源提供专门的转换器
  • 使用数据校验机制,在转换前后进行数据完整性检查
  • 提供了可视化的配置生成器,降低配置复杂度

技术亮点:

  • 支持数据管道模式,可以串联多个转换步骤
  • 实现了配置模板系统,快速生成常用配置
  • 提供了配置调试工具,方便定位问题

1.4 完整技术实现

自定义漏斗转化图

vue
<!-- CustomFunnelChart.vue -->
<template>
    <div class="custom-funnel-chart">
        <BaseChart
            ref="chartRef"
            type="custom"
            :data="chartData"
            :options="chartOptions"
            @click="handleChartClick"
            height="500px"
        />

        <!-- 详情面板 -->
        <div v-if="selectedStage" class="detail-panel">
            <h3>{{ selectedStage.name }} 详细数据</h3>
            <div class="metrics">
                <div class="metric-item">
                    <span>进入人数</span>
                    <strong>{{ selectedStage.value.toLocaleString() }}</strong>
                </div>
                <div class="metric-item">
                    <span>转化率</span>
                    <strong>{{ selectedStage.rate }}%</strong>
                </div>
                <div class="metric-item">
                    <span>流失人数</span>
                    <strong>{{ selectedStage.loss.toLocaleString() }}</strong>
                </div>
            </div>

            <h4>主要流失原因</h4>
            <div class="reason-list">
                <div
                    v-for="reason in selectedStage.reasons"
                    :key="reason.name"
                    class="reason-item"
                >
                    <span>{{ reason.name }}</span>
                    <div class="reason-bar">
                        <div
                            class="reason-fill"
                            :style="{ width: reason.percent + '%' }"
                        ></div>
                    </div>
                    <strong>{{ reason.percent }}%</strong>
                </div>
            </div>
        </div>
    </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import BaseChart from './BaseChart.vue'

const chartRef = ref(null)
const selectedStage = ref(null)

// 原始数据
const funnelData = ref([
    {
        name: '访问首页',
        value: 100000,
        reasons: [
            { name: '页面加载慢', count: 8000, percent: 40 },
            { name: '内容不吸引', count: 6000, percent: 30 },
            { name: '其他', count: 6000, percent: 30 },
        ],
    },
    {
        name: '浏览商品',
        value: 80000,
        reasons: [
            { name: '商品不符合预期', count: 12000, percent: 50 },
            { name: '价格太高', count: 6000, percent: 25 },
            { name: '其他', count: 6000, percent: 25 },
        ],
    },
    {
        name: '加入购物车',
        value: 56000,
        reasons: [
            { name: '犹豫不决', count: 10000, percent: 45 },
            { name: '对比其他平台', count: 7000, percent: 32 },
            { name: '其他', count: 5000, percent: 23 },
        ],
    },
    {
        name: '提交订单',
        value: 34000,
        reasons: [
            { name: '支付流程复杂', count: 5000, percent: 50 },
            { name: '没有优惠券', count: 3000, percent: 30 },
            { name: '其他', count: 2000, percent: 20 },
        ],
    },
    {
        name: '完成支付',
        value: 24000,
        reasons: [],
    },
])

// 计算转化数据
const chartData = computed(() => {
    const stages = funnelData.value.map((item, index) => {
        const prevValue =
            index > 0 ? funnelData.value[index - 1].value : item.value
        const rate = ((item.value / prevValue) * 100).toFixed(1)
        const loss = prevValue - item.value

        return {
            ...item,
            rate,
            loss,
        }
    })

    return { stages }
})

// 图表配置
const chartOptions = computed(() => {
    return {
        title: {
            text: '用户转化漏斗分析',
            left: 'center',
            textStyle: {
                fontSize: 18,
                fontWeight: 'bold',
            },
        },
        tooltip: {
            trigger: 'item',
            formatter: (params) => {
                if (params.componentSubType === 'custom') {
                    const data = params.data
                    return `
            <div style="padding: 8px;">
              <div style="font-weight: bold; margin-bottom: 8px;">${data.name}</div>
              <div>进入人数: ${data.value.toLocaleString()}</div>
              <div>转化率: ${data.rate}%</div>
              <div>流失人数: ${data.loss.toLocaleString()}</div>
            </div>
          `
                }
                return ''
            },
        },
        xAxis: {
            type: 'value',
            show: false,
            min: 0,
            max: 100,
        },
        yAxis: {
            type: 'category',
            show: false,
            data: chartData.value.stages.map((s) => s.name),
        },
        series: [
            // 漏斗主体
            {
                type: 'custom',
                name: '转化漏斗',
                renderItem: renderFunnelItem,
                encode: {
                    x: [0, 1],
                    y: 2,
                },
                data: chartData.value.stages.map((stage, index) => ({
                    ...stage,
                    index,
                })),
                z: 2,
            },
            // 流失原因气泡
            {
                type: 'custom',
                name: '流失原因',
                renderItem: renderReasonBubbles,
                encode: {
                    x: 0,
                    y: 1,
                },
                data: generateReasonData(),
                z: 3,
            },
        ],
    }
})

// 渲染漏斗项
const renderFunnelItem = (params, api) => {
    const index = api.value('index')
    const total = chartData.value.stages.length
    const yIndex = index
    const value = api.value('value')
    const maxValue = chartData.value.stages[0].value

    // 计算位置和尺寸
    const coordSys = params.coordSys
    const width = (value / maxValue) * 80 // 宽度按比例
    const height = 15 // 固定高度
    const centerX = 50
    const centerY = (yIndex + 0.5) * (100 / total)

    const x = centerX - width / 2
    const y = centerY - height / 2

    // 创建梯形路径
    const topWidth = width
    const bottomWidth =
        index < total - 1
            ? (chartData.value.stages[index + 1].value / maxValue) * 80
            : width

    const points = [
        [x, y],
        [x + topWidth, y],
        [x + (topWidth - bottomWidth) / 2 + bottomWidth, y + height],
        [x + (topWidth - bottomWidth) / 2, y + height],
    ]

    const pointsInPixel = points.map((p) => api.coord([p[0], yIndex]))

    // 渐变色
    const colorStops = [
        { offset: 0, color: '#5470c6' },
        { offset: 1, color: '#91cc75' },
    ]

    return {
        type: 'polygon',
        shape: {
            points: pointsInPixel,
        },
        style: {
            fill: {
                type: 'linear',
                x: 0,
                y: 0,
                x2: 0,
                y2: 1,
                colorStops: colorStops,
            },
            stroke: '#fff',
            lineWidth: 2,
        },
        emphasis: {
            style: {
                fill: {
                    type: 'linear',
                    x: 0,
                    y: 0,
                    x2: 0,
                    y2: 1,
                    colorStops: [
                        { offset: 0, color: '#7aa3ff' },
                        { offset: 1, color: '#b5e7a0' },
                    ],
                },
            },
        },
    }
}

// 生成流失原因数据
const generateReasonData = () => {
    const reasonData = []
    chartData.value.stages.forEach((stage, stageIndex) => {
        if (stage.reasons && stage.reasons.length > 0) {
            stage.reasons.forEach((reason, reasonIndex) => {
                reasonData.push({
                    stageIndex,
                    stageName: stage.name,
                    reasonName: reason.name,
                    count: reason.count,
                    percent: reason.percent,
                    reasonIndex,
                })
            })
        }
    })
    return reasonData
}

// 渲染流失原因气泡
const renderReasonBubbles = (params, api) => {
    const stageIndex = api.value('stageIndex')
    const reasonIndex = api.value('reasonIndex')
    const percent = api.value('percent')
    const reasonName = api.value('reasonName')

    const total = chartData.value.stages.length

    // 计算气泡位置
    const baseX = 85 // 右侧位置
    const baseY = (stageIndex + 0.5) * (100 / total)
    const offsetY = (reasonIndex - 1) * 8 // 垂直偏移

    const point = api.coord([baseX, stageIndex])
    const radius = Math.sqrt(percent) * 2 // 根据占比计算半径

    return {
        type: 'circle',
        shape: {
            cx: point[0],
            cy: point[1] + offsetY,
            r: radius,
        },
        style: {
            fill: 'rgba(238, 102, 102, 0.6)',
            stroke: '#ee6666',
            lineWidth: 1,
        },
        emphasis: {
            style: {
                fill: 'rgba(238, 102, 102, 0.9)',
            },
        },
        textContent: {
            type: 'text',
            style: {
                text: reasonName,
                fill: '#fff',
                fontSize: 10,
            },
        },
        textConfig: {
            position: 'inside',
        },
    }
}

// 处理图表点击
const handleChartClick = (params) => {
    if (
        params.componentSubType === 'custom' &&
        params.seriesName === '转化漏斗'
    ) {
        selectedStage.value = params.data
    }
}
</script>

<style scoped>
.custom-funnel-chart {
    display: flex;
    gap: 20px;
    padding: 20px;
}

.detail-panel {
    flex: 0 0 300px;
    padding: 20px;
    background: #f5f5f5;
    border-radius: 8px;
    max-height: 500px;
    overflow-y: auto;
}

.detail-panel h3 {
    margin: 0 0 16px 0;
    color: #333;
    font-size: 16px;
}

.detail-panel h4 {
    margin: 20px 0 12px 0;
    color: #666;
    font-size: 14px;
}

.metrics {
    display: flex;
    flex-direction: column;
    gap: 12px;
}

.metric-item {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 12px;
    background: white;
    border-radius: 4px;
}

.metric-item span {
    color: #666;
    font-size: 14px;
}

.metric-item strong {
    color: #1890ff;
    font-size: 18px;
}

.reason-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.reason-item {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px;
    background: white;
    border-radius: 4px;
}

.reason-item span {
    flex: 0 0 100px;
    font-size: 12px;
    color: #666;
}

.reason-bar {
    flex: 1;
    height: 20px;
    background: #f0f0f0;
    border-radius: 10px;
    overflow: hidden;
}

.reason-fill {
    height: 100%;
    background: linear-gradient(90deg, #ff6b6b, #ff8787);
    transition: width 0.3s;
}

.reason-item strong {
    flex: 0 0 50px;
    text-align: right;
    font-size: 12px;
    color: #ee6666;
}
</style>

二、地图下钻联动

2.1 简历描述模板

项目经验:全国销售数据地图可视化系统

负责开发全国销售数据地图可视化系统,实现了从全国 → 省 → 市三级地图下钻联动功能。通过动态加载地图数据和智能缓存策略,支持 34 个省份、300+ 城市的地图切换,平均响应时间控制在 300ms 以内。核心实现包括:

  • 设计了地图下钻的状态管理机制,支持面包屑导航和一键返回上级地图
  • 实现了地图数据的按需加载和本地缓存,首次加载后无需重复请求
  • 开发了数据聚合展示功能,省级地图自动汇总下属城市数据
  • 实现了地图与图表的联动,点击地图区域同步更新右侧统计图表

2.2 SOP 标准回答

面试官:地图下钻功能是如何实现的?

我们做的是一个全国销售数据的地图系统,需要支持从全国地图点击某个省,然后钻取到省地图,再点击某个市,钻取到市地图。这里面有几个关键技术点。

首先是地图数据的加载。如果一次性加载全国所有城市的地图数据,文件太大,页面加载会很慢。我的方案是按需加载:初始只加载全国地图,用户点击某个省时才去加载这个省的地图数据。为了避免重复加载,我加了一个缓存层,已加载过的地图数据会缓存在内存里。

其次是状态管理。我设计了一个地图堆栈来管理当前的地图层级。每次下钻时,把当前地图信息压入堆栈;返回上级时,从堆栈弹出。这样就能实现任意层级的前进和后退,还能生成面包屑导航。

然后是数据的关联。每个层级的地图需要展示对应粒度的数据。比如省地图要展示各市的销售数据,市地图要展示各区县的数据。我在数据结构设计时就考虑了这个问题,使用了树形结构,可以根据当前地图区域代码快速查找对应的子节点数据。

还有一个细节是地图的高亮和联动。用户鼠标移到某个区域时,这个区域高亮,同时右侧的图表也要高亮对应的数据系列。我通过监听地图的 mouseover 事件,获取当前区域信息后,通过事件总线通知其他图表组件进行联动。

最后是性能优化。因为有些省份的城市很多,比如广东有 21 个地级市,如果每个城市都加载完整的地图数据会很慢。我做了两个优化:一是使用简化版的地图数据,精度降低一点但文件小很多;二是实现了视口裁剪,只渲染当前可视区域的地图元素。

面试官:地图数据是从哪里获取的?

地图数据我们用的是 ECharts 官方的 GeoJSON 数据。全国地图和各省地图可以从 DataV.GeoAtlas 网站下载,这是阿里提供的一个地理数据服务。

但是这些数据有个问题,就是太大了。一个省的 GeoJSON 文件可能有几百 KB,如果不处理的话页面加载会很慢。我做了几个优化:

  1. 数据简化 - 使用 Mapshaper 工具对 GeoJSON 进行简化,保留主要的地理特征但减小文件大小,能压缩到原来的 30% 左右
  2. 数据分包 - 把地图数据按省份拆分,每个省一个独立的文件,需要时才加载
  3. CDN 加速 - 把地图数据放到 CDN 上,利用边缘节点加速数据传输
  4. 本地缓存 - 使用浏览器的 LocalStorage 缓存已加载的地图数据,下次访问直接读缓存

还有一点要注意的是坐标系统。我们的业务数据使用的是 WGS84 坐标系,但 GeoJSON 默认是 GCJ02 坐标系(火星坐标),需要做坐标转换才能准确标点。我用了一个叫 coordtransform 的库来处理这个问题。

2.3 完整技术实现

vue
<!-- MapDrilldown.vue -->
<template>
    <div class="map-drilldown">
        <!-- 工具栏 -->
        <div class="toolbar">
            <!-- 面包屑导航 -->
            <div class="breadcrumb">
                <span
                    v-for="(item, index) in breadcrumb"
                    :key="index"
                    :class="[
                        'breadcrumb-item',
                        { active: index === breadcrumb.length - 1 },
                    ]"
                    @click="backToLevel(index)"
                >
                    {{ item.name }}
                    <span v-if="index < breadcrumb.length - 1" class="separator"
                        >/</span
                    >
                </span>
            </div>

            <!-- 返回按钮 -->
            <button v-if="canGoBack" class="back-btn" @click="goBack">
                返回上级
            </button>
        </div>

        <!-- 地图容器 -->
        <div class="map-container">
            <BaseChart
                ref="mapChartRef"
                type="map"
                :data="mapData"
                :options="mapOptions"
                @click="handleMapClick"
                height="600px"
                :loading="isLoading"
            />
        </div>

        <!-- 数据面板 -->
        <div class="data-panel">
            <h3>{{ currentRegion.name }} 销售数据</h3>
            <div class="stats">
                <div class="stat-item">
                    <span>总销售额</span>
                    <strong>{{
                        formatNumber(currentRegion.totalSales)
                    }}</strong>
                </div>
                <div class="stat-item">
                    <span>订单数</span>
                    <strong>{{
                        formatNumber(currentRegion.orderCount)
                    }}</strong>
                </div>
                <div class="stat-item">
                    <span>增长率</span>
                    <strong
                        :class="
                            currentRegion.growthRate >= 0
                                ? 'positive'
                                : 'negative'
                        "
                    >
                        {{ currentRegion.growthRate >= 0 ? '+' : ''
                        }}{{ currentRegion.growthRate }}%
                    </strong>
                </div>
            </div>

            <!-- 区域排行 -->
            <div class="ranking">
                <h4>销售额排行</h4>
                <div
                    v-for="(item, index) in topRegions"
                    :key="item.code"
                    class="ranking-item"
                >
                    <span class="rank">{{ index + 1 }}</span>
                    <span class="name">{{ item.name }}</span>
                    <span class="value">{{ formatNumber(item.value) }}</span>
                </div>
            </div>
        </div>
    </div>
</template>

<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import BaseChart from './BaseChart.vue'
import * as echarts from 'echarts'
import axios from 'axios'

const mapChartRef = ref(null)
const isLoading = ref(false)

// 地图堆栈
const mapStack = ref([
    {
        level: 'country',
        code: '100000',
        name: '中国',
        mapName: 'china',
    },
])

// 当前地图
const currentMap = computed(() => mapStack.value[mapStack.value.length - 1])

// 面包屑
const breadcrumb = computed(() => mapStack.value)

// 是否可以返回
const canGoBack = computed(() => mapStack.value.length > 1)

// 地图数据缓存
const mapCache = new Map()

// 业务数据缓存
const businessDataCache = new Map()

// 模拟业务数据
const mockBusinessData = {
    100000: {
        // 中国
        totalSales: 15000000000,
        orderCount: 5000000,
        growthRate: 15.5,
        children: {
            110000: { name: '北京市', value: 800000000, code: '110000' },
            120000: { name: '天津市', value: 500000000, code: '120000' },
            310000: { name: '上海市', value: 1200000000, code: '310000' },
            440000: { name: '广东省', value: 2000000000, code: '440000' },
            330000: { name: '浙江省', value: 1500000000, code: '330000' },
        },
    },
    440000: {
        // 广东省
        totalSales: 2000000000,
        orderCount: 800000,
        growthRate: 18.2,
        children: {
            440100: { name: '广州市', value: 600000000, code: '440100' },
            440300: { name: '深圳市', value: 800000000, code: '440300' },
            440600: { name: '佛山市', value: 300000000, code: '440600' },
            441300: { name: '惠州市', value: 150000000, code: '441300' },
            441900: { name: '东莞市', value: 150000000, code: '441900' },
        },
    },
}

// 当前区域数据
const currentRegion = computed(() => {
    const code = currentMap.value.code
    return (
        businessDataCache.get(code) ||
        mockBusinessData[code] || {
            totalSales: 0,
            orderCount: 0,
            growthRate: 0,
        }
    )
})

// 区域排行
const topRegions = computed(() => {
    const children = currentRegion.value.children
    if (!children) return []

    return Object.values(children)
        .sort((a, b) => b.value - a.value)
        .slice(0, 10)
})

// 地图数据
const mapData = computed(() => {
    const children = currentRegion.value.children
    if (!children) return []

    return Object.values(children).map((item) => ({
        name: item.name,
        value: item.value,
        code: item.code,
    }))
})

// 地图配置
const mapOptions = computed(() => {
    return {
        title: {
            text: `${currentMap.value.name}销售数据地图`,
            left: 'center',
            textStyle: {
                fontSize: 18,
                fontWeight: 'bold',
            },
        },
        tooltip: {
            trigger: 'item',
            formatter: (params) => {
                if (params.data) {
                    return `
            <div style="padding: 8px;">
              <div style="font-weight: bold; margin-bottom: 4px;">${params.name}</div>
              <div>销售额: ${formatNumber(params.value)}</div>
            </div>
          `
                }
                return params.name
            },
        },
        visualMap: {
            min: 0,
            max: Math.max(...mapData.value.map((d) => d.value || 0)),
            text: ['高', '低'],
            realtime: false,
            calculable: true,
            inRange: {
                color: ['#e0f3ff', '#3ba272', '#5470c6', '#ee6666'],
            },
            left: 'left',
            bottom: '20px',
        },
        geo: {
            map: currentMap.value.mapName,
            roam: true,
            scaleLimit: {
                min: 1,
                max: 5,
            },
            label: {
                show: true,
                fontSize: 10,
                color: '#333',
            },
            emphasis: {
                label: {
                    show: true,
                    fontSize: 12,
                    fontWeight: 'bold',
                },
                itemStyle: {
                    areaColor: '#ffd700',
                    borderColor: '#fff',
                    borderWidth: 2,
                },
            },
            itemStyle: {
                areaColor: '#f3f3f3',
                borderColor: '#999',
                borderWidth: 0.5,
            },
        },
        series: [
            {
                name: '销售数据',
                type: 'map',
                map: currentMap.value.mapName,
                geoIndex: 0,
                data: mapData.value,
            },
        ],
    }
})

// 加载地图数据
const loadMapData = async (mapName, code) => {
    // 检查缓存
    if (mapCache.has(mapName)) {
        return mapCache.get(mapName)
    }

    isLoading.value = true

    try {
        // 实际项目中这里应该从服务器加载地图数据
        // 这里模拟一个异步加载过程
        await new Promise((resolve) => setTimeout(resolve, 500))

        // 模拟地图数据(实际应该是 GeoJSON 格式)
        const mapData = {
            type: 'FeatureCollection',
            features: [],
        }

        // 注册地图
        echarts.registerMap(mapName, mapData)

        // 缓存地图数据
        mapCache.set(mapName, mapData)

        return mapData
    } catch (error) {
        console.error('加载地图数据失败:', error)
        throw error
    } finally {
        isLoading.value = false
    }
}

// 加载业务数据
const loadBusinessData = async (code) => {
    // 检查缓存
    if (businessDataCache.has(code)) {
        return businessDataCache.get(code)
    }

    try {
        // 实际项目中这里应该从服务器加载业务数据
        const data = mockBusinessData[code] || {
            totalSales: 0,
            orderCount: 0,
            growthRate: 0,
            children: {},
        }

        // 缓存业务数据
        businessDataCache.set(code, data)

        return data
    } catch (error) {
        console.error('加载业务数据失败:', error)
        throw error
    }
}

// 处理地图点击
const handleMapClick = async (params) => {
    if (!params.data) return

    const clickedCode = params.data.code
    const clickedName = params.name

    // 检查是否有下一级
    const children = currentRegion.value.children
    if (!children || !children[clickedCode]) {
        console.log('没有下一级数据')
        return
    }

    // 确定下一级的地图名称
    let nextMapName
    let nextLevel

    if (currentMap.value.level === 'country') {
        // 从国家到省
        nextMapName = getProvinceMapName(clickedCode)
        nextLevel = 'province'
    } else if (currentMap.value.level === 'province') {
        // 从省到市
        nextMapName = getCityMapName(clickedCode)
        nextLevel = 'city'
    } else {
        // 已经是最底层
        return
    }

    // 加载地图数据
    await loadMapData(nextMapName, clickedCode)

    // 加载业务数据
    await loadBusinessData(clickedCode)

    // 压入地图堆栈
    mapStack.value.push({
        level: nextLevel,
        code: clickedCode,
        name: clickedName,
        mapName: nextMapName,
    })
}

// 返回上级
const goBack = () => {
    if (canGoBack.value) {
        mapStack.value.pop()
    }
}

// 返回指定层级
const backToLevel = (index) => {
    if (index < mapStack.value.length - 1) {
        mapStack.value = mapStack.value.slice(0, index + 1)
    }
}

// 获取省份地图名称
const getProvinceMapName = (code) => {
    const provinceMap = {
        110000: 'beijing',
        120000: 'tianjin',
        310000: 'shanghai',
        440000: 'guangdong',
        330000: 'zhejiang',
        // ... 其他省份
    }
    return provinceMap[code] || code
}

// 获取城市地图名称
const getCityMapName = (code) => {
    // 实际项目中需要完整的城市代码映射
    return code
}

// 格式化数字
const formatNumber = (num) => {
    if (!num) return '0'
    if (num >= 100000000) {
        return (num / 100000000).toFixed(2) + '亿'
    }
    if (num >= 10000) {
        return (num / 10000).toFixed(2) + '万'
    }
    return num.toLocaleString()
}

// 初始化
onMounted(async () => {
    // 加载全国地图
    await loadMapData('china', '100000')
    // 加载全国业务数据
    await loadBusinessData('100000')
})

// 监听地图切换
watch(
    currentMap,
    async (newMap, oldMap) => {
        if (newMap.code !== oldMap?.code) {
            // 确保地图数据已加载
            if (!mapCache.has(newMap.mapName)) {
                await loadMapData(newMap.mapName, newMap.code)
            }
            // 确保业务数据已加载
            if (!businessDataCache.has(newMap.code)) {
                await loadBusinessData(newMap.code)
            }
        }
    },
    { deep: true }
)
</script>

<style scoped>
.map-drilldown {
    display: flex;
    flex-direction: column;
    height: 100vh;
    padding: 20px;
    background: #f5f5f5;
}

.toolbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 16px;
    background: white;
    border-radius: 8px;
    margin-bottom: 20px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.breadcrumb {
    display: flex;
    align-items: center;
    gap: 8px;
}

.breadcrumb-item {
    color: #1890ff;
    cursor: pointer;
    transition: color 0.3s;
}

.breadcrumb-item:hover {
    color: #40a9ff;
}

.breadcrumb-item.active {
    color: #333;
    cursor: default;
    font-weight: bold;
}

.separator {
    margin: 0 4px;
    color: #999;
}

.back-btn {
    padding: 8px 16px;
    border: 1px solid #d9d9d9;
    background: white;
    border-radius: 4px;
    cursor: pointer;
    transition: all 0.3s;
}

.back-btn:hover {
    border-color: #40a9ff;
    color: #40a9ff;
}

.map-container {
    flex: 1;
    background: white;
    border-radius: 8px;
    padding: 20px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    margin-bottom: 20px;
}

.data-panel {
    background: white;
    border-radius: 8px;
    padding: 20px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.data-panel h3 {
    margin: 0 0 16px 0;
    color: #333;
    font-size: 18px;
}

.stats {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 16px;
    margin-bottom: 24px;
}

.stat-item {
    padding: 16px;
    background: #f5f5f5;
    border-radius: 4px;
    text-align: center;
}

.stat-item span {
    display: block;
    color: #666;
    font-size: 14px;
    margin-bottom: 8px;
}

.stat-item strong {
    display: block;
    color: #1890ff;
    font-size: 24px;
    font-weight: bold;
}

.stat-item strong.positive {
    color: #52c41a;
}

.stat-item strong.negative {
    color: #ff4d4f;
}

.ranking h4 {
    margin: 0 0 12px 0;
    color: #666;
    font-size: 14px;
}

.ranking-item {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 8px;
    background: #fafafa;
    border-radius: 4px;
    margin-bottom: 8px;
}

.ranking-item .rank {
    flex: 0 0 24px;
    width: 24px;
    height: 24px;
    line-height: 24px;
    text-align: center;
    background: #1890ff;
    color: white;
    border-radius: 50%;
    font-size: 12px;
}

.ranking-item .name {
    flex: 1;
    color: #333;
    font-size: 14px;
}

.ranking-item .value {
    flex: 0 0 100px;
    text-align: right;
    color: #1890ff;
    font-weight: bold;
    font-size: 14px;
}
</style>

三、3D 可视化

3.1 3D 地球数据可视化

vue
<!-- Globe3D.vue -->
<template>
    <div class="globe-3d">
        <div class="controls">
            <button @click="toggleAutoRotate">
                {{ autoRotate ? '停止旋转' : '自动旋转' }}
            </button>
            <button @click="resetView">重置视角</button>
            <label>
                光照强度
                <input
                    type="range"
                    min="0"
                    max="2"
                    step="0.1"
                    v-model.number="lightIntensity"
                />
            </label>
        </div>

        <BaseChart
            ref="chartRef"
            type="custom"
            :data="{}"
            :options="chartOptions"
            height="600px"
        />
    </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import BaseChart from './BaseChart.vue'
import 'echarts-gl'

const chartRef = ref(null)
const autoRotate = ref(true)
const lightIntensity = ref(1)

// 模拟全球城市数据
const cityData = [
    { name: '北京', coords: [116.4074, 39.9042], value: 95 },
    { name: '上海', coords: [121.4737, 31.2304], value: 90 },
    { name: '纽约', coords: [-74.006, 40.7128], value: 100 },
    { name: '伦敦', coords: [-0.1276, 51.5074], value: 85 },
    { name: '东京', coords: [139.6917, 35.6895], value: 92 },
    { name: '巴黎', coords: [2.3522, 48.8566], value: 88 },
    { name: '悉尼', coords: [151.2093, -33.8688], value: 78 },
    { name: '莫斯科', coords: [37.6173, 55.7558], value: 82 },
    { name: '新加坡', coords: [103.8198, 1.3521], value: 87 },
    { name: '香港', coords: [114.1694, 22.3193], value: 89 },
]

// 模拟连线数据
const lineData = [
    { from: [116.4074, 39.9042], to: [-74.006, 40.7128] }, // 北京-纽约
    { from: [121.4737, 31.2304], to: [139.6917, 35.6895] }, // 上海-东京
    { from: [-0.1276, 51.5074], to: [2.3522, 48.8566] }, // 伦敦-巴黎
    { from: [103.8198, 1.3521], to: [151.2093, -33.8688] }, // 新加坡-悉尼
    { from: [116.4074, 39.9042], to: [-0.1276, 51.5074] }, // 北京-伦敦
]

// 图表配置
const chartOptions = computed(() => {
    return {
        backgroundColor: '#000',
        globe: {
            baseTexture: '/earth.jpg', // 地球贴图
            heightTexture: '/earth_height.jpg', // 高度贴图
            displacementScale: 0.05,
            shading: 'realistic',
            environment: '/starfield.jpg', // 星空背景
            atmosphere: {
                show: true,
                offset: 5,
            },
            light: {
                ambient: {
                    intensity: 0.4,
                },
                main: {
                    intensity: lightIntensity.value,
                    shadow: true,
                },
            },
            viewControl: {
                autoRotate: autoRotate.value,
                autoRotateSpeed: 5,
                distance: 200,
                minDistance: 150,
                maxDistance: 400,
            },
            postEffect: {
                enable: true,
                bloom: {
                    enable: true,
                    intensity: 0.1,
                },
            },
        },
        series: [
            // 散点 - 城市标记
            {
                type: 'scatter3D',
                coordinateSystem: 'globe',
                blendMode: 'lighter',
                symbolSize: 8,
                itemStyle: {
                    color: 'rgb(255, 200, 100)',
                    opacity: 1,
                },
                label: {
                    show: true,
                    position: 'right',
                    formatter: '{b}',
                    textStyle: {
                        color: '#fff',
                        fontSize: 12,
                        borderWidth: 1,
                        borderColor: 'rgba(255, 200, 100, 0.8)',
                        backgroundColor: 'rgba(0, 0, 0, 0.5)',
                        padding: 4,
                    },
                },
                emphasis: {
                    label: {
                        show: true,
                    },
                    itemStyle: {
                        color: '#fff',
                    },
                },
                data: cityData.map((city) => ({
                    name: city.name,
                    value: [...city.coords, city.value],
                })),
            },
            // 飞线
            {
                type: 'lines3D',
                coordinateSystem: 'globe',
                blendMode: 'lighter',
                lineStyle: {
                    width: 2,
                    color: 'rgb(50, 150, 250)',
                    opacity: 0.6,
                },
                data: lineData.map((line) => ({
                    coords: [line.from, line.to],
                })),
                effect: {
                    show: true,
                    period: 4,
                    trailWidth: 3,
                    trailLength: 0.5,
                    trailOpacity: 1,
                    symbolSize: 8,
                },
            },
            // 柱状图 - 数据值
            {
                type: 'bar3D',
                coordinateSystem: 'globe',
                shading: 'lambert',
                barSize: 1,
                bevelSize: 0.3,
                itemStyle: {
                    color: 'rgb(50, 150, 250)',
                    opacity: 0.8,
                },
                emphasis: {
                    itemStyle: {
                        color: 'rgb(100, 200, 255)',
                    },
                },
                data: cityData.map((city) => ({
                    name: city.name,
                    value: [...city.coords, city.value],
                })),
            },
        ],
    }
})

// 切换自动旋转
const toggleAutoRotate = () => {
    autoRotate.value = !autoRotate.value
}

// 重置视角
const resetView = () => {
    const chart = chartRef.value?.chartInstance
    if (chart) {
        chart.setOption({
            globe: {
                viewControl: {
                    alpha: 30,
                    beta: 40,
                    distance: 200,
                },
            },
        })
    }
}

// 监听光照强度
watch(lightIntensity, () => {
    // 图表会自动响应配置变化
})
</script>

<style scoped>
.globe-3d {
    position: relative;
    background: #000;
    border-radius: 8px;
    overflow: hidden;
}

.controls {
    position: absolute;
    top: 20px;
    left: 20px;
    z-index: 10;
    display: flex;
    flex-direction: column;
    gap: 12px;
    padding: 16px;
    background: rgba(0, 0, 0, 0.7);
    border-radius: 8px;
}

.controls button {
    padding: 8px 16px;
    border: 1px solid rgba(255, 255, 255, 0.3);
    background: rgba(255, 255, 255, 0.1);
    color: white;
    border-radius: 4px;
    cursor: pointer;
    transition: all 0.3s;
}

.controls button:hover {
    background: rgba(255, 255, 255, 0.2);
    border-color: rgba(255, 255, 255, 0.5);
}

.controls label {
    display: flex;
    flex-direction: column;
    gap: 8px;
    color: white;
    font-size: 14px;
}

.controls input[type='range'] {
    width: 150px;
}
</style>

继续输出剩余内容...

四、实时数据流图表

4.1 WebSocket 实时数据图表

vue
<!-- RealtimeStreamChart.vue -->
<template>
    <div class="realtime-stream">
        <div class="status-bar">
            <div class="connection-status">
                <span :class="['status-dot', connectionStatus]"></span>
                {{ statusText }}
            </div>
            <div class="data-info">
                <span>接收: {{ receivedCount }} 条</span>
                <span>更新率: {{ updateRate }} Hz</span>
            </div>
        </div>

        <BaseChart
            ref="chartRef"
            type="line"
            :data="chartData"
            :options="chartOptions"
            height="400px"
        />
    </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import BaseChart from './BaseChart.vue'

// WebSocket 连接
let ws = null
const connectionStatus = ref('disconnected')
const receivedCount = ref(0)
const updateRate = ref(0)

// 数据缓冲区
const dataBuffer = ref([])
const MAX_BUFFER_SIZE = 100

// 图表数据
const chartData = ref({
    xAxis: [],
    series: [
        { name: '传感器1', data: [] },
        { name: '传感器2', data: [] },
        { name: '传感器3', data: [] },
    ],
})

// 状态文本
const statusText = computed(() => {
    const texts = {
        connecting: '连接中...',
        connected: '已连接',
        disconnected: '未连接',
        error: '连接错误',
    }
    return texts[connectionStatus.value]
})

// 图表配置
const chartOptions = computed(() => {
    return {
        title: {
            text: '实时数据流监控',
            left: 'center',
        },
        tooltip: {
            trigger: 'axis',
            axisPointer: {
                type: 'cross',
            },
        },
        legend: {
            data: ['传感器1', '传感器2', '传感器3'],
            bottom: 10,
        },
        grid: {
            left: '3%',
            right: '4%',
            bottom: '15%',
            containLabel: true,
        },
        xAxis: {
            type: 'category',
            boundaryGap: false,
            data: chartData.value.xAxis,
        },
        yAxis: {
            type: 'value',
            scale: true,
        },
        dataZoom: [
            {
                type: 'inside',
                start: 50,
                end: 100,
            },
            {
                start: 50,
                end: 100,
            },
        ],
        series: chartData.value.series.map((s) => ({
            ...s,
            type: 'line',
            smooth: true,
            symbol: 'none',
            sampling: 'lttb',
            lineStyle: {
                width: 2,
            },
            areaStyle: {
                opacity: 0.3,
            },
        })),
    }
})

// 连接 WebSocket
const connectWebSocket = () => {
    connectionStatus.value = 'connecting'

    // 实际项目中这里应该是真实的 WebSocket 地址
    // ws = new WebSocket('wss://your-server.com/data-stream')

    // 模拟 WebSocket 连接
    setTimeout(() => {
        connectionStatus.value = 'connected'
        startMockDataStream()
    }, 1000)
}

// 模拟数据流
let mockStreamInterval = null
const startMockDataStream = () => {
    let count = 0

    mockStreamInterval = setInterval(() => {
        const timestamp = new Date().toLocaleTimeString()
        const data = {
            time: timestamp,
            sensor1: Math.random() * 100,
            sensor2: Math.random() * 100 + 50,
            sensor3: Math.random() * 100 - 50,
        }

        handleDataReceived(data)
        count++

        // 每秒更新一次更新率
        if (count % 10 === 0) {
            updateRate.value = 10
        }
    }, 100) // 每100ms推送一次数据
}

// 处理接收到的数据
const handleDataReceived = (data) => {
    receivedCount.value++

    // 添加到缓冲区
    dataBuffer.value.push(data)

    // 如果缓冲区满了,批量更新
    if (dataBuffer.value.length >= 10) {
        flushDataBuffer()
    }
}

// 刷新数据缓冲区
const flushDataBuffer = () => {
    if (dataBuffer.value.length === 0) return

    dataBuffer.value.forEach((data) => {
        // 添加时间轴
        chartData.value.xAxis.push(data.time)

        // 添加各传感器数据
        chartData.value.series[0].data.push(data.sensor1)
        chartData.value.series[1].data.push(data.sensor2)
        chartData.value.series[2].data.push(data.sensor3)

        // 限制数据点数量
        if (chartData.value.xAxis.length > MAX_BUFFER_SIZE) {
            chartData.value.xAxis.shift()
            chartData.value.series.forEach((s) => s.data.shift())
        }
    })

    // 清空缓冲区
    dataBuffer.value = []

    // 触发更新
    chartData.value = { ...chartData.value }
}

// 断开连接
const disconnectWebSocket = () => {
    if (ws) {
        ws.close()
        ws = null
    }

    if (mockStreamInterval) {
        clearInterval(mockStreamInterval)
        mockStreamInterval = null
    }

    connectionStatus.value = 'disconnected'
}

// 生命周期
onMounted(() => {
    connectWebSocket()

    // 定期刷新缓冲区
    setInterval(() => {
        flushDataBuffer()
    }, 500)
})

onUnmounted(() => {
    disconnectWebSocket()
})
</script>

<style scoped>
.realtime-stream {
    padding: 20px;
    background: white;
    border-radius: 8px;
}

.status-bar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 12px;
    background: #f5f5f5;
    border-radius: 4px;
    margin-bottom: 20px;
}

.connection-status {
    display: flex;
    align-items: center;
    gap: 8px;
    font-weight: 500;
}

.status-dot {
    width: 12px;
    height: 12px;
    border-radius: 50%;
    animation: pulse 2s infinite;
}

.status-dot.connecting {
    background: #faad14;
}

.status-dot.connected {
    background: #52c41a;
}

.status-dot.disconnected {
    background: #d9d9d9;
    animation: none;
}

.status-dot.error {
    background: #ff4d4f;
}

@keyframes pulse {
    0%,
    100% {
        opacity: 1;
    }
    50% {
        opacity: 0.5;
    }
}

.data-info {
    display: flex;
    gap: 24px;
    color: #666;
    font-size: 14px;
}
</style>

五、大数据量渲染优化

5.1 虚拟滚动大数据图表

vue
<!-- BigDataChart.vue -->
<template>
    <div class="big-data-chart">
        <div class="toolbar">
            <div class="data-info">
                <span>总数据量: {{ totalCount.toLocaleString() }}</span>
                <span>当前显示: {{ visibleData.length }}</span>
                <span>渲染时间: {{ renderTime }}ms</span>
            </div>
            <div class="controls">
                <button @click="loadMoreData">加载更多</button>
                <button @click="resetData">重置数据</button>
                <select v-model="samplingStrategy">
                    <option value="none">无采样</option>
                    <option value="lttb">LTTB采样</option>
                    <option value="average">平均值采样</option>
                </select>
            </div>
        </div>

        <BaseChart
            ref="chartRef"
            type="line"
            :data="chartData"
            :options="chartOptions"
            height="500px"
        />
    </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import BaseChart from './BaseChart.vue'

const chartRef = ref(null)
const totalCount = ref(10000)
const renderTime = ref(0)
const samplingStrategy = ref('lttb')

// 原始数据
const rawData = ref([])

// 可见数据范围
const visibleRange = ref({ start: 0, end: 1000 })

// 生成测试数据
const generateData = (count) => {
    const data = []
    const baseValue = 100
    let value = baseValue

    for (let i = 0; i < count; i++) {
        // 模拟随机波动
        value += (Math.random() - 0.5) * 10
        value = Math.max(0, value)

        data.push({
            time: new Date(Date.now() - (count - i) * 1000).toISOString(),
            value: value,
        })
    }

    return data
}

// 数据采样
const sampleData = (data, targetCount) => {
    if (samplingStrategy.value === 'none' || data.length <= targetCount) {
        return data
    }

    if (samplingStrategy.value === 'lttb') {
        return lttbSampling(data, targetCount)
    }

    if (samplingStrategy.value === 'average') {
        return averageSampling(data, targetCount)
    }

    return data
}

// LTTB 采样算法(Largest Triangle Three Buckets)
const lttbSampling = (data, targetCount) => {
    if (data.length <= targetCount) return data

    const sampled = []
    const bucketSize = (data.length - 2) / (targetCount - 2)

    // 第一个点
    sampled.push(data[0])

    let a = 0

    for (let i = 0; i < targetCount - 2; i++) {
        // 计算桶范围
        let avgRangeStart = Math.floor((i + 1) * bucketSize) + 1
        let avgRangeEnd = Math.floor((i + 2) * bucketSize) + 1
        avgRangeEnd = Math.min(avgRangeEnd, data.length)

        // 计算下一个桶的平均点
        let avgX = 0
        let avgY = 0
        let avgRangeLength = avgRangeEnd - avgRangeStart

        for (; avgRangeStart < avgRangeEnd; avgRangeStart++) {
            avgX += avgRangeStart
            avgY += data[avgRangeStart].value
        }
        avgX /= avgRangeLength
        avgY /= avgRangeLength

        // 在当前桶中找到最大三角形面积的点
        let rangeOffs = Math.floor((i + 0) * bucketSize) + 1
        let rangeTo = Math.floor((i + 1) * bucketSize) + 1

        let pointAx = a
        let pointAy = data[a].value

        let maxArea = -1
        let maxAreaPoint = null

        for (; rangeOffs < rangeTo; rangeOffs++) {
            const pointBx = rangeOffs
            const pointBy = data[rangeOffs].value

            // 计算三角形面积
            const area =
                Math.abs(
                    (pointAx - avgX) * (pointBy - pointAy) -
                        (pointAx - pointBx) * (avgY - pointAy)
                ) * 0.5

            if (area > maxArea) {
                maxArea = area
                maxAreaPoint = data[rangeOffs]
                a = rangeOffs
            }
        }

        sampled.push(maxAreaPoint)
    }

    // 最后一个点
    sampled.push(data[data.length - 1])

    return sampled
}

// 平均值采样
const averageSampling = (data, targetCount) => {
    if (data.length <= targetCount) return data

    const sampled = []
    const bucketSize = Math.floor(data.length / targetCount)

    for (let i = 0; i < targetCount; i++) {
        const start = i * bucketSize
        const end = Math.min((i + 1) * bucketSize, data.length)

        let sum = 0
        for (let j = start; j < end; j++) {
            sum += data[j].value
        }

        const avg = sum / (end - start)
        sampled.push({
            time: data[start].time,
            value: avg,
        })
    }

    return sampled
}

// 可见数据
const visibleData = computed(() => {
    const start = visibleRange.value.start
    const end = Math.min(visibleRange.value.end, rawData.value.length)
    const data = rawData.value.slice(start, end)

    // 采样
    const sampled = sampleData(data, 1000)

    return sampled
})

// 图表数据
const chartData = computed(() => {
    const startTime = performance.now()

    const data = {
        xAxis: visibleData.value.map((d) => d.time),
        series: [
            {
                name: '数据',
                data: visibleData.value.map((d) => d.value),
            },
        ],
    }

    renderTime.value = Math.round(performance.now() - startTime)

    return data
})

// 图表配置
const chartOptions = computed(() => {
    return {
        title: {
            text: '大数据量图表渲染优化',
            left: 'center',
        },
        tooltip: {
            trigger: 'axis',
        },
        grid: {
            left: '3%',
            right: '4%',
            bottom: '15%',
            containLabel: true,
        },
        xAxis: {
            type: 'category',
            boundaryGap: false,
        },
        yAxis: {
            type: 'value',
        },
        dataZoom: [
            {
                type: 'inside',
                start: 0,
                end: 100,
                zoomOnMouseWheel: true,
                moveOnMouseMove: true,
            },
            {
                start: 0,
                end: 100,
                handleIcon:
                    'path://M10.7,11.9H9.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z',
                handleSize: '80%',
            },
        ],
    }
})

// 加载更多数据
const loadMoreData = () => {
    const moreData = generateData(10000)
    rawData.value = [...rawData.value, ...moreData]
    totalCount.value = rawData.value.length
}

// 重置数据
const resetData = () => {
    rawData.value = generateData(10000)
    totalCount.value = rawData.value.length
    visibleRange.value = { start: 0, end: 1000 }
}

// 初始化
rawData.value = generateData(10000)
</script>

<style scoped>
.big-data-chart {
    padding: 20px;
    background: white;
    border-radius: 8px;
}

.toolbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 12px;
    background: #f5f5f5;
    border-radius: 4px;
    margin-bottom: 20px;
}

.data-info {
    display: flex;
    gap: 24px;
    color: #666;
    font-size: 14px;
}

.controls {
    display: flex;
    gap: 12px;
}

.controls button,
.controls select {
    padding: 6px 12px;
    border: 1px solid #d9d9d9;
    background: white;
    border-radius: 4px;
    cursor: pointer;
}

.controls button:hover {
    border-color: #40a9ff;
    color: #40a9ff;
}
</style>

六、真实项目经验总结

6.1 项目背景

在一个智能制造数据监控平台项目中,我负责实现复杂的数据可视化需求。这个项目需要展示全国 50 多个工厂的实时生产数据,包括设备状态、产量统计、质量分析等多个维度。数据量很大,而且需要实时更新,对性能要求很高。

6.2 技术挑战与解决方案

挑战1:地图下钻功能在移动端卡顿严重

我们最初直接用完整的 GeoJSON 数据,在 PC 上还好,但在移动端点击下钻时经常卡顿好几秒。后来我做了几个优化:首先是简化地图数据,用 Mapshaper 把精度降低了 50%,肉眼基本看不出区别但文件小了很多;其次是实现了渐进式渲染,先渲染主要区域,细节部分延迟加载;最后是加了缓存机制,已经加载过的地图数据直接从内存读取。这些优化做完后,移动端的体验好了很多。

挑战2:实时数据流图表内存泄漏

我们的监控页面会长时间打开,有时候一开就是一整天。但是跑了几个小时后,浏览器就会变得很卡,最后甚至崩溃。排查后发现是图表不断接收数据但没有清理旧数据,导致内存持续增长。我的解决方案是实现了一个滑动窗口机制,只保留最近的 N 条数据,旧数据自动被清除。同时在组件销毁时,确保断开 WebSocket 连接和清理所有定时器。

挑战3:大数据量图表渲染性能

有个页面需要展示一个月的设备温度曲线,数据点有 10 万+,直接渲染的话浏览器直接卡死。我采用了多种优化策略:首先是数据采样,用 LTTB 算法把数据点压缩到 2000 个左右,既保持了曲线特征又大幅减少了渲染量;其次是使用 Canvas 渲染而不是 SVG,性能提升明显;最后是实现了虚拟滚动,只渲染可视区域的数据。

6.3 项目成果

经过这些优化,整个系统的性能得到了很大提升。地图下钻的响应时间从 3 秒降到 300ms 以内,实时监控页面可以稳定运行 24 小时以上不出问题,大数据量图表的加载时间从 10 秒降到 1 秒以内。

更重要的是,这些技术方案都做成了可复用的组件,后续其他项目也能直接使用。我还整理了一套完整的文档和最佳实践,团队其他成员反馈说很有帮助。

6.4 经验总结

  1. 性能优化要从数据、渲染、交互多个层面考虑,单一优化很难达到好的效果
  2. 移动端和 PC 端的优化策略要区分对待,不能用同一套方案
  3. 实时数据的处理要特别注意内存管理,长时间运行的稳定性很重要
  4. 数据采样是处理大数据量的关键,但要选择合适的采样算法保证数据特征
  5. 组件化和文档化很重要,不仅方便自己维护,也能让团队其他人快速上手

这个项目让我对数据可视化有了更深入的理解,不仅是实现功能,更要考虑用户体验和系统的长期稳定性。