AI测试可视化图表开发实战:ECharts从封装到高级scatter/heatmap应用

引言

在现代AI测试平台中,数据可视化不仅是展示结果的工具,更是理解模型行为、诊断问题、做出决策的关键窗口。ECharts作为一款功能强大的JavaScript可视化库,在AI测试领域有着广泛的应用前景。然而,直接在项目中嵌入ECharts代码会导致重复劳动、维护困难等问题。本文将深入探讨如何在Vue3中封装可复用的ECharts组件,并实现AI测试专用的高级图表,特别是散点图和热力图在数据质量分析中的应用。

1. 为什么要封装ECharts基础组件?

1.1 原生ECharts在Vue3中的使用痛点

在AI测试平台开发初期,许多团队选择在需要图表的地方直接引入ECharts:

// 典型的问题代码模式
import * as echarts from 'echarts';

export default {
  mounted() {
    const chart = echarts.init(this.$refs.chart);
    chart.setOption({
      // 大量重复配置
      title: { text: '模型准确率趋势' },
      tooltip: { trigger: 'axis' },
      // ... 数十行配置
    });
    
    // 忘记销毁导致内存泄漏
  }
}

这种模式存在以下问题:

  • 配置重复:每个图表文件都有相似的title、tooltip、legend配置
  • 内存泄漏风险:忘记在组件销毁时dispose图表实例
  • 响应式处理混乱:窗口resize时图表不会自适应
  • 事件管理困难:图表事件与组件逻辑耦合度高

1.2 复用性、可维护性考量

封装基础组件的核心价值在于关注点分离。业务组件应该关注数据逻辑和交互,而图表渲染、性能优化、事件处理等通用问题应由基础组件统一处理。

1.3 响应式与生命周期管理

组件挂载

初始化ECharts实例

监听数据变化

更新图表配置

监听窗口resize

组件销毁?

销毁图表实例并移除监听

外部Option变化

窗口Resize事件

防抖处理

调用resize方法

上图展示了基础组件需要管理的完整生命周期,每个环节处理不当都可能导致性能问题或内存泄漏。

2. 通用图表组件BaseChart封装

2.1 Props设计:面向AI测试场景优化

interface BaseChartProps {
  // 核心配置
  option: echarts.EChartsOption;
  
  // 状态控制
  loading?: boolean;
  loadingOptions?: {
    text?: string;
    color?: string;
    textColor?: string;
    maskColor?: string;
    zlevel?: number;
  };
  
  // 主题与样式
  theme?: string | object;
  initOpts?: {
    devicePixelRatio?: number;
    renderer?: 'canvas' | 'svg';
    width?: number | 'auto';
    height?: number | 'auto';
    locale?: string;
  };
  
  // 事件
  autoresize?: boolean;
  watchOptions?: boolean;
  watchTheme?: boolean;
  
  // AI测试专用扩展
  chartType?: 'line' | 'bar' | 'scatter' | 'heatmap' | 'radar';
  dataSource?: string;  // 数据源标识
  metricConfig?: MetricConfig; // 指标配置
}

2.2 生命周期管理的完整实现

<script setup lang="ts">
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { LineChart, BarChart, ScatterChart, HeatmapChart, RadarChart } from 'echarts/charts';
import {
  TitleComponent,
  TooltipComponent,
  GridComponent,
  LegendComponent,
  DataZoomComponent,
  VisualMapComponent
} from 'echarts/components';

// 按需注册组件
echarts.use([
  CanvasRenderer,
  LineChart,
  BarChart,
  ScatterChart,
  HeatmapChart,
  RadarChart,
  TitleComponent,
  TooltipComponent,
  GridComponent,
  LegendComponent,
  DataZoomComponent,
  VisualMapComponent
]);

const props = defineProps<BaseChartProps>();
const emit = defineEmits<{
  (e: 'chart-mounted', instance: echarts.ECharts): void;
  (e: 'chart-updated', instance: echarts.ECharts): void;
  (e: 'chart-destroyed'): void;
  (e: 'click', params: any): void;
  (e: 'datazoom', params: any): void;
  // ... 其他事件
}>();

const chartRef = ref<HTMLElement>();
const chartInstance = ref<echarts.ECharts>();
const resizeObserver = ref<ResizeObserver>();

// 初始化图表
const initChart = () => {
  if (!chartRef.value) return;
  
  // 清理旧实例
  if (chartInstance.value) {
    chartInstance.value.dispose();
  }
  
  // 创建新实例
  chartInstance.value = echarts.init(
    chartRef.value,
    props.theme,
    props.initOpts
  );
  
  // 设置配置
  chartInstance.value.setOption(props.option, true);
  
  // 绑定事件
  bindEvents();
  
  // 监听resize
  if (props.autoresize !== false) {
    setupResizeListener();
  }
  
  emit('chart-mounted', chartInstance.value);
};

