From 3d43a42b12879d4c356f3e954c69e713e3437cf7 Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Fri, 26 Dec 2025 01:05:43 +0100 Subject: [PATCH] u --- uiserver/src/components/Chart.js | 133 +++++++++++++++++++++++-------- uiserver/webpack.config.js | 3 + 2 files changed, 104 insertions(+), 32 deletions(-) diff --git a/uiserver/src/components/Chart.js b/uiserver/src/components/Chart.js index c0876d7..1368bd6 100644 --- a/uiserver/src/components/Chart.js +++ b/uiserver/src/components/Chart.js @@ -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 { {/* Custom Interactive Legend */} - {legendItems.map(item => ( - 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 ( 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 }, }} - /> - - {item.label} - - - ))} + > + + + {item.label} + + + ); + })} { if (!devServer) { throw new Error('webpack-dev-server is not defined');