forked from BLC/AyposWeb
344 lines
9.4 KiB
TypeScript
344 lines
9.4 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import ReactECharts from 'echarts-for-react';
|
|
import { useTheme } from '@mui/material/styles';
|
|
import { Box, Typography } from '@mui/material';
|
|
|
|
interface TemperatureDataPoint {
|
|
currentTimestamp: Date;
|
|
futureTimestamp: Date;
|
|
currentTemp: number;
|
|
predictedTemp: number;
|
|
}
|
|
|
|
interface TemperatureEChartProps {
|
|
data: TemperatureDataPoint[];
|
|
height?: number;
|
|
zoomRange?: [number, number];
|
|
onZoomChange?: (range: [number, number]) => void;
|
|
}
|
|
|
|
const TemperatureEChart: React.FC<TemperatureEChartProps> = React.memo(({ data, height = 400, zoomRange, onZoomChange }) => {
|
|
const theme = useTheme();
|
|
|
|
const option = useMemo(() => {
|
|
if (!data || data.length === 0) {
|
|
return {};
|
|
}
|
|
|
|
// Prepare data for ECharts
|
|
const currentTempData = data.map(item => [
|
|
item.currentTimestamp.getTime(),
|
|
Number(item.currentTemp.toFixed(2))
|
|
]);
|
|
|
|
const predictedTempData = data.map(item => [
|
|
item.futureTimestamp.getTime(),
|
|
Number(item.predictedTemp.toFixed(2))
|
|
]);
|
|
|
|
// Calculate temperature range for better axis scaling
|
|
const allTemps = [...data.map(d => d.currentTemp), ...data.map(d => d.predictedTemp)];
|
|
const minTemp = Math.min(...allTemps);
|
|
const maxTemp = Math.max(...allTemps);
|
|
const tempPadding = (maxTemp - minTemp) * 0.1;
|
|
|
|
const commonSeriesSettings = {
|
|
sampling: 'lttb',
|
|
showSymbol: false,
|
|
smooth: 0.3,
|
|
animation: false,
|
|
emphasis: {
|
|
disabled: true
|
|
}
|
|
};
|
|
|
|
return {
|
|
animation: false,
|
|
progressive: 500,
|
|
progressiveThreshold: 1000,
|
|
renderer: 'canvas',
|
|
title: {
|
|
text: 'Environmental Temperature Monitoring',
|
|
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: theme.palette.primary.main,
|
|
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]}°C</span>
|
|
</div>
|
|
`;
|
|
});
|
|
return html;
|
|
}
|
|
},
|
|
legend: {
|
|
data: ['Current Temperature', 'Predicted Temperature'],
|
|
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: 'rgba(2, 138, 74, 0.1)',
|
|
textStyle: {
|
|
color: theme.palette.text.secondary,
|
|
fontFamily: 'Montserrat, sans-serif'
|
|
},
|
|
handleStyle: {
|
|
color: theme.palette.primary.main
|
|
}
|
|
},
|
|
{
|
|
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: 'Temperature (°C)',
|
|
nameTextStyle: {
|
|
color: theme.palette.text.secondary,
|
|
fontFamily: 'Montserrat, sans-serif',
|
|
fontWeight: 'bold'
|
|
},
|
|
min: Math.max(0, minTemp - tempPadding),
|
|
max: maxTemp + tempPadding,
|
|
axisLabel: {
|
|
formatter: '{value}°C',
|
|
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
|
|
}
|
|
}
|
|
},
|
|
// Optimize throttling and rendering
|
|
throttle: 70,
|
|
silent: true, // Reduce event overhead when not needed
|
|
series: [
|
|
{
|
|
...commonSeriesSettings,
|
|
name: 'Current Temperature',
|
|
type: 'line',
|
|
data: currentTempData,
|
|
lineStyle: {
|
|
color: theme.palette.primary.main,
|
|
width: 2,
|
|
join: 'round'
|
|
},
|
|
itemStyle: {
|
|
color: theme.palette.primary.main,
|
|
opacity: 0.8
|
|
},
|
|
areaStyle: {
|
|
color: {
|
|
type: 'linear',
|
|
x: 0,
|
|
y: 0,
|
|
x2: 0,
|
|
y2: 1,
|
|
colorStops: [{
|
|
offset: 0,
|
|
color: `rgba(2, 138, 74, 0.2)`
|
|
}, {
|
|
offset: 1,
|
|
color: `rgba(2, 138, 74, 0.02)`
|
|
}]
|
|
}
|
|
}
|
|
},
|
|
{
|
|
...commonSeriesSettings,
|
|
name: 'Predicted Temperature',
|
|
type: 'line',
|
|
data: predictedTempData,
|
|
lineStyle: {
|
|
color: theme.palette.warning.main,
|
|
width: 2,
|
|
type: 'dashed'
|
|
},
|
|
itemStyle: {
|
|
color: theme.palette.warning.main,
|
|
opacity: 0.8
|
|
}
|
|
}
|
|
]
|
|
};
|
|
}, [data, theme]);
|
|
|
|
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 temperature 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) => {
|
|
// Custom comparison to prevent unnecessary rerenders
|
|
if (prevProps.height !== nextProps.height) return false;
|
|
if (prevProps.zoomRange?.[0] !== nextProps.zoomRange?.[0] ||
|
|
prevProps.zoomRange?.[1] !== nextProps.zoomRange?.[1]) return false;
|
|
if (prevProps.data.length !== nextProps.data.length) return false;
|
|
|
|
// Only update if the last data point has changed
|
|
const prevLast = prevProps.data[prevProps.data.length - 1];
|
|
const nextLast = nextProps.data[nextProps.data.length - 1];
|
|
if (prevLast && nextLast) {
|
|
return prevLast.currentTimestamp.getTime() === nextLast.currentTimestamp.getTime() &&
|
|
prevLast.currentTemp === nextLast.currentTemp &&
|
|
prevLast.predictedTemp === nextLast.predictedTemp;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
TemperatureEChart.displayName = 'TemperatureEChart';
|
|
|
|
export default TemperatureEChart; |