built out the graphs for the bin sensor display
This commit is contained in:
parent
b00f10b831
commit
99f37f7bda
3 changed files with 436 additions and 16 deletions
|
|
@ -39,7 +39,6 @@ export default function BinSensorCard(props: Props) {
|
|||
return (
|
||||
<Card raised sx={{ height: "100%" }}>
|
||||
<Box padding={1.5} height="100%" display="flex" flexDirection="column">
|
||||
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" marginBottom={1}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{icon &&
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { AccessTime, MoreVert } from "@mui/icons-material";
|
||||
import { Avatar, Box, Button, Card, Grid2, IconButton, Menu, MenuItem, Typography, useTheme } from "@mui/material";
|
||||
import BinSensorCard from "bin/BinSensorCard";
|
||||
import { GetDefaultDateRange } from "common/time/DateRange";
|
||||
import { ExtractMoisture } from "grain";
|
||||
import { useThemeType } from "hooks";
|
||||
import { Bin, Component } from "models";
|
||||
|
|
@ -11,13 +12,15 @@ import { GrainCable } from "models/GrainCable";
|
|||
import { Headspace } from "models/Headspace";
|
||||
import { Plenum } from "models/Plenum";
|
||||
import { Pressure } from "models/Pressure";
|
||||
import moment from "moment";
|
||||
import moment, { Moment } from "moment";
|
||||
import { GetComponentIcon } from "pbHelpers/ComponentType";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { quack } from "protobuf-ts/quack";
|
||||
import { useBinAPI, useComponentAPI, useGlobalState, useSnackbar } from "providers";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { avg } from "utils";
|
||||
import BinSensorGraph from "./graphs/BinSensorGraph";
|
||||
import TimeBar from "common/time/TimeBar";
|
||||
|
||||
interface Props {
|
||||
bin: Bin
|
||||
|
|
@ -65,6 +68,9 @@ export default function BinSensorsDisplay(props: Props){
|
|||
const [selectedComponentSubtype, setSelectedComponentSubtype] = useState(0);
|
||||
const [selectedComponentTopNode, setSelectedComponentTopNode] = useState(0);
|
||||
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
|
||||
useEffect(()=>{
|
||||
|
|
@ -320,7 +326,7 @@ export default function BinSensorsDisplay(props: Props){
|
|||
icon={icon}
|
||||
lastReading={cable.lastReading}
|
||||
name={cable.name()}
|
||||
tag={"cable"}
|
||||
tag={"Cable"}
|
||||
cableRows={rows}
|
||||
onMenuClick={(event) => {
|
||||
setComponentUnnassigned(false);
|
||||
|
|
@ -670,6 +676,28 @@ export default function BinSensorsDisplay(props: Props){
|
|||
</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 (
|
||||
<Box padding={1}>
|
||||
|
|
@ -678,14 +706,34 @@ export default function BinSensorsDisplay(props: Props){
|
|||
<Box>
|
||||
<Box display="flex" gap={2} alignItems="center">
|
||||
<Typography sx={{fontWeight: 650, fontSize: 25}}>Sensors</Typography>
|
||||
<Button variant="contained" color="primary" onClick={() => {
|
||||
setShowGraphs(true)
|
||||
}}>
|
||||
Show Graphs
|
||||
</Button>
|
||||
{graphControls()}
|
||||
</Box>
|
||||
<Grid2 container spacing={2}>
|
||||
{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(
|
||||
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
|
||||
{cableCard(cable)}
|
||||
|
|
@ -694,6 +742,29 @@ export default function BinSensorsDisplay(props: Props){
|
|||
}
|
||||
)}
|
||||
{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(
|
||||
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
|
||||
{tempHumidCard(plenum)}
|
||||
|
|
@ -702,6 +773,29 @@ export default function BinSensorsDisplay(props: Props){
|
|||
}
|
||||
)}
|
||||
{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(
|
||||
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
|
||||
{pressureCard(pressure)}
|
||||
|
|
@ -709,6 +803,29 @@ export default function BinSensorsDisplay(props: Props){
|
|||
)
|
||||
})}
|
||||
{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(
|
||||
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
|
||||
{tempHumidCard(ambient)}
|
||||
|
|
@ -717,6 +834,29 @@ export default function BinSensorsDisplay(props: Props){
|
|||
}
|
||||
)}
|
||||
{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(
|
||||
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
|
||||
{tempHumidCard(h)}
|
||||
|
|
@ -725,6 +865,15 @@ export default function BinSensorsDisplay(props: Props){
|
|||
})}
|
||||
|
||||
{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(
|
||||
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
|
||||
{co2Card(co2)}
|
||||
|
|
@ -732,6 +881,14 @@ export default function BinSensorsDisplay(props: Props){
|
|||
)
|
||||
})}
|
||||
{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 (
|
||||
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
|
||||
{lidarCard(lidar)}
|
||||
|
|
@ -747,17 +904,33 @@ export default function BinSensorsDisplay(props: Props){
|
|||
<Typography sx={{fontWeight: 650, fontSize: 25}}>Controllers</Typography>
|
||||
<Grid2 container spacing={2}>
|
||||
{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 (
|
||||
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
|
||||
{controllerCard(heater)}
|
||||
</Grid2>
|
||||
)
|
||||
})}
|
||||
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
|
||||
{controllerCard(heater)}
|
||||
</Grid2>
|
||||
)
|
||||
})}
|
||||
{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 (
|
||||
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
|
||||
{controllerCard(fan)}
|
||||
</Grid2>
|
||||
<Grid2 size={{xs: 6, sm: 6, md: 4, lg: 3}}>
|
||||
{controllerCard(fan)}
|
||||
</Grid2>
|
||||
)
|
||||
})}
|
||||
</Grid2>
|
||||
|
|
|
|||
248
src/bin/graphs/BinSensorGraph.tsx
Normal file
248
src/bin/graphs/BinSensorGraph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue