u
This commit is contained in:
133
uiserver/src/components/Chart.js
vendored
133
uiserver/src/components/Chart.js
vendored
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user