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
};
}
}
};