Implementación de Líneas Multicolor y Resaltado Interactivo en Gráficos ECharts con Vue.js

Contexto de la Solución

En aplicaciones de monitoreo industrial o análisis de series temporales, es frecuente requerir visualizaciones donde las líneas cambien de color según el estado de los datos (por ejemplo, normal vs. anomalía). Además, resulta útil interactividad que permita resaltar una ventana temporal específica al mover el cursor sobre un punto crítico.

A continuación se detalla una implementación robusta utilizando Vue.js y la biblioteca ECharts, enfocada en generar tramos de línea con colores dinámicos mediante VisualMap y aplicar un área de sombra (markArea) sobre los diez puntos siguientes a la posición del eje X actual.

Componente Envoltorio de ECharts

Se crea un componente reusable que inicializa la instancia del gráfico, registra mapas si son necesarios y gestiona la comunicación de eventos estándar como zoom y puntero del eje.

<template>
  <div :id="containerId" style="width: 100%; height: 100%;"></div>
</template>

<script>
import * as echarts from 'echarts';

export default {
  props: {
    containerId: String,
    chartConfig: Object
  },
  data() {
    return {
      myChart: null
    };
  },
  mounted() {
    this.initializeChart();
  },
  watch: {
    chartConfig: {
      handler(newVal) {
        if (this.myChart && newVal) {
          // Fusionar opciones sin descartar configuraciones previas críticas
          this.myChart.setOption(newVal, { notMerge: false });
        }
      },
      deep: true
    }
  },
  methods: {
    initializeChart() {
      const dom = document.getElementById(this.containerId);
      if (!dom) return;
      
      this.myChart = echarts.init(dom);
      
      // Escuchar eventos internos para propagarlos hacia arriba
      this.myChart.on('dataZoom', this.handleZoomEvent);
      this.myChart.on('updateAxisPointer', this.handlePointerMove);
    },
    handleZoomEvent(params) {
      this.$emit('zoom-change', params);
    },
    handlePointerMove(params) {
      this.$emit('pointer-move', params);
    }
  },
  beforeDestroy() {
    if (this.myChart) {
      this.myChart.dispose();
    }
  }
};
</script>

Generador de Configuración de Opciones

Esta función factory construye la estructura de opciones necesaria. Se implementa el uso de visualMap para dividir la serie en segmentos coloreados y se define la lógica básica de rejilla y ejes.

export function generateChartConfig(labels, metrics, xLabel, yLabel, colorRules, historyData) {
  return {
    tooltip: {
      trigger: 'axis',
      formatter: (items) => {
        let htmlContent = `${items[0].marker} ${items[0].name}`;
        const indexKey = items[0].name;
        
        // Buscar información detallada en los datos históricos
        const record = historyData[indexKey];
        if (record && record.isFault) {
          htmlContent += '<br><ul>'.concat(
            `<li>Tasa de inclinación: ${record.details.ratio}</li>`,
            `<li>Diferencia mínima: ${record.details.min_percent}%</li>`,
            `<li>Inicio: ${record.details.startTime}</li>`,
            `<li>Fin: ${record.details.endTime}</li>`
          );
        }
        htmlContent += '</ul>';
        return htmlContent;
      }
    },
    dataZoom: [
      { type: 'inside', xAxisIndex: 0, start: 0, end: 100 },
      { type: 'slider', start: 0, end: 100 }
    ],
    grid: { top: '10%', left: '2%', right: '5%', containLabel: true },
    xAxis: [{
      type: 'category',
      boundaryGap: false,
      name: xLabel,
      data: labels,
      axisLine: { lineStyle: { color: '#888' } }
    }],
    yAxis: [{
      type: 'value',
      name: yLabel,
      splitLine: { lineStyle: { type: 'dashed' } },
      axisTick: { show: false }
    }],
    visualMap: {
      show: false,
      dimension: 0,
      pieces: colorRules,
      outOfRange: { color: '#aaa' },
      seriesIndex: 0
    },
    series: [
      {
        name: 'Datos Principales',
        type: 'line',
        data: metrics,
        symbol: 'circle',
        sampling: 'lttb', // Optimización para grandes conjuntos
        markLine: { silent: true, data: [{ yAxis: 2 }] }
      },
      {
        name: 'Capa de Interacción',
        type: 'line',
        data: metrics,
        symbol: 'none',
        showSymbol: false,
        sampling: 'lttb'
      }
    ]
  };
}

