added tons of files, got the bin page to render
This commit is contained in:
parent
a027b8a96f
commit
9bbbf0940e
34 changed files with 10492 additions and 19 deletions
335
src/bin/BinActions.tsx
Normal file
335
src/bin/BinActions.tsx
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import {
|
||||
IconButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Theme,
|
||||
Tooltip
|
||||
} from "@mui/material";
|
||||
import { blue } from "@mui/material/colors";
|
||||
import RemoveSelfIcon from "@mui/icons-material/ExitToApp";
|
||||
import MoreIcon from "@mui/icons-material/MoreVert";
|
||||
import GroupSettingsIcon from "@mui/icons-material/Settings";
|
||||
import ShareObjectIcon from "@mui/icons-material/Share";
|
||||
//import SensorIcon from "@material-ui/icons/Sensor";
|
||||
import SensorIcon from "@mui/icons-material/SettingsInputAntenna";
|
||||
import ObjectUsersIcon from "@mui/icons-material/AccountCircle";
|
||||
import ObjectTeamsIcon from "@mui/icons-material/SupervisedUserCircle";
|
||||
import BinSettings from "bin/BinSettings";
|
||||
import { Bin, binScope, Component, User } from "models";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import React, { useState } from "react";
|
||||
import ObjectUsers from "user/ObjectUsers";
|
||||
import RemoveSelfFromObject from "user/RemoveSelfFromObject";
|
||||
import ShareObject from "user/ShareObject";
|
||||
import { isOffline } from "utils/environment";
|
||||
import ObjectTeams from "teams/ObjectTeams";
|
||||
// import BinDuplication from "./BinDuplication";
|
||||
import BinsIcon from "products/Bindapt/BinsIcon";
|
||||
import HelpIcon from "@mui/icons-material/Help";
|
||||
// import BinSensors from "./BinSensors";
|
||||
import { useGlobalState, useSnackbar, useUserAPI } from "providers";
|
||||
import { useMobile } from "hooks";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => {
|
||||
return ({
|
||||
shareIcon: {
|
||||
color: blue["500"],
|
||||
"&:hover": {
|
||||
color: blue["600"]
|
||||
}
|
||||
},
|
||||
removeIcon: {
|
||||
color: "var(--status-alert)"
|
||||
},
|
||||
red: {
|
||||
color: "var(--status-alert)"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface Props {
|
||||
bin: Bin;
|
||||
permissions: pond.Permission[];
|
||||
refreshCallback: () => void;
|
||||
userID: string;
|
||||
components?: Map<string, Component>;
|
||||
setComponents?: React.Dispatch<React.SetStateAction<Map<string, Component>>>;
|
||||
updateBinStatus?: (componentKeys: string[], removed?: boolean) => void;
|
||||
}
|
||||
|
||||
interface OpenState {
|
||||
share: boolean;
|
||||
users: boolean;
|
||||
teams: boolean;
|
||||
settings: boolean;
|
||||
sensors: boolean;
|
||||
removeSelf: boolean;
|
||||
duplication: boolean;
|
||||
}
|
||||
|
||||
export default function BinActions(props: Props) {
|
||||
const classes = useStyles();
|
||||
const {
|
||||
bin,
|
||||
permissions,
|
||||
refreshCallback,
|
||||
userID,
|
||||
components,
|
||||
setComponents,
|
||||
updateBinStatus
|
||||
} = props;
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const [{ user }, dispatch] = useGlobalState();
|
||||
const isMobile = useMobile();
|
||||
const { error } = useSnackbar();
|
||||
const userAPI = useUserAPI();
|
||||
const [openState, setOpenState] = useState<OpenState>({
|
||||
share: false,
|
||||
users: false,
|
||||
teams: false,
|
||||
settings: false,
|
||||
sensors: false,
|
||||
removeSelf: false,
|
||||
duplication: false
|
||||
});
|
||||
|
||||
const startTour = () => {
|
||||
if (user) {
|
||||
let u = user.protobuf();
|
||||
if (u.status) {
|
||||
u.status.finishedBinIntro = "";
|
||||
userAPI
|
||||
.updateUser(userID, u)
|
||||
.then(() => {
|
||||
dispatch({ key: "user", value: User.any(u) });
|
||||
})
|
||||
.catch((err: any) => error(err));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const groupMenu = () => {
|
||||
const canShare = permissions.includes(pond.Permission.PERMISSION_SHARE);
|
||||
const canManageUsers = permissions.includes(pond.Permission.PERMISSION_USERS);
|
||||
return (
|
||||
<Menu
|
||||
id="groupMenu"
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
keepMounted
|
||||
disableAutoFocusItem>
|
||||
{!isOffline() && canShare && (
|
||||
<MenuItem
|
||||
dense
|
||||
onClick={() => {
|
||||
setOpenState({ ...openState, share: true });
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<ShareObjectIcon className={classes.shareIcon} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Share" />
|
||||
</MenuItem>
|
||||
)}
|
||||
{!isOffline() && canManageUsers && (
|
||||
<MenuItem
|
||||
dense
|
||||
onClick={() => {
|
||||
setOpenState({ ...openState, users: true });
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<ObjectUsersIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Users" />
|
||||
</MenuItem>
|
||||
)}
|
||||
{!isOffline() && canManageUsers && (
|
||||
<MenuItem
|
||||
dense
|
||||
onClick={() => {
|
||||
setOpenState({ ...openState, teams: true });
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<ObjectTeamsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Teams" />
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
dense
|
||||
onClick={() => {
|
||||
setOpenState({ ...openState, duplication: true });
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<BinsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Duplicate Bin" />
|
||||
</MenuItem>
|
||||
{isMobile && (
|
||||
<MenuItem
|
||||
dense
|
||||
onClick={() => {
|
||||
setAnchorEl(null);
|
||||
startTour();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<HelpIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Redo Help Tour" />
|
||||
</MenuItem>
|
||||
)}
|
||||
{isMobile && (
|
||||
<MenuItem
|
||||
dense
|
||||
onClick={() => {
|
||||
setAnchorEl(null);
|
||||
setOpenState({ ...openState, sensors: true });
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<SensorIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Bin Sensors" />
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
dense
|
||||
onClick={() => {
|
||||
setOpenState({ ...openState, removeSelf: true });
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<RemoveSelfIcon className={classes.red} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Leave" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const dialogs = () => {
|
||||
const hasWritePermission = permissions.includes(pond.Permission.PERMISSION_WRITE);
|
||||
const key = bin.key();
|
||||
const label = bin.name();
|
||||
return (
|
||||
<React.Fragment>
|
||||
<BinSettings
|
||||
mode="update"
|
||||
bin={bin}
|
||||
canEdit={hasWritePermission}
|
||||
open={openState.settings}
|
||||
userID={userID}
|
||||
onClose={refresh => {
|
||||
if (refresh === true) {
|
||||
refreshCallback();
|
||||
}
|
||||
setOpenState({ ...openState, settings: false });
|
||||
}}
|
||||
/>
|
||||
{/* <BinSensors
|
||||
mode="update"
|
||||
bin={bin}
|
||||
canEdit={hasWritePermission}
|
||||
open={openState.sensors}
|
||||
userID={userID}
|
||||
components={components}
|
||||
setComponents={setComponents}
|
||||
updateBinStatus={updateBinStatus}
|
||||
onClose={refresh => {
|
||||
if (refresh === true) {
|
||||
refreshCallback();
|
||||
}
|
||||
setOpenState({ ...openState, sensors: false });
|
||||
}}
|
||||
/> */}
|
||||
<ShareObject
|
||||
scope={binScope(key)}
|
||||
label={label}
|
||||
permissions={permissions}
|
||||
isDialogOpen={openState.share}
|
||||
closeDialogCallback={() => setOpenState({ ...openState, share: false })}
|
||||
/>
|
||||
<ObjectUsers
|
||||
scope={binScope(key)}
|
||||
label={label}
|
||||
permissions={permissions}
|
||||
isDialogOpen={openState.users}
|
||||
closeDialogCallback={() => setOpenState({ ...openState, users: false })}
|
||||
refreshCallback={refreshCallback}
|
||||
/>
|
||||
{/* <BinDuplication
|
||||
open={openState.duplication}
|
||||
closeDialog={() => {
|
||||
setOpenState({ ...openState, duplication: false });
|
||||
}}
|
||||
bin={bin}
|
||||
refreshCallback={refreshCallback}
|
||||
/> */}
|
||||
<RemoveSelfFromObject
|
||||
scope={binScope(key)}
|
||||
label={label}
|
||||
isDialogOpen={openState.removeSelf}
|
||||
closeDialogCallback={() => setOpenState({ ...openState, removeSelf: false })}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{!isMobile && (
|
||||
<Tooltip title="Redo help tour">
|
||||
<IconButton onClick={startTour}>
|
||||
<HelpIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<Tooltip title="Bin Sensors">
|
||||
<IconButton
|
||||
id="tour-bin-sensors"
|
||||
onClick={() => setOpenState({ ...openState, sensors: true })}>
|
||||
<SensorIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Bin Settings">
|
||||
<IconButton
|
||||
id="tour-bin-settings"
|
||||
onClick={() => setOpenState({ ...openState, settings: true })}>
|
||||
<GroupSettingsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
aria-owns={anchorEl ? "groupMenu" : undefined}
|
||||
aria-haspopup="true"
|
||||
id="tour-bin-kebab"
|
||||
onClick={(event: React.MouseEvent<HTMLButtonElement>) => setAnchorEl(event.currentTarget)}>
|
||||
<MoreIcon />
|
||||
</IconButton>
|
||||
{groupMenu()}
|
||||
{dialogs()}
|
||||
<ObjectTeams
|
||||
scope={binScope(bin.key())}
|
||||
label="Teams"
|
||||
permissions={permissions}
|
||||
isDialogOpen={openState.teams}
|
||||
refreshCallback={refreshCallback}
|
||||
closeDialogCallback={() => setOpenState({ ...openState, teams: false })}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
|
@ -36,7 +36,8 @@ const useStyles = makeStyles((theme: Theme) => {
|
|||
borderRadius: 5,
|
||||
marginRight: -30, //negative margin to extend the box behind the bin svg
|
||||
paddingRight: 30, //padding to offset the text and ignore the extension
|
||||
marginTop: 5
|
||||
marginTop: 5,
|
||||
cursor: "pointer",
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -483,7 +484,7 @@ export default function BinCard(props: Props) {
|
|||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card style={{ cursor: "pointer" }}>
|
||||
{user.hasFeature("admin") && (
|
||||
<IconButton
|
||||
style={{
|
||||
|
|
|
|||
105
src/bin/BinConditioningCard.tsx
Normal file
105
src/bin/BinConditioningCard.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import {
|
||||
Card,
|
||||
darken,
|
||||
Theme,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { Component, Interaction } from "models";
|
||||
import { Ambient } from "models/Ambient";
|
||||
import { Controller } from "models/Controller";
|
||||
import { Plenum } from "models/Plenum";
|
||||
import { componentIDToString } from "pbHelpers/Component";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { useEffect, useState } from "react";
|
||||
import BinConditioningInteraction from "./BinConditioningInteraction";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => {
|
||||
return ({
|
||||
card: {
|
||||
padding: 10,
|
||||
paddingLeft: 15,
|
||||
paddingRight: 15
|
||||
},
|
||||
displayBG: {
|
||||
marginTop: 10,
|
||||
background: darken(theme.palette.background.default, 0.05),
|
||||
borderRadius: 5,
|
||||
padding: 10
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface Props {
|
||||
interactions: Interaction[];
|
||||
interactionDevices: Map<string, number>;
|
||||
mode: pond.BinMode.BIN_MODE_DRYING | pond.BinMode.BIN_MODE_HYDRATING;
|
||||
plenums?: Plenum[];
|
||||
ambients?: Ambient[];
|
||||
heaters?: Controller[];
|
||||
fans?: Controller[];
|
||||
grain?: pond.Grain;
|
||||
}
|
||||
|
||||
export default function BinConditioningCard(props: Props) {
|
||||
const classes = useStyles();
|
||||
const { interactions, interactionDevices, plenums, ambients, heaters, fans, grain, mode } = props;
|
||||
const [sourceMap, setSourceMap] = useState<Map<string, Component>>(new Map());
|
||||
const [sinkMap, setSinkMap] = useState<Map<string, Component>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let sourceMap: Map<string, Component> = new Map();
|
||||
let sinkMap: Map<string, Component> = new Map();
|
||||
|
||||
plenums?.forEach(plenum => {
|
||||
sourceMap.set(plenum.locationString(), plenum.asComponent());
|
||||
});
|
||||
|
||||
ambients?.forEach(ambient => {
|
||||
sourceMap.set(ambient.locationString(), ambient.asComponent());
|
||||
});
|
||||
|
||||
heaters?.forEach(heater => {
|
||||
sinkMap.set(heater.locationString(), heater.asComponent());
|
||||
});
|
||||
|
||||
fans?.forEach(fan => {
|
||||
sinkMap.set(fan.locationString(), fan.asComponent());
|
||||
});
|
||||
|
||||
setSourceMap(sourceMap);
|
||||
setSinkMap(sinkMap);
|
||||
}, [plenums, ambients, heaters, fans]);
|
||||
|
||||
const conditionsDisplay = () => {
|
||||
let conditioningInteractions: JSX.Element[] = [];
|
||||
interactions.forEach(interaction => {
|
||||
let source = sourceMap.get(componentIDToString(interaction.settings.source));
|
||||
let sink = sinkMap.get(componentIDToString(interaction.settings.sink));
|
||||
let deviceId = interactionDevices.get(interaction.key());
|
||||
//if they do exist add to the array of elements to return
|
||||
if (deviceId && source && sink) {
|
||||
conditioningInteractions.push(
|
||||
<BinConditioningInteraction
|
||||
key={interaction.key()}
|
||||
deviceId={deviceId}
|
||||
interaction={interaction}
|
||||
sink={sink}
|
||||
source={source}
|
||||
grain={grain}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
return conditioningInteractions;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card raised className={classes.card}>
|
||||
<Typography style={{ fontWeight: 650 }}>
|
||||
{mode === pond.BinMode.BIN_MODE_DRYING ? "Drying" : "Hydrating"} Conditions
|
||||
</Typography>
|
||||
{conditionsDisplay()}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
346
src/bin/BinConditioningInteraction.tsx
Normal file
346
src/bin/BinConditioningInteraction.tsx
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Box,
|
||||
Button,
|
||||
darken,
|
||||
Grid,
|
||||
Slider,
|
||||
Theme,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { ExpandMore } from "@mui/icons-material";
|
||||
import UnitMeasurementSummary from "component/UnitMeasurementSummary";
|
||||
import { ExtractMoisture } from "grain";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { Component, Interaction } from "models";
|
||||
import { UnitMeasurement } from "models/UnitMeasurement";
|
||||
import { interactionConditionText, interactionResultText } from "pbHelpers/Interaction";
|
||||
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
|
||||
import { pond, quack } from "protobuf-ts/pond";
|
||||
import { useGlobalState, useInteractionsAPI, useSnackbar } from "providers";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { avg, fahrenheitToCelsius, getTemperatureUnit } from "utils";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { Mark } from "@mui/material/Slider/useSlider.types";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => {
|
||||
return ({
|
||||
displayBG: {
|
||||
marginTop: 10,
|
||||
background: darken(theme.palette.background.default, 0.05),
|
||||
borderRadius: 5,
|
||||
padding: 10
|
||||
},
|
||||
markContainer: {
|
||||
zIndex: 2
|
||||
},
|
||||
arrowDown: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: "10px solid transparent",
|
||||
borderRight: "10px solid transparent"
|
||||
//borderTop: "10px solid yellow"
|
||||
},
|
||||
sliderRoot: {},
|
||||
sliderThumb: {
|
||||
height: 15,
|
||||
width: 15,
|
||||
marginTop: -6,
|
||||
marginLeft: -7.5,
|
||||
backgroundColor: "yellow"
|
||||
},
|
||||
sliderTrack: {
|
||||
height: 3,
|
||||
backgroundColor: "white"
|
||||
},
|
||||
sliderRail: {
|
||||
height: 3,
|
||||
backgroundColor: "white"
|
||||
},
|
||||
sliderValLabel: {
|
||||
left: "calc(-50%)",
|
||||
top: 22,
|
||||
"& *": {
|
||||
background: "transparent",
|
||||
color: "#fff"
|
||||
}
|
||||
},
|
||||
sliderMark: {
|
||||
visibility: "hidden"
|
||||
},
|
||||
sliderMarked: {
|
||||
marginTop: 25,
|
||||
marginBottom: 0
|
||||
},
|
||||
sliderMarkLabel: {
|
||||
top: -25
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface Props {
|
||||
deviceId: number;
|
||||
interaction: Interaction;
|
||||
source: Component;
|
||||
sink: Component;
|
||||
grain?: pond.Grain;
|
||||
}
|
||||
|
||||
export default function BinConditioningInteraction(props: Props) {
|
||||
const { interaction, source, sink, grain, deviceId } = props;
|
||||
const [sliderVals, setSliderVals] = useState<Map<quack.MeasurementType, number>>(new Map());
|
||||
const [sliderMarks, setSliderMarks] = useState<Map<quack.MeasurementType, number>>(new Map());
|
||||
//this is the emc value calculated from the interactions temp and humidity conditions
|
||||
const [baseEMC, setBaseEMC] = useState<number | undefined>();
|
||||
const [{ user }] = useGlobalState();
|
||||
const classes = useStyles();
|
||||
const interactionAPI = useInteractionsAPI();
|
||||
const { openSnack } = useSnackbar();
|
||||
|
||||
useEffect(() => {
|
||||
let passedInteraction = interaction;
|
||||
let sliderVals: Map<quack.MeasurementType, number> = new Map();
|
||||
let sliderMarks: Map<quack.MeasurementType, number> = new Map();
|
||||
passedInteraction.conditions().forEach(condition => {
|
||||
let describer = describeMeasurement(condition.measurementType, source.type());
|
||||
//NOTE: toDisplay will convert the temp value to fahrenheit
|
||||
sliderVals.set(condition.measurementType, describer.toDisplay(condition.value));
|
||||
});
|
||||
|
||||
source.status.lastGoodMeasurement.forEach(measurement => {
|
||||
let m = pond.UnitMeasurementsForComponent.fromObject(measurement);
|
||||
if (m.values[0]) {
|
||||
let markVal = avg(m.values[0].values);
|
||||
//do this since this is how interactions handle the values so that the slider can use the toDisplay method of the describer for the marks
|
||||
if (m.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) markVal = markVal * 10;
|
||||
if (m.type === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) markVal = markVal * 100;
|
||||
sliderMarks.set(m.type, markVal);
|
||||
}
|
||||
});
|
||||
setSliderVals(sliderVals);
|
||||
setSliderMarks(sliderMarks);
|
||||
}, [interaction, source]);
|
||||
|
||||
useEffect(() => {
|
||||
let temp = sliderVals.get(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE);
|
||||
let hum = sliderVals.get(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT);
|
||||
if (
|
||||
grain !== undefined &&
|
||||
grain !== pond.Grain.GRAIN_INVALID &&
|
||||
grain !== pond.Grain.GRAIN_CUSTOM &&
|
||||
temp &&
|
||||
hum
|
||||
) {
|
||||
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
|
||||
//the emc calc needs the temp to be in celsius
|
||||
temp = fahrenheitToCelsius(temp);
|
||||
}
|
||||
let emc = ExtractMoisture(grain, temp, hum);
|
||||
setBaseEMC(emc);
|
||||
}
|
||||
}, [sliderVals, grain]);
|
||||
|
||||
const updateInteraction = () => {
|
||||
interactionAPI
|
||||
.updateInteraction(deviceId, interaction.settings)
|
||||
.then(resp => {
|
||||
openSnack("Updated Interaction Conditions");
|
||||
})
|
||||
.catch(err => {
|
||||
openSnack("Failed to Update Interaction Conditions");
|
||||
});
|
||||
};
|
||||
|
||||
const customMark = (val: string, arrowColor: string) => {
|
||||
return (
|
||||
<Box className={classes.markContainer}>
|
||||
<Grid container direction="column" alignContent="center" alignItems="center" spacing={1}>
|
||||
<Grid item>
|
||||
<Typography style={{ color: "white", fontSize: 15, fontWeight: 650, lineHeight: 1 }}>
|
||||
{val}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Box className={classes.arrowDown} style={{ borderTop: "10px solid " + arrowColor }} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const conditionDisplay = () => {
|
||||
return (
|
||||
<Grid container>
|
||||
{interaction.conditions().map((condition, i) => {
|
||||
let describer = describeMeasurement(condition.measurementType, source?.type());
|
||||
let labelTail = "";
|
||||
let marks: Mark[] = [];
|
||||
|
||||
if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) {
|
||||
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
|
||||
labelTail = "°F";
|
||||
} else {
|
||||
labelTail = "°C";
|
||||
}
|
||||
}
|
||||
if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) {
|
||||
labelTail = "%";
|
||||
}
|
||||
|
||||
let mark = sliderMarks.get(condition.measurementType);
|
||||
if (mark !== undefined) {
|
||||
marks.push({
|
||||
label: customMark(describer.toDisplay(mark) + labelTail, describer.colour()),
|
||||
value: describer.toDisplay(mark)
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Grid key={i} container item xs={12} alignContent="center" alignItems="center">
|
||||
<Grid item xs={12}>
|
||||
{interactionConditionText(source, condition, false)}
|
||||
</Grid>
|
||||
<Grid item xs={12} style={{ marginBottom: 20 }}>
|
||||
<Slider
|
||||
classes={{
|
||||
root: classes.sliderRoot,
|
||||
rail: classes.sliderRail,
|
||||
track: classes.sliderTrack,
|
||||
thumb: classes.sliderThumb,
|
||||
valueLabel: classes.sliderValLabel,
|
||||
marked: classes.sliderMarked,
|
||||
mark: classes.sliderMark,
|
||||
markLabel: classes.sliderMarkLabel
|
||||
}}
|
||||
step={0.1}
|
||||
valueLabelDisplay="on"
|
||||
valueLabelFormat={value => {
|
||||
if (
|
||||
condition.measurementType ===
|
||||
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE
|
||||
) {
|
||||
if (
|
||||
getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
|
||||
) {
|
||||
return value.toFixed(1) + "°F";
|
||||
} else {
|
||||
return value.toFixed(1) + "°C";
|
||||
}
|
||||
}
|
||||
if (
|
||||
condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT
|
||||
) {
|
||||
return value.toFixed(1) + "%";
|
||||
}
|
||||
}}
|
||||
marks={marks}
|
||||
min={describer.min()}
|
||||
max={describer.max()}
|
||||
value={sliderVals.get(condition.measurementType) ?? describer.min()}
|
||||
onChange={(_, val) => {
|
||||
condition.value = Math.round(describer.toStored(val as number));
|
||||
let sliders = cloneDeep(sliderVals);
|
||||
sliders.set(condition.measurementType, val as number);
|
||||
setSliderVals(sliders);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
<Grid item xs={12}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
updateInteraction();
|
||||
}}>
|
||||
Update Conditions
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const determineEMCRelation = () => {
|
||||
let relation = "Approximately:";
|
||||
let tempRelation: quack.RelationalOperator | undefined = undefined;
|
||||
let humidRelation: quack.RelationalOperator | undefined = undefined;
|
||||
interaction.conditions().forEach(condition => {
|
||||
if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE)
|
||||
tempRelation = condition.comparison;
|
||||
if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT)
|
||||
humidRelation = condition.comparison;
|
||||
});
|
||||
|
||||
if (
|
||||
(tempRelation === quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN ||
|
||||
tempRelation === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO) &&
|
||||
(humidRelation === quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN ||
|
||||
humidRelation === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO)
|
||||
) {
|
||||
relation = "Less Than: ";
|
||||
}
|
||||
if (
|
||||
(tempRelation === quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN ||
|
||||
tempRelation === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO) &&
|
||||
(humidRelation === quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN ||
|
||||
humidRelation === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO)
|
||||
) {
|
||||
relation = "Greater Than: ";
|
||||
}
|
||||
|
||||
return relation;
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid key={interaction.key()} container className={classes.displayBG}>
|
||||
<Grid item xs={6}>
|
||||
<Typography style={{ fontWeight: 650 }}>{source.name()}</Typography>
|
||||
<UnitMeasurementSummary
|
||||
component={source}
|
||||
reading={UnitMeasurement.convertLastMeasurement(
|
||||
source.status.lastGoodMeasurement.map(um => UnitMeasurement.any(um, user))
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography style={{ fontWeight: 650 }}>{sink.name()}</Typography>
|
||||
<UnitMeasurementSummary
|
||||
component={sink}
|
||||
reading={UnitMeasurement.convertLastMeasurement(
|
||||
sink.status.lastGoodMeasurement.map(um => UnitMeasurement.any(um, user))
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography align="center" style={{ margin: 5, marginBottom: 10, fontWeight: 650 }}>
|
||||
{interactionResultText(interaction, sink)}
|
||||
</Typography>
|
||||
{baseEMC !== undefined ? (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box display="flex">
|
||||
<Typography style={{ fontWeight: 650 }}>EMC {determineEMCRelation()}</Typography>
|
||||
<Typography
|
||||
style={{
|
||||
marginLeft: 5,
|
||||
fontWeight: 650,
|
||||
color: describeMeasurement(
|
||||
quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC
|
||||
).colour()
|
||||
}}>
|
||||
{baseEMC.toFixed(1)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>{conditionDisplay()}</AccordionDetails>
|
||||
</Accordion>
|
||||
) : (
|
||||
<React.Fragment>{conditionDisplay()}</React.Fragment>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
114
src/bin/BinHistory.tsx
Normal file
114
src/bin/BinHistory.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { Box, Card, Typography } from "@material-ui/core";
|
||||
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
|
||||
import { MatchParams } from "navigation/Routes";
|
||||
import DiffHistory, { ListResult, Record } from "common/DiffHistory";
|
||||
import { useSnackbar, useMobile } from "hooks";
|
||||
import { useBinAPI } from "providers";
|
||||
import { Bin } from "models";
|
||||
import { TranslateKey, TranslateValue } from "pbHelpers/Bin";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { or } from "utils/types";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
gutter: {
|
||||
maxHeight: "480px",
|
||||
//display: "flex",
|
||||
flexDirection: "column"
|
||||
},
|
||||
gutterMobile: {
|
||||
height: "auto"
|
||||
},
|
||||
title: {
|
||||
padding: theme.spacing(1),
|
||||
fontWeight: 600
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
interface Props {
|
||||
binID?: string;
|
||||
drawer?: boolean;
|
||||
}
|
||||
|
||||
export default function BinHistory(props: Props) {
|
||||
const params = useParams<MatchParams>();
|
||||
const classes = useStyles();
|
||||
const { openSnack } = useSnackbar();
|
||||
const binAPI = useBinAPI();
|
||||
const binID = props.binID ?? params.binID;
|
||||
const [bin, setBin] = useState(Bin.any({ settings: { binId: binID } }));
|
||||
const binName = bin.name();
|
||||
const isMobile = useMobile();
|
||||
|
||||
useEffect(() => {
|
||||
binAPI
|
||||
.getBin(binID)
|
||||
.then((response: any) => {
|
||||
setBin(Bin.any(response.data));
|
||||
})
|
||||
.catch(err => {
|
||||
openSnack("There was a problem loading your bin");
|
||||
});
|
||||
}, [binAPI, binID, openSnack]);
|
||||
|
||||
let list = (limit: number, offset: number): Promise<ListResult> => {
|
||||
return new Promise(resolve => {
|
||||
binAPI
|
||||
.listHistory(binID, limit, offset)
|
||||
.then((res: any) => {
|
||||
let records: Record[] = or(res.data.history, []).map((record: any) => {
|
||||
return {
|
||||
timestamp: or(record.timestamp, ""),
|
||||
user: or(record.user, ""),
|
||||
data: or(record.settings, {}),
|
||||
status: or(record.progress, "Unknown")
|
||||
} as Record;
|
||||
});
|
||||
resolve({
|
||||
records: records,
|
||||
total: or(res.data.total, 0),
|
||||
offset: or(res.data.nextOffset, 0)
|
||||
});
|
||||
})
|
||||
.catch((err: any) => {
|
||||
resolve({
|
||||
records: [] as Record[],
|
||||
total: 0,
|
||||
offset: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
let translateKey = (key: keyof any): string => {
|
||||
return TranslateKey(key as keyof pond.BinSettings);
|
||||
};
|
||||
|
||||
let translateValue = (key: keyof any, obj: any): string => {
|
||||
return TranslateValue(key as keyof pond.BinSettings, pond.BinSettings.fromObject(obj));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={isMobile || props.drawer ? classes.gutterMobile : classes.gutter}>
|
||||
<Typography variant="subtitle2" color="textPrimary" className={classes.title}>
|
||||
{binName} History
|
||||
</Typography>
|
||||
<Box style={{ overflowY: "scroll", flexShrink: 2 }}>
|
||||
<DiffHistory
|
||||
name={binName}
|
||||
kind="bin"
|
||||
list={list}
|
||||
translateKey={translateKey}
|
||||
translateValue={translateValue}
|
||||
cellStyle={{ margin: "0px", padding: "0.5rem" }}
|
||||
showTitle={false}
|
||||
headerStyle={{ margin: "0px" }}
|
||||
drawer={props.drawer}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
758
src/bin/BinStorageConditions.tsx
Normal file
758
src/bin/BinStorageConditions.tsx
Normal file
|
|
@ -0,0 +1,758 @@
|
|||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
darken,
|
||||
Grid2 as Grid,
|
||||
InputAdornment,
|
||||
lighten,
|
||||
Slider,
|
||||
TextField,
|
||||
Theme,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { CheckCircleOutline, ImportExport } from "@mui/icons-material";
|
||||
import Warning from "@mui/icons-material/Warning";
|
||||
import HumidityIcon from "component/HumidityIcon";
|
||||
import TemperatureIcon from "component/TemperatureIcon";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { Bin, Component } from "models";
|
||||
import { GrainCable } from "models/GrainCable";
|
||||
import { UnitMeasurement } from "models/UnitMeasurement";
|
||||
import Co2Icon from "products/CommonIcons/co2Icon";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { useBinAPI, useSnackbar } from "providers";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { avg, getTemperatureUnit } from "utils";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { Mark } from "@mui/material/Slider/useSlider.types";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => {
|
||||
return ({
|
||||
card: {
|
||||
padding: 10,
|
||||
paddingLeft: 15,
|
||||
paddingRight: 15
|
||||
},
|
||||
displayBG: {
|
||||
marginTop: 10,
|
||||
background: darken(theme.palette.background.default, 0.05),
|
||||
borderRadius: 5,
|
||||
padding: 10
|
||||
},
|
||||
slider: {
|
||||
zIndex: 1,
|
||||
"&:hover": {
|
||||
zIndex: 3
|
||||
}
|
||||
},
|
||||
markContainer: {
|
||||
zIndex: 2
|
||||
},
|
||||
arrowDown: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: "10px solid transparent",
|
||||
borderRight: "10px solid transparent",
|
||||
borderTop: "10px solid yellow"
|
||||
},
|
||||
co2Box: {
|
||||
background: lighten(theme.palette.background.default, 0.2),
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
margin: 10,
|
||||
marginLeft: 0,
|
||||
marginRight: 0
|
||||
},
|
||||
low: {
|
||||
fontWeight: 650,
|
||||
color: "#0575E6"
|
||||
},
|
||||
target: {
|
||||
fontWeight: 650,
|
||||
color: "green"
|
||||
},
|
||||
high: {
|
||||
fontWeight: 650,
|
||||
color: "#c42605"
|
||||
},
|
||||
sliderRoot: {},
|
||||
sliderThumb: {
|
||||
height: 15,
|
||||
width: 15,
|
||||
marginTop: -6,
|
||||
marginLeft: -7.5,
|
||||
backgroundColor: "yellow"
|
||||
},
|
||||
sliderTrack: {
|
||||
height: 3,
|
||||
backgroundColor: "white"
|
||||
},
|
||||
sliderRail: {
|
||||
height: 3,
|
||||
backgroundColor: "white"
|
||||
},
|
||||
sliderValLabel: {
|
||||
left: "calc(-50%)",
|
||||
top: 22,
|
||||
"& *": {
|
||||
background: "transparent",
|
||||
color: "#fff"
|
||||
}
|
||||
},
|
||||
sliderMark: {
|
||||
visibility: "hidden"
|
||||
},
|
||||
sliderMarked: {
|
||||
marginTop: 25,
|
||||
marginBottom: 0
|
||||
},
|
||||
sliderMarkLabel: {
|
||||
top: -25
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface Props {
|
||||
bin: Bin;
|
||||
headspaceCO2: Component[];
|
||||
cables: GrainCable[];
|
||||
}
|
||||
|
||||
interface NodeCounts {
|
||||
below: number;
|
||||
onTarget: number;
|
||||
above: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export default function BinStorageConditions(props: Props) {
|
||||
const classes = useStyles();
|
||||
const binAPI = useBinAPI();
|
||||
const { openSnack } = useSnackbar();
|
||||
const { bin, headspaceCO2, cables } = props;
|
||||
const [sliderTemps, setSliderTemps] = useState<number[]>([-40, 40]);
|
||||
//boolean that if a cable is missing its filled to display an icon or something to let the user know not all of the cables have their fill set
|
||||
const [missingTopNodeWarning, setMissingTopNodeWarning] = useState(false);
|
||||
const [targetTemp, setTargetTemp] = useState("");
|
||||
const [targetEMC, setTargetEMC] = useState("");
|
||||
const [emcDeviation, setEmcDeviation] = useState("");
|
||||
const [averageTemp, setAverageTemp] = useState<number | undefined>();
|
||||
const [averageEMC, setAverageEMC] = useState<number | undefined>();
|
||||
|
||||
//the number of nodes in the grain that fall into each category
|
||||
const [tempTargets, setTempTargets] = useState<NodeCounts>({
|
||||
below: 0,
|
||||
onTarget: 0,
|
||||
above: 0,
|
||||
total: 0
|
||||
});
|
||||
const [emcTargets, setEmcTargets] = useState<NodeCounts>({
|
||||
below: 0,
|
||||
onTarget: 0,
|
||||
above: 0,
|
||||
total: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setSliderTemps([bin.settings.lowTemp, bin.settings.highTemp]);
|
||||
let cableTempAvgs: number[] = [];
|
||||
let tempCounts: NodeCounts = {
|
||||
below: 0,
|
||||
onTarget: 0,
|
||||
above: 0,
|
||||
total: 0
|
||||
};
|
||||
let emcCounts: NodeCounts = {
|
||||
below: 0,
|
||||
onTarget: 0,
|
||||
above: 0,
|
||||
total: 0
|
||||
};
|
||||
let cableEMCAvgs: number[] = [];
|
||||
cables.forEach(cable => {
|
||||
//if a cable is missing the top node being set
|
||||
if (cable.topNode === 0) {
|
||||
setMissingTopNodeWarning(true);
|
||||
} else {
|
||||
//with the way the measurements are stored the node closest to the device (highest on the cable) is the first position of the array
|
||||
//so we will need to flip the array so that node 1 is the beginning of the array
|
||||
let temps = cloneDeep(cable.temperatures).reverse();
|
||||
let emcs = cloneDeep(cable.grainMoistures).reverse();
|
||||
|
||||
//only use the nodes up to the top node
|
||||
let nodeTemps: number[] = [];
|
||||
temps.forEach((temp, i) => {
|
||||
if (i < cable.topNode) {
|
||||
nodeTemps.push(temp);
|
||||
tempCounts.total++;
|
||||
if (temp > bin.settings.highTemp) {
|
||||
tempCounts.above++;
|
||||
} else if (temp < bin.settings.lowTemp) {
|
||||
tempCounts.below++;
|
||||
} else {
|
||||
tempCounts.onTarget++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let nodeEMCs: number[] = [];
|
||||
|
||||
emcs.forEach((emc, i) => {
|
||||
if (bin.settings.inventory?.targetMoisture) {
|
||||
if (i < cable.topNode) {
|
||||
nodeEMCs.push(emc);
|
||||
emcCounts.total++;
|
||||
if (
|
||||
emc >
|
||||
bin.settings.inventory.targetMoisture +
|
||||
bin.settings.inventory.moistureTargetDeviation
|
||||
) {
|
||||
emcCounts.above++;
|
||||
} else if (
|
||||
emc <
|
||||
bin.settings.inventory.targetMoisture -
|
||||
bin.settings.inventory.moistureTargetDeviation
|
||||
) {
|
||||
emcCounts.below++;
|
||||
} else {
|
||||
emcCounts.onTarget++;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
cableTempAvgs.push(avg(nodeTemps));
|
||||
cableEMCAvgs.push(avg(nodeEMCs));
|
||||
}
|
||||
});
|
||||
if (cableTempAvgs.length > 0) {
|
||||
setAverageTemp(avg(cableTempAvgs));
|
||||
}
|
||||
setTempTargets(tempCounts);
|
||||
let tempVal = bin.settings.inventory?.targetTemperature ?? 0;
|
||||
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
|
||||
tempVal = tempVal * 1.8 + 32;
|
||||
}
|
||||
setTargetTemp(tempVal.toFixed(1));
|
||||
|
||||
if (cableEMCAvgs.length > 0) {
|
||||
setAverageEMC(avg(cableEMCAvgs));
|
||||
}
|
||||
setEmcTargets(emcCounts);
|
||||
setTargetEMC(bin.settings.inventory?.targetMoisture.toFixed(1) ?? "");
|
||||
setEmcDeviation(bin.settings.inventory?.moistureTargetDeviation.toFixed(1) ?? "");
|
||||
}, [bin, cables]);
|
||||
|
||||
//useEffect that watches for changes in the target emc and deviation to update the targets
|
||||
useEffect(() => {
|
||||
let target = parseFloat(targetEMC);
|
||||
let deviation = parseFloat(emcDeviation);
|
||||
if (!isNaN(target) && !isNaN(deviation)) {
|
||||
let emcCounts: NodeCounts = {
|
||||
below: 0,
|
||||
onTarget: 0,
|
||||
above: 0,
|
||||
total: 0
|
||||
};
|
||||
|
||||
cables.forEach(cable => {
|
||||
let emcs = cloneDeep(cable.grainMoistures).reverse();
|
||||
//let nodeEMCs: number[] = [];
|
||||
|
||||
emcs.forEach((emc, i) => {
|
||||
if (bin.settings.inventory?.targetMoisture) {
|
||||
if (i < cable.topNode) {
|
||||
//nodeEMCs.push(emc);
|
||||
emcCounts.total++;
|
||||
if (emc > target + deviation) {
|
||||
emcCounts.above++;
|
||||
} else if (emc < target - deviation) {
|
||||
emcCounts.below++;
|
||||
} else {
|
||||
emcCounts.onTarget++;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
setEmcTargets(emcCounts);
|
||||
}
|
||||
}, [targetEMC, emcDeviation, bin, cables]);
|
||||
|
||||
const isErrors = () => {
|
||||
let errors = false;
|
||||
if (
|
||||
isNaN(parseFloat(targetTemp)) ||
|
||||
isNaN(parseFloat(targetEMC)) ||
|
||||
isNaN(parseFloat(emcDeviation))
|
||||
) {
|
||||
errors = true;
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
const updateBinThresholds = () => {
|
||||
//update the bin settings with the new thresholds
|
||||
let settings = bin.settings;
|
||||
settings.lowTemp = sliderTemps[0];
|
||||
settings.highTemp = sliderTemps[1];
|
||||
if (settings.inventory) {
|
||||
let tempVal = parseFloat(targetTemp);
|
||||
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
|
||||
tempVal = Math.fround(((tempVal - 32) * 5) / 9);
|
||||
}
|
||||
settings.inventory.targetTemperature = tempVal;
|
||||
settings.inventory.targetMoisture = parseFloat(targetEMC);
|
||||
settings.inventory.moistureTargetDeviation = parseFloat(emcDeviation);
|
||||
}
|
||||
|
||||
binAPI
|
||||
.updateBin(bin.key(), settings)
|
||||
.then(resp => {
|
||||
openSnack("Updated bin thresholds");
|
||||
})
|
||||
.catch(err => {
|
||||
openSnack("Failed to update bin thresholds");
|
||||
});
|
||||
};
|
||||
|
||||
const updateSliderTemps = (newVals: number[]) => {
|
||||
//the slider always returns the array with the lower value first
|
||||
setSliderTemps(newVals);
|
||||
|
||||
//re-calc the node counts
|
||||
let tempCounts: NodeCounts = {
|
||||
below: 0,
|
||||
onTarget: 0,
|
||||
above: 0,
|
||||
total: 0
|
||||
};
|
||||
cables.forEach(cable => {
|
||||
let temps = cloneDeep(cable.temperatures).reverse();
|
||||
let nodeTemps: number[] = [];
|
||||
temps.forEach((temp, i) => {
|
||||
if (i < cable.topNode) {
|
||||
nodeTemps.push(temp);
|
||||
tempCounts.total++;
|
||||
if (temp > newVals[1]) {
|
||||
tempCounts.above++;
|
||||
} else if (temp < newVals[0]) {
|
||||
tempCounts.below++;
|
||||
} else {
|
||||
tempCounts.onTarget++;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
setTempTargets(tempCounts);
|
||||
};
|
||||
|
||||
const customMark = (val: string, sub: string) => {
|
||||
return (
|
||||
<Box className={classes.markContainer}>
|
||||
<Grid container direction="column" alignContent="center" alignItems="center">
|
||||
<Grid>
|
||||
<Typography style={{ fontSize: 15, fontWeight: 650, lineHeight: 1 }}>{val}</Typography>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Typography style={{ fontSize: 8 }}>{sub}</Typography>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Box className={classes.arrowDown} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
//this will display at all times
|
||||
const tempDisplay = () => {
|
||||
let sliderEdge = 40;
|
||||
|
||||
let mark: Mark[] = [];
|
||||
if (averageTemp) {
|
||||
let temp = averageTemp;
|
||||
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
|
||||
temp = temp * 1.8 + 32;
|
||||
}
|
||||
mark = [
|
||||
{
|
||||
label: customMark(
|
||||
temp.toFixed(1) +
|
||||
(getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
|
||||
? "°F"
|
||||
: "°C"),
|
||||
"AVG"
|
||||
),
|
||||
value: averageTemp
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={classes.displayBG}>
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
alignContent="center"
|
||||
alignItems="center"
|
||||
justifyContent="space-between">
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<Box display="flex" alignContent="center" alignItems="center">
|
||||
<TemperatureIcon />
|
||||
<Typography noWrap style={{ fontWeight: 650 }}>
|
||||
Target Temp =
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 4 }}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
value={targetTemp}
|
||||
error={isNaN(parseFloat(targetTemp))}
|
||||
helperText={isNaN(parseFloat(targetTemp)) && "Must be a valid number"}
|
||||
onChange={e => {
|
||||
setTargetTemp(e.target.value);
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
{getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
|
||||
? "°F"
|
||||
: "°C"}
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Slider
|
||||
className={classes.slider}
|
||||
classes={{
|
||||
root: classes.sliderRoot,
|
||||
rail: classes.sliderRail,
|
||||
track: classes.sliderTrack,
|
||||
thumb: classes.sliderThumb,
|
||||
valueLabel: classes.sliderValLabel,
|
||||
marked: classes.sliderMarked,
|
||||
mark: classes.sliderMark,
|
||||
markLabel: classes.sliderMarkLabel
|
||||
}}
|
||||
marks={mark}
|
||||
step={0.1}
|
||||
value={sliderTemps}
|
||||
min={-sliderEdge}
|
||||
max={sliderEdge}
|
||||
valueLabelDisplay="on"
|
||||
valueLabelFormat={value => {
|
||||
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
|
||||
return ((value * 9) / 5 + 32).toFixed(1) + "°F";
|
||||
}
|
||||
return value.toFixed(1) + "°C";
|
||||
}}
|
||||
onChange={(_, newVals) => {
|
||||
updateSliderTemps(newVals as number[]);
|
||||
}}
|
||||
/>
|
||||
{tempTargets.total > 0 && (
|
||||
<Box style={{ marginLeft: 10, marginRight: 10, marginTop: 5 }}>
|
||||
<Grid container direction="row" justifyContent="space-between">
|
||||
<Grid>
|
||||
<Typography className={classes.low} align="center">
|
||||
{((tempTargets.below / tempTargets.total) * 100).toFixed(2)}%
|
||||
</Typography>
|
||||
<Typography align="center">Below</Typography>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Typography className={classes.target} align="center">
|
||||
{((tempTargets.onTarget / tempTargets.total) * 100).toFixed(2)}%
|
||||
</Typography>
|
||||
<Typography align="center">On Target</Typography>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Typography className={classes.high} align="center">
|
||||
{((tempTargets.above / tempTargets.total) * 100).toFixed(2)}%
|
||||
</Typography>
|
||||
<Typography align="center">Above</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
//this will only display if the bin has the moisture target set
|
||||
const moistureDisplay = () => {
|
||||
const targetVal = parseFloat(targetEMC);
|
||||
const devVal = parseFloat(emcDeviation);
|
||||
let low = targetVal - devVal;
|
||||
let high = targetVal + devVal;
|
||||
const min = 0;
|
||||
const max = 25;
|
||||
if (isNaN(low) || isNaN(high)) {
|
||||
low = 0;
|
||||
high = 0;
|
||||
}
|
||||
|
||||
let mark: Mark[] = [];
|
||||
if (averageEMC) {
|
||||
mark = [
|
||||
{
|
||||
label: customMark(averageEMC.toFixed(1) + "%", "AVG"),
|
||||
value: averageEMC
|
||||
}
|
||||
];
|
||||
}
|
||||
return (
|
||||
<Box className={classes.displayBG}>
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
alignContent="center"
|
||||
alignItems="center"
|
||||
justifyContent="space-between">
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<Box display="flex" alignContent="center" alignItems="center">
|
||||
<Box style={{ width: 24, paddingLeft: 3 }}>
|
||||
<HumidityIcon height={24} width={15} />
|
||||
</Box>
|
||||
<Typography noWrap style={{ fontWeight: 650 }}>
|
||||
Target EMC =
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 4 }}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
value={targetEMC}
|
||||
error={isNaN(parseFloat(targetEMC))}
|
||||
helperText={isNaN(parseFloat(targetEMC)) && "Must be a valid number"}
|
||||
onChange={e => {
|
||||
setTargetEMC(e.target.value);
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">%</InputAdornment>
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
alignContent="center"
|
||||
alignItems="center"
|
||||
justifyContent="space-between">
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<Box display="flex" alignContent="center" alignItems="center">
|
||||
<ImportExport />
|
||||
<Typography noWrap style={{ fontWeight: 650 }}>
|
||||
Deviation =
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 4 }}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
value={emcDeviation}
|
||||
error={isNaN(parseFloat(emcDeviation))}
|
||||
helperText={isNaN(parseFloat(emcDeviation)) && "Must be a valid number"}
|
||||
onChange={e => {
|
||||
setEmcDeviation(e.target.value);
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">%</InputAdornment>
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Slider
|
||||
style={{ cursor: "default" }}
|
||||
marks={mark}
|
||||
classes={{
|
||||
root: classes.sliderRoot,
|
||||
rail: classes.sliderRail,
|
||||
track: classes.sliderTrack,
|
||||
thumb: classes.sliderThumb,
|
||||
valueLabel: classes.sliderValLabel,
|
||||
marked: classes.sliderMarked,
|
||||
mark: classes.sliderMark,
|
||||
markLabel: classes.sliderMarkLabel
|
||||
}}
|
||||
valueLabelDisplay="on"
|
||||
valueLabelFormat={value => {
|
||||
return value.toFixed(1) + "%";
|
||||
}}
|
||||
step={0.5}
|
||||
min={min}
|
||||
max={max}
|
||||
value={[low, high]}
|
||||
/>
|
||||
{emcTargets.total > 0 && (
|
||||
<Box style={{ marginLeft: 10, marginRight: 10, marginTop: 5 }}>
|
||||
<Grid container direction="row" justifyContent="space-between">
|
||||
<Grid>
|
||||
<Typography className={classes.low} align="center">
|
||||
{((emcTargets.below / emcTargets.total) * 100).toFixed(2)}%
|
||||
</Typography>
|
||||
<Typography align="center">Below</Typography>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Typography className={classes.target} align="center">
|
||||
{((emcTargets.onTarget / emcTargets.total) * 100).toFixed(2)}%
|
||||
</Typography>
|
||||
<Typography align="center">On Target</Typography>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Typography className={classes.high} align="center">
|
||||
{((emcTargets.above / emcTargets.total) * 100).toFixed(2)}%
|
||||
</Typography>
|
||||
<Typography align="center">Above</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
//this will only appear when there is a CO2 sensor
|
||||
const co2Display = () => {
|
||||
if (headspaceCO2.length > 0) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Box className={classes.displayBG}>
|
||||
<Box display="flex">
|
||||
<Co2Icon size={24} />
|
||||
<Typography style={{ fontWeight: 650, marginLeft: 5 }}>
|
||||
CO2 Spoilage Detection
|
||||
</Typography>
|
||||
</Box>
|
||||
{headspaceCO2.map(co2 => {
|
||||
let measurement = UnitMeasurement.any(co2.status.lastGoodMeasurement[0]);
|
||||
let reading: number = 0;
|
||||
if (measurement.values[0] && measurement.values[0].values[0]) {
|
||||
reading = measurement.values[0].values[0];
|
||||
}
|
||||
let mark: Mark[] = [];
|
||||
if (reading > 0) {
|
||||
mark = [
|
||||
{
|
||||
label: customMark(reading + "ppm", "last"),
|
||||
value: reading
|
||||
}
|
||||
];
|
||||
}
|
||||
return (
|
||||
<Grid
|
||||
key={co2.key()}
|
||||
container
|
||||
direction="row"
|
||||
alignContent="center"
|
||||
alignItems="center">
|
||||
<Grid size={{ xs: 4 }}>
|
||||
<Box className={classes.co2Box}>
|
||||
<Grid container direction="column" alignContent="center" alignItems="center">
|
||||
<Grid >
|
||||
<Typography style={{ fontSize: 15, textAlign: "center" }}>
|
||||
Current CO2
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid >
|
||||
<Typography
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 650,
|
||||
textAlign: "center",
|
||||
color: measurement.colour
|
||||
}}>
|
||||
{reading} ppm
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid >
|
||||
<CheckCircleOutline style={{ color: reading < 1100 ? "green" : "red" }} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8 }}>
|
||||
<Box marginLeft={2}>
|
||||
<Box>
|
||||
<Slider
|
||||
style={{ cursor: "default" }}
|
||||
min={200}
|
||||
max={1300}
|
||||
value={[400, 1100]}
|
||||
marks={mark}
|
||||
classes={{
|
||||
root: classes.sliderRoot,
|
||||
rail: classes.sliderRail,
|
||||
track: classes.sliderTrack,
|
||||
thumb: classes.sliderThumb,
|
||||
valueLabel: classes.sliderValLabel,
|
||||
marked: classes.sliderMarked,
|
||||
mark: classes.sliderMark,
|
||||
markLabel: classes.sliderMarkLabel
|
||||
}}
|
||||
valueLabelDisplay="on"
|
||||
valueLabelFormat={value => {
|
||||
return value + "ppm";
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
//made up of the co2, moisture and temp displays
|
||||
const storageConditions = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent={"space-between"}>
|
||||
<Typography style={{ fontWeight: 650 }}>Storage Conditions</Typography>
|
||||
{missingTopNodeWarning && (
|
||||
<Tooltip
|
||||
title={`
|
||||
One or more cables do not have their fill level set.
|
||||
Check to make sure all top nodes are set for your bin cables
|
||||
`}>
|
||||
<Warning />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
{tempDisplay()}
|
||||
{moistureDisplay()}
|
||||
{co2Display()}
|
||||
<Box display="flex" marginTop={1} justifyContent="center">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={isErrors()}
|
||||
onClick={() => {
|
||||
updateBinThresholds();
|
||||
}}>
|
||||
Update Thresholds
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card raised className={classes.card}>
|
||||
{storageConditions()}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
170
src/bin/BinTour.tsx
Normal file
170
src/bin/BinTour.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { Typography } from "@material-ui/core";
|
||||
import Tour from "common/Tour";
|
||||
import { random } from "lodash";
|
||||
import moment from "moment";
|
||||
import { useAuth, useGlobalState, useSnackbar, useUserAPI } from "providers";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Emoji from "react-emoji-render";
|
||||
import { Step } from "react-joyride";
|
||||
|
||||
interface Props {}
|
||||
|
||||
export default function BinTour(props: Props) {
|
||||
const { userID } = useAuth();
|
||||
const [{ user }, dispatch] = useGlobalState();
|
||||
const { error } = useSnackbar();
|
||||
const [isTourRunning, setIsTourRunning] = useState(true);
|
||||
const [joyKey, setJoyKey] = useState(random());
|
||||
const userAPI = useUserAPI();
|
||||
|
||||
useEffect(() => {
|
||||
if (user.status.finishedBinIntro.length < 1) {
|
||||
setIsTourRunning(true);
|
||||
setJoyKey(random(1000000));
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const endTour = () => {
|
||||
setIsTourRunning(false);
|
||||
if (user) {
|
||||
user.status.finishedBinIntro = moment().toJSON();
|
||||
userAPI
|
||||
.updateUser(userID, user.protobuf())
|
||||
.then(() => dispatch({ key: "user", value: user }))
|
||||
.catch((err: any) => error(err));
|
||||
}
|
||||
};
|
||||
|
||||
const getTourSteps = (): Step[] => {
|
||||
let steps: Step[] = [
|
||||
{
|
||||
title: (
|
||||
<React.Fragment>
|
||||
Welcome to Bin Management
|
||||
<Emoji text=" 🎉" />
|
||||
</React.Fragment>
|
||||
),
|
||||
content: (
|
||||
<React.Fragment>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{"Hello " + user.name()}
|
||||
<Emoji text=" 👋" />
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph>
|
||||
Looks like this is your first time using our bin management system! Let me show you
|
||||
around.
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
),
|
||||
target: "body",
|
||||
placement: "center",
|
||||
disableBeacon: true
|
||||
},
|
||||
{
|
||||
title: "Sensors",
|
||||
content: (
|
||||
<React.Fragment>
|
||||
<Typography variant="body2" paragraph>
|
||||
In the bin sensors menu, you can attach components from your devices to the bin. This
|
||||
allows you to view component data right from the bin it's attached to, as well as
|
||||
other automation features.
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
),
|
||||
target: "#tour-bin-sensors",
|
||||
placement: "bottom",
|
||||
disableBeacon: false
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
content: (
|
||||
<React.Fragment>
|
||||
<Typography variant="body2" paragraph>
|
||||
Bin settings lets you rename your bin, and change various bin specifications.
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
),
|
||||
target: "#tour-bin-settings",
|
||||
placement: "bottom",
|
||||
disableBeacon: false
|
||||
},
|
||||
{
|
||||
title: "More Options",
|
||||
content: (
|
||||
<React.Fragment>
|
||||
<Typography variant="body2" paragraph>
|
||||
Access this menu if you want to leave the bin, share it, or view the bin's associated
|
||||
users and teams.
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
),
|
||||
target: "#tour-bin-kebab",
|
||||
placement: "bottom",
|
||||
disableBeacon: false
|
||||
},
|
||||
{
|
||||
title: "Graphs",
|
||||
content: (
|
||||
<React.Fragment>
|
||||
<Typography variant="body2" paragraph>
|
||||
Bin related analytics are displayed here.
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
),
|
||||
target: "#tour-graphs",
|
||||
placement: "left",
|
||||
disableBeacon: false
|
||||
},
|
||||
{
|
||||
title: "Choose your graphs",
|
||||
content: (
|
||||
<React.Fragment>
|
||||
<Typography variant="body2" paragraph>
|
||||
Use this tab to view other sets of data. Sensors must be attached to view sensor data.
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
),
|
||||
target: "#tour-graph-tabs",
|
||||
placement: "bottom",
|
||||
disableBeacon: false
|
||||
},
|
||||
{
|
||||
title: "Change Mode",
|
||||
content: (
|
||||
<React.Fragment>
|
||||
<Typography variant="body2" paragraph>
|
||||
Sensors need to be attached to change bin mode.
|
||||
</Typography>
|
||||
<ul>
|
||||
<li>Storage mode: default</li>
|
||||
<li>
|
||||
Drying mode: use heat to dry grain
|
||||
<Emoji text=" ☀️" />
|
||||
</li>
|
||||
<li>
|
||||
Cooldown mode: use fans to hold bin temperature lower
|
||||
<Emoji text=" ❆❅" />
|
||||
</li>
|
||||
</ul>
|
||||
</React.Fragment>
|
||||
),
|
||||
target: "#tour-bin-mode",
|
||||
placement: "bottom",
|
||||
disableBeacon: false
|
||||
}
|
||||
];
|
||||
return steps;
|
||||
};
|
||||
|
||||
// if this intro has been done, return null
|
||||
if (user.status.finishedBinIntro.length > 1) return null;
|
||||
|
||||
// if the user hasn't done the first intro, return null
|
||||
if (user.status.finishedIntro.length < 1) return null;
|
||||
|
||||
return (
|
||||
<div key={joyKey}>
|
||||
<Tour run={isTourRunning} steps={getTourSteps()} endTourCallback={endTour} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2099
src/bin/BinVisualizerV2.tsx
Normal file
2099
src/bin/BinVisualizerV2.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -142,7 +142,7 @@ export default function BinsList(props: Props) {
|
|||
key={i}
|
||||
className={classes.gridListTile}
|
||||
onClick={() => {
|
||||
//!duplicate && goToBin(i)
|
||||
!duplicate && goToBin(i)
|
||||
}}>
|
||||
<BinCardV2
|
||||
bin={b}
|
||||
|
|
|
|||
150
src/bin/CableTopNodeSummary.tsx
Normal file
150
src/bin/CableTopNodeSummary.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { Button, Card, Grid2 as Grid, Slider } from "@mui/material";
|
||||
import { useMobile } from "hooks";
|
||||
import { GrainCable } from "models/GrainCable";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { useBinAPI } from "providers";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
binKey: string;
|
||||
componentDevMap: Map<string, number>;
|
||||
cables: GrainCable[];
|
||||
}
|
||||
|
||||
interface CableNodeInfo {
|
||||
numNodes: number;
|
||||
cableKey: string;
|
||||
cableName: string;
|
||||
device: number;
|
||||
currentTopNode: number;
|
||||
}
|
||||
|
||||
export default function CableTopNodeSummary(props: Props) {
|
||||
const { binKey, cables, componentDevMap } = props;
|
||||
const [cableNodes, setCableNodes] = useState<CableNodeInfo[]>([]);
|
||||
const isMobile = useMobile();
|
||||
const binAPI = useBinAPI();
|
||||
|
||||
useEffect(() => {
|
||||
//go through the cables setting the node list and the name map
|
||||
let nodeList: CableNodeInfo[] = [];
|
||||
cables.forEach(cable => {
|
||||
nodeList.push({
|
||||
device: componentDevMap.get(cable.key()) ?? 0,
|
||||
cableKey: cable.key(),
|
||||
currentTopNode: cable.settings.grainFilledTo,
|
||||
cableName: cable.name(),
|
||||
numNodes: cable.temperatures.length
|
||||
});
|
||||
});
|
||||
setCableNodes(nodeList);
|
||||
}, [cables, componentDevMap]);
|
||||
|
||||
//function to build the new topnode array from the cableNodeInfo and send it to the backend to update the cables and their interactions
|
||||
const submit = () => {
|
||||
let newTopNodes: pond.NewTopNode[] = [];
|
||||
cableNodes.forEach(cable => {
|
||||
newTopNodes.push(
|
||||
pond.NewTopNode.create({
|
||||
cableKey: cable.cableKey,
|
||||
device: cable.device,
|
||||
nodeNumber: cable.currentTopNode
|
||||
})
|
||||
);
|
||||
});
|
||||
binAPI
|
||||
.updateTopNodes(binKey, newTopNodes)
|
||||
.then(resp => {
|
||||
console.log("Set Top Nodes for cables, components and interactions have been updated");
|
||||
})
|
||||
.catch(err => {
|
||||
console.log("One or more cables or interactions failed to update to the new top nodes");
|
||||
});
|
||||
};
|
||||
|
||||
const updateCableNode = (index: number, newNode: number) => {
|
||||
let nodeList = cableNodes;
|
||||
if (nodeList[index]) {
|
||||
nodeList[index].currentTopNode = newNode;
|
||||
}
|
||||
setCableNodes([...nodeList]);
|
||||
};
|
||||
|
||||
const verticalSliders = () => {
|
||||
return (
|
||||
<Grid container direction="row">
|
||||
{cableNodes.map((cable, index) => (
|
||||
<React.Fragment key={cable.cableKey}>
|
||||
<Grid
|
||||
size={{ xs: 2 }}
|
||||
container
|
||||
direction="column"
|
||||
style={{ padding: 10 }}
|
||||
alignContent="center"
|
||||
alignItems="center"
|
||||
justifyContent="center">
|
||||
<Grid style={{ height: 50 }}>
|
||||
{cable.cableName}
|
||||
</Grid>
|
||||
<Grid style={{ height: 150, margin: 10 }}>
|
||||
<Slider
|
||||
orientation="vertical"
|
||||
min={1}
|
||||
max={cable.numNodes}
|
||||
value={cable.currentTopNode}
|
||||
onChange={(_, val) => {
|
||||
updateCableNode(index, val as number);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid style={{ height: 30 }}>
|
||||
{cable.currentTopNode}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const horizontalSliders = () => {
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
style={{ padding: 5 }}
|
||||
alignContent="center"
|
||||
alignItems="center"
|
||||
justifyContent="center">
|
||||
{cableNodes.map((cable, index) => (
|
||||
<React.Fragment key={cable.cableKey}>
|
||||
<Grid size={{ xs: 3 }}>
|
||||
{cable.cableName}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8 }} style={{ paddingRight: 15 }}>
|
||||
<Slider
|
||||
min={1}
|
||||
max={cable.numNodes}
|
||||
value={cable.currentTopNode}
|
||||
onChange={(_, val) => {
|
||||
updateCableNode(index, val as number);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 1 }}>
|
||||
{cable.currentTopNode}
|
||||
</Grid>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={{ padding: 10 }} elevation={2}>
|
||||
{isMobile ? horizontalSliders() : verticalSliders()}
|
||||
<Button onClick={submit} variant="contained" color="primary">
|
||||
Update Cable Top Nodes
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
451
src/bin/GrainNodeInteractions.tsx
Normal file
451
src/bin/GrainNodeInteractions.tsx
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
import {
|
||||
AppBar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardActionArea,
|
||||
Checkbox,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
IconButton,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { ArrowBackIos, ArrowForwardIos } from "@mui/icons-material";
|
||||
import HumidityIcon from "component/HumidityIcon";
|
||||
import TemperatureIcon from "component/TemperatureIcon";
|
||||
import { useMobile, useSnackbar } from "hooks";
|
||||
import InteractionSettings from "interactions/InteractionSettings";
|
||||
import InteractionsOverview from "interactions/InteractionsOverview";
|
||||
import { clone } from "lodash";
|
||||
import { Component, Device } from "models";
|
||||
import { GrainCable } from "models/GrainCable";
|
||||
import { Interaction } from "models/Interaction";
|
||||
import moment from "moment";
|
||||
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
|
||||
import { canWrite } from "pbHelpers/Permission";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { quack } from "protobuf-ts/quack";
|
||||
import { useBinAPI, useComponentAPI, useGlobalState, useInteractionsAPI } from "providers";
|
||||
import AddIcon from "@mui/icons-material/AddCircle";
|
||||
import React, { useEffect, useState } from "react";
|
||||
// import { useHistory } from "react-router";
|
||||
import { green } from "@mui/material/colors";
|
||||
import ResponsiveDialog from "common/ResponsiveDialog";
|
||||
import GraphIcon from "products/CommonIcons/graphIcon";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
device: Device;
|
||||
cable: GrainCable;
|
||||
binKey: string;
|
||||
selectedNode: number;
|
||||
close: () => void;
|
||||
grain: pond.Grain;
|
||||
interactionComponents: Component[];
|
||||
permissions: pond.Permission[];
|
||||
updateComponentCallback: (componentKey: string) => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(() => {
|
||||
return ({
|
||||
dialog: {
|
||||
maxWidth: 350
|
||||
},
|
||||
appBar: {
|
||||
position: "static",
|
||||
borderRadius: 30
|
||||
},
|
||||
readingCard: {
|
||||
padding: 10
|
||||
},
|
||||
greenButton: {
|
||||
color: green["600"],
|
||||
padding: 0
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default function GrainNodeInteractions(props: Props) {
|
||||
const {
|
||||
open,
|
||||
selectedNode,
|
||||
device,
|
||||
cable,
|
||||
close,
|
||||
binKey,
|
||||
grain,
|
||||
interactionComponents,
|
||||
permissions,
|
||||
updateComponentCallback
|
||||
} = props;
|
||||
const [nodeTemp, setNodeTemp] = useState<number>();
|
||||
const [nodeHum, setNodeHum] = useState<number>();
|
||||
const [nodeMoist, setNodeMoist] = useState<number | undefined>();
|
||||
const [{ user }] = useGlobalState();
|
||||
const tempDescriber = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE);
|
||||
const humDescriber = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT);
|
||||
const moistureDescriber = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC);
|
||||
const [topNode, setTopNode] = useState(false);
|
||||
const componentAPI = useComponentAPI();
|
||||
const interactionAPI = useInteractionsAPI();
|
||||
const binAPI = useBinAPI();
|
||||
const [displayNode, setDisplayNode] = useState(0);
|
||||
const classes = useStyles();
|
||||
const isMobile = useMobile();
|
||||
// const history = useHistory();
|
||||
const navigate = useNavigate()
|
||||
const [cableInteractions, setCableInteractions] = useState<Interaction[]>([]);
|
||||
const { openSnack } = useSnackbar();
|
||||
const [newInteraction, setNewInteraction] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayNode(selectedNode);
|
||||
}, [selectedNode]);
|
||||
|
||||
useEffect(() => {
|
||||
interactionAPI.listInteractionsByComponent(device.id(), cable.location()).then(resp => {
|
||||
setCableInteractions(resp);
|
||||
});
|
||||
}, [cable, device, interactionAPI]);
|
||||
|
||||
useEffect(() => {
|
||||
//do the reversing to make the selected node match the array in the
|
||||
let revTemps = clone(cable.temperatures);
|
||||
revTemps.reverse();
|
||||
setNodeTemp(revTemps[displayNode - 1]);
|
||||
let revHums = clone(cable.humidities);
|
||||
revHums.reverse();
|
||||
setNodeHum(revHums[displayNode - 1]);
|
||||
let revMoist = clone(cable.grainMoistures);
|
||||
revMoist.reverse();
|
||||
setNodeMoist(revMoist[displayNode - 1]);
|
||||
|
||||
//determine whether the node being looked at is the active "top fill" node
|
||||
if (displayNode === cable.settings.grainFilledTo) {
|
||||
setTopNode(true);
|
||||
} else {
|
||||
setTopNode(false);
|
||||
}
|
||||
}, [cable, displayNode]);
|
||||
|
||||
const goToComponent = () => {
|
||||
navigate("/devices/" + device.id() + "/components/" + cable.key());
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setDisplayNode(selectedNode);
|
||||
close();
|
||||
};
|
||||
|
||||
const setEMC = () => {
|
||||
let settings = cable.settings;
|
||||
settings.defaultMutations = [pond.Mutator.MUTATOR_EMC];
|
||||
settings.grainType = grain;
|
||||
componentAPI
|
||||
.update(device.id(), settings, [binKey], ["bin"])
|
||||
.then(resp => {
|
||||
openSnack("EMC set on cable " + cable.name());
|
||||
updateComponentCallback(cable.key());
|
||||
})
|
||||
.catch(err => {});
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
componentAPI
|
||||
.update(device.id(), cable.settings, [binKey], ["bin"])
|
||||
.then(resp => {
|
||||
//after updating the component update the interactions with the new subtypes
|
||||
//TODO-CS: make function to update multiple interactions at once to avoid this loop if it becomes a problem
|
||||
cableInteractions.forEach(interaction => {
|
||||
interactionAPI
|
||||
.updateInteraction(device.id(), interaction.settings)
|
||||
.then(resp => {})
|
||||
.catch(err => {});
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
openSnack("Failed to update component");
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const updateTopNode = (checked: boolean) => {
|
||||
if (topNode && !checked) {
|
||||
cable.settings.grainFilledTo = 0;
|
||||
cableInteractions.forEach(interaction => {
|
||||
interaction.settings.subtype = 0;
|
||||
interaction.settings.nodeOne = 0;
|
||||
interaction.settings.nodeTwo = 0;
|
||||
});
|
||||
} else if (checked) {
|
||||
cable.settings.grainFilledTo = displayNode;
|
||||
cableInteractions.forEach(interaction => {
|
||||
interaction.settings.subtype = Interaction.upToSubtype(displayNode);
|
||||
interaction.settings.nodeOne = displayNode;
|
||||
});
|
||||
}
|
||||
let pref = pond.BinComponentPreferences.create({
|
||||
type: pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE,
|
||||
node: cable.settings.grainFilledTo
|
||||
});
|
||||
//update the bins preferences to have the new top node for the cable
|
||||
binAPI
|
||||
.updateComponentPreferences(binKey, cable.key(), pref)
|
||||
.then(resp => {
|
||||
openSnack("Updated cables top node");
|
||||
updateComponentCallback(cable.key());
|
||||
})
|
||||
.catch(err => {});
|
||||
setTopNode(checked);
|
||||
};
|
||||
|
||||
const displayTemp = () => {
|
||||
let tempFinal = nodeTemp ?? 0;
|
||||
if (user.settings.temperatureUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
|
||||
tempFinal = tempFinal * 1.8 + 32;
|
||||
}
|
||||
return tempFinal.toFixed(2);
|
||||
};
|
||||
|
||||
const displayNext = () => {
|
||||
let i = displayNode;
|
||||
if (i === cable.temperatures.length) {
|
||||
i = 1;
|
||||
} else {
|
||||
i = i + 1;
|
||||
}
|
||||
setDisplayNode(i);
|
||||
};
|
||||
|
||||
const displayPrev = () => {
|
||||
let i = displayNode;
|
||||
if (i === 1) {
|
||||
i = cable.temperatures.length;
|
||||
} else {
|
||||
i = i - 1;
|
||||
}
|
||||
setDisplayNode(i);
|
||||
};
|
||||
|
||||
const latestReading = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Typography style={{ fontSize: 20, fontWeight: 650 }}>Latest Reading</Typography>
|
||||
<Typography style={{ fontSize: 10 }}> {moment(cable.lastReading).fromNow()}</Typography>
|
||||
<Grid container direction="row" spacing={2} style={{ padding: 10 }}>
|
||||
<Grid item xs={6}>
|
||||
<Card className={classes.readingCard}>
|
||||
<Box display="flex" justifyContent="center" alignItems="center">
|
||||
<TemperatureIcon />
|
||||
<Typography style={{ color: tempDescriber.colour(), fontWeight: 650 }}>
|
||||
{displayTemp() + tempDescriber.GetUnit()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography style={{ textAlign: "center", color: "grey", fontWeight: 650 }}>
|
||||
Temperature
|
||||
</Typography>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Card className={classes.readingCard}>
|
||||
<Box display="flex" justifyContent="center" alignItems="center">
|
||||
<HumidityIcon />
|
||||
<Typography style={{ color: humDescriber.colour(), fontWeight: 650 }}>
|
||||
{nodeHum + humDescriber.GetUnit()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography style={{ textAlign: "center", color: "grey", fontWeight: 650 }}>
|
||||
Humidity
|
||||
</Typography>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
{/* only show the emc if the bin grain type and the cable grain type match */}
|
||||
{nodeMoist && cable.settings.grainType === grain ? (
|
||||
<Card className={classes.readingCard}>
|
||||
<Box display="flex" justifyContent="center" alignItems="center">
|
||||
<HumidityIcon />
|
||||
<Typography style={{ color: moistureDescriber.colour(), fontWeight: 650 }}>
|
||||
{nodeMoist + moistureDescriber.GetUnit()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography style={{ textAlign: "center", color: "grey", fontWeight: 650 }}>
|
||||
Grain EMC
|
||||
</Typography>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardActionArea
|
||||
className={classes.readingCard}
|
||||
onClick={() => {
|
||||
setEMC();
|
||||
}}>
|
||||
<Box display="flex" justifyContent="center" alignItems="center">
|
||||
<HumidityIcon />
|
||||
</Box>
|
||||
<Typography style={{ textAlign: "center", color: "grey", fontWeight: 650 }}>
|
||||
Set EMC
|
||||
</Typography>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Card>
|
||||
<CardActionArea className={classes.readingCard} onClick={() => goToComponent()}>
|
||||
<Box display="flex" justifyContent="center" alignItems="center">
|
||||
<GraphIcon />
|
||||
</Box>
|
||||
<Typography style={{ textAlign: "center", color: "grey", fontWeight: 650 }}>
|
||||
See Graphs
|
||||
</Typography>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const fillNodeSet = () => {
|
||||
return (
|
||||
<Box style={{ marginTop: 10 }}>
|
||||
<Typography style={{ fontSize: 20, fontWeight: 650 }}>Top Node Control</Typography>
|
||||
<FormControlLabel
|
||||
style={{ marginTop: 10 }}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={topNode}
|
||||
onChange={e => {
|
||||
updateTopNode(e.target.checked);
|
||||
}}
|
||||
name="topNode"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<React.Fragment>
|
||||
<Typography style={{ fontSize: 15, fontWeight: 650 }}>
|
||||
Set as top node in grain
|
||||
</Typography>
|
||||
<Typography style={{ fontSize: 15 }}>
|
||||
If checked this will set interactions to ignore all nodes above it. Interactions can
|
||||
still be adjusted manually to use all nodes
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const interactionDisplay = () => {
|
||||
let component = Component.create();
|
||||
component.settings = cable.settings;
|
||||
component.status = cable.status;
|
||||
return (
|
||||
<Box marginTop={2}>
|
||||
<Typography style={{ fontSize: 20, fontWeight: 650 }}>Interactions</Typography>
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<InteractionsOverview
|
||||
component={component}
|
||||
components={interactionComponents}
|
||||
device={device}
|
||||
interactions={cableInteractions}
|
||||
permissions={permissions}
|
||||
refreshCallback={() => {
|
||||
interactionAPI
|
||||
.listInteractionsByComponent(device.id(), cable.location())
|
||||
.then(resp => {
|
||||
setCableInteractions(resp);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="center">
|
||||
<IconButton
|
||||
color="primary"
|
||||
disabled={!canWrite(permissions)}
|
||||
aria-label="Add Interaction"
|
||||
onClick={() => {
|
||||
setNewInteraction(true);
|
||||
}}
|
||||
className={classes.greenButton}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ResponsiveDialog open={open} onClose={() => closeDialog()} fullWidth={isMobile}>
|
||||
<DialogTitle style={{ padding: 10 }}>
|
||||
<Box display="flex" justifyContent="center" marginBottom={2}>
|
||||
<Typography style={{ fontSize: 30, fontWeight: 650 }}>{cable.name()}</Typography>
|
||||
</Box>
|
||||
<AppBar className={classes.appBar} color="secondary">
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
wrap="nowrap">
|
||||
<Grid item>
|
||||
<Button onClick={() => displayPrev()}>
|
||||
<ArrowBackIos style={{ color: "black" }} />
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Box>
|
||||
<Typography align="center" variant="h5">
|
||||
Node {displayNode}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Button onClick={() => displayNext()}>
|
||||
<ArrowForwardIos style={{ color: "black" }} />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AppBar>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{latestReading()}
|
||||
{fillNodeSet()}
|
||||
{interactionDisplay()}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={closeDialog} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} color="primary">
|
||||
Submit
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</ResponsiveDialog>
|
||||
<InteractionSettings
|
||||
isDialogOpen={newInteraction}
|
||||
initialComponent={cable.asComponent()}
|
||||
mode="add"
|
||||
device={device}
|
||||
components={interactionComponents}
|
||||
closeDialogCallback={() => {
|
||||
setNewInteraction(false);
|
||||
}}
|
||||
refreshCallback={() => {
|
||||
interactionAPI.listInteractionsByComponent(device.id(), cable.location()).then(resp => {
|
||||
setCableInteractions(resp);
|
||||
});
|
||||
}}
|
||||
canEdit={canWrite(permissions)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
363
src/bin/graphs/BinComponentGraph.tsx
Normal file
363
src/bin/graphs/BinComponentGraph.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
Grid2 as Grid,
|
||||
Theme,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import AreaGraph, { AreaData } from "charts/measurementCharts/AreaGraph";
|
||||
import MultiLineGraph, { LineData, Point } from "charts/measurementCharts/MultiLineGraph";
|
||||
import UnitMeasurementSummary from "component/UnitMeasurementSummary";
|
||||
import { useThemeType } from "hooks";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { Component } from "models";
|
||||
import { UnitMeasurement } from "models/UnitMeasurement";
|
||||
import moment, { Moment } from "moment";
|
||||
import { GetComponentIcon } from "pbHelpers/ComponentType";
|
||||
import { describeMeasurement, MeasurementDescriber } from "pbHelpers/MeasurementDescriber";
|
||||
import { useGlobalState } from "providers";
|
||||
import React, { useState } from "react";
|
||||
import { avg } from "utils";
|
||||
|
||||
interface Props {
|
||||
component: Component;
|
||||
measurements: UnitMeasurement[];
|
||||
isLoading: boolean;
|
||||
xDomain: string[] | number[];
|
||||
zoomIn: (domain: string[] | number[]) => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => {
|
||||
return ({
|
||||
card: {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
overflow: "visible"
|
||||
},
|
||||
cardHeader: {
|
||||
padding: theme.spacing(1),
|
||||
paddingLeft: theme.spacing(2),
|
||||
marginRight: 10
|
||||
},
|
||||
avatarIcon: {
|
||||
width: 33,
|
||||
height: 33
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export default function BinComponentGraph(props: Props) {
|
||||
const { component, measurements, isLoading, xDomain, zoomIn } = props;
|
||||
const [selectedNodes, setSelectedNodes] = useState<string[]>(["all"]);
|
||||
const themeType = useThemeType();
|
||||
const classes = useStyles();
|
||||
const [{ user }] = useGlobalState();
|
||||
|
||||
const handleGrainCableControlsChange = (name: string) => (event: any) => {
|
||||
let nodes = selectedNodes;
|
||||
if (event.target.checked) {
|
||||
if (name === "all" || name === "grain" || name === "air") {
|
||||
nodes = [name];
|
||||
} else {
|
||||
nodes = nodes.filter(item => {
|
||||
let str = item.toString();
|
||||
return str !== "all" && str !== "air" && str !== "grain";
|
||||
});
|
||||
nodes.push(name);
|
||||
}
|
||||
} else {
|
||||
if (nodes.includes(name)) {
|
||||
nodes = nodes.filter(item => item.toString() !== name.toString());
|
||||
if (nodes.length < 1 && name !== "all") {
|
||||
nodes = ["all"];
|
||||
}
|
||||
}
|
||||
}
|
||||
setSelectedNodes(nodes);
|
||||
};
|
||||
|
||||
const cableControls = (numNodes: number) => {
|
||||
if (numNodes <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let grainCableControls = [];
|
||||
// let split = isBinSplit(graphFilters);
|
||||
// let splitAt = binSplitAt(graphFilters);
|
||||
|
||||
for (var i = numNodes; i > 0; i--) {
|
||||
if (i === numNodes) {
|
||||
grainCableControls.push(
|
||||
<Grid key="group-selectors">
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedNodes.includes("all") ? true : false}
|
||||
onChange={handleGrainCableControlsChange("all")}
|
||||
value={"all"}
|
||||
disabled={selectedNodes.includes("all") ? true : false}
|
||||
/>
|
||||
}
|
||||
label={"All"}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
let nodeLabel = "Node " + i.toString();
|
||||
if (i === 1) {
|
||||
nodeLabel = "Bottom";
|
||||
} else if (i === numNodes) {
|
||||
nodeLabel = "Top";
|
||||
}
|
||||
grainCableControls.push(
|
||||
<Grid key={"node-" + (i - 1).toString()}>
|
||||
<FormControlLabel
|
||||
//className={split && i <= splitAt ? classes.grainNode : classes.airNode}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedNodes.includes((i - 1).toString()) ? true : false}
|
||||
onChange={handleGrainCableControlsChange((i - 1).toString())}
|
||||
value={(i - 1).toString()}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2">{nodeLabel}</Typography>}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
return numNodes > 0 ? (
|
||||
<FormGroup>
|
||||
<Grid container direction="row">
|
||||
{grainCableControls}
|
||||
</Grid>
|
||||
</FormGroup>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const graphHeader = () => {
|
||||
const componentIcon = GetComponentIcon(
|
||||
component.settings.type,
|
||||
component.settings.subtype,
|
||||
themeType
|
||||
);
|
||||
let nodes = 1;
|
||||
let lastMeasurement = cloneDeep(component.status.measurement);
|
||||
if (
|
||||
lastMeasurement[0] &&
|
||||
lastMeasurement[0].values[0] &&
|
||||
lastMeasurement[0].values[0].values.length > 1
|
||||
) {
|
||||
nodes = lastMeasurement[0].values[0].values.length;
|
||||
}
|
||||
|
||||
return (
|
||||
<CardHeader
|
||||
avatar={
|
||||
<Avatar
|
||||
variant="square"
|
||||
src={componentIcon}
|
||||
className={classes.avatarIcon}
|
||||
alt={component.name()}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<React.Fragment>
|
||||
<Typography style={{ fontSize: 25, fontWeight: 650 }}>{component.name()}</Typography>
|
||||
{nodes > 1 && (
|
||||
<Grid container direction="row">
|
||||
{cableControls(nodes)}
|
||||
</Grid>
|
||||
)}
|
||||
</React.Fragment>
|
||||
}
|
||||
subheader={
|
||||
<UnitMeasurementSummary
|
||||
component={component}
|
||||
reading={UnitMeasurement.convertLastMeasurement(
|
||||
lastMeasurement.map(m => UnitMeasurement.create(m, user))
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const areaGraph = (
|
||||
unitMeasurement: UnitMeasurement,
|
||||
describer: MeasurementDescriber,
|
||||
averages?: number
|
||||
) => {
|
||||
if (unitMeasurement.timestamps.length > 0) {
|
||||
let firstTime: Moment | undefined;
|
||||
let lastTime: Moment | undefined;
|
||||
//build line data to pass to graph
|
||||
let areaData: AreaData[] = [];
|
||||
let avgData: AreaData[] = [];
|
||||
let maxVals: number[] = [];
|
||||
let minVals: number[] = [];
|
||||
|
||||
unitMeasurement.values.forEach((val, j) => {
|
||||
let currentMax = Math.min(...val.values);
|
||||
let currentMin = Math.max(...val.values);
|
||||
let dataPoint: AreaData = {
|
||||
timestamp: moment(unitMeasurement.timestamps[j]).valueOf(),
|
||||
value: [currentMax, currentMin]
|
||||
};
|
||||
if (averages) {
|
||||
if (minVals.length === 0) {
|
||||
firstTime = moment(unitMeasurement.timestamps[j]);
|
||||
}
|
||||
maxVals.push(currentMax);
|
||||
minVals.push(currentMin);
|
||||
if (minVals.length >= averages) {
|
||||
lastTime = moment(unitMeasurement.timestamps[j]);
|
||||
}
|
||||
}
|
||||
if (firstTime && lastTime) {
|
||||
avgData.push({
|
||||
timestamp:
|
||||
Math.abs(firstTime.diff(lastTime)) / 2 +
|
||||
Math.min(firstTime.valueOf(), lastTime.valueOf()),
|
||||
value: [avg(maxVals), avg(minVals)]
|
||||
});
|
||||
firstTime = undefined;
|
||||
lastTime = undefined;
|
||||
maxVals = [];
|
||||
minVals = [];
|
||||
}
|
||||
areaData.push(dataPoint);
|
||||
});
|
||||
return (
|
||||
<AreaGraph
|
||||
key={unitMeasurement.type}
|
||||
customHeight={350}
|
||||
data={areaData}
|
||||
//averagedData={showAveraged ? avgData : []}
|
||||
describer={describer}
|
||||
tooltip
|
||||
newXDomain={xDomain}
|
||||
multiGraphZoom={zoomIn}
|
||||
multiGraphZoomOut
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedNodeVals = (vals: number[]) => {
|
||||
let points: Point[] = [];
|
||||
vals.forEach((val, i) => {
|
||||
if (selectedNodes.includes("all") || selectedNodes.includes(i.toString())) {
|
||||
points.push({ value: val, node: i });
|
||||
}
|
||||
});
|
||||
return points;
|
||||
};
|
||||
|
||||
const lineGraph = (
|
||||
unitMeasurement: UnitMeasurement,
|
||||
describer: MeasurementDescriber,
|
||||
averages?: number
|
||||
) => {
|
||||
if (unitMeasurement.values.length > 0) {
|
||||
let firstTime: Moment | undefined;
|
||||
let lastTime: Moment | undefined;
|
||||
//build line data to pass to graph
|
||||
let lineData: LineData[] = [];
|
||||
let averagedData: LineData[] = [];
|
||||
let valMap: Map<number, number[]> = new Map<number, number[]>();
|
||||
|
||||
unitMeasurement.values.forEach((vals, i) => {
|
||||
let avgVals: Point[] = [];
|
||||
let newLineData: LineData = {
|
||||
timestamp: moment(unitMeasurement.timestamps[i]).valueOf(),
|
||||
points: selectedNodeVals(vals.values)
|
||||
};
|
||||
lineData.push(newLineData);
|
||||
if (averages) {
|
||||
vals.values.forEach((val, k) => {
|
||||
let entry = valMap.get(k);
|
||||
if (entry) {
|
||||
entry.push(val);
|
||||
if (entry.length >= averages) {
|
||||
lastTime = moment(unitMeasurement.timestamps[i]);
|
||||
avgVals.push({
|
||||
node: k,
|
||||
value: avg(entry)
|
||||
});
|
||||
}
|
||||
} else {
|
||||
valMap.set(k, [val]);
|
||||
firstTime = moment(unitMeasurement.timestamps[i]);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (firstTime && lastTime) {
|
||||
averagedData.push({
|
||||
points: avgVals,
|
||||
timestamp:
|
||||
Math.abs(firstTime.diff(lastTime)) / 2 +
|
||||
Math.min(firstTime.valueOf(), lastTime.valueOf())
|
||||
});
|
||||
firstTime = undefined;
|
||||
lastTime = undefined;
|
||||
valMap = new Map<number, number[]>();
|
||||
}
|
||||
});
|
||||
return (
|
||||
<MultiLineGraph
|
||||
customHeight={300}
|
||||
key={unitMeasurement.type}
|
||||
lineData={lineData}
|
||||
//averagedData={showAveraged ? averagedData : []}
|
||||
numLines={unitMeasurement.values.length > 1 ? unitMeasurement.values[0].values.length : 0}
|
||||
describer={describer}
|
||||
tooltip
|
||||
newXDomain={xDomain}
|
||||
multiGraphZoom={zoomIn}
|
||||
multiGraphZoomOut
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card raised key={component.key()} style={{ padding: 10, marginBottom: 15 }}>
|
||||
<Box display="flex" justifyContent="center" alignItems="center" style={{ height: 300 }}>
|
||||
<CircularProgress size={150} thickness={1.5} />
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Card raised key={component.key()} style={{ padding: 10, marginBottom: 15 }}>
|
||||
{graphHeader()}
|
||||
{selectedNodes.includes("all") && component.numNodes() > 1
|
||||
? measurements?.map(um =>
|
||||
areaGraph(
|
||||
um,
|
||||
describeMeasurement(um.type, component.type(), component.subType()),
|
||||
component.settings.smoothingAverages
|
||||
)
|
||||
)
|
||||
: measurements?.map(um =>
|
||||
lineGraph(
|
||||
um,
|
||||
describeMeasurement(um.type, component.type(), component.subType()),
|
||||
component.settings.smoothingAverages
|
||||
)
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
157
src/bin/graphs/BinCompositionGraph.tsx
Normal file
157
src/bin/graphs/BinCompositionGraph.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import {
|
||||
Card,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
Switch,
|
||||
Typography,
|
||||
useTheme
|
||||
} from "@mui/material";
|
||||
import GrainDescriber from "grain/GrainDescriber";
|
||||
import { Bin } from "models";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bar, BarChart, LabelList, ResponsiveContainer, XAxis, YAxis } from "recharts";
|
||||
import { Props as LabelProps } from "recharts/types/component/Label";
|
||||
|
||||
interface GraphProps {
|
||||
bin: Bin;
|
||||
nameMap: Map<string, string>;
|
||||
}
|
||||
|
||||
interface CompositionData {
|
||||
sourceName: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export default function BinCompositionGraph(props: GraphProps) {
|
||||
const { bin, nameMap } = props;
|
||||
const [originalSourceData, setOriginalSourceData] = useState<CompositionData[]>([]);
|
||||
const [objectSourceData, setObjectSourceData] = useState<CompositionData[]>([]);
|
||||
const [colour, setColour] = useState("");
|
||||
const [originalSource, setOriginalSource] = useState(true);
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
let originalSourceBarData: CompositionData[] = [];
|
||||
let objectSourceBarData: CompositionData[] = [];
|
||||
Object.keys(bin.status.grainComposition).forEach(key => {
|
||||
let roundedVal = Math.round(bin.status.grainComposition[key] * 100) / 100;
|
||||
if (roundedVal !== 0) {
|
||||
originalSourceBarData.push({
|
||||
sourceName: nameMap.get(key) ?? "Unknown",
|
||||
value: roundedVal
|
||||
});
|
||||
}
|
||||
});
|
||||
Object.keys(bin.status.objectSourceMap).forEach(key => {
|
||||
let roundedVal = Math.round(bin.status.objectSourceMap[key] * 100) / 100;
|
||||
if (roundedVal !== 0) {
|
||||
objectSourceBarData.push({
|
||||
sourceName: nameMap.get(key) ?? "Unknown",
|
||||
value: roundedVal
|
||||
});
|
||||
}
|
||||
});
|
||||
setOriginalSourceData(originalSourceBarData);
|
||||
setObjectSourceData(objectSourceBarData);
|
||||
setColour(GrainDescriber(bin.grain()).colour);
|
||||
}, [bin, nameMap]);
|
||||
|
||||
const customizedLabel = (props: LabelProps) => {
|
||||
const { x, y, width, height, value } = props;
|
||||
let centerX = 0;
|
||||
let centerY = 0;
|
||||
|
||||
let percent = "0%";
|
||||
if (bin.settings.inventory && value) {
|
||||
percent = "(" + ((+value / bin.settings.inventory.grainBushels) * 100).toFixed(2) + ")%";
|
||||
}
|
||||
|
||||
if (x && width && y && height) {
|
||||
centerX = +x + +width / 2;
|
||||
centerY = +y + +height / 2;
|
||||
}
|
||||
return (
|
||||
<g>
|
||||
<text
|
||||
x={centerX}
|
||||
y={centerY}
|
||||
fill="white"
|
||||
stroke="white"
|
||||
strokeWidth={1.5}
|
||||
fontSize={20}
|
||||
textAnchor="middle">
|
||||
{value}
|
||||
</text>
|
||||
<text
|
||||
x={centerX}
|
||||
y={centerY + 20}
|
||||
fill="white"
|
||||
stroke="white"
|
||||
strokeWidth={1}
|
||||
fontSize={15}
|
||||
textAnchor="middle">
|
||||
{percent}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const graphComponent = () => {
|
||||
return (
|
||||
<ResponsiveContainer width={"100%"} height={350}>
|
||||
<BarChart data={originalSource ? originalSourceData : objectSourceData}>
|
||||
<YAxis />
|
||||
<XAxis dataKey={"sourceName"} tick={{ fill: theme.palette.text.primary }} />
|
||||
<defs>
|
||||
<linearGradient id="gradientColour" x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0%" stopColor={colour} stopOpacity="0%" />
|
||||
<stop offset="100%" stopColor={colour} stopOpacity="90%" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Bar
|
||||
dataKey={"value"}
|
||||
fill={"url(#gradientColour)"}
|
||||
//barSize={barSize}
|
||||
>
|
||||
<LabelList
|
||||
fill={"white"}
|
||||
//dataKey="value"
|
||||
position="center"
|
||||
content={e => {
|
||||
return customizedLabel(e);
|
||||
}}
|
||||
style={{ fontWeight: 750, fontSize: 20 }}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card raised style={{ padding: 5 }}>
|
||||
<Grid container direction="row" justifyContent="space-between">
|
||||
<Grid item>
|
||||
<Typography style={{ fontSize: 25, fontWeight: 650, marginLeft: 20 }}>
|
||||
Grain Composition
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={originalSource}
|
||||
onChange={(_, checked) => {
|
||||
setOriginalSource(checked);
|
||||
}}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Original Source"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{graphComponent()}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
570
src/bin/graphs/BinGraphs.tsx
Normal file
570
src/bin/graphs/BinGraphs.tsx
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
//FormControlLabel,
|
||||
Grid2 as Grid,
|
||||
//Switch,
|
||||
Theme,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { ZoomOut } from "@mui/icons-material";
|
||||
import { GetDefaultDateRange } from "common/time/DateRange";
|
||||
import TimeBar from "common/time/TimeBar";
|
||||
import GrainDescriber from "grain/GrainDescriber";
|
||||
import { useMobile, useThemeType } from "hooks";
|
||||
import { Bin, Component } from "models";
|
||||
import { GrainCable } from "models/GrainCable";
|
||||
import { Plenum } from "models/Plenum";
|
||||
import { Pressure } from "models/Pressure";
|
||||
import { UnitMeasurement } from "models/UnitMeasurement";
|
||||
import moment, { Moment } from "moment";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { useBinAPI, useGlobalState } from "providers";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { stringToMaterialColour } from "utils";
|
||||
import BinGraphsTrending from "./BinGraphsTrending";
|
||||
import BinGraphsVPD from "./BinGraphsVPD";
|
||||
import BinLevelOverTime from "./BinLevelOverTime";
|
||||
import BinWaterLevel from "./BinWaterLevel";
|
||||
import VPDLight from "assets/products/Ag/dryingLight.png";
|
||||
import VPDDark from "assets/products/Ag/dryingDark.png";
|
||||
import WaterLight from "assets/products/Ag/waterContentLight.png";
|
||||
import WaterDark from "assets/products/Ag/waterContentDark.png";
|
||||
import TrendLight from "assets/products/Ag/trendingLight.png";
|
||||
import TrendDark from "assets/products/Ag/trendingDark.png";
|
||||
import { GrainDryingPoint } from "charts/GrainDryingChart";
|
||||
import { blue, orange, teal } from "@mui/material/colors";
|
||||
import { DataPoint, TrendPoint } from "charts/TrendingChart";
|
||||
import { DataPoint as WaterPoint } from "charts/WaterLevelChart";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { Controller } from "models/Controller";
|
||||
import BinComponentGraph from "./BinComponentGraph";
|
||||
import BinCompositionGraph from "./BinCompositionGraph";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
|
||||
interface Props {
|
||||
bin: Bin;
|
||||
plenums: Plenum[];
|
||||
cables: GrainCable[];
|
||||
pressures: Pressure[];
|
||||
fans: Controller[];
|
||||
componentDevices: Map<string, number>;
|
||||
compositionNameMap: Map<string, string>;
|
||||
compMap: Map<string, Component>;
|
||||
binLoading: boolean;
|
||||
display: "inventory" | "sensors" | "analytics";
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>{
|
||||
return ({
|
||||
card: {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
overflow: "visible"
|
||||
},
|
||||
cardHeader: {
|
||||
padding: theme.spacing(1),
|
||||
paddingLeft: theme.spacing(2),
|
||||
marginRight: 10
|
||||
},
|
||||
avatarIcon: {
|
||||
width: 33,
|
||||
height: 33
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export default function BinGraphs(props: Props) {
|
||||
const {
|
||||
bin,
|
||||
plenums,
|
||||
cables,
|
||||
pressures,
|
||||
binLoading,
|
||||
display,
|
||||
componentDevices,
|
||||
compositionNameMap,
|
||||
compMap,
|
||||
fans
|
||||
} = props;
|
||||
// map using the component key and the unitmeasurements for that component
|
||||
const [compMeasurements, setCompMeasurements] = useState<Map<string, UnitMeasurement[]>>(
|
||||
new Map<string, UnitMeasurement[]>()
|
||||
);
|
||||
const defaultDateRange = GetDefaultDateRange();
|
||||
const [startDate, setStartDate] = useState<Moment>(defaultDateRange.start);
|
||||
const [endDate, setEndDate] = useState<Moment>(defaultDateRange.end);
|
||||
const binAPI = useBinAPI();
|
||||
const [xDomain, setXDomain] = useState<string[] | number[]>(["dataMin", "dataMax"]);
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
const themeType = useThemeType();
|
||||
const classes = useStyles();
|
||||
const [recentVPD, setRecentVPD] = useState<GrainDryingPoint | undefined>();
|
||||
const [recentPlenumTrend, setRecentPlenumTrend] = useState<TrendPoint | undefined>();
|
||||
const [recentCableTrend, setRecentCableTrend] = useState<DataPoint | undefined>();
|
||||
const [recentWaterContent, setRecentWaterContent] = useState<WaterPoint | undefined>();
|
||||
const [{ user }] = useGlobalState();
|
||||
const [measurementsLoading, setMeasurementsLoading] = useState(false);
|
||||
const isMobile = useMobile();
|
||||
const [{ showErrors }, dispatch] = useGlobalState();
|
||||
//const [includeCFM, setIncludeCFM] = useState(false);
|
||||
|
||||
const zoomOut = () => {
|
||||
setXDomain(["dataMin", "dataMax"]);
|
||||
setZoomed(false);
|
||||
};
|
||||
|
||||
const zoomIn = (domain: string[] | number[]) => {
|
||||
if (!isMobile) {
|
||||
setXDomain(domain);
|
||||
setZoomed(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let measurementMap: Map<string, UnitMeasurement[]> = new Map<string, UnitMeasurement[]>();
|
||||
if (display !== "inventory" && !measurementsLoading) {
|
||||
setMeasurementsLoading(true);
|
||||
//check if the bin has a fan to use the cfm in the drying graph
|
||||
// if (bin.settings.fan?.type !== pond.FanType.FAN_TYPE_UNKNOWN) {
|
||||
// setIncludeCFM(true);
|
||||
// }
|
||||
binAPI
|
||||
.listBinComponentsMeasurements(
|
||||
bin.key(),
|
||||
startDate.toISOString(),
|
||||
endDate.toISOString(),
|
||||
showErrors
|
||||
)
|
||||
.then(resp => {
|
||||
resp.data.measurements.forEach((um: any) => {
|
||||
let unitMeasurement = UnitMeasurement.any(um, user);
|
||||
let entry = measurementMap.get(unitMeasurement.componentId);
|
||||
if (entry) {
|
||||
entry.push(unitMeasurement);
|
||||
} else {
|
||||
measurementMap.set(unitMeasurement.componentId, [unitMeasurement]);
|
||||
}
|
||||
});
|
||||
setCompMeasurements(measurementMap);
|
||||
setMeasurementsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [bin, binAPI, startDate, endDate, display, user]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const updateDateRange = (newStartDate: any, newEndDate: any) => {
|
||||
let range = GetDefaultDateRange();
|
||||
range.start = newStartDate;
|
||||
range.end = newEndDate;
|
||||
setStartDate(newStartDate);
|
||||
setEndDate(newEndDate);
|
||||
};
|
||||
|
||||
const determineGrainColour = () => {
|
||||
let col = "yellow";
|
||||
let binInv = bin.settings.inventory;
|
||||
if (binInv) {
|
||||
if (binInv.grainType === pond.Grain.GRAIN_CUSTOM) {
|
||||
col = stringToMaterialColour(binInv.customTypeName);
|
||||
} else if (binInv.grainType !== pond.Grain.GRAIN_NONE) {
|
||||
col = GrainDescriber(binInv.grainType).colour;
|
||||
}
|
||||
}
|
||||
return col;
|
||||
};
|
||||
|
||||
const inventoryGraph = () => {
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<BinLevelOverTime
|
||||
customHeight={575}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
binLoading={binLoading}
|
||||
bin={bin}
|
||||
colour={determineGrainColour()}
|
||||
fertilizerBin={bin.settings.storage === pond.BinStorage.BIN_STORAGE_FERTILIZER}
|
||||
/>
|
||||
</Grid>
|
||||
{user.hasFeature("grain-composition") && (
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<BinCompositionGraph bin={bin} nameMap={compositionNameMap} />
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
//will use the compMeasurments to show all the graphs for all connected sensors, as it goes through the plenums/pressures/cables it will remove them from
|
||||
//the temp measurments map so that it will show the remaining components at the bottom
|
||||
const sensorGraphs = () => {
|
||||
let toShow = cloneDeep(compMeasurements);
|
||||
return (
|
||||
<Box>
|
||||
{plenums.map(p => {
|
||||
let measurements = toShow.get(p.key());
|
||||
toShow.delete(p.key());
|
||||
return (
|
||||
<BinComponentGraph
|
||||
key={p.key()}
|
||||
component={p.asComponent()}
|
||||
isLoading={measurementsLoading}
|
||||
measurements={measurements ?? []}
|
||||
xDomain={xDomain}
|
||||
zoomIn={zoomIn}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{pressures.map(p => {
|
||||
let measurements = toShow.get(p.key());
|
||||
toShow.delete(p.key());
|
||||
return (
|
||||
<BinComponentGraph
|
||||
key={p.key()}
|
||||
component={p.asComponent()}
|
||||
isLoading={measurementsLoading}
|
||||
measurements={measurements ?? []}
|
||||
xDomain={xDomain}
|
||||
zoomIn={zoomIn}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{cables.map(c => {
|
||||
let measurements = toShow.get(c.key());
|
||||
toShow.delete(c.key());
|
||||
return (
|
||||
<BinComponentGraph
|
||||
key={c.key()}
|
||||
component={c.asComponent()}
|
||||
isLoading={measurementsLoading}
|
||||
measurements={measurements ?? []}
|
||||
xDomain={xDomain}
|
||||
zoomIn={zoomIn}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{Array.from(toShow.values()).map((compMeasurement) => {
|
||||
let c = Component.create();
|
||||
if (compMeasurement[0]) {
|
||||
c = compMap.get(compMeasurement[0].componentId) ?? Component.create();
|
||||
}
|
||||
return (
|
||||
<BinComponentGraph
|
||||
key={c.key()}
|
||||
component={c}
|
||||
isLoading={measurementsLoading}
|
||||
measurements={compMeasurement}
|
||||
xDomain={xDomain}
|
||||
zoomIn={zoomIn}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const waterGraph = () => {
|
||||
let moistureCables: GrainCable[] = [];
|
||||
cables.forEach(cable => {
|
||||
if (cable.humidities.length > 0) {
|
||||
moistureCables.push(cable);
|
||||
}
|
||||
});
|
||||
if (plenums[0] && moistureCables[0] && pressures[0] && bin.supportedGrain()) {
|
||||
return (
|
||||
<Box>
|
||||
<BinWaterLevel
|
||||
bin={bin}
|
||||
range={{ start: startDate, end: endDate }}
|
||||
plenumKey={componentDevices.get(plenums[0].key()) + ":" + plenums[0].key()}
|
||||
cableKey={componentDevices.get(moistureCables[0].key()) + ":" + moistureCables[0].key()}
|
||||
pressureKey={componentDevices.get(pressures[0].key()) + ":" + pressures[0].key()}
|
||||
fanKey={
|
||||
fans.length > 0
|
||||
? componentDevices.get(fans[0].key()) + ":" + fans[0].key()
|
||||
: undefined
|
||||
}
|
||||
newXDomain={xDomain}
|
||||
multiGraphZoom={zoomIn}
|
||||
multiGraphZoomOut
|
||||
returnLast={lastWater => {
|
||||
setRecentWaterContent(lastWater);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Box minHeight={100} display="flex" justifyContent="center" alignItems="center">
|
||||
<Typography style={{ fontWeight: 650 }}>
|
||||
Plenum with pressure and moisture cable as well as an initial moisture and supported
|
||||
grain type set in the bin settings required to calculate Water Content
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const analyticGraphs = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Card raised style={{ padding: 10, marginBottom: 15 }}>
|
||||
<CardHeader
|
||||
avatar={
|
||||
<Avatar
|
||||
variant="square"
|
||||
src={themeType === "light" ? VPDDark : VPDLight}
|
||||
className={classes.avatarIcon}
|
||||
alt={"Drying Score"}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<Grid container direction="row" justifyContent="space-between">
|
||||
<Grid >
|
||||
<Typography style={{ fontSize: 25, fontWeight: 650 }}>
|
||||
Drying vs. Hydrating
|
||||
</Typography>
|
||||
</Grid>
|
||||
{/* {bin.settings.fan?.type !== pond.FanType.FAN_TYPE_UNKNOWN && (
|
||||
<Grid item>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={includeCFM}
|
||||
onChange={(_, checked) => {
|
||||
setIncludeCFM(checked);
|
||||
}}
|
||||
name="include CFM"
|
||||
/>
|
||||
}
|
||||
label="Include CFM"
|
||||
labelPlacement="start"
|
||||
/>
|
||||
</Grid>
|
||||
)} */}
|
||||
</Grid>
|
||||
}
|
||||
subheader={
|
||||
recentVPD ? (
|
||||
<Grid container>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<span>
|
||||
<span style={{ color: recentVPD.dryScore > 0 ? orange[500] : blue[500] }}>
|
||||
{recentVPD.dryScore > 0 ? "drying" : "hydrating"}
|
||||
</span>
|
||||
<span>{" : "}</span>
|
||||
<span
|
||||
style={{
|
||||
color: recentVPD.dryScore > 0 ? orange[500] : blue[500],
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{recentVPD.dryScore.toFixed(2)}
|
||||
</span>
|
||||
<br />
|
||||
</span>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Typography color="textSecondary" variant={"caption"}>
|
||||
{moment(recentVPD.timestamp).fromNow()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body1" color="textPrimary">
|
||||
No Data
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{cables.length > 0 && plenums.length > 0 ? (
|
||||
<BinGraphsVPD
|
||||
cables={cables}
|
||||
plenums={plenums}
|
||||
measurementMap={compMeasurements}
|
||||
newXDomain={xDomain}
|
||||
multiGraphZoom={zoomIn}
|
||||
multiGraphZoomOut
|
||||
//perBushelCFM={includeCFM ? bin.status.cfmPerBushel : undefined}
|
||||
returnLastVPD={lastVPD => {
|
||||
setRecentVPD(lastVPD);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box minHeight={100} display="flex" justifyContent="center" alignItems="center">
|
||||
<Typography style={{ fontWeight: 650 }}>
|
||||
Plenum and moisture cable required to calculate VPD
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
<Card raised style={{ padding: 10, marginBottom: 15 }}>
|
||||
<CardHeader
|
||||
avatar={
|
||||
<Avatar
|
||||
variant="square"
|
||||
src={themeType === "light" ? TrendDark : TrendLight}
|
||||
className={classes.avatarIcon}
|
||||
alt={"VPD"}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<Typography style={{ fontSize: 25, fontWeight: 650 }}>Moisture Trending</Typography>
|
||||
}
|
||||
subheader={
|
||||
recentPlenumTrend && recentCableTrend ? (
|
||||
<Grid container>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<span>
|
||||
{"Plenum Moisture: "}
|
||||
<span style={{ color: orange[500], fontWeight: 500 }}>
|
||||
{recentPlenumTrend.trend.toFixed(2)}
|
||||
</span>
|
||||
<br />
|
||||
</span>
|
||||
<span>
|
||||
{"Grain Moisture: "}
|
||||
<span style={{ color: teal[500], fontWeight: 500 }}>
|
||||
{recentCableTrend.moisture.toString()}
|
||||
</span>
|
||||
<br />
|
||||
</span>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Typography color="textSecondary" variant={"caption"}>
|
||||
{recentPlenumTrend.timestamp > recentCableTrend.timestamp
|
||||
? moment(recentPlenumTrend.timestamp).fromNow()
|
||||
: moment(recentCableTrend.timestamp).fromNow()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body1" color="textPrimary">
|
||||
No Data
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{cables.length > 0 && plenums.length > 0 ? (
|
||||
<BinGraphsTrending
|
||||
cables={cables}
|
||||
plenums={plenums}
|
||||
measurementMap={compMeasurements}
|
||||
bin={bin}
|
||||
newXDomain={xDomain}
|
||||
multiGraphZoom={zoomIn}
|
||||
multiGraphZoomOut
|
||||
returnLastMeasurements={(lastData, lastTrend) => {
|
||||
setRecentCableTrend(lastData);
|
||||
setRecentPlenumTrend(lastTrend);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box minHeight={100} display="flex" justifyContent="center" alignItems="center">
|
||||
<Typography style={{ fontWeight: 650 }}>
|
||||
Plenum and moisture cable required to calculate trending data
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
<Card raised style={{ padding: 10 }}>
|
||||
<CardHeader
|
||||
avatar={
|
||||
<Avatar
|
||||
variant="square"
|
||||
src={themeType === "light" ? WaterDark : WaterLight}
|
||||
className={classes.avatarIcon}
|
||||
alt={"VPD"}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<Typography style={{ fontSize: 25, fontWeight: 650 }}>Grain Water Content</Typography>
|
||||
}
|
||||
subheader={
|
||||
recentWaterContent &&
|
||||
bin.settings.inventory &&
|
||||
bin.settings.inventory.initialMoisture > 0 ? (
|
||||
<Grid container>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<span>
|
||||
{"Water Content: "}
|
||||
<span style={{ color: blue[500], fontWeight: 500 }}>
|
||||
{recentWaterContent.liters && !isNaN(recentWaterContent.liters)
|
||||
? recentWaterContent.liters.toFixed(2)
|
||||
: ""}
|
||||
</span>
|
||||
<br />
|
||||
</span>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Typography color="textSecondary" variant={"caption"}>
|
||||
{moment(recentWaterContent.timestamp).fromNow()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body1" color="textPrimary">
|
||||
No Data
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{waterGraph()}
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const showGraphs = () => {
|
||||
switch (display) {
|
||||
case "inventory":
|
||||
return inventoryGraph();
|
||||
case "sensors":
|
||||
return sensorGraphs();
|
||||
case "analytics":
|
||||
return analyticGraphs();
|
||||
default:
|
||||
return <Box>Unknown Graph Type Selected</Box>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Grid container justifyContent="space-between">
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" marginBottom={2}>
|
||||
<TimeBar updateDateRange={updateDateRange} startDate={startDate} endDate={endDate} />
|
||||
{display !== "inventory" && zoomed && (
|
||||
<Tooltip title="Zoom Out Graphs">
|
||||
<Button variant="outlined" onClick={zoomOut}>
|
||||
<ZoomOut />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
<Grid>
|
||||
<FormControlLabel
|
||||
label="Show Errors"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={showErrors}
|
||||
onChange={() => {
|
||||
//setShowErrors(!showErrors);
|
||||
dispatch({ key: "showErrors", value: !showErrors });
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{showGraphs()}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
187
src/bin/graphs/BinGraphsTrending.tsx
Normal file
187
src/bin/graphs/BinGraphsTrending.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { Box, MenuItem, TextField } from "@mui/material";
|
||||
import TrendingChart, { DataPoint, TrendPoint } from "charts/TrendingChart";
|
||||
import { ExtractMoisture } from "grain";
|
||||
import { Bin } from "models";
|
||||
import { GrainCable } from "models/GrainCable";
|
||||
import { Plenum } from "models/Plenum";
|
||||
import { UnitMeasurement } from "models/UnitMeasurement";
|
||||
import moment from "moment";
|
||||
import { quack } from "protobuf-ts/quack";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
bin: Bin;
|
||||
plenums: Plenum[];
|
||||
cables: GrainCable[];
|
||||
measurementMap: Map<string, UnitMeasurement[]>;
|
||||
returnLastMeasurements: (
|
||||
dataPoint: DataPoint | undefined,
|
||||
trendPoint: TrendPoint | undefined
|
||||
) => void;
|
||||
newXDomain?: number[] | string[];
|
||||
multiGraphZoom?: (domain: number[] | string[]) => void;
|
||||
multiGraphZoomOut?: boolean;
|
||||
}
|
||||
|
||||
export default function BinGraphsTrending(props: Props) {
|
||||
const {
|
||||
bin,
|
||||
plenums,
|
||||
cables,
|
||||
measurementMap,
|
||||
newXDomain,
|
||||
multiGraphZoom,
|
||||
multiGraphZoomOut,
|
||||
returnLastMeasurements
|
||||
} = props;
|
||||
const [plenum, setPlenum] = useState("");
|
||||
const [cable, setCable] = useState("");
|
||||
const [cableOptions, setCableOptions] = useState<GrainCable[]>([]);
|
||||
const [chartData, setChartData] = useState<DataPoint[]>([]);
|
||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let plenum: string = "";
|
||||
if (plenums[0]) {
|
||||
plenum = plenums[0].key();
|
||||
}
|
||||
let ops: GrainCable[] = [];
|
||||
let cable: string = "";
|
||||
cables.forEach(c => {
|
||||
if (c.humidities.length > 0) {
|
||||
ops.push(c);
|
||||
if (ops.length === 1) {
|
||||
cable = c.key();
|
||||
}
|
||||
}
|
||||
});
|
||||
setPlenum(plenum);
|
||||
setCable(cable);
|
||||
setCableOptions(ops);
|
||||
}, [plenums, cables]);
|
||||
|
||||
useEffect(() => {
|
||||
if (plenum && cable) {
|
||||
let lastData: DataPoint | undefined;
|
||||
let lastTrend: TrendPoint | undefined;
|
||||
setTrendData([]);
|
||||
setChartData([]);
|
||||
let data: DataPoint[] = [];
|
||||
let trendData: TrendPoint[] = [];
|
||||
let plenumMeasurements = measurementMap.get(plenum);
|
||||
let cableMeasurements = measurementMap.get(cable);
|
||||
if (plenumMeasurements && cableMeasurements) {
|
||||
// set the data of the plenum run through the emc equation
|
||||
let plenumTemp: UnitMeasurement = UnitMeasurement.create();
|
||||
let plenumHumidity: UnitMeasurement = UnitMeasurement.create();
|
||||
|
||||
plenumMeasurements.forEach(um => {
|
||||
if (um.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) {
|
||||
plenumTemp = um;
|
||||
}
|
||||
if (um.type === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) {
|
||||
plenumHumidity = um;
|
||||
}
|
||||
});
|
||||
|
||||
if (plenumTemp.values.length > 0) {
|
||||
plenumTemp.values.forEach((val, i) => {
|
||||
if (val.values[0] && plenumHumidity.values[i] && plenumHumidity.values[i].values[0]) {
|
||||
let trendPoint: TrendPoint = {
|
||||
timestamp: moment(plenumTemp.timestamps[i]).valueOf(),
|
||||
trend: ExtractMoisture(
|
||||
bin.grain(),
|
||||
val.values[0],
|
||||
plenumHumidity.values[i].values[0]
|
||||
)
|
||||
};
|
||||
trendData.push(trendPoint);
|
||||
if (!lastTrend || lastTrend.timestamp < trendPoint.timestamp) {
|
||||
lastTrend = trendPoint;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let dataPoint: DataPoint;
|
||||
//the grain emc mutation must be set on the component to show this graph
|
||||
let grainMoisture: UnitMeasurement | undefined;
|
||||
cableMeasurements.forEach(um => {
|
||||
if (um.type === quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC) {
|
||||
grainMoisture = um;
|
||||
}
|
||||
});
|
||||
|
||||
//set the data for the grain moisture of the graph ONLY if the component was set to have an emc mutation
|
||||
if (grainMoisture) {
|
||||
grainMoisture.values.forEach((val, i) => {
|
||||
dataPoint = {
|
||||
timestamp: grainMoisture ? moment(grainMoisture.timestamps[i]).valueOf() : 0,
|
||||
moisture: [Math.min(...val.values), Math.max(...val.values)]
|
||||
};
|
||||
data.push(dataPoint);
|
||||
if (!lastData || lastData.timestamp < dataPoint.timestamp) {
|
||||
lastData = dataPoint;
|
||||
}
|
||||
});
|
||||
}
|
||||
setChartData(data);
|
||||
setTrendData(trendData);
|
||||
returnLastMeasurements(lastData, lastTrend);
|
||||
}
|
||||
}
|
||||
}, [plenum, cable, measurementMap, bin]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{plenums.length > 1 && (
|
||||
<TextField
|
||||
style={{ marginRight: 15 }}
|
||||
label="Plenum"
|
||||
variant="outlined"
|
||||
select
|
||||
id="plenumSelector"
|
||||
value={plenum}
|
||||
onChange={e => {
|
||||
setPlenum(e.target.value as string);
|
||||
}}>
|
||||
{plenums.map(k => {
|
||||
return (
|
||||
<MenuItem key={k.key()} value={k.key()}>
|
||||
{k.name()}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</TextField>
|
||||
)}
|
||||
{cableOptions.length > 1 && (
|
||||
<TextField
|
||||
label="Cable"
|
||||
variant="outlined"
|
||||
select
|
||||
id="cableSelector"
|
||||
value={cable}
|
||||
onChange={e => {
|
||||
setCable(e.target.value as string);
|
||||
}}>
|
||||
{cableOptions.map(k => {
|
||||
return (
|
||||
<MenuItem key={k.key()} value={k.key()}>
|
||||
{k.name()}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</TextField>
|
||||
)}
|
||||
{trendData.length > 0 && chartData.length > 0 && (
|
||||
<TrendingChart
|
||||
data={chartData}
|
||||
trend={trendData}
|
||||
newXDomain={newXDomain}
|
||||
multiGraphZoom={multiGraphZoom}
|
||||
multiGraphZoomOut={multiGraphZoomOut}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
303
src/bin/graphs/BinGraphsVPD.tsx
Normal file
303
src/bin/graphs/BinGraphsVPD.tsx
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { Box, CircularProgress, MenuItem, TextField } from "@mui/material";
|
||||
import GrainDryingChart, { GrainDryingPoint } from "charts/GrainDryingChart";
|
||||
import { GrainCable } from "models/GrainCable";
|
||||
import { Plenum } from "models/Plenum";
|
||||
import { UnitMeasurement } from "models/UnitMeasurement";
|
||||
import moment from "moment";
|
||||
import { quack } from "protobuf-ts/quack";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { avg } from "utils";
|
||||
|
||||
interface Props {
|
||||
plenums: Plenum[];
|
||||
cables: GrainCable[];
|
||||
measurementMap: Map<string, UnitMeasurement[]>;
|
||||
returnLastVPD: (lastVPD: GrainDryingPoint | undefined) => void;
|
||||
newXDomain?: number[] | string[];
|
||||
multiGraphZoom?: (domain: number[] | string[]) => void;
|
||||
multiGraphZoomOut?: boolean;
|
||||
//perBushelCFM?: number;
|
||||
}
|
||||
|
||||
interface UnitMeasurementHelper {
|
||||
timestamp: string;
|
||||
compKey: string;
|
||||
measurementType: quack.MeasurementType;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface NodeOption {
|
||||
name: string;
|
||||
number: number;
|
||||
}
|
||||
|
||||
export default function BinGraphsVPD(props: Props) {
|
||||
const {
|
||||
plenums,
|
||||
cables,
|
||||
measurementMap,
|
||||
newXDomain,
|
||||
multiGraphZoom,
|
||||
multiGraphZoomOut,
|
||||
returnLastVPD
|
||||
//perBushelCFM
|
||||
} = props;
|
||||
const [vpdData, setVpdData] = useState<GrainDryingPoint[]>([]);
|
||||
const [plenum, setPlenum] = useState<string>("");
|
||||
const [cable, setCable] = useState<string>("");
|
||||
const [cableOptions, setCableOptions] = useState<GrainCable[]>([]);
|
||||
const [calculating, setCalculating] = useState(false);
|
||||
const [selectedNode, setSelectedNode] = useState(-1);
|
||||
const [nodeOps, setNodeOps] = useState<NodeOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let plenum: string = "";
|
||||
if (plenums[0]) {
|
||||
plenum = plenums[0].key();
|
||||
}
|
||||
let ops: GrainCable[] = [];
|
||||
let cable: string = "";
|
||||
cables.forEach(c => {
|
||||
if (c.humidities.length > 0) {
|
||||
ops.push(c);
|
||||
if (ops.length === 1) {
|
||||
cable = c.key();
|
||||
let nodes: NodeOption[] = [];
|
||||
c.humidities.forEach((hum, i) => {
|
||||
nodes.push({
|
||||
name: "Node" + (i + 1),
|
||||
number: i
|
||||
});
|
||||
});
|
||||
setNodeOps(nodes);
|
||||
}
|
||||
}
|
||||
});
|
||||
setPlenum(plenum);
|
||||
setCable(cable);
|
||||
setCableOptions(ops);
|
||||
}, [plenums, cables]);
|
||||
|
||||
const buildHelperArray = (
|
||||
plenumUM: UnitMeasurement[],
|
||||
cableUM: UnitMeasurement[],
|
||||
node?: number
|
||||
) => {
|
||||
let helper: UnitMeasurementHelper[] = [];
|
||||
//doing the plenum and the cable seperately because the plenum will always be done the same way however the cable will be done differently if there is a node selected
|
||||
plenumUM.forEach(unitM => {
|
||||
if (
|
||||
unitM.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE ||
|
||||
unitM.type === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT
|
||||
) {
|
||||
unitM.values.forEach((valSet, i) => {
|
||||
helper.push({
|
||||
timestamp: unitM.timestamps[i],
|
||||
measurementType: unitM.type,
|
||||
compKey: unitM.componentId,
|
||||
value: avg(valSet.values)
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
cableUM.forEach(unitM => {
|
||||
if (
|
||||
unitM.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE ||
|
||||
unitM.type === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT
|
||||
) {
|
||||
unitM.values.forEach((valSet, i) => {
|
||||
let newHelper = {
|
||||
timestamp: unitM.timestamps[i],
|
||||
measurementType: unitM.type,
|
||||
compKey: unitM.componentId,
|
||||
value: node !== undefined ? valSet.values[node] : avg(valSet.values)
|
||||
};
|
||||
helper.push(newHelper);
|
||||
});
|
||||
}
|
||||
});
|
||||
helper.sort((a, b) => {
|
||||
return moment(a.timestamp).valueOf() - moment(b.timestamp).valueOf();
|
||||
});
|
||||
return helper;
|
||||
};
|
||||
|
||||
// T = temperature im degrees Celsius
|
||||
// RH = relative humidity (%)
|
||||
const calculateVPD = (T: number, RH: number) => {
|
||||
const es = 0.6108 * Math.exp((17.27 * T) / (T + 237.3));
|
||||
const ea = (RH / 100) * es;
|
||||
return ea - es;
|
||||
};
|
||||
|
||||
const buildData = (measurements: UnitMeasurementHelper[]) => {
|
||||
let data: GrainDryingPoint[] = [];
|
||||
|
||||
let recent: GrainDryingPoint | undefined;
|
||||
|
||||
let currentPlenumTemp: number;
|
||||
let currentPlenumHum: number;
|
||||
let currentCableTemp: number;
|
||||
let currentCableHum: number;
|
||||
if (measurements[0]) {
|
||||
let lastTime = moment(measurements[0].timestamp);
|
||||
measurements.forEach(m => {
|
||||
let thisTime = moment(m.timestamp);
|
||||
if (m.compKey === plenum) {
|
||||
if (m.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) {
|
||||
currentPlenumTemp = m.value;
|
||||
} else if (m.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) {
|
||||
currentPlenumHum = m.value;
|
||||
}
|
||||
}
|
||||
if (m.compKey === cable) {
|
||||
if (m.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) {
|
||||
currentCableTemp = m.value;
|
||||
} else if (m.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) {
|
||||
currentCableHum = m.value;
|
||||
}
|
||||
}
|
||||
if (currentPlenumTemp && currentPlenumHum && currentCableTemp && currentCableHum) {
|
||||
if (thisTime.diff(lastTime, "seconds") >= 2) {
|
||||
lastTime = thisTime;
|
||||
let plenumVPD = calculateVPD(currentPlenumTemp, currentPlenumHum);
|
||||
let cableVPD = calculateVPD(currentCableTemp, currentCableHum);
|
||||
let dryScore = cableVPD - plenumVPD;
|
||||
// if (perBushelCFM) {
|
||||
// dryScore = dryScore * perBushelCFM;
|
||||
// }
|
||||
let newPoint: GrainDryingPoint = {
|
||||
dryScore: dryScore,
|
||||
timestamp: moment(m.timestamp).valueOf()
|
||||
};
|
||||
data.push(newPoint);
|
||||
if (!recent || recent.timestamp < newPoint.timestamp) {
|
||||
recent = newPoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setVpdData(data);
|
||||
returnLastVPD(recent);
|
||||
};
|
||||
|
||||
//calculate the vpd using the average values of the nodes
|
||||
useEffect(() => {
|
||||
setSelectedNode(-1);
|
||||
setVpdData([]);
|
||||
if (plenum && cable) {
|
||||
let plenumUM = measurementMap.get(plenum);
|
||||
let cableUM = measurementMap.get(cable);
|
||||
if (plenumUM && cableUM) {
|
||||
setCalculating(true);
|
||||
let measurements = buildHelperArray(plenumUM, cableUM);
|
||||
buildData(measurements);
|
||||
setCalculating(false);
|
||||
}
|
||||
}
|
||||
}, [plenum, cable, measurementMap]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
//calculate the vpd using the specified node value
|
||||
const nodeVPD = (node: number) => {
|
||||
if (plenum && cable) {
|
||||
setVpdData([]);
|
||||
let plenumUM = measurementMap.get(plenum);
|
||||
let cableUM = measurementMap.get(cable);
|
||||
if (plenumUM && cableUM) {
|
||||
setCalculating(true);
|
||||
let measurements: UnitMeasurementHelper[] = [];
|
||||
if (node === -1) {
|
||||
measurements = buildHelperArray(plenumUM, cableUM);
|
||||
} else {
|
||||
measurements = buildHelperArray(plenumUM, cableUM, node);
|
||||
}
|
||||
buildData(measurements);
|
||||
setCalculating(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{plenums.length > 1 && (
|
||||
<TextField
|
||||
style={{ marginRight: 15 }}
|
||||
label="Plenum"
|
||||
variant="outlined"
|
||||
select
|
||||
id="plenumSelector"
|
||||
value={plenum}
|
||||
onChange={e => {
|
||||
setPlenum(e.target.value as string);
|
||||
}}>
|
||||
{plenums.map(k => {
|
||||
return (
|
||||
<MenuItem key={k.key()} value={k.key()}>
|
||||
{k.name()}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</TextField>
|
||||
)}
|
||||
{cableOptions.length > 1 && (
|
||||
<TextField
|
||||
style={{ marginRight: 15 }}
|
||||
label="Cable"
|
||||
variant="outlined"
|
||||
select
|
||||
id="cableSelector"
|
||||
value={cable}
|
||||
onChange={e => {
|
||||
setCable(e.target.value as string);
|
||||
}}>
|
||||
{cableOptions.map(k => {
|
||||
return (
|
||||
<MenuItem key={k.key()} value={k.key()}>
|
||||
{k.name()}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</TextField>
|
||||
)}
|
||||
<TextField
|
||||
label="Node"
|
||||
variant="outlined"
|
||||
select
|
||||
id="nodeSelector"
|
||||
value={selectedNode}
|
||||
onChange={e => {
|
||||
setSelectedNode(+e.target.value);
|
||||
nodeVPD(+e.target.value);
|
||||
}}>
|
||||
<MenuItem key={"noNode"} value={-1}>
|
||||
Average Score
|
||||
</MenuItem>
|
||||
{nodeOps.map(n => {
|
||||
return (
|
||||
<MenuItem key={n.name} value={n.number}>
|
||||
{n.name}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</TextField>
|
||||
{calculating ? (
|
||||
<Box minHeight={100} display="flex" justifyContent="center" alignItems="center">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
vpdData.length > 0 && (
|
||||
<GrainDryingChart
|
||||
data={vpdData}
|
||||
showStroke
|
||||
displayY
|
||||
newXDomain={newXDomain}
|
||||
multiGraphZoom={multiGraphZoom}
|
||||
multiGraphZoomOut={multiGraphZoomOut}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
279
src/bin/graphs/BinLevelOverTime.tsx
Normal file
279
src/bin/graphs/BinLevelOverTime.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import {
|
||||
Box,
|
||||
Card,
|
||||
CircularProgress,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
Switch,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { blue, grey, red, yellow, orange } from "@mui/material/colors";
|
||||
import BarGraph, { BarData, RefArea } from "charts/BarGraph";
|
||||
import { Bin } from "models";
|
||||
import moment, { Moment } from "moment";
|
||||
import { pond, quack } from "protobuf-ts/pond";
|
||||
import { useBinAPI } from "providers";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Legend } from "recharts";
|
||||
import { getGrainUnit } from "utils";
|
||||
|
||||
interface Props {
|
||||
binLoading: boolean;
|
||||
bin: Bin;
|
||||
colour: string;
|
||||
fertilizerBin: boolean;
|
||||
startDate: Moment;
|
||||
endDate: Moment;
|
||||
customHeight?: number | string;
|
||||
}
|
||||
|
||||
export default function BinLevelOverTime(props: Props) {
|
||||
const { bin, fertilizerBin, colour, startDate, endDate, customHeight } = props;
|
||||
const binAPI = useBinAPI();
|
||||
const [showCable, setShowCable] = useState(false);
|
||||
const [manualData, setManualData] = useState<BarData[]>([]);
|
||||
const [cableData, setCableData] = useState<BarData[]>([]);
|
||||
const [modeAreas, setModeAreas] = useState<RefArea[]>([]);
|
||||
// const defaultDateRange = GetDefaultDateRange();
|
||||
// const [startDate, setStartDate] = useState<Moment>(defaultDateRange.start);
|
||||
// const [endDate, setEndDate] = useState<Moment>(defaultDateRange.end);
|
||||
const [dataLoading, setDataLoading] = useState(false);
|
||||
const [cableDataLoading, setCableDataLoading] = useState(false);
|
||||
const [capacity, setCapacity] = useState<number | undefined>();
|
||||
|
||||
// const updateDateRange = (newStartDate: any, newEndDate: any) => {
|
||||
// let range = GetDefaultDateRange();
|
||||
// range.start = newStartDate;
|
||||
// range.end = newEndDate;
|
||||
// setStartDate(newStartDate);
|
||||
// setEndDate(newEndDate);
|
||||
// };
|
||||
|
||||
const getFill = (mode: pond.BinMode) => {
|
||||
let fill = "";
|
||||
switch (mode) {
|
||||
case pond.BinMode.BIN_MODE_STORAGE:
|
||||
fill = yellow[500];
|
||||
break;
|
||||
case pond.BinMode.BIN_MODE_COOLDOWN:
|
||||
fill = orange[500];
|
||||
break;
|
||||
case pond.BinMode.BIN_MODE_DRYING:
|
||||
fill = red[500];
|
||||
break;
|
||||
case pond.BinMode.BIN_MODE_HYDRATING:
|
||||
fill = blue[500];
|
||||
break;
|
||||
default:
|
||||
fill = grey[500];
|
||||
break;
|
||||
}
|
||||
return fill;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (dataLoading || bin.key() === "") return;
|
||||
setDataLoading(true);
|
||||
let cap = bin.settings.specs?.bushelCapacity;
|
||||
if (fertilizerBin && cap) {
|
||||
cap = cap * 35.239;
|
||||
}
|
||||
if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_WEIGHT && cap) {
|
||||
cap = cap / bin.bushelsPerTonne();
|
||||
}
|
||||
setCapacity(cap);
|
||||
binAPI
|
||||
.listHistoryBetween(bin.key(), 500, startDate.toISOString(), endDate.toISOString())
|
||||
.then(resp => {
|
||||
let data: BarData[] = [];
|
||||
let modeAreas: RefArea[] = [];
|
||||
let lastBushels = -1;
|
||||
|
||||
//values for ref areas
|
||||
let x1 = 0;
|
||||
let x2 = 0;
|
||||
let y1 = -50;
|
||||
let y2 = -200;
|
||||
|
||||
let currentMode: pond.BinMode;
|
||||
|
||||
resp.data.history.forEach(hist => {
|
||||
if (hist.settings?.inventory) {
|
||||
let settings = pond.BinSettings.fromObject(hist.settings);
|
||||
let bushels = hist.settings.inventory.grainBushels ?? 0;
|
||||
if (bushels !== lastBushels || currentMode !== settings.mode) {
|
||||
x1 = moment(hist.timestamp).valueOf();
|
||||
x2 = moment(hist.timestamp).valueOf();
|
||||
modeAreas.push({
|
||||
x1: x1,
|
||||
x2: x2,
|
||||
y1: cap ? cap * -0.1 : y1,
|
||||
y2: cap ? cap * -0.2 : y2,
|
||||
fill: getFill(settings.mode)
|
||||
});
|
||||
currentMode = settings.mode;
|
||||
let newData: BarData = {
|
||||
timestamp: moment(hist.timestamp).valueOf(),
|
||||
value: fertilizerBin
|
||||
? Math.round(bushels * 35.239)
|
||||
: getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_WEIGHT
|
||||
? Math.round((bushels / bin.bushelsPerTonne()) * 100) / 100
|
||||
: bushels
|
||||
};
|
||||
data.push(newData);
|
||||
lastBushels = bushels;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (data.length === 0) {
|
||||
let bushels = bin.settings.inventory?.grainBushels ?? 0;
|
||||
let currentTime = moment().valueOf();
|
||||
data.push({
|
||||
value: fertilizerBin
|
||||
? Math.round(bushels * 35.239)
|
||||
: getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_WEIGHT
|
||||
? Math.round((bushels / bin.bushelsPerTonne()) * 100) / 100
|
||||
: bushels,
|
||||
timestamp: currentTime
|
||||
});
|
||||
|
||||
modeAreas.push({
|
||||
x1: currentTime,
|
||||
x2: currentTime,
|
||||
y1: cap ? cap * -0.1 : y1,
|
||||
y2: cap ? cap * -0.2 : y2,
|
||||
fill: getFill(bin.settings.mode)
|
||||
});
|
||||
}
|
||||
setManualData(data);
|
||||
setModeAreas(modeAreas);
|
||||
})
|
||||
.finally(() => {
|
||||
setDataLoading(false);
|
||||
});
|
||||
}, [binAPI, bin, startDate, endDate, fertilizerBin]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (cableDataLoading || bin.key() === "") return;
|
||||
setCableDataLoading(true);
|
||||
binAPI
|
||||
.listBinMeasurements(bin.key(), startDate, endDate, 500, 0, "asc")
|
||||
.then(resp => {
|
||||
resp.data.measurements.forEach(objMeasurement => {
|
||||
//set the bushel cable measurements
|
||||
let m = pond.UnitMeasurementsForObject.fromObject(objMeasurement);
|
||||
if (m.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_CABLE) {
|
||||
let cableBarData: BarData[] = [];
|
||||
let lastBushels = -1;
|
||||
|
||||
//val will be an array of only a single number since this is not a measurement that involves nodes
|
||||
m.values.forEach((val, i) => {
|
||||
if (val.values[0] && m.timestamps[i]) {
|
||||
if (lastBushels !== val.values[0]) {
|
||||
cableBarData.push({
|
||||
value: val.values[0],
|
||||
timestamp: moment(m.timestamps[i]).valueOf()
|
||||
});
|
||||
lastBushels = val.values[0];
|
||||
}
|
||||
}
|
||||
});
|
||||
setCableData(cableBarData);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(err => {})
|
||||
.finally(() => {
|
||||
setCableDataLoading(false);
|
||||
});
|
||||
}, [binAPI, bin, startDate, endDate, fertilizerBin]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const legend = () => {
|
||||
return (
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
payload={[
|
||||
{ value: "Drying", type: "square", id: "drying", color: red[500] },
|
||||
{ value: "Cooldown", type: "square", id: "cooldown", color: orange[500] },
|
||||
{ value: "Storage", type: "square", id: "storage", color: yellow[500] },
|
||||
{ value: "Hydrating", type: "square", id: "hydrating", color: blue[500] }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const manualChart = () => {
|
||||
return (
|
||||
<BarGraph
|
||||
customHeight={customHeight}
|
||||
data={manualData}
|
||||
refAreas={modeAreas}
|
||||
barColour={colour}
|
||||
yMax={capacity}
|
||||
graphLegend={legend()}
|
||||
labelColour="white"
|
||||
labels
|
||||
useGradient
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const cableChart = () => {
|
||||
if (cableData.length === 0) {
|
||||
return (
|
||||
<Box height={customHeight} paddingTop={"25%"}>
|
||||
<Typography style={{ textAlign: "center", fontSize: 25, fontWeight: 650 }}>
|
||||
No cable estimates for selected time frame
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<BarGraph
|
||||
customHeight={customHeight}
|
||||
data={cableData}
|
||||
//refAreas={modeAreas}
|
||||
barColour={colour}
|
||||
yMax={capacity}
|
||||
yMin={0}
|
||||
graphLegend={legend()}
|
||||
labelColour="white"
|
||||
labels
|
||||
useGradient
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card raised style={{ padding: 5 }}>
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
alignContent="center"
|
||||
alignItems="center"
|
||||
justifyContent="space-between">
|
||||
<Grid>
|
||||
<Typography style={{ fontSize: 25, fontWeight: 650, marginLeft: 20 }}>
|
||||
Grain Levels Over Time
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={showCable}
|
||||
onChange={(_, checked) => {
|
||||
setShowCable(checked);
|
||||
}}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Cable Estimates"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{dataLoading ? <CircularProgress /> : showCable ? cableChart() : manualChart()}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
140
src/bin/graphs/BinWaterLevel.tsx
Normal file
140
src/bin/graphs/BinWaterLevel.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { Box, InputAdornment, TextField, Typography } from "@mui/material";
|
||||
import WaterLevelChart, { DataPoint } from "charts/WaterLevelChart";
|
||||
import { DateRange } from "common/time/DateRange";
|
||||
import { WaterContent } from "grain";
|
||||
import { Bin } from "models";
|
||||
import moment from "moment";
|
||||
import { useBinAPI, useGlobalState } from "providers";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
bin: Bin;
|
||||
range: DateRange;
|
||||
plenumKey: string;
|
||||
cableKey: string;
|
||||
pressureKey: string;
|
||||
fanKey?: string;
|
||||
returnLast: (lastWater: DataPoint | undefined) => void;
|
||||
newXDomain?: number[] | string[];
|
||||
multiGraphZoom?: (domain: number[] | string[]) => void;
|
||||
multiGraphZoomOut?: boolean;
|
||||
}
|
||||
|
||||
export default function BinWaterLevel(props: Props) {
|
||||
const {
|
||||
bin,
|
||||
range,
|
||||
plenumKey,
|
||||
cableKey,
|
||||
pressureKey,
|
||||
fanKey,
|
||||
newXDomain,
|
||||
multiGraphZoom,
|
||||
multiGraphZoomOut,
|
||||
returnLast
|
||||
} = props;
|
||||
const binAPI = useBinAPI();
|
||||
const [chartData, setChartData] = useState<DataPoint[]>([]);
|
||||
const grainMoisture = bin.settings.inventory?.initialMoisture ?? 0;
|
||||
const [targetMoisture, setTargetMoisture] = useState(
|
||||
bin.settings.inventory?.targetMoisture.toString() ?? ""
|
||||
);
|
||||
const [reference, setReference] = useState(0);
|
||||
// const [{ newStructure }] = useGlobalState();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (bin.settings.inventory) {
|
||||
let moisture = isNaN(parseFloat(targetMoisture)) ? 0 : parseFloat(targetMoisture);
|
||||
setReference(
|
||||
WaterContent(
|
||||
bin.settings.inventory.grainType,
|
||||
bin.settings.inventory.grainBushels,
|
||||
moisture
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [targetMoisture, bin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNaN(grainMoisture) && plenumKey && cableKey) {
|
||||
if (!loading) {
|
||||
setLoading(true);
|
||||
binAPI
|
||||
.listBinLiters(
|
||||
bin.key(),
|
||||
range.start.toISOString(),
|
||||
range.end.toISOString(),
|
||||
grainMoisture,
|
||||
//(note: the variable needs to be the device the component belongs to and then the component key ie. "10:LTe7Q1sT5g65")
|
||||
plenumKey,
|
||||
cableKey,
|
||||
pressureKey,
|
||||
fanKey
|
||||
)
|
||||
.then(resp => {
|
||||
let lastData: DataPoint | undefined;
|
||||
let data: DataPoint[] = [];
|
||||
if (resp.data.liters) {
|
||||
resp.data.liters.forEach(l => {
|
||||
let newPoint: DataPoint = {
|
||||
liters: l.liters ?? 0,
|
||||
timestamp: moment(l.time).valueOf()
|
||||
};
|
||||
data.push(newPoint);
|
||||
if (!lastData || lastData.timestamp < newPoint.timestamp) {
|
||||
lastData = newPoint;
|
||||
}
|
||||
});
|
||||
}
|
||||
returnLast(lastData);
|
||||
setChartData(data);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [binAPI, range, bin, grainMoisture, plenumKey, cableKey, pressureKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Box paddingY={1}>
|
||||
{chartData.length > 0 ? (
|
||||
<React.Fragment>
|
||||
<TextField
|
||||
style={{ marginBottom: 10 }}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={targetMoisture}
|
||||
error={isNaN(+targetMoisture) && targetMoisture !== ""}
|
||||
onChange={e => setTargetMoisture(e.target.value)}
|
||||
label="Target Moisture"
|
||||
helperText={isNaN(+targetMoisture) && targetMoisture !== "" ? "Invalid number" : ""}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<InputAdornment position="end">% WB</InputAdornment>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<WaterLevelChart
|
||||
data={chartData}
|
||||
reference={reference}
|
||||
newXDomain={newXDomain}
|
||||
multiGraphZoom={multiGraphZoom}
|
||||
multiGraphZoomOut={multiGraphZoomOut}
|
||||
/>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<Box display="flex" justifyContent="center" alignItems="center">
|
||||
{isNaN(grainMoisture) && (
|
||||
<Typography variant="subtitle1" color="textSecondary" align="center">
|
||||
Set initial moisture in the bin settings to calculate estimated water content
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue