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