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
43
package-lock.json
generated
43
package-lock.json
generated
|
|
@ -35,6 +35,7 @@
|
||||||
"react-color": "^2.19.3",
|
"react-color": "^2.19.3",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-error-boundary": "^5.0.0",
|
"react-error-boundary": "^5.0.0",
|
||||||
|
"react-full-screen": "^1.1.1",
|
||||||
"react-horizontal-scrolling-menu": "^7.1.1",
|
"react-horizontal-scrolling-menu": "^7.1.1",
|
||||||
"react-image": "^4.1.0",
|
"react-image": "^4.1.0",
|
||||||
"react-infinite-scroller": "^1.2.6",
|
"react-infinite-scroller": "^1.2.6",
|
||||||
|
|
@ -411,14 +412,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helpers": {
|
"node_modules/@babel/helpers": {
|
||||||
"version": "7.26.9",
|
"version": "7.26.10",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
|
||||||
"integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==",
|
"integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/template": "^7.26.9",
|
"@babel/template": "^7.26.9",
|
||||||
"@babel/types": "^7.26.9"
|
"@babel/types": "^7.26.10"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
|
|
@ -1564,9 +1565,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
"version": "7.26.0",
|
"version": "7.26.10",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
|
||||||
"integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
|
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"regenerator-runtime": "^0.14.0"
|
"regenerator-runtime": "^0.14.0"
|
||||||
},
|
},
|
||||||
|
|
@ -1615,9 +1617,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.26.9",
|
"version": "7.26.10",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz",
|
||||||
"integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==",
|
"integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.25.9",
|
"@babel/helper-string-parser": "^7.25.9",
|
||||||
|
|
@ -5661,6 +5663,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/fscreen": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fscreen/-/fscreen-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-hlq4+BU0hlPmwsFjwGGzZ+OZ9N/wq9Ljg/sq3pX+2CD7hrJsX9tJgWWK/wiNTFM212CLHWhicOoqwXyZGGetJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
|
@ -7495,6 +7503,21 @@
|
||||||
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
|
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-full-screen": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-full-screen/-/react-full-screen-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-xoEgkoTiN0dw9cjYYGViiMCBYbkS97BYb4bHPhQVWXj1UnOs8PZ1rPzpX+2HMhuvQV1jA5AF9GaRbO3fA5aZtg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fscreen": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-horizontal-scrolling-menu": {
|
"node_modules/react-horizontal-scrolling-menu": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-horizontal-scrolling-menu/-/react-horizontal-scrolling-menu-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-horizontal-scrolling-menu/-/react-horizontal-scrolling-menu-7.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
"react-color": "^2.19.3",
|
"react-color": "^2.19.3",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-error-boundary": "^5.0.0",
|
"react-error-boundary": "^5.0.0",
|
||||||
|
"react-full-screen": "^1.1.1",
|
||||||
"react-horizontal-scrolling-menu": "^7.1.1",
|
"react-horizontal-scrolling-menu": "^7.1.1",
|
||||||
"react-image": "^4.1.0",
|
"react-image": "^4.1.0",
|
||||||
"react-infinite-scroller": "^1.2.6",
|
"react-infinite-scroller": "^1.2.6",
|
||||||
|
|
|
||||||
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,
|
borderRadius: 5,
|
||||||
marginRight: -30, //negative margin to extend the box behind the bin svg
|
marginRight: -30, //negative margin to extend the box behind the bin svg
|
||||||
paddingRight: 30, //padding to offset the text and ignore the extension
|
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 (
|
return (
|
||||||
<Card>
|
<Card style={{ cursor: "pointer" }}>
|
||||||
{user.hasFeature("admin") && (
|
{user.hasFeature("admin") && (
|
||||||
<IconButton
|
<IconButton
|
||||||
style={{
|
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}
|
key={i}
|
||||||
className={classes.gridListTile}
|
className={classes.gridListTile}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
//!duplicate && goToBin(i)
|
!duplicate && goToBin(i)
|
||||||
}}>
|
}}>
|
||||||
<BinCardV2
|
<BinCardV2
|
||||||
bin={b}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/charts/BarGraph.tsx
Normal file
152
src/charts/BarGraph.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
// import { useTheme } from "@mui/material";
|
||||||
|
import { useTheme } from "@mui/material";
|
||||||
|
import moment from "moment";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
LabelList,
|
||||||
|
ReferenceArea,
|
||||||
|
ResponsiveContainer,
|
||||||
|
XAxis,
|
||||||
|
YAxis
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
export interface BarData {
|
||||||
|
timestamp: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefArea {
|
||||||
|
x1: number;
|
||||||
|
x2: number;
|
||||||
|
y1: number;
|
||||||
|
y2: number;
|
||||||
|
fill: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AxisLabel {
|
||||||
|
value: string;
|
||||||
|
//must be a valid label position, for a list of possible position see Recharts documentation
|
||||||
|
position: string;
|
||||||
|
angle?: number;
|
||||||
|
offset?: number;
|
||||||
|
fill?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: BarData[];
|
||||||
|
barColour: string;
|
||||||
|
refAreas?: RefArea[];
|
||||||
|
yMin?: number;
|
||||||
|
yMax?: number;
|
||||||
|
graphLegend?: JSX.Element;
|
||||||
|
labels?: boolean;
|
||||||
|
labelSize?: number;
|
||||||
|
useGradient?: boolean;
|
||||||
|
labelColour?: string;
|
||||||
|
customHeight?: number | string;
|
||||||
|
xAxisType?: "number" | "category";
|
||||||
|
barGap?: number | string;
|
||||||
|
barSize?: number;
|
||||||
|
yLabel?: AxisLabel;
|
||||||
|
xLabel?: AxisLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BarGraph(props: Props) {
|
||||||
|
const now = moment();
|
||||||
|
const theme = useTheme();
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
barColour,
|
||||||
|
refAreas,
|
||||||
|
yMin,
|
||||||
|
yMax,
|
||||||
|
graphLegend,
|
||||||
|
labels,
|
||||||
|
labelSize,
|
||||||
|
useGradient,
|
||||||
|
labelColour,
|
||||||
|
customHeight,
|
||||||
|
xAxisType,
|
||||||
|
barGap,
|
||||||
|
barSize,
|
||||||
|
yLabel,
|
||||||
|
xLabel
|
||||||
|
} = props;
|
||||||
|
const [referenceAreas, setReferenceAreas] = useState<JSX.Element[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ras: JSX.Element[] = [];
|
||||||
|
if (refAreas && refAreas.length > 0) {
|
||||||
|
refAreas.forEach((ra, i) => {
|
||||||
|
ras.push(
|
||||||
|
<ReferenceArea
|
||||||
|
key={"area#" + i}
|
||||||
|
x1={ra.x1}
|
||||||
|
x2={ra.x2}
|
||||||
|
y1={ra.y1}
|
||||||
|
y2={ra.y2}
|
||||||
|
fill={ra.fill}
|
||||||
|
fillOpacity={1}
|
||||||
|
ifOverflow="extendDomain"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setReferenceAreas(ras);
|
||||||
|
}, [refAreas]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width={"100%"} height={customHeight ?? 350}>
|
||||||
|
<BarChart data={data} barGap={barGap}>
|
||||||
|
{graphLegend}
|
||||||
|
<YAxis
|
||||||
|
domain={[yMin ?? "dataMin", yMax ?? "auto"]}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
scale={"linear"}
|
||||||
|
label={yLabel}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey={"timestamp"}
|
||||||
|
domain={["dataMin", "dataMax"]}
|
||||||
|
name="Time"
|
||||||
|
scale={xAxisType === "number" ? "time" : "auto"}
|
||||||
|
type={xAxisType}
|
||||||
|
label={xLabel}
|
||||||
|
tickFormatter={tick => {
|
||||||
|
if (!isNaN(tick)) {
|
||||||
|
let t = moment(tick);
|
||||||
|
return now.isSame(t, "day") ? t.format("LT") : t.format("MMM DD");
|
||||||
|
} else {
|
||||||
|
return tick;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tick={{ fill: theme.palette.text.primary }}
|
||||||
|
stroke={theme.palette.divider}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradientColour" x1="0" y1="1" x2="0" y2="0">
|
||||||
|
<stop offset="0%" stopColor={barColour} stopOpacity="0%" />
|
||||||
|
<stop offset="100%" stopColor={barColour} stopOpacity="90%" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<Bar
|
||||||
|
dataKey={"value"}
|
||||||
|
fill={useGradient ? "url(#gradientColour)" : barColour}
|
||||||
|
barSize={barSize}>
|
||||||
|
{labels && (
|
||||||
|
<LabelList
|
||||||
|
fill={labelColour}
|
||||||
|
dataKey="value"
|
||||||
|
position="center"
|
||||||
|
style={{ fontWeight: 750, fontSize: labelSize }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Bar>
|
||||||
|
{referenceAreas}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
src/charts/TrendingChart.tsx
Normal file
176
src/charts/TrendingChart.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { useTheme } from "@mui/material";
|
||||||
|
import { teal, orange } from "@mui/material/colors";
|
||||||
|
import moment from "moment";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
ComposedChart,
|
||||||
|
Legend,
|
||||||
|
Line,
|
||||||
|
ReferenceArea,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProps,
|
||||||
|
XAxis,
|
||||||
|
YAxis
|
||||||
|
} from "recharts";
|
||||||
|
import MaterialChartTooltip from "./MaterialChartTooltip";
|
||||||
|
|
||||||
|
export interface DataPoint {
|
||||||
|
timestamp: number;
|
||||||
|
moisture: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendPoint {
|
||||||
|
timestamp: number;
|
||||||
|
trend: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: DataPoint[];
|
||||||
|
trend: TrendPoint[];
|
||||||
|
newXDomain?: number[] | string[];
|
||||||
|
multiGraphZoom?: (domain: number[] | string[]) => void;
|
||||||
|
multiGraphZoomOut?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposedData {
|
||||||
|
timestamp: number;
|
||||||
|
trend?: number;
|
||||||
|
moisture?: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrendingChart(props: Props) {
|
||||||
|
const { data, trend, newXDomain, multiGraphZoom } = props;
|
||||||
|
const theme = useTheme();
|
||||||
|
const now = moment();
|
||||||
|
const [composedData, setComposedData] = useState<ComposedData[]>([]);
|
||||||
|
const [xDomain, setXDomain] = useState<string[] | number[]>(["dataMin", "dataMax"]);
|
||||||
|
const [refLeft, setRefLeft] = useState<number | undefined>();
|
||||||
|
const [refRight, setRefRight] = useState<number | undefined>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (newXDomain) {
|
||||||
|
setXDomain(newXDomain);
|
||||||
|
}
|
||||||
|
}, [newXDomain]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let newData: ComposedData[] = [];
|
||||||
|
data.forEach(d => {
|
||||||
|
newData.push({
|
||||||
|
timestamp: d.timestamp,
|
||||||
|
moisture: d.moisture
|
||||||
|
});
|
||||||
|
});
|
||||||
|
trend.forEach(t => {
|
||||||
|
newData.push({
|
||||||
|
timestamp: t.timestamp,
|
||||||
|
trend: Math.round(t.trend * 100) / 100
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setComposedData([...newData]);
|
||||||
|
}, [data, trend]);
|
||||||
|
|
||||||
|
const zoom = () => {
|
||||||
|
let newDomain: number[] | string[] = ["dataMin", "dataMax"];
|
||||||
|
if (refLeft && refRight && refLeft !== refRight) {
|
||||||
|
refLeft < refRight ? (newDomain = [refLeft, refRight]) : (newDomain = [refRight, refLeft]);
|
||||||
|
setRefLeft(undefined);
|
||||||
|
setRefRight(undefined);
|
||||||
|
if (multiGraphZoom) {
|
||||||
|
multiGraphZoom(newDomain);
|
||||||
|
} else {
|
||||||
|
setXDomain(newDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//just commenting this out as this charts zoom will likely never be controlled from inside
|
||||||
|
// const zoomOut = () => {
|
||||||
|
// setXDomain(["dataMin", "dataMax"]);
|
||||||
|
// };
|
||||||
|
|
||||||
|
if (composedData.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width={"100%"} height={270}>
|
||||||
|
<ComposedChart
|
||||||
|
data={composedData}
|
||||||
|
onMouseDown={(e: any) => {
|
||||||
|
if (e) {
|
||||||
|
setRefLeft(e.activeLabel);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseMove={(e: any) => {
|
||||||
|
if (e) {
|
||||||
|
setRefRight(e.activeLabel);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseUp={() => {
|
||||||
|
setRefLeft(undefined);
|
||||||
|
setRefRight(undefined);
|
||||||
|
zoom();
|
||||||
|
}}>
|
||||||
|
<Legend
|
||||||
|
verticalAlign="top"
|
||||||
|
payload={[
|
||||||
|
{ value: "Grain Moisture", type: "square", id: "gMoist", color: teal[500] },
|
||||||
|
{ value: "Plenum Moisture", type: "square", id: "pMoist", color: orange[500] }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
animationEasing="ease-out"
|
||||||
|
cursor={{ fill: theme.palette.text.primary, opacity: "0.15" }}
|
||||||
|
labelFormatter={timestamp => moment(timestamp).format("lll")}
|
||||||
|
content={(props: TooltipProps<any, any>) => (
|
||||||
|
<MaterialChartTooltip {...props} valueFormatter={val => `${val}`} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
allowDataOverflow
|
||||||
|
dataKey="timestamp"
|
||||||
|
domain={xDomain}
|
||||||
|
name="Time"
|
||||||
|
tickFormatter={timestamp => {
|
||||||
|
let t = moment(timestamp);
|
||||||
|
return now.isSame(t, "day") ? t.format("LT") : t.format("MMM DD");
|
||||||
|
}}
|
||||||
|
scale="time"
|
||||||
|
type="number"
|
||||||
|
tick={{ fill: theme.palette.text.primary }}
|
||||||
|
stroke={theme.palette.divider}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={["auto", "auto"]}
|
||||||
|
tickFormatter={moisture => {
|
||||||
|
let val = moisture + "%";
|
||||||
|
return val;
|
||||||
|
}}
|
||||||
|
// label={{
|
||||||
|
// value: "Moisture (%)",
|
||||||
|
// angle: -90,
|
||||||
|
// position: "insideLeft",
|
||||||
|
// fill: theme.palette.text.primary
|
||||||
|
// }}
|
||||||
|
tick={{ fill: theme.palette.text.primary }}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey={"moisture"}
|
||||||
|
name="Moisture"
|
||||||
|
stroke={"transparent"}
|
||||||
|
fill={teal[500]}
|
||||||
|
/>
|
||||||
|
{refLeft && refRight ? (
|
||||||
|
<ReferenceArea x1={refLeft} x2={refRight} strokeOpacity={0.3} />
|
||||||
|
) : null}
|
||||||
|
<Line dataKey="trend" stroke={orange[500]} strokeWidth={2} dot={false} />
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
src/charts/WaterLevelChart.tsx
Normal file
136
src/charts/WaterLevelChart.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { useTheme } from "@mui/material";
|
||||||
|
import { blue } from "@mui/material/colors";
|
||||||
|
import moment from "moment";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
ReferenceArea,
|
||||||
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProps,
|
||||||
|
XAxis,
|
||||||
|
YAxis
|
||||||
|
} from "recharts";
|
||||||
|
import MaterialChartTooltip from "./MaterialChartTooltip";
|
||||||
|
|
||||||
|
export interface DataPoint {
|
||||||
|
timestamp: number;
|
||||||
|
liters: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: DataPoint[];
|
||||||
|
reference: number;
|
||||||
|
newXDomain?: number[] | string[];
|
||||||
|
multiGraphZoom?: (domain: number[] | string[]) => void;
|
||||||
|
multiGraphZoomOut?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WaterLevelChart(props: Props) {
|
||||||
|
const { data, reference, newXDomain, multiGraphZoom } = props;
|
||||||
|
const theme = useTheme();
|
||||||
|
const now = moment();
|
||||||
|
const [xDomain, setXDomain] = useState<string[] | number[]>(["dataMin", "dataMax"]);
|
||||||
|
const [refLeft, setRefLeft] = useState<number | undefined>();
|
||||||
|
const [refRight, setRefRight] = useState<number | undefined>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (newXDomain) {
|
||||||
|
setXDomain(newXDomain);
|
||||||
|
}
|
||||||
|
}, [newXDomain]);
|
||||||
|
|
||||||
|
const zoom = () => {
|
||||||
|
let newDomain: number[] | string[] = ["dataMin", "dataMax"];
|
||||||
|
if (refLeft && refRight && refLeft !== refRight) {
|
||||||
|
refLeft < refRight ? (newDomain = [refLeft, refRight]) : (newDomain = [refRight, refLeft]);
|
||||||
|
setRefLeft(undefined);
|
||||||
|
setRefRight(undefined);
|
||||||
|
if (multiGraphZoom) {
|
||||||
|
multiGraphZoom(newDomain);
|
||||||
|
} else {
|
||||||
|
setXDomain(newDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//just commenting this out as this charts zoom will likely never be controlled from inside
|
||||||
|
// const zoomOut = () => {
|
||||||
|
// setXDomain(["dataMin", "dataMax"]);
|
||||||
|
// };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<ResponsiveContainer width={"100%"} height={270}>
|
||||||
|
<AreaChart
|
||||||
|
data={data}
|
||||||
|
onMouseDown={(e: any) => {
|
||||||
|
if (e) {
|
||||||
|
setRefLeft(e.activeLabel);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseMove={(e: any) => {
|
||||||
|
if (e) {
|
||||||
|
setRefRight(e.activeLabel);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseUp={() => {
|
||||||
|
setRefLeft(undefined);
|
||||||
|
setRefRight(undefined);
|
||||||
|
zoom();
|
||||||
|
}}>
|
||||||
|
<Tooltip
|
||||||
|
animationEasing="ease-out"
|
||||||
|
cursor={{ fill: theme.palette.text.primary, opacity: "0.15" }}
|
||||||
|
labelFormatter={timestamp => moment(timestamp).format("lll")}
|
||||||
|
content={(props: TooltipProps<any, any>) => (
|
||||||
|
<MaterialChartTooltip {...props} valueFormatter={liters => `${liters}`} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
allowDataOverflow
|
||||||
|
dataKey="timestamp"
|
||||||
|
domain={xDomain}
|
||||||
|
name="Time"
|
||||||
|
tickFormatter={timestamp => {
|
||||||
|
let t = moment(timestamp);
|
||||||
|
return now.isSame(t, "day") ? t.format("LT") : t.format("MMM DD");
|
||||||
|
}}
|
||||||
|
scale="time"
|
||||||
|
type="number"
|
||||||
|
tick={{ fill: theme.palette.text.primary }}
|
||||||
|
stroke={theme.palette.divider}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={liters => {
|
||||||
|
let val = liters / 1000 + "kL";
|
||||||
|
return val;
|
||||||
|
}}
|
||||||
|
type="number"
|
||||||
|
// label={{
|
||||||
|
// value: "Water Content",
|
||||||
|
// angle: -90,
|
||||||
|
// position: "insideLeft",
|
||||||
|
// fill: theme.palette.text.primary
|
||||||
|
// }}
|
||||||
|
domain={["auto", "auto"]}
|
||||||
|
tick={{ fill: theme.palette.text.primary }}
|
||||||
|
/>
|
||||||
|
<Area dataKey={"liters"} fill={blue[500]} />
|
||||||
|
<ReferenceLine
|
||||||
|
ifOverflow="extendDomain"
|
||||||
|
y={reference}
|
||||||
|
stroke="red"
|
||||||
|
strokeDasharray={"5 2"}
|
||||||
|
/>
|
||||||
|
{refLeft && refRight ? (
|
||||||
|
<ReferenceArea x1={refLeft} x2={refRight} strokeOpacity={0.3} />
|
||||||
|
) : null}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
src/common/Tour.tsx
Normal file
156
src/common/Tour.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { useTheme, makeStyles, createStyles, Theme } from "@material-ui/core/styles";
|
||||||
|
import React from "react";
|
||||||
|
import Joyride, { Step, CallBackProps, STATUS, TooltipRenderProps, Locale } from "react-joyride";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardActions,
|
||||||
|
Grid,
|
||||||
|
MobileStepper,
|
||||||
|
Tooltip,
|
||||||
|
IconButton
|
||||||
|
} from "@material-ui/core";
|
||||||
|
import { KeyboardArrowLeft, KeyboardArrowRight, Close as CloseIcon } from "@material-ui/icons";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { grey } from "@material-ui/core/colors";
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
stepContainer: {
|
||||||
|
width: "60vw",
|
||||||
|
[theme.breakpoints.only("sm")]: {
|
||||||
|
width: "50vw"
|
||||||
|
},
|
||||||
|
[theme.breakpoints.only("md")]: {
|
||||||
|
width: "40vw"
|
||||||
|
},
|
||||||
|
[theme.breakpoints.only("lg")]: {
|
||||||
|
width: "30vw"
|
||||||
|
},
|
||||||
|
[theme.breakpoints.only("xl")]: {
|
||||||
|
width: "20vw"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
[theme.breakpoints.up("sm")]: {
|
||||||
|
padding: theme.spacing(2)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stepper: {
|
||||||
|
width: "100%"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const StepContainer = (props: TooltipRenderProps, numSteps: number, skip: () => void) => {
|
||||||
|
const {
|
||||||
|
continuous,
|
||||||
|
index,
|
||||||
|
step,
|
||||||
|
backProps,
|
||||||
|
isLastStep,
|
||||||
|
closeProps,
|
||||||
|
primaryProps,
|
||||||
|
tooltipProps
|
||||||
|
} = props;
|
||||||
|
const theme = useTheme();
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card {...tooltipProps} className={classes.stepContainer}>
|
||||||
|
<CardHeader
|
||||||
|
title={step.title}
|
||||||
|
action={
|
||||||
|
<Tooltip title="Skip Tutorial">
|
||||||
|
<IconButton aria-label="Skip Tutorial" onClick={skip}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
className={classes.spacing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{step.content && <CardContent className={classes.spacing}>{step.content}</CardContent>}
|
||||||
|
|
||||||
|
{!step.hideFooter &&
|
||||||
|
(continuous ? (
|
||||||
|
<MobileStepper
|
||||||
|
className={classNames(classes.stepper, classes.spacing)}
|
||||||
|
variant="text"
|
||||||
|
steps={numSteps}
|
||||||
|
position="static"
|
||||||
|
activeStep={index}
|
||||||
|
backButton={
|
||||||
|
<Button size="small" {...backProps} disabled={index === 0}>
|
||||||
|
{theme.direction === "rtl" ? <KeyboardArrowRight /> : <KeyboardArrowLeft />}
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
nextButton={
|
||||||
|
<Button size="small" {...primaryProps}>
|
||||||
|
{isLastStep ? "Finish" : "Next"}
|
||||||
|
{theme.direction === "rtl" ? <KeyboardArrowLeft /> : <KeyboardArrowRight />}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
!step.hideCloseButton && (
|
||||||
|
<CardActions>
|
||||||
|
<Grid container justify="flex-end">
|
||||||
|
<Button size="small" {...closeProps}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</CardActions>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
run: boolean;
|
||||||
|
steps: Step[];
|
||||||
|
endTourCallback: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Tour(props: Props) {
|
||||||
|
const { run, steps, endTourCallback } = props;
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const handleTourCallback = (data: CallBackProps) => {
|
||||||
|
const { status } = data;
|
||||||
|
const finishedStatuses: string[] = [STATUS.FINISHED, STATUS.SKIPPED];
|
||||||
|
|
||||||
|
if (finishedStatuses.includes(status)) {
|
||||||
|
endTourCallback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Joyride
|
||||||
|
run={run}
|
||||||
|
steps={steps}
|
||||||
|
continuous
|
||||||
|
scrollToFirstStep={true}
|
||||||
|
showProgress={true}
|
||||||
|
showSkipButton={true}
|
||||||
|
callback={handleTourCallback}
|
||||||
|
tooltipComponent={props => StepContainer(props, steps.length, endTourCallback)}
|
||||||
|
locale={{ back: "Back", close: "Close", last: "Done", next: "Next", skip: "Skip" } as Locale}
|
||||||
|
styles={{
|
||||||
|
options: {
|
||||||
|
arrowColor: theme.palette.type === "light" ? grey["100"] : grey["800"],
|
||||||
|
backgroundColor: theme.palette.background.default,
|
||||||
|
overlayColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
primaryColor: theme.palette.primary.main,
|
||||||
|
textColor: theme.palette.text.primary,
|
||||||
|
zIndex: theme.zIndex.modal
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
1013
src/device/DevicePresetsFromPicker.tsx
Normal file
1013
src/device/DevicePresetsFromPicker.tsx
Normal file
File diff suppressed because it is too large
Load diff
517
src/device/presets/devicePresetCard.tsx
Normal file
517
src/device/presets/devicePresetCard.tsx
Normal file
|
|
@ -0,0 +1,517 @@
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Grid,
|
||||||
|
InputAdornment,
|
||||||
|
MenuItem,
|
||||||
|
Slider,
|
||||||
|
TextField,
|
||||||
|
Typography
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useMobile } from "hooks";
|
||||||
|
import { cloneDeep } from "lodash";
|
||||||
|
import { DevicePreset } from "models/DevicePreset";
|
||||||
|
import { ObjectTypeString } from "objects/ObjectDescriber";
|
||||||
|
import { pond } from "protobuf-ts/pond";
|
||||||
|
import { useSnackbar } from "providers";
|
||||||
|
// import { useDevicePresetAPI } from "providers/pond/devicePresetAPI";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { getTemperatureUnit } from "utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
preset: DevicePreset;
|
||||||
|
existing?: boolean;
|
||||||
|
objectKey?: string;
|
||||||
|
objectType?: pond.ObjectType;
|
||||||
|
addCallback?: (preset: DevicePreset, tempKey: string) => void;
|
||||||
|
removeCallback?: (preset: DevicePreset) => void;
|
||||||
|
updateCallback?: (oldPreset: DevicePreset, newPreset: DevicePreset) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevicePresetCard(props: Props) {
|
||||||
|
const {
|
||||||
|
preset,
|
||||||
|
existing,
|
||||||
|
objectKey,
|
||||||
|
objectType,
|
||||||
|
addCallback,
|
||||||
|
removeCallback,
|
||||||
|
updateCallback
|
||||||
|
} = props;
|
||||||
|
// const devicePresetAPI = useDevicePresetAPI();
|
||||||
|
const [presetName, setPresetName] = useState("");
|
||||||
|
const [presetType, setPresetType] = useState<pond.PresetType>(1);
|
||||||
|
const [controllerType, setControllerType] = useState<pond.ControllerType>(1);
|
||||||
|
const [openDeleteConfirmation, setOpenDeleteConfirmation] = useState(false);
|
||||||
|
const { openSnack } = useSnackbar();
|
||||||
|
const [tempString, setTempString] = useState("0");
|
||||||
|
const [tempC, setTempC] = useState(0);
|
||||||
|
const [humString, setHumString] = useState("0");
|
||||||
|
const [hum, setHum] = useState(0);
|
||||||
|
const isMobile = useMobile();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPresetName(preset.name);
|
||||||
|
setPresetType(preset.settings.type);
|
||||||
|
setControllerType(preset.settings.controller);
|
||||||
|
setTempC(preset.settings.temperature);
|
||||||
|
setHum(preset.settings.humidity);
|
||||||
|
setHumString(preset.settings.humidity.toString());
|
||||||
|
let displayTemp = preset.settings.temperature.toString();
|
||||||
|
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
|
||||||
|
displayTemp = ((preset.settings.temperature * 9) / 5 + 32).toFixed();
|
||||||
|
}
|
||||||
|
setTempString(displayTemp);
|
||||||
|
}, [preset]);
|
||||||
|
|
||||||
|
const saveNewPreset = () => {
|
||||||
|
let typestring;
|
||||||
|
if (objectType) {
|
||||||
|
typestring = ObjectTypeString(objectType);
|
||||||
|
}
|
||||||
|
let p = preset;
|
||||||
|
p.name = presetName;
|
||||||
|
p.settings.type = presetType;
|
||||||
|
p.settings.controller = controllerType;
|
||||||
|
p.settings.temperature = tempC;
|
||||||
|
p.settings.humidity = hum;
|
||||||
|
|
||||||
|
// devicePresetAPI
|
||||||
|
// .addDevicePreset(p.name, p.settings, objectKey, typestring)
|
||||||
|
// .then(resp => {
|
||||||
|
// //get the temp key from the preset
|
||||||
|
// let temp = p.key;
|
||||||
|
// //update the key in the preset with the key that came back from the add
|
||||||
|
// p.key = resp.data.key;
|
||||||
|
// //use the callback function passing in the preset and the temp key
|
||||||
|
// if (addCallback) {
|
||||||
|
// addCallback(p, temp);
|
||||||
|
// }
|
||||||
|
// openSnack("New preset saved");
|
||||||
|
// })
|
||||||
|
// .catch(err => {
|
||||||
|
// openSnack("Failed to save new preset");
|
||||||
|
// });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePreset = () => {
|
||||||
|
let newPreset = cloneDeep(preset);
|
||||||
|
let oldPreset = cloneDeep(preset);
|
||||||
|
newPreset.name = presetName;
|
||||||
|
newPreset.settings.type = presetType;
|
||||||
|
newPreset.settings.controller = controllerType;
|
||||||
|
newPreset.settings.temperature = tempC;
|
||||||
|
newPreset.settings.humidity = hum;
|
||||||
|
|
||||||
|
// devicePresetAPI
|
||||||
|
// .updateDevicePreset(newPreset.key, newPreset.name, newPreset.settings)
|
||||||
|
// .then(resp => {
|
||||||
|
// if (updateCallback) {
|
||||||
|
// updateCallback(oldPreset, newPreset);
|
||||||
|
// }
|
||||||
|
// openSnack("Preset Updated");
|
||||||
|
// })
|
||||||
|
// .catch(err => {
|
||||||
|
// openSnack("There was a problem updating the preset");
|
||||||
|
// });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePreset = () => {
|
||||||
|
// devicePresetAPI
|
||||||
|
// .removeDevicePreset(preset.key)
|
||||||
|
// .then(resp => {
|
||||||
|
// openSnack("Preset has been deleted");
|
||||||
|
// setOpenDeleteConfirmation(false);
|
||||||
|
// if (removeCallback) {
|
||||||
|
// removeCallback(preset);
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// .catch(err => {
|
||||||
|
// openSnack("There was a problem deleting this preset");
|
||||||
|
// });
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteConfirmation = () => {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={openDeleteConfirmation}
|
||||||
|
onClose={() => {
|
||||||
|
setOpenDeleteConfirmation(false);
|
||||||
|
}}>
|
||||||
|
<DialogTitle>Delete Preset</DialogTitle>
|
||||||
|
<DialogContent>This action will delete the selected preset</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setOpenDeleteConfirmation(false);
|
||||||
|
}}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => {
|
||||||
|
removePreset();
|
||||||
|
}}>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const presetComparitors = () => {
|
||||||
|
if (controllerType === pond.ControllerType.CONTROLLER_TYPE_HEATER) {
|
||||||
|
switch (presetType) {
|
||||||
|
case pond.PresetType.PRESET_TYPE_DRYING:
|
||||||
|
return { temp: "below", hum: "above" };
|
||||||
|
case pond.PresetType.PRESET_TYPE_COOLING:
|
||||||
|
return { temp: "below", hum: "above" };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
//the controller type is a fan
|
||||||
|
switch (presetType) {
|
||||||
|
case pond.PresetType.PRESET_TYPE_DRYING:
|
||||||
|
return { temp: "above", hum: "below" };
|
||||||
|
case pond.PresetType.PRESET_TYPE_COOLING:
|
||||||
|
return { temp: "below", hum: "below" };
|
||||||
|
case pond.PresetType.PRESET_TYPE_HYDRATING:
|
||||||
|
return { temp: "below", hum: "above" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//replace having a default case with final return
|
||||||
|
return { temp: "", hum: "" };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card raised style={{ padding: 10, width: "100%" }}>
|
||||||
|
{deleteConfirmation()}
|
||||||
|
<Grid container direction="row" alignContent="center" alignItems="center">
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField
|
||||||
|
variant="outlined"
|
||||||
|
margin="normal"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
value={presetName}
|
||||||
|
onChange={e => {
|
||||||
|
setPresetName(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
{presetType !== pond.PresetType.PRESET_TYPE_UNKNOWN &&
|
||||||
|
controllerType !== pond.ControllerType.CONTROLLER_TYPE_UNKNOWN && (
|
||||||
|
<Box paddingLeft={2}>
|
||||||
|
<Typography>
|
||||||
|
Toggle{" "}
|
||||||
|
{controllerType === pond.ControllerType.CONTROLLER_TYPE_HEATER ? "heater" : "fan"}{" "}
|
||||||
|
on when temperature {presetComparitors().temp} and humidity{" "}
|
||||||
|
{presetComparitors().hum} set points
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
{/* type */}
|
||||||
|
<TextField
|
||||||
|
style={{ width: "100%", marginRight: 5 }}
|
||||||
|
margin="normal"
|
||||||
|
select
|
||||||
|
label="Type"
|
||||||
|
variant="outlined"
|
||||||
|
value={presetType}
|
||||||
|
onChange={e => {
|
||||||
|
setPresetType(+e.target.value as pond.PresetType);
|
||||||
|
}}>
|
||||||
|
<MenuItem
|
||||||
|
key={pond.PresetType.PRESET_TYPE_UNKNOWN}
|
||||||
|
value={pond.PresetType.PRESET_TYPE_UNKNOWN}>
|
||||||
|
Mode For Preset
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
key={pond.PresetType.PRESET_TYPE_DRYING}
|
||||||
|
value={pond.PresetType.PRESET_TYPE_DRYING}>
|
||||||
|
Drying
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
key={pond.PresetType.PRESET_TYPE_COOLING}
|
||||||
|
value={pond.PresetType.PRESET_TYPE_COOLING}>
|
||||||
|
Cooling
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
key={pond.PresetType.PRESET_TYPE_HYDRATING}
|
||||||
|
value={pond.PresetType.PRESET_TYPE_HYDRATING}>
|
||||||
|
Hydrating
|
||||||
|
</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
{/* controller type */}
|
||||||
|
<TextField
|
||||||
|
style={{ width: "100%", marginLeft: 5 }}
|
||||||
|
margin="normal"
|
||||||
|
select
|
||||||
|
label="Controller Type"
|
||||||
|
variant="outlined"
|
||||||
|
value={controllerType}
|
||||||
|
onChange={e => {
|
||||||
|
setControllerType(+e.target.value as pond.ControllerType);
|
||||||
|
}}>
|
||||||
|
<MenuItem
|
||||||
|
key={pond.ControllerType.CONTROLLER_TYPE_UNKNOWN}
|
||||||
|
value={pond.ControllerType.CONTROLLER_TYPE_UNKNOWN}>
|
||||||
|
Controller For Preset
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
key={pond.ControllerType.CONTROLLER_TYPE_HEATER}
|
||||||
|
value={pond.ControllerType.CONTROLLER_TYPE_HEATER}>
|
||||||
|
Heater
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
key={pond.ControllerType.CONTROLLER_TYPE_FAN}
|
||||||
|
value={pond.ControllerType.CONTROLLER_TYPE_FAN}>
|
||||||
|
Fan
|
||||||
|
</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
{/* temp slider */}
|
||||||
|
{isMobile ? (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
alignContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between">
|
||||||
|
<Typography>Temperature</Typography>
|
||||||
|
<TextField
|
||||||
|
style={{ marginLeft: 5 }}
|
||||||
|
margin="dense"
|
||||||
|
variant="outlined"
|
||||||
|
type="number"
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
{getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
|
||||||
|
? "°F"
|
||||||
|
: "°C"}
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
error={isNaN(+tempString)}
|
||||||
|
value={tempString}
|
||||||
|
onChange={e => {
|
||||||
|
if (!isNaN(+e.target.value)) {
|
||||||
|
let temp = +e.target.value;
|
||||||
|
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
|
||||||
|
temp = Math.round((((temp - 32) * 5) / 9) * 10) / 10;
|
||||||
|
}
|
||||||
|
setTempC(temp);
|
||||||
|
}
|
||||||
|
setTempString(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box paddingLeft={2} paddingRight={2}>
|
||||||
|
<Slider
|
||||||
|
//orientation="vertical"
|
||||||
|
value={tempC}
|
||||||
|
min={-40}
|
||||||
|
max={40}
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
valueLabelFormat={value => {
|
||||||
|
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
|
||||||
|
return ((value * 9) / 5 + 32).toFixed() + "°F";
|
||||||
|
}
|
||||||
|
return value.toFixed() + "°C";
|
||||||
|
}}
|
||||||
|
onChange={(_, value) => {
|
||||||
|
setTempC(value as number);
|
||||||
|
setTempString(value.toString());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<React.Fragment>
|
||||||
|
<Grid item xs={2}>
|
||||||
|
<Typography>Temperature</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={7}>
|
||||||
|
<Box paddingRight={2}>
|
||||||
|
<Slider
|
||||||
|
//orientation="vertical"
|
||||||
|
value={tempC}
|
||||||
|
min={-40}
|
||||||
|
max={40}
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
valueLabelFormat={value => {
|
||||||
|
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
|
||||||
|
return ((value * 9) / 5 + 32).toFixed() + "°F";
|
||||||
|
}
|
||||||
|
return value.toFixed() + "°C";
|
||||||
|
}}
|
||||||
|
onChange={(_, value) => {
|
||||||
|
setTempC(value as number);
|
||||||
|
setTempString(value.toString());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={3}>
|
||||||
|
<TextField
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
margin="dense"
|
||||||
|
variant="outlined"
|
||||||
|
type="number"
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
{getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
|
||||||
|
? "°F"
|
||||||
|
: "°C"}
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
error={isNaN(+tempString)}
|
||||||
|
value={tempString}
|
||||||
|
onChange={e => {
|
||||||
|
if (!isNaN(+e.target.value)) {
|
||||||
|
let temp = +e.target.value;
|
||||||
|
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
|
||||||
|
temp = Math.round((((temp - 32) * 5) / 9) * 10) / 10;
|
||||||
|
}
|
||||||
|
setTempC(temp);
|
||||||
|
}
|
||||||
|
setTempString(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
{/* humidity slider */}
|
||||||
|
{isMobile ? (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
alignContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between">
|
||||||
|
<Typography>Humidity</Typography>
|
||||||
|
<TextField
|
||||||
|
style={{ marginLeft: 5 }}
|
||||||
|
margin="dense"
|
||||||
|
variant="outlined"
|
||||||
|
type="number"
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: <InputAdornment position="end">%</InputAdornment>
|
||||||
|
}}
|
||||||
|
error={isNaN(+humString)}
|
||||||
|
value={humString}
|
||||||
|
onChange={e => {
|
||||||
|
if (!isNaN(+humString)) {
|
||||||
|
setHum(+e.target.value);
|
||||||
|
}
|
||||||
|
setHumString(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box paddingLeft={2} paddingRight={2}>
|
||||||
|
<Slider
|
||||||
|
//orientation="vertical"
|
||||||
|
value={hum}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
valueLabelFormat={value => value.toFixed() + "%"}
|
||||||
|
onChange={(_, value) => {
|
||||||
|
setHum(value as number);
|
||||||
|
setHumString(value.toString());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<React.Fragment>
|
||||||
|
<Grid item xs={2}>
|
||||||
|
<Typography>Humidity</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={7}>
|
||||||
|
<Box paddingRight={2}>
|
||||||
|
<Slider
|
||||||
|
//orientation="vertical"
|
||||||
|
value={hum}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
valueLabelFormat={value => value.toFixed() + "%"}
|
||||||
|
onChange={(_, value) => {
|
||||||
|
setHum(value as number);
|
||||||
|
setHumString(value.toString());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={3}>
|
||||||
|
<TextField
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
margin="dense"
|
||||||
|
variant="outlined"
|
||||||
|
type="number"
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: <InputAdornment position="end">%</InputAdornment>
|
||||||
|
}}
|
||||||
|
error={isNaN(+humString)}
|
||||||
|
value={humString}
|
||||||
|
onChange={e => {
|
||||||
|
if (!isNaN(+humString)) {
|
||||||
|
setHum(+e.target.value);
|
||||||
|
}
|
||||||
|
setHumString(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
<Box display="flex" justifyContent="flex-end" alignContent="flex-end" marginTop={1}>
|
||||||
|
{existing && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
style={{ background: "red" }}
|
||||||
|
onClick={() => {
|
||||||
|
setOpenDeleteConfirmation(true);
|
||||||
|
}}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{existing ? (
|
||||||
|
<Button
|
||||||
|
style={{ marginLeft: 5 }}
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => {
|
||||||
|
updatePreset();
|
||||||
|
}}>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
presetType === pond.PresetType.PRESET_TYPE_UNKNOWN ||
|
||||||
|
controllerType === pond.ControllerType.CONTROLLER_TYPE_UNKNOWN
|
||||||
|
}
|
||||||
|
onClick={() => saveNewPreset()}
|
||||||
|
variant="contained"
|
||||||
|
color="primary">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
src/device/presets/devicePresetController.tsx
Normal file
208
src/device/presets/devicePresetController.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListSubheader,
|
||||||
|
Typography
|
||||||
|
} from "@mui/material";
|
||||||
|
import { cloneDeep } from "lodash";
|
||||||
|
import { DevicePreset } from "models/DevicePreset";
|
||||||
|
import ObjectDescriber from "objects/ObjectDescriber";
|
||||||
|
import { pond } from "protobuf-ts/pond";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import DevicePresetCard from "./devicePresetCard";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
objectKey: string;
|
||||||
|
objectType: pond.ObjectType;
|
||||||
|
presets: DevicePreset[];
|
||||||
|
refreshCallback: (newPresets: DevicePreset[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevicePresetController(props: Props) {
|
||||||
|
const { objectKey, objectType, presets, refreshCallback } = props;
|
||||||
|
const [newPresets, setNewPresets] = useState<Map<string, DevicePreset>>(new Map());
|
||||||
|
const [savedPresets, setSavedPresets] = useState<Map<pond.PresetType, DevicePreset[]>>(new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let presetTypeMap: Map<pond.PresetType, DevicePreset[]> = new Map();
|
||||||
|
presets.forEach(preset => {
|
||||||
|
let typePresets = presetTypeMap.get(preset.settings.type);
|
||||||
|
if (typePresets) {
|
||||||
|
typePresets.push(preset);
|
||||||
|
} else {
|
||||||
|
presetTypeMap.set(preset.settings.type, [preset]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setSavedPresets(presetTypeMap);
|
||||||
|
}, [presets]);
|
||||||
|
|
||||||
|
const createNewPreset = () => {
|
||||||
|
//add to the list of new presets using the default preset settings
|
||||||
|
let newPreset = DevicePreset.create();
|
||||||
|
newPreset.name = "New Preset";
|
||||||
|
let p = new Map(newPresets);
|
||||||
|
let tempKey = p.size.toString(); //generate a unique string for a tempKey
|
||||||
|
newPreset.key = tempKey;
|
||||||
|
p.set(tempKey, newPreset);
|
||||||
|
setNewPresets(p);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCallback = (preset: DevicePreset, tempKey: string) => {
|
||||||
|
//add the preset to the list of saved presets
|
||||||
|
let presetMap = cloneDeep(savedPresets);
|
||||||
|
if (presetMap.get(preset.settings.type)) {
|
||||||
|
presetMap.get(preset.settings.type)?.push(preset);
|
||||||
|
} else {
|
||||||
|
presetMap.set(preset.settings.type, [preset]);
|
||||||
|
}
|
||||||
|
//send the parent component the list of presets after the new one has been saved
|
||||||
|
let presets: DevicePreset[] = [];
|
||||||
|
presetMap.forEach(presetList => {
|
||||||
|
presets = presets.concat(presetList);
|
||||||
|
});
|
||||||
|
refreshCallback(presets);
|
||||||
|
setSavedPresets(presetMap);
|
||||||
|
//remove the preset from the newpreset map using the temp key
|
||||||
|
let pMap = cloneDeep(newPresets);
|
||||||
|
pMap.delete(tempKey);
|
||||||
|
setNewPresets(pMap);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCallback = (preset: DevicePreset) => {
|
||||||
|
//get the type list this preset is a part of
|
||||||
|
let map = cloneDeep(savedPresets);
|
||||||
|
let list = map.get(preset.settings.type);
|
||||||
|
//remove it from the list
|
||||||
|
if (list) {
|
||||||
|
let foundIndex;
|
||||||
|
list.forEach((p, i) => {
|
||||||
|
if (p.key === preset.key) {
|
||||||
|
foundIndex = i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (foundIndex !== undefined) {
|
||||||
|
list.splice(foundIndex, 1);
|
||||||
|
}
|
||||||
|
map.set(preset.settings.type, list);
|
||||||
|
}
|
||||||
|
//send the parent the new list of presets after it has been removed
|
||||||
|
let presets: DevicePreset[] = [];
|
||||||
|
map.forEach(presetList => {
|
||||||
|
presets = presets.concat(presetList);
|
||||||
|
});
|
||||||
|
refreshCallback(presets);
|
||||||
|
setSavedPresets(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCallback = (oldPreset: DevicePreset, newPreset: DevicePreset) => {
|
||||||
|
//only need to update the lists if the presets if the type changed
|
||||||
|
if (oldPreset.settings.type !== newPreset.settings.type) {
|
||||||
|
let map = cloneDeep(savedPresets);
|
||||||
|
//use the new preset to tget the list to add to
|
||||||
|
let newPresetList = map.get(newPreset.settings.type);
|
||||||
|
if (newPresetList) {
|
||||||
|
newPresetList.push(newPreset);
|
||||||
|
map.set(newPreset.settings.type, newPresetList);
|
||||||
|
} else {
|
||||||
|
map.set(newPreset.settings.type, [newPreset]);
|
||||||
|
}
|
||||||
|
|
||||||
|
//use the type from the old preset to get the list to remove from
|
||||||
|
let oldPresetList = map.get(oldPreset.settings.type);
|
||||||
|
if (oldPresetList) {
|
||||||
|
let foundIndex;
|
||||||
|
oldPresetList.forEach((preset, i) => {
|
||||||
|
if (oldPreset.key === preset.key) {
|
||||||
|
foundIndex = i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (foundIndex !== undefined) {
|
||||||
|
oldPresetList.splice(foundIndex, 1);
|
||||||
|
if (oldPresetList.length === 0) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.set(oldPreset.settings.type, oldPresetList);
|
||||||
|
}
|
||||||
|
let presets: DevicePreset[] = [];
|
||||||
|
map.forEach(presetList => {
|
||||||
|
presets = presets.concat(presetList);
|
||||||
|
});
|
||||||
|
refreshCallback(presets);
|
||||||
|
setSavedPresets(map);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const savedPresetDisplay = () => {
|
||||||
|
let display: JSX.Element[] = [];
|
||||||
|
savedPresets.forEach((presetList, type) => {
|
||||||
|
let list: JSX.Element = (
|
||||||
|
<React.Fragment key={type}>
|
||||||
|
<List>
|
||||||
|
{/* <ListSubheader>Saved {ObjectDescriber(objectType).name} Presets</ListSubheader> */}
|
||||||
|
<ListSubheader>{DevicePreset.presetTypeString(type)} Presets</ListSubheader>
|
||||||
|
{presetList.map((preset, i) => (
|
||||||
|
<ListItem key={i}>
|
||||||
|
<DevicePresetCard
|
||||||
|
preset={preset}
|
||||||
|
existing
|
||||||
|
objectType={objectType}
|
||||||
|
objectKey={objectKey}
|
||||||
|
removeCallback={removeCallback}
|
||||||
|
updateCallback={updateCallback}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
display.push(list);
|
||||||
|
});
|
||||||
|
|
||||||
|
return display;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card raised>
|
||||||
|
<Box padding={1} display="flex" justifyContent="space-between">
|
||||||
|
<Typography style={{ fontWeight: 650, fontSize: 25 }}>Custom Bin Presets</Typography>
|
||||||
|
<Button onClick={createNewPreset} variant="contained" color="primary">
|
||||||
|
Add New Preset
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{newPresets.size > 0 && (
|
||||||
|
<React.Fragment>
|
||||||
|
<Typography style={{ paddingLeft: 10, fontWeight: 650 }}>New Presets</Typography>
|
||||||
|
<List>
|
||||||
|
{Array.from(newPresets.values()).map((preset, i) => (
|
||||||
|
<ListItem key={i}>
|
||||||
|
<DevicePresetCard
|
||||||
|
preset={preset}
|
||||||
|
addCallback={addCallback}
|
||||||
|
objectType={objectType}
|
||||||
|
objectKey={objectKey}
|
||||||
|
removeCallback={removeCallback}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
{savedPresets.size > 0 ? (
|
||||||
|
<React.Fragment>
|
||||||
|
<Typography style={{ paddingLeft: 10, fontWeight: 650 }}>
|
||||||
|
Saved {ObjectDescriber(objectType).name} Presets
|
||||||
|
</Typography>
|
||||||
|
{savedPresetDisplay()}
|
||||||
|
</React.Fragment>
|
||||||
|
) : (
|
||||||
|
<Box padding={2}>
|
||||||
|
<Typography>Add custom presets to use when changing modes</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/hooks/useViewport.ts
Normal file
28
src/hooks/useViewport.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
interface Viewport {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getViewport() {
|
||||||
|
return {
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useViewport() {
|
||||||
|
const [viewport, setViewport] = useState<Viewport>(getViewport());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleResize() {
|
||||||
|
setViewport(getViewport());
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return viewport;
|
||||||
|
}
|
||||||
146
src/models/Ambient.ts
Normal file
146
src/models/Ambient.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { pond } from "protobuf-ts/pond";
|
||||||
|
import { quack } from "protobuf-ts/pond";
|
||||||
|
import { or } from "utils/types";
|
||||||
|
import { cloneDeep } from "lodash";
|
||||||
|
import { Component } from "models";
|
||||||
|
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
|
||||||
|
|
||||||
|
export class Ambient {
|
||||||
|
public settings: pond.ComponentSettings = pond.ComponentSettings.create();
|
||||||
|
public status: pond.ComponentStatus = pond.ComponentStatus.create();
|
||||||
|
public temperature: number = -127;
|
||||||
|
public humidity: number = 200;
|
||||||
|
|
||||||
|
public static create(comp: Component): Ambient {
|
||||||
|
let my = new Ambient();
|
||||||
|
my.settings = comp.settings;
|
||||||
|
my.status = comp.status;
|
||||||
|
|
||||||
|
if (comp.status.measurement.length > 0) {
|
||||||
|
comp.status.measurement.forEach(um => {
|
||||||
|
if (um.values[0]) {
|
||||||
|
if (um.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) {
|
||||||
|
my.temperature = or(um.values[0].values[0], -127);
|
||||||
|
}
|
||||||
|
if (um.type === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) {
|
||||||
|
my.humidity = or(um.values[0].values[0], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let temp = comp.status?.lastMeasurement?.measurement?.humidityAndTemperature?.celciusTimes_10;
|
||||||
|
let hum =
|
||||||
|
comp.status?.lastMeasurement?.measurement?.humidityAndTemperature
|
||||||
|
?.relativeHumidityTimes_100;
|
||||||
|
if (temp === undefined) {
|
||||||
|
if (comp.status?.lastMeasurement?.measurement?.humidityAndTemperature) {
|
||||||
|
let humAndTemp = comp.status?.lastMeasurement?.measurement?.humidityAndTemperature as any;
|
||||||
|
temp = humAndTemp["celciusTimes10"];
|
||||||
|
hum = humAndTemp["relativeHumidityTimes100"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
my.temperature = or(temp, -1270) / 10;
|
||||||
|
my.humidity = or(hum, 20000) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return my;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createPond(comp: pond.Component): Ambient {
|
||||||
|
let my = new Ambient();
|
||||||
|
my.settings = comp.settings ? comp.settings : pond.ComponentSettings.create();
|
||||||
|
my.status = comp.status ? comp.status : pond.ComponentStatus.create();
|
||||||
|
let temp = comp.status?.lastMeasurement?.measurement?.humidityAndTemperature?.celciusTimes_10;
|
||||||
|
let hum =
|
||||||
|
comp.status?.lastMeasurement?.measurement?.humidityAndTemperature?.relativeHumidityTimes_100;
|
||||||
|
if (temp === undefined) {
|
||||||
|
if (comp.status?.lastMeasurement?.measurement?.humidityAndTemperature) {
|
||||||
|
let humAndTemp = comp.status?.lastMeasurement?.measurement?.humidityAndTemperature as any;
|
||||||
|
temp = humAndTemp["celciusTimes10"];
|
||||||
|
hum = humAndTemp["relativeHumidityTimes100"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
my.temperature = or(temp, -1270) / 10;
|
||||||
|
my.humidity = or(hum, 20000) / 100;
|
||||||
|
|
||||||
|
return my;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static any(data: any): Ambient {
|
||||||
|
let comp = pond.Component.fromObject(cloneDeep(data));
|
||||||
|
let my = Ambient.createPond(comp);
|
||||||
|
if (data && data.status && data.status.lastMeasurement) {
|
||||||
|
my.status.lastMeasurement = data.status.lastMeasurement;
|
||||||
|
}
|
||||||
|
return my;
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(other: Ambient) {
|
||||||
|
this.settings = other.settings;
|
||||||
|
this.status = other.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static humidColour() {
|
||||||
|
return describeMeasurement(
|
||||||
|
quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
|
||||||
|
quack.ComponentType.COMPONENT_TYPE_DHT
|
||||||
|
).colour();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static tempColour() {
|
||||||
|
return describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour();
|
||||||
|
}
|
||||||
|
|
||||||
|
public name(): string {
|
||||||
|
return this.settings.name !== "" ? this.settings.name : "Component " + this.key();
|
||||||
|
}
|
||||||
|
|
||||||
|
public key(): string {
|
||||||
|
return this.settings.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public location(): quack.ComponentID {
|
||||||
|
return quack.ComponentID.fromObject({
|
||||||
|
type: this.settings.type,
|
||||||
|
addressType: this.settings.addressType,
|
||||||
|
address: this.settings.address
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public locationString(): string {
|
||||||
|
return (
|
||||||
|
or(this.settings.type, 0).toString() +
|
||||||
|
"-" +
|
||||||
|
or(this.settings.addressType, 0).toString() +
|
||||||
|
"-" +
|
||||||
|
or(this.settings.address, 0).toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public type(): quack.ComponentType {
|
||||||
|
return this.settings.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public subType(): number {
|
||||||
|
return this.settings.subtype;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTempString(unit = pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS): string {
|
||||||
|
if (this.temperature < -126) return "--";
|
||||||
|
if (unit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT)
|
||||||
|
return (this.temperature * (9 / 5) + 32).toFixed(2) + "°F";
|
||||||
|
return this.temperature.toFixed(2) + "°C";
|
||||||
|
}
|
||||||
|
|
||||||
|
public getHumidityString(): string {
|
||||||
|
if (this.humidity > 199) return "--";
|
||||||
|
return this.humidity.toFixed(2) + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
public asComponent(): Component {
|
||||||
|
let c = Component.create();
|
||||||
|
c.settings = this.settings;
|
||||||
|
c.status = this.status;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/models/Controller.ts
Normal file
95
src/models/Controller.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { pond } from "protobuf-ts/pond";
|
||||||
|
import { quack } from "protobuf-ts/pond";
|
||||||
|
import { or } from "utils/types";
|
||||||
|
import { cloneDeep } from "lodash";
|
||||||
|
import { Component } from "models";
|
||||||
|
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
|
||||||
|
|
||||||
|
export class Controller {
|
||||||
|
public settings: pond.ComponentSettings = pond.ComponentSettings.create();
|
||||||
|
public status: pond.ComponentStatus = pond.ComponentStatus.create();
|
||||||
|
public on = false;
|
||||||
|
|
||||||
|
public static create(comp: Component): Controller {
|
||||||
|
let my = new Controller();
|
||||||
|
my.settings = comp.settings;
|
||||||
|
my.status = comp.status;
|
||||||
|
|
||||||
|
my.on = Boolean(my.status.lastMeasurement?.measurement?.booleanOutput?.value);
|
||||||
|
|
||||||
|
return my;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createPond(comp: pond.Component): Controller {
|
||||||
|
let my = new Controller();
|
||||||
|
my.settings = comp.settings ? comp.settings : pond.ComponentSettings.create();
|
||||||
|
my.status = comp.status ? comp.status : pond.ComponentStatus.create();
|
||||||
|
|
||||||
|
my.on = Boolean(my.status.lastMeasurement?.measurement?.booleanOutput?.value);
|
||||||
|
|
||||||
|
return my;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static any(data: any): Controller {
|
||||||
|
let comp = pond.Component.fromObject(cloneDeep(data));
|
||||||
|
let my = Controller.createPond(comp);
|
||||||
|
if (data && data.status && data.status.lastMeasurement) {
|
||||||
|
my.status.lastMeasurement = data.status.lastMeasurement;
|
||||||
|
}
|
||||||
|
return my;
|
||||||
|
}
|
||||||
|
|
||||||
|
public colour() {
|
||||||
|
return describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN).colour();
|
||||||
|
}
|
||||||
|
|
||||||
|
public color() {
|
||||||
|
return describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN).colour();
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(other: Controller) {
|
||||||
|
this.settings = other.settings;
|
||||||
|
this.status = other.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public name(): string {
|
||||||
|
return this.settings.name !== "" ? this.settings.name : "Component " + this.key();
|
||||||
|
}
|
||||||
|
|
||||||
|
public key(): string {
|
||||||
|
return this.settings.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public location(): quack.ComponentID {
|
||||||
|
return quack.ComponentID.fromObject({
|
||||||
|
type: this.settings.type,
|
||||||
|
addressType: this.settings.addressType,
|
||||||
|
address: this.settings.address
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public locationString(): string {
|
||||||
|
return (
|
||||||
|
or(this.settings.type, 0).toString() +
|
||||||
|
"-" +
|
||||||
|
or(this.settings.addressType, 0).toString() +
|
||||||
|
"-" +
|
||||||
|
or(this.settings.address, 0).toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public type(): quack.ComponentType {
|
||||||
|
return this.settings.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public subType(): number {
|
||||||
|
return this.settings.subtype;
|
||||||
|
}
|
||||||
|
|
||||||
|
public asComponent(): Component {
|
||||||
|
let c = Component.create();
|
||||||
|
c.settings = this.settings;
|
||||||
|
c.status = this.status;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/models/DevicePreset.ts
Normal file
98
src/models/DevicePreset.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { cloneDeep } from "lodash";
|
||||||
|
import { pond } from "protobuf-ts/pond";
|
||||||
|
import { or } from "utils/types";
|
||||||
|
|
||||||
|
export class DevicePreset {
|
||||||
|
public settings: pond.DevicePresetSettings = pond.DevicePresetSettings.create();
|
||||||
|
public name: string = "Preset";
|
||||||
|
public key: string = "";
|
||||||
|
|
||||||
|
public static create(pb?: pond.DevicePreset): DevicePreset {
|
||||||
|
let my = new DevicePreset();
|
||||||
|
if (pb) {
|
||||||
|
my.settings = pond.DevicePresetSettings.fromObject(cloneDeep(or(pb.settings, {})));
|
||||||
|
my.name = pb.name;
|
||||||
|
my.key = pb.key;
|
||||||
|
}
|
||||||
|
return my;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static clone(other?: DevicePreset): DevicePreset {
|
||||||
|
if (other) {
|
||||||
|
return DevicePreset.create(
|
||||||
|
pond.DevicePreset.fromObject({
|
||||||
|
title: other.name,
|
||||||
|
key: other.key,
|
||||||
|
settings: cloneDeep(other.settings)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return DevicePreset.create();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static any(data: any): DevicePreset {
|
||||||
|
return DevicePreset.create(pond.DevicePreset.fromObject(cloneDeep(data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public objectType(): pond.ObjectType {
|
||||||
|
return pond.ObjectType.OBJECT_TYPE_DEVICE_PRESET;
|
||||||
|
}
|
||||||
|
|
||||||
|
public objectTypeString(): string {
|
||||||
|
return "Device Preset";
|
||||||
|
}
|
||||||
|
|
||||||
|
// public static controllerString(controllerType: pond.ControllerType): string {
|
||||||
|
// switch(controllerType){
|
||||||
|
// case pond.ControllerType.CONTROLLER_TYPE_FAN:
|
||||||
|
// return "Fan"
|
||||||
|
// }
|
||||||
|
// return ""
|
||||||
|
// }
|
||||||
|
|
||||||
|
public static presetTypeString(presetType: pond.PresetType): string {
|
||||||
|
switch (presetType) {
|
||||||
|
case pond.PresetType.PRESET_TYPE_COOLING:
|
||||||
|
return "Cooling";
|
||||||
|
case pond.PresetType.PRESET_TYPE_DRYING:
|
||||||
|
return "Drying";
|
||||||
|
case pond.PresetType.PRESET_TYPE_HYDRATING:
|
||||||
|
return "Hydrating";
|
||||||
|
default:
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public compareToBinMode(binMode: pond.BinMode): boolean {
|
||||||
|
switch (this.settings.type) {
|
||||||
|
case pond.PresetType.PRESET_TYPE_COOLING:
|
||||||
|
return binMode === pond.BinMode.BIN_MODE_COOLDOWN;
|
||||||
|
case pond.PresetType.PRESET_TYPE_DRYING:
|
||||||
|
return binMode === pond.BinMode.BIN_MODE_DRYING;
|
||||||
|
case pond.PresetType.PRESET_TYPE_HYDRATING:
|
||||||
|
return binMode === pond.BinMode.BIN_MODE_HYDRATING;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public type(): pond.PresetType {
|
||||||
|
return this.settings.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public controllerType(): pond.ControllerType {
|
||||||
|
return this.settings.controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
public favorite(): boolean {
|
||||||
|
return this.settings.favorite;
|
||||||
|
}
|
||||||
|
|
||||||
|
public temp(): number {
|
||||||
|
return this.settings.temperature;
|
||||||
|
}
|
||||||
|
|
||||||
|
public hum(): number {
|
||||||
|
return this.settings.humidity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import { ErrorBoundary } from "react-error-boundary";
|
||||||
import DeviceComponent from "pages/DeviceComponent";
|
import DeviceComponent from "pages/DeviceComponent";
|
||||||
import Bins from "pages/Bins";
|
import Bins from "pages/Bins";
|
||||||
import { getWhitelabel } from "services/whiteLabel";
|
import { getWhitelabel } from "services/whiteLabel";
|
||||||
|
import Bin from "pages/Bin";
|
||||||
|
|
||||||
const DeviceHistory = lazy(() => import("pages/DeviceHistory"));
|
const DeviceHistory = lazy(() => import("pages/DeviceHistory"));
|
||||||
const DevicePage = lazy(() => import("pages/Device"));
|
const DevicePage = lazy(() => import("pages/Device"));
|
||||||
|
|
@ -23,6 +24,7 @@ const GroupPage = lazy(() => import("pages/Group"));
|
||||||
const Teams = lazy(() => import("pages/Teams"));
|
const Teams = lazy(() => import("pages/Teams"));
|
||||||
const Users = lazy(() => import("pages/Users"));
|
const Users = lazy(() => import("pages/Users"));
|
||||||
const TeamPage = lazy(() => import("pages/Team"));
|
const TeamPage = lazy(() => import("pages/Team"));
|
||||||
|
const BinPage = lazy(() => import("pages/Bin"));
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
|
|
@ -108,22 +110,22 @@ export default function Router(props: Props) {
|
||||||
path="" // "/settings/basic"
|
path="" // "/settings/basic"
|
||||||
element={<Bins key={"Bins page"} />}
|
element={<Bins key={"Bins page"} />}
|
||||||
/>
|
/>
|
||||||
{/* <Route
|
<Route
|
||||||
path="/:binID" // "/settings/basic"
|
path="/:binID" // "/settings/basic"
|
||||||
element={<Bin />}
|
element={<Bin />}
|
||||||
/>
|
/>
|
||||||
<Route
|
{/* <Route
|
||||||
path="/:deviceID/components/:componentID" // "/settings/basic"
|
path="/:deviceID/components/:componentID" // "/settings/basic"
|
||||||
element={<DeviceComponent />}
|
element={<DeviceComponent />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/:deviceID/history" // "/settings/basic"
|
path="/:deviceID/history" // "/settings/basic"
|
||||||
element={<DeviceHistory />}
|
element={<DeviceHistory />}
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/:deviceID/*" // "/settings/basic"
|
|
||||||
element={<RelativeRoutes />}
|
|
||||||
/> */}
|
/> */}
|
||||||
|
<Route
|
||||||
|
path="/:binID/*" // "/settings/basic"
|
||||||
|
element={<RelativeRoutes />}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
1166
src/pages/Bin.tsx
Normal file
1166
src/pages/Bin.tsx
Normal file
File diff suppressed because it is too large
Load diff
28
src/products/CommonIcons/co2Icon.tsx
Normal file
28
src/products/CommonIcons/co2Icon.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import Co2DarkIcon from "assets/components/co2Dark.png";
|
||||||
|
import Co2LightIcon from "assets/components/co2Light.png";
|
||||||
|
import { ImgIcon } from "common/ImgIcon";
|
||||||
|
import { useThemeType } from "hooks";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type?: "light" | "dark";
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Co2Icon(props: Props) {
|
||||||
|
const themeType = useThemeType();
|
||||||
|
const { type, size } = props;
|
||||||
|
|
||||||
|
const src = () => {
|
||||||
|
if (type) {
|
||||||
|
return type === "light" ? Co2LightIcon : Co2DarkIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
return themeType === "light" ? Co2DarkIcon : Co2LightIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (size) {
|
||||||
|
return <img width={size} height={size} alt="co2" src={src()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ImgIcon alt="co2" src={src()} />;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue