added tons of files, got the bin page to render

This commit is contained in:
Carter 2025-03-12 12:41:20 -06:00
parent a027b8a96f
commit 9bbbf0940e
34 changed files with 10492 additions and 19 deletions

43
package-lock.json generated
View file

@ -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",

View file

@ -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
View 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>
);
}

View file

@ -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={{

View 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>
);
}

View 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
View 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>
);
}

View 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
View 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

File diff suppressed because it is too large Load diff

View file

@ -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}

View 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>
);
}

View 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>
);
}

View 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>
);
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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
View 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
}
}}
/>
);
}

File diff suppressed because it is too large Load diff

View 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>
);
}

View 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
View 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
View 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
View 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;
}
}

View 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;
}
}

View file

@ -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

File diff suppressed because it is too large Load diff

View 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()} />;
}