forked from BLC/AyposWeb
updated mainteanace charts
This commit is contained in:
440
src/components/Charts/MaintenanceChart.tsx
Normal file
440
src/components/Charts/MaintenanceChart.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
interface MaintenanceDataPoint {
|
||||
currentTimestamp: Date;
|
||||
futureTimestamp: Date;
|
||||
currentPower: number;
|
||||
predictedPower: number;
|
||||
positive3p: number;
|
||||
negative3p: number;
|
||||
positive7p: number;
|
||||
negative7p: number;
|
||||
}
|
||||
|
||||
interface MaintenanceChartProps {
|
||||
data: MaintenanceDataPoint[];
|
||||
height?: number;
|
||||
zoomRange?: [number, number];
|
||||
onZoomChange?: (range: [number, number]) => void;
|
||||
}
|
||||
|
||||
const MaintenanceChart: React.FC<MaintenanceChartProps> = React.memo(({
|
||||
data,
|
||||
height = 400,
|
||||
zoomRange,
|
||||
onZoomChange
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const commonSeriesSettings = {
|
||||
sampling: 'lttb',
|
||||
animation: false,
|
||||
emphasis: {
|
||||
focus: 'self',
|
||||
scale: 1,
|
||||
itemStyle: {
|
||||
borderWidth: 3,
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(0,0,0,0.2)'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 4
|
||||
}
|
||||
},
|
||||
blur: {
|
||||
lineStyle: {
|
||||
opacity: 0.8
|
||||
},
|
||||
itemStyle: {
|
||||
opacity: 0.8
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const option = useMemo(() => {
|
||||
if (!data || data.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const currentData = data.map(item => [
|
||||
item.currentTimestamp.getTime(),
|
||||
Number(item.currentPower.toFixed(2))
|
||||
]);
|
||||
|
||||
const predictedData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.predictedPower.toFixed(2))
|
||||
]);
|
||||
|
||||
const positive3pData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.positive3p.toFixed(2))
|
||||
]);
|
||||
|
||||
const negative3pData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.negative3p.toFixed(2))
|
||||
]);
|
||||
|
||||
const positive7pData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.positive7p.toFixed(2))
|
||||
]);
|
||||
|
||||
const negative7pData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.negative7p.toFixed(2))
|
||||
]);
|
||||
|
||||
const allValues = [
|
||||
...data.map(d => d.currentPower),
|
||||
...data.map(d => d.predictedPower),
|
||||
...data.map(d => d.positive3p),
|
||||
...data.map(d => d.negative3p),
|
||||
...data.map(d => d.positive7p),
|
||||
...data.map(d => d.negative7p)
|
||||
];
|
||||
|
||||
const minValue = Math.min(...allValues);
|
||||
const maxValue = Math.max(...allValues);
|
||||
const valuePadding = (maxValue - minValue) * 0.1;
|
||||
|
||||
return {
|
||||
animation: false,
|
||||
progressive: 500,
|
||||
progressiveThreshold: 1000,
|
||||
renderer: 'canvas',
|
||||
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]}W</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['Current Power', 'Predicted Power', '±3% Threshold', '±7% Threshold'],
|
||||
bottom: '60px',
|
||||
left: 'center',
|
||||
padding: [8, 16],
|
||||
itemGap: 32,
|
||||
textStyle: {
|
||||
fontFamily: 'Montserrat, sans-serif',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: theme.palette.text.secondary
|
||||
},
|
||||
icon: 'circle',
|
||||
itemHeight: 10,
|
||||
itemWidth: 10,
|
||||
selectedMode: false,
|
||||
backgroundColor: 'transparent'
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '3%',
|
||||
bottom: '100px',
|
||||
top: '20px',
|
||||
containLabel: true
|
||||
},
|
||||
toolbox: {
|
||||
right: '20px',
|
||||
top: '10px',
|
||||
feature: {
|
||||
dataZoom: {
|
||||
yAxisIndex: 'none',
|
||||
title: {
|
||||
zoom: 'Zoom',
|
||||
back: 'Reset Zoom'
|
||||
}
|
||||
},
|
||||
restore: {
|
||||
title: 'Reset'
|
||||
},
|
||||
saveAsImage: {
|
||||
title: 'Save'
|
||||
}
|
||||
}
|
||||
},
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'slider',
|
||||
show: true,
|
||||
xAxisIndex: [0],
|
||||
start: zoomRange?.[0] ?? 0,
|
||||
end: zoomRange?.[1] ?? 100,
|
||||
height: 30,
|
||||
bottom: 20,
|
||||
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',
|
||||
fontSize: 12
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: theme.palette.divider
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.divider,
|
||||
type: 'dashed',
|
||||
opacity: 0.5
|
||||
}
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Power (W)',
|
||||
nameTextStyle: {
|
||||
color: theme.palette.text.secondary,
|
||||
fontFamily: 'Montserrat, sans-serif',
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold',
|
||||
padding: [0, 0, 8, 0]
|
||||
},
|
||||
min: Math.max(0, minValue - valuePadding),
|
||||
max: maxValue + valuePadding,
|
||||
axisLabel: {
|
||||
formatter: '{value}W',
|
||||
color: theme.palette.text.secondary,
|
||||
fontFamily: 'Montserrat, sans-serif',
|
||||
fontSize: 12
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: theme.palette.divider
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: theme.palette.divider,
|
||||
type: 'dashed',
|
||||
opacity: 0.3
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
...commonSeriesSettings,
|
||||
name: 'Current Power',
|
||||
type: 'line',
|
||||
data: currentData,
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.primary.main,
|
||||
width: 3
|
||||
},
|
||||
itemStyle: {
|
||||
color: theme.palette.primary.main,
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [{
|
||||
offset: 0,
|
||||
color: `rgba(2, 138, 74, 0.3)`
|
||||
}, {
|
||||
offset: 1,
|
||||
color: `rgba(2, 138, 74, 0.05)`
|
||||
}]
|
||||
}
|
||||
},
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
showSymbol: true
|
||||
},
|
||||
{
|
||||
...commonSeriesSettings,
|
||||
name: 'Predicted Power',
|
||||
type: 'line',
|
||||
data: predictedData,
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.warning.main,
|
||||
width: 3,
|
||||
type: 'dashed'
|
||||
},
|
||||
itemStyle: {
|
||||
color: theme.palette.warning.main,
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
showSymbol: true
|
||||
},
|
||||
{
|
||||
...commonSeriesSettings,
|
||||
name: '±3% Threshold',
|
||||
type: 'line',
|
||||
data: positive3pData.concat(negative3pData.reverse()),
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.warning.light,
|
||||
width: 1,
|
||||
type: 'dashed'
|
||||
},
|
||||
areaStyle: {
|
||||
color: theme.palette.warning.light,
|
||||
opacity: 0.1
|
||||
},
|
||||
symbol: 'none'
|
||||
},
|
||||
{
|
||||
...commonSeriesSettings,
|
||||
name: '±7% Threshold',
|
||||
type: 'line',
|
||||
data: positive7pData.concat(negative7pData.reverse()),
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.error.light,
|
||||
width: 1,
|
||||
type: 'dashed'
|
||||
},
|
||||
areaStyle: {
|
||||
color: theme.palette.error.light,
|
||||
opacity: 0.1
|
||||
},
|
||||
symbol: 'none'
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data, theme, 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 maintenance data available
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<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) => {
|
||||
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;
|
||||
|
||||
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.currentPower !== nextItem.currentPower ||
|
||||
prevItem.predictedPower !== nextItem.predictedPower) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
MaintenanceChart.displayName = 'MaintenanceChart';
|
||||
|
||||
export default MaintenanceChart;
|
||||
441
src/components/Charts/MaintenanceChart.tsx.bak
Normal file
441
src/components/Charts/MaintenanceChart.tsx.bak
Normal file
@@ -0,0 +1,441 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
fontWeight: 500,
|
||||
color: theme.palette.text.secondary
|
||||
},
|
||||
icon: 'circle',
|
||||
itemHeight: 10,
|
||||
itemWidth: 10,
|
||||
selectedMode: false
|
||||
},t { Box, Typography } from '@mui/material';
|
||||
|
||||
interface MaintenanceDataPoint {
|
||||
currentTimestamp: Date;
|
||||
futureTimestamp: Date;
|
||||
currentPower: number;
|
||||
predictedPower: number;
|
||||
positive3p: number;
|
||||
negative3p: number;
|
||||
positive7p: number;
|
||||
negative7p: number;
|
||||
}
|
||||
|
||||
interface MaintenanceChartProps {
|
||||
data: MaintenanceDataPoint[];
|
||||
height?: number;
|
||||
zoomRange?: [number, number];
|
||||
onZoomChange?: (range: [number, number]) => void;
|
||||
}
|
||||
|
||||
const MaintenanceChart: React.FC<MaintenanceChartProps> = React.memo(({
|
||||
data,
|
||||
height = 400,
|
||||
zoomRange,
|
||||
onZoomChange
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const commonSeriesSettings = {
|
||||
sampling: 'lttb',
|
||||
animation: false,
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
itemStyle: {
|
||||
borderWidth: 3,
|
||||
shadowBlur: 10
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const option = useMemo(() => {
|
||||
if (!data || data.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const currentData = data.map(item => [
|
||||
item.currentTimestamp.getTime(),
|
||||
Number(item.currentPower.toFixed(2))
|
||||
]);
|
||||
|
||||
const predictedData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.predictedPower.toFixed(2))
|
||||
]);
|
||||
|
||||
const positive3pData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.positive3p.toFixed(2))
|
||||
]);
|
||||
|
||||
const negative3pData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.negative3p.toFixed(2))
|
||||
]);
|
||||
|
||||
const positive7pData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.positive7p.toFixed(2))
|
||||
]);
|
||||
|
||||
const negative7pData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.negative7p.toFixed(2))
|
||||
]);
|
||||
|
||||
const allValues = [
|
||||
...data.map(d => d.currentPower),
|
||||
...data.map(d => d.predictedPower),
|
||||
...data.map(d => d.positive3p),
|
||||
...data.map(d => d.negative3p),
|
||||
...data.map(d => d.positive7p),
|
||||
...data.map(d => d.negative7p)
|
||||
];
|
||||
|
||||
const minValue = Math.min(...allValues);
|
||||
const maxValue = Math.max(...allValues);
|
||||
const valuePadding = (maxValue - minValue) * 0.1;
|
||||
|
||||
return {
|
||||
animation: false,
|
||||
progressive: 500,
|
||||
progressiveThreshold: 1000,
|
||||
renderer: 'canvas',
|
||||
title: {
|
||||
text: 'Preventive Maintenance Monitoring',
|
||||
subtext: 'Power consumption with predictive thresholds',
|
||||
textStyle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: theme.palette.text.primary,
|
||||
fontFamily: 'Montserrat, sans-serif'
|
||||
},
|
||||
subtextStyle: {
|
||||
fontSize: 14,
|
||||
color: theme.palette.text.secondary,
|
||||
fontFamily: 'Montserrat, sans-serif'
|
||||
},
|
||||
left: '5%',
|
||||
top: 0,
|
||||
padding: [0, 0, 12, 0]
|
||||
},
|
||||
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]}W</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['Current Power', 'Predicted Power', '+3% Threshold', '-3% Threshold', '+7% Threshold', '-7% Threshold'],
|
||||
top: 40,
|
||||
textStyle: {
|
||||
fontFamily: 'Montserrat, sans-serif',
|
||||
fontSize: 12,
|
||||
color: theme.palette.text.secondary
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '5%',
|
||||
right: '5%',
|
||||
bottom: '12%',
|
||||
top: '15%',
|
||||
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: 'Power (W)',
|
||||
nameTextStyle: {
|
||||
color: theme.palette.text.secondary,
|
||||
fontFamily: 'Montserrat, sans-serif',
|
||||
fontWeight: 'bold'
|
||||
},
|
||||
min: Math.max(0, minValue - valuePadding),
|
||||
max: maxValue + valuePadding,
|
||||
axisLabel: {
|
||||
formatter: '{value}W',
|
||||
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 Power',
|
||||
type: 'line',
|
||||
data: currentData,
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.primary.main,
|
||||
width: 3
|
||||
},
|
||||
itemStyle: {
|
||||
color: theme.palette.primary.main,
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [{
|
||||
offset: 0,
|
||||
color: `rgba(2, 138, 74, 0.3)`
|
||||
}, {
|
||||
offset: 1,
|
||||
color: `rgba(2, 138, 74, 0.05)`
|
||||
}]
|
||||
}
|
||||
},
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
showSymbol: true
|
||||
},
|
||||
{
|
||||
...commonSeriesSettings,
|
||||
name: 'Predicted Power',
|
||||
type: 'line',
|
||||
data: predictedData,
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.warning.main,
|
||||
width: 3,
|
||||
type: 'dashed'
|
||||
},
|
||||
itemStyle: {
|
||||
color: theme.palette.warning.main,
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
showSymbol: true
|
||||
},
|
||||
{
|
||||
...commonSeriesSettings,
|
||||
name: '±3% Threshold',
|
||||
type: 'line',
|
||||
data: positive3pData.concat(negative3pData.reverse()),
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.warning.light,
|
||||
width: 1,
|
||||
type: 'dashed'
|
||||
},
|
||||
areaStyle: {
|
||||
color: theme.palette.warning.light,
|
||||
opacity: 0.1
|
||||
},
|
||||
symbol: 'none'
|
||||
},
|
||||
{
|
||||
...commonSeriesSettings,
|
||||
name: '±7% Threshold',
|
||||
type: 'line',
|
||||
data: positive7pData.concat(negative7pData.reverse()),
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.error.light,
|
||||
width: 1,
|
||||
type: 'dashed'
|
||||
},
|
||||
areaStyle: {
|
||||
color: theme.palette.error.light,
|
||||
opacity: 0.1
|
||||
},
|
||||
symbol: 'none'
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data, theme, 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 maintenance 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) => {
|
||||
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;
|
||||
|
||||
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.currentPower !== nextItem.currentPower ||
|
||||
prevItem.predictedPower !== nextItem.predictedPower) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
MaintenanceChart.displayName = 'MaintenanceChart';
|
||||
|
||||
export default MaintenanceChart;
|
||||
446
src/components/Charts/MaintenanceChart.tsx.new
Normal file
446
src/components/Charts/MaintenanceChart.tsx.new
Normal file
@@ -0,0 +1,446 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
interface MaintenanceDataPoint {
|
||||
currentTimestamp: Date;
|
||||
futureTimestamp: Date;
|
||||
currentPower: number;
|
||||
predictedPower: number;
|
||||
positive3p: number;
|
||||
negative3p: number;
|
||||
positive7p: number;
|
||||
negative7p: number;
|
||||
}
|
||||
|
||||
interface MaintenanceChartProps {
|
||||
data: MaintenanceDataPoint[];
|
||||
height?: number;
|
||||
zoomRange?: [number, number];
|
||||
onZoomChange?: (range: [number, number]) => void;
|
||||
}
|
||||
|
||||
const MaintenanceChart: React.FC<MaintenanceChartProps> = React.memo(({
|
||||
data,
|
||||
height = 400,
|
||||
zoomRange,
|
||||
onZoomChange
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const commonSeriesSettings = {
|
||||
sampling: 'lttb',
|
||||
animation: false,
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
itemStyle: {
|
||||
borderWidth: 3,
|
||||
shadowBlur: 10
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const option = useMemo(() => {
|
||||
if (!data || data.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const currentData = data.map(item => [
|
||||
item.currentTimestamp.getTime(),
|
||||
Number(item.currentPower.toFixed(2))
|
||||
]);
|
||||
|
||||
const predictedData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.predictedPower.toFixed(2))
|
||||
]);
|
||||
|
||||
const positive3pData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.positive3p.toFixed(2))
|
||||
]);
|
||||
|
||||
const negative3pData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.negative3p.toFixed(2))
|
||||
]);
|
||||
|
||||
const positive7pData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.positive7p.toFixed(2))
|
||||
]);
|
||||
|
||||
const negative7pData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.negative7p.toFixed(2))
|
||||
]);
|
||||
|
||||
const allValues = [
|
||||
...data.map(d => d.currentPower),
|
||||
...data.map(d => d.predictedPower),
|
||||
...data.map(d => d.positive3p),
|
||||
...data.map(d => d.negative3p),
|
||||
...data.map(d => d.positive7p),
|
||||
...data.map(d => d.negative7p)
|
||||
];
|
||||
|
||||
const minValue = Math.min(...allValues);
|
||||
const maxValue = Math.max(...allValues);
|
||||
const valuePadding = (maxValue - minValue) * 0.1;
|
||||
|
||||
return {
|
||||
animation: false,
|
||||
progressive: 500,
|
||||
progressiveThreshold: 1000,
|
||||
renderer: 'canvas',
|
||||
title: {
|
||||
text: 'Preventive Maintenance Monitoring',
|
||||
subtext: 'Power consumption with predictive thresholds',
|
||||
textStyle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: theme.palette.text.primary,
|
||||
fontFamily: 'Montserrat, sans-serif'
|
||||
},
|
||||
subtextStyle: {
|
||||
fontSize: 14,
|
||||
color: theme.palette.text.secondary,
|
||||
fontFamily: 'Montserrat, sans-serif'
|
||||
},
|
||||
left: '5%',
|
||||
top: 0,
|
||||
padding: [0, 0, 12, 0]
|
||||
},
|
||||
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]}W</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['Current Power', 'Predicted Power', '\u00b13% Threshold', '\u00b17% Threshold'],
|
||||
top: 'top',
|
||||
right: '5%',
|
||||
padding: [8, 16],
|
||||
itemGap: 24,
|
||||
textStyle: {
|
||||
fontFamily: 'Montserrat, sans-serif',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: theme.palette.text.secondary
|
||||
},
|
||||
icon: 'circle',
|
||||
itemHeight: 10,
|
||||
itemWidth: 10,
|
||||
selectedMode: false
|
||||
},
|
||||
grid: {
|
||||
left: '5%',
|
||||
right: '5%',
|
||||
bottom: '12%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
toolbox: {
|
||||
right: 90,
|
||||
top: 0,
|
||||
feature: {
|
||||
dataZoom: {
|
||||
yAxisIndex: 'none',
|
||||
title: {
|
||||
zoom: 'Zoom',
|
||||
back: 'Reset Zoom'
|
||||
}
|
||||
},
|
||||
restore: {
|
||||
title: 'Reset'
|
||||
},
|
||||
saveAsImage: {
|
||||
title: 'Save'
|
||||
}
|
||||
}
|
||||
},
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'slider',
|
||||
show: true,
|
||||
xAxisIndex: [0],
|
||||
start: zoomRange?.[0] ?? 0,
|
||||
end: zoomRange?.[1] ?? 100,
|
||||
height: 20,
|
||||
bottom: 10,
|
||||
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',
|
||||
fontSize: 12
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: theme.palette.divider
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.divider,
|
||||
type: 'dashed',
|
||||
opacity: 0.5
|
||||
}
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Power (W)',
|
||||
nameTextStyle: {
|
||||
color: theme.palette.text.secondary,
|
||||
fontFamily: 'Montserrat, sans-serif',
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold',
|
||||
padding: [0, 0, 8, 0]
|
||||
},
|
||||
min: Math.max(0, minValue - valuePadding),
|
||||
max: maxValue + valuePadding,
|
||||
axisLabel: {
|
||||
formatter: '{value}W',
|
||||
color: theme.palette.text.secondary,
|
||||
fontFamily: 'Montserrat, sans-serif',
|
||||
fontSize: 12
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: theme.palette.divider
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: theme.palette.divider,
|
||||
type: 'dashed',
|
||||
opacity: 0.3
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
...commonSeriesSettings,
|
||||
name: 'Current Power',
|
||||
type: 'line',
|
||||
data: currentData,
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.primary.main,
|
||||
width: 3
|
||||
},
|
||||
itemStyle: {
|
||||
color: theme.palette.primary.main,
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [{
|
||||
offset: 0,
|
||||
color: `rgba(2, 138, 74, 0.3)`
|
||||
}, {
|
||||
offset: 1,
|
||||
color: `rgba(2, 138, 74, 0.05)`
|
||||
}]
|
||||
}
|
||||
},
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
showSymbol: true
|
||||
},
|
||||
{
|
||||
...commonSeriesSettings,
|
||||
name: 'Predicted Power',
|
||||
type: 'line',
|
||||
data: predictedData,
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.warning.main,
|
||||
width: 3,
|
||||
type: 'dashed'
|
||||
},
|
||||
itemStyle: {
|
||||
color: theme.palette.warning.main,
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
showSymbol: true
|
||||
},
|
||||
{
|
||||
...commonSeriesSettings,
|
||||
name: '\u00b13% Threshold',
|
||||
type: 'line',
|
||||
data: positive3pData.concat(negative3pData.reverse()),
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.warning.light,
|
||||
width: 1,
|
||||
type: 'dashed'
|
||||
},
|
||||
areaStyle: {
|
||||
color: theme.palette.warning.light,
|
||||
opacity: 0.1
|
||||
},
|
||||
symbol: 'none'
|
||||
},
|
||||
{
|
||||
...commonSeriesSettings,
|
||||
name: '\u00b17% Threshold',
|
||||
type: 'line',
|
||||
data: positive7pData.concat(negative7pData.reverse()),
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.error.light,
|
||||
width: 1,
|
||||
type: 'dashed'
|
||||
},
|
||||
areaStyle: {
|
||||
color: theme.palette.error.light,
|
||||
opacity: 0.1
|
||||
},
|
||||
symbol: 'none'
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data, theme, 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 maintenance data available
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: theme.palette.background.paper,
|
||||
borderRadius: 2,
|
||||
boxShadow: 3,
|
||||
overflow: 'hidden',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<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) => {
|
||||
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;
|
||||
|
||||
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.currentPower !== nextItem.currentPower ||
|
||||
prevItem.predictedPower !== nextItem.predictedPower) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
MaintenanceChart.displayName = 'MaintenanceChart';
|
||||
|
||||
export default MaintenanceChart;
|
||||
@@ -1,31 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Paper, Typography, Fade, useTheme, AppBar, Toolbar, Chip, Slider} from '@mui/material';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
TimeScale
|
||||
} from 'chart.js';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { Box, Paper, Typography, AppBar, Toolbar, Chip } from '@mui/material';
|
||||
import { config } from '../config/env';
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
TimeScale
|
||||
);
|
||||
import MaintenanceChart from '../components/Charts/MaintenanceChart';
|
||||
|
||||
interface DataItem {
|
||||
now_timestamp: string;
|
||||
@@ -39,378 +15,181 @@ interface DataItem {
|
||||
flag: string;
|
||||
}
|
||||
|
||||
const API_BASE_URL = config.apiUrl;
|
||||
|
||||
const Maintenance = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const [data, setData] = useState<DataItem[]>([]);
|
||||
const [currentFlag, setCurrentFlag] = useState<string>('');
|
||||
const [, setLoading] = useState(true);
|
||||
const [windowSize, setWindowSize] = useState(20);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [windowSize, setWindowSize] = useState<20 | 50 | 100>(20);
|
||||
const [zoomRange, setZoomRange] = useState<[number, number]>([0, 100]);
|
||||
|
||||
const lastFetchRef = useRef<number>(0);
|
||||
const THROTTLE_INTERVAL = 2000; // Minimum 2 seconds between updates
|
||||
|
||||
// Reset zoom when window size changes
|
||||
useEffect(() => {
|
||||
setZoomRange([0, 100]);
|
||||
setData([]); // Reset data when window size changes
|
||||
}, [windowSize]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const now = Date.now();
|
||||
if (now - lastFetchRef.current < THROTTLE_INTERVAL || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
lastFetchRef.current = now;
|
||||
|
||||
const response = await fetch(`${config.apiUrl}/prom/get_chart_data/maintenance/${windowSize}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.data && result.data.length > 0) {
|
||||
const newData = [...result.data]
|
||||
.filter((item: DataItem) =>
|
||||
item.now_timestamp &&
|
||||
item.future_timestamp &&
|
||||
item.power &&
|
||||
item.power_future_min &&
|
||||
item.positive_3p &&
|
||||
item.negative_3p &&
|
||||
item.positive_7p &&
|
||||
item.negative_7p
|
||||
)
|
||||
.sort((a, b) => new Date(a.now_timestamp).getTime() - new Date(b.now_timestamp).getTime());
|
||||
|
||||
setCurrentFlag(newData[newData.length - 1].flag);
|
||||
setData(newData.slice(-windowSize));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [windowSize, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_BASE_URL}/prom/get_chart_data/maintenance/20`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.data && result.data.length > 0) {
|
||||
const last20Data = result.data.slice(-20);
|
||||
setCurrentFlag(last20Data[last20Data.length - 1].flag);
|
||||
setData(last20Data);
|
||||
console.log('Fetched data:', last20Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [fetchData]);
|
||||
|
||||
// Process data for charts with sliding window
|
||||
const prepareChartData = () => {
|
||||
if (!data || data.length === 0) {
|
||||
console.log('No data available, using fallback data');
|
||||
// Fallback data for testing
|
||||
const now = new Date();
|
||||
const fallbackData = Array.from({ length: 10 }, (_, i) => ({
|
||||
currentTimestamp: new Date(now.getTime() - (9 - i) * 60000), // 1 minute intervals
|
||||
futureTimestamp: new Date(now.getTime() - (9 - i) * 60000 + 3 * 60000), // 3 minutes in the future
|
||||
currentPower: 95 + Math.random() * 10,
|
||||
predictedPower: 115 + Math.random() * 10,
|
||||
positive3p: 118 + Math.random() * 5,
|
||||
negative3p: 112 + Math.random() * 5,
|
||||
positive7p: 123 + Math.random() * 5,
|
||||
negative7p: 107 + Math.random() * 5,
|
||||
}));
|
||||
return fallbackData;
|
||||
}
|
||||
|
||||
const processedData = data.map(item => {
|
||||
const currentPower = parseFloat(item.power) || 0;
|
||||
const predictedPower = parseFloat(item.power_future_min) || currentPower * 1.1; // Fallback to 10% higher than current
|
||||
|
||||
return {
|
||||
currentTimestamp: new Date(item.now_timestamp),
|
||||
futureTimestamp: new Date(item.future_timestamp),
|
||||
currentPower: currentPower,
|
||||
predictedPower: predictedPower,
|
||||
positive3p: parseFloat(item.positive_3p) || predictedPower * 1.03,
|
||||
negative3p: parseFloat(item.negative_3p) || predictedPower * 0.97,
|
||||
positive7p: parseFloat(item.positive_7p) || predictedPower * 1.07,
|
||||
negative7p: parseFloat(item.negative_7p) || predictedPower * 0.93,
|
||||
};
|
||||
});
|
||||
|
||||
// Apply sliding window - show only last N records
|
||||
const slidingData = processedData.slice(-windowSize);
|
||||
|
||||
console.log('Processed chart data:', {
|
||||
totalRecords: processedData.length,
|
||||
showingRecords: slidingData.length,
|
||||
timeRange: {
|
||||
start: slidingData[0]?.currentTimestamp,
|
||||
end: slidingData[slidingData.length - 1]?.currentTimestamp
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Data validation:', {
|
||||
hasCurrentPower: slidingData.some(d => d.currentPower > 0),
|
||||
hasPredictedPower: slidingData.some(d => d.predictedPower > 0),
|
||||
currentPowerRange: [Math.min(...slidingData.map(d => d.currentPower)), Math.max(...slidingData.map(d => d.currentPower))],
|
||||
predictedPowerRange: [Math.min(...slidingData.map(d => d.predictedPower)), Math.max(...slidingData.map(d => d.predictedPower))],
|
||||
rawDataSample: data.slice(-2).map(item => ({
|
||||
power: item.power,
|
||||
power_future_min: item.power_future_min,
|
||||
parsedCurrent: parseFloat(item.power),
|
||||
parsedPredicted: parseFloat(item.power_future_min)
|
||||
}))
|
||||
});
|
||||
|
||||
return slidingData;
|
||||
};
|
||||
|
||||
const chartData = prepareChartData();
|
||||
|
||||
// Prepare Chart.js data structure
|
||||
const chartJsData = {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Current Power',
|
||||
data: chartData.map(item => ({
|
||||
x: item.currentTimestamp,
|
||||
y: item.currentPower
|
||||
})),
|
||||
borderColor: '#028a4a',
|
||||
backgroundColor: '#028a4a',
|
||||
pointBackgroundColor: '#028a4a',
|
||||
pointBorderColor: '#028a4a',
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 6,
|
||||
tension: 0.4,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: 'Predicted (Dynamic)',
|
||||
data: chartData.map(item => ({
|
||||
x: item.futureTimestamp,
|
||||
y: item.predictedPower
|
||||
})),
|
||||
borderColor: '#ff9800',
|
||||
backgroundColor: '#ff9800',
|
||||
pointBackgroundColor: '#ff9800',
|
||||
pointBorderColor: '#ff9800',
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 6,
|
||||
tension: 0.4,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: '+3% Positive',
|
||||
data: chartData.map(item => ({
|
||||
x: item.futureTimestamp,
|
||||
y: item.positive3p
|
||||
})),
|
||||
borderColor: '#ffb400',
|
||||
backgroundColor: 'rgba(255, 180, 0, 0.1)',
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: '-3% Negative',
|
||||
data: chartData.map(item => ({
|
||||
x: item.futureTimestamp,
|
||||
y: item.negative3p
|
||||
})),
|
||||
borderColor: '#ffb400',
|
||||
backgroundColor: 'rgba(255, 180, 0, 0.1)',
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: '+7% Positive',
|
||||
data: chartData.map(item => ({
|
||||
x: item.futureTimestamp,
|
||||
y: item.positive7p
|
||||
})),
|
||||
borderColor: '#FF1744',
|
||||
backgroundColor: 'rgba(255, 23, 68, 0.1)',
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: '-7% Negative',
|
||||
data: chartData.map(item => ({
|
||||
x: item.futureTimestamp,
|
||||
y: item.negative7p
|
||||
})),
|
||||
borderColor: '#FF1744',
|
||||
backgroundColor: 'rgba(255, 23, 68, 0.1)',
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
fill: false
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Chart.js options
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
font: {
|
||||
size: 12,
|
||||
weight: 'normal' as const
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: function(context: any) {
|
||||
const date = new Date(context[0].parsed.x);
|
||||
return date.toLocaleTimeString();
|
||||
},
|
||||
label: function(context: any) {
|
||||
return `${context.dataset.label}: ${context.parsed.y.toFixed(2)} W`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time' as const,
|
||||
time: {
|
||||
displayFormats: {
|
||||
minute: 'HH:mm:ss'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Time'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Power (W)'
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value: any) {
|
||||
return `${value} W`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest' as const,
|
||||
axis: 'x' as const,
|
||||
intersect: false
|
||||
}
|
||||
};
|
||||
|
||||
// Debug logging
|
||||
console.log('Chart.js data structure:', chartJsData);
|
||||
// Transform data for the maintenance chart
|
||||
const chartData = useMemo(() => {
|
||||
return data.map(item => ({
|
||||
currentTimestamp: new Date(item.now_timestamp),
|
||||
futureTimestamp: new Date(item.future_timestamp),
|
||||
currentPower: parseFloat(item.power),
|
||||
predictedPower: parseFloat(item.power_future_min),
|
||||
positive3p: parseFloat(item.positive_3p),
|
||||
negative3p: parseFloat(item.negative_3p),
|
||||
positive7p: parseFloat(item.positive_7p),
|
||||
negative7p: parseFloat(item.negative_7p)
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, bgcolor: theme.palette.background.default }}>
|
||||
<AppBar
|
||||
position="static"
|
||||
elevation={0}
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
mb: 3
|
||||
position: 'relative',
|
||||
minHeight: '500px',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{ px: { xs: 2, sm: 4 } }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
component="h1"
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
fontWeight: 500,
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
letterSpacing: '-0.5px'
|
||||
}}
|
||||
>
|
||||
Preventive Maintenance
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
{currentFlag && (
|
||||
<Chip
|
||||
label={currentFlag}
|
||||
color={currentFlag === 'Correct Estimation for PM energy' ? 'success' : 'warning'}
|
||||
size="medium"
|
||||
<AppBar
|
||||
position="static"
|
||||
color="transparent"
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: theme => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.02)'
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{ px: 3 }}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: 'text.primary',
|
||||
lineHeight: 1.3
|
||||
}}
|
||||
>
|
||||
Preventive Maintenance Monitoring
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Real-time power consumption with predictive indicators
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ ml: 'auto', display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={`Status: ${currentFlag}`}
|
||||
color={currentFlag === 'normal' ? 'success' : 'warning'}
|
||||
sx={{
|
||||
minWidth: 120,
|
||||
height: 32,
|
||||
'& .MuiChip-label': {
|
||||
px: 2,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600
|
||||
},
|
||||
animation: 'pulse 2s infinite',
|
||||
'@keyframes pulse': {
|
||||
'0%': {
|
||||
boxShadow: '0 0 0 0 rgba(0, 0, 0, 0.2)',
|
||||
},
|
||||
'70%': {
|
||||
boxShadow: '0 0 0 6px rgba(0, 0, 0, 0)',
|
||||
},
|
||||
'100%': {
|
||||
boxShadow: '0 0 0 0 rgba(0, 0, 0, 0)',
|
||||
},
|
||||
}
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
label={`Showing last ${windowSize} records`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
height: 24,
|
||||
'& .MuiChip-label': {
|
||||
px: 1.5,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500
|
||||
},
|
||||
borderColor: 'rgba(0, 0, 0, 0.2)',
|
||||
color: 'rgba(0, 0, 0, 0.7)'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{ p: { xs: 2, sm: 4 } }}>
|
||||
<Fade in timeout={800}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: { xs: 2, sm: 3 },
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
justifyContent: 'flex-end'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ height: 'calc(100vh - 280px)', minHeight: '500px' }}>
|
||||
<Line data={chartJsData} options={chartOptions} />
|
||||
</Box>
|
||||
|
||||
{/* Chart Controls */}
|
||||
<Box sx={{
|
||||
mt: 2,
|
||||
p: 2,
|
||||
bgcolor: 'background.default',
|
||||
borderRadius: 1,
|
||||
border: `1px solid ${theme.palette.divider}`
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
|
||||
Chart Settings
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ minWidth: 120 }}>
|
||||
Records to show:
|
||||
</Typography>
|
||||
<Slider
|
||||
value={windowSize}
|
||||
onChange={(_, value) => setWindowSize(value as number)}
|
||||
min={5}
|
||||
max={50}
|
||||
step={5}
|
||||
marks={[
|
||||
{ value: 5, label: '5' },
|
||||
{ value: 20, label: '20' },
|
||||
{ value: 50, label: '50' }
|
||||
]}
|
||||
sx={{ flex: 1, maxWidth: 200 }}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ minWidth: 40 }}>
|
||||
{windowSize}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Fade>
|
||||
</Box>
|
||||
{[20, 50, 100].map((size) => (
|
||||
<Paper
|
||||
key={size}
|
||||
elevation={0}
|
||||
onClick={() => setWindowSize(size as 20 | 50 | 100)}
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: 1.5,
|
||||
border: 1,
|
||||
borderColor: windowSize === size ? 'primary.main' : 'divider',
|
||||
cursor: 'pointer',
|
||||
bgcolor: windowSize === size ? 'primary.main' : 'transparent',
|
||||
color: windowSize === size ? 'primary.contrastText' : 'text.primary',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
bgcolor: windowSize === size ? 'primary.dark' : 'action.hover',
|
||||
borderColor: windowSize === size ? 'primary.dark' : 'primary.main'
|
||||
},
|
||||
transition: theme => theme.transitions.create(['all'], {
|
||||
duration: 200
|
||||
})
|
||||
}}
|
||||
>
|
||||
{size} points
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
<MaintenanceChart
|
||||
data={chartData}
|
||||
height={500}
|
||||
zoomRange={zoomRange}
|
||||
onZoomChange={setZoomRange}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user