466 lines
No EOL
20 KiB
TypeScript
466 lines
No EOL
20 KiB
TypeScript
import { Edit, GppGood, Info, Save } from "@mui/icons-material";
|
|
import { Box, Button, darken, DialogActions, IconButton, Table, TableBody, TableCell, TableHead, TableRow, TextField, Theme, Tooltip, Typography, useTheme } from "@mui/material";
|
|
import { green, grey, orange, red, yellow } from "@mui/material/colors";
|
|
import { makeStyles } from "@mui/styles";
|
|
import ResponsiveDialog from "common/ResponsiveDialog";
|
|
import HumidityIcon from "component/HumidityIcon";
|
|
import TemperatureIcon from "component/TemperatureIcon";
|
|
import { cloneDeep } from "lodash";
|
|
import { Bin } from "models";
|
|
import { pond } from "protobuf-ts/pond";
|
|
import { useGlobalState } from "providers";
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import { useMemo } from "react";
|
|
import { avg, celsiusToFahrenheit } from "utils";
|
|
import BinTableExpanded from "./BinTableExpanded";
|
|
import { useMobile } from "hooks";
|
|
|
|
const useStyles = makeStyles((theme: Theme) => ({
|
|
tableStyle: {
|
|
borderCollapse: "separate",
|
|
borderSpacing: 0, // removes the default gap that "separate" introduces
|
|
},
|
|
headerCellStyle: {
|
|
fontWeight: 650,
|
|
backgroundColor: darken(theme.palette.background.paper, 0.3),
|
|
textAlign: "center",
|
|
padding: 10
|
|
},
|
|
mobileHeaderCellStyle: {
|
|
fontWeight: 650,
|
|
backgroundColor: darken(theme.palette.background.paper, 0.3),
|
|
textAlign: "center",
|
|
padding: 2
|
|
},
|
|
targetCellStyle: {
|
|
textAlign: "center",
|
|
backgroundColor: theme.palette.background.paper
|
|
},
|
|
mobileTargetCellStyle: {
|
|
textAlign: "center",
|
|
backgroundColor: theme.palette.background.paper,
|
|
padding: 2
|
|
},
|
|
defaultCellStyle: {
|
|
padding: 2,
|
|
fontSize: 15,
|
|
textAlign: "center",
|
|
backgroundColor: darken(theme.palette.background.paper, 0.1),
|
|
borderTop: "1px solid " + darken(theme.palette.background.paper, 0.4),
|
|
borderBottom: "none"
|
|
},
|
|
colouredCellStyle: {
|
|
padding: 2,
|
|
borderTop: "1px solid " + darken(theme.palette.background.paper, 0.4),
|
|
// fontWeight: 650,
|
|
fontSize: 15,
|
|
textAlign: "center",
|
|
borderBottom: "none"
|
|
}
|
|
})
|
|
);
|
|
|
|
interface Props {
|
|
bin: Bin
|
|
updateBinCallback?: (bin: Bin) => void
|
|
}
|
|
|
|
interface BinLevel {
|
|
grainTemps: number[]
|
|
airTemps: number[]
|
|
grainMoistures: number[]
|
|
airMoistures: number[]
|
|
}
|
|
|
|
|
|
export default function BinTableView(props: Props) {
|
|
const classes = useStyles()
|
|
const {bin, updateBinCallback} = props
|
|
const [{user}] = useGlobalState()
|
|
const theme = useTheme()
|
|
const [enterTemp, setEnterTemp] = useState(false)
|
|
const [enterMoisture, setEnterMoisture] = useState(false)
|
|
const [tempTarget, setTempTarget] = useState(0)
|
|
const [tempEntry, setTempEntry] = useState("")
|
|
const [moistureEntry, setMoistureEntry] = useState("")
|
|
const [moistureTarget, setMoistureTarget] = useState(0)
|
|
const [openExpandedCables, setOpenExpandedCables] = useState(false)
|
|
const isMobile = useMobile()
|
|
const inBounds = green[600]
|
|
const caution = yellow[800]
|
|
const warning = red[700]
|
|
const headerRow1Ref = useRef<HTMLTableRowElement>(null)
|
|
const [firstRowHeight, setFirstRowHeight] = useState(0)
|
|
|
|
useEffect(() => {
|
|
if (headerRow1Ref.current) {
|
|
setFirstRowHeight(headerRow1Ref.current.getBoundingClientRect().height)
|
|
}
|
|
}, [enterTemp, enterMoisture]) // re-measure when the row height might change due to TextField appearing
|
|
|
|
useEffect(()=>{
|
|
let val = bin.targetTemp()
|
|
if(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){
|
|
val = bin.targetTemp() * (9/5) + 32
|
|
}
|
|
setTempTarget(val)
|
|
setTempEntry(val.toFixed(2))
|
|
setMoistureTarget(bin.targetMoisture())
|
|
setMoistureEntry(bin.targetMoisture().toFixed(2))
|
|
},[bin])
|
|
|
|
const binLevels = useMemo(() => {
|
|
const cables = bin.status.grainCables
|
|
let levels: BinLevel[] = []
|
|
//note for future self, the first item in the measurement arrays is the lowest node on the cable
|
|
//the zones we are creating here will match with the node number because most cables will either have the same number of nodes or only have a difference of 1 or 2
|
|
//example: cable 1 has 5 nodes cable 2 has 7 nodes, the first five nodes of the cables will match up and average together for each zone
|
|
//and then zones 6 and 7 will be made using only cable 2 because cable 1 doesn't have them
|
|
cables.forEach(cable => {
|
|
cable.celcius.forEach((c, index) => {
|
|
//remember index will be 0 based but the top node starts at 1 for the lowest cable
|
|
let inGrain = index < cable.topNode
|
|
if (levels[index]){
|
|
if(cable.excludedNodes.includes(index+1)) return
|
|
if(inGrain){
|
|
levels[index].grainTemps.push(c)
|
|
//need to check that the cable not only has the moisture mutation on it, but also that it reads humidity
|
|
//cables that dont read humidity return an array of 0 values so if the average of all of the values is 0 then it is a temp only cable
|
|
if(cable.moisture.length > 0 && avg(cable.moisture) > 0){
|
|
//based on all the other checks and the fact that the indexes between the array should match this should never be 0 but put a guard here just in case
|
|
//also note that we are only doing moisture and not humidity, it seems like users dont care about humidity
|
|
levels[index].grainMoistures.push(cable.moisture[index] ?? 0)
|
|
}
|
|
}else{
|
|
levels[index].airTemps.push(c)
|
|
//need to check that the cable not only has the moisture mutation on it, but also that it reads humidity
|
|
//cables that dont read humidity return an array of 0 values so if the average of all of the values is 0 then it is a temp only cable
|
|
if(cable.moisture.length > 0 && avg(cable.moisture) > 0){
|
|
//based on all the other checks and the fact that the indexes between the array should match this should never be 0 but put a guard here just in case
|
|
levels[index].airMoistures.push(cable.moisture[index] ?? 0)
|
|
}
|
|
}
|
|
}else{
|
|
let newlevel: BinLevel = {
|
|
grainTemps: [],
|
|
grainMoistures: [],
|
|
airTemps: [],
|
|
airMoistures: []
|
|
}
|
|
if(!cable.excludedNodes.includes(index)){
|
|
if(inGrain){
|
|
newlevel.grainTemps.push(c)
|
|
if(cable.moisture.length > 0 && avg(cable.moisture) > 0){
|
|
newlevel.grainMoistures.push(cable.moisture[index] ?? 0)
|
|
}
|
|
}else{
|
|
newlevel.airTemps.push(c)
|
|
if(cable.moisture.length > 0 && avg(cable.moisture) > 0){
|
|
newlevel.airMoistures.push(cable.moisture[index] ?? 0)
|
|
}
|
|
}
|
|
}
|
|
levels.push(newlevel)
|
|
}
|
|
|
|
})
|
|
})
|
|
//flip the levels so that the highest nodes are on the top
|
|
levels.reverse()
|
|
return levels
|
|
},[bin])
|
|
|
|
const levelDisplay = (level: BinLevel, lastRow?: boolean) => {
|
|
let tempDisplay = "--"
|
|
let moistureDisplay = "--"
|
|
let lvlTemp
|
|
let lvlMoisture
|
|
let tempColour: string = darken(theme.palette.background.paper, 0.1)
|
|
let moistureColour: string = darken(theme.palette.background.paper, 0.1)
|
|
|
|
//determine the temp
|
|
if(level.grainTemps.length > 0){
|
|
lvlTemp = avg(level.grainTemps)
|
|
//if the average temp is 5 degrees above the bins threshold
|
|
if(lvlTemp > (bin.targetTemp() + 10)){
|
|
tempColour = warning
|
|
}else if(lvlTemp > bin.targetTemp() + 5){
|
|
//if the average temp is greater than 5 degrees over
|
|
tempColour = caution
|
|
}else {
|
|
tempColour = inBounds
|
|
}
|
|
}else if (level.airTemps.length > 0){
|
|
lvlTemp = avg(level.airTemps)
|
|
}
|
|
if (lvlTemp){
|
|
if(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){
|
|
lvlTemp = celsiusToFahrenheit(lvlTemp)
|
|
}
|
|
tempDisplay = lvlTemp.toFixed(2)
|
|
}
|
|
|
|
//determine the moisture to show
|
|
if(level.grainMoistures.length > 0){
|
|
lvlMoisture = avg(level.grainMoistures)
|
|
if(lvlMoisture > (bin.targetMoisture() + 1.5)){
|
|
moistureColour = warning
|
|
}else if(lvlMoisture > bin.targetMoisture() + 0.5){
|
|
moistureColour = caution
|
|
}else {
|
|
moistureColour = inBounds
|
|
}
|
|
}else if (level.airMoistures.length > 0){
|
|
lvlMoisture = avg(level.airMoistures)
|
|
}
|
|
if(lvlMoisture){
|
|
moistureDisplay = lvlMoisture.toFixed(2)
|
|
}
|
|
return (
|
|
<React.Fragment>
|
|
<TableCell className={classes.colouredCellStyle} padding="none" sx={{backgroundColor: tempColour}}>{tempDisplay}</TableCell>
|
|
<TableCell className={classes.colouredCellStyle} padding="none" sx={{backgroundColor: moistureColour, borderBottomRightRadius: lastRow ? 8 : 0}}>{moistureDisplay}</TableCell>
|
|
</React.Fragment>
|
|
)
|
|
}
|
|
|
|
const update = () => {
|
|
let temp = tempTarget
|
|
if(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){
|
|
//convert to c
|
|
temp = (temp - 32) * (5 / 9);
|
|
|
|
}
|
|
let clone = cloneDeep(bin)
|
|
if(clone.settings.inventory && updateBinCallback){
|
|
clone.settings.inventory.targetTemperature = temp
|
|
clone.settings.inventory.targetMoisture = moistureTarget
|
|
updateBinCallback(clone)
|
|
}
|
|
}
|
|
|
|
const tempThreshDisplay = (hideUnit?: boolean) => {
|
|
if(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){
|
|
return tempTarget.toFixed(2) + (hideUnit ? "" : " °F")
|
|
}
|
|
return tempTarget.toFixed(2) + (hideUnit ? "" : " °C")
|
|
}
|
|
|
|
const LegendSwatch = ({ color }: { color: string }) => (
|
|
<Box sx={{ width: 40, height: 20, backgroundColor: color, borderRadius: 1, flexShrink: 0 }} />
|
|
)
|
|
|
|
|
|
const updateTempEntry = () => {
|
|
return (
|
|
<TextField
|
|
value={tempEntry}
|
|
onChange={e => {
|
|
let val = parseFloat(e.target.value)
|
|
if(!isNaN(val)){
|
|
setTempTarget(val)
|
|
}
|
|
setTempEntry(e.target.value)
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const updateMoistureEntry = () => {
|
|
return (
|
|
<TextField
|
|
value={moistureEntry}
|
|
onChange={e => {
|
|
let val = parseFloat(e.target.value)
|
|
if(!isNaN(val)){
|
|
setMoistureTarget(val)
|
|
}
|
|
setMoistureEntry(e.target.value)
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const expandedTableDialog = () => {
|
|
return (
|
|
<ResponsiveDialog open={openExpandedCables} onClose={()=>{setOpenExpandedCables(false)}}>
|
|
<BinTableExpanded
|
|
cables={bin.status.grainCables}
|
|
targetMoisture={bin.targetMoisture()}
|
|
targetTemp={bin.targetTemp()}
|
|
inBounds={inBounds}
|
|
caution={caution}
|
|
warning={warning}
|
|
/>
|
|
<DialogActions>
|
|
<Button onClick={()=> {setOpenExpandedCables(false)}}>
|
|
Close
|
|
</Button>
|
|
</DialogActions>
|
|
</ResponsiveDialog>
|
|
)
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
<Box padding={2} sx={{ display: "flex", flexDirection: "column", height: isMobile ? "88%" : "100%" }}>
|
|
{expandedTableDialog()}
|
|
{/* Level Table */}
|
|
<Box display="flex" justifyContent="space-between" alignItems="center">
|
|
<Typography fontWeight={650}>Grain Table</Typography>
|
|
<Box display="flex" alignItems="center" gap={1}>
|
|
<Button
|
|
variant="contained"
|
|
sx={{ background: theme.palette.background.default, color: theme.palette.text.primary}}
|
|
onClick={() => {
|
|
setOpenExpandedCables(true)
|
|
}}>
|
|
Show All Cables
|
|
</Button>
|
|
<Tooltip title="This table shows the bins levels using nodes that are in the grain averaged together. Data comes from the bins status.
|
|
Use the button to see all of the cables individually.">
|
|
<Info />
|
|
</Tooltip>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* this box is to make the whole table scrollable */}
|
|
<Box sx={{ overflowY: "auto", flex: 1, minHeight: 0 }}>
|
|
<Table className={classes.tableStyle}>
|
|
<TableHead>
|
|
<TableRow ref={headerRow1Ref}>
|
|
{/* "Targets" cell — hide on mobile when either field is being edited */}
|
|
{!(isMobile && (enterTemp || enterMoisture)) && (
|
|
<TableCell className={isMobile ? classes.mobileTargetCellStyle : classes.targetCellStyle} sx={{top: 0, position: "sticky", zIndex: 4, borderBottom: "none" }}>
|
|
<Box justifyContent={"center"} sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
<GppGood />
|
|
{!isMobile &&
|
|
<Typography fontSize={16} fontWeight={650}>
|
|
Targets
|
|
</Typography>
|
|
}
|
|
</Box>
|
|
</TableCell>
|
|
)}
|
|
|
|
{/* Temperature cell */}
|
|
{!(isMobile && enterMoisture) && (
|
|
<TableCell
|
|
className={isMobile ? classes.mobileTargetCellStyle : classes.targetCellStyle}
|
|
colSpan={isMobile && enterTemp ? 3 : 1}
|
|
sx={{ top: 0, position: "sticky", zIndex: 4, borderBottom: "none" }}
|
|
>
|
|
<Box justifyContent={"center"} sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
<TemperatureIcon heightWidth={isMobile ? 25 : 30} />
|
|
{enterTemp ? updateTempEntry() : (
|
|
<Typography fontSize={isMobile ? 12 : 16}>
|
|
{tempThreshDisplay(isMobile)}
|
|
</Typography>
|
|
)}
|
|
{enterTemp ? (
|
|
<IconButton onClick={() => { setEnterTemp(false); update(); }}>
|
|
<Save />
|
|
</IconButton>
|
|
) : (
|
|
<IconButton onClick={() => setEnterTemp(true)}>
|
|
<Edit />
|
|
</IconButton>
|
|
)}
|
|
</Box>
|
|
</TableCell>
|
|
)}
|
|
{/* Moisture cell — hide on mobile when temperature is being edited */}
|
|
{!(isMobile && enterTemp) && (
|
|
<TableCell
|
|
className={isMobile ? classes.mobileTargetCellStyle : classes.targetCellStyle}
|
|
colSpan={isMobile && enterMoisture ? 3 : 1}
|
|
sx={{ top: 0, position: "sticky", zIndex: 4, borderBottom: "none" }}
|
|
>
|
|
<Box justifyContent={"center"} sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
<HumidityIcon height={isMobile ? 25 : 30} width={20} />
|
|
{enterMoisture ? updateMoistureEntry() : (
|
|
<Typography fontSize={isMobile ? 12 : 16} sx={{ marginLeft: "10px" }}>
|
|
{bin.targetMoisture()}
|
|
</Typography>
|
|
)}
|
|
{enterMoisture ? (
|
|
<IconButton onClick={() => { setEnterMoisture(false); update(); }}>
|
|
<Save />
|
|
</IconButton>
|
|
) : (
|
|
<IconButton onClick={() => setEnterMoisture(true)}>
|
|
<Edit />
|
|
</IconButton>
|
|
)}
|
|
</Box>
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
<TableRow>
|
|
<TableCell
|
|
className={isMobile ? classes.mobileHeaderCellStyle : classes.headerCellStyle}
|
|
sx={{
|
|
top: firstRowHeight,
|
|
position: "sticky",
|
|
zIndex: 3,
|
|
borderBottom: "none",
|
|
borderTopLeftRadius: 8,
|
|
boxShadow: `-12px -12px 0px 4px ${theme.palette.background.paper}`
|
|
}}>
|
|
Level
|
|
</TableCell>
|
|
<TableCell
|
|
className={isMobile ? classes.mobileHeaderCellStyle : classes.headerCellStyle}
|
|
sx={{
|
|
top: firstRowHeight,
|
|
position: "sticky",
|
|
zIndex: 3,
|
|
borderBottom: "none"
|
|
}}>
|
|
{isMobile ? "T" : "Temperature"}{(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? " (°F)" : " (°C)")}
|
|
</TableCell>
|
|
<TableCell
|
|
className={isMobile ? classes.mobileHeaderCellStyle : classes.headerCellStyle}
|
|
sx={{
|
|
top: firstRowHeight,
|
|
position: "sticky",
|
|
zIndex: 3,
|
|
borderBottom: "none",
|
|
borderTopRightRadius: 8,
|
|
// fills the transparent corner area with the target row's background colour
|
|
boxShadow: `16px -16px 8px 12px ${theme.palette.background.paper}`
|
|
}}>
|
|
EMC (%)
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
|
|
<TableBody>
|
|
{binLevels.map((level, i) => (
|
|
<TableRow key={i}>
|
|
<TableCell sx={{borderBottomLeftRadius: (i === binLevels.length-1 ? 8 : 0)}} padding="none" className={classes.defaultCellStyle}>{binLevels.length - i}</TableCell>
|
|
{levelDisplay(level, i === binLevels.length-1)}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</Box>
|
|
{/* Table Legend */}
|
|
<Box display="flex" alignItems="center" justifyContent="space-between" marginTop={2} sx={{ flexShrink: 0 }}>
|
|
<Box display="flex" alignItems="center" gap={1}>
|
|
<LegendSwatch color={inBounds} />
|
|
<Typography sx={{fontWeight: 600, fontSize: 10}}>On target</Typography>
|
|
</Box>
|
|
<Box display="flex" alignItems="center" gap={1}>
|
|
<LegendSwatch color={caution} />
|
|
<Typography sx={{fontWeight: 600, fontSize: 10}}>{user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "~10 °F" : "+5 °C"} (0.5%) above</Typography>
|
|
</Box>
|
|
<Box display="flex" alignItems="center" gap={1}>
|
|
<LegendSwatch color={warning} />
|
|
<Typography sx={{fontWeight: 600, fontSize: 10}}>{user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "~20 °F" : "+10 °C"} (1.5%) above</Typography>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
} |