// 防抖resize实现
const handleResize = debounce(() => {
  if (chartInstance.value && !chartInstance.value.isDisposed()) {
    chartInstance.value.resize();
    emit('chart-updated', chartInstance.value);
  }
}, 300);

// 使用ResizeObserver实现精确监听
const setupResizeListener = () => {
  if (!chartRef.value) return;
  
  // 先移除旧的监听器
  if (resizeObserver.value) {
    resizeObserver.value.disconnect();
  }
  
  // 创建新的ResizeObserver
  resizeObserver.value = new ResizeObserver(handleResize);
  resizeObserver.value.observe(chartRef.value);
};

// 事件绑定
const bindEvents = () => {
  if (!chartInstance.value) return;
  
  const events = [
    'click',
    'dblclick',
    'mousedown',
    'mousemove',
    'mouseup',
    'mouseover',
    'mouseout',
    'globalout',
    'contextmenu',
    'legendselectchanged',
    'legendselected',
    'legendunselected',
    'datazoom',
    'datarangeselected',
    'timelinechanged',
    'timelineplaychanged'
  ];
  
  events.forEach(eventName => {
    chartInstance.value?.on(eventName, (params: any) => {
      emit(eventName, params);
    });
  });
};

// 响应式更新
watch(() => props.option, (newOption) => {
  if (chartInstance.value && !chartInstance.value.isDisposed()) {
    chartInstance.value.setOption(newOption, true);
    emit('chart-updated', chartInstance.value);
  }
}, { deep: true });

// 组件卸载清理
onUnmounted(() => {
  if (chartInstance.value) {
    chartInstance.value.dispose();
    chartInstance.value = undefined;
  }
  
  if (resizeObserver.value) {
    resizeObserver.value.disconnect();
  }
  
  emit('chart-destroyed');
});

onMounted(() => {
  nextTick(() => {
    initChart();
  });
});
</script>

<template>
  <div class="base-chart-container">
    <div
      ref="chartRef"
      class="base-chart"
      :style="{
        width: '100%',
        height: '100%',
        minHeight: '300px'
      }"
    />
    
    <!-- 加载状态 -->
    <div
      v-if="loading"
      class="chart-loading-overlay"
      :style="{
        backgroundColor: loadingOptions?.maskColor || 'rgba(255, 255, 255, 0.9)'
      }"
    >
      <div class="loading-content">
        <div class="loading-spinner"></div>
        <div class="loading-text">
          {{ loadingOptions?.text || '数据加载中...' }}
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.base-chart-container {
  position: relative;
  width: 100%;
  height: 100%;
}

.base-chart {
  transition: opacity 0.3s ease;
}

.chart-loading-overlay {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 10;
}

.loading-content {
  text-align: center;
}

