diff --git a/package-lock.json b/package-lock.json index 7ec52df..56f5058 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e744f72..ae6c786 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/bin/BinActions.tsx b/src/bin/BinActions.tsx new file mode 100644 index 0000000..d50fedb --- /dev/null +++ b/src/bin/BinActions.tsx @@ -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; + setComponents?: React.Dispatch>>; + 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); + const [{ user }, dispatch] = useGlobalState(); + const isMobile = useMobile(); + const { error } = useSnackbar(); + const userAPI = useUserAPI(); + const [openState, setOpenState] = useState({ + 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 ( + setAnchorEl(null)} + keepMounted + disableAutoFocusItem> + {!isOffline() && canShare && ( + { + setOpenState({ ...openState, share: true }); + setAnchorEl(null); + }} + > + + + + + + )} + {!isOffline() && canManageUsers && ( + { + setOpenState({ ...openState, users: true }); + setAnchorEl(null); + }} + > + + + + + + )} + {!isOffline() && canManageUsers && ( + { + setOpenState({ ...openState, teams: true }); + setAnchorEl(null); + }} + > + + + + + + )} + { + setOpenState({ ...openState, duplication: true }); + setAnchorEl(null); + }} + > + + + + + + {isMobile && ( + { + setAnchorEl(null); + startTour(); + }} + > + + + + + + )} + {isMobile && ( + { + setAnchorEl(null); + setOpenState({ ...openState, sensors: true }); + }} + > + + + + + + )} + { + setOpenState({ ...openState, removeSelf: true }); + setAnchorEl(null); + }} + > + + + + + + + ); + }; + + const dialogs = () => { + const hasWritePermission = permissions.includes(pond.Permission.PERMISSION_WRITE); + const key = bin.key(); + const label = bin.name(); + return ( + + { + if (refresh === true) { + refreshCallback(); + } + setOpenState({ ...openState, settings: false }); + }} + /> + {/* { + if (refresh === true) { + refreshCallback(); + } + setOpenState({ ...openState, sensors: false }); + }} + /> */} + setOpenState({ ...openState, share: false })} + /> + setOpenState({ ...openState, users: false })} + refreshCallback={refreshCallback} + /> + {/* { + setOpenState({ ...openState, duplication: false }); + }} + bin={bin} + refreshCallback={refreshCallback} + /> */} + setOpenState({ ...openState, removeSelf: false })} + /> + + ); + }; + + return ( + + {!isMobile && ( + + + + + + )} + {!isMobile && ( + + setOpenState({ ...openState, sensors: true })}> + + + + )} + + setOpenState({ ...openState, settings: true })}> + + + + ) => setAnchorEl(event.currentTarget)}> + + + {groupMenu()} + {dialogs()} + setOpenState({ ...openState, teams: false })} + /> + + ); +} diff --git a/src/bin/BinCardV2.tsx b/src/bin/BinCardV2.tsx index 1f8cf71..e8fb815 100644 --- a/src/bin/BinCardV2.tsx +++ b/src/bin/BinCardV2.tsx @@ -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 ( - + {user.hasFeature("admin") && ( { + 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; + 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>(new Map()); + const [sinkMap, setSinkMap] = useState>(new Map()); + + useEffect(() => { + let sourceMap: Map = new Map(); + let sinkMap: Map = 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( + + ); + } + }); + return conditioningInteractions; + }; + + return ( + + + {mode === pond.BinMode.BIN_MODE_DRYING ? "Drying" : "Hydrating"} Conditions + + {conditionsDisplay()} + + ); +} diff --git a/src/bin/BinConditioningInteraction.tsx b/src/bin/BinConditioningInteraction.tsx new file mode 100644 index 0000000..c088bdd --- /dev/null +++ b/src/bin/BinConditioningInteraction.tsx @@ -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>(new Map()); + const [sliderMarks, setSliderMarks] = useState>(new Map()); + //this is the emc value calculated from the interactions temp and humidity conditions + const [baseEMC, setBaseEMC] = useState(); + const [{ user }] = useGlobalState(); + const classes = useStyles(); + const interactionAPI = useInteractionsAPI(); + const { openSnack } = useSnackbar(); + + useEffect(() => { + let passedInteraction = interaction; + let sliderVals: Map = new Map(); + let sliderMarks: Map = 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 ( + + + + + {val} + + + + + + + + ); + }; + + const conditionDisplay = () => { + return ( + + {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 ( + + + {interactionConditionText(source, condition, false)} + + + { + 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); + }} + /> + + + ); + })} + + + + + ); + }; + + 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 ( + + + {source.name()} + UnitMeasurement.any(um, user)) + )} + /> + + + {sink.name()} + UnitMeasurement.any(um, user)) + )} + /> + + + + {interactionResultText(interaction, sink)} + + {baseEMC !== undefined ? ( + + }> + + EMC {determineEMCRelation()} + + {baseEMC.toFixed(1)}% + + + + {conditionDisplay()} + + ) : ( + {conditionDisplay()} + )} + + + ); +} diff --git a/src/bin/BinHistory.tsx b/src/bin/BinHistory.tsx new file mode 100644 index 0000000..16b30c7 --- /dev/null +++ b/src/bin/BinHistory.tsx @@ -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(); + 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 => { + 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 ( + + + {binName} History + + + + + + ); +} diff --git a/src/bin/BinStorageConditions.tsx b/src/bin/BinStorageConditions.tsx new file mode 100644 index 0000000..16c1da4 --- /dev/null +++ b/src/bin/BinStorageConditions.tsx @@ -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([-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(); + const [averageEMC, setAverageEMC] = useState(); + + //the number of nodes in the grain that fall into each category + const [tempTargets, setTempTargets] = useState({ + below: 0, + onTarget: 0, + above: 0, + total: 0 + }); + const [emcTargets, setEmcTargets] = useState({ + 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 ( + + + + {val} + + + {sub} + + + + + + + ); + }; + + //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 ( + + + + + + + Target Temp = + + + + + { + setTargetTemp(e.target.value); + }} + InputProps={{ + endAdornment: ( + + {getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT + ? "°F" + : "°C"} + + ) + }} + /> + + + { + 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 && ( + + + + + {((tempTargets.below / tempTargets.total) * 100).toFixed(2)}% + + Below + + + + {((tempTargets.onTarget / tempTargets.total) * 100).toFixed(2)}% + + On Target + + + + {((tempTargets.above / tempTargets.total) * 100).toFixed(2)}% + + Above + + + + )} + + ); + }; + + //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 ( + + + + + + + + + Target EMC = + + + + + { + setTargetEMC(e.target.value); + }} + InputProps={{ + endAdornment: % + }} + /> + + + + + + + + Deviation = + + + + + { + setEmcDeviation(e.target.value); + }} + InputProps={{ + endAdornment: % + }} + /> + + + { + return value.toFixed(1) + "%"; + }} + step={0.5} + min={min} + max={max} + value={[low, high]} + /> + {emcTargets.total > 0 && ( + + + + + {((emcTargets.below / emcTargets.total) * 100).toFixed(2)}% + + Below + + + + {((emcTargets.onTarget / emcTargets.total) * 100).toFixed(2)}% + + On Target + + + + {((emcTargets.above / emcTargets.total) * 100).toFixed(2)}% + + Above + + + + )} + + ); + }; + + //this will only appear when there is a CO2 sensor + const co2Display = () => { + if (headspaceCO2.length > 0) { + return ( + + + + + + CO2 Spoilage Detection + + + {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 ( + + + + + + + Current CO2 + + + + + {reading} ppm + + + + + + + + + + + + { + return value + "ppm"; + }} + /> + + + + + ); + })} + + + ); + } + return; + }; + + //made up of the co2, moisture and temp displays + const storageConditions = () => { + return ( + + + Storage Conditions + {missingTopNodeWarning && ( + + + + )} + + {tempDisplay()} + {moistureDisplay()} + {co2Display()} + + + + + ); + }; + + return ( + + {storageConditions()} + + ); +} diff --git a/src/bin/BinTour.tsx b/src/bin/BinTour.tsx new file mode 100644 index 0000000..9daf11e --- /dev/null +++ b/src/bin/BinTour.tsx @@ -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: ( + + Welcome to Bin Management + + + ), + content: ( + + + {"Hello " + user.name()} + + + + Looks like this is your first time using our bin management system! Let me show you + around. + + + ), + target: "body", + placement: "center", + disableBeacon: true + }, + { + title: "Sensors", + content: ( + + + 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. + + + ), + target: "#tour-bin-sensors", + placement: "bottom", + disableBeacon: false + }, + { + title: "Settings", + content: ( + + + Bin settings lets you rename your bin, and change various bin specifications. + + + ), + target: "#tour-bin-settings", + placement: "bottom", + disableBeacon: false + }, + { + title: "More Options", + content: ( + + + Access this menu if you want to leave the bin, share it, or view the bin's associated + users and teams. + + + ), + target: "#tour-bin-kebab", + placement: "bottom", + disableBeacon: false + }, + { + title: "Graphs", + content: ( + + + Bin related analytics are displayed here. + + + ), + target: "#tour-graphs", + placement: "left", + disableBeacon: false + }, + { + title: "Choose your graphs", + content: ( + + + Use this tab to view other sets of data. Sensors must be attached to view sensor data. + + + ), + target: "#tour-graph-tabs", + placement: "bottom", + disableBeacon: false + }, + { + title: "Change Mode", + content: ( + + + Sensors need to be attached to change bin mode. + +
    +
  • Storage mode: default
  • +
  • + Drying mode: use heat to dry grain + +
  • +
  • + Cooldown mode: use fans to hold bin temperature lower + +
  • +
+
+ ), + 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 ( +
+ +
+ ); +} diff --git a/src/bin/BinVisualizerV2.tsx b/src/bin/BinVisualizerV2.tsx new file mode 100644 index 0000000..3a38722 --- /dev/null +++ b/src/bin/BinVisualizerV2.tsx @@ -0,0 +1,2099 @@ +import { + Avatar, + Box, + Button, + Card, + darken, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Grid2 as Grid, + IconButton, + InputAdornment, + lighten, + Link, + Skeleton, + Slider, + Switch, + TextField, + Theme, + ToggleButton, + ToggleButtonGroup, + Typography, + useTheme, +} from "@mui/material"; +import FullscreenIcon from "@mui/icons-material/Fullscreen"; +import FullscreenExitIcon from "@mui/icons-material/FullscreenExit"; +import RefreshIcon from "@mui/icons-material/Refresh"; +// import { Skeleton, ToggleButton, ToggleButtonGroup } from "@material-ui/lab"; +import HumidityIcon from "component/HumidityIcon"; +import TemperatureIcon from "component/TemperatureIcon"; +import GrainDescriber, { GrainOptions, ToGrainOption } from "grain/GrainDescriber"; +import useViewport from "hooks/useViewport"; +import { cloneDeep, round } from "lodash"; +import { Bin, Component, Device, Interaction } from "models"; +import { GetComponentIcon } from "pbHelpers/ComponentType"; +import { describeMeasurement } from "pbHelpers/MeasurementDescriber"; +import { useMobile } from "hooks"; +import { pond } from "protobuf-ts/pond"; +import { quack } from "protobuf-ts/quack"; +import { useGlobalState, useSnackbar } from "providers"; +import React, { useCallback, useEffect, useState } from "react"; +import { FullScreen, useFullScreenHandle } from "react-full-screen"; +import moment, { Moment } from "moment"; +import ResponsiveDialog from "common/ResponsiveDialog"; +import { getThemeType } from "theme"; +import { getGrainUnit, getTemperatureUnit, or } from "utils"; +import { useBinAPI } from "providers/pond/binAPI"; +import BindaptIcon from "products/Bindapt/BindaptIcon"; +import { + AccessTime, + CheckCircleOutline, + Error, + InfoOutlined, + TrendingDown, + TrendingFlat, + TrendingUp, + Warning +} from "@mui/icons-material"; +import DevicePresetsFromPicker from "device/DevicePresetsFromPicker"; +import { Plenum } from "models/Plenum"; +import { Pressure } from "models/Pressure"; +import { GrainCable } from "models/GrainCable"; +import GrainNodeInteractions from "./GrainNodeInteractions"; +import GrainTransaction from "grain/GrainTransaction"; +import { DevicePreset } from "models/DevicePreset"; +import BinSVGV2 from "./BinSVGV2"; +import AerationFanIcon from "products/AgIcons/AerationFanIcon"; +import ObjectHeaterIcon from "products/Construction/ObjectHeaterIcon"; +import { Ambient } from "models/Ambient"; +import { ExtractMoisture } from "grain"; +import SearchSelect, { Option } from "common/SearchSelect"; +import Edit from "@mui/icons-material/Edit"; +import { makeStyles, styled } from "@mui/styles"; + +const useStyles = makeStyles((theme: Theme) => { + return ({ + cardContent: { + padding: theme.spacing(1), + paddingRight: theme.spacing(0) + }, + bgItem: { + background: darken(theme.palette.background.paper, getThemeType() === "light" ? 0.05 : 0.25), + borderRadius: theme.shape.borderRadius, + width: "auto", + marginRight: theme.spacing(1), + padding: 5 + }, + displayBoxBinLeft: { + background: darken(theme.palette.background.default, 0.05), + borderRadius: 5, + marginRight: -70, //negative margin to extend the box behind the bin svg + paddingRight: 80, //padding to offset the text and ignore the extension + paddingTop: 5, + paddingBottom: 5, + paddingLeft: 5 + }, + displayBox: { + background: darken(theme.palette.background.default, 0.05), + borderRadius: 5, + padding: 5 + }, + controllerDisplay: { + display: "flex", + justifyContent: "space-between", + paddingLeft: 5, + paddingRight: 5 + }, + days: { + margin: theme.spacing(0.5), + fontSize: "16px" + }, + avatarIcon: { + background: "transparent", + width: theme.spacing(3), + height: theme.spacing(3), + margin: "auto", + marginBottom: "auto" + }, + selected: { + backgroundColor: "gold", + "&.selected": { + backgroundColor: "gold" + } + }, + extraDetails: { + padding: 1, + marginTop: theme.spacing(1), + display: "flex", + width: "100%", + flexGrow: 1, + height: "100%" + }, + grainOVerlay: { + position: "absolute", + height: "10%", + width: "100%", + textAlign: "center", + textShadow: "4px 4px 10px black" + }, + grainDiff: { + position: "absolute", + top: "10%" + }, + lightBox: { + background: lighten(theme.palette.background.default, 0.2), + borderRadius: 10, + padding: 10, + margin: 10 + }, + bottomSpacing: { + marginBottom: theme.spacing(1) + } + }); +}); + +const average = (arr: any) => arr.reduce((p: any, c: any) => p + c, 0) / arr.length; + +interface GrainConditions { + tempC: number; + humidity: number; + emc: number; + tempCTrend: number; + humidityTrend: number; + emcTrend: number; +} + +interface Props { + bin: Bin; + loading: boolean; + components: Map; + componentDevices?: Map; + devices: Device[]; + plenum?: Plenum; + ambient?: Ambient; + cables?: GrainCable[]; + pressure?: Pressure; + interactions?: Interaction[]; + //changeBinMode: (binMode: pond.BinMode) => boolean; + refresh: (showSnack?: boolean) => void; + afterUpdate?(): void; + preferences?: Map; + permissions?: pond.Permission[]; + updateComponentCallback: (componentKey: string) => void; + binPresets: DevicePreset[]; +} + +export default function BinVisualizer(props: Props) { + const { + bin, + //changeBinMode, + //changeGrainAmount, + plenum, + ambient, + pressure, + loading, + cables, + components, + devices, + preferences, + componentDevices, + permissions, + //interactions, + refresh, + updateComponentCallback, + binPresets + } = props; + const binAPI = useBinAPI(); + const isMobile = useMobile(); + const classes = useStyles(); + const theme = useTheme(); + const fullScreenHandler = useFullScreenHandle(); + const viewport = useViewport(); + const { openSnack } = useSnackbar(); + const [fillPercentage, setFillPercentage] = useState(0); + const [lidarBushels, setLidarBushels] = useState(); + const [lidarPercentage, setLidarPercentage] = useState(); + const [pendingGrainAmount, setPendingGrainAmount] = useState(); + const tempColour = describeMeasurement( + quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE + ).colour(); + const humidColour = describeMeasurement( + quack.MeasurementType.MEASUREMENT_TYPE_PERCENT, + quack.ComponentType.COMPONENT_TYPE_DHT + ).colour(); + const pressColour = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE).colour(); + const emcColour = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour(); + const [showInputMoisture, setShowInputMoisture] = useState(false); + const [moistureInput, setMoistureInput] = useState(""); + const [tempInput, setTempInput] = useState(""); + const [outdoorHumidityInput, setOutdoorHumidityInput] = useState(""); + const [modeTime, setModeTime] = useState(moment()); + const [grainUpdate, setGrainUpdate] = useState(false); + const [openStorageTime, setOpenStorageTime] = useState(false); + const [sliderColour, setSliderCoulour] = useState("gold"); + const [grainDiff, setGrainDiff] = useState(0); + + const [grainCables, setGrainCables] = useState([]); + const iOS = typeof window !== "undefined" && /iPad|iPhone|iPod/.test(navigator.userAgent); + const [nodeDetails, setNodeDetails] = useState(false); + const [cfmLowOpen, setCFMLowOpen] = useState(false); + const [cfmHighOpen, setCFMHighOpen] = useState(false); + const [{ user }] = useGlobalState(); + const [newPreset, setNewPreset] = useState(pond.BinMode.BIN_MODE_NONE); + const [selectedCable, setSelectedCable] = useState(); + const [openNodeDialog, setOpenNodeDialog] = useState(false); + const [cableDevice, setCableDevice] = useState(); + const [selectedNode, setSelectedNode] = useState(0); + const [devMap, setDevMap] = useState>(new Map()); + //the cables with the highest and lowest temp nodes + //const [highTempCable, setHighTempCable] = useState(); + //const [lowTempCable, setLowTempCable] = useState(); + //the switch value to determine what to display in the grain condition box + const [valueDisplay, setValueDisplay] = useState<"low" | "avg" | "high">("avg"); + //the variables to be able to change the grain type through a unique dialog outside of the settings + const [grainChangeDialog, setGrainChangeDialog] = useState(false); + const grainOptions = GrainOptions(); + const [isCustomInventory, setIsCustomInventory] = useState(false); + const [grainOption, setGrainOption] = useState