import React, { useMemo } from 'react'; import ReactECharts from 'echarts-for-react'; import { useTheme } from '@mui/material/styles'; import { Box, Typography } from '@mui/material'; interface DataPoint { currentTimestamp: Date; futureTimestamp: Date; currentValue: number; predictedValue: number; } interface MonitoringChartProps { data: DataPoint[]; height?: number; zoomRange?: [number, number]; onZoomChange?: (range: [number, number]) => void; chartTitle: string; unit: string; colors?: { current: string; predicted: string; }; } const MonitoringChart: React.FC = React.memo(({ data, height = 400, zoomRange, onZoomChange, chartTitle, unit, colors }) => { const theme = useTheme(); const commonSeriesSettings = { sampling: 'lttb', showSymbol: false, smooth: 0.3, animation: false, emphasis: { disabled: true } }; const option = useMemo(() => { if (!data || data.length === 0) { return {}; } const currentData = data.map(item => [ item.currentTimestamp.getTime(), Number(item.currentValue.toFixed(2)) ]); const predictedData = data.map(item => [ item.futureTimestamp.getTime(), Number(item.predictedValue.toFixed(2)) ]); const allValues = [...data.map(d => d.currentValue), ...data.map(d => d.predictedValue)]; const minValue = Math.min(...allValues); const maxValue = Math.max(...allValues); const valuePadding = (maxValue - minValue) * 0.1; const currentColor = colors?.current || theme.palette.primary.main; const predictedColor = colors?.predicted || theme.palette.warning.main; return { animation: false, progressive: 500, progressiveThreshold: 1000, renderer: 'canvas', title: { text: chartTitle, textStyle: { fontSize: 18, fontWeight: 'bold', color: theme.palette.text.primary, fontFamily: 'Montserrat, sans-serif' }, left: 'center', top: 10 }, tooltip: { trigger: 'axis', backgroundColor: 'rgba(255, 255, 255, 0.95)', borderColor: currentColor, borderWidth: 1, textStyle: { color: theme.palette.text.primary, fontFamily: 'Montserrat, sans-serif' }, formatter: function (params: any) { const time = new Date(params[0].value[0]).toLocaleString(); let html = `
${time}
`; params.forEach((param: any) => { html += `
${param.seriesName}: ${param.value[1]}${unit}
`; }); return html; } }, legend: { data: ['Current Value', 'Predicted Value'], top: 40, textStyle: { fontFamily: 'Montserrat, sans-serif', fontSize: 12, color: theme.palette.text.secondary } }, grid: { left: '3%', right: '4%', bottom: '15%', top: '25%', containLabel: true }, toolbox: { feature: { dataZoom: { yAxisIndex: 'none', title: { zoom: 'Zoom', back: 'Reset Zoom' } }, restore: { title: 'Reset' }, saveAsImage: { title: 'Save' } }, right: 15, top: 5 }, dataZoom: [ { type: 'slider', show: true, xAxisIndex: [0], start: zoomRange?.[0] ?? 0, end: zoomRange?.[1] ?? 100, height: 20, bottom: 0, borderColor: theme.palette.divider, fillerColor: `${currentColor}1A`, // 10% opacity textStyle: { color: theme.palette.text.secondary, fontFamily: 'Montserrat, sans-serif' }, handleStyle: { color: currentColor } }, { type: 'inside', xAxisIndex: [0], start: 0, end: 100, zoomOnMouseWheel: 'shift' } ], xAxis: { type: 'time', boundaryGap: false, axisLabel: { formatter: function (value: number) { const date = new Date(value); return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); }, color: theme.palette.text.secondary, fontFamily: 'Montserrat, sans-serif' }, axisLine: { lineStyle: { color: theme.palette.divider } }, splitLine: { show: true, lineStyle: { color: theme.palette.divider, type: 'dashed', opacity: 0.5 } } }, yAxis: { type: 'value', name: chartTitle, nameTextStyle: { color: theme.palette.text.secondary, fontFamily: 'Montserrat, sans-serif', fontWeight: 'bold' }, min: Math.max(0, minValue - valuePadding), max: maxValue + valuePadding, axisLabel: { formatter: `{value}${unit}`, color: theme.palette.text.secondary, fontFamily: 'Montserrat, sans-serif' }, axisLine: { lineStyle: { color: theme.palette.divider } }, splitLine: { lineStyle: { color: theme.palette.divider, type: 'dashed', opacity: 0.3 } } }, series: [ { ...commonSeriesSettings, name: 'Current Value', type: 'line', data: currentData, lineStyle: { color: currentColor, width: 2, join: 'round' }, itemStyle: { color: currentColor, opacity: 0.8 }, areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: `${currentColor}33` // 20% opacity }, { offset: 1, color: `${currentColor}05` // 2% opacity }] } } }, { ...commonSeriesSettings, name: 'Predicted Value', type: 'line', data: predictedData, lineStyle: { color: predictedColor, width: 2, type: 'dashed' }, itemStyle: { color: predictedColor, opacity: 0.8 } } ] }; }, [data, theme, chartTitle, unit, colors, zoomRange]); if (!data || data.length === 0) { return ( No data available ); } return ( { if (onZoomChange && params.batch?.[0]) { onZoomChange([params.batch[0].start, params.batch[0].end]); } } }} /> ); }, (prevProps, nextProps) => { // Always re-render if data length changes if (prevProps.data.length !== nextProps.data.length) return false; // Always re-render if essential props change if (prevProps.height !== nextProps.height) return false; if (prevProps.chartTitle !== nextProps.chartTitle) return false; if (prevProps.unit !== nextProps.unit) return false; if (prevProps.zoomRange?.[0] !== nextProps.zoomRange?.[0] || prevProps.zoomRange?.[1] !== nextProps.zoomRange?.[1]) return false; // Deep compare the last few data points const compareCount = 5; // Compare last 5 points for smooth transitions for (let i = 1; i <= compareCount; i++) { const prevItem = prevProps.data[prevProps.data.length - i]; const nextItem = nextProps.data[nextProps.data.length - i]; if (!prevItem || !nextItem) return false; if (prevItem.currentTimestamp.getTime() !== nextItem.currentTimestamp.getTime() || prevItem.currentValue !== nextItem.currentValue || prevItem.predictedValue !== nextItem.predictedValue) { return false; } } return true; }); MonitoringChart.displayName = 'MonitoringChart'; export default MonitoringChart;