423 lines
No EOL
16 KiB
TypeScript
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>
|
|
)
|
|
} |