built out the graphs for the bin sensor display

This commit is contained in:
csawatzky 2026-05-28 14:56:59 -06:00
parent b00f10b831
commit 99f37f7bda
3 changed files with 436 additions and 16 deletions

View file

@ -39,7 +39,6 @@ export default function BinSensorCard(props: Props) {
return ( return (
<Card raised sx={{ height: "100%" }}> <Card raised sx={{ height: "100%" }}>
<Box padding={1.5} height="100%" display="flex" flexDirection="column"> <Box padding={1.5} height="100%" display="flex" flexDirection="column">
<Box display="flex" justifyContent="space-between" alignItems="center" marginBottom={1}> <Box display="flex" justifyContent="space-between" alignItems="center" marginBottom={1}>
<Box display="flex" alignItems="center" gap={1}> <Box display="flex" alignItems="center" gap={1}>
{icon && {icon &&

View file

@ -1,6 +1,7 @@
import { AccessTime, MoreVert } from "@mui/icons-material"; import { AccessTime, MoreVert } from "@mui/icons-material";
import { Avatar, Box, Button, Card, Grid2, IconButton, Menu, MenuItem, Typography, useTheme } from "@mui/material"; import { Avatar, Box, Button, Card, Grid2, IconButton, Menu, MenuItem, Typography, useTheme } from "@mui/material";
import BinSensorCard from "bin/BinSensorCard"; import BinSensorCard from "bin/BinSensorCard";
import { GetDefaultDateRange } from "common/time/DateRange";
import { ExtractMoisture } from "grain"; import { ExtractMoisture } from "grain";
import { useThemeType } from "hooks"; import { useThemeType } from "hooks";
import { Bin, Component } from "models"; import { Bin, Component } from "models";
@ -11,13 +12,15 @@ import { GrainCable } from "models/GrainCable";
import { Headspace } from "models/Headspace"; import { Headspace } from "models/Headspace";
import { Plenum } from "models/Plenum"; import { Plenum } from "models/Plenum";
import { Pressure } from "models/Pressure"; import { Pressure } from "models/Pressure";
import moment from "moment"; import moment, { Moment } from "moment";
import { GetComponentIcon } from "pbHelpers/ComponentType"; import { GetComponentIcon } from "pbHelpers/ComponentType";
import { pond } from "protobuf-ts/pond"; import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack"; import { quack } from "protobuf-ts/quack";
import { useBinAPI, useComponentAPI, useGlobalState, useSnackbar } from "providers"; import { useBinAPI, useComponentAPI, useGlobalState, useSnackbar } from "providers";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { avg } from "utils"; import { avg } from "utils";
import BinSensorGraph from "./graphs/BinSensorGraph";
import TimeBar from "common/time/TimeBar";
interface Props { interface Props {
bin: Bin bin: Bin
@ -65,6 +68,9 @@ export default function BinSensorsDisplay(props: Props){
const [selectedComponentSubtype, setSelectedComponentSubtype] = useState(0); const [selectedComponentSubtype, setSelectedComponentSubtype] = useState(0);
const [selectedComponentTopNode, setSelectedComponentTopNode] = useState(0); const [selectedComponentTopNode, setSelectedComponentTopNode] = useState(0);
const [showGraphs, setShowGraphs] = useState(false); const [showGraphs, setShowGraphs] = useState(false);
const defaultDateRange = GetDefaultDateRange();
const [startDate, setStartDate] = useState<Moment>(defaultDateRange.start);
const [endDate, setEndDate] = useState<Moment>(defaultDateRange.end);
//organize the components into the correct 'positions' using the bins preferences //organize the components into the correct 'positions' using the bins preferences
useEffect(()=>{ useEffect(()=>{
@ -320,7 +326,7 @@ export default function BinSensorsDisplay(props: Props){
icon={icon} icon={icon}
lastReading={cable.lastReading} lastReading={cable.lastReading}
name={cable.name()} name={cable.name()}
tag={"cable"} tag={"Cable"}
cableRows={rows} cableRows={rows}
onMenuClick={(event) => { onMenuClick={(event) => {
setComponentUnnassigned(false); setComponentUnnassigned(false);
@ -670,6 +676,28 @@ export default function BinSensorsDisplay(props: Props){
</Card> </Card>
) )
} }
const updateDateRange = (newStartDate: any, newEndDate: any) => {
let range = GetDefaultDateRange();
range.start = newStartDate;
range.end = newEndDate;
setStartDate(newStartDate);
setEndDate(newEndDate);
};
const graphControls = () => {
return (
<Box display="flex" gap={2} alignItems="center">
<Button variant="contained" color="primary" onClick={() => {
setShowGraphs(!showGraphs)
}}>
{showGraphs ? "Show Cards" : "Show Graphs"}
</Button>
{showGraphs &&
<TimeBar updateDateRange={updateDateRange} startDate={startDate} endDate={endDate} />
}
</Box>
)
}
return ( return (
<Box padding={1}> <Box padding={1}>
@ -678,14 +706,34 @@ export default function BinSensorsDisplay(props: Props){
<Box> <Box>
<Box display="flex" gap={2} alignItems="center"> <Box display="flex" gap={2} alignItems="center">
<Typography sx={{fontWeight: 650, fontSize: 25}}>Sensors</Typography> <Typography sx={{fontWeight: 650, fontSize: 25}}>Sensors</Typography>
<Button variant="contained" color="primary" onClick={() => { {graphControls()}
setShowGraphs(true)
}}>
Show Graphs
</Button>
</Box> </Box>
<Grid2 container spacing={2}> <Grid2 container spacing={2}>
{cables.map(cable => { {cables.map(cable => {
let device = componentDevices.get(cable.key())
if(showGraphs && device){
let icon = GetComponentIcon(cable.type(), cable.subType(), themeType)
return(
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
<BinSensorGraph
icon={icon}
title={cable.name()}
tag={"Cable"}
device={device}
component={cable.key()}
startDate={startDate}
endDate={endDate}
onMenuClick={(event) => {
setComponentUnnassigned(false);
setSelectedComponentType(cable.type());
setSelectedComponentKey(cable.key());
setSelectedComponentTopNode(cable.settings.grainFilledTo);
setAnchorEl(event.currentTarget);
}}
/>
</Grid2>
)
}
return( return(
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}> <Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
{cableCard(cable)} {cableCard(cable)}
@ -694,6 +742,29 @@ export default function BinSensorsDisplay(props: Props){
} }
)} )}
{plenums.map(plenum => { {plenums.map(plenum => {
let device = componentDevices.get(plenum.key())
if(showGraphs && device){
let icon = GetComponentIcon(plenum.type(), plenum.subType(), themeType)
return(
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
<BinSensorGraph
icon={icon}
title={plenum.name()}
tag={"Plenum"}
device={device}
component={plenum.key()}
startDate={startDate}
endDate={endDate}
onMenuClick={(event) => {
setComponentUnnassigned(false);
setSelectedComponentType(plenum.type());
setSelectedComponentKey(plenum.key());
setAnchorEl(event.currentTarget);
}}
/>
</Grid2>
)
}
return( return(
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}> <Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
{tempHumidCard(plenum)} {tempHumidCard(plenum)}
@ -702,6 +773,29 @@ export default function BinSensorsDisplay(props: Props){
} }
)} )}
{pressures.map(pressure => { {pressures.map(pressure => {
let device = componentDevices.get(pressure.key())
if(showGraphs && device){
let icon = GetComponentIcon(pressure.type(), pressure.subType(), themeType)
return(
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
<BinSensorGraph
icon={icon}
title={pressure.name()}
tag={"Pressure"}
device={device}
component={pressure.key()}
startDate={startDate}
endDate={endDate}
onMenuClick={(event) => {
setComponentUnnassigned(false);
setSelectedComponentType(pressure.type());
setSelectedComponentKey(pressure.key());
setAnchorEl(event.currentTarget);
}}
/>
</Grid2>
)
}
return( return(
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}> <Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
{pressureCard(pressure)} {pressureCard(pressure)}
@ -709,6 +803,29 @@ export default function BinSensorsDisplay(props: Props){
) )
})} })}
{ambient.map(ambient => { {ambient.map(ambient => {
let device = componentDevices.get(ambient.key())
if(showGraphs && device){
let icon = GetComponentIcon(ambient.type(), ambient.subType(), themeType)
return(
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
<BinSensorGraph
icon={icon}
title={ambient.name()}
tag={"Ambient"}
device={device}
component={ambient.key()}
startDate={startDate}
endDate={endDate}
onMenuClick={(event) => {
setComponentUnnassigned(false);
setSelectedComponentType(ambient.type());
setSelectedComponentKey(ambient.key());
setAnchorEl(event.currentTarget);
}}
/>
</Grid2>
)
}
return( return(
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}> <Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
{tempHumidCard(ambient)} {tempHumidCard(ambient)}
@ -717,6 +834,29 @@ export default function BinSensorsDisplay(props: Props){
} }
)} )}
{headspaceTH.map(h => { {headspaceTH.map(h => {
let device = componentDevices.get(h.key())
if(showGraphs && device){
let icon = GetComponentIcon(h.type(), h.subType(), themeType)
return(
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
<BinSensorGraph
icon={icon}
title={h.name()}
tag={"Headspace"}
device={device}
component={h.key()}
startDate={startDate}
endDate={endDate}
onMenuClick={(event) => {
setComponentUnnassigned(false);
setSelectedComponentType(h.type());
setSelectedComponentKey(h.key());
setAnchorEl(event.currentTarget);
}}
/>
</Grid2>
)
}
return( return(
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}> <Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
{tempHumidCard(h)} {tempHumidCard(h)}
@ -725,6 +865,15 @@ export default function BinSensorsDisplay(props: Props){
})} })}
{headspaceCO2.map(co2 => { {headspaceCO2.map(co2 => {
let device = componentDevices.get(co2.key())
if(showGraphs && device){
let icon = GetComponentIcon(co2.type(), co2.subType(), themeType)
return(
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
<BinSensorGraph device={device} component={co2.key()} startDate={startDate} endDate={endDate} />
</Grid2>
)
}
return( return(
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}> <Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
{co2Card(co2)} {co2Card(co2)}
@ -732,6 +881,14 @@ export default function BinSensorsDisplay(props: Props){
) )
})} })}
{headspaceLidar.map(lidar => { {headspaceLidar.map(lidar => {
let device = componentDevices.get(lidar.key())
if(showGraphs && device){
return(
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
<BinSensorGraph device={device} component={lidar.key()} startDate={startDate} endDate={endDate} />
</Grid2>
)
}
return ( return (
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}> <Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
{lidarCard(lidar)} {lidarCard(lidar)}
@ -747,6 +904,14 @@ export default function BinSensorsDisplay(props: Props){
<Typography sx={{fontWeight: 650, fontSize: 25}}>Controllers</Typography> <Typography sx={{fontWeight: 650, fontSize: 25}}>Controllers</Typography>
<Grid2 container spacing={2}> <Grid2 container spacing={2}>
{heaters.map(heater => { {heaters.map(heater => {
let device = componentDevices.get(heater.key())
if(showGraphs && device){
return(
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
<BinSensorGraph device={device} component={heater.key()} startDate={startDate} endDate={endDate} />
</Grid2>
)
}
return ( return (
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}> <Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
{controllerCard(heater)} {controllerCard(heater)}
@ -754,6 +919,14 @@ export default function BinSensorsDisplay(props: Props){
) )
})} })}
{fans.map(fan => { {fans.map(fan => {
let device = componentDevices.get(fan.key())
if(showGraphs && device){
return(
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
<BinSensorGraph device={device} component={fan.key()} startDate={startDate} endDate={endDate} />
</Grid2>
)
}
return ( return (
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}> <Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
{controllerCard(fan)} {controllerCard(fan)}

View file

@ -0,0 +1,248 @@
import { Avatar, Box, Card, CardHeader, CircularProgress, IconButton, Paper, Typography, useTheme } from "@mui/material";
import moment, { Moment } from "moment";
import { useComponentAPI, useGlobalState } from "providers";
import { useEffect, useMemo, useState } from "react";
import { UnitMeasurement } from "models/UnitMeasurement";
import {
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
TooltipProps,
XAxis,
YAxis
} from "recharts";
import { avg, roundTo } from "utils";
import { MoreVert } from "@mui/icons-material";
interface Props {
device: number;
icon?: string;
title?: string;
tag?: string;
onMenuClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
component: string;
startDate: Moment;
endDate: Moment;
customHeight?: string | number;
}
interface MeasurementMeta {
value: number;
unit: string;
label: string;
}
interface ChartPoint {
timestamp: number;
_meta: Record<string, MeasurementMeta>;
[seriesKey: string]: number | Record<string, MeasurementMeta> | undefined;
}
interface SeriesInfo {
key: string;
colour: string;
unit: string;
label: string;
}
function normalizeValue(value: number, min: number, max: number): number {
if (max === min) {
return 50;
}
return ((value - min) / (max - min)) * 100;
}
function buildChartData(measurements: UnitMeasurement[]): {
data: ChartPoint[];
series: SeriesInfo[];
} {
const rawSeries = measurements
.filter(um => um.timestamps.length > 0 && um.values.length > 0)
.map(um => {
const points: { timestamp: number; value: number }[] = [];
um.values.forEach((valArray, i) => {
if (valArray.error || !um.timestamps[i]) {
return;
}
const nodeValues = valArray.values.filter(v => !isNaN(v));
if (nodeValues.length === 0) {
return;
}
points.push({
timestamp: moment(um.timestamps[i]).valueOf(),
value: avg(nodeValues)
});
});
const actualValues = points.map(p => p.value);
const min = actualValues.length > 0 ? Math.min(...actualValues) : 0;
const max = actualValues.length > 0 ? Math.max(...actualValues) : 100;
return { um, points, min, max, key: um.label };
})
.filter(s => s.points.length > 0);
const timestampMap = new Map<number, ChartPoint>();
rawSeries.forEach(({ um, points, min, max, key }) => {
points.forEach(({ timestamp, value }) => {
let point = timestampMap.get(timestamp);
if (!point) {
point = { timestamp, _meta: {} };
timestampMap.set(timestamp, point);
}
point[key] = normalizeValue(value, min, max);
point._meta[key] = { value, unit: um.unit, label: um.label };
});
});
const data = Array.from(timestampMap.values()).sort((a, b) => a.timestamp - b.timestamp);
const series = rawSeries.map(({ um, key }) => ({
key,
colour: um.colour,
unit: um.unit,
label: um.label
}));
return { data, series };
}
function SensorGraphTooltip(props: TooltipProps<number, string>) {
const { active, label, payload } = props;
if (!active || !payload?.length) {
return null;
}
return (
<Paper variant="outlined" style={{ opacity: 0.9 }}>
<Box padding={1}>
{payload.map(p => {
const dataKey = String(p.dataKey);
const meta = (p.payload as ChartPoint)._meta?.[dataKey];
return (
<Typography color="textPrimary" variant="subtitle1" key={dataKey}>
{meta?.label ?? dataKey}
{": "}
<Box component="span">
{meta ? `${roundTo(meta.value, 2)} ${meta.unit}` : p.value}
</Box>
</Typography>
);
})}
<Typography color="textSecondary" variant="caption">
{moment(label).format("lll")}
</Typography>
</Box>
</Paper>
);
}
export default function BinSensorGraph(props: Props) {
const { device, icon, title, tag, component, startDate, endDate, customHeight = 350, onMenuClick } = props;
const [measurements, setMeasurements] = useState<UnitMeasurement[]>([]);
const [loading, setLoading] = useState(false);
const componentAPI = useComponentAPI();
const [{ user }] = useGlobalState();
const theme = useTheme();
const now = moment();
useEffect(() => {
setLoading(true);
componentAPI
.sampleUnitMeasurements(device, component, startDate, endDate, 1000)
.then(res => {
setMeasurements(res.data.measurements.map(um => UnitMeasurement.any(um, user)));
})
.catch(() => {
setMeasurements([]);
})
.finally(() => {
setLoading(false);
});
}, [device, component, startDate, endDate, componentAPI, user]);
const { data, series } = useMemo(() => buildChartData(measurements), [measurements]);
return (
<Card raised sx={{ height: "100%", padding: 1.5 }}>
<Box display="flex" justifyContent="space-between" alignItems="center" marginBottom={1}>
<Box display="flex" alignItems="center" gap={1}>
{icon &&
<Avatar
variant="square"
src={icon}
alt={name + " icon"}
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
/>
}
<Typography fontWeight={500}>{title}</Typography>
{tag && <Typography variant="caption" color="textSecondary">- {tag}</Typography>}
</Box>
{onMenuClick &&
<IconButton size="small" onClick={onMenuClick}>
<MoreVert fontSize="small" />
</IconButton>
}
</Box>
<Box height={customHeight} padding={1}>
{loading ? (
<Box display="flex" justifyContent="center" alignItems="center" height="100%">
<CircularProgress size={80} thickness={1.5} />
</Box>
) : data.length === 0 || series.length === 0 ? (
<Box display="flex" justifyContent="center" alignItems="center" height="100%">
<Typography color="textSecondary">No measurement data</Typography>
</Box>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<XAxis
allowDataOverflow
dataKey="timestamp"
domain={["dataMin", "dataMax"]}
name="Time"
tickFormatter={timestamp => {
const t = moment(timestamp);
return now.isSame(t, "day") ? t.format("LT") : t.format("MMM DD");
}}
scale="time"
type="number"
tick={{ fill: theme.palette.text.primary }}
stroke={theme.palette.divider}
interval="preserveStartEnd"
/>
{/* <YAxis
domain={[0, 100]}
tick={false}
stroke={theme.palette.divider}
label={{
value: "Readings",
position: "insideLeft",
angle: -90,
fill: theme.palette.text.primary
}}
/> */}
<Tooltip
animationEasing="ease-out"
cursor={{ fill: theme.palette.text.primary, opacity: "0.15" }}
content={SensorGraphTooltip}
/>
<Legend verticalAlign="bottom" />
{series.map(s => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={s.colour}
strokeWidth={2}
dot={false}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
)}
</Box>
</Card>
);
}