198 lines
6.9 KiB
JavaScript
198 lines
6.9 KiB
JavaScript
import React, { Component } from 'react';
|
|
import { Paper, Typography, Box, CircularProgress } from '@mui/material';
|
|
import { LineChart } from '@mui/x-charts/LineChart';
|
|
|
|
export default class Chart extends Component {
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
data: [],
|
|
loading: true
|
|
};
|
|
this.intervalId = null;
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.fetchData();
|
|
this.intervalId = setInterval(this.fetchData, 60000);
|
|
}
|
|
|
|
componentDidUpdate(prevProps) {
|
|
// Compare props to see if we need to refetch
|
|
const prevEffective = this.getEffectiveChannels(prevProps);
|
|
const currEffective = this.getEffectiveChannels(this.props);
|
|
|
|
if (prevEffective.join(',') !== currEffective.join(',')) {
|
|
this.fetchData();
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
if (this.intervalId) {
|
|
clearInterval(this.intervalId);
|
|
}
|
|
}
|
|
|
|
getEffectiveChannels(props) {
|
|
return props.channelConfig
|
|
? props.channelConfig.map(c => c.id)
|
|
: props.selectedChannels;
|
|
}
|
|
|
|
fetchData = () => {
|
|
const effectiveChannels = this.getEffectiveChannels(this.props);
|
|
|
|
// Only fetch if selection exists
|
|
if (effectiveChannels.length === 0) {
|
|
this.setState({ data: [], loading: false });
|
|
return;
|
|
}
|
|
|
|
const selectionStr = effectiveChannels.join(',');
|
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
|
|
fetch(`/api/readings?selection=${encodeURIComponent(selectionStr)}&since=${since}`)
|
|
.then(res => res.json())
|
|
.then(rows => {
|
|
const timeMap = new Map();
|
|
|
|
rows.forEach(row => {
|
|
const id = `${row.device}:${row.channel}`;
|
|
if (!effectiveChannels.includes(id)) return;
|
|
|
|
const time = new Date(row.timestamp).getTime();
|
|
if (!timeMap.has(time)) {
|
|
timeMap.set(time, { time: new Date(row.timestamp) });
|
|
}
|
|
const entry = timeMap.get(time);
|
|
|
|
let val = row.value;
|
|
if (row.data_type === 'json' && !val) {
|
|
val = null;
|
|
}
|
|
entry[id] = val;
|
|
});
|
|
|
|
const sortedData = Array.from(timeMap.values()).sort((a, b) => a.time - b.time);
|
|
this.setState({ data: sortedData, loading: false });
|
|
})
|
|
.catch(err => {
|
|
console.error("Failed to fetch data", err);
|
|
this.setState({ loading: false });
|
|
});
|
|
};
|
|
|
|
computeAxisLimits(axisKey, effectiveChannels, series) {
|
|
// Collect all data points for this axis
|
|
let axisMin = Infinity;
|
|
let axisMax = -Infinity;
|
|
|
|
const axisSeries = series.filter(s => s.yAxisKey === axisKey).map(s => s.dataKey);
|
|
|
|
if (axisSeries.length === 0) return {}; // No data for this axis
|
|
|
|
// Check if config exists for this axis
|
|
const { axisConfig } = this.props;
|
|
let cfgMin = NaN;
|
|
let cfgMax = NaN;
|
|
if (axisConfig && axisConfig[axisKey]) {
|
|
cfgMin = parseFloat(axisConfig[axisKey].min);
|
|
cfgMax = parseFloat(axisConfig[axisKey].max);
|
|
}
|
|
|
|
// Optimization: If no config set, just return empty and let chart autoscale fully.
|
|
if (isNaN(cfgMin) && isNaN(cfgMax)) return {};
|
|
|
|
// Calculate data bounds
|
|
let hasData = false;
|
|
this.state.data.forEach(row => {
|
|
axisSeries.forEach(key => {
|
|
const val = row[key];
|
|
if (val !== null && val !== undefined) {
|
|
hasData = true;
|
|
if (val < axisMin) axisMin = val;
|
|
if (val > axisMax) axisMax = val;
|
|
}
|
|
});
|
|
});
|
|
|
|
if (!hasData) return {}; // No valid data points
|
|
|
|
// Apply config soft limits
|
|
if (!isNaN(cfgMin)) axisMin = Math.min(axisMin, cfgMin);
|
|
if (!isNaN(cfgMax)) axisMax = Math.max(axisMax, cfgMax);
|
|
|
|
return { min: axisMin, max: axisMax };
|
|
}
|
|
|
|
render() {
|
|
const { loading, data } = this.state;
|
|
const { channelConfig } = this.props;
|
|
const effectiveChannels = this.getEffectiveChannels(this.props);
|
|
|
|
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
|
if (effectiveChannels.length === 0) return <Box sx={{ p: 4 }}><Typography>No channels selected.</Typography></Box>;
|
|
|
|
const series = effectiveChannels.map(id => {
|
|
// Find alias and axis if config exists
|
|
let label = id;
|
|
let yAxisKey = 'left';
|
|
if (channelConfig) {
|
|
const item = channelConfig.find(c => c.id === id);
|
|
if (item) {
|
|
if (item.alias) label = item.alias;
|
|
if (item.yAxis) yAxisKey = item.yAxis;
|
|
}
|
|
}
|
|
|
|
return {
|
|
dataKey: id,
|
|
label: label,
|
|
connectNulls: true,
|
|
showMark: false,
|
|
yAxisKey: yAxisKey,
|
|
};
|
|
});
|
|
|
|
const hasRightAxis = series.some(s => s.yAxisKey === 'right');
|
|
|
|
const leftLimits = this.computeAxisLimits('left', effectiveChannels, series);
|
|
const rightLimits = this.computeAxisLimits('right', effectiveChannels, series);
|
|
|
|
const yAxes = [
|
|
{ id: 'left', scaleType: 'linear', ...leftLimits }
|
|
];
|
|
if (hasRightAxis) {
|
|
yAxes.push({ id: 'right', scaleType: 'linear', ...rightLimits });
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ width: '100%', height: '80vh', p: 2 }}>
|
|
<Typography variant="h6" gutterBottom>Last 24 Hours</Typography>
|
|
<Paper sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
<Box sx={{ flexGrow: 1 }}>
|
|
<LineChart
|
|
dataset={data}
|
|
series={series}
|
|
xAxis={[{
|
|
dataKey: 'time',
|
|
scaleType: 'time',
|
|
valueFormatter: (date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
}]}
|
|
yAxis={yAxes}
|
|
rightAxis={hasRightAxis ? 'right' : null}
|
|
slotProps={{
|
|
legend: {
|
|
direction: 'row',
|
|
position: { vertical: 'top', horizontal: 'middle' },
|
|
padding: 0,
|
|
},
|
|
}}
|
|
/>
|
|
</Box>
|
|
</Paper>
|
|
</Box>
|
|
);
|
|
}
|
|
}
|