This commit is contained in:
sebseb7
2025-12-26 01:05:43 +01:00
parent d586d12e68
commit 3d43a42b12
2 changed files with 104 additions and 32 deletions

View File

@@ -113,9 +113,12 @@ export default class Chart extends Component {
this.state = { this.state = {
data: [], data: [],
loading: true, loading: true,
hiddenSeries: {} // { seriesId: true/false } hiddenSeries: {}, // { seriesId: true/false }
lastValues: {}, // { channelId: lastValue } - for detecting changes
flashStates: {} // { channelId: 'up' | 'down' | null } - for flash animation
}; };
this.interval = null; this.interval = null;
this.flashTimeouts = {}; // Store timeouts to clear flash states
} }
componentDidMount() { componentDidMount() {
@@ -156,6 +159,8 @@ export default class Chart extends Component {
if (this.interval) { if (this.interval) {
clearInterval(this.interval); clearInterval(this.interval);
} }
// Clear any pending flash timeouts
Object.values(this.flashTimeouts).forEach(timeout => clearTimeout(timeout));
} }
getEffectiveChannels(props) { getEffectiveChannels(props) {
@@ -189,6 +194,16 @@ export default class Chart extends Component {
fetch(`/api/readings?selection=${encodeURIComponent(selectionStr)}&since=${since}&until=${until}`) fetch(`/api/readings?selection=${encodeURIComponent(selectionStr)}&since=${since}&until=${until}`)
.then(res => res.json()) .then(res => res.json())
.then(dataObj => { .then(dataObj => {
// Safety check: ensure dataObj is a valid object
if (!dataObj || typeof dataObj !== 'object') {
console.error('Invalid data received from API:', dataObj);
this.setState({ data: [], loading: false });
return;
}
// Recalculate effective channels inside callback (closure fix)
const channelList = this.getEffectiveChannels(this.props);
// 1. Parse raw rows into intervals per channel // 1. Parse raw rows into intervals per channel
const intervals = []; const intervals = [];
const timestampsSet = new Set(); const timestampsSet = new Set();
@@ -196,7 +211,10 @@ export default class Chart extends Component {
// dataObj format: { "device:channel": [ [timestamp, value, until], ... ] } // dataObj format: { "device:channel": [ [timestamp, value, until], ... ] }
Object.entries(dataObj).forEach(([id, points]) => { Object.entries(dataObj).forEach(([id, points]) => {
// Check if this ID is in our effective/requested list // Check if this ID is in our effective/requested list
if (!effectiveChannels.includes(id)) return; if (!channelList || !channelList.includes(id)) return;
// Skip if points is not a valid array
if (!Array.isArray(points)) return;
// Ensure sorted by time // Ensure sorted by time
points.sort((a, b) => new Date(a[0]) - new Date(b[0])); points.sort((a, b) => new Date(a[0]) - new Date(b[0]));
@@ -274,6 +292,12 @@ export default class Chart extends Component {
row[inv.id] = inv.val; row[inv.id] = inv.val;
} }
}); });
// Ensure all channel values are numbers or null (MUI-X requirement)
channelList.forEach(ch => {
if (row[ch] !== null && (typeof row[ch] !== 'number' || !Number.isFinite(row[ch]))) {
row[ch] = null;
}
});
return row; return row;
}); });
@@ -289,7 +313,45 @@ export default class Chart extends Component {
}); });
} }
this.setState({ data: processedData, loading: false }); // 5. Detect value changes for flash animation
const effectiveChannels = this.getEffectiveChannels(this.props);
const newLastValues = {};
const newFlashStates = { ...this.state.flashStates };
// Get latest value for each channel (last row)
if (processedData.length > 0) {
const lastRow = processedData[processedData.length - 1];
effectiveChannels.forEach(channelId => {
const newVal = lastRow[channelId];
if (newVal !== null && newVal !== undefined) {
newLastValues[channelId] = newVal;
const oldVal = this.state.lastValues[channelId];
// Only flash if we had a previous value and it changed
if (oldVal !== undefined && oldVal !== newVal) {
const direction = newVal > oldVal ? 'up' : 'down';
newFlashStates[channelId] = direction;
// Clear flash after 1 second
if (this.flashTimeouts[channelId]) {
clearTimeout(this.flashTimeouts[channelId]);
}
this.flashTimeouts[channelId] = setTimeout(() => {
this.setState(prev => ({
flashStates: { ...prev.flashStates, [channelId]: null }
}));
}, 1000);
}
}
});
}
this.setState({
data: processedData,
loading: false,
lastValues: newLastValues,
flashStates: newFlashStates
});
}) })
.catch(err => { .catch(err => {
console.error(err); console.error(err);
@@ -350,7 +412,7 @@ export default class Chart extends Component {
}; };
render() { render() {
const { loading, data, hiddenSeries } = this.state; const { loading, data, hiddenSeries, flashStates } = this.state;
const { channelConfig, windowEnd, range } = this.props; const { channelConfig, windowEnd, range } = this.props;
const effectiveChannels = this.getEffectiveChannels(this.props); const effectiveChannels = this.getEffectiveChannels(this.props);
@@ -456,37 +518,44 @@ export default class Chart extends Component {
<Paper sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}> <Paper sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
{/* Custom Interactive Legend */} {/* Custom Interactive Legend */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, justifyContent: 'center', mb: 1, py: 0.5 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, justifyContent: 'center', mb: 1, py: 0.5 }}>
{legendItems.map(item => ( {legendItems.map(item => {
<Box const flash = flashStates[item.id];
key={item.id} const flashColor = flash === 'up' ? 'rgba(76, 175, 80, 0.4)' : flash === 'down' ? 'rgba(244, 67, 54, 0.4)' : 'transparent';
onClick={() => this.toggleSeries(item.id)} return (
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
opacity: item.hidden ? 0.4 : 1,
textDecoration: item.hidden ? 'line-through' : 'none',
transition: 'opacity 0.2s',
userSelect: 'none',
'&:hover': { opacity: item.hidden ? 0.6 : 0.8 },
}}
>
<Box <Box
key={item.id}
onClick={() => this.toggleSeries(item.id)}
sx={{ sx={{
width: 14, display: 'flex',
height: 14, alignItems: 'center',
borderRadius: '50%', gap: 0.5,
bgcolor: item.color, cursor: 'pointer',
border: '2px solid', opacity: item.hidden ? 0.4 : 1,
borderColor: item.hidden ? 'grey.500' : item.color, textDecoration: item.hidden ? 'line-through' : 'none',
transition: 'opacity 0.2s, background-color 0.3s',
userSelect: 'none',
backgroundColor: flashColor,
borderRadius: 1,
px: 0.5,
'&:hover': { opacity: item.hidden ? 0.6 : 0.8 },
}} }}
/> >
<Typography variant="body2" component="span"> <Box
{item.label} sx={{
</Typography> width: 14,
</Box> height: 14,
))} borderRadius: '50%',
bgcolor: item.color,
border: '2px solid',
borderColor: item.hidden ? 'grey.500' : item.color,
}}
/>
<Typography variant="body2" component="span">
{item.label}
</Typography>
</Box>
);
})}
</Box> </Box>
<Box sx={{ flexGrow: 1, width: '100%', height: '100%' }}> <Box sx={{ flexGrow: 1, width: '100%', height: '100%' }}>
<LineChart <LineChart

View File

@@ -618,6 +618,9 @@ module.exports = {
historyApiFallback: true, historyApiFallback: true,
hot: true, hot: true,
allowedHosts: 'all', allowedHosts: 'all',
client: {
webSocketURL: 'auto://0.0.0.0:0/ws',
},
setupMiddlewares: (middlewares, devServer) => { setupMiddlewares: (middlewares, devServer) => {
if (!devServer) { if (!devServer) {
throw new Error('webpack-dev-server is not defined'); throw new Error('webpack-dev-server is not defined');