.loading-spinner {
  width: 40px;
  height: 40px;
  margin: 0 auto 16px;
  border: 3px solid #f3f3f3;
  border-top: 3px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

.loading-text {
  color: #666;
  font-size: 14px;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}
</style>

2.3 事件绑定的高级处理

对于AI测试图表,我们需要更精细的事件控制:

// 扩展事件处理
const bindAIEvents = () => {
  if (!chartInstance.value) return;
  
  // 数据点点击 - 显示详细指标
  chartInstance.value.on('click', { seriesType: 'line' }, (params) => {
    if (params.componentType === 'series') {
      emit('data-point-click', {
        metric: params.seriesName,
        value: params.value,
        timestamp: params.dataIndex,
        extra: params.data
      });
    }
  });
  
  // 图例切换 - 更新对比状态
  chartInstance.value.on('legendselectchanged', (params) => {
    const selected = Object.keys(params.selected).filter(key => params.selected[key]);
    emit('legend-change', {
      selectedMetrics: selected,
      allSelected: selected.length === Object.keys(params.selected).length
    });
  });
  
  // 区域缩放 - 时间范围选择
  chartInstance.value.on('datazoom', (params) => {
    const option = chartInstance.value!.getOption();
    const xAxis = option.xAxis[0];
    
    if (xAxis && xAxis.data) {
      const startIndex = Math.floor(params.start * xAxis.data.length / 100);
      const endIndex = Math.floor(params.end * xAxis.data.length / 100);
      
      emit('time-range-change', {
        start: xAxis.data[startIndex],
        end: xAxis.data[endIndex],
        startIndex,
        endIndex
      });
    }
  });
};

3. AI测试专用图表实现

3.1 性能趋势折线图(PerformanceTrendChart)

在AI测试中,监控模型性能随时间的变化至关重要:

interface PerformanceDataPoint {
  timestamp: string;
  accuracy: number;
  loss: number;
  latency: number; // 毫秒
  throughput: number; // 请求/秒
  memory_usage: number; // MB
}

const createPerformanceOption = (
  data: PerformanceDataPoint[],
  metrics: string[] = ['accuracy', 'loss', 'latency']
): echarts.EChartsOption => {
  // 时间序列数据处理
  const timestamps = data.map(d => d.timestamp);
  
  // 多指标系列配置
  const series = metrics.map(metric => {
    const isRateMetric = ['accuracy'].includes(metric);
    const isLossMetric = ['loss'].includes(metric);
    const isLatencyMetric = ['latency'].includes(metric);
    
    return {
      name: metric,
      type: 'line',
      data: data.map(d => d[metric as keyof PerformanceDataPoint]),
      yAxisIndex: isLatencyMetric ? 1 : 0,
      smooth: true,
      showSymbol: data.length <= 30, // 数据点多时不显示符号
      symbolSize: 8,
      lineStyle: {
        width: 3
      },
      emphasis: {
        focus: 'series',
        shadowBlur: 10,
        shadowColor: 'rgba(0, 0, 0, 0.5)'
      }
    };
  });
  
  // 双Y轴配置
  const yAxes = [
    {
      type: 'value',
      name: '准确率/损失',
      position: 'left',
      axisLabel: {
        formatter: (value: number) => {
          return metrics.includes('accuracy') ? `${(value * 100).toFixed(1)}%` : value.toFixed(4);
        }
      },
      splitLine: {
        lineStyle: {
          type: 'dashed'
        }
      }
    },
    {
      type: 'value',
      name: '延迟 (ms)',
      position: 'right',
      axisLabel: {
        formatter: '{value} ms'
      },
      splitLine: { show: false }
    }
  ];
  
  return {
    tooltip: {
      trigger: 'axis',
      formatter: (params: any[]) => {
        let result = `<div style="margin-bottom: 5px">${params[0].axisValue}</div>`;
        params.forEach(param => {
          const value = param.value;
          const metric = param.seriesName;
          let formattedValue = value;
          
          if (metric === 'accuracy') {
            formattedValue = `${(value * 100).toFixed(2)}%`;
          } else if (metric === 'latency') {
            formattedValue = `${value.toFixed(2)} ms`;
          } else if (metric === 'loss') {
            formattedValue = value.toFixed(6);
          }
          
          result += `
            <div style="display: flex; align-items: center; margin: 2px 0">
              <span style="display: inline-block; width: 10px; height: 10px; 
                background-color: ${param.color}; border-radius: 50%; margin-right: 5px">
              </span>
              <span style="flex: 1">${param.seriesName}:</span>
              <span style="font-weight: bold; margin-left: 10px">${formattedValue}</span>
            </div>
          `;
        });
        return result;
      }
    },
    legend: {
      data: metrics,
      top: '5%'
    },
    grid: {
      left: '3%',
      right: '8%',
      bottom: '12%',
      top: '15%',
      containLabel: true
    },
    xAxis: {
      type: 'category',
      data: timestamps,
      axisLabel: {
        formatter: (value: string) => {
          const date = new Date(value);
          return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
        },
        rotate: 45
      }
    },
    yAxis: yAxes,
    series,
    dataZoom: [
      {
        type: 'inside',
        xAxisIndex: 0,
        filterMode: 'filter',
        start: 0,
        end: 100
      },
      {
        show: true,
        xAxisIndex: 0,
        type: 'slider',
        bottom: '5%',
        start: 0,
        end: 100,
        height: 20
      }
    ]
  };
};

3.2 模型评估雷达图(ModelRadarChart)

原始评估数据

数据标准化

维度配置

基准线计算

雷达图生成

是否对比模式?

多模型对比

单一模型展示

差值区域高亮

Tooltip定制

雷达图在模型能力多维评估中非常有用:

interface ModelEvaluation {
  model_name: string;
  metrics: {
    accuracy: number;
    precision: number;
    recall: number;
    f1_score: number;
    inference_speed: number; // FPS
    robustness: number; // 对抗样本测试
    fairness: number; // 公平性指标
    efficiency: number; // 资源利用率
  };
  baseline?: {
    model_name: string;
    metrics: Record<string, number>;
  };
}

const createRadarOption = (
  evaluation: ModelEvaluation,
  compareMode: boolean = false
): echarts.EChartsOption => {
  const metrics = Object.keys(evaluation.metrics);
  const maxValues: Record<string, number> = {};
  
  // 计算每个指标的最大值(用于归一化)
  metrics.forEach(metric => {
    const values = [evaluation.metrics[metric]];
    if (evaluation.baseline) {
      values.push(evaluation.baseline.metrics[metric]);
    }
    maxValues[metric] = Math.max(...values) * 1.1; // 留10%空间
  });
  
  // 雷达图指标配置
  const indicator = metrics.map(metric => {
    const displayName = {
      accuracy: '准确率',
      precision: '精确率',
      recall: '召回率',
      f1_score: 'F1分数',
      inference_speed: '推理速度',
      robustness: '鲁棒性',
      fairness: '公平性',
      efficiency: '效率'
    }[metric] || metric;
    
    return {
      name: displayName,
      max: maxValues[metric],
      formatter: (value: number) => {
        if (metric === 'inference_speed') {
          return `${value.toFixed(1)} FPS`;
        }
        return value.toFixed(3);
      }
    };
  });
  
  // 数据系列
  const seriesData = [
    {
      value: metrics.map(metric => evaluation.metrics[metric]),
      name: evaluation.model_name,
      areaStyle: {
        color: new echarts.graphic.RadialGradient(0.5, 0.5, 1, [
          { offset: 0, color: 'rgba(64, 123, 255, 0.8)' },
          { offset: 1, color: 'rgba(64, 123, 255, 0.1)' }
        ])
      }
    }
  ];
  
  // 如果有基准模型,添加对比
  if (compareMode && evaluation.baseline) {
    seriesData.push({
      value: metrics.map(metric => evaluation.baseline!.metrics[metric]),
      name: evaluation.baseline.model_name,
      areaStyle: {
        color: new echarts.graphic.RadialGradient(0.5, 0.5, 1, [
          { offset: 0, color: 'rgba(255, 86, 48, 0.8)' },
          { offset: 1, color: 'rgba(255, 86, 48, 0.1)' }
        ])
      }
    });
  }
  
  return {
    tooltip: {
      trigger: 'item',
      formatter: (params: any) => {
        const metricName = indicator[params.dataIndex].name;
        const value = params.value;
        const modelName = params.seriesName;
        
        return `
          <div style="font-weight: bold; margin-bottom: 5px">${metricName}</div>
          <div>${modelName}: <b>${value}</b></div>
        `;
      }
    },
    radar: {
      indicator,
      shape: 'circle',
      splitNumber: 5,
      axisName: {
        color: '#666',
        fontSize: 12,
        borderRadius: 3,
        padding: [3, 5]
      },
      splitArea: {
        areaStyle: {
          color: ['rgba(114, 172, 209, 0.2)', 
                  'rgba(114, 172, 209, 0.4)', 
                  'rgba(114, 172, 209, 0.6)', 
                  'rgba(114, 172, 209, 0.8)', 
                  'rgba(114, 172, 209, 1)'],
          shadowColor: 'rgba(0, 0, 0, 0.3)',
          shadowBlur: 10
        }
      }
    },
    series: [
      {
        type: 'radar',
        data: seriesData,
        symbolSize: 6,
        lineStyle: {
          width: 2
        }
      }
    ]
  };
};

4. 高级图表:热力图与散点图

4.1 数据质量热力图(DataQualityHeatmap)

数据质量是AI测试的核心,热力图可以直观展示数据集中不同维度的质量分布:

interface DataQualityMatrix {
  features: string[]; // 特征名称
  samples: string[]; // 样本ID或时间戳
  qualityScores: number[][]; // 二维质量分数矩阵
  thresholds: {
    good: number;    // > 0.8
    warning: number; // 0.6 - 0.8
    bad: number;     // < 0.6
  };
}

const createHeatmapOption = (
  matrix: DataQualityMatrix,
  config?: {
    showGrid?: boolean;
    useGradient?: boolean;
    highlightLowQuality?: boolean;
  }
): echarts.EChartsOption => {
  const { features, samples, qualityScores, thresholds } = matrix;
  
  // 准备热力图数据
  const heatmapData: [number, number, number][] = [];
  const anomalyData: [number, number, number][] = []; // 异常点
  
  qualityScores.forEach((row, rowIndex) => {
    row.forEach((score, colIndex) => {
      heatmapData.push([colIndex, rowIndex, score]);
      
      // 标记低质量数据点
      if (score < thresholds.warning) {
        anomalyData.push([colIndex, rowIndex, score]);
      }
    });
  });
  
  return {
    tooltip: {
      position: 'top',
      formatter: (params: any) => {
        const [x, y, value] = params.data;
        const feature = features[y];
        const sample = samples[x];
        
        let qualityLevel = '优秀';
        let color = '#4caf50';
        
        if (value < thresholds.good) {
          qualityLevel = '良好';
          color = '#8bc34a';
        }
        if (value < thresholds.warning) {
          qualityLevel = '警告';
          color = '#ff9800';
        }
        if (value < thresholds.bad) {
          qualityLevel = '异常';
          color = '#f44336';
        }
        
        return `
          <div style="
            background: ${color};
            color: white;
            padding: 5px 10px;
            border-radius: 4px;
            margin-bottom: 5px;
          ">
            ${qualityLevel} (${value.toFixed(3)})
          </div>
          <div><b>特征:</b> ${feature}</div>
          <div><b>样本:</b> ${sample}</div>
        `;
      }
    },
    grid: {
      height: '70%',
      top: '10%',
      left: '10%',
      right: '10%'
    },
    xAxis: {
      type: 'category',
      data: samples,
      splitArea: {
        show: true
      },
      axisLabel: {
        interval: 0,
        rotate: 45,
        fontSize: 10
      }
    },
    yAxis: {
      type: 'category',
      data: features,
      splitArea: {
        show: true
      }
    },
    visualMap: {
      min: 0,
      max: 1,
      calculable: true,
      orient: 'vertical',
      left: 'left',
      top: 'center',
      text: ['高', '低'],
      inRange: {
        color: [
          '#313695', '#4575b4', '#74add1', '#abd9e9',
          '#e0f3f8', '#ffffbf', '#fee090', '#fdae61',
          '#f46d43', '#d73027', '#a50026'
        ]
      },
      controller: {
        inRange: {
          symbolSize: [30, 100]
        }
      }
    },
    series: [
      {
        name: '数据质量',
        type: 'heatmap',
        data: heatmapData,
        label: {
          show: false
        },
        emphasis: {
          itemStyle: {
            shadowBlur: 10,
            shadowColor: 'rgba(0, 0, 0, 0.5)'
          }
        }
      },
      {
        name: '异常点',
        type: 'scatter',
        data: anomalyData,
        symbolSize: 12,
        itemStyle: {
          color: 'rgba(255, 0, 0, 0.6)',
          borderColor: '#ff0000',
          borderWidth: 2
        },
        tooltip: {
          formatter: (params: any) => {
            const [x, y, value] = params.data;
            return `<div style="color: #f44336; font-weight: bold">
              数据质量问题!<br/>
              特征: ${features[y]}<br/>
              样本: ${samples[x]}<br/>
              质量分数: ${value.toFixed(3)}
            </div>`;
          }
        }
      }
    ]
  };
};

// 大数据量优化:分块渲染
const createLargeHeatmapOption = (
  matrix: DataQualityMatrix,
  chunkSize: number = 1000
): echarts.EChartsOption => {
  const { features, samples, qualityScores } = matrix;
  
  // 如果数据量太大,使用渐进式渲染
  if (features.length * samples.length > chunkSize) {
    return {
      progressive: chunkSize,
      progressiveThreshold: chunkSize * 2,
      // ... 其他配置
    };
  }
  
  return createHeatmapOption(matrix);
};

4.2 数据漂移检测散点图(DataDriftScatter)

数据漂移是模型性能下降的主要原因之一,散点图可以直观对比训练数据和测试数据的分布差异:

interface DataDriftPoint {
  feature: string;
  feature_type: 'numerical' | 'categorical';
  train_distribution: {
    mean: number;
    std: number;
    min: number;
    max: number;
    samples: number[];
  };
  test_distribution: {
    mean: number;
    std: number;
    min: number;
    max: number;
    samples: number[];
  };
  drift_score: number; // 0-1, 越高表示漂移越严重
  statistical_test: {
    p_value: number;
    test_name: string;
    threshold: number;
  };
}

const createDriftScatterOption = (
  driftData: DataDriftPoint[],
  selectedFeature?: string
): echarts.EChartsOption => {
  // 准备散点图数据
  const scatterData: any[] = [];
  const meanPoints: any[] = [];
  const driftVectors: any[] = [];
  
  driftData.forEach(point => {
    const isSelected = selectedFeature === point.feature;
    const pointSize = isSelected ? 12 : 6;
    const opacity = isSelected ? 0.8 : 0.3;
    
    // 训练数据点
    point.train_distribution.samples.forEach((value, index) => {
      scatterData.push({
        name: `训练-${point.feature}`,
        value: [value, Math.random() * 0.1], // 添加少量垂直偏移避免重叠
        itemStyle: {
          color: `rgba(66, 133, 244, ${opacity})`,
          borderColor: '#4285f4'
        },
        symbolSize: pointSize
      });
    });
    
    // 测试数据点
    point.test_distribution.samples.forEach((value, index) => {
      scatterData.push({
        name: `测试-${point.feature}`,
        value: [value, 1 + Math.random() * 0.1], // 垂直分层显示
        itemStyle: {
          color: `rgba(219, 68, 55, ${opacity})`,
          borderColor: '#db4437'
        },
        symbolSize: pointSize
      });
    });
    
    // 均值点
    meanPoints.push({
      name: `${point.feature}-训练均值`,
      value: [point.train_distribution.mean, 0],
      symbol: 'diamond',
      symbolSize: 16,
      itemStyle: {
        color: '#0d47a1'
      }
    }, {
      name: `${point.feature}-测试均值`,
      value: [point.test_distribution.mean, 1],
      symbol: 'diamond',
      symbolSize: 16,
      itemStyle: {
        color: '#b71c1c'
      }
    });
    
    // 漂移向量(从训练均值指向测试均值)
    if (point.drift_score > 0.3) {
      driftVectors.push({
        type: 'line',
        fromCoord: [point.train_distribution.mean, 0],
        toCoord: [point.test_distribution.mean, 1],
        lineStyle: {
          color: point.drift_score > 0.7 ? '#f44336' : 
                 point.drift_score > 0.5 ? '#ff9800' : '#ffeb3b',
          width: point.drift_score * 4,
          type: 'dashed',
          opacity: 0.6
        }
      });
    }
  });
  
  return {
    tooltip: {
      trigger: 'item',
      formatter: (params: any) => {
        const data = params.data;
        if (data.type === 'line') return null;
        
        const [xValue, yLayer] = data.value;
        const isTrain = yLayer < 0.5;
        const distribution = isTrain ? '训练集' : '测试集';
        const feature = data.name.split('-')[1];
        
        const pointInfo = driftData.find(p => p.feature === feature);
        if (!pointInfo) return '';
        
        return `
          <div style="font-weight: bold; margin-bottom: 5px">${feature}</div>
          <div>数据集: <b>${distribution}</b></div>
          <div>值: <b>${xValue.toFixed(4)}</b></div>
          <div style="margin-top: 5px; padding: 3px; background: #f5f5f5; border-radius: 2px">
            漂移分数: <b style="color: ${pointInfo.drift_score > 0.7 ? '#f44336' : pointInfo.drift_score > 0.5 ? '#ff9800' : '#4caf50'}">
              ${pointInfo.drift_score.toFixed(3)}
            </b>
          </div>
          <div>P值: ${pointInfo.statistical_test.p_value.toExponential(2)}</div>
        `;
      }
    },
    grid: {
      left: '3%',
      right: '4%',
      bottom: '15%',
      top: '10%',
      containLabel: true
    },
    xAxis: {
      type: 'value',
      name: '特征值',
      scale: true
    },
    yAxis: {
      type: 'value',
      name: '数据集',
      min: -0.5,
      max: 1.5,
      axisLabel: {
        formatter: (value: number) => {
          if (value < 0.5) return '训练集';
          if (value > 0.5) return '测试集';
          return '';
        }
      },
      splitLine: {
        lineStyle: {
          type: 'dashed'
        }
      }
    },
    series: [
      {
        name: '数据分布',
        type: 'scatter',
        data: scatterData,
        large: true,
        largeThreshold: 2000,
        symbolSize: (data: number[]) => {
          // 根据数据密度调整点的大小
          return data[2] || 6;
        }
      },
      {
        name: '均值点',
        type: 'scatter',
        data: meanPoints,
        tooltip: {
          formatter: (params: any) => {
            const [meanValue, layer] = params.data.value;
            const isTrain = layer < 0.5;
            const feature = params.data.name.split('-')[0];
            
            const pointInfo = driftData.find(p => p.feature === feature);
            if (!pointInfo) return '';
            
            const dist = isTrain ? pointInfo.train_distribution : pointInfo.test_distribution;
            
            return `
              <div style="font-weight: bold">${feature} - ${isTrain ? '训练集' : '测试集'}均值</div>
              <div>均值: ${meanValue.toFixed(4)}</div>
              <div>标准差: ${dist.std.toFixed(4)}</div>
              <div>范围: [${dist.min.toFixed(4)}, ${dist.max.toFixed(4)}]</div>
            `;
          }
        }
      },
      ...driftVectors
    ],
    graphic: driftVectors.map(vector => ({
      type: 'line',
      shape: {
        x1: vector.fromCoord[0],
        y1: vector.fromCoord[1],
        x2: vector.toCoord[0],
        y2: vector.toCoord[1]
      },
      style: vector.lineStyle
    })),
    dataZoom: [
      {
        type: 'slider',
        xAxisIndex: 0,
        filterMode: 'filter',
        bottom: '5%'
      }
    ]
  };
};

5. 图表交互功能深度开发

5.1 useChartInteractions组合函数设计

// 图表交互状态管理
interface ChartInteractionState {
  selectedPoints: Set<string>; // 选中的数据点ID
  highlightedSeries: string[]; // 高亮的系列
  zoomHistory: Array<{
    start: number;
    end: number;
    timestamp: number;
  }>;
  filterConditions: Record<string, any>;
}

// 主交互组合函数
export const useChartInteractions = (
  chartRefs: Ref<echarts.ECharts>[],
  options?: {
    enableCrossHighlight?: boolean;
    enableDataDrilldown?: boolean;
    enableFilterSync?: boolean;
    maxZoomHistory?: number;
  }
) => {
  const state = reactive<ChartInteractionState>({
    selectedPoints: new Set(),
    highlightedSeries: [],
    zoomHistory: [],
    filterConditions: {}
  });
  
  // 跨图表高亮
  const highlightSeries = (seriesName: string, chartIndex?: number) => {
    if (chartIndex !== undefined) {
      // 单个图表高亮
      highlightSingleChart(chartRefs[chartIndex].value, seriesName);
    } else {
      // 所有图表高亮
      chartRefs.forEach(chart => {
        if (chart.value) {
          highlightSingleChart(chart.value, seriesName);
        }
      });
    }
    
    state.highlightedSeries = [seriesName];
  };
  
  const highlightSingleChart = (chart: echarts.ECharts, seriesName: string) => {
    const option = chart.getOption();
    
    // 更新所有系列的透明度
    option.series = (option.series as any[]).map((series: any) => {
      if (series.name === seriesName) {
        return {
          ...series,
          itemStyle: {
            ...series.itemStyle,
            opacity: 1
          },
          lineStyle: {
            ...series.lineStyle,
            width: 4
          }
        };
      } else {
        return {
          ...series,
          itemStyle: {
            ...series.itemStyle,
            opacity: 0.3
          }
        };
      }
    });
    
    chart.setOption(option);
  };
  
  // 数据钻取
  const drilldown = async (
    pointId: string,
    dimension: string,
    callback: (drilldownData: any) => void
  ) => {
    // 显示加载状态
    chartRefs.forEach(chart => {
      chart.value?.showLoading();
    });
    
    try {
      // 模拟API调用获取钻取数据
      const drilldownData = await fetchDrilldownData(pointId, dimension);
      
      // 更新图表
      callback(drilldownData);
      
      // 记录钻取历史
      recordDrilldownHistory(pointId, dimension);
    } catch (error) {
      console.error('Drilldown failed:', error);
    } finally {
      chartRefs.forEach(chart => {
        chart.value?.hideLoading();
      });
    }
  };
  
  // 筛选条件同步
  const syncFilter = (filterKey: string, value: any) => {
    state.filterConditions[filterKey] = value;
    
    // 应用筛选到所有图表
    chartRefs.forEach((chartRef, index) => {
      if (chartRef.value) {
        applyFilterToChart(chartRef.value, state.filterConditions);
      }
    });
    
    // 触发筛选事件
    emitFilterChange();
  };
  
  // 重置所有交互状态
  const resetAllInteractions = () => {
    state.selectedPoints.clear();
    state.highlightedSeries = [];
    state.filterConditions = {};
    
    // 重置所有图表
    chartRefs.forEach(chartRef => {
      if (chartRef.value) {
        resetChart(chartRef.value);
      }
    });
  };
  
  return {
    // 状态
    state,
    
    // 方法
    highlightSeries,
    drilldown,
    syncFilter,
    resetAllInteractions,
    
    // 工具函数
    getSelectedPoints: () => Array.from(state.selectedPoints),
    getActiveFilters: () => ({ ...state.filterConditions }),
    canUndoZoom: () => state.zoomHistory.length > 0
  };
};

6. 性能优化与踩坑指南

6.1 ECharts数据格式常见错误

// 错误示例1:数据格式不一致
const wrongData1 = [
  { value: 10, name: 'A' },
  [20, 'B'],  // 混合格式会导致渲染错误
  { value: 30, name: 'C' }
];

// 正确做法:统一数据格式
const correctData1 = [
  { value: 10, name: 'A' },
  { value: 20, name: 'B' },
  { value: 30, name: 'C' }
];

// 错误示例2:时间格式不统一
const wrongData2 = [
  ['2023-01-01', 100],
  ['2023/01/02', 200],  // 日期格式不一致
  [new Date('2023-01-03'), 300]  // Date对象
];

// 正确做法:使用时间戳或统一字符串格式
const correctData2 = [
  [1672531200000, 100],  // 时间戳
  [1672617600000, 200],
  [1672704000000, 300]
];

// 或使用字符串格式
const correctData2b = [
  ['2023-01-01 00:00:00', 100],
  ['2023-01-02 00:00:00', 200],
  ['2023-01-03 00:00:00', 300]
];

6.2 Vue3响应式与ECharts更新循环

<script setup>
// 错误示例:直接修改响应式对象导致无限循环
const chartData = ref([]);
const chartOption = computed(() => ({
  series: [{
    type: 'line',
    data: chartData.value
  }]
}));

watch(chartData, () => {
  // 这会触发循环更新!
  chartOption.value.series[0].data = chartData.value;
});
</script>

// 正确做法:使用不可变数据模式
const updateChartData = (newData) => {
  // 创建新的数组引用
  chartData.value = [...newData];
  
  // 或者在需要时重新计算整个option
  const newOption = {
    series: [{
      type: 'line',
      data: newData
    }]
  };
  
  if (chartInstance.value) {
    chartInstance.value.setOption(newOption);
  }
};

6.3 大数据量渲染卡顿解决方案

// 方案1:数据采样(适用于时间序列)
const downsampleData = (data: number[], maxPoints: number = 1000) => {
  if (data.length <= maxPoints) return data;
  
  const sampled = [];
  const step = Math.ceil(data.length / maxPoints);
  
  for (let i = 0; i < data.length; i += step) {
    // 取每个区间的最大值和最小值
    const chunk = data.slice(i, i + step);
    sampled.push(Math.max(...chunk));
    sampled.push(Math.min(...chunk));
  }
  
  return sampled.slice(0, maxPoints);
};

// 方案2:分块渲染
const renderInChunks = async (
  chart: echarts.ECharts,
  data: any[],
  chunkSize: number = 1000,
  delay: number = 50
) => {
  const totalChunks = Math.ceil(data.length / chunkSize);
  
  for (let i = 0; i < totalChunks; i++) {
    const chunk = data.slice(i * chunkSize, (i + 1) * chunkSize);
    
    // 分批更新图表
    chart.appendData({
      seriesIndex: 0,
      data: chunk
    });
    
    // 添加延迟,避免阻塞主线程
    if (i < totalChunks - 1) {
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
};

// 方案3:Web Worker处理
const createWebWorkerProcessor = () => {
  const worker = new Worker('./chartData.worker.js');
  
  return {
    processLargeData: (data: any[]) => {
      return new Promise((resolve) => {
        worker.postMessage({
          type: 'PROCESS_DATA',
          data
        });
        
        worker.onmessage = (e) => {
          if (e.data.type === 'PROCESSED_DATA') {
            resolve(e.data.result);
          }
        };
      });
    },
    
    destroy: () => {
      worker.terminate();
    }
  };
};

7. 总结与下篇预告

7.1 图表层技术要点总结

  1. 组件化是基础:封装BaseChart组件解决了代码复用、维护性和性能问题
  2. AI测试场景专用:针对模型评估、数据质量、漂移检测等场景设计专用图表
  3. 交互体验优化:通过组合函数实现跨图表联动、数据钻取等高级功能
  4. 性能是关键:大数据量下需要采用采样、分块渲染、Web Worker等技术
  5. 类型安全:使用TypeScript确保代码质量和开发体验

7.2 架构演进建议

基础图表组件

业务专用图表

图表交互层

数据状态管理

API服务层

设计系统

性能监控

错误边界

7.3 下篇预告:前后端数据交互

在下一篇中,我们将深入探讨:

  1. API设计与数据协议:如何设计适合图表展示的RESTful API
  2. WebSocket实时数据推送:实现监控图表的实时更新
  3. 数据缓存策略:IndexedDB与localStorage在图表数据缓存中的应用
  4. 数据聚合与采样:后端数据处理减轻前端压力
  5. 错误处理与降级:网络异常时的图表降级方案
  6. 安全考虑:大数据量传输时的压缩与加密

我们将构建一个完整的AI测试数据可视化平台,实现从数据采集、处理到展示的全链路优化。


实用资源推荐

通过本文的实践,您可以构建出既美观又实用的AI测试可视化系统,为模型开发、测试和部署提供有力的数据支持。记住,好的可视化不仅仅是展示数据,更是讲述数据背后的故事。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