frontend/src/bin/binTableView.tsx

453 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 } 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.2),
fontSize: 15,
textAlign: "center",
borderTop: "1px solid black",
borderLeft: "1px solid black",
borderBottom: "1px solid black",
"&:last-child": {
borderRight: "1px solid black",
}
},
cellStyle: {
padding: 2,
fontWeight: 650,
fontSize: 17,
textAlign: "center",
border: "1px solid black",
}
})
);
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 [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 mobileView = 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) => {
let tempDisplay = "--"
let moistureDisplay = "--"
let lvlTemp
let lvlMoisture
let tempColour: string = ""
let moistureColour: string = ""
//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() + 5)){
tempColour = warning
}else if(lvlTemp > bin.targetTemp()){
//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) + (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? " °F" : " °C")
}
//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()){
//if the average temp is greater than 5 degrees over
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 padding="none" className={classes.cellStyle} sx={{backgroundColor: tempColour}}>{tempDisplay}</TableCell>
<TableCell padding="none" className={classes.cellStyle} sx={{backgroundColor: moistureColour}}>{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 = () => {
if(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){
return tempTarget.toFixed(2) + " °F"
}
return tempTarget.toFixed(2) + " °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: "100%" }}>
{expandedTableDialog()}
{/* Level Table */}
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography>Grain Table</Typography>
<Box display="flex" alignItems="center" gap={1}>
<Button
variant="contained"
onClick={() => {
setOpenExpandedCables(true)
}}>
Show All Cables
</Button>
<Tooltip title="testing">
<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}>
<TableCell className={classes.headerCellStyle} sx={{ top: 0, position: "sticky", zIndex: 3 }}>
<Box justifyContent={"center"} sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<GppGood />
<Typography fontWeight={650}>
Targets
</Typography>
</Box>
</TableCell>
<TableCell className={classes.headerCellStyle} sx={{ top: 0, position: "sticky", zIndex: 3 }}>
<Box justifyContent={"center"} sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TemperatureIcon heightWidth={30}/>
{enterTemp ?
updateTempEntry()
:
<Typography fontWeight={650}>
{tempThreshDisplay()}
</Typography>
}
{enterTemp ?
<IconButton
onClick={() => {
setEnterTemp(false)
//and update the bin with the new temp target
update()
}}
>
<Save />
</IconButton>
:
<IconButton
onClick={() => {
setEnterTemp(true)
}}>
<Edit />
</IconButton>
}
</Box>
</TableCell>
<TableCell className={classes.headerCellStyle} sx={{ top: 0, position: "sticky", zIndex: 3 }}>
<Box justifyContent={"center"} sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<HumidityIcon height={30} width={20}/>
{enterMoisture ?
updateMoistureEntry()
:
<Typography fontWeight={650} sx={{marginLeft: "10px"}}>
{bin.targetMoisture()}%
</Typography>
}
{enterMoisture ?
<IconButton
onClick={() => {
setEnterMoisture(false)
//and update the bin with the new temp target
update()
}}
>
<Save />
</IconButton>
:
<IconButton
onClick={() => {
setEnterMoisture(true)
}}>
<Edit />
</IconButton>
}
</Box>
</TableCell>
</TableRow>
<TableRow>
<TableCell className={classes.headerCellStyle} sx={{ top: firstRowHeight, position: "sticky", zIndex: 3 }}>Level</TableCell>
<TableCell className={classes.headerCellStyle} sx={{ top: firstRowHeight, position: "sticky", zIndex: 3 }}>Temperature</TableCell>
<TableCell className={classes.headerCellStyle} sx={{ top: firstRowHeight, position: "sticky", zIndex: 3 }}>Moisture</TableCell>
</TableRow>
</TableHead>
<TableBody>
{binLevels.map((level, i) => (
<TableRow key={i}>
<TableCell padding="none" className={classes.cellStyle}>{binLevels.length - i}</TableCell>
{levelDisplay(level)}
</TableRow>
))}
<TableRow>
<TableCell>TESTING</TableCell>
<TableCell>SCROLLING</TableCell>
<TableCell>ROWS</TableCell>
</TableRow>
<TableRow>
<TableCell>TESTING</TableCell>
<TableCell>SCROLLING</TableCell>
<TableCell>ROWS</TableCell>
</TableRow>
<TableRow>
<TableCell>TESTING</TableCell>
<TableCell>SCROLLING</TableCell>
<TableCell>ROWS</TableCell>
</TableRow>
<TableRow>
<TableCell>TESTING</TableCell>
<TableCell>SCROLLING</TableCell>
<TableCell>ROWS</TableCell>
</TableRow>
<TableRow>
<TableCell>TESTING</TableCell>
<TableCell>SCROLLING</TableCell>
<TableCell>ROWS</TableCell>
</TableRow>
<TableRow>
<TableCell>TESTING</TableCell>
<TableCell>SCROLLING</TableCell>
<TableCell>ROWS</TableCell>
</TableRow>
<TableRow>
<TableCell>TESTING</TableCell>
<TableCell>SCROLLING</TableCell>
<TableCell>ROWS</TableCell>
</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 or below 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"} above target</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"} above target</Typography>
</Box>
</Box>
</Box>
)
}