Lógica de Procesamiento y Eventos en el Padre

El componente princiapl maneja la transformación de los datos crudos para definir los rangos de colores y controla la visualización del área somberada (markArea). Es vital preservar el estado del dataZoom para evitar que el gráfico se restablezca al interactuar con el puntero.

import BaseChart from './components/BaseChart';

export default {
  components: { BaseChart },
  data() {
    return {
      chartOptions: {},
      savedZoom: {},
      anomalyRecord: [],
      xAxisLabels: [],
      yAxisMetrics: []
    };
  },
  created() {
    this.processIncomingData(this.apiResponseData);
  },
  methods: {
    processIncomingData(dataStream) {
      this.xAxisLabels = [];
      this.yAxisMetrics = [];
      this.anomalyRecord = dataStream;
      const colorSegments = [];
      let segmentStart = 0;
      
      // Generar reglas de colores basadas en cambios de estado
      dataStream.forEach((item, idx) => {
        this.xAxisLabels.push(idx.toString());
        this.yAxisMetrics.push(item.trendValue);
        
        if (idx > 0 && item.stateFlag !== dataStream[idx - 1].stateFlag) {
          const prevType = dataStream[idx - 1].stateFlag;
          colorSegments.push({
            color: prevType === '0' ? '#4caf50' : '#f44336', // Verde o Rojo
            lt: idx,
            gte: segmentStart
          });
          segmentStart = idx;
        }
      });
      
      // Cerrar el último segmento
      const lastType = dataStream[dataStream.length - 1].stateFlag;
      colorSegments.push({
        color: lastType === '0' ? '#4caf50' : '#f44336',
        gte: segmentStart,
        lt: dataStream.length
      });

      // Actualizar opción del gráfico usando nextTick para sincronización
      this.$nextTick(() => {
        this.chartOptions = generateChartConfig(
          this.xAxisLabels, 
          this.yAxisMetrics, 
          'Índice de Tiempo', 
          'Porcentaje de Diferencia', 
          colorSegments, 
          dataStream
        );
      });
    },

    handlePointerMove(event) {
      if (event.axesInfo && event.axesInfo.length) {
        const dataIndex = event.dataIndex;
        const endIndex = dataIndex + 9;
        
        // Validaciones para mostrar marcadores solo en fallos activos
        if (this.anomalyRecord[dataIndex] && 
            this.anomalyRecord[dataIndex].isFault && 
            this.anomalyRecord[dataIndex].stateFlag === '1') {
            
          if (endIndex < this.anomalyRecord.length && 
              this.anomalyRecord[endIndex].stateFlag !== '0') {
            // Aplicar zona de énfasis
            this.applyHighlightZone(dataIndex, endIndex);
            return;
          }
        }
      }
      this.clearHighlightZone();
    },

    applyHighlightZone(startIdx, endIdx) {
      // Mantener el rango de zoom actual antes de actualizar opciones
      this.preserveZoomState();
      
      const highlightConfig = {
        itemStyle: { color: 'rgba(255, 100, 100, 0.2)' },
        data: [[{ xAxis: startIdx }, { xAxis: endIdx }]]
      };
      
      // Mutar reactivamente la primera serie
      this.$set(this.chartOptions.series, 0, {
        ...this.chartOptions.series[0],
        markArea: highlightConfig
      });
    },

    clearHighlightZone() {
      this.preserveZoomState();
      const emptyMark = { data: [] };
      this.$set(this.chartOptions.series, 0, {
        ...this.chartOptions.series[0],
        markArea: emptyMark
      });
    },

    preserveZoomState() {
      if (this.chartOptions.dataZoom && this.savedZoom) {
        this.chartOptions.dataZoom[0].start = this.savedZoom.start;
        this.chartOptions.dataZoom[0].end = this.savedZoom.end;
      }
    },

    handleZoomEvent(params) {
      const batch = params.batch ? params.batch[0] : params;
      this.savedZoom = {
        start: batch.start,
        end: batch.end
      };
    }
  }
};

Etiquetas: ECharts vue.js JavaScript MarkArea VisualMap

Publicado el 8-5 16:17