started work on the bin summary part of the new bin page, built the framework and the new 3d visualizer, still doing some work on the camera to create an overlay with a reset button

This commit is contained in:
csawatzky 2026-05-01 13:11:12 -06:00
parent b3cf5b5e83
commit 4b2c67dfd9
11 changed files with 974 additions and 220 deletions

View file

@ -0,0 +1,90 @@
import { useThree } from "@react-three/fiber";
import { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
interface Props {
showResetButton?: boolean;
onReset?: () => void;
}
/**
* Renders camera control UI buttons as a DOM overlay on top of the R3F canvas.
* Must be placed inside a <Canvas> so it has access to useThree().
*
* Uses a portal into a div that is absolutely positioned over the canvas element,
* so the buttons sit in 2D screen space rather than 3D world space.
*/
export default function CameraOverlay({ showResetButton, onReset }: Props) {
const { gl } = useThree();
const [container, setContainer] = useState<HTMLDivElement | null>(null);
useEffect(() => {
const canvas = gl.domElement;
const parent = canvas.parentElement;
if (!parent) return;
// Ensure the parent is positioned so absolute children are relative to it
const parentPosition = getComputedStyle(parent).position;
if (parentPosition === "static") {
parent.style.position = "relative";
}
const div = document.createElement("div");
div.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
`;
parent.appendChild(div);
setContainer(div);
return () => {
parent.removeChild(div);
};
}, [gl]);
if (!container) return null;
return ReactDOM.createPortal(
<div style={{
position: "absolute",
bottom: 12,
right: 12,
display: "flex",
flexDirection: "column",
gap: 8,
pointerEvents: "auto",
}}>
{showResetButton && (
<button
onClick={onReset}
title="Reset camera"
style={{
width: 36,
height: 36,
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(0,0,0,0.45)",
color: "white",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 16,
backdropFilter: "blur(4px)",
transition: "background 0.15s",
}}
onMouseEnter={e => (e.currentTarget.style.background = "rgba(0,0,0,0.7)")}
onMouseLeave={e => (e.currentTarget.style.background = "rgba(0,0,0,0.45)")}
>
</button>
)}
</div>,
container
);
}

View file

@ -1,6 +1,7 @@
import React from "react";
import { useThree } from "@react-three/fiber"; import { useThree } from "@react-three/fiber";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Plane, Raycaster, Vector2, Vector3 } from "three"; import { Vector2, Vector3 } from "three";
interface CameraTarget { interface CameraTarget {
x: number; x: number;
@ -21,14 +22,37 @@ interface Props {
* @default Math.PI - 0.01 * @default Math.PI - 0.01
*/ */
maxPhi?: number; maxPhi?: number;
/**
* Starting camera distance from the target.
* Pass a value computed from the bin dimensions so the whole bin
* fits in frame on first render. If omitted defaults to 10.
*/
initialRadius?: number;
/**
* Maximum zoom-out distance. Defaults to 30 but should be set
* larger when initialRadius is large.
*/
maxRadius?: number;
/**
* Called when the camera is reset to its initial position.
* OrbitCameraControls manages the reset internally use this to
* trigger it from outside (e.g. from CameraOverlay's reset button).
* Wire it by passing a ref setter: onReset={fn => resetFn.current = fn}
*/
onReset?: (resetFn: () => void) => void;
} }
export default function OrbitCameraControls(props: Props) { export default function OrbitCameraControls(props: Props) {
const { target, clampVerticalRotation, minPhi = 0.01, maxPhi = Math.PI - 0.01 } = props; const {
target,
clampVerticalRotation,
minPhi = 0.01,
maxPhi = Math.PI - 0.01,
onReset,
} = props;
const { camera, gl } = useThree(); const { camera, gl } = useThree();
// const panPlane = useRef(new Plane());
// const panStartPoint = useRef(new Vector3());
// const raycaster = useRef(new Raycaster());
const mouse = useRef(new Vector2()); const mouse = useRef(new Vector2());
const targetRef = useRef({ const targetRef = useRef({
x: target?.x ?? 0, x: target?.x ?? 0,
@ -43,14 +67,15 @@ export default function OrbitCameraControls(props: Props) {
// Spherical coords // Spherical coords
const spherical = useRef({ const spherical = useRef({
radius: 10, radius: props.initialRadius ?? 10,
theta: 0, // horizontal theta: 0, // horizontal angle
phi: Math.PI / 2, // vertical phi: Math.PI / 2, // vertical angle
}); });
useEffect(() => { useEffect(() => {
const canvas = gl.domElement; const canvas = gl.domElement;
canvas.addEventListener("contextmenu", e => e.preventDefault()); canvas.addEventListener("contextmenu", e => e.preventDefault());
const updateCamera = () => { const updateCamera = () => {
const { radius, theta, phi } = spherical.current; const { radius, theta, phi } = spherical.current;
@ -70,66 +95,50 @@ export default function OrbitCameraControls(props: Props) {
); );
}; };
// Hand the reset function to the caller so they can trigger it
// (e.g. from a button in CameraOverlay) without needing an external ref.
if (onReset) {
onReset(() => {
spherical.current.radius = props.initialRadius ?? 10;
spherical.current.theta = 0;
spherical.current.phi = Math.PI / 2;
targetRef.current = {
x: target?.x ?? 0,
y: target?.y ?? 0,
z: target?.z ?? 0,
};
updateCamera();
});
}
updateCamera(); updateCamera();
const handleMouseDown = (e: MouseEvent) => { const handleMouseDown = (e: MouseEvent) => {
if (e.target !== canvas) return; if (e.target !== canvas) return;
lastPos.current = { x: e.clientX, y: e.clientY }; lastPos.current = { x: e.clientX, y: e.clientY };
panCameraPosition.current.copy(camera.position); panCameraPosition.current.copy(camera.position);
// Left click = rotate
if (e.button === 0) { if (e.button === 0) {
isDragging.current = true; isDragging.current = true;
isPanning.current = false; isPanning.current = false;
} }
// Right click = pan
if (e.button === 2) { if (e.button === 2) {
isPanning.current = true; isPanning.current = true;
isDragging.current = false; isDragging.current = false;
// plane facing camera through target
const camDir = new Vector3();
camera.getWorldDirection(camDir);
// panPlane.current.setFromNormalAndCoplanarPoint(
// camDir,
// new Vector3(
// targetRef.current.x,
// targetRef.current.y,
// targetRef.current.z
// )
// );
// const rect = canvas.getBoundingClientRect();
// raycaster.current.setFromCamera(
// new Vector2(
// ((e.clientX - rect.left) / rect.width) * 2 - 1,
// -((e.clientY - rect.top) / rect.height) * 2 + 1
// ),
// camera
// );
// raycaster.current.ray.intersectPlane(
// panPlane.current,
// panStartPoint.current
// );
} }
}; };
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - lastPos.current.x; const deltaX = e.clientX - lastPos.current.x;
const deltaY = e.clientY - lastPos.current.y; const deltaY = e.clientY - lastPos.current.y;
// ROTATE // ROTATE — left drag
if (isDragging.current) { if (isDragging.current) {
spherical.current.theta -= deltaX * 0.005; spherical.current.theta -= deltaX * 0.005;
spherical.current.phi -= deltaY * 0.005; spherical.current.phi -= deltaY * 0.005;
if (clampVerticalRotation) { if (clampVerticalRotation) {
spherical.current.phi = Math.max( spherical.current.phi = Math.max(
minPhi, minPhi,
@ -137,72 +146,50 @@ export default function OrbitCameraControls(props: Props) {
); );
} }
} }
// PAN // PAN — right drag
// if (isPanning.current) { // Uses screen-space axes derived from the camera so panning is
// const rect = canvas.getBoundingClientRect(); // correct at any angle including top-down.
// mouse.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
// mouse.current.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
// const currentPoint = new Vector3();
// const tempCamera = camera.clone();
// tempCamera.position.copy(panCameraPosition.current);
// tempCamera.updateMatrixWorld();
// raycaster.current.setFromCamera(mouse.current, tempCamera);
// raycaster.current.ray.intersectPlane(panPlane.current, currentPoint);
// const delta = new Vector3().subVectors(
// panStartPoint.current,
// currentPoint
// );
// targetRef.current.x += delta.x;
// targetRef.current.y += delta.y;
// targetRef.current.z += delta.z;
// panStartPoint.current.copy(currentPoint);
// }
if (isPanning.current) { if (isPanning.current) {
const deltaX = e.clientX - lastPos.current.x;
const deltaY = e.clientY - lastPos.current.y;
// distance-based scaling (important for consistent feel)
const distance = spherical.current.radius; const distance = spherical.current.radius;
const panSpeed = 0.002 * distance; const panSpeed = 0.002 * distance;
const offset = new Vector3(); const forward = new Vector3();
camera.getWorldDirection(forward);
// get camera basis vectors
// Right vector — perpendicular to forward in the horizontal plane
const right = new Vector3(); const right = new Vector3();
const up = new Vector3(); right.crossVectors(forward, new Vector3(0, 1, 0)).normalize();
camera.getWorldDirection(offset); // forward // Degenerate guard: when camera is nearly straight up or down,
right.crossVectors(offset, camera.up).normalize(); // right // fall back to camera.up
up.copy(camera.up).normalize(); if (right.lengthSq() < 0.001) {
right.crossVectors(forward, camera.up).normalize();
// apply movement }
// screenUp — perpendicular to both forward and right.
// This is the actual "up" direction on screen regardless of
// camera tilt, avoiding the zoom-while-panning bug.
const screenUp = new Vector3();
screenUp.crossVectors(right, forward).normalize();
right.multiplyScalar(-deltaX * panSpeed); right.multiplyScalar(-deltaX * panSpeed);
up.multiplyScalar(deltaY * panSpeed); screenUp.multiplyScalar(deltaY * panSpeed);
const pan = new Vector3().addVectors(right, up); const pan = new Vector3().addVectors(right, screenUp);
targetRef.current.x += pan.x; targetRef.current.x += pan.x;
targetRef.current.y += pan.y; targetRef.current.y += pan.y;
targetRef.current.z += pan.z; targetRef.current.z += pan.z;
} }
lastPos.current = { x: e.clientX, y: e.clientY }; lastPos.current = { x: e.clientX, y: e.clientY };
updateCamera(); updateCamera();
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
isDragging.current = false; isDragging.current = false;
isPanning.current = false; isPanning.current = false;
}; };
const handleWheel = (e: WheelEvent) => { const handleWheel = (e: WheelEvent) => {
@ -211,11 +198,9 @@ export default function OrbitCameraControls(props: Props) {
e.preventDefault(); e.preventDefault();
spherical.current.radius += e.deltaY * 0.01; spherical.current.radius += e.deltaY * 0.01;
spherical.current.radius = Math.max(
// Clamp zoom
spherical.current.radius = Math.max(
3, 3,
Math.min(30, spherical.current.radius) Math.min(props.maxRadius ?? 30, spherical.current.radius)
); );
updateCamera(); updateCamera();
@ -223,16 +208,14 @@ export default function OrbitCameraControls(props: Props) {
window.addEventListener("mousedown", handleMouseDown); window.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp); window.addEventListener("mouseup", handleMouseUp);
canvas.addEventListener("wheel", handleWheel, { passive: false });
canvas.addEventListener("wheel", handleWheel, { passive: false });
return () => { return () => {
window.removeEventListener("mousedown", handleMouseDown); window.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp); window.removeEventListener("mouseup", handleMouseUp);
canvas.removeEventListener("wheel", handleWheel);
canvas.removeEventListener("wheel", handleWheel);
}; };
}, [camera, gl, target, clampVerticalRotation, minPhi, maxPhi]); }, [camera, gl, target, clampVerticalRotation, minPhi, maxPhi]);

View file

@ -1,5 +1,6 @@
import React from "react";
import OrbitCameraControls from "3dModels/CameraControls/OrbitCameraControls"; import OrbitCameraControls from "3dModels/CameraControls/OrbitCameraControls";
import CameraOverlay from "3dModels/CameraControls/CameraOverlay";
import { Canvas } from "@react-three/fiber"; import { Canvas } from "@react-three/fiber";
import { Bin } from "models"; import { Bin } from "models";
import BinShell from "../BinParts/BinShell"; import BinShell from "../BinParts/BinShell";
@ -13,28 +14,47 @@ import { pond } from "protobuf-ts/pond";
import GrainCableFill from "../Systems/Inventory/GrainCableFill"; import GrainCableFill from "../Systems/Inventory/GrainCableFill";
import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; import TempHeatMap from "../Systems/Heatmap/TempHeatMap";
import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; import NodePointCloud from "../Systems/Heatmap/NodePointCloud";
interface Props { interface Props {
bin: Bin bin: Bin
scale: number scale: number
fillPercent?: number fillPercent?: number
nodeClick?: (node: NodeData, cable: CableData) => void nodeClick?: (node: NodeData, cable: CableData) => void
showGrain?: boolean showGrain?: boolean
showHeatmap?: boolean showTempHeatmap?: boolean
showMoistureHeatmap?: boolean
showHotspots?: boolean showHotspots?: boolean
/**
* When true renders a reset camera button overlaid on the 3D view.
*/
showResetButton?: boolean
showTemp?: boolean
showMoisture?: boolean
} }
export default function Bin3dView(props: Props) { export default function Bin3dView(props: Props) {
const { bin, scale = 100, fillPercent, nodeClick, showHeatmap, showGrain, showHotspots } = props const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture } = props
const binCenter = useMemo(() => new Vector3(0, 0, 0), []); const binCenter = useMemo(() => new Vector3(0, 0, 0), []);
// Compute the initial camera radius so the full bin fits in frame.
// Uses the perspective camera FOV (default 75°) with a padding factor.
// Both the diameter and total height are considered so neither is clipped.
const initialCameraRadius = useMemo(() => {
const totalHeight = (bin.sidewallHeight() + bin.roofHeight() + (bin.hopperHeight() ?? 0)) / scale;
const diameter = bin.diameter() / scale;
const largestDim = Math.max(totalHeight, diameter);
const fovRad = (75 * Math.PI) / 180;
const padding = 1.3; // 30% breathing room
return (largestDim / (2 * Math.tan(fovRad / 2))) * padding;
}, [bin, scale]);
const cableData = useMemo(() => BuildCableData(bin), [bin]); const cableData = useMemo(() => BuildCableData(bin), [bin]);
const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]);
const isCableInventory = const isCableInventory =
bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC || bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC ||
bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_CABLE; bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_CABLE;
// For cable inventory, TempHeatMap derives the wavy surface directly from // For cable inventory, TempHeatMap derives the wavy surface directly from
// the top nodes in nodeData — no scalar needed. // the top nodes in nodeData — no scalar needed.
// //
@ -42,21 +62,24 @@ export default function Bin3dView(props: Props) {
// using the same volume math as GrainFillFlat and pass it as flatMaxY. // using the same volume math as GrainFillFlat and pass it as flatMaxY.
const flatMaxY = useMemo(() => { const flatMaxY = useMemo(() => {
if (isCableInventory || fillPercent === undefined) return undefined; if (isCableInventory || fillPercent === undefined) return undefined;
const radius = bin.diameter() / 2; const radius = bin.diameter() / 2;
const sidewallH = bin.sidewallHeight(); const sidewallH = bin.sidewallHeight();
const hopperH = bin.hopperHeight() ?? 0; const hopperH = bin.hopperHeight() ?? 0;
const cylVolume = Math.PI * radius * radius * sidewallH; const cylVolume = Math.PI * radius * radius * sidewallH;
const hopperVol = hopperH > 0 ? (1 / 3) * Math.PI * radius * radius * hopperH : 0; const hopperVol = hopperH > 0 ? (1 / 3) * Math.PI * radius * radius * hopperH : 0;
const filled = (cylVolume + hopperVol) * fillPercent; const filled = (cylVolume + hopperVol) * fillPercent;
const cylinderFillH = hopperH > 0 && filled <= hopperVol const cylinderFillH = hopperH > 0 && filled <= hopperVol
? 0 ? 0
: Math.max(0, (filled - hopperVol) / (Math.PI * radius * radius)); : Math.max(0, (filled - hopperVol) / (Math.PI * radius * radius));
return -sidewallH / 2 + cylinderFillH; return -sidewallH / 2 + cylinderFillH;
}, [isCableInventory, fillPercent, bin]); }, [isCableInventory, fillPercent, bin]);
// Internal ref — wired to OrbitCameraControls and called by CameraOverlay
const resetCameraFn = React.useRef<(() => void) | null>(null);
const grainInventory = () => { const grainInventory = () => {
if (isCableInventory) { if (isCableInventory) {
return ( return (
@ -81,7 +104,7 @@ export default function Bin3dView(props: Props) {
) )
} }
} }
return ( return (
<Canvas> <Canvas>
<group scale={[1 / scale, 1 / scale, 1 / scale]}> <group scale={[1 / scale, 1 / scale, 1 / scale]}>
@ -97,10 +120,12 @@ export default function Bin3dView(props: Props) {
hopperHeight={bin.hopperHeight()} hopperHeight={bin.hopperHeight()}
renderOrder={4} renderOrder={4}
/> />
{showGrain && grainInventory()} {showGrain && grainInventory()}
<BinCables <BinCables
displayTemp={showTemp}
displayMoisture={showMoisture}
cableData={cableData} cableData={cableData}
nodeData={nodeData} nodeData={nodeData}
bin={bin} bin={bin}
@ -110,24 +135,34 @@ export default function Bin3dView(props: Props) {
}} }}
renderOrder={1} renderOrder={1}
/> />
{showHotspots && <NodePointCloud bin={bin} nodes={nodeData} />} {showHotspots && <NodePointCloud bin={bin} nodes={nodeData} />}
{showHeatmap && ( {showTempHeatmap && (
<TempHeatMap <TempHeatMap
bin={bin} bin={bin}
nodes={nodeData} nodes={nodeData}
flatMaxY={flatMaxY} flatMaxY={flatMaxY}
/> />
)} )}
</group> </group>
<ambientLight intensity={0.2} color={"white"} /> <ambientLight intensity={0.2} color={"white"} />
<directionalLight intensity={0.2} color={"white"} position={[0, 0, 5]} /> <directionalLight intensity={0.2} color={"white"} position={[0, 0, 5]} />
<directionalLight intensity={0.2} color={"white"} position={[0, 0, -5]} /> <directionalLight intensity={0.2} color={"white"} position={[0, 0, -5]} />
<directionalLight intensity={0.2} color={"white"} position={[5, 0, 0]} /> <directionalLight intensity={0.2} color={"white"} position={[5, 0, 0]} />
<directionalLight intensity={0.2} color={"white"} position={[-5, 0, 0]} /> <directionalLight intensity={0.2} color={"white"} position={[-5, 0, 0]} />
<OrbitCameraControls clampVerticalRotation /> <OrbitCameraControls
clampVerticalRotation
initialRadius={initialCameraRadius}
maxRadius={initialCameraRadius * 2}
onReset={fn => { resetCameraFn.current = fn; }}
/>
{/* <CameraOverlay
showResetButton={showResetButton}
onReset={() => resetCameraFn.current?.()}
/> */}
</Canvas> </Canvas>
) )
} }

View file

@ -11,11 +11,13 @@ interface Props {
bin: Bin bin: Bin
binCenter: Vector3, binCenter: Vector3,
renderOrder?: number, renderOrder?: number,
displayTemp?: boolean,
displayMoisture?: boolean,
onNodeClick?: (node: NodeData, cable: CableData) => void onNodeClick?: (node: NodeData, cable: CableData) => void
} }
export default function BinCables(props: Props){ export default function BinCables(props: Props){
const {bin, binCenter, cableData, nodeData, onNodeClick, renderOrder} = props const {bin, binCenter, cableData, nodeData, onNodeClick, renderOrder, displayTemp, displayMoisture} = props
return ( return (
<React.Fragment> <React.Fragment>
{cableData.map((cable, i) => { {cableData.map((cable, i) => {
@ -28,6 +30,8 @@ export default function BinCables(props: Props){
nodes={cableNodes} nodes={cableNodes}
bin={bin} bin={bin}
binCenter={binCenter} binCenter={binCenter}
displayTemp={displayTemp}
displayMoisture={displayMoisture}
onNodeClick={(node) => { onNodeClick={(node) => {
if(onNodeClick){ if(onNodeClick){
onNodeClick(node, cable) onNodeClick(node, cable)

View file

@ -17,36 +17,39 @@ interface Props {
lowerThreshold: number lowerThreshold: number
upperThreshold: number, upperThreshold: number,
renderOrder?: number, renderOrder?: number,
displayTemp?: boolean,
displayMoisture?: boolean,
onNodeClick?: (node: NodeData) => void onNodeClick?: (node: NodeData) => void
} }
export default function CableNode(props: Props) { export default function CableNode(props: Props) {
const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick, renderOrder } = props const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick, renderOrder, displayTemp, displayMoisture } = props
const { camera } = useThree(); const { camera } = useThree();
const [{ user }] = useGlobalState(); const [{ user }] = useGlobalState();
const [showLabel, setShowLabel] = useState(false); // const [showLabel, setShowLabel] = useState(false);
const labelGroupRef = React.useRef<Group>(null); const labelGroupRef = React.useRef<Group>(null);
const ROW_HEIGHT = 35; const ROW_HEIGHT = 35;
const PADDING = 20; const PADDING = 20;
useFrame(() => { //this can be used to control label visibility through the cameras zoom level
const distance = camera.position.distanceTo(binCenter) // useFrame(() => {
// const distance = camera.position.distanceTo(binCenter)
const SHOW_DISTANCE = 15; // const SHOW_DISTANCE = 15;
const HIDE_DISTANCE = 16; // const HIDE_DISTANCE = 16;
if (!showLabel && distance < SHOW_DISTANCE) { // if (!showLabel && distance < SHOW_DISTANCE) {
setShowLabel(true); // setShowLabel(true);
} else if (showLabel && distance > HIDE_DISTANCE) { // } else if (showLabel && distance > HIDE_DISTANCE) {
setShowLabel(false); // setShowLabel(false);
} // }
const scale = distance * 0.07; // const scale = distance * 0.07;
if (labelGroupRef.current) { // if (labelGroupRef.current) {
labelGroupRef.current.scale.set(scale, scale, 1); // labelGroupRef.current.scale.set(scale, scale, 1);
} // }
}); // });
const tempDisplay = useMemo(() => { const tempDisplay = useMemo(() => {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
@ -56,36 +59,47 @@ export default function CableNode(props: Props) {
}, [node.celcius, user]); }, [node.celcius, user]);
const rows = useMemo(() => { const rows = useMemo(() => {
const r: { text: string; color: string }[] = []; const anyToggleOn = displayTemp || displayMoisture;
if (node.excluded) { if (node.excluded) {
r.push({ text: "Excluded", color: "red" }); if (!anyToggleOn) return [];
return r; return [{ text: "Excluded", color: "red" }];
} }
if (node.topNode) { const r: { text: string; color: string }[] = [];
r.push({ text: "Top Node", color: "yellow" });
} // TEMP
if (displayTemp && node.celcius !== 0) {
r.push({
text: tempDisplay,
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour()
});
if (node.moisture) {
r.push({ r.push({
text: `${node.moisture.toFixed(2)}% EMC`, text: tempDisplay,
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour() color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour()
});
} else if (node.humidity) {
r.push({
text: `${node.humidity.toFixed(2)}%`,
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour()
}); });
} }
// MOISTURE
if (displayMoisture) {
if (node.moisture && node.moisture > 0) {
r.push({
text: `${node.moisture.toFixed(2)}% EMC`,
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour()
});
} else if (node.humidity && node.humidity > 0) {
r.push({
text: `${node.humidity.toFixed(2)}%`,
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour()
});
}
}
const hasData = r.length > 0;
// Only show top node if there's actual data
if (node.topNode && hasData) {
r.unshift({ text: "Top Node", color: "yellow" });
}
return r; return r;
}, [node, tempDisplay]); }, [node, tempDisplay, displayTemp, displayMoisture]);
const geometry = React.useMemo(() => ({ const geometry = React.useMemo(() => ({
radius: node.radius, radius: node.radius,
@ -126,6 +140,8 @@ export default function CableNode(props: Props) {
onNodeClick?.(node); onNodeClick?.(node);
}; };
const showLabel = rows.length > 0;
return ( return (
<React.Fragment> <React.Fragment>
{(!node.inGrain || !showLabel) && {(!node.inGrain || !showLabel) &&

View file

@ -12,10 +12,12 @@ interface Props {
bin: Bin bin: Bin
binCenter: Vector3, binCenter: Vector3,
renderOrder?: number, renderOrder?: number,
displayTemp?: boolean,
displayMoisture?: boolean
onNodeClick?: (node: NodeData) => void onNodeClick?: (node: NodeData) => void
} }
export default function GrainCable(props: Props) { export default function GrainCable(props: Props) {
const {cable, nodes, bin, binCenter, onNodeClick, renderOrder} = props const {cable, nodes, bin, binCenter, onNodeClick, renderOrder, displayTemp, displayMoisture} = props
const calculateHeight = () => { const calculateHeight = () => {
return cable.topPosition.distanceTo(cable.bottomPosition) return cable.topPosition.distanceTo(cable.bottomPosition)
@ -42,7 +44,7 @@ export default function GrainCable(props: Props) {
<React.Fragment> <React.Fragment>
<Cylinder geometry={geometry} position={calculateCenter()} opacity={0.5} renderOrder={renderOrder} depthWrite={false} depthTest={false}/> <Cylinder geometry={geometry} position={calculateCenter()} opacity={0.5} renderOrder={renderOrder} depthWrite={false} depthTest={false}/>
{nodes.map((node, i) => ( {nodes.map((node, i) => (
<CableNode onNodeClick={onNodeClick} key={"node " + i} renderOrder={renderOrder} node={node} binCenter={binCenter} lowerThreshold={bin.lowerTempThreshold()} upperThreshold={bin.upperTempThreshold()}/> <CableNode displayTemp={displayTemp} displayMoisture={displayMoisture} onNodeClick={onNodeClick} key={"node " + i} renderOrder={renderOrder} node={node} binCenter={binCenter} lowerThreshold={bin.lowerTempThreshold()} upperThreshold={bin.upperTempThreshold()}/>
))} ))}
</React.Fragment> </React.Fragment>
) )

View file

@ -0,0 +1,76 @@
import { Box, Card, Grid2 } from "@mui/material";
import { useMobile } from "hooks";
import { Bin } from "models";
import Bin3dVisualizer from "./components/bin3dVisualizer";
interface Props {
bin: Bin
}
export default function BinSummary(props: Props){
const {bin} = props
const isMobile = useMobile()
//need to decide if the vies shoulw both be displayed or toggled between
const bin3dView = () => {
return (
<Bin3dVisualizer bin={bin}/>
)
}
const tableView = () => {
return (
<Box>table View</Box>
)
}
const inventoryOverView = () => {
return (
<Box>inventory overview</Box>
)
}
const sensorData = () => {
return (
<Box>sensor data</Box>
)
}
const controllerStatus = () => {
return (
<Box>controller status</Box>
)
}
return (
<Box padding={2}>
<Grid2 container spacing={2}>
<Grid2 size={isMobile ? 12 : 8}>
<Card>
{bin3dView()}
</Card>
</Grid2>
<Grid2 size={isMobile ? 12 : 4}>
<Card>
{tableView()}
</Card>
</Grid2>
<Grid2 size={12}>
<Card>
{inventoryOverView()}
</Card>
</Grid2>
<Grid2 size={12}>
<Card>
{sensorData()}
</Card>
</Grid2>
<Grid2 size={12}>
<Card>
{controllerStatus()}
</Card>
</Grid2>
</Grid2>
</Box>
)
}

View file

@ -0,0 +1,105 @@
import { Box, Checkbox, Grid2, FormControlLabel } from "@mui/material";
import Bin3dView from "bin/3dView/Scene/Bin3dView";
import { Bin } from "models";
import { useState } from "react";
interface Props {
bin: Bin
}
export default function bin3dVisualizer(props: Props){
const {bin} = props
const [showHeatmap, setShowHeatmap] = useState(true)
const [showHotspots, setShowHotspots] = useState(false)
const [showFill, setShowFill] = useState(false)
const [showTemp, setShowTemp] = useState(false)
const [showMoisture, setShowMoisture] = useState(false)
const getFill = () => {
//calculate the fill percent of the bin when using manual/lidar for inventory
return 0
}
return (
<Box>
<Grid2 container spacing={1}>
<Grid2 size={3}>
<FormControlLabel
control={
<Checkbox
value={showHeatmap}
checked={showHeatmap}
onChange={(_, checked) => {
setShowHeatmap(checked);
}}
/>
}
label={"Toggle Heatmap"}
/>
<FormControlLabel
control={
<Checkbox
value={showHotspots}
checked={showHotspots}
onChange={(_, checked) => {
setShowHotspots(checked);
}}
/>
}
label={"Toggle Hotspots"}
/>
<FormControlLabel
control={
<Checkbox
value={showFill}
checked={showFill}
onChange={(_, checked) => {
setShowFill(checked);
}}
/>
}
label={"Toggle Hotspots"}
/>
<FormControlLabel
control={
<Checkbox
value={showTemp}
checked={showTemp}
onChange={(_, checked) => {
setShowTemp(checked);
}}
/>
}
label={"Toggle Temp"}
/>
<FormControlLabel
control={
<Checkbox
value={showMoisture}
checked={showMoisture}
onChange={(_, checked) => {
setShowMoisture(checked);
}}
/>
}
label={"Toggle Moisture"}
/>
</Grid2>
<Grid2 size={9}>
<Box height={750}>
<Bin3dView
bin={bin}
scale={100}
showTempHeatmap={showHeatmap}
showTemp={showTemp}
showMoisture={showMoisture}
showGrain={showFill}
showHotspots={showHotspots}
/>
</Box>
</Grid2>
</Grid2>
</Box>
)
}

View file

@ -20,6 +20,7 @@ const Teams = lazy(() => import("pages/Teams"));
const Users = lazy(() => import("pages/Users")); const Users = lazy(() => import("pages/Users"));
const TeamPage = lazy(() => import("pages/Team")); const TeamPage = lazy(() => import("pages/Team"));
const BinPage = lazy(() => import("pages/Bin")); const BinPage = lazy(() => import("pages/Bin"));
const BinPageV2 = lazy(()=> import("pages/BinV2"));
const Bins = lazy(() => import("pages/Bins")); const Bins = lazy(() => import("pages/Bins"));
const Mines = lazy(() => import("pages/Mines")); const Mines = lazy(() => import("pages/Mines"));
const DeviceComponent = lazy(() => import("pages/DeviceComponent")); const DeviceComponent = lazy(() => import("pages/DeviceComponent"));
@ -139,10 +140,17 @@ export default function Router() {
path="" // "/settings/basic" path="" // "/settings/basic"
element={<Bins key={"Bins page"} />} element={<Bins key={"Bins page"} />}
/> />
{user.hasFeature("beta") ?
<Route
path="/:binID" // "/settings/basic"
element={<BinPageV2 />}
/>
:
<Route <Route
path="/:binID" // "/settings/basic" path="/:binID" // "/settings/basic"
element={<BinPage />} element={<BinPage />}
/> />
}
{/* <Route {/* <Route
path="/:deviceID/components/:componentID" // "/settings/basic" path="/:deviceID/components/:componentID" // "/settings/basic"
element={<DeviceComponent />} element={<DeviceComponent />}

View file

@ -816,53 +816,6 @@ export default function Bin(props: Props) {
</Box> </Box>
{overview()} {overview()}
{preferences && binComponents(preferences)} {preferences && binComponents(preferences)}
<Card>
<Box height={1000}>
<TextField
type="number"
onChange={e => {
setFillPercent(+e.target.value)
}}
/>
<FormControlLabel
control={
<Checkbox
value={showGrain}
checked={showGrain}
onChange={(_, checked) => {
setShowGrain(checked);
}}
/>
}
label={"Inventory Toggle"}
/>
<FormControlLabel
control={
<Checkbox
value={showHotspots}
checked={showHotspots}
onChange={(_, checked) => {
setShowHotspots(checked);
}}
/>
}
label={"Hot Spots"}
/>
<FormControlLabel
control={
<Checkbox
value={showHeatmap}
checked={showHeatmap}
onChange={(_, checked) => {
setShowHeatmap(checked);
}}
/>
}
label={"Heatmap"}
/>
<Bin3dView bin={bin} scale={100} fillPercent={fillPercent/100} showGrain={showGrain} showHeatmap={showHeatmap} showHotspots={showHotspots}/>
</Box>
</Card>
</Grid> </Grid>
<Grid id="tour-conditions" size={{ xs: 12, sm: 12, md: 12, lg: 2.6 }}> <Grid id="tour-conditions" size={{ xs: 12, sm: 12, md: 12, lg: 2.6 }}>
{(bin.settings.mode === pond.BinMode.BIN_MODE_DRYING || {(bin.settings.mode === pond.BinMode.BIN_MODE_DRYING ||

482
src/pages/BinV2.tsx Normal file
View file

@ -0,0 +1,482 @@
import { useCallback, useEffect, useRef, useState } from "react";
import PageContainer from "./PageContainer";
import { DevicePreset } from "models/DevicePreset";
import { Controller } from "models/Controller";
import { useNavigate, useParams } from "react-router-dom";
import { useMobile } from "hooks";
import { Component, Device, Bin as IBin, Interaction } from "models";
import { useBinAPI, useGlobalState } from "providers";
import { pond } from "protobuf-ts/pond";
import { Plenum } from "models/Plenum";
import { Ambient } from "models/Ambient";
import { GrainCable } from "models/GrainCable";
import { Pressure } from "models/Pressure";
import { CO2 } from "models/CO2";
import moment from "moment";
import { quack } from "protobuf-ts/quack";
import { Box, Drawer, Grid2 as Grid, IconButton, Theme, Tooltip } from "@mui/material";
import { makeStyles } from "@mui/styles";
import NotesIcon from "@mui/icons-material/Notes";
import TasksIcon from "products/Construction/TasksIcon";
import BindaptIcon from "products/Bindapt/BindaptIcon";
import FieldsIcon from "products/AgIcons/FieldsIcon";
import BinActions from "bin/BinActions";
import BinSummary from "bin/binSummary/BinSummary";
const useStyles = makeStyles((theme: Theme) => {
const themeType = theme.palette.mode;
const activeBG = theme.palette.secondary.main;
return ({
spacer: {
width: "32px",
height: "32px",
padding: "auto",
display: "flex",
justifyContent: "center",
alignItems: "center"
},
avatar: {
color: themeType === "light" ? theme.palette.common.black : theme.palette.common.white,
backgroundColor: "transparent",
width: theme.spacing(5),
height: theme.spacing(5),
border: "1px solid",
borderColor: theme.palette.divider
},
})
})
export default function BinV2(){
const warningThreshold = 6;
const isMobile = useMobile();
const binID = useParams<{ binID: string }>()?.binID ?? "";
const binAPI = useBinAPI();
const classes = useStyles()
const navigate = useNavigate()
const [{ user, as, showErrors }] = useGlobalState();
const [binLoading, setBinLoading] = useState(false);
const loadRef = useRef<boolean>(false);
const [bin, setBin] = useState<IBin>(IBin.create());
const [devices, setDevices] = useState<Device[]>([]);
const [components, setComponents] = useState<Map<string, Component>>(new Map());
const [interactions, setInteractions] = useState<Interaction[]>([]);
const [permissions, setPermissions] = useState<pond.Permission[]>([]);
const [preferences, setPreferences] = useState<Map<string, pond.BinComponentPreferences>>();
const [plenums, setPlenums] = useState<Plenum[]>([]);
const [ambients, setAmbients] = useState<Ambient[]>([]);
const [grainCables, setGrainCables] = useState<GrainCable[]>([]);
const [pressures, setPressures] = useState<Pressure[]>([]);
const [headspaceCO2, setHeadspaceCO2] = useState<CO2[]>([]);
const [heaters, setHeaters] = useState<Controller[]>([]);
const [fans, setFans] = useState<Controller[]>([]);
const [compositionNameMap, setCompositionNameMap] = useState<Map<string, string>>(new Map());
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [componentDevices, setComponentDevices] = useState<Map<string, number>>(
new Map<string, number>()
);
const [interactionDevices, setInteractionDevices] = useState<Map<string, number>>(
new Map<string, number>()
);
const [binPresets, setBinPresets] = useState<DevicePreset[]>([]);
const [missedReadings, setMissedReadings] = useState(0);
const [currentSection, setCurrentSection] = useState<"binSum">("binSum")
//Drawer states
const [noteDrawerOpen, setNoteDrawerOpen] = useState(false)
const [taskDrawerOpen, setTaskDrawerOpen] = useState(false)
//DATA LOAD/UPDATE FUNCTIONS
const load = useCallback(() => {
if (loadRef.current || user.id() === "") return;
setBinLoading(true);
loadRef.current = true;
//add the presets to the bin page data load
binAPI
.getBinPageData(binID, user.id(), showErrors, as)
.then(resp => {
if (resp.data.grainCompositionNames) {
let tempMap: Map<string, string> = new Map();
Object.keys(resp.data.grainCompositionNames).forEach(key => {
tempMap.set(key, resp.data.grainCompositionNames[key]);
});
tempMap.set("correction", "Unknown Source");
setCompositionNameMap(tempMap);
}
let devs: Device[] = [];
let p = new Map<string, pond.BinComponentPreferences>();
Object.keys(resp.data.preferences).forEach(k => {
let prefKey = k.split(":")[1] ? k.split(":")[1] : k;
p.set(prefKey, pond.BinComponentPreferences.fromObject(resp.data.preferences[k]));
});
setPreferences(p);
let r: pond.GetBinPageDataResponse = pond.GetBinPageDataResponse.fromObject(resp.data);
setBin(IBin.any(resp.data.bin));
let newComponentDevices = new Map<string, number>();
let attachedDevIds: number[] = [];
if (resp.data.componentDevices)
Object.keys(resp.data.componentDevices).forEach(k => {
newComponentDevices.set(k, resp.data.componentDevices[k]);
//make a list of all of the ids for devices that components are currently attached
if (!attachedDevIds.includes(resp.data.componentDevices[k])) {
attachedDevIds.push(resp.data.componentDevices[k]);
}
});
setComponentDevices(newComponentDevices);
let newInteractionDevices = new Map<string, number>();
if (r.interactionDevices)
Object.keys(r.interactionDevices).forEach(k => {
newInteractionDevices.set(k, r.interactionDevices[k]);
});
setInteractionDevices(newInteractionDevices);
if (resp.data.interactions) {
setInteractions(resp.data.interactions.map((i: pond.Interaction) => Interaction.any(i)));
} else {
setInteractions([]);
}
//go through the devices and if the device has an attached component include it in the device list
if (resp.data.devices && resp.data.devices[0]) {
resp.data.devices.forEach((dev: any) => {
let device = Device.create(dev);
if (attachedDevIds.includes(device.id())) {
devs.push(device);
}
});
setDevices(devs);
}
if (resp.data.components) {
let compMap: Map<string, Component> = new Map();
resp.data.components.forEach((comp: pond.Component) => {
if (comp && comp.settings) {
let c = Component.any(comp);
compMap.set(comp.settings.key, c);
}
});
setComponents(compMap);
}
if (resp.data.presets) {
setBinPresets(
resp.data.presets.map((preset: pond.DevicePreset) => DevicePreset.create(preset))
);
}
setPermissions(r.permissions ? r.permissions : []);
setBinLoading(false);
loadRef.current = false;
})
.finally(() => {
setBinLoading(false);
loadRef.current = false;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [binID, user.id(), binAPI, showErrors, as]);
useEffect(() => {
load();
}, [load]);
const setBinComponents = useCallback(() => {
if (!preferences) return;
var unassigned: Component[] = [];
var plenums: Plenum[] = [];
var ambients: Ambient[] = [];
var grainCables: GrainCable[] = [];
var fans: Controller[] = [];
var heaters: Controller[] = [];
var pressures: Pressure[] = [];
var headspaceCo2: CO2[] = [];
var mostMissed: number = 0;
let now = moment();
components.forEach(comp => {
let pref = preferences.get(comp.key());
if (pref) {
if (pref.type) {
if (pref.type === pond.BinComponent.BIN_COMPONENT_PLENUM)
plenums.push(Plenum.create(comp));
if (pref.type === pond.BinComponent.BIN_COMPONENT_AMBIENT)
ambients.push(Ambient.create(comp));
if (pref.type === pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE) {
//check cable for missed measurements
let cable = GrainCable.create(comp);
let lastRead = moment(cable.lastReading);
let elapsedMS = now.diff(lastRead);
if (elapsedMS > cable.settings.measurementPeriodMs) {
let missedReadings = Math.floor(elapsedMS / cable.settings.measurementPeriodMs);
if (missedReadings > mostMissed) {
mostMissed = missedReadings;
}
}
grainCables.push(cable);
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_HEATER)
heaters.push(Controller.create(comp));
if (pref.type === pond.BinComponent.BIN_COMPONENT_FAN) fans.push(Controller.create(comp));
if (pref.type === pond.BinComponent.BIN_COMPONENT_PRESSURE)
pressures.push(Pressure.create(comp));
if (pref.type === pond.BinComponent.BIN_COMPONENT_HEADSPACE) {
if (comp.type() === quack.ComponentType.COMPONENT_TYPE_CO2) {
//check if there are missed readings from the co2 and compare them to what we already had
let coComp = CO2.create(comp)
let lastRead = moment(coComp.lastReading);
let elapsedMS = now.diff(lastRead);
if (elapsedMS > coComp.settings.measurementPeriodMs) {
let missedReadings = Math.floor(elapsedMS / coComp.settings.measurementPeriodMs);
if (missedReadings > mostMissed) {
mostMissed = missedReadings;
}
}
headspaceCo2.push(coComp);
}
}
} else {
unassigned.push(comp);
}
}
});
setMissedReadings(mostMissed);
setPlenums(plenums);
setGrainCables(grainCables);
setPressures(pressures);
setAmbients(ambients);
setHeaters(heaters);
setFans(fans);
setHeadspaceCO2(headspaceCo2);
}, [components, preferences]);
useEffect(() => {
setBinComponents();
}, [setBinComponents]);
const updateStatus = (componentKeys: string[], removed?: boolean) => {
componentKeys.forEach(compKey => {
let comp = components.get(compKey);
if (comp) {
//determine what part of the status to update based on the component
//for lidar
if (comp.type() === quack.ComponentType.COMPONENT_TYPE_LIDAR) {
//determine how to update the status based on if added or removed
if (removed) {
bin.status.distance = 0;
} else {
if (
comp.lastMeasurement[0] &&
comp.lastMeasurement[0].values[0] &&
comp.lastMeasurement[0].values[0].values[0]
) {
bin.status.distance = comp.lastMeasurement[0].values[0].values[0];
}
}
}
}
});
//update the bins status with the new values based on the components changed
binAPI.updateBinStatus(bin.key(), bin.status, as);
};
//NAVIGATION FUNCTIONS
const goToDevice = (dev: Device) => {
navigate(window.location.pathname + "/devices/" + dev.id(), { replace: true });
};
const goToYard = (bin: IBin) => {
navigate("/visualFarm", { state: {
long: bin.getLocation()?.longitude,
lat: bin.getLocation()?.latitude
}
});
};
/**
* UI STUFF STARTS HERE
*/
/**
* Header - consists of the actions and things that are always visible like
* opening the note/history and tasks drawers, navigating to the device and map pages, opening binsensors and settings etc.
* also has the bin name/model and activity and the dropdown for changing sections
*/
const pageHeader = () => {
return (
<Box marginTop={1} marginBottom={1}>
<Grid
container
direction="row"
justifyContent="space-between"
alignContent="center"
alignItems="center">
<Grid >
<IconButton
onClick={() =>
setNoteDrawerOpen(true)
}
size="small"
style={{
marginRight: "0.5rem"
}}
className={classes.avatar}
component="span">
<div className={classes.spacer}>
<NotesIcon />
</div>
</IconButton>
<IconButton
onClick={() =>
setTaskDrawerOpen(true)
}
size="small"
style={{
marginRight: "0.5rem"
}}
className={classes.avatar}
component="span">
<div className={classes.spacer}>
<TasksIcon />
</div>
</IconButton>
{devices.length > 1 ? (
<Tooltip key="devices" title="devices">
<IconButton
key="devButton"
onClick={(event: React.MouseEvent<HTMLButtonElement>) =>
setAnchorEl(event.currentTarget)
}
size="small"
style={{
marginRight: "0.5rem"
}}
className={classes.avatar}
component="span">
<BindaptIcon />
</IconButton>
</Tooltip>
) : (
devices.map(dev => (
<Tooltip key={dev.id()} title={dev.name()}>
<IconButton
key={dev.id()}
onClick={() => goToDevice(dev)}
size="small"
style={{
marginRight: "0.5rem"
}}
className={classes.avatar}
component="span">
<div className={classes.spacer}>
<BindaptIcon />
</div>
</IconButton>
</Tooltip>
))
)}
{bin.binMapped() && (
<Tooltip key={"goToMap"} title={"Go to Yard"}>
<IconButton
onClick={() => {
goToYard(bin);
}}
size="small"
style={{
marginRight: "0.5rem"
}}
className={classes.avatar}
component="span">
<FieldsIcon />
</IconButton>
</Tooltip>
)}
</Grid>
<Grid>
<BinActions
bin={bin}
permissions={permissions}
refreshCallback={() => {
load();
}}
userID={user.id()}
components={components}
setComponents={setComponents}
componentDevices={componentDevices}
setComponentDevices={setComponentDevices}
updateBinStatus={updateStatus}
/>
</Grid>
</Grid>
</Box>
)
}
//PAGE SECTIONS
/**
* Contains the main summary of your bin as it currently is
* has the 3D bin model, a switch to go to the table view
* the bin conditions if it is in a mode other than storage (the do nothing mode)
* a box underneath to display sensor data, for the plenums and such
* as well as the controls for attached controllers
*/
const binSummarySection = () => {
return (
<BinSummary bin={bin}/>
)
}
/**
* this sections is for showing attached bin components and assigning them to bin positions (see old pages bin components)
* as well as seeing the graph data for attached sensors
*/
const sensorsSection = () => {}
//Inventory
/**
* this section is for seeing information relating to the bins inventory for its fill level over time and transactions related to inventory of the bin
*/
const InventorySection = () => {}
//Analysis
/**
* this section is for the analytical charts (drying/hydrating, Moisture trending, Grain Water Content)
*/
const analysisSection = () => {}
//Interactions
/**
* this is the section for alerts and controls on the bin, if presets get re-implemented they can go here too
*/
const interactionsSection = () => {}
//DRAWERS
const noteDrawer = () => {
return (
<Drawer open={noteDrawerOpen}>
</Drawer>
)
}
const taskDrawer = () => {
return (
<Drawer open={taskDrawerOpen}>
</Drawer>
)
}
return (
<PageContainer>
{pageHeader()}
{currentSection === "binSum" && binSummarySection()}
{/* render drawers */}
{noteDrawer()}
{taskDrawer()}
</PageContainer>
)
}