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:
parent
b3cf5b5e83
commit
4b2c67dfd9
11 changed files with 974 additions and 220 deletions
90
src/3dModels/CameraControls/CameraOverlay.tsx
Normal file
90
src/3dModels/CameraControls/CameraOverlay.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,6 +95,22 @@ 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) => {
|
||||||
|
|
@ -78,54 +119,22 @@ export default function OrbitCameraControls(props: Props) {
|
||||||
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;
|
||||||
|
|
@ -138,58 +147,36 @@ 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
// apply movement
|
|
||||||
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;
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
// Clamp zoom
|
|
||||||
spherical.current.radius = Math.max(
|
spherical.current.radius = Math.max(
|
||||||
3,
|
3,
|
||||||
Math.min(30, spherical.current.radius)
|
Math.min(props.maxRadius ?? 30, spherical.current.radius)
|
||||||
);
|
);
|
||||||
|
|
||||||
updateCamera();
|
updateCamera();
|
||||||
|
|
@ -224,14 +209,12 @@ 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]);
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -20,14 +21,33 @@ interface Props {
|
||||||
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]);
|
||||||
|
|
||||||
|
|
@ -57,6 +77,9 @@ export default function Bin3dView(props: Props) {
|
||||||
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 (
|
||||||
|
|
@ -101,6 +124,8 @@ export default function Bin3dView(props: Props) {
|
||||||
{showGrain && grainInventory()}
|
{showGrain && grainInventory()}
|
||||||
|
|
||||||
<BinCables
|
<BinCables
|
||||||
|
displayTemp={showTemp}
|
||||||
|
displayMoisture={showMoisture}
|
||||||
cableData={cableData}
|
cableData={cableData}
|
||||||
nodeData={nodeData}
|
nodeData={nodeData}
|
||||||
bin={bin}
|
bin={bin}
|
||||||
|
|
@ -113,13 +138,14 @@ export default function Bin3dView(props: Props) {
|
||||||
|
|
||||||
{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"} />
|
||||||
|
|
@ -127,7 +153,16 @@ export default function Bin3dView(props: Props) {
|
||||||
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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({
|
r.push({
|
||||||
text: tempDisplay,
|
text: tempDisplay,
|
||||||
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour()
|
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour()
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (node.moisture) {
|
// MOISTURE
|
||||||
|
if (displayMoisture) {
|
||||||
|
if (node.moisture && node.moisture > 0) {
|
||||||
r.push({
|
r.push({
|
||||||
text: `${node.moisture.toFixed(2)}% EMC`,
|
text: `${node.moisture.toFixed(2)}% EMC`,
|
||||||
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour()
|
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour()
|
||||||
});
|
});
|
||||||
} else if (node.humidity) {
|
} else if (node.humidity && node.humidity > 0) {
|
||||||
r.push({
|
r.push({
|
||||||
text: `${node.humidity.toFixed(2)}%`,
|
text: `${node.humidity.toFixed(2)}%`,
|
||||||
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour()
|
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) &&
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
76
src/bin/binSummary/BinSummary.tsx
Normal file
76
src/bin/binSummary/BinSummary.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
src/bin/binSummary/components/bin3dVisualizer.tsx
Normal file
105
src/bin/binSummary/components/bin3dVisualizer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 />}
|
||||||
|
|
|
||||||
|
|
@ -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
482
src/pages/BinV2.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue