u
This commit is contained in:
174
uiserver/src/components/Chart.js
vendored
174
uiserver/src/components/Chart.js
vendored
@@ -1,6 +1,8 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Paper, Typography, Box, CircularProgress } from '@mui/material';
|
||||
import { Box, Paper, Typography, CircularProgress, IconButton } from '@mui/material';
|
||||
import { LineChart } from '@mui/x-charts/LineChart';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
|
||||
export default class Chart extends Component {
|
||||
constructor(props) {
|
||||
@@ -9,37 +11,58 @@ export default class Chart extends Component {
|
||||
data: [],
|
||||
loading: true
|
||||
};
|
||||
this.intervalId = null;
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchData();
|
||||
this.intervalId = setInterval(this.fetchData, 60000);
|
||||
// Set interval if in Live mode (no windowEnd prop or windowEnd is null)
|
||||
if (!this.props.windowEnd) {
|
||||
this.interval = 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(',')) {
|
||||
const propsChanged = prevEffective.join(',') !== currEffective.join(',') ||
|
||||
JSON.stringify(prevProps.channelConfig) !== JSON.stringify(this.props.channelConfig) ||
|
||||
JSON.stringify(prevProps.axisConfig) !== JSON.stringify(this.props.axisConfig) ||
|
||||
prevProps.windowEnd !== this.props.windowEnd ||
|
||||
prevProps.range !== this.props.range;
|
||||
|
||||
if (propsChanged) {
|
||||
this.fetchData();
|
||||
}
|
||||
|
||||
// Manage interval based on windowEnd prop
|
||||
if (prevProps.windowEnd !== this.props.windowEnd) {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
if (!this.props.windowEnd) {
|
||||
this.interval = setInterval(this.fetchData, 60000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
}
|
||||
|
||||
getEffectiveChannels(props) {
|
||||
return props.channelConfig
|
||||
? props.channelConfig.map(c => c.id)
|
||||
: props.selectedChannels;
|
||||
if (props.channelConfig) {
|
||||
return props.channelConfig.map(c => c.id);
|
||||
}
|
||||
return props.selectedChannels || [];
|
||||
}
|
||||
|
||||
fetchData = () => {
|
||||
const { windowEnd, range } = this.props;
|
||||
const effectiveChannels = this.getEffectiveChannels(this.props);
|
||||
|
||||
// Only fetch if selection exists
|
||||
@@ -49,35 +72,104 @@ export default class Chart extends Component {
|
||||
}
|
||||
|
||||
const selectionStr = effectiveChannels.join(',');
|
||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
fetch(`/api/readings?selection=${encodeURIComponent(selectionStr)}&since=${since}`)
|
||||
// Time Window Logic
|
||||
const rangeMs = range || 24 * 60 * 60 * 1000;
|
||||
|
||||
const endTimeVal = windowEnd ? windowEnd.getTime() : Date.now();
|
||||
const startWindowTime = endTimeVal - rangeMs;
|
||||
|
||||
const since = new Date(startWindowTime).toISOString();
|
||||
const until = new Date(endTimeVal).toISOString();
|
||||
|
||||
fetch(`/api/readings?selection=${encodeURIComponent(selectionStr)}&since=${since}&until=${until}`)
|
||||
.then(res => res.json())
|
||||
.then(rows => {
|
||||
const timeMap = new Map();
|
||||
.then(dataObj => {
|
||||
// 1. Parse raw rows into intervals per channel
|
||||
const intervals = [];
|
||||
const timestampsSet = new Set();
|
||||
|
||||
rows.forEach(row => {
|
||||
const id = `${row.device}:${row.channel}`;
|
||||
// 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;
|
||||
|
||||
const time = new Date(row.timestamp).getTime();
|
||||
if (!timeMap.has(time)) {
|
||||
timeMap.set(time, { time: new Date(row.timestamp) });
|
||||
}
|
||||
const entry = timeMap.get(time);
|
||||
// Ensure sorted by time
|
||||
points.sort((a, b) => new Date(a[0]) - new Date(b[0]));
|
||||
|
||||
let val = row.value;
|
||||
if (row.data_type === 'json' && !val) {
|
||||
val = null;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const [tsStr, rawVal, untilStr] = points[i];
|
||||
const val = Number(rawVal);
|
||||
|
||||
let start = new Date(tsStr).getTime();
|
||||
let explicitEnd = untilStr ? new Date(untilStr).getTime() : null;
|
||||
|
||||
// Determine start of next point to prevent overlap
|
||||
let nextStart = null;
|
||||
if (i < points.length - 1) {
|
||||
nextStart = new Date(points[i + 1][0]).getTime();
|
||||
}
|
||||
|
||||
// Calculate effective end
|
||||
let end = explicitEnd;
|
||||
// If 'until' is null, extend to next point or now
|
||||
if (!end) {
|
||||
end = nextStart || endTimeVal;
|
||||
}
|
||||
|
||||
// Strict Cutoff: Current interval cannot extend past the start of the next interval
|
||||
// This fixes the "vertical artifacting" where old data persists underneath new data
|
||||
if (nextStart && end > nextStart) {
|
||||
end = nextStart;
|
||||
}
|
||||
|
||||
// Clamping logic
|
||||
if (start < startWindowTime) start = startWindowTime;
|
||||
if (end > endTimeVal) end = endTimeVal;
|
||||
|
||||
// If valid interval
|
||||
if (end >= start) {
|
||||
intervals.push({ id, start, end, val });
|
||||
timestampsSet.add(start);
|
||||
timestampsSet.add(end);
|
||||
}
|
||||
}
|
||||
entry[id] = val;
|
||||
});
|
||||
|
||||
const sortedData = Array.from(timeMap.values()).sort((a, b) => a.time - b.time);
|
||||
this.setState({ data: sortedData, loading: false });
|
||||
// 2. Sort unique timestamps
|
||||
const sortedTimestamps = Array.from(timestampsSet).sort((a, b) => a - b);
|
||||
|
||||
// 3. densify data
|
||||
const denseData = sortedTimestamps.map(t => {
|
||||
const row = { time: new Date(t) };
|
||||
intervals.forEach(inv => {
|
||||
// Inclusive of start, exclusive of end?
|
||||
// Actually, dense data points are discrete samples.
|
||||
// We check: start <= t <= end.
|
||||
// However, if we have contiguous intervals [A, B] and [B, C],
|
||||
// Point B belongs to the *second* interval usually (new value starts).
|
||||
// With our cutoff logic, first interval ends at B, second starts at B.
|
||||
// If we use <= end, both match at B. Last writer wins?
|
||||
// Intervals are pushed in channel order.
|
||||
// For same channel, intervals are sorted.
|
||||
// `intervals.forEach(inv)` -> if multiple match, the *last* one in the array (later time) overwrites.
|
||||
// So correct: t=B matches [A, B] and [B, C]. [B, C] comes later in `intervals` (if we sorted intervals total? No we didn't).
|
||||
// We only sorted points within channel loop. `intervals` array order is: channel A points, channel B points...
|
||||
// So overlapping intervals for SAME channel:
|
||||
// [A, B] pushed first. [B, C] pushed second.
|
||||
// At t=B, both match. [B, C] overwrites. Correct (new value wins).
|
||||
|
||||
if (t >= inv.start && t <= inv.end) {
|
||||
row[inv.id] = inv.val;
|
||||
}
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
this.setState({ data: denseData, loading: false });
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to fetch data", err);
|
||||
console.error(err);
|
||||
this.setState({ loading: false });
|
||||
});
|
||||
};
|
||||
@@ -93,8 +185,8 @@ export default class Chart extends Component {
|
||||
|
||||
// Check if config exists for this axis
|
||||
const { axisConfig } = this.props;
|
||||
let cfgMin = NaN;
|
||||
let cfgMax = NaN;
|
||||
let cfgMin = parseFloat(NaN);
|
||||
let cfgMax = parseFloat(NaN);
|
||||
if (axisConfig && axisConfig[axisKey]) {
|
||||
cfgMin = parseFloat(axisConfig[axisKey].min);
|
||||
cfgMax = parseFloat(axisConfig[axisKey].max);
|
||||
@@ -127,7 +219,7 @@ export default class Chart extends Component {
|
||||
|
||||
render() {
|
||||
const { loading, data } = this.state;
|
||||
const { channelConfig } = this.props;
|
||||
const { channelConfig, windowEnd, range } = this.props;
|
||||
const effectiveChannels = this.getEffectiveChannels(this.props);
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
@@ -137,21 +229,25 @@ export default class Chart extends Component {
|
||||
// Find alias and axis if config exists
|
||||
let label = id;
|
||||
let yAxisKey = 'left';
|
||||
let color = undefined;
|
||||
if (channelConfig) {
|
||||
const item = channelConfig.find(c => c.id === id);
|
||||
if (item) {
|
||||
if (item.alias) label = item.alias;
|
||||
if (item.yAxis) yAxisKey = item.yAxis;
|
||||
if (item.color) color = item.color;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const sObj = {
|
||||
dataKey: id,
|
||||
label: label,
|
||||
connectNulls: true,
|
||||
showMark: false,
|
||||
yAxisKey: yAxisKey,
|
||||
};
|
||||
if (color) sObj.color = color;
|
||||
return sObj;
|
||||
});
|
||||
|
||||
const hasRightAxis = series.some(s => s.yAxisKey === 'right');
|
||||
@@ -166,17 +262,23 @@ export default class Chart extends Component {
|
||||
yAxes.push({ id: 'right', scaleType: 'linear', ...rightLimits });
|
||||
}
|
||||
|
||||
// Calculate X-Axis Limits
|
||||
const rangeMs = range || 24 * 60 * 60 * 1000;
|
||||
const axisEnd = windowEnd ? windowEnd.getTime() : Date.now();
|
||||
const axisStart = axisEnd - rangeMs;
|
||||
|
||||
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 }}>
|
||||
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', p: 2, boxSizing: 'border-box' }}>
|
||||
<Paper sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
|
||||
<Box sx={{ flexGrow: 1, width: '100%', height: '100%' }}>
|
||||
<LineChart
|
||||
dataset={data}
|
||||
series={series}
|
||||
xAxis={[{
|
||||
dataKey: 'time',
|
||||
scaleType: 'time',
|
||||
min: new Date(axisStart),
|
||||
max: new Date(axisEnd),
|
||||
valueFormatter: (date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}]}
|
||||
yAxis={yAxes}
|
||||
|
||||
Reference in New Issue
Block a user