frontend/src/bin/binTableView.tsx

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