frontend/src/bin/bin3dVisualizer.tsx

423 lines
No EOL
16 KiB
TypeScript

import { Box, Checkbox, FormControl, MenuItem, List, ListItem, InputLabel, Select, Typography, Stack, FormControlLabel, useTheme } from "@mui/material";
import Bin3dView from "bin/3dView/Scene/Bin3dView";
import { Bin, Component, Device } from "models";
import { Controller } from "models/Controller";
import { pond, quack } from "protobuf-ts/pond";
import React, { useEffect } from "react";
import { useState } from "react";
import NodeControls from "./binSummary/components/nodeControls";
import { NodeData } from "bin/3dView/Data/BuildNodeData";
import { CableData } from "bin/3dView/Data/BuildCableData";
import ModeChangeDialog from "bin/conditioning/modeChangeDialog";
import { cloneDeep } from "lodash";
import { useMobile } from "hooks";
interface Props {
bin: Bin
// cables?: GrainCable[]
devices: Device[]
// fans?: Controller[]
// heaters?: Controller[]
permissions: pond.Permission[]
componentDevices: Map<string, number>
componentMap: Map<string, Component>
binPrefs?: Map<string, pond.BinComponentPreferences>
updateBinCallback?: (bin: Bin) => void
}
export default function bin3dVisualizer(props: Props){
const {bin, permissions, devices, componentDevices, componentMap, binPrefs, updateBinCallback} = props
const theme = useTheme()
const [showHeatmap, setShowHeatmap] = useState(true)
// const [showHotspots, setShowHotspots] = useState(false)
// const [showFill, setShowFill] = useState(false)
const [showTemp, setShowTemp] = useState(true)
const [showMoisture, setShowMoisture] = useState(false)
const [showMoistureHeatmap, setShowMoistureHeatmap] = useState(false)
const [binDisplay, setBinDisplay] = useState<string>("temp")
const [binMode, setBinMode] = useState<pond.BinMode>(pond.BinMode.BIN_MODE_NONE)
const [openModeChange, setOpenModeChange] = useState(false)
const [selectedCable, setSelectedCable] = useState<CableData | undefined>(undefined)
const [selectedNode, setSelectedNode] = useState<NodeData | undefined>(undefined)
const [selectedCableDevice, setSelectedCableDevice] = useState(Device.create()) //this is the device that the cable that has the node that was clicked on belongs to
const [filteredComponents, setFilteredComponents] = useState<Component[]>([]) //components that are filtered to only include ones on the same device
const [openNodeControls, setOpenNodeControls] = useState(false)
const [devMap, setDevMap] = useState<Map<number, Device>>(new Map())
const [modeChangeInProgress, setModeChangeInProgress] = useState(false)
const [showLabels, setShowLabels] = useState(true)
const isMobile = useMobile()
useEffect(()=>{
let newMap: Map<number, Device> = new Map()
devices.forEach(d => {
newMap.set(d.id(), d)
})
setDevMap(newMap)
},[devices])
useEffect(()=>{
setBinMode(bin.settings.mode)
},[bin])
const updateBin = () => {
if(updateBinCallback){
let clone = cloneDeep(bin)
clone.settings.mode = binMode
updateBinCallback(clone)
}
};
const binModeControl = () => {
return (
<Box>
<Typography sx={{fontWeight: 650, fontSize: isMobile ? 13 : 17}}>{bin.name()}</Typography>
<FormControl fullWidth>
<Select
value={binMode}
disabled={modeChangeInProgress}
sx={{
borderRadius: 1,
bgcolor: '#151E27',
'& .MuiSelect-select': {
py: isMobile ? 0.5 : 1.5,
fontSize: 13
},
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
'&:hover .MuiOutlinedInput-notchedOutline': { border: 'none' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' },
}}
onChange={event => {
setBinMode(event.target.value as pond.BinMode)
setOpenModeChange(true)
}}
>
<MenuItem value={pond.BinMode.BIN_MODE_NONE}>Select Mode..</MenuItem>
<MenuItem value={pond.BinMode.BIN_MODE_STORAGE}>Storage</MenuItem>
<MenuItem value={pond.BinMode.BIN_MODE_DRYING}>Drying</MenuItem>
<MenuItem value={pond.BinMode.BIN_MODE_HYDRATING}>Hydrating</MenuItem>
<MenuItem value={pond.BinMode.BIN_MODE_COOLDOWN}>Cooldown</MenuItem>
</Select>
</FormControl>
</Box>
)
}
const heatmapDisplay = () => {
return (
<Box>
<Typography noWrap sx={{fontWeight: 650, fontSize: isMobile ? 13 : 17}}>
Heatmap
</Typography>
<FormControl fullWidth>
<Select
value={binDisplay}
sx={{
borderRadius: 1,
bgcolor: '#151E27',
'& .MuiSelect-select': {
py: isMobile ? 0.5 : 1.5,
fontSize: 13
},
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
'&:hover .MuiOutlinedInput-notchedOutline': { border: 'none' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' },
}}
onChange={event => {
let selection = event.target.value
setBinDisplay(selection)
if (selection === "temp") {
setShowHeatmap(true)
setShowTemp(true)
setShowMoisture(false)
setShowMoistureHeatmap(false)
}
if (selection === "moisture") {
setShowHeatmap(false)
setShowTemp(false)
setShowMoisture(true)
setShowMoistureHeatmap(true)
}
}}
>
<MenuItem value={"temp"}>Temperature</MenuItem>
<MenuItem value={"moisture"}>Moisture</MenuItem>
</Select>
</FormControl>
{/* Checkbox moved below dropdown — no longer affects top alignment */}
<FormControlLabel
control={
<Checkbox
size="small"
checked={showLabels}
onChange={(e, checked) => setShowLabels(checked)}
/>
}
label={<Typography noWrap sx={{fontSize: 11}}>Show Values</Typography>}
sx={{ mt: 0.5, ml: '-9px' }}
/>
</Box>
)
}
// const controllerState = (controller: Controller) => {
// let state = false
// controller.status.lastGoodMeasurement.forEach(um => {
// if(um.type === quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN){
// if(um.values.length > 0){
// let readings = um.values
// if(readings[readings.length-1].values.length > 0){
// let nodes = readings[readings.length-1].values
// if (nodes[nodes.length -1] === 1){
// state = true
// }
// }
// }
// }
// })
// return state
// }
const controllerDisplay = () => {
if ((bin.status.fans && bin.status.fans.length > 0) || (bin.status.heaters && bin.status.heaters.length > 0)){
return (
<Box width="100%">
<Typography sx={{fontWeight: 650, fontSize: isMobile ? 13 : 17}}>
Controllers
</Typography>
{bin.status.fans && bin.status.fans.map(fan => {
// let isOn = controllerState(fan)
return (
<Box
key={fan.key}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
// bgcolor: 'rgba(255,255,255,0.05)',
bgcolor: '#151E27',
borderRadius: 1,
px: 2,
py: 1,
}}
>
<Typography sx={{fontSize: 13}}>{fan.name}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{fontWeight: 650, fontSize: 13}}>
{fan.state ? "ON" : "OFF"}
</Typography>
<Box sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: fan.state ? 'success.main' : 'error.main'
}} />
</Box>
</Box>
)})}
{bin.status.heaters && bin.status.heaters.map(heater => {
// let isOn = controllerState(heater)
return (
<Box
key={heater.key}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
// bgcolor: 'rgba(255,255,255,0.05)',
bgcolor: '#151E27',
borderRadius: 1,
px: 2,
py: 1,
}}
>
<Typography sx={{fontSize: 13}}>{heater.name}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{fontWeight: 650, fontSize: 13}}>
{heater.state ? "ON" : "OFF"}
</Typography>
<Box sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: heater.state ? 'success.main' : 'error.main'
}} />
</Box>
</Box>
)})}
</Box>
// loop through controllers displaying each one
)
}else{
return
}
}
const desktopHUD = () => {
return (
<Box position={"absolute"} top={20} left={25} height={"75%"} width={"25%"}>
<Stack direction={"column"} justifyContent={"space-between"} sx={{height: "100%", width: "100%"}}>
{binModeControl()}
{heatmapDisplay()}
{controllerDisplay()}
</Stack>
</Box>
)
}
const mobileHUD = () => {
return (
<React.Fragment>
{/* Top-left: Bin Name / Mode */}
<Box
position="absolute"
top={12}
left={12}
width="40%"
sx={{ pointerEvents: 'none' }}
>
<Box sx={{ pointerEvents: 'auto' }}>
{binModeControl()}
</Box>
</Box>
{/* Top-right: Heatmap Display */}
<Box
position="absolute"
top={12}
right={12}
width="40%"
sx={{ pointerEvents: 'none' }}
>
<Box sx={{ pointerEvents: 'auto' }}>
{heatmapDisplay()}
</Box>
</Box>
{/* Bottom-left: Controller Status */}
<Box
position="absolute"
bottom={12}
left={12}
width="45%"
sx={{ pointerEvents: 'none' }}
>
<Box sx={{ pointerEvents: 'auto' }}>
{controllerDisplay()}
</Box>
</Box>
</React.Fragment>
)
}
return (
<React.Fragment>
{(selectedCable && selectedNode) &&
<NodeControls
bin={bin}
cable={selectedCable}
node={selectedNode}
open={openNodeControls}
permissions={permissions}
device={selectedCableDevice}
filteredComponents={filteredComponents}
componentMap={componentMap}
onClose={() => {
setOpenNodeControls(false)
}}
/>
}
<ModeChangeDialog
binKey={bin.key()}
binMode={binMode}
grain={bin.settings.inventory?.grainType}
devices={devices}
open={openModeChange}
compDevMap={componentDevices}
preferences={binPrefs}
onClose={(refresh) => {
setOpenModeChange(false)
if(refresh) {
updateBin()
}else{
setBinMode(bin.settings.mode)
};
}}
defaultTargetMoisture={bin.settings.inventory?.initialMoisture}
// defaultOutdoorTemp={outdoorTemp}
// defaultOutdoorHumidity={outdoorHumidity}
// changeTargetMoisture={newMoisture => {
// setTargetMoisture(newMoisture)
// }}
// changeOutdoorTemp={newTemp => {
// setOutdoorTemp(newTemp)
// }}
// changeOutdoorHumidity={newHumidity => {
// setOutdoorHumidity(newHumidity)
// }}
startChange={() => {
setModeChangeInProgress(true)
}}
changeComplete={() => {
setModeChangeInProgress(false)
}}
/>
<Box position={"relative"} height={600}>
<Bin3dView
height={isMobile ? 600 : undefined}
width={isMobile ? window.innerWidth : undefined}
bin={bin}
fillPercent={bin.fillPercent()/100}//expects a decimal between 0 and 1
nodeClick={(node, cable) => {
//this will be how to control the dialog to update the top nodes and node exclusion etc (make new component called NodeControls for this)
//use the cable data to get the device it belongs to
let d = devMap.get(cable.grainCable.device)
if(d !== undefined){
setSelectedNode(node)
setSelectedCable(cable)
setSelectedCableDevice(d)
//filter the controls so that only components on THIS device are options for new interactions, a side note of this using the components that are in the bins status
//is that it will indirectly also filter by bin as well so only components that are both on the bin and the same device as the cable will appear
let filtered: Component[] = []
if(bin.status.fans){
bin.status.fans.forEach((f) => {
if(componentDevices.get(f.key) === d.id()){
let comp = componentMap.get(f.key)
if(comp){
filtered.push(comp)
}
}
})
}
if(bin.status.heaters){
bin.status.heaters.forEach((h) => {
if(componentDevices.get(h.key) === d.id()){
let comp = componentMap.get(h.key)
if(comp){
filtered.push(comp)
}
}
})
}
//also make sure to push the cable into the list of filtered components for the source
let c = componentMap.get(cable.grainCable.key)
if(c !== undefined){
filtered.push(c)
}
setFilteredComponents(filtered)
setOpenNodeControls(true)
}
}}
scale={100}
showTempHeatmap={showHeatmap}
showMoistureHeatmap={showMoistureHeatmap}
showTemp={showTemp && showLabels}
showMoisture={showMoisture && showLabels}
xOffset={isMobile ? undefined : -120}
/>
{isMobile ? mobileHUD() : desktopHUD()}
</Box>
</React.Fragment>
)
}