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 = {
|
||||
data: [],
|
||||
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.flashTimeouts = {}; // Store timeouts to clear flash states
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -156,6 +159,8 @@ export default class Chart extends Component {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
// Clear any pending flash timeouts
|
||||
Object.values(this.flashTimeouts).forEach(timeout => clearTimeout(timeout));
|
||||
}
|
||||
|
||||
getEffectiveChannels(props) {
|
||||
@@ -189,6 +194,16 @@ export default class Chart extends Component {
|
||||
fetch(`/api/readings?selection=${encodeURIComponent(selectionStr)}&since=${since}&until=${until}`)
|
||||
.then(res => res.json())
|
||||
.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
|
||||
const intervals = [];
|
||||
const timestampsSet = new Set();
|
||||
@@ -196,7 +211,10 @@ export default class Chart extends Component {
|
||||
// dataObj format: { "device:channel": [ [timestamp, value, until], ... ] }
|
||||
Object.entries(dataObj).forEach(([id, points]) => {
|
||||
// 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
|
||||
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;
|
||||
}
|
||||
});
|
||||
// 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;
|
||||
});
|
||||
|
||||
@@ -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 => {
|
||||
console.error(err);
|
||||
@@ -350,7 +412,7 @@ export default class Chart extends Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loading, data, hiddenSeries } = this.state;
|
||||
const { loading, data, hiddenSeries, flashStates } = this.state;
|
||||
const { channelConfig, windowEnd, range } = 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' }}>
|
||||
{/* Custom Interactive Legend */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, justifyContent: 'center', mb: 1, py: 0.5 }}>
|
||||
{legendItems.map(item => (
|
||||
<Box
|
||||
key={item.id}
|
||||
onClick={() => this.toggleSeries(item.id)}
|
||||
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 },
|
||||
}}
|
||||
>
|
||||
{legendItems.map(item => {
|
||||
const flash = flashStates[item.id];
|
||||
const flashColor = flash === 'up' ? 'rgba(76, 175, 80, 0.4)' : flash === 'down' ? 'rgba(244, 67, 54, 0.4)' : 'transparent';
|
||||
return (
|
||||
<Box
|
||||
key={item.id}
|
||||
onClick={() => this.toggleSeries(item.id)}
|
||||
sx={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
bgcolor: item.color,
|
||||
border: '2px solid',
|
||||
borderColor: item.hidden ? 'grey.500' : item.color,
|
||||
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, 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">
|
||||
{item.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 14,
|
||||
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 sx={{ flexGrow: 1, width: '100%', height: '100%' }}>
|
||||
<LineChart
|
||||
|
||||
Reference in New Issue
Block a user