forked from BLC/AyposWeb
UpGraded tempreture charts version 2.0, using Echarts library, optomized rendering and added proper data selection options
This commit is contained in:
367
src/components/Charts/MonitoringChart.tsx
Normal file
367
src/components/Charts/MonitoringChart.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
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<MonitoringChartProps> = 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 = `<div style="margin-bottom: 8px; font-weight: bold;">${time}</div>`;
|
||||
params.forEach((param: any) => {
|
||||
html += `
|
||||
<div style="display: flex; align-items: center; margin-bottom: 4px;">
|
||||
<span style="display: inline-block; margin-right: 8px; border-radius: 50%; width: 10px; height: 10px; background-color: ${param.color};"></span>
|
||||
<span style="font-weight: 500;">${param.seriesName}:</span>
|
||||
<span style="margin-left: 8px; color: ${param.color}; font-weight: bold;">${param.value[1]}${unit}</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
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 (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
height={height}
|
||||
bgcolor={theme.palette.background.paper}
|
||||
borderRadius={2}
|
||||
boxShadow={2}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="text.secondary"
|
||||
fontFamily="Montserrat, sans-serif"
|
||||
>
|
||||
No data available
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: theme.palette.background.paper,
|
||||
borderRadius: 2,
|
||||
boxShadow: 3,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<ReactECharts
|
||||
option={option}
|
||||
style={{ height: `${height}px`, width: '100%' }}
|
||||
opts={{
|
||||
renderer: 'canvas',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
}}
|
||||
notMerge={true}
|
||||
lazyUpdate={true}
|
||||
theme={theme.palette.mode}
|
||||
loadingOption={{
|
||||
color: theme.palette.primary.main,
|
||||
maskColor: 'rgba(255, 255, 255, 0.8)',
|
||||
text: 'Loading...'
|
||||
}}
|
||||
onEvents={{
|
||||
datazoom: (params: any) => {
|
||||
if (onZoomChange && params.batch?.[0]) {
|
||||
onZoomChange([params.batch[0].start, params.batch[0].end]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}, (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;
|
||||
Reference in New Issue
Block a user