Merge branch 'plenum_ambient_component' into dev_environment
This commit is contained in:
commit
6ef69f8b98
66 changed files with 10408 additions and 80 deletions
724
package-lock.json
generated
724
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -38,10 +38,13 @@
|
|||
"@mui/styles": "^6.1.6",
|
||||
"@mui/x-date-pickers": "^7.26.0",
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"@react-three/drei": "^9.105.6",
|
||||
"@react-three/fiber": "^8.18.0",
|
||||
"@sentry/react": "^8.38.0",
|
||||
"@turf/area": "^7.2.0",
|
||||
"@turf/turf": "^7.2.0",
|
||||
"@types/classnames": "^2.3.0",
|
||||
"@types/three": "^0.183.1",
|
||||
"axios": "^1.7.7",
|
||||
"crisp-sdk-web": "^1.0.26",
|
||||
"dayjs": "^1.11.13",
|
||||
|
|
@ -74,6 +77,7 @@
|
|||
"react-virtualized-auto-sizer": "^1.0.25",
|
||||
"recharts": "^2.15.1",
|
||||
"semver": "^7.7.1",
|
||||
"three": "^0.152.2",
|
||||
"victory": "^37.3.6",
|
||||
"weather-icons-react": "^1.2.0"
|
||||
},
|
||||
|
|
|
|||
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
|
||||
);
|
||||
}
|
||||
329
src/3dModels/CameraControls/OrbitCameraControls.tsx
Normal file
329
src/3dModels/CameraControls/OrbitCameraControls.tsx
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import React from "react";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Vector2, Vector3 } from "three";
|
||||
|
||||
interface CameraTarget {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
target?: CameraTarget;
|
||||
clampVerticalRotation?: boolean;
|
||||
/**
|
||||
* The minimum vertical rotation in radians
|
||||
* @default 0.01
|
||||
*/
|
||||
minPhi?: number;
|
||||
/**
|
||||
* The maximum vertical rotation in radians
|
||||
* @default Math.PI - 0.01
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* used to control the offset of the target from the center that the camera rotates around
|
||||
*/
|
||||
offset?: { x: number; y: number; z: number };
|
||||
viewOffset?: 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) {
|
||||
const {
|
||||
target,
|
||||
clampVerticalRotation,
|
||||
minPhi = 0.01,
|
||||
maxPhi = Math.PI - 0.01,
|
||||
offset,
|
||||
viewOffset,
|
||||
initialRadius,
|
||||
maxRadius,
|
||||
onReset,
|
||||
} = props;
|
||||
|
||||
const { camera, gl } = useThree();
|
||||
|
||||
const mouse = useRef(new Vector2());
|
||||
const targetRef = useRef({
|
||||
x: (target?.x ?? 0) + (offset?.x ?? 0),
|
||||
y: (target?.y ?? 0) + (offset?.y ?? 0),
|
||||
z: (target?.z ?? 0) + (offset?.z ?? 0),
|
||||
});
|
||||
|
||||
const isDragging = useRef(false);
|
||||
const isPanning = useRef(false);
|
||||
const lastPos = useRef({ x: 0, y: 0 });
|
||||
const panCameraPosition = useRef(new Vector3());
|
||||
const lastPinchDist = useRef(0);
|
||||
|
||||
// Spherical coords
|
||||
const spherical = useRef({
|
||||
radius: initialRadius ?? 10,
|
||||
theta: 0, // horizontal angle
|
||||
phi: Math.PI / 2, // vertical angle
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Sync radius whenever initialRadius prop changes (e.g. after bin data loads)
|
||||
spherical.current.radius = initialRadius ?? 10;
|
||||
|
||||
const canvas = gl.domElement;
|
||||
canvas.addEventListener("contextmenu", e => e.preventDefault());
|
||||
|
||||
const updateCamera = () => {
|
||||
const { radius, theta, phi } = spherical.current;
|
||||
|
||||
const x = radius * Math.sin(phi) * Math.sin(theta);
|
||||
const y = radius * Math.cos(phi);
|
||||
const z = radius * Math.sin(phi) * Math.cos(theta);
|
||||
|
||||
camera.position.set(
|
||||
targetRef.current.x + x,
|
||||
targetRef.current.y + y,
|
||||
targetRef.current.z + z
|
||||
);
|
||||
camera.lookAt(
|
||||
targetRef.current.x,
|
||||
targetRef.current.y,
|
||||
targetRef.current.z
|
||||
);
|
||||
|
||||
// Shift the projected image without affecting orbit
|
||||
const { width, height } = gl.domElement.getBoundingClientRect();
|
||||
(camera as any).setViewOffset(width, height, viewOffset ?? 0, 0, width, height);
|
||||
};
|
||||
|
||||
// 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 = initialRadius ?? 10;
|
||||
spherical.current.theta = 0;
|
||||
spherical.current.phi = Math.PI / 2;
|
||||
targetRef.current = {
|
||||
x: (target?.x ?? 0) + (offset?.x ?? 0),
|
||||
y: (target?.y ?? 0) + (offset?.y ?? 0),
|
||||
z: (target?.z ?? 0) + (offset?.z ?? 0),
|
||||
};
|
||||
updateCamera();
|
||||
});
|
||||
}
|
||||
|
||||
updateCamera();
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (e.target !== canvas) return;
|
||||
|
||||
lastPos.current = { x: e.clientX, y: e.clientY };
|
||||
panCameraPosition.current.copy(camera.position);
|
||||
|
||||
if (e.button === 0) {
|
||||
isDragging.current = true;
|
||||
isPanning.current = false;
|
||||
}
|
||||
|
||||
if (e.button === 2) {
|
||||
isPanning.current = true;
|
||||
isDragging.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = e.clientX - lastPos.current.x;
|
||||
const deltaY = e.clientY - lastPos.current.y;
|
||||
|
||||
// ROTATE — left drag
|
||||
if (isDragging.current) {
|
||||
spherical.current.theta -= deltaX * 0.005;
|
||||
spherical.current.phi -= deltaY * 0.005;
|
||||
|
||||
if (clampVerticalRotation) {
|
||||
spherical.current.phi = Math.max(
|
||||
minPhi,
|
||||
Math.min(maxPhi, spherical.current.phi)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PAN — right drag
|
||||
// Uses screen-space axes derived from the camera so panning is
|
||||
// correct at any angle including top-down.
|
||||
if (isPanning.current) {
|
||||
const distance = spherical.current.radius;
|
||||
const panSpeed = 0.002 * distance;
|
||||
|
||||
const forward = new Vector3();
|
||||
camera.getWorldDirection(forward);
|
||||
|
||||
// Right vector — perpendicular to forward in the horizontal plane
|
||||
const right = new Vector3();
|
||||
right.crossVectors(forward, new Vector3(0, 1, 0)).normalize();
|
||||
|
||||
// Degenerate guard: when camera is nearly straight up or down,
|
||||
// fall back to camera.up
|
||||
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();
|
||||
|
||||
right.multiplyScalar(-deltaX * panSpeed);
|
||||
screenUp.multiplyScalar(deltaY * panSpeed);
|
||||
|
||||
const pan = new Vector3().addVectors(right, screenUp);
|
||||
|
||||
targetRef.current.x += pan.x;
|
||||
targetRef.current.y += pan.y;
|
||||
targetRef.current.z += pan.z;
|
||||
}
|
||||
|
||||
lastPos.current = { x: e.clientX, y: e.clientY };
|
||||
updateCamera();
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging.current = false;
|
||||
isPanning.current = false;
|
||||
};
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (e.target !== canvas) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
spherical.current.radius += e.deltaY * 0.01;
|
||||
spherical.current.radius = Math.max(
|
||||
3,
|
||||
Math.min(maxRadius ?? 30, spherical.current.radius)
|
||||
);
|
||||
|
||||
updateCamera();
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
if (e.target !== canvas) return;
|
||||
e.preventDefault();
|
||||
|
||||
if (e.touches.length === 1) {
|
||||
isDragging.current = true;
|
||||
isPanning.current = false;
|
||||
lastPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
} else if (e.touches.length === 2) {
|
||||
isDragging.current = false;
|
||||
isPanning.current = true;
|
||||
lastPos.current = {
|
||||
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||||
y: (e.touches[0].clientY + e.touches[1].clientY) / 2,
|
||||
};
|
||||
// Store initial pinch distance for zoom
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
lastPinchDist.current = Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.touches.length === 1 && isDragging.current) {
|
||||
const deltaX = e.touches[0].clientX - lastPos.current.x;
|
||||
const deltaY = e.touches[0].clientY - lastPos.current.y;
|
||||
spherical.current.theta -= deltaX * 0.005;
|
||||
spherical.current.phi -= deltaY * 0.005;
|
||||
if (clampVerticalRotation) {
|
||||
spherical.current.phi = Math.max(minPhi, Math.min(maxPhi, spherical.current.phi));
|
||||
}
|
||||
lastPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
updateCamera();
|
||||
|
||||
} else if (e.touches.length === 2) {
|
||||
// Pinch zoom
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const pinchDelta = lastPinchDist.current - dist;
|
||||
spherical.current.radius += pinchDelta * 0.05;
|
||||
spherical.current.radius = Math.max(3, Math.min(maxRadius ?? 30, spherical.current.radius));
|
||||
lastPinchDist.current = dist;
|
||||
|
||||
// Two-finger pan
|
||||
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
||||
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
||||
const panDeltaX = midX - lastPos.current.x;
|
||||
const panDeltaY = midY - lastPos.current.y;
|
||||
|
||||
const distance = spherical.current.radius;
|
||||
const panSpeed = 0.002 * distance;
|
||||
const forward = new Vector3();
|
||||
camera.getWorldDirection(forward);
|
||||
const right = new Vector3().crossVectors(forward, new Vector3(0, 1, 0)).normalize();
|
||||
if (right.lengthSq() < 0.001) right.crossVectors(forward, camera.up).normalize();
|
||||
const screenUp = new Vector3().crossVectors(right, forward).normalize();
|
||||
right.multiplyScalar(-panDeltaX * panSpeed);
|
||||
screenUp.multiplyScalar(panDeltaY * panSpeed);
|
||||
const pan = new Vector3().addVectors(right, screenUp);
|
||||
targetRef.current.x += pan.x;
|
||||
targetRef.current.y += pan.y;
|
||||
targetRef.current.z += pan.z;
|
||||
|
||||
lastPos.current = { x: midX, y: midY };
|
||||
updateCamera();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: TouchEvent) => {
|
||||
if (e.touches.length === 0) {
|
||||
isDragging.current = false;
|
||||
isPanning.current = false;
|
||||
} else if (e.touches.length === 1) {
|
||||
// Transitioned from pinch back to single finger
|
||||
isDragging.current = true;
|
||||
isPanning.current = false;
|
||||
lastPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousedown", handleMouseDown);
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
canvas.addEventListener("wheel", handleWheel, { passive: false });
|
||||
canvas.addEventListener("touchstart", handleTouchStart, { passive: false });
|
||||
canvas.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
canvas.addEventListener("touchend", handleTouchEnd);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", handleMouseDown);
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
canvas.removeEventListener("wheel", handleWheel);
|
||||
canvas.removeEventListener("touchstart", handleTouchStart);
|
||||
canvas.removeEventListener("touchmove", handleTouchMove);
|
||||
canvas.removeEventListener("touchend", handleTouchEnd);
|
||||
};
|
||||
}, [camera, gl, target, clampVerticalRotation, minPhi, maxPhi, initialRadius, maxRadius]);
|
||||
|
||||
return null;
|
||||
}
|
||||
34
src/3dModels/Shapes/3D/Circle.tsx
Normal file
34
src/3dModels/Shapes/3D/Circle.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import BaseMesh, { BaseMeshProps } from "../BaseMesh";
|
||||
|
||||
export interface CircleGeometry{
|
||||
radius: number
|
||||
radialSegments?: number
|
||||
thetaStart?: number
|
||||
thetaLength?: number
|
||||
}
|
||||
|
||||
interface Props extends Omit<BaseMeshProps, "geometry"> {
|
||||
geometry: CircleGeometry;
|
||||
}
|
||||
|
||||
export default function Circle(props: Props) {
|
||||
const { geometry } = props;
|
||||
|
||||
const geo = useMemo(() => {
|
||||
return new THREE.CircleGeometry(
|
||||
geometry.radius,
|
||||
geometry.radialSegments ?? 24,
|
||||
geometry.thetaStart ?? 0,
|
||||
geometry.thetaLength ?? Math.PI * 2
|
||||
);
|
||||
}, [
|
||||
geometry.radius,
|
||||
geometry.radialSegments,
|
||||
geometry.thetaStart,
|
||||
geometry.thetaLength
|
||||
]);
|
||||
|
||||
return <BaseMesh {...props} geometry={geo} />;
|
||||
}
|
||||
43
src/3dModels/Shapes/3D/Cone.tsx
Normal file
43
src/3dModels/Shapes/3D/Cone.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import BaseMesh, { BaseMeshProps } from "../BaseMesh";
|
||||
|
||||
export interface ConeGeometry {
|
||||
radius: number;
|
||||
height: number;
|
||||
radialSegments?: number;
|
||||
heightSegments?: number;
|
||||
openEnded?: boolean;
|
||||
thetaStart?: number;
|
||||
thetaLength?: number;
|
||||
}
|
||||
|
||||
interface Props extends Omit<BaseMeshProps, "geometry"> {
|
||||
geometry: ConeGeometry;
|
||||
}
|
||||
|
||||
export default function Cone(props: Props) {
|
||||
const { geometry } = props;
|
||||
|
||||
const geo = useMemo(() => {
|
||||
return new THREE.ConeGeometry(
|
||||
geometry.radius,
|
||||
geometry.height,
|
||||
geometry.radialSegments ?? 24,
|
||||
geometry.heightSegments ?? 1,
|
||||
geometry.openEnded ?? false,
|
||||
geometry.thetaStart ?? 0,
|
||||
geometry.thetaLength ?? Math.PI * 2
|
||||
);
|
||||
}, [
|
||||
geometry.radius,
|
||||
geometry.height,
|
||||
geometry.radialSegments,
|
||||
geometry.heightSegments,
|
||||
geometry.openEnded,
|
||||
geometry.thetaStart,
|
||||
geometry.thetaLength
|
||||
]);
|
||||
|
||||
return <BaseMesh {...props} geometry={geo} />;
|
||||
}
|
||||
40
src/3dModels/Shapes/3D/Cube.tsx
Normal file
40
src/3dModels/Shapes/3D/Cube.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import BaseMesh, { BaseMeshProps } from "../BaseMesh";
|
||||
|
||||
export interface CubeGeometry {
|
||||
height: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
widthSegments?: number;
|
||||
heightSegments?: number;
|
||||
depthSegments?: number;
|
||||
}
|
||||
|
||||
interface Props extends Omit<BaseMeshProps, "geometry"> {
|
||||
geometry: CubeGeometry;
|
||||
}
|
||||
|
||||
export default function Cube(props: Props) {
|
||||
const { geometry } = props;
|
||||
|
||||
const geo = useMemo(() => {
|
||||
return new THREE.BoxGeometry(
|
||||
geometry.height,
|
||||
geometry.width,
|
||||
geometry.depth,
|
||||
geometry.widthSegments ?? 2,
|
||||
geometry.heightSegments ?? 2,
|
||||
geometry.depthSegments ?? 2
|
||||
);
|
||||
}, [
|
||||
geometry.height,
|
||||
geometry.width,
|
||||
geometry.depth,
|
||||
geometry.widthSegments,
|
||||
geometry.heightSegments,
|
||||
geometry.depthSegments
|
||||
]);
|
||||
|
||||
return <BaseMesh {...props} geometry={geo} />;
|
||||
}
|
||||
46
src/3dModels/Shapes/3D/Cylinder.tsx
Normal file
46
src/3dModels/Shapes/3D/Cylinder.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import BaseMesh, { BaseMeshProps } from "../BaseMesh";
|
||||
|
||||
export interface CylinderGeometry {
|
||||
radiusTop: number;
|
||||
radiusBottom: number;
|
||||
height: number;
|
||||
radialSegments?: number;
|
||||
heightSegments?: number;
|
||||
openEnded?: boolean;
|
||||
thetaStart?: number;
|
||||
thetaLength?: number;
|
||||
}
|
||||
|
||||
interface Props extends Omit<BaseMeshProps, "geometry"> {
|
||||
geometry: CylinderGeometry;
|
||||
}
|
||||
|
||||
export default function Cylinder(props: Props) {
|
||||
const { geometry } = props;
|
||||
|
||||
const geo = useMemo(() => {
|
||||
return new THREE.CylinderGeometry(
|
||||
geometry.radiusTop,
|
||||
geometry.radiusBottom,
|
||||
geometry.height,
|
||||
geometry.radialSegments ?? 24,
|
||||
geometry.heightSegments ?? 1,
|
||||
geometry.openEnded ?? false,
|
||||
geometry.thetaStart ?? 0,
|
||||
geometry.thetaLength ?? Math.PI * 2
|
||||
);
|
||||
}, [
|
||||
geometry.radiusTop,
|
||||
geometry.radiusBottom,
|
||||
geometry.height,
|
||||
geometry.radialSegments,
|
||||
geometry.heightSegments,
|
||||
geometry.openEnded,
|
||||
geometry.thetaStart,
|
||||
geometry.thetaLength
|
||||
]);
|
||||
|
||||
return <BaseMesh {...props} geometry={geo} />;
|
||||
}
|
||||
40
src/3dModels/Shapes/3D/Ring.tsx
Normal file
40
src/3dModels/Shapes/3D/Ring.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import BaseMesh, { BaseMeshProps } from "../BaseMesh";
|
||||
|
||||
export interface RingGeometry{
|
||||
// the radius of the ring from the center to the ouside edge, must be larger than thickness
|
||||
radius: number
|
||||
// the thickness of the tube, must be smaller than radius
|
||||
thickness: number
|
||||
radialSegments?: number
|
||||
tubularSegments?: number
|
||||
// central angle in radians
|
||||
arc?: number
|
||||
}
|
||||
|
||||
interface Props extends Omit<BaseMeshProps, "geometry"> {
|
||||
geometry: RingGeometry;
|
||||
}
|
||||
|
||||
export default function Ring(props: Props) {
|
||||
const { geometry } = props;
|
||||
|
||||
const geo = useMemo(() => {
|
||||
return new THREE.TorusGeometry(
|
||||
geometry.radius,
|
||||
geometry.thickness,
|
||||
geometry.radialSegments ?? 12,
|
||||
geometry.tubularSegments ?? 48,
|
||||
geometry.arc ?? Math.PI * 2
|
||||
);
|
||||
}, [
|
||||
geometry.radius,
|
||||
geometry.thickness,
|
||||
geometry.radialSegments,
|
||||
geometry.tubularSegments,
|
||||
geometry.arc
|
||||
]);
|
||||
|
||||
return <BaseMesh {...props} geometry={geo} />;
|
||||
}
|
||||
43
src/3dModels/Shapes/3D/Sphere.tsx
Normal file
43
src/3dModels/Shapes/3D/Sphere.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import BaseMesh, { BaseMeshProps } from "../BaseMesh";
|
||||
|
||||
export interface SphereGeometry {
|
||||
radius: number
|
||||
widthSegments?: number
|
||||
heightSegments?: number
|
||||
phiStart?: number
|
||||
phiLength?: number
|
||||
thetaStart?: number
|
||||
thetaLength?: number
|
||||
}
|
||||
|
||||
interface Props extends Omit<BaseMeshProps, "geometry"> {
|
||||
geometry: SphereGeometry;
|
||||
}
|
||||
|
||||
export default function Sphere(props: Props){
|
||||
const { geometry } = props;
|
||||
|
||||
const geo = useMemo(() => {
|
||||
return new THREE.SphereGeometry(
|
||||
geometry.radius,
|
||||
geometry.widthSegments,
|
||||
geometry.heightSegments,
|
||||
geometry.phiStart,
|
||||
geometry.phiLength,
|
||||
geometry.thetaStart,
|
||||
geometry.thetaLength
|
||||
);
|
||||
}, [
|
||||
geometry.radius,
|
||||
geometry.widthSegments,
|
||||
geometry.heightSegments,
|
||||
geometry.phiStart,
|
||||
geometry.phiLength,
|
||||
geometry.thetaStart,
|
||||
geometry.thetaLength
|
||||
]);
|
||||
|
||||
return <BaseMesh {...props} geometry={geo} />;
|
||||
}
|
||||
99
src/3dModels/Shapes/BaseMesh.tsx
Normal file
99
src/3dModels/Shapes/BaseMesh.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { Euler, Vector3, BufferGeometry, MaterialParameters, Side } from "three";
|
||||
import { ThreeEvent } from "@react-three/fiber";
|
||||
|
||||
export interface BaseMeshProps {
|
||||
geometry: BufferGeometry;
|
||||
position?: Vector3;
|
||||
rotation?: Euler;
|
||||
colour?: string;
|
||||
opacity?: number;
|
||||
metalness?: number;
|
||||
roughness?: number;
|
||||
wireframe?: boolean;
|
||||
frameColour?: string;
|
||||
materialType?: "standard" | "basic";
|
||||
materialProps?: Partial<MaterialParameters>;
|
||||
materialOverride?: React.ReactNode;
|
||||
side?: Side;
|
||||
depthWrite?: boolean;
|
||||
depthTest?: boolean;
|
||||
renderOrder?: number;
|
||||
onClick?: (event: ThreeEvent<MouseEvent>) => void;
|
||||
}
|
||||
|
||||
export default function BaseMesh(props: BaseMeshProps) {
|
||||
const {
|
||||
geometry,
|
||||
position,
|
||||
rotation,
|
||||
colour = "#fff",
|
||||
opacity = 1,
|
||||
metalness = 0,
|
||||
roughness = 1,
|
||||
wireframe = false,
|
||||
frameColour = "black",
|
||||
materialType,
|
||||
materialProps,
|
||||
materialOverride,
|
||||
side,
|
||||
depthWrite = true,
|
||||
depthTest = true,
|
||||
renderOrder = 0,
|
||||
onClick,
|
||||
} = props;
|
||||
|
||||
const isTransparent = opacity < 1;
|
||||
|
||||
const buildMaterial = () => {
|
||||
if (materialOverride) return materialOverride;
|
||||
switch (materialType) {
|
||||
case "basic":
|
||||
return (
|
||||
<meshBasicMaterial
|
||||
color={colour}
|
||||
transparent={isTransparent}
|
||||
opacity={opacity}
|
||||
depthWrite={depthWrite}
|
||||
depthTest={depthTest}
|
||||
side={side}
|
||||
{...materialProps}
|
||||
/>
|
||||
);
|
||||
|
||||
case "standard":
|
||||
default:
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={colour}
|
||||
metalness={metalness}
|
||||
roughness={roughness}
|
||||
transparent={isTransparent}
|
||||
opacity={opacity}
|
||||
depthWrite={depthWrite}
|
||||
depthTest={depthTest}
|
||||
side={side}
|
||||
{...materialProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position} rotation={rotation} renderOrder={renderOrder}>
|
||||
{/* Main surface */}
|
||||
<mesh geometry={geometry} onClick={onClick} renderOrder={renderOrder}>
|
||||
{buildMaterial()}
|
||||
</mesh>
|
||||
|
||||
{/* Optional wireframe overlay */}
|
||||
{wireframe && (
|
||||
<mesh geometry={geometry} renderOrder={renderOrder}>
|
||||
<meshStandardMaterial
|
||||
wireframe
|
||||
color={frameColour}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
140
src/bin/3dView/BinParts/BinShell.tsx
Normal file
140
src/bin/3dView/BinParts/BinShell.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import Circle, { CircleGeometry } from "3dModels/Shapes/3D/Circle"
|
||||
import Cone, { ConeGeometry } from "3dModels/Shapes/3D/Cone"
|
||||
import Cylinder, { CylinderGeometry } from "3dModels/Shapes/3D/Cylinder"
|
||||
import React from "react"
|
||||
import { Euler, Vector3 } from "three"
|
||||
|
||||
interface Props {
|
||||
radialSegments: number
|
||||
binBodyColour: string
|
||||
binMetalness: number
|
||||
binRoughness: number
|
||||
binOpacity: number
|
||||
diameter: number
|
||||
sidewallHeight: number
|
||||
roofHeight: number
|
||||
hopperHeight?: number
|
||||
wireframe?: boolean
|
||||
renderOrder?: number
|
||||
}
|
||||
|
||||
export default function BinShell(props: Props) {
|
||||
const { radialSegments, binBodyColour, binMetalness, binRoughness, binOpacity, diameter, sidewallHeight, roofHeight, hopperHeight, wireframe, renderOrder } = props
|
||||
const cylinderGeometry = React.useMemo(() => ({
|
||||
radiusTop: diameter / 2,
|
||||
radiusBottom: diameter / 2,
|
||||
height: sidewallHeight,
|
||||
radialSegments,
|
||||
heightSegments: 2,
|
||||
openEnded: true,
|
||||
} as CylinderGeometry), [diameter, sidewallHeight, radialSegments]);
|
||||
|
||||
const roofGeometry = React.useMemo(() => ({
|
||||
height: roofHeight,
|
||||
radius: diameter /2,
|
||||
radialSegments: radialSegments,
|
||||
openEnded: true
|
||||
} as ConeGeometry), [diameter, roofHeight, radialSegments])
|
||||
|
||||
const hopperGeometry = React.useMemo(() => ({
|
||||
height: hopperHeight,
|
||||
radius: diameter /2,
|
||||
radialSegments: radialSegments,
|
||||
openEnded: true
|
||||
} as ConeGeometry),[hopperHeight, diameter, radialSegments])
|
||||
|
||||
const flatBottomGeometry = React.useMemo(() => ({
|
||||
radius: diameter/2,
|
||||
radialSegments: radialSegments
|
||||
} as CircleGeometry),[diameter, radialSegments])
|
||||
|
||||
const roofPosition = React.useMemo(
|
||||
() => new Vector3(0, sidewallHeight/2 + roofHeight/2, 0),
|
||||
[sidewallHeight, roofHeight]
|
||||
);
|
||||
|
||||
const bottomPosition = React.useMemo(
|
||||
() => {
|
||||
if(hopperHeight !== undefined && hopperHeight > 0){
|
||||
//this returns the position for the center of the cone for the hopper
|
||||
return new Vector3(0, (sidewallHeight/2 + hopperHeight/2)*-1, 0)
|
||||
} else {
|
||||
//this returns the position for the circle for the flat bottom
|
||||
return new Vector3(0, -sidewallHeight/2, 0)
|
||||
}
|
||||
},
|
||||
[sidewallHeight, hopperHeight]
|
||||
)
|
||||
|
||||
const hopperRotation = React.useMemo(
|
||||
() => new Euler(Math.PI, 0, 0),
|
||||
[]
|
||||
);
|
||||
|
||||
const flatBottomRotation = React.useMemo(
|
||||
() => new Euler(-Math.PI / 2, 0, 0),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{/* bin roof - cone */}
|
||||
<Cone
|
||||
geometry={roofGeometry}
|
||||
colour={binBodyColour}
|
||||
wireframe= {wireframe}
|
||||
metalness={binMetalness}
|
||||
position={roofPosition}
|
||||
roughness={binRoughness}
|
||||
side={2}
|
||||
opacity={binOpacity}
|
||||
depthWrite={false}
|
||||
depthTest={false}
|
||||
renderOrder={renderOrder}/>
|
||||
{/* bin sidewall - cylinder (open ended for the roof and bottom)*/}
|
||||
<Cylinder
|
||||
geometry={cylinderGeometry}
|
||||
colour={binBodyColour}
|
||||
wireframe= {wireframe}
|
||||
metalness={binMetalness}
|
||||
roughness={binRoughness}
|
||||
opacity={binOpacity}
|
||||
depthWrite={false}
|
||||
depthTest={false}
|
||||
renderOrder={renderOrder}/>
|
||||
{/* bin bottom - cone -OR- circle depending on the bins shape*/}
|
||||
{hopperHeight !== undefined && hopperHeight > 0 ?
|
||||
<Cone
|
||||
geometry={hopperGeometry}
|
||||
colour={binBodyColour}
|
||||
wireframe= {wireframe}
|
||||
metalness={binMetalness}
|
||||
position={bottomPosition}
|
||||
rotation={hopperRotation}
|
||||
roughness={binRoughness}
|
||||
opacity={binOpacity}
|
||||
side={2}
|
||||
depthWrite={false}
|
||||
depthTest={false}
|
||||
renderOrder={renderOrder}
|
||||
/>
|
||||
:
|
||||
<Circle
|
||||
geometry={flatBottomGeometry}
|
||||
colour={binBodyColour}
|
||||
wireframe= {wireframe}
|
||||
metalness={binMetalness}
|
||||
position={bottomPosition}
|
||||
rotation={flatBottomRotation}
|
||||
roughness={binRoughness}
|
||||
opacity={binOpacity}
|
||||
side={2}
|
||||
depthWrite={false}
|
||||
depthTest={false}
|
||||
renderOrder={renderOrder}
|
||||
/>
|
||||
}
|
||||
</React.Fragment>
|
||||
)
|
||||
|
||||
}
|
||||
212
src/bin/3dView/Data/BuildCableData.ts
Normal file
212
src/bin/3dView/Data/BuildCableData.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { RadiansToDegrees } from "common/TrigFunctions";
|
||||
import { Bin } from "models";
|
||||
import { GrainCable } from "models/GrainCable";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { Vector3 } from "three";
|
||||
import { avg } from "utils";
|
||||
|
||||
export interface CableData {
|
||||
cableIndex: number
|
||||
radius: number
|
||||
topPosition: Vector3
|
||||
bottomPosition: Vector3
|
||||
grainCable: pond.GrainCable
|
||||
radialSegments?: number
|
||||
heightSegments?: number
|
||||
}
|
||||
|
||||
export interface CableOrbit {
|
||||
orbit: number;
|
||||
radius: number; // in cm
|
||||
expectedCount: number;
|
||||
assignedCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective bin dimensions used for cable placement.
|
||||
* Passed in from Bin3dView so that default fallback values are applied
|
||||
* consistently — cables will always match the rendered shell even when
|
||||
* the bin has no advanced dimensions configured.
|
||||
*/
|
||||
export interface BinDims {
|
||||
diameter: number
|
||||
sidewallHeight: number
|
||||
roofHeight: number
|
||||
hopperHeight: number
|
||||
}
|
||||
|
||||
|
||||
function getLayoutFromDiameter(diameterCm: number): CableOrbit[] {
|
||||
const diameterFt = diameterCm / 30.48;
|
||||
|
||||
let summaries: { Orbit: number; DistanceFromCenter: number; NumberOfCables: number }[] = [];
|
||||
|
||||
if (diameterFt < 24) {
|
||||
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
|
||||
} else if (diameterFt < 36) {
|
||||
summaries.push({ Orbit: 1, DistanceFromCenter: 7, NumberOfCables: 3 });
|
||||
} else if (diameterFt < 42) {
|
||||
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
|
||||
summaries.push({ Orbit: 1, DistanceFromCenter: 9, NumberOfCables: 3 });
|
||||
} else if (diameterFt < 48) {
|
||||
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
|
||||
summaries.push({ Orbit: 1, DistanceFromCenter: 14, NumberOfCables: 4 });
|
||||
} else if (diameterFt < 54) {
|
||||
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
|
||||
summaries.push({ Orbit: 1, DistanceFromCenter: 16, NumberOfCables: 5 });
|
||||
} else if (diameterFt < 60) {
|
||||
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
|
||||
summaries.push({ Orbit: 1, DistanceFromCenter: 18, NumberOfCables: 6 });
|
||||
} else if (diameterFt < 72) {
|
||||
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
|
||||
summaries.push({ Orbit: 1, DistanceFromCenter: 20, NumberOfCables: 7 });
|
||||
} else if (diameterFt < 78) {
|
||||
summaries.push({ Orbit: 1, DistanceFromCenter: 10, NumberOfCables: 4 });
|
||||
summaries.push({ Orbit: 2, DistanceFromCenter: 27, NumberOfCables: 9 });
|
||||
} else if (diameterFt < 90) {
|
||||
summaries.push({ Orbit: 1, DistanceFromCenter: 10, NumberOfCables: 4 });
|
||||
summaries.push({ Orbit: 2, DistanceFromCenter: 29, NumberOfCables: 10 });
|
||||
} else if (diameterFt < 105) {
|
||||
summaries.push({ Orbit: 0, DistanceFromCenter: 2, NumberOfCables: 1 });
|
||||
summaries.push({ Orbit: 1, DistanceFromCenter: 18, NumberOfCables: 6 });
|
||||
summaries.push({ Orbit: 2, DistanceFromCenter: 36, NumberOfCables: 12 });
|
||||
} else {
|
||||
summaries.push({ Orbit: 1, DistanceFromCenter: 9, NumberOfCables: 3 });
|
||||
summaries.push({ Orbit: 2, DistanceFromCenter: 26.5, NumberOfCables: 9 });
|
||||
summaries.push({ Orbit: 3, DistanceFromCenter: 44, NumberOfCables: 14 });
|
||||
}
|
||||
|
||||
return summaries.map(s => ({
|
||||
orbit: s.Orbit,
|
||||
radius: s.DistanceFromCenter * 30.48, // ft → cm
|
||||
expectedCount: s.NumberOfCables
|
||||
}));
|
||||
};
|
||||
|
||||
function distributeCables(cableCount: number, layout: CableOrbit[]) {
|
||||
const totalExpected = layout.reduce((sum, o) => sum + o.expectedCount, 0);
|
||||
|
||||
let assigned = layout.map(o => ({
|
||||
...o,
|
||||
assignedCount: Math.round((o.expectedCount / totalExpected) * cableCount)
|
||||
}));
|
||||
|
||||
// normalize rounding
|
||||
let currentTotal = assigned.reduce((sum, o) => sum + (o.assignedCount ?? 0), 0);
|
||||
|
||||
while (currentTotal < cableCount) {
|
||||
assigned[assigned.length - 1].assignedCount!++;
|
||||
currentTotal++;
|
||||
}
|
||||
|
||||
while (currentTotal > cableCount) {
|
||||
assigned[assigned.length - 1].assignedCount!--;
|
||||
currentTotal--;
|
||||
}
|
||||
|
||||
return assigned;
|
||||
};
|
||||
|
||||
function getTopY(radiusFromCenter: number, dims: BinDims) {
|
||||
const binRadius = dims.diameter / 2;
|
||||
|
||||
const distanceFromEdge = binRadius - radiusFromCenter;
|
||||
|
||||
// Use the real roof angle when available; when dimensions aren't configured
|
||||
// the angle will be 0 which gives a flat cable top — correct for a default shape.
|
||||
const angleRad = Math.atan((dims.roofHeight) / (binRadius));
|
||||
|
||||
const roofRise = Math.tan(angleRad) * distanceFromEdge;
|
||||
|
||||
return dims.sidewallHeight / 2 + roofRise;
|
||||
};
|
||||
|
||||
function getBottomY(radiusFromCenter: number, dims: BinDims) {
|
||||
const binRadius = dims.diameter / 2;
|
||||
|
||||
const distanceFromCenter = binRadius - radiusFromCenter;
|
||||
|
||||
const angleRad = Math.atan((dims.hopperHeight) / (binRadius))
|
||||
|
||||
const hopperDrop = Math.tan(angleRad) * distanceFromCenter;
|
||||
|
||||
const clearance = 60; // cm
|
||||
|
||||
return -dims.sidewallHeight / 2 - hopperDrop + clearance;
|
||||
};
|
||||
|
||||
|
||||
export function BuildCableData(bin: Bin, dims: BinDims): CableData[] {
|
||||
const cables = [...(bin.status.grainCables ?? [])];
|
||||
|
||||
if (cables.length === 0) return [];
|
||||
|
||||
// optional: sort cables (better visual consistency later)
|
||||
//cables.sort((a, b) => b.celcius.length - a.celcius.length);
|
||||
cables.sort((a, b) => {
|
||||
//if the cables have the same number of nodes
|
||||
if (b.celcius.length === a.celcius.length) {
|
||||
//sort by temp only last and any type that has humidity first
|
||||
if ((avg(a.relativeHumidity) === 0) && (avg(b.relativeHumidity) !== 0)) {
|
||||
return 1;
|
||||
} else if ((avg(b.relativeHumidity) === 0) && (avg(a.relativeHumidity) !== 0)) {
|
||||
return -1;
|
||||
}
|
||||
else {
|
||||
return a.key > b.key ? 1 : -1;
|
||||
}
|
||||
}
|
||||
//otherwise sort by the longest cable first
|
||||
return b.celcius.length - a.celcius.length;
|
||||
});
|
||||
|
||||
const layout = getLayoutFromDiameter(dims.diameter);
|
||||
const distributed = distributeCables(cables.length, layout);
|
||||
|
||||
const cableRadius = 3;
|
||||
|
||||
const result: CableData[] = [];
|
||||
|
||||
let cableIndex = 0;
|
||||
|
||||
distributed.forEach(orbit => {
|
||||
const count = orbit.assignedCount ?? 0;
|
||||
|
||||
const topY = getTopY(orbit.radius, dims); //this is if we want the cables to start at the roof
|
||||
//const topY = dims.sidewallHeight/2 //this will start the cables at the top of the sidewall
|
||||
|
||||
const bottomY = getBottomY(orbit.radius, dims);
|
||||
|
||||
if (orbit.orbit === 0 && count > 0) {
|
||||
const cable = cables[cableIndex];
|
||||
if (!cable) return;
|
||||
result.push({
|
||||
cableIndex: cableIndex,
|
||||
radius: cableRadius,
|
||||
topPosition: new Vector3(0, topY, 0),
|
||||
bottomPosition: new Vector3(0, bottomY, 0),
|
||||
grainCable: cable
|
||||
});
|
||||
cableIndex++;
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = (i / count) * Math.PI * 2;
|
||||
const x = Math.cos(angle) * orbit.radius;
|
||||
const z = Math.sin(angle) * orbit.radius;
|
||||
const cable = cables[cableIndex];
|
||||
|
||||
result.push({
|
||||
cableIndex: cableIndex,
|
||||
radius: cableRadius,
|
||||
topPosition: new Vector3(x, topY, z),
|
||||
bottomPosition: new Vector3(x, bottomY, z),
|
||||
grainCable: cable
|
||||
});
|
||||
cableIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
72
src/bin/3dView/Data/BuildNodeData.ts
Normal file
72
src/bin/3dView/Data/BuildNodeData.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Vector3 } from "three"
|
||||
import { CableData } from "./BuildCableData"
|
||||
|
||||
export interface NodeData {
|
||||
cableIndex: number
|
||||
nodeIndex: number
|
||||
radius: number
|
||||
position: Vector3
|
||||
celcius: number
|
||||
humidity?: number
|
||||
moisture?: number
|
||||
topNode?: boolean
|
||||
excluded?: boolean
|
||||
inGrain?: boolean
|
||||
/**
|
||||
* The vertical spacing between nodes on this cable (cm).
|
||||
* Used by GrainCableFill to offset the grain surface half a spacing above the top node,
|
||||
* matching the 2D bin view behaviour.
|
||||
*/
|
||||
nodeSpacing: number
|
||||
}
|
||||
|
||||
export function BuildNodeData(cables: CableData[]): NodeData[] {
|
||||
const nodeRadius = 10
|
||||
let nodeData: NodeData[] = []
|
||||
|
||||
cables.forEach((cable, cableIndex) => {
|
||||
const grainCable = cable.grainCable
|
||||
|
||||
const nodeCount = grainCable.celcius.length;
|
||||
|
||||
if (nodeCount === 0) return;
|
||||
|
||||
const bottomY = cable.bottomPosition.y;
|
||||
const topY = cable.topPosition.y;
|
||||
const height = topY - bottomY;
|
||||
|
||||
const spacing = height / nodeCount;
|
||||
|
||||
grainCable.celcius.forEach((celcius, i) => {
|
||||
|
||||
const nodeY = bottomY + (i * spacing);
|
||||
|
||||
const humidity = grainCable.relativeHumidity[i] || undefined;
|
||||
const moisture = grainCable.moisture[i] || undefined;
|
||||
let t = celcius
|
||||
// for testing
|
||||
// if(i===0){
|
||||
// t = 40
|
||||
// }
|
||||
// if (i===5){
|
||||
// t = 0
|
||||
// }
|
||||
|
||||
nodeData.push({
|
||||
cableIndex: cableIndex,
|
||||
nodeIndex: i,
|
||||
radius: nodeRadius,
|
||||
position: new Vector3(cable.bottomPosition.x, nodeY, cable.bottomPosition.z),
|
||||
celcius: t,
|
||||
humidity: humidity,
|
||||
moisture: moisture,
|
||||
topNode: grainCable.topNode === i + 1,
|
||||
excluded: grainCable.excludedNodes?.includes(i) ?? false,
|
||||
inGrain: i + 1 <= grainCable.topNode || !grainCable.topNode,
|
||||
nodeSpacing: spacing,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return nodeData
|
||||
}
|
||||
216
src/bin/3dView/Scene/Bin3dView.tsx
Normal file
216
src/bin/3dView/Scene/Bin3dView.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import React from "react";
|
||||
import OrbitCameraControls from "3dModels/CameraControls/OrbitCameraControls";
|
||||
import CameraOverlay from "3dModels/CameraControls/CameraOverlay";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { Bin } from "models";
|
||||
import BinShell from "../BinParts/BinShell";
|
||||
import GrainFillFlat from "../Systems/Inventory/GrainFillFlat";
|
||||
import BinCables from "../Systems/Cables/BinCables";
|
||||
// import { Vector3 } from "three";
|
||||
import { useMemo } from "react";
|
||||
import { BuildCableData, CableData } from "../Data/BuildCableData";
|
||||
import { BuildNodeData, NodeData } from "../Data/BuildNodeData";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import GrainCableFill from "../Systems/Inventory/GrainCableFill";
|
||||
import TempHeatMap from "../Systems/Heatmap/TempHeatMap";
|
||||
import NodePointCloud from "../Systems/Heatmap/NodePointCloud";
|
||||
// import { Box } from "@mui/material";
|
||||
import MoistureHeatMap from "../Systems/Heatmap/MoistureHeatmap";
|
||||
import { grainYBoundsFromFillPercent } from "../utils/grainFillBounds";
|
||||
// import { RadiansToDegrees } from "common/TrigFunctions";
|
||||
// import { GrainCable } from "models/GrainCable"
|
||||
|
||||
// Fallback dimensions used when a bin has no advanced dimensions configured.
|
||||
// Chosen to resemble a typical mid-size grain bin.
|
||||
const DEFAULT_ROOF_HEIGHT_CM = 200; // 2 m
|
||||
const DEFAULT_FLAT_HEIGHT_CM = 0; // flat bottom
|
||||
const DEFAULT_HOPPER_HEIGHT_CM = 280; // hopper bottom
|
||||
|
||||
interface Props {
|
||||
bin: Bin
|
||||
scale: number
|
||||
/**
|
||||
* cables can come from the bins status when not passed in, however data may be out of synce from when the cables read to when the bins status updates
|
||||
* they may need to be passed in but for the moment we wont worry about it due to the last active on the bin page can be used to explain being out of sync
|
||||
* if it becomes an issue I can make the changes necessary to pass the cable components in
|
||||
*/
|
||||
// cables?: GrainCable[]
|
||||
/**
|
||||
* the percentage of the bin to be filled, expects a number between 0 and 1
|
||||
*/
|
||||
fillPercent?: number
|
||||
nodeClick?: (node: NodeData, cable: CableData) => void
|
||||
/**
|
||||
* When true, renders the basic fill and suppresses the heatmaps.
|
||||
* Heatmaps already simulate the grain level so showing both is redundant.
|
||||
*/
|
||||
showGrain?: boolean
|
||||
showTempHeatmap?: boolean
|
||||
showMoistureHeatmap?: boolean
|
||||
showHotspots?: boolean
|
||||
/**
|
||||
* When true renders a reset camera button overlaid on the 3D view.
|
||||
*/
|
||||
showResetButton?: boolean
|
||||
showTemp?: boolean
|
||||
showMoisture?: boolean
|
||||
xOffset?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export default function Bin3dView(props: Props) {
|
||||
const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture, xOffset, width = 1, height = 1 } = props
|
||||
|
||||
// Resolve effective dimensions, falling back to defaults when advanced
|
||||
// dimensions have not been configured (bin methods return 0 in that case).
|
||||
const dims = useMemo(() => ({
|
||||
diameter: bin.diameter(),
|
||||
sidewallHeight: bin.sidewallHeight() || bin.diameter() + 50, //calculated using the diameter so 'default' bins always have the same shape
|
||||
roofHeight: bin.roofHeight() || DEFAULT_ROOF_HEIGHT_CM,
|
||||
hopperHeight: bin.hopperHeight() || (bin.binShape() === pond.BinShape.BIN_SHAPE_HOPPER_BOTTOM ? DEFAULT_HOPPER_HEIGHT_CM : DEFAULT_FLAT_HEIGHT_CM),
|
||||
}), [bin]);
|
||||
|
||||
// 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 = (dims.sidewallHeight + dims.roofHeight + dims.hopperHeight) / scale;
|
||||
const diameter = dims.diameter / scale;
|
||||
const largestDim = Math.max(totalHeight, diameter);
|
||||
const fovRad = (75 * Math.PI) / 180;
|
||||
const aspect = width/height
|
||||
const aspectPadding = aspect < 1 ? 1.4 : 1.0 // extra room on mobile
|
||||
// const padding = 1.3; // 30% breathing room
|
||||
const padding = (1.1 + (largestDim/100) * 4.5) * aspectPadding
|
||||
return (largestDim / (2 * Math.tan(fovRad / 2))) * padding;
|
||||
}, [dims, scale]);
|
||||
|
||||
const cableData = useMemo(() => BuildCableData(bin, dims), [bin, dims]);
|
||||
const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]);
|
||||
|
||||
const isCableInventory =
|
||||
bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC ||
|
||||
bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_CABLE;
|
||||
|
||||
// For cable inventory, TempHeatMap derives the wavy surface directly from
|
||||
// the top nodes in nodeData — no scalar needed.
|
||||
//
|
||||
// Flat fill: Y bounds from the same volume math as GrainFillFlat.
|
||||
const flatGrainBounds = useMemo(() => {
|
||||
if (isCableInventory || fillPercent === undefined) return undefined;
|
||||
return grainYBoundsFromFillPercent(
|
||||
dims.diameter,
|
||||
dims.sidewallHeight,
|
||||
dims.hopperHeight,
|
||||
dims.roofHeight,
|
||||
fillPercent
|
||||
);
|
||||
}, [isCableInventory, fillPercent, dims]);
|
||||
|
||||
// Internal ref — wired to OrbitCameraControls and called by CameraOverlay
|
||||
const resetCameraFn = React.useRef<(() => void) | null>(null);
|
||||
|
||||
const grainInventory = () => {
|
||||
const grainColour = "#fff300";
|
||||
//note that adjusting the opacity is how to adjust the brightness, less makes it dimmer
|
||||
if (isCableInventory) {
|
||||
return (
|
||||
<GrainCableFill
|
||||
diameter={dims.diameter}
|
||||
colour={grainColour}
|
||||
nodes={nodeData}
|
||||
sidewallHeight={dims.sidewallHeight}
|
||||
fallbackFillPercent={fillPercent}
|
||||
hopperHeight={dims.hopperHeight}
|
||||
// grainOpacity={0.7}
|
||||
/>
|
||||
)
|
||||
} else if (fillPercent) {
|
||||
return (
|
||||
<GrainFillFlat
|
||||
diameter={dims.diameter}
|
||||
colour={grainColour}
|
||||
|
||||
sidewallHeight={dims.sidewallHeight}
|
||||
hopperHeight={dims.hopperHeight}
|
||||
fillPercent={fillPercent}
|
||||
// grainOpacity={0.7}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Canvas style={{position: "absolute", inset: 0}}>
|
||||
<OrbitCameraControls
|
||||
clampVerticalRotation
|
||||
initialRadius={initialCameraRadius}
|
||||
maxRadius={initialCameraRadius * 2}
|
||||
onReset={fn => { resetCameraFn.current = fn; }}
|
||||
viewOffset={xOffset}
|
||||
/>
|
||||
<group scale={[1 / scale, 1 / scale, 1 / scale]}>
|
||||
<BinShell
|
||||
binBodyColour="#fff"
|
||||
radialSegments={20}
|
||||
binMetalness={0.7}
|
||||
binRoughness={0.3}
|
||||
binOpacity={0.2}
|
||||
diameter={dims.diameter}
|
||||
sidewallHeight={dims.sidewallHeight}
|
||||
roofHeight={dims.roofHeight}
|
||||
hopperHeight={dims.hopperHeight}
|
||||
renderOrder={4}
|
||||
/>
|
||||
|
||||
<BinCables
|
||||
displayTemp={showTemp}
|
||||
displayMoisture={showMoisture}
|
||||
cableData={cableData}
|
||||
nodeData={nodeData}
|
||||
bin={bin}
|
||||
onNodeClick={(node, cable) => {
|
||||
if (nodeClick) nodeClick(node, cable)
|
||||
}}
|
||||
renderOrder={5}
|
||||
/>
|
||||
|
||||
{showHotspots && <NodePointCloud bin={bin} nodes={nodeData} />}
|
||||
|
||||
{/* note that the heatmaps internally use render order 1,2, and 3 for the three separate coloured meshes */}
|
||||
{showGrain ? grainInventory() :
|
||||
showTempHeatmap && nodeData.length > 0
|
||||
? <TempHeatMap
|
||||
binDimensions={dims}
|
||||
targetTemp={bin.targetTemp()}
|
||||
nodes={nodeData}
|
||||
flatMaxY={flatGrainBounds?.maxY}
|
||||
flatMinY={flatGrainBounds?.minY}
|
||||
/>
|
||||
: showMoistureHeatmap && nodeData.length > 0
|
||||
? <MoistureHeatMap
|
||||
binDimensions={dims}
|
||||
targetMoisture={bin.targetMoisture()}
|
||||
nodes={nodeData}
|
||||
flatMaxY={flatGrainBounds?.maxY}
|
||||
flatMinY={flatGrainBounds?.minY}
|
||||
/>
|
||||
: grainInventory()
|
||||
}
|
||||
|
||||
</group>
|
||||
|
||||
<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={[5, 0, 0]} />
|
||||
<directionalLight intensity={0.2} color={"white"} position={[-5, 0, 0]} />
|
||||
|
||||
{/* Overlay — must be inside <Canvas> so it shares the same stacking context */}
|
||||
{showResetButton && (
|
||||
<CameraOverlay onReset={() => resetCameraFn.current?.()} />
|
||||
)}
|
||||
</Canvas>
|
||||
)
|
||||
}
|
||||
44
src/bin/3dView/Systems/Cables/BinCables.tsx
Normal file
44
src/bin/3dView/Systems/Cables/BinCables.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import GrainCable from "./GrainCable";
|
||||
import { Bin } from "models";
|
||||
import React from "react";
|
||||
import { CableData } from "../../Data/BuildCableData";
|
||||
import { NodeData } from "../../Data/BuildNodeData";
|
||||
|
||||
interface Props {
|
||||
cableData: CableData[],
|
||||
nodeData: NodeData[],
|
||||
bin: Bin,
|
||||
renderOrder?: number,
|
||||
displayTemp?: boolean,
|
||||
displayMoisture?: boolean,
|
||||
onNodeClick?: (node: NodeData, cable: CableData) => void
|
||||
}
|
||||
|
||||
export default function BinCables(props: Props){
|
||||
const {bin, cableData, nodeData, onNodeClick, renderOrder, displayTemp, displayMoisture} = props
|
||||
return (
|
||||
<React.Fragment>
|
||||
{cableData.map((cable, i) => {
|
||||
const cableNodes = nodeData.filter(n => n.cableIndex === cable.cableIndex);
|
||||
|
||||
return (
|
||||
<GrainCable
|
||||
key={"cable " + i}
|
||||
cable={cable}
|
||||
nodes={cableNodes}
|
||||
bin={bin}
|
||||
displayTemp={displayTemp}
|
||||
displayMoisture={displayMoisture}
|
||||
onNodeClick={(node) => {
|
||||
if(onNodeClick){
|
||||
onNodeClick(node, cable)
|
||||
}
|
||||
}}
|
||||
renderOrder={renderOrder}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
187
src/bin/3dView/Systems/Cables/CableNode.tsx
Normal file
187
src/bin/3dView/Systems/Cables/CableNode.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import Sphere, { SphereGeometry } from "3dModels/Shapes/3D/Sphere"
|
||||
import { useFrame, useThree } from "@react-three/fiber"
|
||||
import { Text } from "@react-three/drei"
|
||||
import React, { useMemo } from "react"
|
||||
import { Euler, Group, Vector3 } from "three"
|
||||
import { green, grey, orange, red } from "@mui/material/colors";
|
||||
import { useGlobalState } from "providers"
|
||||
import { pond } from "protobuf-ts/pond"
|
||||
import Ring, { RingGeometry } from "3dModels/Shapes/3D/Ring"
|
||||
import { NodeData } from "../../Data/BuildNodeData"
|
||||
|
||||
interface Props {
|
||||
node: NodeData
|
||||
upperThreshold: number,
|
||||
targetMoisture: number,
|
||||
renderOrder?: number,
|
||||
displayTemp?: boolean,
|
||||
displayMoisture?: boolean,
|
||||
onNodeClick?: (node: NodeData) => void
|
||||
}
|
||||
|
||||
export default function CableNode(props: Props) {
|
||||
const { node, upperThreshold, targetMoisture, onNodeClick, renderOrder, displayTemp, displayMoisture } = props
|
||||
const { camera } = useThree();
|
||||
const [{ user }] = useGlobalState();
|
||||
// const [showLabel, setShowLabel] = useState(false);
|
||||
const ROW_HEIGHT = 35;
|
||||
const PADDING = 5;
|
||||
const LABEL_WIDTH = 200;
|
||||
const FONT_SIZE = 25;
|
||||
const flagRef = React.useRef<Group>(null);
|
||||
|
||||
useFrame(() => {
|
||||
if (!flagRef.current || !showLabel) return;
|
||||
|
||||
// Get camera's right direction in world space
|
||||
const cameraRight = new Vector3();
|
||||
camera.getWorldDirection(cameraRight); // forward
|
||||
const up = new Vector3(0, 1, 0);
|
||||
cameraRight.crossVectors(cameraRight, up).normalize(); // right = forward × up
|
||||
|
||||
const pos = node.position.clone();
|
||||
pos.addScaledVector(cameraRight, LABEL_WIDTH / 2); // offset right in screen space
|
||||
pos.addScaledVector(camera.position.clone().sub(node.position).normalize(), 1); // nudge toward camera
|
||||
|
||||
flagRef.current.position.copy(pos);
|
||||
flagRef.current.quaternion.copy(camera.quaternion);
|
||||
});
|
||||
|
||||
const tempDisplay = useMemo(() => {
|
||||
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
|
||||
return ((node.celcius * 9 / 5) + 32).toFixed(2) + "°F"
|
||||
}
|
||||
return node.celcius.toFixed(2) + "°C"
|
||||
}, [node.celcius, user]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const anyToggleOn = displayTemp || displayMoisture;
|
||||
|
||||
if (node.excluded) {
|
||||
if (!anyToggleOn) return [];
|
||||
return [{ text: "Excluded", color: grey[700] }];
|
||||
}
|
||||
|
||||
const r: { text: string; color: string }[] = [];
|
||||
|
||||
// TEMP
|
||||
if (displayTemp && node.celcius !== 0) {
|
||||
r.push({
|
||||
text: tempDisplay,
|
||||
// color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour()
|
||||
//will be green when below threshold, orange when it goeas above but by less than 5 degrees, and red if more than 5 over
|
||||
color: node.celcius > upperThreshold ? node.celcius > upperThreshold + 5 ? red[700] : orange[800]: green[800]
|
||||
});
|
||||
}
|
||||
|
||||
// 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()
|
||||
color: node.moisture > targetMoisture ? node.moisture > targetMoisture + 0.5 ? red[700] : orange[800]: green[800]
|
||||
});
|
||||
}
|
||||
//apparently we dont want to display humidity at all
|
||||
// else if (node.humidity && node.humidity > 0) {
|
||||
// r.push({
|
||||
// text: `${node.humidity.toFixed(2)}%`,
|
||||
// // color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour()
|
||||
// color: "black"
|
||||
// });
|
||||
// }
|
||||
}
|
||||
|
||||
const hasData = r.length > 0;
|
||||
|
||||
// Only show top node if there's actual data
|
||||
if (node.topNode && hasData) {
|
||||
r.unshift({ text: "Top Node", color: grey[700] });
|
||||
}
|
||||
|
||||
return r;
|
||||
}, [node, tempDisplay, displayTemp, displayMoisture]);
|
||||
|
||||
const geometry = React.useMemo(() => ({
|
||||
radius: node.radius,
|
||||
} as SphereGeometry), [node]);
|
||||
|
||||
const topNodeGeo = React.useMemo(() => ({
|
||||
radius: node.radius,
|
||||
thickness: 5,
|
||||
} as RingGeometry), [node]);
|
||||
|
||||
const topNodePosition = React.useMemo(
|
||||
() => new Vector3(node.position.x, node.position.y + 25, node.position.z),
|
||||
[node]
|
||||
);
|
||||
|
||||
const topNodeRotation = React.useMemo(() => new Euler(-Math.PI / 2, 0, 0), []);
|
||||
|
||||
const labelHeight = rows.length * ROW_HEIGHT + PADDING;
|
||||
|
||||
const getRowY = (index: number) => {
|
||||
const totalHeight = rows.length * ROW_HEIGHT;
|
||||
return totalHeight / 2 - (index * ROW_HEIGHT) - ROW_HEIGHT / 2;
|
||||
};
|
||||
|
||||
const nodeColour = () => {
|
||||
if (node.excluded) return "grey";
|
||||
return "white";
|
||||
};
|
||||
|
||||
const handleClick = (e: any) => {
|
||||
e.stopPropagation();
|
||||
onNodeClick?.(node);
|
||||
};
|
||||
|
||||
const showLabel = rows.length > 0;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<React.Fragment>
|
||||
<Sphere
|
||||
geometry={geometry}
|
||||
position={node.position}
|
||||
colour={nodeColour()}
|
||||
onClick={handleClick}
|
||||
renderOrder={renderOrder}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.99}
|
||||
/>
|
||||
{node.topNode && (
|
||||
<Ring geometry={topNodeGeo} position={topNodePosition} rotation={topNodeRotation} renderOrder={renderOrder} opacity={0.99} depthTest={false} depthWrite={false}/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
{node.inGrain && showLabel &&
|
||||
<group ref={flagRef} renderOrder={999} onClick={handleClick}>
|
||||
<mesh position={[0, 0, -1]}>
|
||||
<planeGeometry args={[LABEL_WIDTH, labelHeight]} />
|
||||
<meshBasicMaterial
|
||||
color="white"
|
||||
transparent
|
||||
opacity={0.9}
|
||||
depthWrite={false}
|
||||
depthTest={false}
|
||||
/>
|
||||
</mesh>
|
||||
{rows.map((row, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
position={[0, getRowY(i), 0]}
|
||||
fontSize={FONT_SIZE}
|
||||
fontWeight={650}
|
||||
color={row.color}
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
>
|
||||
{row.text}
|
||||
</Text>
|
||||
))}
|
||||
</group>
|
||||
}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
50
src/bin/3dView/Systems/Cables/GrainCable.tsx
Normal file
50
src/bin/3dView/Systems/Cables/GrainCable.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import Cylinder, { CylinderGeometry } from "3dModels/Shapes/3D/Cylinder"
|
||||
import React from "react"
|
||||
import { Vector3 } from "three"
|
||||
import CableNode from "./CableNode"
|
||||
import { Bin } from "models"
|
||||
import { CableData } from "../../Data/BuildCableData"
|
||||
import { NodeData } from "../../Data/BuildNodeData"
|
||||
|
||||
interface Props {
|
||||
cable: CableData,
|
||||
nodes: NodeData[],
|
||||
bin: Bin,
|
||||
renderOrder?: number,
|
||||
displayTemp?: boolean,
|
||||
displayMoisture?: boolean,
|
||||
onNodeClick?: (node: NodeData) => void
|
||||
}
|
||||
export default function GrainCable(props: Props) {
|
||||
const {cable, nodes, bin, onNodeClick, renderOrder, displayTemp, displayMoisture} = props
|
||||
|
||||
const calculateHeight = () => {
|
||||
return cable.topPosition.distanceTo(cable.bottomPosition)
|
||||
}
|
||||
|
||||
const calculateCenter = () => {
|
||||
return cable.bottomPosition.clone()
|
||||
.add(
|
||||
cable.topPosition.clone()
|
||||
.sub(cable.bottomPosition)
|
||||
.multiplyScalar(0.5)
|
||||
);
|
||||
};
|
||||
|
||||
const geometry = React.useMemo(() => ({
|
||||
radiusTop: cable.radius,
|
||||
radiusBottom: cable.radius,
|
||||
height: calculateHeight(),
|
||||
radialSegments: cable.radialSegments ?? 10,
|
||||
heightSegments: cable.heightSegments ?? 2
|
||||
} as CylinderGeometry), [cable]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Cylinder geometry={geometry} position={calculateCenter()} opacity={0.99} renderOrder={renderOrder} depthWrite={false} depthTest={false}/>
|
||||
{nodes.map((node, i) => (
|
||||
<CableNode displayTemp={displayTemp} displayMoisture={displayMoisture} onNodeClick={onNodeClick} key={"node " + i} renderOrder={renderOrder} node={node} targetMoisture={bin.targetMoisture()} upperThreshold={bin.targetTemp()}/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
466
src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx
Normal file
466
src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
import { useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import { Bin } from "models";
|
||||
import { NodeData } from "../../Data/BuildNodeData";
|
||||
import React from "react";
|
||||
import { BinDims } from "bin/3dView/Data/BuildCableData";
|
||||
|
||||
interface Props {
|
||||
binDimensions: BinDims
|
||||
targetMoisture: number
|
||||
nodes: NodeData[];
|
||||
/**
|
||||
* For flat fill percent inventory — a scalar Y ceiling for the heatmap top.
|
||||
* Used when there are no cable top nodes to drive a surface.
|
||||
*/
|
||||
flatMaxY?: number;
|
||||
/** For flat fill percent — Y floor of filled grain (hopper tip or cylinder base). */
|
||||
flatMinY?: number;
|
||||
}
|
||||
|
||||
const RADIAL_RINGS = 20;
|
||||
const THETA_SEGMENTS = 40;
|
||||
const HEIGHT_STEPS = 28;
|
||||
|
||||
const YELLOW_DELTA = 2;
|
||||
const RED_DELTA = 4;
|
||||
|
||||
const IDW_POWER = 4;
|
||||
const RED_OPACITY = 0.3;
|
||||
const GREEN_OPACITY = 0.3;
|
||||
const YELLOW_OPACITY = 0.8;
|
||||
|
||||
const ANGLE_JITTER = 0.2;
|
||||
const RADIAL_JITTER = 0.05;
|
||||
const LAYER_TWIST = 0.22;
|
||||
|
||||
// IDW power for the horizontal surface interpolation —
|
||||
// matches GrainCableFill's default of 2
|
||||
const SURFACE_IDW_POWER = 2;
|
||||
|
||||
function moistureToHeat(moisture: number, target: number): number {
|
||||
if (moisture <= target) return 0;
|
||||
const delta = moisture - target;
|
||||
if (delta >= RED_DELTA) return 2;
|
||||
if (delta >= YELLOW_DELTA) return 1 + (delta - YELLOW_DELTA) / (RED_DELTA - YELLOW_DELTA);
|
||||
return delta / YELLOW_DELTA;
|
||||
}
|
||||
|
||||
function heatToRGB(heat: number): number[] {
|
||||
const GREEN = [0, 0.5, 0.02];
|
||||
const YELLOW = [0.7, 0.86, 0.0];
|
||||
const RED = [0.8, 0.0, 0.01];
|
||||
|
||||
if (heat <= 0) return GREEN;
|
||||
|
||||
if (heat <= 1) {
|
||||
const t = Math.pow(heat, 0.75);
|
||||
return [
|
||||
GREEN[0] + (YELLOW[0] - GREEN[0]) * t,
|
||||
GREEN[1] + (YELLOW[1] - GREEN[1]) * t,
|
||||
GREEN[2] + (YELLOW[2] - GREEN[2]) * t,
|
||||
];
|
||||
}
|
||||
|
||||
const t = Math.pow(heat - 1, 0.75);
|
||||
return [
|
||||
YELLOW[0] + (RED[0] - YELLOW[0]) * t,
|
||||
YELLOW[1] + (RED[1] - YELLOW[1]) * t,
|
||||
YELLOW[2] + (RED[2] - YELLOW[2]) * t,
|
||||
];
|
||||
}
|
||||
|
||||
interface MoistureAnchor {
|
||||
x: number; y: number; z: number;
|
||||
moisture: number;
|
||||
}
|
||||
|
||||
interface SurfaceAnchor {
|
||||
x: number; z: number; y: number;
|
||||
}
|
||||
|
||||
// 3D IDW for moisture interpolation
|
||||
function idwMoisture(
|
||||
px: number, py: number, pz: number,
|
||||
anchors: MoistureAnchor[],
|
||||
power: number
|
||||
): number {
|
||||
let totalWeight = 0;
|
||||
let weightedSum = 0;
|
||||
|
||||
for (const a of anchors) {
|
||||
const dx = px - a.x;
|
||||
const dy = py - a.y;
|
||||
const dz = pz - a.z;
|
||||
const distSq = dx * dx + dy * dy + dz * dz;
|
||||
if (distSq < 0.001) return a.moisture;
|
||||
const weight = 1 / Math.pow(distSq, power / 2);
|
||||
totalWeight += weight;
|
||||
weightedSum += a.moisture * weight;
|
||||
}
|
||||
|
||||
return totalWeight === 0 ? 0 : weightedSum / totalWeight;
|
||||
}
|
||||
|
||||
// 2D IDW for surface height interpolation — matches GrainCableFill exactly
|
||||
function idwSurfaceY(
|
||||
x: number, z: number,
|
||||
anchors: SurfaceAnchor[],
|
||||
power: number
|
||||
): number {
|
||||
let totalWeight = 0;
|
||||
let weightedY = 0;
|
||||
|
||||
for (const a of anchors) {
|
||||
const dx = x - a.x;
|
||||
const dz = z - a.z;
|
||||
const distSq = dx * dx + dz * dz;
|
||||
if (distSq < 0.001) return a.y;
|
||||
const weight = 1 / Math.pow(distSq, power / 2);
|
||||
totalWeight += weight;
|
||||
weightedY += a.y * weight;
|
||||
}
|
||||
|
||||
return totalWeight === 0 ? 0 : weightedY / totalWeight;
|
||||
}
|
||||
|
||||
function hashNoise(a: number, b: number, c: number) {
|
||||
const x = Math.sin(a * 127.1 + b * 311.7 + c * 74.7) * 43758.5453;
|
||||
return (x - Math.floor(x)) * 2 - 1;
|
||||
}
|
||||
|
||||
export default function MoistureHeatMap({ binDimensions, targetMoisture, nodes, flatMaxY, flatMinY }: Props) {
|
||||
const binRadius = binDimensions.diameter / 2;
|
||||
const sidewallHeight = binDimensions.sidewallHeight;
|
||||
const hopperHeight = binDimensions.hopperHeight ?? 0;
|
||||
const roofHeight = binDimensions.roofHeight ?? 0;
|
||||
|
||||
const sidewallBaseY = -sidewallHeight / 2;
|
||||
const sidewallTopY = sidewallHeight / 2;
|
||||
const hopperTipY = sidewallBaseY - hopperHeight;
|
||||
const roofTipY = sidewallTopY + roofHeight;
|
||||
|
||||
const maxRadiusAtY = (y: number) => {
|
||||
if (y > sidewallTopY) {
|
||||
if (roofHeight <= 0 || y >= roofTipY) return 0;
|
||||
return binRadius * (1 - (y - sidewallTopY) / roofHeight);
|
||||
}
|
||||
if (y >= sidewallBaseY) return binRadius;
|
||||
if (hopperHeight <= 0 || y <= hopperTipY) return 0;
|
||||
return binRadius * ((y - hopperTipY) / hopperHeight);
|
||||
};
|
||||
|
||||
// Moisture anchors — all in-grain nodes that have a moisture reading
|
||||
const moistureAnchors = useMemo<MoistureAnchor[]>(
|
||||
() =>
|
||||
nodes
|
||||
.filter(n => n.inGrain && !n.excluded && n.moisture !== undefined)
|
||||
.map(n => ({
|
||||
x: n.position.x,
|
||||
y: n.position.y,
|
||||
z: n.position.z,
|
||||
moisture: n.moisture as number,
|
||||
})),
|
||||
[nodes]
|
||||
);
|
||||
|
||||
// Surface anchors — top nodes only, Y offset by half spacing.
|
||||
// Only used when flatMaxY is NOT provided (i.e. Automatic / Hybrid_Cable
|
||||
// inventory control). For any other control type the caller passes flatMaxY
|
||||
// and we must use a flat surface instead of the wavy cable-driven one.
|
||||
const surfaceAnchors = useMemo<SurfaceAnchor[]>(() => {
|
||||
if (flatMaxY !== undefined) {
|
||||
// Non-cable inventory: force flat surface — ignore cable top nodes
|
||||
return [];
|
||||
}
|
||||
return nodes
|
||||
.filter(n => n.topNode && n.inGrain && !n.excluded)
|
||||
.map(n => ({
|
||||
x: n.position.x,
|
||||
z: n.position.z,
|
||||
y: n.position.y + n.nodeSpacing * 0.5,
|
||||
}));
|
||||
}, [nodes, flatMaxY]);
|
||||
|
||||
// Wall clamp Y — average of surface anchor heights.
|
||||
// Prevents the surface from piling up at the bin wall where there are no cables.
|
||||
// Matches GrainCableFill's wallY computation.
|
||||
const wallY = useMemo(() => {
|
||||
if (surfaceAnchors.length === 0) return flatMaxY ?? sidewallBaseY;
|
||||
return surfaceAnchors.reduce((sum, a) => sum + a.y, 0) / surfaceAnchors.length;
|
||||
}, [surfaceAnchors, flatMaxY, sidewallBaseY]);
|
||||
|
||||
// Lowest Y the grain surface or heatmap may occupy (hopper tip or cylinder floor).
|
||||
const grainFloorY = hopperHeight > 0 ? hopperTipY : sidewallBaseY;
|
||||
|
||||
// For each (x, z) position, returns the Y ceiling of the grain surface.
|
||||
// Uses cable surface IDW when top nodes exist, falls back to flatMaxY.
|
||||
const getSurfaceY = (x: number, z: number): number => {
|
||||
if (surfaceAnchors.length > 0) {
|
||||
const raw = idwSurfaceY(x, z, surfaceAnchors, SURFACE_IDW_POWER);
|
||||
return Math.max(grainFloorY, Math.min(roofTipY, raw));
|
||||
}
|
||||
return flatMaxY ?? sidewallBaseY;
|
||||
};
|
||||
|
||||
// Global max Y — highest point of the surface (used for grid bottom calc)
|
||||
const maxGrainY = useMemo(() => {
|
||||
if (surfaceAnchors.length > 0) {
|
||||
return Math.max(...surfaceAnchors.map(a => a.y), wallY);
|
||||
}
|
||||
return flatMaxY ?? sidewallBaseY;
|
||||
}, [surfaceAnchors, wallY, flatMaxY, sidewallBaseY]);
|
||||
|
||||
// Bottom of filled grain. Flat fill uses volume bounds; cable fill rests on
|
||||
// the hopper tip (matches GrainCableFill) rather than the lowest cable node.
|
||||
const minGrainY = useMemo(() => {
|
||||
if (flatMinY !== undefined) return flatMinY;
|
||||
return grainFloorY;
|
||||
}, [flatMinY, grainFloorY]);
|
||||
|
||||
const geometry = useMemo(() => {
|
||||
if (!moistureAnchors.length) return null;
|
||||
|
||||
const pointsPerLayer = 1 + RADIAL_RINGS * THETA_SEGMENTS;
|
||||
const totalVerts = HEIGHT_STEPS * pointsPerLayer + 1;
|
||||
|
||||
const positions = new Float32Array(totalVerts * 3);
|
||||
const colors = new Float32Array(totalVerts * 3);
|
||||
const heats = new Float32Array(totalVerts);
|
||||
|
||||
const grainBottomY = minGrainY;
|
||||
const grainHeight = maxGrainY - grainBottomY;
|
||||
|
||||
if (grainHeight <= 0) return null;
|
||||
|
||||
// Pre-compute the (x, z) position for each (ring, seg) slot so we
|
||||
// can look up the surface Y ceiling per column
|
||||
const colX: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS);
|
||||
const colZ: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS);
|
||||
const colSurfaceY: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS);
|
||||
|
||||
// Use the outermost layer's radius for surface Y lookup (no jitter/twist)
|
||||
// so the surface shape matches GrainCableFill cleanly
|
||||
const topLayerAllowedRadius = maxRadiusAtY(maxGrainY) * 0.97;
|
||||
|
||||
for (let ring = 0; ring < RADIAL_RINGS; ring++) {
|
||||
const u = (ring + 1) / RADIAL_RINGS;
|
||||
const rFrac = 1 - Math.pow(1 - u, 2.2);
|
||||
const r = rFrac * topLayerAllowedRadius;
|
||||
|
||||
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
|
||||
const angle = (seg / THETA_SEGMENTS) * Math.PI * 2;
|
||||
const x = Math.cos(angle) * r;
|
||||
const z = Math.sin(angle) * r;
|
||||
const ci = ring * THETA_SEGMENTS + seg;
|
||||
colX[ci] = x;
|
||||
colZ[ci] = z;
|
||||
// Outermost ring uses wallY, inner rings use full IDW surface
|
||||
colSurfaceY[ci] = ring === RADIAL_RINGS - 1
|
||||
? wallY
|
||||
: getSurfaceY(x, z);
|
||||
}
|
||||
}
|
||||
// Center column surface Y
|
||||
const centerSurfaceY = getSurfaceY(0, 0);
|
||||
|
||||
for (let hStep = 0; hStep < HEIGHT_STEPS; hStep++) {
|
||||
const t = hStep / (HEIGHT_STEPS - 1);
|
||||
|
||||
const layerBase = hStep * pointsPerLayer;
|
||||
const allowedRadius = maxRadiusAtY(grainBottomY + t * grainHeight) * 0.97;
|
||||
|
||||
// Center vertex — Y is lerped from bottom to its column surface ceiling
|
||||
const centerY = grainBottomY + t * (centerSurfaceY - grainBottomY);
|
||||
|
||||
const centerMoisture = idwMoisture(0, centerY, 0, moistureAnchors, IDW_POWER);
|
||||
const centerHeat = moistureToHeat(centerMoisture, targetMoisture);
|
||||
const [cr, cg, cb] = heatToRGB(centerHeat);
|
||||
|
||||
positions[layerBase * 3] = 0;
|
||||
positions[layerBase * 3 + 1] = centerY;
|
||||
positions[layerBase * 3 + 2] = 0;
|
||||
colors[layerBase * 3] = cr;
|
||||
colors[layerBase * 3 + 1] = cg;
|
||||
colors[layerBase * 3 + 2] = cb;
|
||||
heats[layerBase] = centerHeat;
|
||||
|
||||
for (let ring = 0; ring < RADIAL_RINGS; ring++) {
|
||||
const u = (ring + 1) / RADIAL_RINGS;
|
||||
const rFrac = 1 - Math.pow(1 - u, 2.2);
|
||||
|
||||
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
|
||||
const noiseA = hashNoise(hStep, ring, seg);
|
||||
const noiseR = hashNoise(seg, hStep, ring + 11);
|
||||
|
||||
const baseRadius = rFrac * allowedRadius;
|
||||
const jitterRadius = baseRadius + noiseR * RADIAL_JITTER * allowedRadius;
|
||||
const r = Math.max(0, Math.min(jitterRadius, allowedRadius));
|
||||
|
||||
const layerPhase = t * LAYER_TWIST;
|
||||
const angle =
|
||||
(seg / THETA_SEGMENTS) * Math.PI * 2 +
|
||||
layerPhase +
|
||||
noiseA * ANGLE_JITTER;
|
||||
|
||||
const x = Math.cos(angle) * r;
|
||||
const z = Math.sin(angle) * r;
|
||||
|
||||
// Per-column surface Y ceiling — this is what gives the wavy top
|
||||
const ci = ring * THETA_SEGMENTS + seg;
|
||||
const surfaceY = colSurfaceY[ci];
|
||||
|
||||
// Lerp this vertex Y from grainBottomY up to its column surface ceiling
|
||||
const y = grainBottomY + t * (surfaceY - grainBottomY);
|
||||
|
||||
const moisture = idwMoisture(x, y, z, moistureAnchors, IDW_POWER);
|
||||
const heat = moistureToHeat(moisture, targetMoisture);
|
||||
const [vr, vg, vb] = heatToRGB(heat);
|
||||
|
||||
const vi = layerBase + 1 + ring * THETA_SEGMENTS + seg;
|
||||
|
||||
heats[vi] = heat;
|
||||
positions[vi * 3] = x;
|
||||
positions[vi * 3 + 1] = y;
|
||||
positions[vi * 3 + 2] = z;
|
||||
colors[vi * 3] = vr;
|
||||
colors[vi * 3 + 1] = vg;
|
||||
colors[vi * 3 + 2] = vb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const idx = (hStep: number, ring: number, seg: number) => {
|
||||
if (ring < 0) return hStep * pointsPerLayer;
|
||||
const s = ((seg % THETA_SEGMENTS) + THETA_SEGMENTS) % THETA_SEGMENTS;
|
||||
return hStep * pointsPerLayer + 1 + ring * THETA_SEGMENTS + s;
|
||||
};
|
||||
|
||||
const green: number[] = [];
|
||||
const yellow: number[] = [];
|
||||
const red: number[] = [];
|
||||
|
||||
function pushTri(a: number, b: number, c: number) {
|
||||
const avg = (heats[a] + heats[b] + heats[c]) / 3;
|
||||
if (avg < 1) green.push(a, b, c);
|
||||
else if (avg < 2) yellow.push(a, b, c);
|
||||
else red.push(a, b, c);
|
||||
}
|
||||
|
||||
for (let h = 0; h < HEIGHT_STEPS - 1; h++) {
|
||||
for (let ring = 0; ring < RADIAL_RINGS - 1; ring++) {
|
||||
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
|
||||
const next = (seg + 1) % THETA_SEGMENTS;
|
||||
const a = idx(h, ring, seg);
|
||||
const b = idx(h, ring, next);
|
||||
const e = idx(h + 1, ring, seg);
|
||||
const f = idx(h + 1, ring, next);
|
||||
pushTri(a, e, b);
|
||||
pushTri(e, f, b);
|
||||
}
|
||||
}
|
||||
|
||||
// center fill
|
||||
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
|
||||
const next = (seg + 1) % THETA_SEGMENTS;
|
||||
const center0 = idx(h, -1, 0);
|
||||
const center1 = idx(h + 1, -1, 0);
|
||||
const a = idx(h, 0, seg);
|
||||
const b = idx(h, 0, next);
|
||||
const c = idx(h + 1, 0, seg);
|
||||
const d = idx(h + 1, 0, next);
|
||||
pushTri(center0, c, a);
|
||||
pushTri(center0, center1, c);
|
||||
pushTri(a, c, b);
|
||||
pushTri(b, c, d);
|
||||
}
|
||||
}
|
||||
|
||||
// Close the bottom at the hopper tip only when grain reaches the tip.
|
||||
const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer;
|
||||
if (hopperHeight > 0 && grainBottomY <= hopperTipY + 0.01) {
|
||||
const tipMoisture = idwMoisture(0, hopperTipY, 0, moistureAnchors, IDW_POWER);
|
||||
const tipHeat = moistureToHeat(tipMoisture, targetMoisture);
|
||||
const [tr, tg, tb] = heatToRGB(tipHeat);
|
||||
|
||||
positions[tipVertexIndex * 3] = 0;
|
||||
positions[tipVertexIndex * 3 + 1] = hopperTipY;
|
||||
positions[tipVertexIndex * 3 + 2] = 0;
|
||||
colors[tipVertexIndex * 3] = tr;
|
||||
colors[tipVertexIndex * 3 + 1] = tg;
|
||||
colors[tipVertexIndex * 3 + 2] = tb;
|
||||
heats[tipVertexIndex] = tipHeat;
|
||||
|
||||
const outerRing = RADIAL_RINGS - 1;
|
||||
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
|
||||
const next = (seg + 1) % THETA_SEGMENTS;
|
||||
pushTri(tipVertexIndex, idx(0, outerRing, next), idx(0, outerRing, seg));
|
||||
}
|
||||
}
|
||||
|
||||
function makeGeo(indices: number[]) {
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
g.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
||||
g.setIndex(indices);
|
||||
return g;
|
||||
}
|
||||
|
||||
return {
|
||||
green: makeGeo(green),
|
||||
yellow: makeGeo(yellow),
|
||||
red: makeGeo(red),
|
||||
};
|
||||
}, [
|
||||
moistureAnchors,
|
||||
surfaceAnchors,
|
||||
wallY,
|
||||
maxGrainY,
|
||||
minGrainY,
|
||||
hopperTipY,
|
||||
sidewallBaseY,
|
||||
binRadius,
|
||||
targetMoisture,
|
||||
hopperHeight,
|
||||
roofHeight,
|
||||
roofTipY,
|
||||
flatMaxY,
|
||||
flatMinY,
|
||||
]);
|
||||
|
||||
if (!geometry) return null;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<mesh geometry={geometry.green} renderOrder={1}>
|
||||
<meshBasicMaterial
|
||||
vertexColors
|
||||
transparent
|
||||
opacity={GREEN_OPACITY}
|
||||
side={THREE.DoubleSide}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
<mesh geometry={geometry.yellow} renderOrder={2}>
|
||||
<meshBasicMaterial
|
||||
vertexColors
|
||||
transparent
|
||||
opacity={YELLOW_OPACITY}
|
||||
side={THREE.DoubleSide}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
<mesh geometry={geometry.red} renderOrder={3}>
|
||||
<meshBasicMaterial
|
||||
vertexColors
|
||||
transparent
|
||||
opacity={RED_OPACITY}
|
||||
side={THREE.DoubleSide}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
254
src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx
Normal file
254
src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { NodeData } from "bin/3dView/Data/BuildNodeData";
|
||||
import { colourFade } from "bin/3dView/utils/tempToColour";
|
||||
import { Bin } from "models";
|
||||
import { useMemo } from "react";
|
||||
import { CanvasTexture, NormalBlending } from "three";
|
||||
|
||||
interface Props {
|
||||
bin: Bin;
|
||||
nodes: NodeData[];
|
||||
}
|
||||
|
||||
export default function NodePointCloud(props: Props) {
|
||||
const { bin, nodes } = props;
|
||||
// 🎛️ TUNING KNOBS
|
||||
const BASE_RADIUS_FACTOR = 0.15;
|
||||
const INTENSITY_DENSITY_MULT = 1.25;
|
||||
const INTENSITY_DENSITY_BASE = 0.3;
|
||||
|
||||
const RADIAL_BASE = 6;
|
||||
const THETA_BASE = 10;
|
||||
const PHI_BASE = 10;
|
||||
|
||||
const MIN_EDGE_INTENSITY = 0.2;
|
||||
|
||||
const BASE_BRIGHTNESS = 0.7;
|
||||
const INTENSITY_BRIGHTNESS_MULT = 0.7;
|
||||
|
||||
const OPACITY = 0.3;
|
||||
const POINT_SIZE = 1;
|
||||
|
||||
// IDW power — how sharply nearer nodes dominate the field
|
||||
const IDW_POWER = 2;
|
||||
|
||||
// Minimum net signed magnitude to emit a point.
|
||||
// Keeps near-zero cancellation zones empty rather than noisy.
|
||||
const EMIT_THRESHOLD = 0.05;
|
||||
|
||||
// -----------------------------
|
||||
// BIN GEOMETRY
|
||||
// -----------------------------
|
||||
const binRadius = bin.diameter() / 2;
|
||||
const sidewallHeight = bin.sidewallHeight();
|
||||
const hopperHeight = bin.hopperHeight() ?? 0;
|
||||
const sidewallBaseY = -sidewallHeight / 2;
|
||||
const hopperTipY = sidewallBaseY - hopperHeight;
|
||||
|
||||
const maxRadiusAtY = (y: number): number => {
|
||||
if (y >= sidewallBaseY) return binRadius;
|
||||
if (hopperHeight <= 0 || y <= hopperTipY) return 0;
|
||||
const t = (y - hopperTipY) / hopperHeight;
|
||||
return binRadius * t;
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// GRAIN LIMIT
|
||||
// -----------------------------
|
||||
const maxGrainY = useMemo(() => {
|
||||
let maxY = -Infinity;
|
||||
nodes.forEach((node) => {
|
||||
if (!node.inGrain || node.excluded) return;
|
||||
if (node.position.y > maxY) maxY = node.position.y;
|
||||
});
|
||||
return maxY;
|
||||
}, [nodes]);
|
||||
|
||||
// -----------------------------
|
||||
// PRE-COMPUTE SIGNED NODE CONTRIBUTIONS
|
||||
// Only nodes outside threshold contribute to the field.
|
||||
// Hot nodes carry a positive signed intensity, cold nodes negative.
|
||||
// -----------------------------
|
||||
const signedNodes = useMemo(() => {
|
||||
return nodes
|
||||
.filter(n => n.inGrain && !n.excluded)
|
||||
.map(n => {
|
||||
const lower = bin.lowerTempThreshold();
|
||||
const upper = bin.targetTemp();
|
||||
|
||||
if (n.celcius >= lower && n.celcius <= upper) return null;
|
||||
|
||||
const distance = n.celcius < lower
|
||||
? lower - n.celcius
|
||||
: n.celcius - upper;
|
||||
|
||||
const intensity = Math.min(1, distance / colourFade);
|
||||
// positive = hot, negative = cold
|
||||
const signedIntensity = n.celcius > upper ? intensity : -intensity;
|
||||
|
||||
return {
|
||||
x: n.position.x,
|
||||
y: n.position.y,
|
||||
z: n.position.z,
|
||||
signedIntensity,
|
||||
cloudRadius: bin.diameter() * BASE_RADIUS_FACTOR * (0.5 + intensity),
|
||||
densityMultiplier: INTENSITY_DENSITY_BASE + intensity * INTENSITY_DENSITY_MULT,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as {
|
||||
x: number; y: number; z: number;
|
||||
signedIntensity: number;
|
||||
cloudRadius: number;
|
||||
densityMultiplier: number;
|
||||
}[];
|
||||
}, [nodes, bin]);
|
||||
|
||||
/**
|
||||
* Evaluates the net signed field at (px, py, pz) by summing
|
||||
* IDW-weighted signed intensities from all contributing nodes.
|
||||
*
|
||||
* Returns a value in [-1, 1]:
|
||||
* > 0 → net hot
|
||||
* < 0 → net cold
|
||||
* ~0 → cancelled out — point will not be emitted
|
||||
*/
|
||||
const evaluateField = (px: number, py: number, pz: number): number => {
|
||||
let totalWeight = 0;
|
||||
let weightedSum = 0;
|
||||
|
||||
for (const node of signedNodes) {
|
||||
const dx = px - node.x;
|
||||
const dy = py - node.y;
|
||||
const dz = pz - node.z;
|
||||
const distSq = dx * dx + dy * dy + dz * dz;
|
||||
|
||||
const weight = distSq < 0.001
|
||||
? 1e6
|
||||
: 1 / Math.pow(distSq, IDW_POWER / 2);
|
||||
|
||||
totalWeight += weight;
|
||||
weightedSum += node.signedIntensity * weight;
|
||||
}
|
||||
|
||||
if (totalWeight === 0) return 0;
|
||||
return weightedSum / totalWeight;
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// BUILD POINT CLOUD
|
||||
// -----------------------------
|
||||
const { positions, colors } = useMemo(() => {
|
||||
const positions: number[] = [];
|
||||
const colors: number[] = [];
|
||||
|
||||
signedNodes.forEach((node) => {
|
||||
const radialSteps = Math.floor(RADIAL_BASE * node.densityMultiplier);
|
||||
const thetaSteps = Math.floor(THETA_BASE * node.densityMultiplier);
|
||||
const phiSteps = Math.floor(PHI_BASE * node.densityMultiplier);
|
||||
|
||||
for (let rStep = 0; rStep < radialSteps; rStep++) {
|
||||
const r = Math.sqrt(rStep / radialSteps) * node.cloudRadius;
|
||||
|
||||
for (let pStep = 0; pStep < phiSteps; pStep++) {
|
||||
const phi = ((pStep + 0.5) / phiSteps) * Math.PI;
|
||||
|
||||
for (let tStep = 0; tStep < thetaSteps; tStep++) {
|
||||
const theta = (tStep / thetaSteps) * Math.PI * 2;
|
||||
|
||||
const x = node.x + r * Math.sin(phi) * Math.cos(theta);
|
||||
const y = node.y + r * Math.cos(phi);
|
||||
const z = node.z + r * Math.sin(phi) * Math.sin(theta);
|
||||
|
||||
// Bin boundary checks
|
||||
if (y < hopperTipY || y > maxGrainY) continue;
|
||||
const distXZ = Math.sqrt(x * x + z * z);
|
||||
if (distXZ > maxRadiusAtY(y)) continue;
|
||||
|
||||
// Evaluate the full signed field at this point.
|
||||
// A point in the overlap of a hot and cold cloud will have
|
||||
// a net value near zero and will be skipped rather than
|
||||
// rendered pink.
|
||||
const netField = evaluateField(x, y, z);
|
||||
|
||||
if (Math.abs(netField) < EMIT_THRESHOLD) continue;
|
||||
|
||||
positions.push(x, y, z);
|
||||
|
||||
// Brightness driven by net magnitude, not per-node intensity
|
||||
const netMagnitude = Math.abs(netField);
|
||||
const falloff = 1 - r / node.cloudRadius;
|
||||
const spatialFalloff = Math.pow(Math.max(0, falloff), 2);
|
||||
const adjustedFalloff = MIN_EDGE_INTENSITY + (1 - MIN_EDGE_INTENSITY) * spatialFalloff;
|
||||
const intensityCurve = netMagnitude * netMagnitude;
|
||||
const brightness = adjustedFalloff * (BASE_BRIGHTNESS + INTENSITY_BRIGHTNESS_MULT * intensityCurve);
|
||||
|
||||
if (netField > 0) {
|
||||
colors.push(brightness, 0, 0); // hot → red
|
||||
} else {
|
||||
colors.push(0, 0, brightness); // cold → blue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
positions: new Float32Array(positions),
|
||||
colors: new Float32Array(colors),
|
||||
};
|
||||
}, [signedNodes, maxGrainY, hopperTipY]);
|
||||
|
||||
const circleTexture = useMemo(() => {
|
||||
const size = 64;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const gradient = ctx.createRadialGradient(
|
||||
size / 2, size / 2, 0,
|
||||
size / 2, size / 2, size / 2
|
||||
);
|
||||
|
||||
gradient.addColorStop(0, "rgba(255,255,255,1)");
|
||||
gradient.addColorStop(1, "rgba(255,255,255,0)");
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
const texture = new CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}, []);
|
||||
|
||||
// -----------------------------
|
||||
// RENDER
|
||||
// -----------------------------
|
||||
return (
|
||||
<points renderOrder={2}>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
array={positions}
|
||||
count={positions.length / 3}
|
||||
itemSize={3}
|
||||
/>
|
||||
<bufferAttribute
|
||||
attach="attributes-color"
|
||||
array={colors}
|
||||
count={colors.length / 3}
|
||||
itemSize={3}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
|
||||
<pointsMaterial
|
||||
size={POINT_SIZE}
|
||||
map={circleTexture}
|
||||
vertexColors
|
||||
transparent
|
||||
opacity={OPACITY}
|
||||
depthWrite={false}
|
||||
blending={NormalBlending}
|
||||
/>
|
||||
</points>
|
||||
);
|
||||
}
|
||||
470
src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx
Normal file
470
src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
import { useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import { Bin } from "models";
|
||||
import { NodeData } from "../../Data/BuildNodeData";
|
||||
import React from "react";
|
||||
import { BinDims } from "bin/3dView/Data/BuildCableData";
|
||||
|
||||
interface Props {
|
||||
binDimensions: BinDims
|
||||
targetTemp: number
|
||||
nodes: NodeData[];
|
||||
/**
|
||||
* For flat fill percent inventory — a scalar Y ceiling for the heatmap top.
|
||||
* Used when there are no cable top nodes to drive a surface.
|
||||
*/
|
||||
flatMaxY?: number;
|
||||
/** For flat fill percent — Y floor of filled grain (hopper tip or cylinder base). */
|
||||
flatMinY?: number;
|
||||
}
|
||||
|
||||
const RADIAL_RINGS = 20;
|
||||
const THETA_SEGMENTS = 40;
|
||||
const HEIGHT_STEPS = 28;
|
||||
|
||||
const YELLOW_DELTA = 5;
|
||||
const RED_DELTA = 10;
|
||||
|
||||
const IDW_POWER = 4;
|
||||
const RED_OPACITY = 0.3;
|
||||
const GREEN_OPACITY = 0.3;
|
||||
const YELLOW_OPACITY = 0.8;
|
||||
|
||||
const ANGLE_JITTER = 0.2;
|
||||
const RADIAL_JITTER = 0.05;
|
||||
const LAYER_TWIST = 0.22;
|
||||
|
||||
// IDW power for the horizontal surface interpolation —
|
||||
// matches GrainCableFill's default of 2
|
||||
const SURFACE_IDW_POWER = 2;
|
||||
|
||||
function tempToHeat(temp: number, upper: number): number {
|
||||
if (temp <= upper) return 0;
|
||||
const delta = temp - upper;
|
||||
if (delta >= RED_DELTA) return 2;
|
||||
if (delta >= YELLOW_DELTA) return 1 + (delta - YELLOW_DELTA) / (RED_DELTA - YELLOW_DELTA);
|
||||
return delta / YELLOW_DELTA;
|
||||
}
|
||||
|
||||
function heatToRGB(heat: number): number[] {
|
||||
const GREEN = [0, 0.5, 0.02];
|
||||
const YELLOW = [0.7, 0.86, 0.0];
|
||||
const RED = [0.8, 0.0, 0.01];
|
||||
|
||||
if (heat <= 0) return GREEN;
|
||||
|
||||
if (heat <= 1) {
|
||||
const t = Math.pow(heat, 0.75);
|
||||
return [
|
||||
GREEN[0] + (YELLOW[0] - GREEN[0]) * t,
|
||||
GREEN[1] + (YELLOW[1] - GREEN[1]) * t,
|
||||
GREEN[2] + (YELLOW[2] - GREEN[2]) * t,
|
||||
];
|
||||
}
|
||||
|
||||
const t = Math.pow(heat - 1, 0.75);
|
||||
return [
|
||||
YELLOW[0] + (RED[0] - YELLOW[0]) * t,
|
||||
YELLOW[1] + (RED[1] - YELLOW[1]) * t,
|
||||
YELLOW[2] + (RED[2] - YELLOW[2]) * t,
|
||||
];
|
||||
}
|
||||
|
||||
interface TempAnchor {
|
||||
x: number; y: number; z: number;
|
||||
celcius: number;
|
||||
}
|
||||
|
||||
interface SurfaceAnchor {
|
||||
x: number; z: number; y: number;
|
||||
}
|
||||
|
||||
// 3D IDW for temperature interpolation
|
||||
function idwTemp(
|
||||
px: number, py: number, pz: number,
|
||||
anchors: TempAnchor[],
|
||||
power: number
|
||||
): number {
|
||||
let totalWeight = 0;
|
||||
let weightedSum = 0;
|
||||
|
||||
for (const a of anchors) {
|
||||
const dx = px - a.x;
|
||||
const dy = py - a.y;
|
||||
const dz = pz - a.z;
|
||||
const distSq = dx * dx + dy * dy + dz * dz;
|
||||
if (distSq < 0.001) return a.celcius;
|
||||
const weight = 1 / Math.pow(distSq, power / 2);
|
||||
totalWeight += weight;
|
||||
weightedSum += a.celcius * weight;
|
||||
}
|
||||
|
||||
return totalWeight === 0 ? 0 : weightedSum / totalWeight;
|
||||
}
|
||||
|
||||
// 2D IDW for surface height interpolation — matches GrainCableFill exactly
|
||||
function idwSurfaceY(
|
||||
x: number, z: number,
|
||||
anchors: SurfaceAnchor[],
|
||||
power: number
|
||||
): number {
|
||||
let totalWeight = 0;
|
||||
let weightedY = 0;
|
||||
|
||||
for (const a of anchors) {
|
||||
const dx = x - a.x;
|
||||
const dz = z - a.z;
|
||||
const distSq = dx * dx + dz * dz;
|
||||
if (distSq < 0.001) return a.y;
|
||||
const weight = 1 / Math.pow(distSq, power / 2);
|
||||
totalWeight += weight;
|
||||
weightedY += a.y * weight;
|
||||
}
|
||||
|
||||
return totalWeight === 0 ? 0 : weightedY / totalWeight;
|
||||
}
|
||||
|
||||
function hashNoise(a: number, b: number, c: number) {
|
||||
const x = Math.sin(a * 127.1 + b * 311.7 + c * 74.7) * 43758.5453;
|
||||
return (x - Math.floor(x)) * 2 - 1;
|
||||
}
|
||||
|
||||
export default function TempHeatMap({ binDimensions, targetTemp, nodes, flatMaxY, flatMinY }: Props) {
|
||||
const binRadius = binDimensions.diameter / 2;
|
||||
const sidewallHeight = binDimensions.sidewallHeight;
|
||||
const hopperHeight = binDimensions.hopperHeight ?? 0;
|
||||
const roofHeight = binDimensions.roofHeight ?? 0;
|
||||
const upperThreshold = targetTemp;
|
||||
|
||||
const sidewallBaseY = -sidewallHeight / 2;
|
||||
const sidewallTopY = sidewallHeight / 2;
|
||||
const hopperTipY = sidewallBaseY - hopperHeight;
|
||||
const roofTipY = sidewallTopY + roofHeight;
|
||||
|
||||
const maxRadiusAtY = (y: number) => {
|
||||
// Inside the roof cone — tapers from binRadius at sidewallTopY to 0 at roofTipY
|
||||
if (y > sidewallTopY) {
|
||||
if (roofHeight <= 0 || y >= roofTipY) return 0;
|
||||
return binRadius * (1 - (y - sidewallTopY) / roofHeight);
|
||||
}
|
||||
// Straight sidewall
|
||||
if (y >= sidewallBaseY) return binRadius;
|
||||
// Inside the hopper cone — tapers from binRadius at sidewallBaseY to 0 at hopperTipY
|
||||
if (hopperHeight <= 0 || y <= hopperTipY) return 0;
|
||||
return binRadius * ((y - hopperTipY) / hopperHeight);
|
||||
};
|
||||
|
||||
// Temperature anchors — all in-grain nodes
|
||||
const tempAnchors = useMemo<TempAnchor[]>(
|
||||
() =>
|
||||
nodes
|
||||
.filter(n => n.inGrain && !n.excluded)
|
||||
.map(n => ({
|
||||
x: n.position.x,
|
||||
y: n.position.y,
|
||||
z: n.position.z,
|
||||
celcius: n.celcius,
|
||||
})),
|
||||
[nodes]
|
||||
);
|
||||
|
||||
// Surface anchors — top nodes only, Y offset by half spacing.
|
||||
// Only used when flatMaxY is NOT provided (i.e. Automatic / Hybrid_Cable
|
||||
// inventory control). For any other control type the caller passes flatMaxY
|
||||
// and we must use a flat surface instead of the wavy cable-driven one.
|
||||
const surfaceAnchors = useMemo<SurfaceAnchor[]>(() => {
|
||||
if (flatMaxY !== undefined) {
|
||||
// Non-cable inventory: force flat surface — ignore cable top nodes
|
||||
return [];
|
||||
}
|
||||
return nodes
|
||||
.filter(n => n.topNode && n.inGrain && !n.excluded)
|
||||
.map(n => ({
|
||||
x: n.position.x,
|
||||
z: n.position.z,
|
||||
y: n.position.y + n.nodeSpacing * 0.5,
|
||||
}));
|
||||
}, [nodes, flatMaxY]);
|
||||
|
||||
// Wall clamp Y — average of surface anchor heights.
|
||||
// Prevents the surface from piling up at the bin wall where there are no cables.
|
||||
// Matches GrainCableFill's wallY computation.
|
||||
const wallY = useMemo(() => {
|
||||
if (surfaceAnchors.length === 0) return flatMaxY ?? sidewallBaseY;
|
||||
return surfaceAnchors.reduce((sum, a) => sum + a.y, 0) / surfaceAnchors.length;
|
||||
}, [surfaceAnchors, flatMaxY, sidewallBaseY]);
|
||||
|
||||
// Lowest Y the grain surface or heatmap may occupy (hopper tip or cylinder floor).
|
||||
const grainFloorY = hopperHeight > 0 ? hopperTipY : sidewallBaseY;
|
||||
|
||||
// For each (x, z) position, returns the Y ceiling of the grain surface.
|
||||
// Uses cable surface IDW when top nodes exist, falls back to flatMaxY.
|
||||
const getSurfaceY = (x: number, z: number): number => {
|
||||
if (surfaceAnchors.length > 0) {
|
||||
const raw = idwSurfaceY(x, z, surfaceAnchors, SURFACE_IDW_POWER);
|
||||
return Math.max(grainFloorY, Math.min(roofTipY, raw));
|
||||
}
|
||||
return flatMaxY ?? sidewallBaseY;
|
||||
};
|
||||
|
||||
// Global max Y — highest point of the surface (used for grid bottom calc)
|
||||
const maxGrainY = useMemo(() => {
|
||||
if (surfaceAnchors.length > 0) {
|
||||
return Math.max(...surfaceAnchors.map(a => a.y), wallY);
|
||||
}
|
||||
return flatMaxY ?? sidewallBaseY;
|
||||
}, [surfaceAnchors, wallY, flatMaxY, sidewallBaseY]);
|
||||
|
||||
// Bottom of filled grain. Flat fill uses volume bounds; cable fill rests on
|
||||
// the hopper tip (matches GrainCableFill) rather than the lowest cable node.
|
||||
const minGrainY = useMemo(() => {
|
||||
if (flatMinY !== undefined) return flatMinY;
|
||||
return grainFloorY;
|
||||
}, [flatMinY, grainFloorY]);
|
||||
|
||||
const geometry = useMemo(() => {
|
||||
if (!tempAnchors.length) return null;
|
||||
|
||||
const pointsPerLayer = 1 + RADIAL_RINGS * THETA_SEGMENTS;
|
||||
const totalVerts = HEIGHT_STEPS * pointsPerLayer + 1;
|
||||
|
||||
const positions = new Float32Array(totalVerts * 3);
|
||||
const colors = new Float32Array(totalVerts * 3);
|
||||
const heats = new Float32Array(totalVerts);
|
||||
|
||||
const grainBottomY = minGrainY;
|
||||
const grainHeight = maxGrainY - grainBottomY;
|
||||
|
||||
if (grainHeight <= 0) return null;
|
||||
|
||||
// Pre-compute the (x, z) position for each (ring, seg) slot so we
|
||||
// can look up the surface Y ceiling per column
|
||||
const colX: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS);
|
||||
const colZ: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS);
|
||||
const colSurfaceY: number[] = new Array(RADIAL_RINGS * THETA_SEGMENTS);
|
||||
|
||||
// Use the outermost layer's radius for surface Y lookup (no jitter/twist)
|
||||
// so the surface shape matches GrainCableFill cleanly
|
||||
const topLayerAllowedRadius = maxRadiusAtY(maxGrainY) * 0.97;
|
||||
|
||||
for (let ring = 0; ring < RADIAL_RINGS; ring++) {
|
||||
const u = (ring + 1) / RADIAL_RINGS;
|
||||
const rFrac = 1 - Math.pow(1 - u, 2.2);
|
||||
const r = rFrac * topLayerAllowedRadius;
|
||||
|
||||
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
|
||||
const angle = (seg / THETA_SEGMENTS) * Math.PI * 2;
|
||||
const x = Math.cos(angle) * r;
|
||||
const z = Math.sin(angle) * r;
|
||||
const ci = ring * THETA_SEGMENTS + seg;
|
||||
colX[ci] = x;
|
||||
colZ[ci] = z;
|
||||
// Outermost ring uses wallY, inner rings use full IDW surface
|
||||
colSurfaceY[ci] = ring === RADIAL_RINGS - 1
|
||||
? wallY
|
||||
: getSurfaceY(x, z);
|
||||
}
|
||||
}
|
||||
// Center column surface Y
|
||||
const centerSurfaceY = getSurfaceY(0, 0);
|
||||
|
||||
for (let hStep = 0; hStep < HEIGHT_STEPS; hStep++) {
|
||||
const t = hStep / (HEIGHT_STEPS - 1);
|
||||
|
||||
const layerBase = hStep * pointsPerLayer;
|
||||
const allowedRadius = maxRadiusAtY(grainBottomY + t * grainHeight) * 0.97;
|
||||
|
||||
// Center vertex — Y is lerped from bottom to its column surface ceiling
|
||||
const centerY = grainBottomY + t * (centerSurfaceY - grainBottomY);
|
||||
|
||||
const centerTemp = idwTemp(0, centerY, 0, tempAnchors, IDW_POWER);
|
||||
const centerHeat = tempToHeat(centerTemp, upperThreshold);
|
||||
const [cr, cg, cb] = heatToRGB(centerHeat);
|
||||
|
||||
positions[layerBase * 3] = 0;
|
||||
positions[layerBase * 3 + 1] = centerY;
|
||||
positions[layerBase * 3 + 2] = 0;
|
||||
colors[layerBase * 3] = cr;
|
||||
colors[layerBase * 3 + 1] = cg;
|
||||
colors[layerBase * 3 + 2] = cb;
|
||||
heats[layerBase] = centerHeat;
|
||||
|
||||
for (let ring = 0; ring < RADIAL_RINGS; ring++) {
|
||||
const u = (ring + 1) / RADIAL_RINGS;
|
||||
const rFrac = 1 - Math.pow(1 - u, 2.2);
|
||||
|
||||
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
|
||||
const noiseA = hashNoise(hStep, ring, seg);
|
||||
const noiseR = hashNoise(seg, hStep, ring + 11);
|
||||
|
||||
const baseRadius = rFrac * allowedRadius;
|
||||
const jitterRadius = baseRadius + noiseR * RADIAL_JITTER * allowedRadius;
|
||||
const r = Math.max(0, Math.min(jitterRadius, allowedRadius));
|
||||
|
||||
const layerPhase = t * LAYER_TWIST;
|
||||
const angle =
|
||||
(seg / THETA_SEGMENTS) * Math.PI * 2 +
|
||||
layerPhase +
|
||||
noiseA * ANGLE_JITTER;
|
||||
|
||||
const x = Math.cos(angle) * r;
|
||||
const z = Math.sin(angle) * r;
|
||||
|
||||
// Per-column surface Y ceiling — this is what gives the wavy top
|
||||
const ci = ring * THETA_SEGMENTS + seg;
|
||||
const surfaceY = colSurfaceY[ci];
|
||||
|
||||
// Lerp this vertex Y from grainBottomY up to its column surface ceiling
|
||||
const y = grainBottomY + t * (surfaceY - grainBottomY);
|
||||
|
||||
const temp = idwTemp(x, y, z, tempAnchors, IDW_POWER);
|
||||
const heat = tempToHeat(temp, upperThreshold);
|
||||
const [vr, vg, vb] = heatToRGB(heat);
|
||||
|
||||
const vi = layerBase + 1 + ring * THETA_SEGMENTS + seg;
|
||||
|
||||
heats[vi] = heat;
|
||||
positions[vi * 3] = x;
|
||||
positions[vi * 3 + 1] = y;
|
||||
positions[vi * 3 + 2] = z;
|
||||
colors[vi * 3] = vr;
|
||||
colors[vi * 3 + 1] = vg;
|
||||
colors[vi * 3 + 2] = vb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const idx = (hStep: number, ring: number, seg: number) => {
|
||||
if (ring < 0) return hStep * pointsPerLayer;
|
||||
const s = ((seg % THETA_SEGMENTS) + THETA_SEGMENTS) % THETA_SEGMENTS;
|
||||
return hStep * pointsPerLayer + 1 + ring * THETA_SEGMENTS + s;
|
||||
};
|
||||
|
||||
const green: number[] = [];
|
||||
const yellow: number[] = [];
|
||||
const red: number[] = [];
|
||||
|
||||
function pushTri(a: number, b: number, c: number) {
|
||||
const avg = (heats[a] + heats[b] + heats[c]) / 3;
|
||||
if (avg < 1) green.push(a, b, c);
|
||||
else if (avg < 2) yellow.push(a, b, c);
|
||||
else red.push(a, b, c);
|
||||
}
|
||||
|
||||
for (let h = 0; h < HEIGHT_STEPS - 1; h++) {
|
||||
for (let ring = 0; ring < RADIAL_RINGS - 1; ring++) {
|
||||
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
|
||||
const next = (seg + 1) % THETA_SEGMENTS;
|
||||
const a = idx(h, ring, seg);
|
||||
const b = idx(h, ring, next);
|
||||
const e = idx(h + 1, ring, seg);
|
||||
const f = idx(h + 1, ring, next);
|
||||
pushTri(a, e, b);
|
||||
pushTri(e, f, b);
|
||||
}
|
||||
}
|
||||
|
||||
// center fill
|
||||
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
|
||||
const next = (seg + 1) % THETA_SEGMENTS;
|
||||
const center0 = idx(h, -1, 0);
|
||||
const center1 = idx(h + 1, -1, 0);
|
||||
const a = idx(h, 0, seg);
|
||||
const b = idx(h, 0, next);
|
||||
const c = idx(h + 1, 0, seg);
|
||||
const d = idx(h + 1, 0, next);
|
||||
pushTri(center0, c, a);
|
||||
pushTri(center0, center1, c);
|
||||
pushTri(a, c, b);
|
||||
pushTri(b, c, d);
|
||||
}
|
||||
}
|
||||
|
||||
// Close the bottom at the hopper tip only when grain reaches the tip.
|
||||
const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer;
|
||||
if (hopperHeight > 0 && grainBottomY <= hopperTipY + 0.01) {
|
||||
const tipTemp = idwTemp(0, hopperTipY, 0, tempAnchors, IDW_POWER);
|
||||
const tipHeat = tempToHeat(tipTemp, upperThreshold);
|
||||
const [tr, tg, tb] = heatToRGB(tipHeat);
|
||||
|
||||
positions[tipVertexIndex * 3] = 0;
|
||||
positions[tipVertexIndex * 3 + 1] = hopperTipY;
|
||||
positions[tipVertexIndex * 3 + 2] = 0;
|
||||
colors[tipVertexIndex * 3] = tr;
|
||||
colors[tipVertexIndex * 3 + 1] = tg;
|
||||
colors[tipVertexIndex * 3 + 2] = tb;
|
||||
heats[tipVertexIndex] = tipHeat;
|
||||
|
||||
const outerRing = RADIAL_RINGS - 1;
|
||||
for (let seg = 0; seg < THETA_SEGMENTS; seg++) {
|
||||
const next = (seg + 1) % THETA_SEGMENTS;
|
||||
pushTri(tipVertexIndex, idx(0, outerRing, next), idx(0, outerRing, seg));
|
||||
}
|
||||
}
|
||||
|
||||
function makeGeo(indices: number[]) {
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
g.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
||||
g.setIndex(indices);
|
||||
return g;
|
||||
}
|
||||
|
||||
return {
|
||||
green: makeGeo(green),
|
||||
yellow: makeGeo(yellow),
|
||||
red: makeGeo(red),
|
||||
};
|
||||
}, [
|
||||
tempAnchors,
|
||||
surfaceAnchors,
|
||||
wallY,
|
||||
maxGrainY,
|
||||
minGrainY,
|
||||
hopperTipY,
|
||||
sidewallBaseY,
|
||||
binRadius,
|
||||
upperThreshold,
|
||||
hopperHeight,
|
||||
roofHeight,
|
||||
roofTipY,
|
||||
flatMaxY,
|
||||
flatMinY,
|
||||
]);
|
||||
|
||||
if (!geometry) return null;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<mesh geometry={geometry.green} renderOrder={1}>
|
||||
<meshBasicMaterial
|
||||
vertexColors
|
||||
transparent
|
||||
opacity={GREEN_OPACITY}
|
||||
side={THREE.DoubleSide}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
<mesh geometry={geometry.yellow} renderOrder={2}>
|
||||
<meshBasicMaterial
|
||||
vertexColors
|
||||
transparent
|
||||
opacity={YELLOW_OPACITY}
|
||||
side={THREE.DoubleSide}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
<mesh geometry={geometry.red} renderOrder={3}>
|
||||
<meshBasicMaterial
|
||||
vertexColors
|
||||
transparent
|
||||
opacity={RED_OPACITY}
|
||||
side={THREE.DoubleSide}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
353
src/bin/3dView/Systems/Inventory/GrainCableFill.tsx
Normal file
353
src/bin/3dView/Systems/Inventory/GrainCableFill.tsx
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import { NodeData } from "../../Data/BuildNodeData";
|
||||
import Cone from "3dModels/Shapes/3D/Cone";
|
||||
import { Vector3, Euler } from "three";
|
||||
import Cylinder from "3dModels/Shapes/3D/Cylinder";
|
||||
|
||||
interface Props {
|
||||
diameter: number;
|
||||
sidewallHeight: number;
|
||||
hopperHeight?: number;
|
||||
nodes: NodeData[];
|
||||
grainOpacity?: number
|
||||
/**
|
||||
* Fallback flat fill percent (0–1) used when no top nodes are available.
|
||||
* If undefined and no top nodes exist, nothing is rendered.
|
||||
*/
|
||||
fallbackFillPercent?: number;
|
||||
colour?: string
|
||||
}
|
||||
|
||||
// Tuning knobs
|
||||
const RADIAL_RINGS = 24; // vertex rings radiating outward from center
|
||||
const THETA_SEGMENTS = 36; // vertices around each ring
|
||||
|
||||
/**
|
||||
* Inverse-distance weighted interpolation of Y height at a given (x, z) point.
|
||||
* Each top node contributes a weighted Y based on its horizontal distance from the point.
|
||||
* The power parameter controls how sharply nearer nodes dominate (2 = standard IDW).
|
||||
*/
|
||||
function idwHeight(
|
||||
x: number,
|
||||
z: number,
|
||||
anchors: { x: number; z: number; y: number }[],
|
||||
power = 2
|
||||
): number {
|
||||
let totalWeight = 0;
|
||||
let weightedY = 0;
|
||||
|
||||
for (const anchor of anchors) {
|
||||
const dx = x - anchor.x;
|
||||
const dz = z - anchor.z;
|
||||
const distSq = dx * dx + dz * dz;
|
||||
|
||||
// If we're sitting exactly on an anchor, return its Y immediately
|
||||
if (distSq < 0.001) return anchor.y;
|
||||
|
||||
const weight = 1 / Math.pow(distSq, power / 2);
|
||||
totalWeight += weight;
|
||||
weightedY += anchor.y * weight;
|
||||
}
|
||||
|
||||
return weightedY / totalWeight;
|
||||
}
|
||||
|
||||
export default function GrainCableFill(props: Props) {
|
||||
const {
|
||||
diameter,
|
||||
sidewallHeight,
|
||||
hopperHeight = 0,
|
||||
nodes,
|
||||
fallbackFillPercent,
|
||||
grainOpacity,
|
||||
colour
|
||||
} = props;
|
||||
|
||||
const binRadius = diameter / 2;
|
||||
// Slightly inset to avoid z-fighting with the shell
|
||||
const grainRadius = binRadius * 0.98;
|
||||
|
||||
// --- Collect top nodes (non-excluded, inGrain) ---
|
||||
const topNodes = useMemo(() =>
|
||||
nodes.filter(n => n.topNode && n.inGrain && !n.excluded),
|
||||
[nodes]
|
||||
);
|
||||
|
||||
// --- Build surface anchors: top node Y + half spacing offset ---
|
||||
// This places the grain line halfway between the top node and the node above it,
|
||||
// matching the 2D bin view convention.
|
||||
const anchors = useMemo(() =>
|
||||
topNodes.map(n => ({
|
||||
x: n.position.x,
|
||||
z: n.position.z,
|
||||
y: n.position.y + n.nodeSpacing * 0.5,
|
||||
})),
|
||||
[topNodes]
|
||||
);
|
||||
|
||||
// --- Wall clamp: average of all anchor Y values ---
|
||||
// Prevents grain from piling up at the wall where there are no cables.
|
||||
const wallY = useMemo(() => {
|
||||
if (anchors.length === 0) return -sidewallHeight / 2;
|
||||
return anchors.reduce((sum, a) => sum + a.y, 0) / anchors.length;
|
||||
}, [anchors, sidewallHeight]);
|
||||
|
||||
// --- Build the polar surface mesh ---
|
||||
const surfaceGeometry = useMemo(() => {
|
||||
if (anchors.length === 0) return null;
|
||||
|
||||
// Total vertices: center point + (RADIAL_RINGS * THETA_SEGMENTS) ring vertices
|
||||
const ringCount = RADIAL_RINGS;
|
||||
const segCount = THETA_SEGMENTS;
|
||||
const vertexCount = 1 + ringCount * segCount;
|
||||
|
||||
const positions = new Float32Array(vertexCount * 3);
|
||||
const normals = new Float32Array(vertexCount * 3);
|
||||
const uvs = new Float32Array(vertexCount * 2);
|
||||
|
||||
// Center vertex
|
||||
const centerY = idwHeight(0, 0, anchors);
|
||||
positions[0] = 0;
|
||||
positions[1] = centerY;
|
||||
positions[2] = 0;
|
||||
normals[0] = 0; normals[1] = 1; normals[2] = 0;
|
||||
uvs[0] = 0.5; uvs[1] = 0.5;
|
||||
|
||||
// Ring vertices
|
||||
for (let ring = 0; ring < ringCount; ring++) {
|
||||
// sqrt distribution for even area density across rings
|
||||
const t = Math.sqrt((ring + 1) / ringCount);
|
||||
const r = t * grainRadius;
|
||||
|
||||
for (let seg = 0; seg < segCount; seg++) {
|
||||
const angle = (seg / segCount) * Math.PI * 2;
|
||||
const x = Math.cos(angle) * r;
|
||||
const z = Math.sin(angle) * r;
|
||||
|
||||
// Outermost ring clamps to wall average; inner rings interpolate
|
||||
//const isOuterRing = ring === ringCount - 1;
|
||||
// const y = idwHeight(x, z, anchors);
|
||||
const rawY = idwHeight(x, z, anchors);
|
||||
|
||||
// Blend outer 20% of radius toward wallY
|
||||
const edgeStart = 0.8; // start taper at 80% radius
|
||||
const blendT = Math.max(0, (t - edgeStart) / (1 - edgeStart));
|
||||
|
||||
// smoothstep
|
||||
const s = blendT * blendT * (3 - 2 * blendT);
|
||||
|
||||
const y = rawY * (1 - s) + wallY * s;
|
||||
|
||||
// Clamp Y to valid range: no higher than roof base, no lower than bin floor
|
||||
const clampedY = Math.max(
|
||||
-sidewallHeight / 2,
|
||||
Math.min(sidewallHeight / 2, y)
|
||||
);
|
||||
|
||||
const vi = (1 + ring * segCount + seg) * 3;
|
||||
positions[vi] = x;
|
||||
positions[vi + 1] = clampedY;
|
||||
positions[vi + 2] = z;
|
||||
|
||||
// Approximate normals — pointing up (will look fine for grain)
|
||||
normals[vi] = 0; normals[vi + 1] = 1; normals[vi + 2] = 0;
|
||||
|
||||
const ui = (1 + ring * segCount + seg) * 2;
|
||||
uvs[ui] = (x / grainRadius) * 0.5 + 0.5;
|
||||
uvs[ui + 1] = (z / grainRadius) * 0.5 + 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Build triangle indices ---
|
||||
// Center fan: triangles from center point to first ring
|
||||
const indexList: number[] = [];
|
||||
|
||||
for (let seg = 0; seg < segCount; seg++) {
|
||||
const next = (seg + 1) % segCount;
|
||||
indexList.push(0, 1 + seg, 1 + next);
|
||||
}
|
||||
|
||||
// Ring quads: two triangles per quad between adjacent rings
|
||||
for (let ring = 0; ring < ringCount - 1; ring++) {
|
||||
for (let seg = 0; seg < segCount; seg++) {
|
||||
const next = (seg + 1) % segCount;
|
||||
|
||||
const a = 1 + ring * segCount + seg;
|
||||
const b = 1 + ring * segCount + next;
|
||||
const c = 1 + (ring + 1) * segCount + seg;
|
||||
const d = 1 + (ring + 1) * segCount + next;
|
||||
|
||||
indexList.push(a, c, b);
|
||||
indexList.push(b, c, d);
|
||||
}
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
|
||||
geo.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
|
||||
geo.setIndex(indexList);
|
||||
geo.computeVertexNormals(); // smooth out the approximated normals
|
||||
|
||||
return geo;
|
||||
}, [anchors, wallY, grainRadius, sidewallHeight]);
|
||||
|
||||
// --- Hopper fill (reuse existing cone approach) ---
|
||||
// The surface mesh handles the cylindrical portion.
|
||||
// The hopper below is always fully filled if the surface is above the bin floor.
|
||||
const lowestSurfaceY = useMemo(() => {
|
||||
if (anchors.length === 0) return -sidewallHeight / 2;
|
||||
return Math.min(...anchors.map(a => a.y), wallY);
|
||||
}, [anchors, wallY, sidewallHeight]);
|
||||
|
||||
const hopperIsActive = hopperHeight > 0 && lowestSurfaceY > -sidewallHeight / 2;
|
||||
|
||||
const hopperPosition = useMemo(
|
||||
() => new Vector3(0, -(sidewallHeight / 2 + hopperHeight / 2), 0),
|
||||
[sidewallHeight, hopperHeight]
|
||||
);
|
||||
|
||||
const hopperRotation = useMemo(() => new Euler(Math.PI, 0, 0), []);
|
||||
|
||||
// --- Fallback: flat fill when no top nodes ---
|
||||
const fallbackGeometry = useMemo(() => {
|
||||
if (anchors.length > 0 || fallbackFillPercent === undefined) return null;
|
||||
|
||||
const radius = diameter / 2;
|
||||
const fbRadius = radius * 0.98;
|
||||
const cylinderVolume = Math.PI * radius * radius * sidewallHeight;
|
||||
const hopperVolume = hopperHeight > 0
|
||||
? (1 / 3) * Math.PI * radius * radius * hopperHeight
|
||||
: 0;
|
||||
const totalVolume = cylinderVolume + hopperVolume;
|
||||
const filledVolume = totalVolume * fallbackFillPercent;
|
||||
|
||||
let hopperFillHeight = 0;
|
||||
let cylinderFillHeight = 0;
|
||||
|
||||
if (hopperHeight > 0 && filledVolume <= hopperVolume) {
|
||||
const ratio = filledVolume / hopperVolume;
|
||||
hopperFillHeight = hopperHeight * Math.pow(ratio, 1 / 3);
|
||||
cylinderFillHeight = 0;
|
||||
} else {
|
||||
hopperFillHeight = hopperHeight;
|
||||
const remaining = filledVolume - hopperVolume;
|
||||
cylinderFillHeight = Math.max(0, remaining / (Math.PI * radius * radius));
|
||||
}
|
||||
|
||||
return { fbRadius, hopperFillHeight, cylinderFillHeight };
|
||||
}, [anchors.length, fallbackFillPercent, diameter, sidewallHeight, hopperHeight]);
|
||||
|
||||
// --- Render fallback ---
|
||||
if (anchors.length === 0) {
|
||||
if (!fallbackGeometry) return null;
|
||||
|
||||
const { fbRadius, hopperFillHeight, cylinderFillHeight } = fallbackGeometry;
|
||||
|
||||
return (
|
||||
<>
|
||||
{hopperHeight > 0 && hopperFillHeight > 0 && (
|
||||
<Cone
|
||||
geometry={{
|
||||
radius: fbRadius * (hopperFillHeight / hopperHeight),
|
||||
height: hopperFillHeight,
|
||||
radialSegments: 20,
|
||||
openEnded: false,
|
||||
}}
|
||||
position={new Vector3(0, -(sidewallHeight / 2 + hopperHeight) + hopperFillHeight / 2, 0)}
|
||||
rotation={new Euler(Math.PI, 0, 0)}
|
||||
colour={colour}
|
||||
roughness={1}
|
||||
metalness={0}
|
||||
opacity={grainOpacity}
|
||||
depthWrite={false}
|
||||
depthTest={false}
|
||||
renderOrder={0}
|
||||
/>
|
||||
)}
|
||||
{cylinderFillHeight > 0 && (
|
||||
<Cylinder
|
||||
geometry={{
|
||||
radiusTop: fbRadius,
|
||||
radiusBottom: fbRadius,
|
||||
height: cylinderFillHeight,
|
||||
radialSegments: 20,
|
||||
heightSegments: 1,
|
||||
openEnded: false
|
||||
}}
|
||||
position={new Vector3(0, -sidewallHeight / 2 + cylinderFillHeight / 2, 0)}
|
||||
colour={colour}
|
||||
roughness={1}
|
||||
metalness={0}
|
||||
opacity={grainOpacity}
|
||||
depthWrite={false}
|
||||
depthTest={false}
|
||||
renderOrder={0}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Render cable-driven surface ---
|
||||
return (
|
||||
<>
|
||||
{/* Interpolated grain surface - i have not made a react component for this shape, not exactly a basic shape, so am just using mesh as is */}
|
||||
{surfaceGeometry && (
|
||||
<mesh renderOrder={0} geometry={surfaceGeometry}>
|
||||
<meshStandardMaterial
|
||||
color={colour}
|
||||
roughness={1}
|
||||
metalness={0}
|
||||
side={THREE.DoubleSide}
|
||||
transparent
|
||||
opacity={grainOpacity}
|
||||
depthWrite={false}
|
||||
depthTest={false}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Cylindrical body of grain below the surface down to the bin floor / hopper top */}
|
||||
<Cylinder
|
||||
position={new Vector3(0, -sidewallHeight / 2 + wallY / 2 + sidewallHeight / 4, 0)}
|
||||
geometry={{
|
||||
height: sidewallHeight / 2 + wallY,
|
||||
radiusBottom: grainRadius,
|
||||
radiusTop: grainRadius,
|
||||
radialSegments: THETA_SEGMENTS,
|
||||
heightSegments: 1,
|
||||
openEnded: false
|
||||
}}
|
||||
colour={colour}
|
||||
roughness={1}
|
||||
opacity={grainOpacity}
|
||||
depthWrite={false}
|
||||
depthTest={false}
|
||||
renderOrder={0}
|
||||
/>
|
||||
|
||||
{/* Hopper fill — always full when grain surface exists above the floor */}
|
||||
{hopperIsActive && (
|
||||
<Cone
|
||||
geometry={{
|
||||
radius: grainRadius,
|
||||
height: hopperHeight,
|
||||
radialSegments: THETA_SEGMENTS,
|
||||
openEnded: false,
|
||||
}}
|
||||
position={hopperPosition}
|
||||
rotation={hopperRotation}
|
||||
colour={colour}
|
||||
roughness={1}
|
||||
metalness={0}
|
||||
opacity={grainOpacity}
|
||||
depthWrite={false}
|
||||
depthTest={false}
|
||||
renderOrder={0}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
132
src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx
Normal file
132
src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import React, { useMemo } from "react";
|
||||
import Cylinder from "3dModels/Shapes/3D/Cylinder";
|
||||
import Cone from "3dModels/Shapes/3D/Cone";
|
||||
import { Vector3, Euler } from "three";
|
||||
|
||||
interface Props {
|
||||
diameter: number;
|
||||
sidewallHeight: number;
|
||||
hopperHeight?: number;
|
||||
fillPercent: number;
|
||||
grainOpacity?: number;
|
||||
colour?: string
|
||||
}
|
||||
|
||||
export default function GrainFillFlat(props: Props) {
|
||||
const {
|
||||
diameter,
|
||||
sidewallHeight,
|
||||
hopperHeight = 0,
|
||||
fillPercent,
|
||||
grainOpacity,
|
||||
colour
|
||||
} = props;
|
||||
|
||||
const radius = diameter / 2;
|
||||
|
||||
// Slightly smaller to avoid z-fighting with shell
|
||||
const grainRadius = radius * 0.98;
|
||||
|
||||
// --- volumes ---
|
||||
const cylinderVolume = Math.PI * radius * radius * sidewallHeight;
|
||||
const hopperVolume = hopperHeight > 0
|
||||
? (1 / 3) * Math.PI * radius * radius * hopperHeight
|
||||
: 0;
|
||||
|
||||
const totalVolume = cylinderVolume + hopperVolume;
|
||||
const filledVolume = totalVolume * fillPercent;
|
||||
|
||||
// --- result values ---
|
||||
let hopperFillHeight = 0;
|
||||
let cylinderFillHeight = 0;
|
||||
|
||||
if (hopperHeight > 0 && filledVolume <= hopperVolume) {
|
||||
// ONLY hopper filling
|
||||
const ratio = filledVolume / hopperVolume;
|
||||
|
||||
hopperFillHeight = hopperHeight * Math.pow(ratio, 1 / 3);
|
||||
cylinderFillHeight = 0;
|
||||
} else {
|
||||
// hopper full (or no hopper)
|
||||
hopperFillHeight = hopperHeight;
|
||||
|
||||
const remainingVolume = filledVolume - hopperVolume;
|
||||
|
||||
cylinderFillHeight = Math.max(
|
||||
0,
|
||||
remainingVolume / (Math.PI * radius * radius)
|
||||
);
|
||||
}
|
||||
|
||||
// --- positions ---
|
||||
|
||||
const hopperPosition = useMemo(
|
||||
() => new Vector3(
|
||||
0,
|
||||
-(sidewallHeight / 2 + hopperHeight) + hopperFillHeight / 2, //adding the hopper height so it starts at the cone and not the flat side
|
||||
0
|
||||
),
|
||||
[sidewallHeight, hopperFillHeight]
|
||||
);
|
||||
|
||||
//flip the cone so it matches the hoppers rotation with the cone pointed down
|
||||
const hopperRotation = useMemo(
|
||||
() => new Euler(Math.PI, 0, 0),
|
||||
[]
|
||||
);
|
||||
|
||||
const cylinderPosition = useMemo(
|
||||
() => new Vector3(
|
||||
0,
|
||||
-sidewallHeight / 2 + cylinderFillHeight / 2,
|
||||
0
|
||||
),
|
||||
[sidewallHeight, cylinderFillHeight]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hopper fill */}
|
||||
{hopperHeight > 0 && hopperFillHeight > 0 && (
|
||||
<Cone
|
||||
geometry={{
|
||||
radius: grainRadius * (hopperFillHeight / hopperHeight),
|
||||
height: hopperFillHeight,
|
||||
radialSegments: 20,
|
||||
openEnded: false
|
||||
}}
|
||||
position={hopperPosition}
|
||||
rotation={hopperRotation}
|
||||
colour={colour}
|
||||
roughness={1}
|
||||
metalness={0}
|
||||
opacity={grainOpacity}
|
||||
depthWrite={false}
|
||||
depthTest={false}
|
||||
renderOrder={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cylinder fill */}
|
||||
{cylinderFillHeight > 0 && (
|
||||
<Cylinder
|
||||
geometry={{
|
||||
radiusTop: grainRadius,
|
||||
radiusBottom: grainRadius,
|
||||
height: cylinderFillHeight,
|
||||
radialSegments: 20,
|
||||
openEnded: false
|
||||
}}
|
||||
position={cylinderPosition}
|
||||
colour={colour}
|
||||
roughness={1}
|
||||
metalness={0}
|
||||
opacity={grainOpacity}
|
||||
depthWrite={false}
|
||||
depthTest={false}
|
||||
renderOrder={0}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
71
src/bin/3dView/utils/grainFillBounds.ts
Normal file
71
src/bin/3dView/utils/grainFillBounds.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { ConeVolume, CylinderVolume } from "common/TrigFunctions";
|
||||
|
||||
/**
|
||||
* Y bounds of filled grain volume — matches GrainFillFlat / GrainCableFill fallback volume math.
|
||||
*/
|
||||
export interface GrainYBounds {
|
||||
minY: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
// Grain never physically fills all the way to the roof tip — cap roof fill
|
||||
// at this fraction of roofHeight so there is always visible breathing room.
|
||||
const MAX_ROOF_FILL_FRACTION = 0.75;
|
||||
|
||||
export function grainYBoundsFromFillPercent(
|
||||
diameter: number,
|
||||
sidewallHeight: number,
|
||||
hopperHeight: number,
|
||||
roofHeight: number,
|
||||
fillPercent: number
|
||||
): GrainYBounds {
|
||||
const radius = diameter / 2;
|
||||
const sidewallBaseY = -sidewallHeight / 2;
|
||||
const hopperTipY = sidewallBaseY - hopperHeight;
|
||||
const roofBaseY = sidewallHeight / 2;
|
||||
|
||||
if (fillPercent <= 0) {
|
||||
return { minY: sidewallBaseY, maxY: sidewallBaseY };
|
||||
}
|
||||
|
||||
const cylinderVolume = CylinderVolume(radius, sidewallHeight);
|
||||
const hopperVolume = hopperHeight > 0 ? ConeVolume(radius, hopperHeight) : 0;
|
||||
const roofVolume = roofHeight > 0 ? ConeVolume(radius, roofHeight) : 0;
|
||||
|
||||
// Volume of the usable roof portion (up to MAX_ROOF_FILL_FRACTION of height).
|
||||
// A cone fills as h³ so the capped volume fraction = MAX_ROOF_FILL_FRACTION³.
|
||||
const roofVolumeCapped = roofVolume * Math.pow(MAX_ROOF_FILL_FRACTION, 3);
|
||||
|
||||
const totalVolume = cylinderVolume + hopperVolume + roofVolumeCapped;
|
||||
const filledVolume = totalVolume * fillPercent;
|
||||
|
||||
// Fill the hopper first
|
||||
if (hopperHeight > 0 && filledVolume <= hopperVolume) {
|
||||
const ratio = filledVolume / hopperVolume;
|
||||
const hopperFillHeight = hopperHeight * Math.pow(ratio, 1 / 3);
|
||||
return {
|
||||
minY: hopperTipY,
|
||||
maxY: hopperTipY + hopperFillHeight,
|
||||
};
|
||||
}
|
||||
|
||||
// Then fill the cylinder
|
||||
const volumeAfterHopper = filledVolume - hopperVolume;
|
||||
if (volumeAfterHopper <= cylinderVolume) {
|
||||
const cylinderFillHeight = volumeAfterHopper / (Math.PI * radius * radius);
|
||||
return {
|
||||
minY: hopperHeight > 0 ? hopperTipY : sidewallBaseY,
|
||||
maxY: sidewallBaseY + cylinderFillHeight,
|
||||
};
|
||||
}
|
||||
|
||||
// Finally fill into the roof cone, capped at MAX_ROOF_FILL_FRACTION
|
||||
const roofFilledVolume = volumeAfterHopper - cylinderVolume;
|
||||
const roofRatio = Math.min(1, roofFilledVolume / roofVolumeCapped);
|
||||
const roofFillHeight = roofHeight * MAX_ROOF_FILL_FRACTION * Math.pow(roofRatio, 1 / 3);
|
||||
|
||||
return {
|
||||
minY: hopperHeight > 0 ? hopperTipY : sidewallBaseY,
|
||||
maxY: roofBaseY + roofFillHeight,
|
||||
};
|
||||
}
|
||||
68
src/bin/3dView/utils/tempToColour.ts
Normal file
68
src/bin/3dView/utils/tempToColour.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { Color } from "three";
|
||||
import { NodeData } from "../Data/BuildNodeData";
|
||||
|
||||
type ColourMode = "node" | "heatmap";
|
||||
|
||||
const GREEN = new Color("#52c41a");
|
||||
const BLUE = new Color("#3399ff");
|
||||
const RED = new Color("#ff4d4f");
|
||||
|
||||
export const colourFade = 20;
|
||||
const minimumLightness = 0.3;
|
||||
const lightnessRange = 0.2;
|
||||
const minimumSaturation = 0.7;
|
||||
const saturationRange = 0.8;
|
||||
|
||||
const getTemperatureIntensity = (temp: number, lower: number, upper: number) => {
|
||||
if (temp >= lower && temp <= upper) return 0;
|
||||
if (temp < lower) return lower - temp;
|
||||
return temp - upper;
|
||||
}
|
||||
|
||||
const getVisualIntensity = (distance: number, mode: ColourMode) => {
|
||||
// 1 = near threshold, 0 = far
|
||||
const intensity = Math.min(1, distance / colourFade);
|
||||
switch(mode){
|
||||
case "node":
|
||||
return 1 - intensity
|
||||
case "heatmap":
|
||||
return intensity
|
||||
}
|
||||
};
|
||||
|
||||
export function TempToColour(
|
||||
node: NodeData,
|
||||
lower: number,
|
||||
upper: number,
|
||||
mode: ColourMode
|
||||
): Color | null {
|
||||
|
||||
if (!node.inGrain || node.excluded) return null;
|
||||
|
||||
const temp = node.celcius;
|
||||
|
||||
// in-threshold behavior
|
||||
if (temp >= lower && temp <= upper) {
|
||||
return mode === "heatmap" ? null : GREEN;
|
||||
}
|
||||
|
||||
const distance = getTemperatureIntensity(temp, lower, upper);
|
||||
const intensity = getVisualIntensity(distance, mode);
|
||||
|
||||
const color = new Color();
|
||||
const hsl = { h: 0, s: 1, l: 1 };
|
||||
|
||||
if (temp < lower) {
|
||||
BLUE.getHSL(hsl);
|
||||
} else {
|
||||
RED.getHSL(hsl);
|
||||
}
|
||||
|
||||
color.setHSL(
|
||||
hsl.h,
|
||||
(saturationRange * intensity) + minimumSaturation,
|
||||
(lightnessRange * intensity) + minimumLightness
|
||||
);
|
||||
|
||||
return color;
|
||||
}
|
||||
718
src/bin/BinPlayer.tsx
Normal file
718
src/bin/BinPlayer.tsx
Normal file
|
|
@ -0,0 +1,718 @@
|
|||
import { DateRange, PlayArrow, Stop } from "@mui/icons-material";
|
||||
import { Box, Button, DialogActions, DialogContent, FormControl, IconButton, LinearProgress, MenuItem, Select, TextField, Typography } from "@mui/material";
|
||||
import { grey } from "@mui/material/colors";
|
||||
import ResponsiveDialog from "common/ResponsiveDialog";
|
||||
import { useMobile } from "hooks";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { Bin } from "models";
|
||||
import moment, { Moment } from "moment";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { quack } from "protobuf-ts/quack";
|
||||
import { useComponentAPI } from "providers";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
const CHECKPOINT_COUNT = 10;
|
||||
|
||||
interface StatusCheckpoint {
|
||||
time: Moment;
|
||||
timeString: string;
|
||||
cables: pond.GrainCable[];
|
||||
fans: pond.BinFan[];
|
||||
heaters: pond.BinHeater[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
bin: Bin;
|
||||
setBin: React.Dispatch<React.SetStateAction<Bin>>;
|
||||
componentDevices: Map<string, number>;
|
||||
}
|
||||
|
||||
type ReadingSlot = {
|
||||
timestamp: string;
|
||||
values: number[];
|
||||
};
|
||||
|
||||
type CableReadingsAtCheckpoint = {
|
||||
temperature?: ReadingSlot;
|
||||
humidity?: ReadingSlot;
|
||||
moisture?: ReadingSlot;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-checkpoint bucket for boolean output components (fans, heaters).
|
||||
* Only the most recent boolean reading in the time slice is kept.
|
||||
*/
|
||||
type BooleanStateAtCheckpoint = {
|
||||
state?: ReadingSlot; // values[0] is the boolean (1 = on, 0 = off)
|
||||
};
|
||||
|
||||
/** Per-checkpoint bucket: cable key → latest temp/humidity/moisture readings in that time slice. */
|
||||
type CheckpointReadings = Map<string, CableReadingsAtCheckpoint>;
|
||||
|
||||
/** Per-checkpoint bucket: component key → latest boolean state in that time slice. */
|
||||
type CheckpointBooleanStates = Map<string, BooleanStateAtCheckpoint>;
|
||||
|
||||
/**
|
||||
* Returns true when `candidate` should replace `existing` in a bucket.
|
||||
* Used so multiple samples in the same checkpoint keep only the newest reading per type.
|
||||
*/
|
||||
function readingIsNewer(candidate: ReadingSlot, existing?: ReadingSlot): boolean {
|
||||
if (!existing) {
|
||||
return true;
|
||||
}
|
||||
return moment(candidate.timestamp).valueOf() > moment(existing.timestamp).valueOf();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores one sampled reading on a cable's in-progress bucket state.
|
||||
* Routes by measurement type: temperature → celcius nodes, percent RH → humidity nodes,
|
||||
* grain EMC → moisture nodes. Ignores the update if an equal-or-newer reading is already stored.
|
||||
*/
|
||||
function upsertReading(
|
||||
readings: CableReadingsAtCheckpoint,
|
||||
type: quack.MeasurementType,
|
||||
timestamp: string,
|
||||
values: number[]
|
||||
): void {
|
||||
const slot: ReadingSlot = { timestamp, values };
|
||||
if (type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) {
|
||||
if (readingIsNewer(slot, readings.temperature)) {
|
||||
readings.temperature = slot;
|
||||
}
|
||||
} else if (type === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) {
|
||||
if (readingIsNewer(slot, readings.humidity)) {
|
||||
readings.humidity = slot;
|
||||
}
|
||||
} else if (type === quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC) {
|
||||
if (readingIsNewer(slot, readings.moisture)) {
|
||||
readings.moisture = slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces `count` wall-clock times evenly spaced from `start` through `end` (inclusive).
|
||||
* These are the playback frames — not derived from measurement data.
|
||||
*/
|
||||
function buildCheckpointTimes(start: Moment, end: Moment, count: number): Moment[] {
|
||||
const startMs = start.valueOf();
|
||||
const endMs = end.valueOf();
|
||||
if (count <= 1 || endMs <= startMs) {
|
||||
return [moment(startMs)];
|
||||
}
|
||||
const span = endMs - startMs;
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
moment(startMs + (span * i) / (count - 1))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a measurement timestamp to a checkpoint bucket index in [0, count - 1].
|
||||
* The [start, end] range is split into `count` equal-width segments; a reading lands in
|
||||
* the segment it falls into. Times before start → 0, after end → last bucket.
|
||||
*/
|
||||
function checkpointIndexForTimestamp(
|
||||
timestamp: Moment,
|
||||
start: Moment,
|
||||
end: Moment,
|
||||
count: number
|
||||
): number {
|
||||
const startMs = start.valueOf();
|
||||
const endMs = end.valueOf();
|
||||
if (count <= 1 || endMs <= startMs) {
|
||||
return 0;
|
||||
}
|
||||
const t = timestamp.valueOf();
|
||||
if (t <= startMs) {
|
||||
return 0;
|
||||
}
|
||||
if (t >= endMs) {
|
||||
return count - 1;
|
||||
}
|
||||
const fraction = (t - startMs) / (endMs - startMs);
|
||||
const idx = Math.floor(fraction * count);
|
||||
return Math.min(idx, count - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates `count` empty buckets, each pre-populated with an empty reading map per cable key.
|
||||
* Output length matches checkpoint count; index aligns with `buildCheckpointTimes`.
|
||||
*/
|
||||
function emptyCheckpointReadings(cableKeys: string[], count: number): CheckpointReadings[] {
|
||||
return Array.from({ length: count }, () => {
|
||||
const map: CheckpointReadings = new Map();
|
||||
cableKeys.forEach(key => map.set(key, {}));
|
||||
return map;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates `count` empty boolean state buckets, each pre-populated per component key.
|
||||
*/
|
||||
function emptyCheckpointBooleanStates(
|
||||
componentKeys: string[],
|
||||
count: number
|
||||
): CheckpointBooleanStates[] {
|
||||
return Array.from({ length: count }, () => {
|
||||
const map: CheckpointBooleanStates = new Map();
|
||||
componentKeys.forEach(key => map.set(key, {}));
|
||||
return map;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks every sampled measurement from each cable response and places it into a time bucket.
|
||||
* `measurementResponses[i]` corresponds to `cableKeys[i]` (same order as the sample requests).
|
||||
* Within a bucket, only the newest reading per measurement type is kept per cable.
|
||||
*/
|
||||
function assignMeasurementsToCheckpoints(
|
||||
cableKeys: string[],
|
||||
measurementResponses: pond.SampleUnitMeasurementsResponse[],
|
||||
start: Moment,
|
||||
end: Moment,
|
||||
count: number
|
||||
): CheckpointReadings[] {
|
||||
const buckets = emptyCheckpointReadings(cableKeys, count);
|
||||
|
||||
measurementResponses.forEach((response, cableIndex) => {
|
||||
const cableKey = cableKeys[cableIndex];
|
||||
if (!cableKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
response.measurements.forEach(um => {
|
||||
um.timestamps.forEach((ts, i) => {
|
||||
const valueArray = um.values[i];
|
||||
if (!ts || !valueArray || valueArray.error) {
|
||||
return;
|
||||
}
|
||||
const idx = checkpointIndexForTimestamp(moment(ts), start, end, count);
|
||||
const readings = buckets[idx].get(cableKey)!;
|
||||
upsertReading(readings, um.type, ts, valueArray.values);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks every sampled measurement from each boolean output component (fan/heater) response
|
||||
* and places the boolean state into a time bucket. Only the newest MEASUREMENT_TYPE_BOOLEAN
|
||||
* reading per component per bucket is kept.
|
||||
*/
|
||||
function assignBooleanStatesToCheckpoints(
|
||||
componentKeys: string[],
|
||||
measurementResponses: pond.SampleUnitMeasurementsResponse[],
|
||||
start: Moment,
|
||||
end: Moment,
|
||||
count: number
|
||||
): CheckpointBooleanStates[] {
|
||||
const buckets = emptyCheckpointBooleanStates(componentKeys, count);
|
||||
|
||||
measurementResponses.forEach((response, componentIndex) => {
|
||||
const componentKey = componentKeys[componentIndex];
|
||||
if (!componentKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
response.measurements.forEach(um => {
|
||||
if (um.type !== quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN) {
|
||||
return;
|
||||
}
|
||||
um.timestamps.forEach((ts, i) => {
|
||||
const valueArray = um.values[i];
|
||||
if (!ts || !valueArray || valueArray.error || valueArray.values.length === 0) {
|
||||
return;
|
||||
}
|
||||
const slot: ReadingSlot = { timestamp: ts, values: valueArray.values };
|
||||
const idx = checkpointIndexForTimestamp(moment(ts), start, end, count);
|
||||
const entry = buckets[idx].get(componentKey)!;
|
||||
if (readingIsNewer(slot, entry.state)) {
|
||||
entry.state = slot;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the newest timestamp across temp, humidity, and moisture slots for a cable.
|
||||
* Used as `lastRead` on the reconstructed `pond.GrainCable`.
|
||||
*/
|
||||
function latestTimestamp(readings: CableReadingsAtCheckpoint): string | undefined {
|
||||
const slots = [readings.temperature, readings.humidity, readings.moisture].filter(
|
||||
(s): s is ReadingSlot => s !== undefined
|
||||
);
|
||||
if (slots.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return slots.reduce(
|
||||
(latest, slot) =>
|
||||
moment(slot.timestamp).valueOf() > moment(latest).valueOf() ? slot.timestamp : latest,
|
||||
slots[0].timestamp
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines readings carried forward from earlier checkpoints with readings in the current bucket.
|
||||
* Any type missing in the current bucket keeps the previous checkpoint's value so playback
|
||||
* does not drop cables back to template defaults between sparse samples.
|
||||
*/
|
||||
function mergeCarriedReadings(
|
||||
carried: CableReadingsAtCheckpoint,
|
||||
current: CableReadingsAtCheckpoint
|
||||
): CableReadingsAtCheckpoint {
|
||||
return {
|
||||
temperature: current.temperature ?? carried.temperature,
|
||||
humidity: current.humidity ?? carried.humidity,
|
||||
moisture: current.moisture ?? carried.moisture
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines boolean states carried forward from earlier checkpoints with the current bucket.
|
||||
* If no reading exists in the current bucket the last known state is preserved, so fans/heaters
|
||||
* don't flicker back to their template default between sparse samples.
|
||||
*/
|
||||
function mergeCarriedBooleanState(
|
||||
carried: BooleanStateAtCheckpoint,
|
||||
current: BooleanStateAtCheckpoint
|
||||
): BooleanStateAtCheckpoint {
|
||||
return {
|
||||
state: current.state ?? carried.state
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones a live `pond.GrainCable` template (name, key, device, top node, etc.) and overwrites
|
||||
* node arrays from reconciled readings. `lastRead` is the newest measurement time, or the
|
||||
* checkpoint time when no readings exist yet for that cable.
|
||||
*/
|
||||
function buildGrainCableFromReadings(
|
||||
template: pond.GrainCable,
|
||||
readings: CableReadingsAtCheckpoint,
|
||||
checkpointTime: Moment
|
||||
): pond.GrainCable {
|
||||
const cable = pond.GrainCable.fromObject(cloneDeep(template));
|
||||
|
||||
// Clear template values so empty checkpoints don't inherit live data
|
||||
cable.celcius = [];
|
||||
cable.relativeHumidity = [];
|
||||
cable.moisture = [];
|
||||
|
||||
if (readings.temperature) {
|
||||
cable.celcius = [...readings.temperature.values];
|
||||
}
|
||||
if (readings.humidity) {
|
||||
cable.relativeHumidity = [...readings.humidity.values];
|
||||
}
|
||||
if (readings.moisture) {
|
||||
cable.moisture = [...readings.moisture.values];
|
||||
}
|
||||
|
||||
cable.lastRead = latestTimestamp(readings) ?? checkpointTime.toISOString();
|
||||
return cable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones a `pond.BinFan` template and overwrites its `state` from the reconciled boolean
|
||||
* reading. If no reading has been seen yet the template state is left as-is (carry-forward
|
||||
* will eventually set it once the first sample arrives).
|
||||
*/
|
||||
function buildBinFanFromState(
|
||||
template: pond.BinFan,
|
||||
booleanState: BooleanStateAtCheckpoint
|
||||
): pond.BinFan {
|
||||
const fan = pond.BinFan.fromObject(cloneDeep(template));
|
||||
if (booleanState.state !== undefined) {
|
||||
fan.state = booleanState.state.values[0] !== 0;
|
||||
}
|
||||
return fan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones a `pond.BinHeater` template and overwrites its `state` from the reconciled boolean
|
||||
* reading. Same carry-forward semantics as `buildBinFanFromState`.
|
||||
*/
|
||||
function buildBinHeaterFromState(
|
||||
template: pond.BinHeater,
|
||||
booleanState: BooleanStateAtCheckpoint
|
||||
): pond.BinHeater {
|
||||
const heater = pond.BinHeater.fromObject(cloneDeep(template));
|
||||
if (booleanState.state !== undefined) {
|
||||
heater.state = booleanState.state.values[0] !== 0;
|
||||
}
|
||||
return heater;
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end pipeline: evenly spaced times → bucket measurements → carry-forward merge → cables + fans + heaters.
|
||||
* Returns one `StatusCheckpoint` per frame with `time` (playback moment), `cables`
|
||||
* (full `grainCables` array), `fans`, and `heaters` as they would have appeared at that moment.
|
||||
*/
|
||||
function buildStatusCheckpoints(
|
||||
cableTemplates: pond.GrainCable[],
|
||||
fanTemplates: pond.BinFan[],
|
||||
heaterTemplates: pond.BinHeater[],
|
||||
cableMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
|
||||
fanMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
|
||||
heaterMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
|
||||
start: Moment,
|
||||
end: Moment,
|
||||
count: number = CHECKPOINT_COUNT
|
||||
): StatusCheckpoint[] {
|
||||
const times = buildCheckpointTimes(start, end, count);
|
||||
console.log(times);
|
||||
|
||||
// --- cables ---
|
||||
const cableKeys = cableTemplates.map(c => c.key);
|
||||
const cableBuckets = assignMeasurementsToCheckpoints(
|
||||
cableKeys,
|
||||
cableMeasurementResponses,
|
||||
start,
|
||||
end,
|
||||
count
|
||||
);
|
||||
|
||||
// --- fans ---
|
||||
const fanKeys = fanTemplates.map(f => f.key);
|
||||
const fanBuckets = assignBooleanStatesToCheckpoints(
|
||||
fanKeys,
|
||||
fanMeasurementResponses,
|
||||
start,
|
||||
end,
|
||||
count
|
||||
);
|
||||
|
||||
// --- heaters ---
|
||||
const heaterKeys = heaterTemplates.map(h => h.key);
|
||||
const heaterBuckets = assignBooleanStatesToCheckpoints(
|
||||
heaterKeys,
|
||||
heaterMeasurementResponses,
|
||||
start,
|
||||
end,
|
||||
count
|
||||
);
|
||||
|
||||
// Carry-forward state per component
|
||||
const carriedByCable = new Map<string, CableReadingsAtCheckpoint>();
|
||||
cableKeys.forEach(key => carriedByCable.set(key, {}));
|
||||
|
||||
const carriedByFan = new Map<string, BooleanStateAtCheckpoint>();
|
||||
fanKeys.forEach(key => carriedByFan.set(key, {}));
|
||||
|
||||
const carriedByHeater = new Map<string, BooleanStateAtCheckpoint>();
|
||||
heaterKeys.forEach(key => carriedByHeater.set(key, {}));
|
||||
|
||||
return cableBuckets.map((cableBucket, i) => {
|
||||
const time = times[i];
|
||||
|
||||
// Build cables
|
||||
const cables = cableTemplates.map(template => {
|
||||
const key = template.key;
|
||||
const bucketReadings = cableBucket.get(key) ?? {};
|
||||
const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings);
|
||||
carriedByCable.set(key, merged);
|
||||
return buildGrainCableFromReadings(template, merged, time);
|
||||
});
|
||||
|
||||
// Build fans
|
||||
const fans = fanTemplates.map(template => {
|
||||
const key = template.key;
|
||||
const bucketState = fanBuckets[i].get(key) ?? {};
|
||||
const merged = mergeCarriedBooleanState(carriedByFan.get(key)!, bucketState);
|
||||
carriedByFan.set(key, merged);
|
||||
return buildBinFanFromState(template, merged);
|
||||
});
|
||||
|
||||
// Build heaters
|
||||
const heaters = heaterTemplates.map(template => {
|
||||
const key = template.key;
|
||||
const bucketState = heaterBuckets[i].get(key) ?? {};
|
||||
const merged = mergeCarriedBooleanState(carriedByHeater.get(key)!, bucketState);
|
||||
carriedByHeater.set(key, merged);
|
||||
return buildBinHeaterFromState(template, merged);
|
||||
});
|
||||
|
||||
return { time, cables, fans, heaters, timeString: time.toISOString() };
|
||||
}).filter(checkpoint =>
|
||||
// Keep checkpoints that have at least some cable data
|
||||
checkpoint.cables.some(cable =>
|
||||
cable.celcius.length > 0 ||
|
||||
cable.relativeHumidity.length > 0 ||
|
||||
cable.moisture.length > 0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the number of checkpoints to create based on the start and end dates.
|
||||
* Is hard coded to space the checkpoints 6 hours apart.
|
||||
* @param start - The start date
|
||||
* @param end - The end date
|
||||
* @returns The number of checkpoints to create
|
||||
*/
|
||||
function checkpointCountFromRange(start: Moment, end: Moment): number {
|
||||
const hours = end.diff(start, 'hours');
|
||||
return Math.max(1, Math.floor(hours / 6) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is used to reconcile measurements with an array of bin objects in order to see how the bin has progressed over a set period.
|
||||
* It gets the measurements for connected components, and uses those measurements to create a bin and simulate what its status would have been
|
||||
* at that time, creates an array of bins doing this and then uses a timer in a loop to change the bin in the status.
|
||||
* Fans and heaters are included so playback also reflects which equipment was running at each checkpoint.
|
||||
* @returns a JSX Element
|
||||
*/
|
||||
export default function BinPlayer(props: Props) {
|
||||
const { bin, componentDevices, setBin } = props;
|
||||
const isMobile = useMobile();
|
||||
const componentAPI = useComponentAPI();
|
||||
const [dateSelect, setDateSelect] = useState<number>(0);
|
||||
const [startDate, setStartDate] = useState<Moment>(moment().subtract(1, 'week'));
|
||||
const [endDate, setEndDate] = useState<Moment>(moment);
|
||||
const [dateRangeDialog, setDateRangeDialog] = useState(false);
|
||||
const [tempStartDate, setTempStartDate] = useState<Moment>(moment().subtract(1, 'week'));
|
||||
const [tempEndDate, setTempEndDate] = useState<Moment>(moment());
|
||||
const [currentCheckpointTime, setCurrentCheckpointTime] = useState<Moment | undefined>(undefined);
|
||||
const [displayProgress, setDisplayProgress] = useState(0);
|
||||
const totalCheckpoints = useRef(0);
|
||||
const checkpointStartTime = useRef(0);
|
||||
const currentCheckpointIndex = useRef(0);
|
||||
const isPlaying = useRef(false);
|
||||
const PLAYBACK_INTERVAL = 1000;
|
||||
const [isPlayingState, setIsPlayingState] = useState(false);
|
||||
const stopPlayer = () => { isPlaying.current = false; };
|
||||
const originalBin = useRef<Bin | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlayingState) return;
|
||||
|
||||
const fps = 30;
|
||||
const interval = 1000 / fps;
|
||||
const stepDuration = PLAYBACK_INTERVAL;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (!isPlayingState) {
|
||||
clearInterval(timer);
|
||||
setDisplayProgress(0);
|
||||
return;
|
||||
}
|
||||
const total = totalCheckpoints.current;
|
||||
if (total === 0) return;
|
||||
|
||||
const idx = currentCheckpointIndex.current;
|
||||
const elapsed = Date.now() - checkpointStartTime.current;
|
||||
const stepProgress = Math.min(elapsed / stepDuration, 1);
|
||||
const raw = total <= 1 ? 100 : ((idx + stepProgress) / (total - 1)) * 100;
|
||||
setDisplayProgress(Math.min(raw, 100));
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isPlayingState]);
|
||||
|
||||
/**
|
||||
* Fetches sampled unit measurements for every grain cable, fan, and heater in the current
|
||||
* bin status, then builds `statusCheckpoints` for playback over [startDate, endDate].
|
||||
*/
|
||||
const startPlayer = async () => {
|
||||
originalBin.current = cloneDeep(bin);
|
||||
isPlaying.current = true;
|
||||
setIsPlayingState(true);
|
||||
|
||||
// Cable requests
|
||||
const cableSampleRequests = bin.status.grainCables.map(cable => {
|
||||
const deviceID = componentDevices.get(cable.key) ?? 0;
|
||||
return componentAPI.sampleUnitMeasurements(deviceID, cable.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true);
|
||||
});
|
||||
|
||||
// Fan requests
|
||||
const fanSampleRequests = bin.status.fans.map(fan => {
|
||||
const deviceID = componentDevices.get(fan.key) ?? 0;
|
||||
return componentAPI.sampleUnitMeasurements(deviceID, fan.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true);
|
||||
});
|
||||
|
||||
// Heater requests
|
||||
const heaterSampleRequests = bin.status.heaters.map(heater => {
|
||||
const deviceID = componentDevices.get(heater.key) ?? 0;
|
||||
return componentAPI.sampleUnitMeasurements(deviceID, heater.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true);
|
||||
});
|
||||
|
||||
const [cableResponses, fanResponses, heaterResponses] = await Promise.all([
|
||||
Promise.all(cableSampleRequests),
|
||||
Promise.all(fanSampleRequests),
|
||||
Promise.all(heaterSampleRequests),
|
||||
]);
|
||||
|
||||
const checkpoints = buildStatusCheckpoints(
|
||||
bin.status.grainCables,
|
||||
bin.status.fans,
|
||||
bin.status.heaters,
|
||||
cableResponses.map(r => r.data),
|
||||
fanResponses.map(r => r.data),
|
||||
heaterResponses.map(r => r.data),
|
||||
startDate,
|
||||
endDate,
|
||||
checkpointCountFromRange(startDate, endDate)
|
||||
);
|
||||
|
||||
for (let i = 0; i < checkpoints.length; i++) {
|
||||
if (!isPlaying.current) {
|
||||
setBin(originalBin.current!);
|
||||
setCurrentCheckpointTime(undefined);
|
||||
setIsPlayingState(false);
|
||||
return;
|
||||
}
|
||||
currentCheckpointIndex.current = i;
|
||||
totalCheckpoints.current = checkpoints.length;
|
||||
checkpointStartTime.current = Date.now();
|
||||
|
||||
const newBin = cloneDeep(originalBin.current!);
|
||||
newBin.status.grainCables = checkpoints[i].cables;
|
||||
newBin.status.fans = checkpoints[i].fans;
|
||||
newBin.status.heaters = checkpoints[i].heaters;
|
||||
|
||||
setBin(newBin);
|
||||
setCurrentCheckpointTime(checkpoints[i].time);
|
||||
await new Promise(res => setTimeout(res, PLAYBACK_INTERVAL));
|
||||
}
|
||||
|
||||
isPlaying.current = false;
|
||||
setIsPlayingState(false);
|
||||
setBin(originalBin.current!);
|
||||
setCurrentCheckpointTime(undefined);
|
||||
};
|
||||
|
||||
const datePickerDialog = () => {
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open={dateRangeDialog}
|
||||
onClose={() => setDateRangeDialog(false)}
|
||||
aria-labelledby="date-range-dialog">
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
type="date"
|
||||
label="Start Date"
|
||||
value={tempStartDate.format("YYYY-MM-DD")}
|
||||
onChange={e => {
|
||||
setTempStartDate(moment(e.target.value));
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
type="date"
|
||||
label="End Date"
|
||||
value={tempEndDate.format("YYYY-MM-DD")}
|
||||
onChange={e => {
|
||||
setTempEndDate(moment(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDateRangeDialog(false)} color="primary">
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
const now = moment();
|
||||
setStartDate(tempStartDate.clone().set({
|
||||
hour: now.hour(),
|
||||
minute: now.minute(),
|
||||
second: now.second()
|
||||
}));
|
||||
setEndDate(tempEndDate.clone().set({
|
||||
hour: now.hour(),
|
||||
minute: now.minute(),
|
||||
second: now.second()
|
||||
}));
|
||||
setDateRangeDialog(false);
|
||||
}} color="primary">
|
||||
Submit
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
};
|
||||
|
||||
const dateSelector = () => {
|
||||
return (
|
||||
<Box display="flex" flexDirection="row" justifyContent="space-between">
|
||||
<FormControl fullWidth>
|
||||
<Select
|
||||
value={dateSelect}
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: grey[800],
|
||||
'& .MuiSelect-select': {
|
||||
py: 1.5,
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||||
}}
|
||||
onChange={event => {
|
||||
setDateSelect(event.target.value as number);
|
||||
let currentDate = moment();
|
||||
setEndDate(currentDate);
|
||||
switch (event.target.value) {
|
||||
case 0:
|
||||
setStartDate(currentDate.clone().subtract(1, 'week'));
|
||||
break;
|
||||
case 1:
|
||||
setStartDate(currentDate.clone().subtract(2, 'weeks'));
|
||||
break;
|
||||
case 2:
|
||||
setStartDate(currentDate.clone().subtract(4, 'weeks'));
|
||||
break;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value={0}>1 Week</MenuItem>
|
||||
<MenuItem value={1}>2 Weeks</MenuItem>
|
||||
<MenuItem value={2}>4 Weeks</MenuItem>
|
||||
<MenuItem value={3}>Custom</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{dateSelect === 3 && (
|
||||
<Button onClick={() => setDateRangeDialog(true)} color="primary"><DateRange /></Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{datePickerDialog()}
|
||||
<Box display="flex" flexDirection="row" justifyContent="space-between" width="100%" gap={2} alignContent={"center"} alignItems={"center"}>
|
||||
{/* start/stop button */}
|
||||
<Box>
|
||||
{isPlayingState ? (
|
||||
<IconButton sx={{ border: "1px solid", borderRadius: "50%", padding: 1, height: isMobile ? 40 : 60, width: isMobile ? 40 : 60 }} onClick={stopPlayer}><Stop fontSize="large" /></IconButton>
|
||||
) : (
|
||||
<IconButton sx={{ border: "1px solid", borderRadius: "50%", padding: 1, height: isMobile ? 40 : 60, width: isMobile ? 40 : 60 }} onClick={startPlayer}><PlayArrow fontSize="large" /></IconButton>
|
||||
)}
|
||||
</Box>
|
||||
{/* progress bar and date display */}
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography fontSize={isMobile ? 15 : 17} textAlign={"center"}>{currentCheckpointTime ? currentCheckpointTime.format("MMM D, YYYY h:mm A") : moment().format("MMM D, YYYY h:mm A")}</Typography>
|
||||
<LinearProgress value={displayProgress} variant="determinate" color="inherit" />
|
||||
<Box display="flex" flexDirection="row" justifyContent="space-between">
|
||||
<Typography variant="caption">{startDate.format("MMM D, YYYY")}</Typography>
|
||||
<Typography variant="caption">{endDate.format("MMM D, YYYY")}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* date range selector */}
|
||||
<Box>
|
||||
{dateSelector()}
|
||||
</Box>
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
|
@ -615,7 +615,9 @@ export default function BinSVGV2(props: Props) {
|
|||
//determine how high to draw the node on the cable
|
||||
const nodeY = nodeSpacingY * (index + 1) + minNodeY;
|
||||
|
||||
//if we are using the auto top nodes only show the heatmap for nodes at or below the top node and it is not an ecluded node
|
||||
//if we are using the auto top nodes only show the heatmap for nodes at or below the top node and it is not an excluded node
|
||||
//NOTE it is nodeNumber - 1 because the excluded node array uses a 0 based count for the node number but the nodeNumber variable uses a 1 based count
|
||||
//ie. the bottom node of the cable would be nodeNumber of 1 but in the array it would be 0
|
||||
if (!cable.excludedNodes.includes(nodeNumber - 1)){
|
||||
if (cable.topNode > 0) {
|
||||
if (nodeNumber <= cable.topNode) {
|
||||
|
|
|
|||
104
src/bin/BinSensorCard.tsx
Normal file
104
src/bin/BinSensorCard.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { AccessTime, MoreVert } from "@mui/icons-material";
|
||||
import { Avatar, Box, Card, IconButton, Table, TableBody, TableCell, TableHead, TableRow, Typography, useTheme } from "@mui/material";
|
||||
import moment from "moment";
|
||||
import React from "react";
|
||||
|
||||
interface SensorRow {
|
||||
label: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface CableRow {
|
||||
label: string;
|
||||
min: string;
|
||||
avg: string;
|
||||
max: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
icon?: string;
|
||||
tag: string;
|
||||
lastReading: string;
|
||||
onMenuClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
rows?: SensorRow[];
|
||||
cableRows?: CableRow[];
|
||||
/**
|
||||
* the time in hours that a reading is considered 'good'
|
||||
* defaults to 6
|
||||
*/
|
||||
staleLimit?: number;
|
||||
}
|
||||
|
||||
|
||||
export default function BinSensorCard(props: Props) {
|
||||
const { name, icon, tag, lastReading, onMenuClick, rows, cableRows, staleLimit = 6 } = props
|
||||
const theme = useTheme()
|
||||
const isStale = moment().diff(moment(lastReading), "hours") > staleLimit
|
||||
|
||||
return (
|
||||
<Card raised sx={{ height: "100%" }}>
|
||||
<Box padding={1.5} height="100%" display="flex" flexDirection="column">
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" marginBottom={1}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{icon &&
|
||||
<Avatar
|
||||
variant="square"
|
||||
src={icon}
|
||||
alt={name + " icon"}
|
||||
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
|
||||
/>
|
||||
}
|
||||
<Typography fontWeight={500}>{name}</Typography>
|
||||
<Typography variant="caption" color="textSecondary">- {tag}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={onMenuClick}>
|
||||
<MoreVert fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Table size="small">
|
||||
{cableRows ? (
|
||||
<>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ color: "text.secondary", fontSize: 11, fontWeight: 500, border: 0, pb: 0.5, pl: 0 }} />
|
||||
<TableCell align="right" sx={{ color: "text.secondary", fontSize: 11, fontWeight: 500, border: 0, pb: 0.5 }}>Min</TableCell>
|
||||
<TableCell align="right" sx={{ color: "primary.main", fontSize: 11, fontWeight: 500, border: 0, pb: 0.5 }}>Avg</TableCell>
|
||||
<TableCell align="right" sx={{ color: "text.secondary", fontSize: 11, fontWeight: 500, border: 0, pb: 0.5, pr: 0 }}>Max</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{cableRows.map((row) => (
|
||||
<TableRow key={row.label}>
|
||||
<TableCell sx={{ color: "text.secondary", fontSize: 11, pl: 0, py: 0.5 }}>{row.label}</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: 12, py: 0.5 }}>{row.min}</TableCell>
|
||||
<TableCell align="right" sx={{ color: "primary.main", fontWeight: 500, fontSize: 12, py: 0.5 }}>{row.avg}</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: 12, py: 0.5, pr: 0 }}>{row.max}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</>
|
||||
) : (
|
||||
<TableBody>
|
||||
{rows?.map((row) => (
|
||||
<TableRow key={row.label}>
|
||||
<TableCell sx={{ color: "text.secondary", fontSize: 11, pl: 0, py: 0.5 }}>{row.label}</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: 12, py: 0.5 }}>{row.data}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={0.5} marginTop="auto">
|
||||
<AccessTime sx={{ fontSize: 12, color: isStale ? "warning.main" : "text.disabled" }} />
|
||||
<Typography variant="caption" color={isStale ? "warning.main" : "text.secondary"}>
|
||||
{moment(lastReading).fromNow()}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
194
src/bin/BinTableExpanded.tsx
Normal file
194
src/bin/BinTableExpanded.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { Box, Table, TableBody, TableCell, TableHead, TableRow, Theme, Typography, useTheme } from "@mui/material";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { avg, celsiusToFahrenheit } from "utils";
|
||||
import { useGlobalState } from "providers";
|
||||
import { darken } from "@mui/material";
|
||||
import React from "react";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => ({
|
||||
tableStyle: {
|
||||
borderCollapse: "separate",
|
||||
borderSpacing: 0,
|
||||
},
|
||||
headerCellStyle: {
|
||||
fontWeight: 650,
|
||||
backgroundColor: darken(theme.palette.background.paper, 0.3),
|
||||
fontSize: 15,
|
||||
textAlign: "center",
|
||||
borderBottom: "none",
|
||||
padding: 10,
|
||||
},
|
||||
defaultCellStyle: {
|
||||
padding: 2,
|
||||
fontWeight: 650,
|
||||
fontSize: 17,
|
||||
textAlign: "center",
|
||||
backgroundColor: darken(theme.palette.background.paper, 0.1),
|
||||
borderTop: "1px solid " + darken(theme.palette.background.paper, 0.4),
|
||||
borderBottom: "none",
|
||||
},
|
||||
colouredCellStyle: {
|
||||
padding: 2,
|
||||
fontWeight: 650,
|
||||
fontSize: 17,
|
||||
textAlign: "center",
|
||||
borderTop: "1px solid " + darken(theme.palette.background.paper, 0.4),
|
||||
borderBottom: "none",
|
||||
},
|
||||
}));
|
||||
|
||||
// interface Column {
|
||||
// label: string // e.g. "Cable 1 Temp", "Cable 2 Moisture"
|
||||
// cableIndex: number
|
||||
// type: "temp" | "moisture"
|
||||
// }
|
||||
|
||||
interface Props {
|
||||
cables: pond.GrainCable[]
|
||||
targetTemp: number // in Celsius, for colouring
|
||||
targetMoisture: number // for colouring
|
||||
inBounds?: string
|
||||
caution?: string
|
||||
warning?: string
|
||||
|
||||
}
|
||||
|
||||
export default function BinTableExpanded(props: Props) {
|
||||
const { cables, targetTemp, targetMoisture, inBounds = "#43a047", caution = "#f9a825", warning = "#c62828" } = props
|
||||
const classes = useStyles()
|
||||
const [{ user }] = useGlobalState()
|
||||
const theme = useTheme()
|
||||
|
||||
const useFahrenheit = user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
|
||||
|
||||
// Row count driven by the longest cable
|
||||
const rowCount = useMemo(
|
||||
() => Math.max(0, ...cables.map(c => c.celcius.length)),
|
||||
[cables]
|
||||
)
|
||||
|
||||
// Render a single cell value + background colour
|
||||
const renderCell = (cable: pond.GrainCable, nodeIndex: number, type: "temp" | "moisture") => {
|
||||
if (type === "temp") {
|
||||
const raw = cable.celcius[nodeIndex]
|
||||
if (raw === undefined) return { display: "--", color: "" }
|
||||
|
||||
const isExcluded = cable.excludedNodes.includes(nodeIndex)
|
||||
if (isExcluded) return { display: "--", color: "" }
|
||||
|
||||
let display: string
|
||||
let color = ""
|
||||
const inGrain = nodeIndex < cable.topNode
|
||||
|
||||
if (inGrain) {
|
||||
if (raw > targetTemp + 10) color = warning
|
||||
else if (raw > targetTemp + 5) color = caution
|
||||
else color = inBounds
|
||||
}
|
||||
|
||||
const converted = useFahrenheit ? celsiusToFahrenheit(raw) : raw
|
||||
display = converted.toFixed(1) + (useFahrenheit ? " °F" : " °C")
|
||||
return { display, color }
|
||||
}
|
||||
|
||||
// moisture
|
||||
const raw = cable.moisture[nodeIndex]
|
||||
if (raw === undefined || raw === 0) return { display: "--", color: "" }
|
||||
|
||||
const isExcluded = cable.excludedNodes.includes(nodeIndex)
|
||||
if (isExcluded) return { display: "--", color: "" }
|
||||
|
||||
let color = ""
|
||||
const inGrain = nodeIndex < cable.topNode
|
||||
if (inGrain) {
|
||||
if (raw > targetMoisture + 1.5) color = warning
|
||||
else if (raw > targetMoisture + 0.5) color = caution
|
||||
else color = inBounds
|
||||
}
|
||||
return { display: raw.toFixed(2) + "%", color }
|
||||
}
|
||||
|
||||
const cableTable = (cable: pond.GrainCable) => {
|
||||
return (
|
||||
<Table className={classes.tableStyle}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className={classes.headerCellStyle} sx={{ borderTopLeftRadius: 8 }}>
|
||||
Level
|
||||
</TableCell>
|
||||
<TableCell className={classes.headerCellStyle}>
|
||||
Temperature
|
||||
</TableCell>
|
||||
<TableCell className={classes.headerCellStyle} sx={{ borderTopRightRadius: 8 }}>
|
||||
Moisture
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Array.from({ length: rowCount }, (_, i) => rowCount - 1 - i).map((nodeIndex, rowI) => {
|
||||
const isLast = rowI === rowCount - 1
|
||||
const tempData = renderCell(cable, nodeIndex, "temp")
|
||||
const moistureData = renderCell(cable, nodeIndex, "moisture")
|
||||
return (
|
||||
<TableRow key={nodeIndex}>
|
||||
<TableCell
|
||||
padding="none"
|
||||
className={classes.defaultCellStyle}
|
||||
sx={{ borderBottomLeftRadius: isLast ? 8 : 0 }}>
|
||||
{nodeIndex + 1}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
padding="none"
|
||||
className={classes.colouredCellStyle}
|
||||
sx={{ backgroundColor: tempData.color || darken(theme.palette.background.paper, 0.1) }}>
|
||||
{tempData.display}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
padding="none"
|
||||
className={classes.colouredCellStyle}
|
||||
sx={{
|
||||
backgroundColor: moistureData.color || darken(theme.palette.background.paper, 0.1),
|
||||
borderBottomRightRadius: isLast ? 8 : 0,
|
||||
}}>
|
||||
{moistureData.display}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box padding={1}>
|
||||
{/* Table Legend */}
|
||||
<Box display="flex" alignItems="center" justifyContent={"space-between"} marginY={2}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Box sx={{ width: 40, height: 20, backgroundColor: inBounds, borderRadius: 1, flexShrink: 0 }} />
|
||||
<Typography sx={{fontWeight: 600, fontSize: 10}}>On target</Typography>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Box sx={{ width: 40, height: 20, backgroundColor: caution, borderRadius: 1, flexShrink: 0 }} />
|
||||
<Typography sx={{fontWeight: 600, fontSize: 10}}>{user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "~10 °F" : "+5 °C"} above</Typography>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Box sx={{ width: 40, height: 20, backgroundColor: warning, borderRadius: 1, flexShrink: 0 }} />
|
||||
<Typography sx={{fontWeight: 600, fontSize: 10}}>{user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "~20 °F" : "+10 °C"} above</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexDirection: "row", overflowX: "auto", gap: 2, paddingBottom: 2}}>
|
||||
{cables.map((cable, i) => (
|
||||
<Box key={i} sx={{ flexShrink: 0 }}>
|
||||
<Typography className={classes.headerCellStyle} sx={{ textAlign: "center" }}>
|
||||
{cable.name}
|
||||
</Typography>
|
||||
{cableTable(cable)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
423
src/bin/bin3dVisualizer.tsx
Normal file
423
src/bin/bin3dVisualizer.tsx
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
import { Box, Checkbox, FormControl, MenuItem, List, ListItem, InputLabel, Select, Typography, Stack, FormControlLabel, useTheme } from "@mui/material";
|
||||
import Bin3dView from "bin/3dView/Scene/Bin3dView";
|
||||
import { Bin, Component, Device } from "models";
|
||||
import { Controller } from "models/Controller";
|
||||
import { pond, quack } from "protobuf-ts/pond";
|
||||
import React, { useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import NodeControls from "./binSummary/components/nodeControls";
|
||||
import { NodeData } from "bin/3dView/Data/BuildNodeData";
|
||||
import { CableData } from "bin/3dView/Data/BuildCableData";
|
||||
import ModeChangeDialog from "bin/conditioning/modeChangeDialog";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { useMobile } from "hooks";
|
||||
|
||||
interface Props {
|
||||
bin: Bin
|
||||
// cables?: GrainCable[]
|
||||
devices: Device[]
|
||||
// fans?: Controller[]
|
||||
// heaters?: Controller[]
|
||||
permissions: pond.Permission[]
|
||||
componentDevices: Map<string, number>
|
||||
componentMap: Map<string, Component>
|
||||
binPrefs?: Map<string, pond.BinComponentPreferences>
|
||||
updateBinCallback?: (bin: Bin) => void
|
||||
}
|
||||
export default function bin3dVisualizer(props: Props){
|
||||
const {bin, permissions, devices, componentDevices, componentMap, binPrefs, updateBinCallback} = props
|
||||
const theme = useTheme()
|
||||
const [showHeatmap, setShowHeatmap] = useState(true)
|
||||
// const [showHotspots, setShowHotspots] = useState(false)
|
||||
// const [showFill, setShowFill] = useState(false)
|
||||
const [showTemp, setShowTemp] = useState(true)
|
||||
const [showMoisture, setShowMoisture] = useState(false)
|
||||
const [showMoistureHeatmap, setShowMoistureHeatmap] = useState(false)
|
||||
const [binDisplay, setBinDisplay] = useState<string>("temp")
|
||||
const [binMode, setBinMode] = useState<pond.BinMode>(pond.BinMode.BIN_MODE_NONE)
|
||||
const [openModeChange, setOpenModeChange] = useState(false)
|
||||
const [selectedCable, setSelectedCable] = useState<CableData | undefined>(undefined)
|
||||
const [selectedNode, setSelectedNode] = useState<NodeData | undefined>(undefined)
|
||||
const [selectedCableDevice, setSelectedCableDevice] = useState(Device.create()) //this is the device that the cable that has the node that was clicked on belongs to
|
||||
const [filteredComponents, setFilteredComponents] = useState<Component[]>([]) //components that are filtered to only include ones on the same device
|
||||
const [openNodeControls, setOpenNodeControls] = useState(false)
|
||||
const [devMap, setDevMap] = useState<Map<number, Device>>(new Map())
|
||||
const [modeChangeInProgress, setModeChangeInProgress] = useState(false)
|
||||
const [showLabels, setShowLabels] = useState(true)
|
||||
const isMobile = useMobile()
|
||||
|
||||
useEffect(()=>{
|
||||
let newMap: Map<number, Device> = new Map()
|
||||
devices.forEach(d => {
|
||||
newMap.set(d.id(), d)
|
||||
})
|
||||
setDevMap(newMap)
|
||||
},[devices])
|
||||
|
||||
useEffect(()=>{
|
||||
setBinMode(bin.settings.mode)
|
||||
},[bin])
|
||||
|
||||
const updateBin = () => {
|
||||
if(updateBinCallback){
|
||||
let clone = cloneDeep(bin)
|
||||
clone.settings.mode = binMode
|
||||
updateBinCallback(clone)
|
||||
}
|
||||
};
|
||||
|
||||
const binModeControl = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Typography sx={{fontWeight: 650, fontSize: isMobile ? 13 : 17}}>{bin.name()}</Typography>
|
||||
<FormControl fullWidth>
|
||||
<Select
|
||||
value={binMode}
|
||||
disabled={modeChangeInProgress}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
bgcolor: '#151E27',
|
||||
'& .MuiSelect-select': {
|
||||
py: isMobile ? 0.5 : 1.5,
|
||||
fontSize: 13
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||||
}}
|
||||
onChange={event => {
|
||||
setBinMode(event.target.value as pond.BinMode)
|
||||
setOpenModeChange(true)
|
||||
}}
|
||||
>
|
||||
<MenuItem value={pond.BinMode.BIN_MODE_NONE}>Select Mode..</MenuItem>
|
||||
<MenuItem value={pond.BinMode.BIN_MODE_STORAGE}>Storage</MenuItem>
|
||||
<MenuItem value={pond.BinMode.BIN_MODE_DRYING}>Drying</MenuItem>
|
||||
<MenuItem value={pond.BinMode.BIN_MODE_HYDRATING}>Hydrating</MenuItem>
|
||||
<MenuItem value={pond.BinMode.BIN_MODE_COOLDOWN}>Cooldown</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const heatmapDisplay = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Typography noWrap sx={{fontWeight: 650, fontSize: isMobile ? 13 : 17}}>
|
||||
Heatmap
|
||||
</Typography>
|
||||
<FormControl fullWidth>
|
||||
<Select
|
||||
value={binDisplay}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
bgcolor: '#151E27',
|
||||
'& .MuiSelect-select': {
|
||||
py: isMobile ? 0.5 : 1.5,
|
||||
fontSize: 13
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||||
}}
|
||||
onChange={event => {
|
||||
let selection = event.target.value
|
||||
setBinDisplay(selection)
|
||||
if (selection === "temp") {
|
||||
setShowHeatmap(true)
|
||||
setShowTemp(true)
|
||||
setShowMoisture(false)
|
||||
setShowMoistureHeatmap(false)
|
||||
}
|
||||
if (selection === "moisture") {
|
||||
setShowHeatmap(false)
|
||||
setShowTemp(false)
|
||||
setShowMoisture(true)
|
||||
setShowMoistureHeatmap(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value={"temp"}>Temperature</MenuItem>
|
||||
<MenuItem value={"moisture"}>Moisture</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{/* Checkbox moved below dropdown — no longer affects top alignment */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={showLabels}
|
||||
onChange={(e, checked) => setShowLabels(checked)}
|
||||
/>
|
||||
}
|
||||
label={<Typography noWrap sx={{fontSize: 11}}>Show Values</Typography>}
|
||||
sx={{ mt: 0.5, ml: '-9px' }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// const controllerState = (controller: Controller) => {
|
||||
// let state = false
|
||||
// controller.status.lastGoodMeasurement.forEach(um => {
|
||||
// if(um.type === quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN){
|
||||
// if(um.values.length > 0){
|
||||
// let readings = um.values
|
||||
// if(readings[readings.length-1].values.length > 0){
|
||||
// let nodes = readings[readings.length-1].values
|
||||
// if (nodes[nodes.length -1] === 1){
|
||||
// state = true
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// return state
|
||||
// }
|
||||
|
||||
const controllerDisplay = () => {
|
||||
if ((bin.status.fans && bin.status.fans.length > 0) || (bin.status.heaters && bin.status.heaters.length > 0)){
|
||||
return (
|
||||
<Box width="100%">
|
||||
<Typography sx={{fontWeight: 650, fontSize: isMobile ? 13 : 17}}>
|
||||
Controllers
|
||||
</Typography>
|
||||
{bin.status.fans && bin.status.fans.map(fan => {
|
||||
// let isOn = controllerState(fan)
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={fan.key}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
// bgcolor: 'rgba(255,255,255,0.05)',
|
||||
bgcolor: '#151E27',
|
||||
borderRadius: 1,
|
||||
px: 2,
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{fontSize: 13}}>{fan.name}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{fontWeight: 650, fontSize: 13}}>
|
||||
{fan.state ? "ON" : "OFF"}
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: fan.state ? 'success.main' : 'error.main'
|
||||
}} />
|
||||
</Box>
|
||||
</Box>
|
||||
)})}
|
||||
{bin.status.heaters && bin.status.heaters.map(heater => {
|
||||
// let isOn = controllerState(heater)
|
||||
return (
|
||||
<Box
|
||||
key={heater.key}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
// bgcolor: 'rgba(255,255,255,0.05)',
|
||||
bgcolor: '#151E27',
|
||||
borderRadius: 1,
|
||||
px: 2,
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{fontSize: 13}}>{heater.name}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{fontWeight: 650, fontSize: 13}}>
|
||||
{heater.state ? "ON" : "OFF"}
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: heater.state ? 'success.main' : 'error.main'
|
||||
}} />
|
||||
</Box>
|
||||
</Box>
|
||||
)})}
|
||||
</Box>
|
||||
// loop through controllers displaying each one
|
||||
|
||||
)
|
||||
}else{
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const desktopHUD = () => {
|
||||
return (
|
||||
<Box position={"absolute"} top={20} left={25} height={"75%"} width={"25%"}>
|
||||
<Stack direction={"column"} justifyContent={"space-between"} sx={{height: "100%", width: "100%"}}>
|
||||
{binModeControl()}
|
||||
{heatmapDisplay()}
|
||||
{controllerDisplay()}
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const mobileHUD = () => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{/* Top-left: Bin Name / Mode */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={12}
|
||||
left={12}
|
||||
width="40%"
|
||||
sx={{ pointerEvents: 'none' }}
|
||||
>
|
||||
<Box sx={{ pointerEvents: 'auto' }}>
|
||||
{binModeControl()}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Top-right: Heatmap Display */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={12}
|
||||
right={12}
|
||||
width="40%"
|
||||
sx={{ pointerEvents: 'none' }}
|
||||
>
|
||||
<Box sx={{ pointerEvents: 'auto' }}>
|
||||
{heatmapDisplay()}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Bottom-left: Controller Status */}
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={12}
|
||||
left={12}
|
||||
width="45%"
|
||||
sx={{ pointerEvents: 'none' }}
|
||||
>
|
||||
<Box sx={{ pointerEvents: 'auto' }}>
|
||||
{controllerDisplay()}
|
||||
</Box>
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{(selectedCable && selectedNode) &&
|
||||
<NodeControls
|
||||
bin={bin}
|
||||
cable={selectedCable}
|
||||
node={selectedNode}
|
||||
open={openNodeControls}
|
||||
permissions={permissions}
|
||||
device={selectedCableDevice}
|
||||
filteredComponents={filteredComponents}
|
||||
componentMap={componentMap}
|
||||
onClose={() => {
|
||||
setOpenNodeControls(false)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
<ModeChangeDialog
|
||||
binKey={bin.key()}
|
||||
binMode={binMode}
|
||||
grain={bin.settings.inventory?.grainType}
|
||||
devices={devices}
|
||||
open={openModeChange}
|
||||
compDevMap={componentDevices}
|
||||
preferences={binPrefs}
|
||||
onClose={(refresh) => {
|
||||
setOpenModeChange(false)
|
||||
if(refresh) {
|
||||
updateBin()
|
||||
}else{
|
||||
setBinMode(bin.settings.mode)
|
||||
};
|
||||
}}
|
||||
defaultTargetMoisture={bin.settings.inventory?.initialMoisture}
|
||||
// defaultOutdoorTemp={outdoorTemp}
|
||||
// defaultOutdoorHumidity={outdoorHumidity}
|
||||
// changeTargetMoisture={newMoisture => {
|
||||
// setTargetMoisture(newMoisture)
|
||||
// }}
|
||||
// changeOutdoorTemp={newTemp => {
|
||||
// setOutdoorTemp(newTemp)
|
||||
// }}
|
||||
// changeOutdoorHumidity={newHumidity => {
|
||||
// setOutdoorHumidity(newHumidity)
|
||||
// }}
|
||||
startChange={() => {
|
||||
setModeChangeInProgress(true)
|
||||
}}
|
||||
changeComplete={() => {
|
||||
setModeChangeInProgress(false)
|
||||
}}
|
||||
|
||||
/>
|
||||
<Box position={"relative"} height={600}>
|
||||
<Bin3dView
|
||||
height={isMobile ? 600 : undefined}
|
||||
width={isMobile ? window.innerWidth : undefined}
|
||||
bin={bin}
|
||||
fillPercent={bin.fillPercent()/100}//expects a decimal between 0 and 1
|
||||
nodeClick={(node, cable) => {
|
||||
//this will be how to control the dialog to update the top nodes and node exclusion etc (make new component called NodeControls for this)
|
||||
//use the cable data to get the device it belongs to
|
||||
let d = devMap.get(cable.grainCable.device)
|
||||
if(d !== undefined){
|
||||
setSelectedNode(node)
|
||||
setSelectedCable(cable)
|
||||
setSelectedCableDevice(d)
|
||||
//filter the controls so that only components on THIS device are options for new interactions, a side note of this using the components that are in the bins status
|
||||
//is that it will indirectly also filter by bin as well so only components that are both on the bin and the same device as the cable will appear
|
||||
let filtered: Component[] = []
|
||||
if(bin.status.fans){
|
||||
bin.status.fans.forEach((f) => {
|
||||
if(componentDevices.get(f.key) === d.id()){
|
||||
let comp = componentMap.get(f.key)
|
||||
if(comp){
|
||||
filtered.push(comp)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if(bin.status.heaters){
|
||||
bin.status.heaters.forEach((h) => {
|
||||
if(componentDevices.get(h.key) === d.id()){
|
||||
let comp = componentMap.get(h.key)
|
||||
if(comp){
|
||||
filtered.push(comp)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
//also make sure to push the cable into the list of filtered components for the source
|
||||
let c = componentMap.get(cable.grainCable.key)
|
||||
if(c !== undefined){
|
||||
filtered.push(c)
|
||||
}
|
||||
setFilteredComponents(filtered)
|
||||
setOpenNodeControls(true)
|
||||
}
|
||||
}}
|
||||
scale={100}
|
||||
showTempHeatmap={showHeatmap}
|
||||
showMoistureHeatmap={showMoistureHeatmap}
|
||||
showTemp={showTemp && showLabels}
|
||||
showMoisture={showMoisture && showLabels}
|
||||
xOffset={isMobile ? undefined : -120}
|
||||
/>
|
||||
{isMobile ? mobileHUD() : desktopHUD()}
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
1032
src/bin/binSensorsDisplay.tsx
Normal file
1032
src/bin/binSensorsDisplay.tsx
Normal file
File diff suppressed because it is too large
Load diff
415
src/bin/binSummary/BinSummary.tsx
Normal file
415
src/bin/binSummary/BinSummary.tsx
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
import { Box, Card, DialogContent, DialogTitle, Grid2, IconButton, LinearProgress, Skeleton, Slider, Switch, Tooltip, Typography } from "@mui/material";
|
||||
import { useMobile } from "hooks";
|
||||
import { Bin, Component, Device } from "models";
|
||||
import Bin3dVisualizer from "../bin3dVisualizer";
|
||||
// import GrassIcon from "@mui/icons-material/Grass"
|
||||
import { grey, orange, red } from "@mui/material/colors";
|
||||
import { AccessTime, Spa } from "@mui/icons-material";
|
||||
import moment from "moment";
|
||||
import { Controller } from "models/Controller";
|
||||
import { GrainCable } from "models/GrainCable";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import BinDetails from "./components/binDetails";
|
||||
import { Plenum } from "models/Plenum";
|
||||
import BinSensorsDisplay from "../binSensorsDisplay";
|
||||
import ResponsiveDialog from "common/ResponsiveDialog";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import SearchSelect from "common/SearchSelect";
|
||||
import ChangeGrainDialog from "grain/ChangeGrainDialog";
|
||||
import { useGlobalState } from "providers";
|
||||
import GrainTransaction from "grain/GrainTransaction";
|
||||
import { cloneDeep } from "lodash";
|
||||
import BinPlayer from "bin/BinPlayer";
|
||||
|
||||
interface Props {
|
||||
bin: Bin
|
||||
loading?: boolean,
|
||||
devices: Device[]
|
||||
cables?: GrainCable[]
|
||||
plenums?: Plenum[]
|
||||
fans?: Controller[]
|
||||
heaters?: Controller[]
|
||||
permissions: pond.Permission[]
|
||||
componentDevices: Map<string, number>
|
||||
componentMap: Map<string, Component>
|
||||
binPrefs?: Map<string, pond.BinComponentPreferences>
|
||||
setPreferences: React.Dispatch<
|
||||
React.SetStateAction<Map<string, pond.BinComponentPreferences> | undefined>
|
||||
>;
|
||||
updateBinCallback?: (bin: Bin) => void
|
||||
setBin?: React.Dispatch<React.SetStateAction<Bin>>;
|
||||
|
||||
}
|
||||
export default function BinSummary(props: Props){
|
||||
const {bin, loading, devices, fans, heaters, permissions, componentDevices, componentMap, binPrefs, updateBinCallback, cables = [], plenums = [], setPreferences, setBin} = props
|
||||
//const [currentDevice, setCurrentDevice] = useState<Device | undefined>(undefined)
|
||||
const isMobile = useMobile()
|
||||
const [openGrainDialog, setOpenGrainDialog] = useState(false)
|
||||
const [manualFillPercent, setManualFillPercent] = useState(0)
|
||||
const [manualBushels, setManualBushels] = useState(0)
|
||||
const [{user}] = useGlobalState()
|
||||
const fillCapRef = useRef<HTMLSpanElement>(null);
|
||||
const [fillCapWidth, setFillCapWidth] = useState<number | undefined>(undefined);
|
||||
const [openNewTransaction, setOpenNewTransaction] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (fillCapRef.current) {
|
||||
// measure the max possible string width once
|
||||
fillCapRef.current.textContent = `${bin.grainCapacity(user)}/${bin.grainCapacity(user)}`;
|
||||
setFillCapWidth(fillCapRef.current.offsetWidth);
|
||||
}
|
||||
}, [bin, user]);
|
||||
|
||||
useEffect(()=>{
|
||||
setManualFillPercent(bin.fillPercent())
|
||||
setManualBushels(bin.bushels())
|
||||
},[bin])
|
||||
|
||||
// useEffect(() => {
|
||||
// if(devices.length > 0){
|
||||
// setCurrentDevice(devices[0])
|
||||
// }
|
||||
// },[devices])
|
||||
|
||||
const inventoryControl = () => {
|
||||
switch (bin.inventoryControl()) {
|
||||
case pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL:
|
||||
return "Manual"
|
||||
case pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC:
|
||||
return "Auto Cable"
|
||||
case pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC_LIDAR:
|
||||
return "Auto Lidar"
|
||||
case pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_CABLE:
|
||||
return "Hybrid Cable"
|
||||
case pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_LIDAR:
|
||||
return "Hybrid Lidar"
|
||||
case pond.BinInventoryControl.BIN_INVENTORY_CONTROL_LIBRACART:
|
||||
return "Libra Cart"
|
||||
default:
|
||||
return "Not Set"
|
||||
}
|
||||
}
|
||||
|
||||
const activity = (reading: string) => {
|
||||
//if the device hasnt checked in in 6 hours = missing 12 hours = inactive within 6 hours = live
|
||||
let status = "Live"
|
||||
let colour = "#4caf50"
|
||||
let diff = moment().diff(moment(reading), "hour")
|
||||
if(diff > 12){
|
||||
status = "Inactive"
|
||||
colour = red[700]
|
||||
}else if (diff > 6){
|
||||
status = "Missing"
|
||||
colour = orange[300]
|
||||
}
|
||||
|
||||
return (
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: colour,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ color: colour }}>
|
||||
{status}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const currentFill = () => {
|
||||
if(bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL){
|
||||
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && bin.bushelsPerTonne() > 1){
|
||||
return Math.round((manualBushels / bin.bushelsPerTonne())*10)/10
|
||||
}else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && bin.bushelsPerTonne() > 1){
|
||||
return Math.round((manualBushels / (bin.bushelsPerTonne() * 0.907))*10)/10
|
||||
}
|
||||
return Math.round(manualBushels)
|
||||
}
|
||||
return bin.grainInventory(user)
|
||||
}
|
||||
|
||||
const inventorySummaryMobile = () => (
|
||||
<Card raised sx={{ mb: 2, overflow: "hidden", borderRadius: 4 }}>
|
||||
{/* Header row */}
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" sx={{ px: 2, pt: 1.5, pb: 1.25 }}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Box sx={{ width: 30, height: 30, borderRadius: 2, backgroundColor: "rgba(76,175,80,0.15)", display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
onClick={() => setOpenGrainDialog(true)}>
|
||||
<Spa sx={{ color: "#4caf50", fontSize: 18 }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: grey[500], display: "block" }}>Grain type</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, fontSize: 14 }}>{bin.grainName()}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={0.75} sx={{ textAlign: "right" }}>
|
||||
<Box sx={{ width: 7, height: 7, borderRadius: "50%", backgroundColor: "#4caf50", flexShrink: 0 }} />
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: grey[500], display: "block" }}>Last updated</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: 13 }}>{moment(bin.status.timestamp).fromNow()}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Fill row */}
|
||||
<Box sx={{ borderTop: `1px solid ${grey[800]}`, px: 2, pt: 1.5, pb: 1.75 }}>
|
||||
<Box display="flex" alignItems="baseline" justifyContent="space-between" sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: grey[500] }}>Bin fill ({inventoryControl()})</Typography>
|
||||
<Box display="flex" alignItems="baseline" gap={0.75}>
|
||||
<Typography sx={{ fontSize: 20, fontWeight: 500 }}>
|
||||
{bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL
|
||||
? manualFillPercent : bin.fillPercent()}%
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: grey[500] }}>
|
||||
{currentFill().toLocaleString()} / {bin.grainCapacity(user).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL ? (
|
||||
<Box sx={{ display: "flex", alignItems: "center", marginY: -2.5 }}>
|
||||
<Slider value={manualFillPercent} onChangeCommitted={() => setOpenNewTransaction(true)}
|
||||
onChange={(_, val) => { setManualFillPercent(val as number); setManualBushels((val as number / 100) * bin.bushelCapacity()); }}
|
||||
min={0} max={100} sx={{
|
||||
color: "#4caf50",
|
||||
py: 0,
|
||||
mt: 0,
|
||||
mb: 0,
|
||||
height: 4,
|
||||
"& .MuiSlider-root": { py: 0 },
|
||||
"& .MuiSlider-thumb": { width: 16, height: 16 },
|
||||
"& .MuiSlider-rail": { backgroundColor: "rgba(255,255,255,0.1)" },
|
||||
}} />
|
||||
</Box>
|
||||
) : (
|
||||
<LinearProgress variant="determinate" value={bin.fillPercent()}
|
||||
sx={{ height: 8, borderRadius: 4, backgroundColor: "rgba(255,255,255,0.1)", "& .MuiLinearProgress-bar": { backgroundColor: "#4caf50", borderRadius: 4 } }} />
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const inventorySummaryDesktop = () => {
|
||||
return (
|
||||
<Card raised sx={{ marginBottom: 2, borderRadius: 4 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-start" sx={{ padding: 2 }}>
|
||||
|
||||
{/* Grain Type */}
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: grey[500] }}>
|
||||
Grain Type
|
||||
</Typography>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={0.5}
|
||||
onClick={() => setOpenGrainDialog(true)}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
mt: 0.5,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderRadius: 1,
|
||||
border: "1px solid",
|
||||
borderColor: grey[700],
|
||||
"&:hover": {
|
||||
borderColor: grey[500],
|
||||
backgroundColor: "rgba(255,255,255,0.05)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Spa sx={{ color: "#4caf50", fontSize: 18 }} />
|
||||
<Typography variant="body1">{bin.grainName()}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Bin Fill */}
|
||||
<Box sx={{ flexGrow: 1, maxWidth: 450, mx: 4 }}>
|
||||
<Typography variant="caption" sx={{ color: grey[500] }}>
|
||||
Bin Fill ({inventoryControl()})
|
||||
</Typography>
|
||||
<Box display="flex" alignItems="center" gap={1} sx={{ mt: 0.5 }}>
|
||||
{/* hidden measuring span, renders off-screen */}
|
||||
<span
|
||||
ref={fillCapRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
visibility: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: "0.875rem", // match body2
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
noWrap
|
||||
sx={{
|
||||
width: (bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL || bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_UNKNOWN) ? fillCapWidth : undefined, flexShrink: 0
|
||||
}}>
|
||||
{currentFill()}/{bin.grainCapacity(user)}
|
||||
</Typography>
|
||||
{bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL ?
|
||||
<Slider
|
||||
value={manualFillPercent}
|
||||
onChangeCommitted={() => {
|
||||
setOpenNewTransaction(true)
|
||||
}}
|
||||
onChange={(_, val) => {
|
||||
let fillPercent = val as number
|
||||
setManualFillPercent(fillPercent)
|
||||
let newBushels = (fillPercent/100) * bin.bushelCapacity()
|
||||
setManualBushels(newBushels)
|
||||
//this does casue the 3d bin to fill as you drag it but it throws off the transaction and the reset if you cancel
|
||||
// let clone = cloneDeep(bin)
|
||||
// let inventory = clone.settings.inventory ?? pond.BinInventory.create()
|
||||
// inventory.grainBushels = newBushels
|
||||
// if(setBin){
|
||||
// setBin(clone)
|
||||
// }
|
||||
}}
|
||||
min={0}
|
||||
max={100}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
color: "#4caf50",
|
||||
"& .MuiSlider-thumb": {
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
"& .MuiSlider-rail": {
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
:
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={bin.fillPercent()}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
"& .MuiLinearProgress-bar": {
|
||||
backgroundColor: "#4caf50",
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}
|
||||
<Typography variant="body2" noWrap sx={{ width: 100 }}>
|
||||
{bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_MANUAL ? manualFillPercent : bin.fillPercent()}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Last Updated */}
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: grey[500] }}>
|
||||
Last Updated
|
||||
</Typography>
|
||||
<Tooltip title="Bin Status will update whenever its settings change or a device with a connected sensor checks in">
|
||||
<Box display="flex" alignItems="center" gap={2} sx={{ mt: 0.5 }}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<AccessTime sx={{ fontSize: 16, color: grey[400] }} />
|
||||
<Typography variant="body2" sx={{ color: grey[300] }}>
|
||||
{moment(bin.status.timestamp).fromNow()}
|
||||
</Typography>
|
||||
</Box>
|
||||
{activity(bin.status.timestamp)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box padding={2}>
|
||||
<GrainTransaction
|
||||
open={openNewTransaction}
|
||||
mainObject={bin}
|
||||
grainAdjustment={manualBushels - bin.bushels()}
|
||||
close={(confirmed) => {
|
||||
//this resets all of the state variables used
|
||||
setOpenNewTransaction(false);
|
||||
if(!confirmed){
|
||||
setManualBushels(bin.bushels())
|
||||
setManualFillPercent(bin.fillPercent())
|
||||
}else{
|
||||
let clone = cloneDeep(bin)
|
||||
let inventory = clone.settings.inventory ?? pond.BinInventory.create()
|
||||
inventory.grainBushels = manualBushels
|
||||
if(setBin){
|
||||
setBin(clone)
|
||||
}
|
||||
}
|
||||
|
||||
}}
|
||||
/>
|
||||
<ChangeGrainDialog bin={bin} open={openGrainDialog} closeDialog={()=>setOpenGrainDialog(false)} permissions={permissions} updateBin={updateBinCallback}/>
|
||||
{loading ? <Skeleton variant="rectangular" height={100} sx={{marginBottom: 2}} /> : isMobile ? inventorySummaryMobile() : inventorySummaryDesktop()}
|
||||
<Grid2 container spacing={2}>
|
||||
{!isMobile &&
|
||||
<Grid2 size={isMobile ? 12 : 7}>
|
||||
{loading ?
|
||||
<Skeleton variant="rectangular" height={600}/>
|
||||
:
|
||||
<Card raised sx={{height: 600, position: "relative", borderRadius: 4}}>
|
||||
<Bin3dVisualizer
|
||||
bin={bin}
|
||||
permissions={permissions}
|
||||
devices={devices}
|
||||
componentDevices={componentDevices}
|
||||
componentMap={componentMap}
|
||||
binPrefs={binPrefs}
|
||||
updateBinCallback={updateBinCallback}
|
||||
/>
|
||||
{setBin &&
|
||||
<Box padding={3} position="absolute" bottom={3} width="100%">
|
||||
<BinPlayer bin={bin} componentDevices={componentDevices} setBin={setBin}/>
|
||||
</Box>
|
||||
}
|
||||
</Card>
|
||||
}
|
||||
</Grid2>
|
||||
}
|
||||
<Grid2 size={isMobile ? 12 : 5}>
|
||||
{loading ?
|
||||
<Skeleton variant="rectangular" height={600}/>
|
||||
:
|
||||
<Card raised sx={{ height: 600, display: "flex", flexDirection: "column", overflow: "hidden", borderRadius: 4 }}>
|
||||
<BinDetails
|
||||
bin={bin}
|
||||
updateBinCallback={updateBinCallback}
|
||||
componentDevices={componentDevices}
|
||||
devices={devices}
|
||||
linkedComponents={componentMap}
|
||||
permissions={permissions}
|
||||
cables={cables}
|
||||
plenums={plenums}
|
||||
fans={fans}
|
||||
heaters={heaters}
|
||||
binPrefs={binPrefs}
|
||||
componentMap={componentMap}
|
||||
setBin={setBin}/>
|
||||
</Card>
|
||||
}
|
||||
</Grid2>
|
||||
<Grid2 size={12}>
|
||||
{loading ?
|
||||
<Skeleton variant="rectangular" height={250}/>
|
||||
:
|
||||
<Card sx={{borderRadius: 4}}>
|
||||
<BinSensorsDisplay components={componentMap} bin={bin} preferences={binPrefs} setPreferences={setPreferences} componentDevices={componentDevices}/>
|
||||
</Card>
|
||||
}
|
||||
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
137
src/bin/binSummary/components/binAlerts.tsx
Normal file
137
src/bin/binSummary/components/binAlerts.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { LinearProgress } from "@mui/material";
|
||||
import { Bin, Component, Device, Interaction } from "models";
|
||||
import Alerts, { Alert } from "objects/objectInteractions/Alerts";
|
||||
import NewObjectInteraction from "objects/objectInteractions/NewObjectInteraction";
|
||||
import { extension } from "pbHelpers/ComponentType";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { useGlobalState, useInteractionsAPI } from "providers";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
linkedComponents: Map<string, Component>; //the component key to the component object
|
||||
componentDevices: Map<string, number>;
|
||||
permissions: pond.Permission[]
|
||||
}
|
||||
|
||||
export default function BinAlerts(props: Props){
|
||||
const {linkedComponents, componentDevices, permissions} = props
|
||||
const [{as}] = useGlobalState();
|
||||
const interactionsAPI = useInteractionsAPI();
|
||||
|
||||
//list of alerts, each alert contains a list of interactions with matching conditions
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [newAlert, setNewAlert] = useState(false)
|
||||
|
||||
const matchConditions = (interaction: Interaction, set: Alert) => {
|
||||
//if the number of conditions are not the same they are different
|
||||
if (interaction.settings.conditions.length !== set.conditions.length) {
|
||||
return false;
|
||||
}
|
||||
//continue with the comparison
|
||||
let matching = true;
|
||||
interaction.settings.conditions.forEach((condition, i) => {
|
||||
if (
|
||||
condition.comparison !== set.conditions[i].comparison ||
|
||||
condition.measurementType !== set.conditions[i].measurementType ||
|
||||
condition.value !== set.conditions[i].value
|
||||
) {
|
||||
matching = false;
|
||||
}
|
||||
});
|
||||
return matching;
|
||||
};
|
||||
|
||||
const load = useCallback(()=>{
|
||||
if(!loading){
|
||||
//load the interactions for the linked components
|
||||
setLoading(true)
|
||||
const newInteractions: Interaction[] = [];
|
||||
const interactionSourceMap = new Map<string, string>();
|
||||
const sinkOptions: Component[] = [];
|
||||
const run = async () => {
|
||||
// Run all component API fetches in parallel
|
||||
await Promise.all(
|
||||
[...linkedComponents].map(async ([key, comp]) => {
|
||||
const device = componentDevices.get(key);
|
||||
|
||||
if (device) {
|
||||
const resp = await interactionsAPI.listInteractionsByComponent(
|
||||
device,
|
||||
comp.location(),
|
||||
undefined,
|
||||
as
|
||||
);
|
||||
|
||||
resp.forEach(interaction => interactionSourceMap.set(interaction.key(), key));
|
||||
newInteractions.push(...resp);
|
||||
}
|
||||
|
||||
// Collect controller components
|
||||
if (extension(comp.type()).isController) {
|
||||
sinkOptions.push(comp);
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
run().then(() => {
|
||||
newInteractions.forEach(interaction => {
|
||||
//alert and control are not mutally exclusive, it is possible to be both if the control is sending notifications
|
||||
if (interaction.isAlert()) {
|
||||
// Find matching alert
|
||||
let similarAlertIndex = alerts.findIndex(alert =>
|
||||
matchConditions(interaction, alert)
|
||||
);
|
||||
|
||||
const compKey = interactionSourceMap.get(interaction.key());
|
||||
const component = compKey ? linkedComponents.get(compKey) : undefined;
|
||||
|
||||
if (component) {
|
||||
if (similarAlertIndex === -1) {
|
||||
alerts.push({
|
||||
conditions: interaction.settings.conditions,
|
||||
sourceComponents: [component],
|
||||
});
|
||||
} else {
|
||||
alerts[similarAlertIndex].sourceComponents.push(component);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
setAlerts(alerts);
|
||||
}).catch(err => {
|
||||
console.error("Interaction fetch error:", err)
|
||||
}).finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
},[linkedComponents, interactionsAPI, componentDevices, as])
|
||||
|
||||
useEffect(()=>{
|
||||
load()
|
||||
},[load])
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<NewObjectInteraction
|
||||
open={newAlert}
|
||||
onClose={(refresh) => {
|
||||
setNewAlert(false)
|
||||
if(refresh){
|
||||
load()
|
||||
}
|
||||
}}
|
||||
linkedComponents={linkedComponents}
|
||||
componentDevices={componentDevices}/>
|
||||
{loading ?
|
||||
<LinearProgress />
|
||||
:
|
||||
<Alerts alerts={alerts} permissions={permissions} addNew={() => {setNewAlert(true)}}/>
|
||||
}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
410
src/bin/binSummary/components/binAnalysisGraphs.tsx
Normal file
410
src/bin/binSummary/components/binAnalysisGraphs.tsx
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
import { Avatar, Box, Button, CardHeader, Checkbox, FormControlLabel, Grid2, Theme, Tooltip, Typography } from "@mui/material";
|
||||
import { useMobile, useThemeType } from "hooks";
|
||||
import VPDDark from "assets/products/Ag/dryingDark.png";
|
||||
import VPDLight from "assets/products/Ag/dryingLight.png";
|
||||
import TrendLight from "assets/products/Ag/trendingLight.png";
|
||||
import TrendDark from "assets/products/Ag/trendingDark.png";
|
||||
import { useEffect, useState } from "react";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import moment, { Moment } from "moment";
|
||||
import { blue, orange, teal } from "@mui/material/colors";
|
||||
import { GrainDryingPoint } from "charts/GrainDryingChart";
|
||||
import { GrainCable } from "models/GrainCable";
|
||||
import { Plenum } from "models/Plenum";
|
||||
import BinGraphsVPD from "bin/graphs/BinGraphsVPD";
|
||||
import { UnitMeasurement } from "models/UnitMeasurement";
|
||||
import { Bin } from "models";
|
||||
import { useBinAPI, useGlobalState } from "providers";
|
||||
import TimeBar from "common/time/TimeBar";
|
||||
import { GetDefaultDateRange } from "common/time/DateRange";
|
||||
import { ZoomOut } from "@mui/icons-material";
|
||||
import BinGraphsTrending from "bin/graphs/BinGraphsTrending";
|
||||
import { DataPoint, TrendPoint } from "charts/TrendingChart";
|
||||
import WaterLight from "assets/products/Ag/waterContentLight.png";
|
||||
import WaterDark from "assets/products/Ag/waterContentDark.png";
|
||||
import { Pressure } from "models/Pressure";
|
||||
import { DataPoint as WaterPoint } from "charts/WaterLevelChart";
|
||||
import BinWaterLevel from "bin/graphs/BinWaterLevel";
|
||||
import { Controller } from "models/Controller";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
|
||||
interface Props {
|
||||
cables: GrainCable[]
|
||||
plenums: Plenum[]
|
||||
componentDevices: Map<string, number>
|
||||
bin: Bin
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>{
|
||||
return ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
},
|
||||
toolbar: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
scrollContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
},
|
||||
card: {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
overflow: "visible"
|
||||
},
|
||||
cardHeader: {
|
||||
padding: theme.spacing(1),
|
||||
paddingLeft: theme.spacing(2),
|
||||
marginRight: 10
|
||||
},
|
||||
avatarIcon: {
|
||||
width: 33,
|
||||
height: 33
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export default function BinAnalysisGraphs(props: Props){
|
||||
const {cables, plenums, componentDevices, bin} = props
|
||||
const themeType = useThemeType()
|
||||
const classes = useStyles()
|
||||
const [{user, as, showErrors}, dispatch] = useGlobalState()
|
||||
const isMobile = useMobile()
|
||||
const [xDomain, setXDomain] = useState<string[] | number[]>(["dataMin", "dataMax"]);
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
const [recentVPD, setRecentVPD] = useState<GrainDryingPoint | undefined>();
|
||||
const [loading, setLoading] = useState(false)
|
||||
// map using the component key and the unitmeasurements for that component
|
||||
const [compMeasurements, setCompMeasurements] = useState<Map<string, UnitMeasurement[]>>(
|
||||
new Map<string, UnitMeasurement[]>()
|
||||
);
|
||||
const binAPI = useBinAPI()
|
||||
const defaultDateRange = GetDefaultDateRange()
|
||||
const [startDate, setStartDate] = useState<Moment>(defaultDateRange.start);
|
||||
const [endDate, setEndDate] = useState<Moment>(defaultDateRange.end);
|
||||
const [recentPlenumTrend, setRecentPlenumTrend] = useState<TrendPoint | undefined>();
|
||||
const [recentCableTrend, setRecentCableTrend] = useState<DataPoint | undefined>();
|
||||
const [recentWaterContent, setRecentWaterContent] = useState<WaterPoint | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
let measurementMap: Map<string, UnitMeasurement[]> = new Map<string, UnitMeasurement[]>();
|
||||
if (!loading) {
|
||||
setLoading(true);
|
||||
//check if the bin has a fan to use the cfm in the drying graph
|
||||
// if (bin.settings.fan?.type !== pond.FanType.FAN_TYPE_UNKNOWN) {
|
||||
// setIncludeCFM(true);
|
||||
// }
|
||||
binAPI
|
||||
.listBinComponentsMeasurements(
|
||||
bin.key(),
|
||||
startDate.toISOString(),
|
||||
endDate.toISOString(),
|
||||
showErrors,
|
||||
showErrors,
|
||||
as
|
||||
)
|
||||
.then(resp => {
|
||||
resp.data.measurements.forEach((um: any) => {
|
||||
let unitMeasurement = UnitMeasurement.any(um, user);
|
||||
let entry = measurementMap.get(unitMeasurement.componentId);
|
||||
if (entry) {
|
||||
entry.push(unitMeasurement);
|
||||
} else {
|
||||
measurementMap.set(unitMeasurement.componentId, [unitMeasurement]);
|
||||
}
|
||||
});
|
||||
setCompMeasurements(measurementMap);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [bin, binAPI, startDate, endDate, user, as]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const zoomOut = () => {
|
||||
setXDomain(["dataMin", "dataMax"]);
|
||||
setZoomed(false);
|
||||
};
|
||||
|
||||
const zoomIn = (domain: string[] | number[]) => {
|
||||
if (!isMobile) {
|
||||
setXDomain(domain);
|
||||
setZoomed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDateRange = (newStartDate: any, newEndDate: any) => {
|
||||
let range = GetDefaultDateRange();
|
||||
range.start = newStartDate;
|
||||
range.end = newEndDate;
|
||||
setStartDate(newStartDate);
|
||||
setEndDate(newEndDate);
|
||||
};
|
||||
|
||||
//this uses the components found in the bins status
|
||||
const waterGraph = () => {
|
||||
let moistureCables: pond.GrainCable[] = [];
|
||||
bin.status.grainCables.forEach(cable => {
|
||||
if (cable.relativeHumidity.length > 0) {
|
||||
moistureCables.push(cable);
|
||||
}
|
||||
});
|
||||
if (bin.status.plenums[0] && moistureCables[0] && bin.status.pressures[0] && bin.supportedGrain()) {
|
||||
return (
|
||||
<Box>
|
||||
<BinWaterLevel
|
||||
bin={bin}
|
||||
// range={{ start: startDate, end: endDate }}
|
||||
start={startDate.toISOString()}
|
||||
end={endDate.toISOString()}
|
||||
plenumKey={componentDevices.get(bin.status.plenums[0].key) + ":" + bin.status.plenums[0].key}
|
||||
cableKey={componentDevices.get(moistureCables[0].key) + ":" + moistureCables[0].key}
|
||||
pressureKey={componentDevices.get(bin.status.pressures[0].key) + ":" + bin.status.pressures[0].key}
|
||||
fanKey={
|
||||
bin.status.fans.length > 0
|
||||
? componentDevices.get(bin.status.fans[0].key) + ":" + bin.status.fans[0].key
|
||||
: undefined
|
||||
}
|
||||
newXDomain={xDomain}
|
||||
multiGraphZoom={zoomIn}
|
||||
multiGraphZoomOut
|
||||
returnLast={lastWater => {
|
||||
setRecentWaterContent(lastWater);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Box minHeight={100} display="flex" justifyContent="center" alignItems="center">
|
||||
<Typography style={{ fontWeight: 650 }}>
|
||||
Plenum with pressure and moisture cable as well as an initial moisture and supported
|
||||
grain type set in the bin settings required to calculate Water Content
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={classes.root}>
|
||||
<Box className={classes.toolbar} display="flex" justifyContent="space-between">
|
||||
<Box>
|
||||
<TimeBar startDate={startDate} endDate={endDate} updateDateRange={updateDateRange}/>
|
||||
</Box>
|
||||
<Box>
|
||||
{zoomed && (
|
||||
<Tooltip title="Zoom Out Graphs">
|
||||
<Button variant="outlined" onClick={zoomOut}>
|
||||
<ZoomOut />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<FormControlLabel
|
||||
label="Show Errors"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={showErrors}
|
||||
onChange={() => {
|
||||
//setShowErrors(!showErrors);
|
||||
dispatch({ key: "showErrors", value: !showErrors });
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className={classes.scrollContent}>
|
||||
<CardHeader
|
||||
avatar={
|
||||
<Avatar
|
||||
variant="square"
|
||||
src={themeType === "light" ? VPDDark : VPDLight}
|
||||
className={classes.avatarIcon}
|
||||
alt={"Drying Score"}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<Grid2 container direction="row" justifyContent="space-between">
|
||||
<Grid2 >
|
||||
<Typography style={{ fontSize: 25, fontWeight: 650 }}>
|
||||
Drying vs. Hydrating
|
||||
</Typography>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
}
|
||||
subheader={
|
||||
recentVPD ? (
|
||||
<Grid2 container>
|
||||
<Grid2 size={{ xs: 12 }}>
|
||||
<span>
|
||||
<span style={{ color: recentVPD.dryScore > 0 ? orange[500] : blue[500] }}>
|
||||
{recentVPD.dryScore > 0 ? "drying" : "hydrating"}
|
||||
</span>
|
||||
<span>{" : "}</span>
|
||||
<span
|
||||
style={{
|
||||
color: recentVPD.dryScore > 0 ? orange[500] : blue[500],
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{recentVPD.dryScore.toFixed(2)}
|
||||
</span>
|
||||
<br />
|
||||
</span>
|
||||
</Grid2>
|
||||
<Grid2 size={{ xs: 12 }}>
|
||||
<Typography color="textSecondary" variant={"caption"}>
|
||||
{moment(recentVPD.timestamp).fromNow()}
|
||||
</Typography>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
) : (
|
||||
<Typography variant="body1" color="textPrimary">
|
||||
No Data
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Box padding={1}>
|
||||
{cables.length > 0 && plenums.length > 0 ? (
|
||||
<BinGraphsVPD
|
||||
cables={cables}
|
||||
plenums={plenums}
|
||||
measurementMap={compMeasurements}
|
||||
newXDomain={xDomain}
|
||||
multiGraphZoom={zoomIn}
|
||||
multiGraphZoomOut
|
||||
//perBushelCFM={includeCFM ? bin.status.cfmPerBushel : undefined}
|
||||
returnLastVPD={lastVPD => {
|
||||
setRecentVPD(lastVPD);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box minHeight={100} display="flex" justifyContent="center" alignItems="center">
|
||||
<Typography style={{ fontWeight: 650 }}>
|
||||
Plenum and moisture cable required to calculate VPD
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<CardHeader
|
||||
avatar={
|
||||
<Avatar
|
||||
variant="square"
|
||||
src={themeType === "light" ? TrendDark : TrendLight}
|
||||
className={classes.avatarIcon}
|
||||
alt={"VPD"}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<Typography style={{ fontSize: 25, fontWeight: 650 }}>Moisture Trending</Typography>
|
||||
}
|
||||
subheader={
|
||||
recentPlenumTrend && recentCableTrend ? (
|
||||
<Grid2 container>
|
||||
<Grid2 size={{ xs: 12 }}>
|
||||
<span>
|
||||
{"Plenum Moisture: "}
|
||||
<span style={{ color: orange[500], fontWeight: 500 }}>
|
||||
{recentPlenumTrend.trend.toFixed(2)}
|
||||
</span>
|
||||
<br />
|
||||
</span>
|
||||
<span>
|
||||
{"Grain Moisture: "}
|
||||
<span style={{ color: teal[500], fontWeight: 500 }}>
|
||||
{recentCableTrend.moisture.toString()}
|
||||
</span>
|
||||
<br />
|
||||
</span>
|
||||
</Grid2>
|
||||
<Grid2 size={{ xs: 12 }}>
|
||||
<Typography color="textSecondary" variant={"caption"}>
|
||||
{recentPlenumTrend.timestamp > recentCableTrend.timestamp
|
||||
? moment(recentPlenumTrend.timestamp).fromNow()
|
||||
: moment(recentCableTrend.timestamp).fromNow()}
|
||||
</Typography>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
) : (
|
||||
<Typography variant="body1" color="textPrimary">
|
||||
No Data
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Box padding={1}>
|
||||
{cables.length > 0 && plenums.length > 0 ? (
|
||||
<BinGraphsTrending
|
||||
cables={cables}
|
||||
plenums={plenums}
|
||||
measurementMap={compMeasurements}
|
||||
bin={bin}
|
||||
newXDomain={xDomain}
|
||||
multiGraphZoom={zoomIn}
|
||||
multiGraphZoomOut
|
||||
returnLastMeasurements={(lastData, lastTrend) => {
|
||||
setRecentCableTrend(lastData);
|
||||
setRecentPlenumTrend(lastTrend);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box minHeight={100} display="flex" justifyContent="center" alignItems="center">
|
||||
<Typography style={{ fontWeight: 650 }}>
|
||||
Plenum and moisture cable required to calculate trending data
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<CardHeader
|
||||
avatar={
|
||||
<Avatar
|
||||
variant="square"
|
||||
src={themeType === "light" ? WaterDark : WaterLight}
|
||||
className={classes.avatarIcon}
|
||||
alt={"VPD"}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<Typography style={{ fontSize: 25, fontWeight: 650 }}>Grain Water Content</Typography>
|
||||
}
|
||||
subheader={
|
||||
recentWaterContent &&
|
||||
bin.settings.inventory &&
|
||||
bin.settings.inventory.initialMoisture > 0 ? (
|
||||
<Grid2 container>
|
||||
<Grid2 size={{ xs: 12 }}>
|
||||
<span>
|
||||
{"Water Content: "}
|
||||
<span style={{ color: blue[500], fontWeight: 500 }}>
|
||||
{recentWaterContent.liters && !isNaN(recentWaterContent.liters)
|
||||
? recentWaterContent.liters.toFixed(2)
|
||||
: ""}
|
||||
</span>
|
||||
<br />
|
||||
</span>
|
||||
</Grid2>
|
||||
<Grid2 size={{ xs: 12 }}>
|
||||
<Typography color="textSecondary" variant={"caption"}>
|
||||
{moment(recentWaterContent.timestamp).fromNow()}
|
||||
</Typography>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
) : (
|
||||
<Typography variant="body1" color="textPrimary">
|
||||
No Data
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Box padding={1}>
|
||||
{waterGraph()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
188
src/bin/binSummary/components/binControls.tsx
Normal file
188
src/bin/binSummary/components/binControls.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { LinearProgress } from "@mui/material";
|
||||
import { Bin, Component, Device, Interaction } from "models";
|
||||
import Controls, { Control } from "objects/objectInteractions/Controls";
|
||||
import NewObjectInteraction from "objects/objectInteractions/NewObjectInteraction";
|
||||
import { sameComponentID } from "pbHelpers/Component";
|
||||
import { extension } from "pbHelpers/ComponentType";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { quack } from "protobuf-ts/quack";
|
||||
import { useGlobalState } from "providers";
|
||||
import interactionsAPI, { useInteractionsAPI } from "providers/pond/interactionsAPI";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
linkedComponents: Map<string, Component>; //the component key to the component object
|
||||
componentDevices: Map<string, number>;
|
||||
devices: Device[];
|
||||
permissions: pond.Permission[]
|
||||
}
|
||||
|
||||
export default function BinControls(props: Props) {
|
||||
const {linkedComponents, componentDevices, devices, permissions} = props
|
||||
const [{as}] = useGlobalState()
|
||||
const interactionsAPI = useInteractionsAPI()
|
||||
const [controls, setControls] = useState<Control[]>([]);
|
||||
const [selectedDevice, setSelectedDevice] = useState<Device | undefined>(undefined)
|
||||
const [openNewInteraction, setOpenNewInteraction] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
|
||||
const findDevice = (deviceID: number) => {
|
||||
for (let device of devices) {
|
||||
if (device.id() === deviceID) return device
|
||||
}
|
||||
}
|
||||
const findSinkComponent = (sinks: Component[], interactionSink: quack.ComponentID, sourceDevice: number) => {
|
||||
for (let component of sinks) {
|
||||
if (sameComponentID(component.location(), interactionSink) && sourceDevice === componentDevices.get(component.key())){
|
||||
return component
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const matchConditions = (interaction: Interaction, set: Control) => {
|
||||
//if the number of conditions are not the same they are different
|
||||
if (interaction.settings.conditions.length !== set.conditions.length) {
|
||||
return false;
|
||||
}
|
||||
//continue with the comparison
|
||||
let matching = true;
|
||||
interaction.settings.conditions.forEach((condition, i) => {
|
||||
if (
|
||||
condition.comparison !== set.conditions[i].comparison ||
|
||||
condition.measurementType !== set.conditions[i].measurementType ||
|
||||
condition.value !== set.conditions[i].value
|
||||
) {
|
||||
matching = false;
|
||||
}
|
||||
});
|
||||
return matching;
|
||||
};
|
||||
|
||||
const matchResult = (interaction: Interaction, set: Control) => {
|
||||
let interactionResult = interaction.settings.result
|
||||
let setResult = set.result
|
||||
let matching = true
|
||||
// if anything in the result for the interaction is different from the set make matching false
|
||||
if(interactionResult?.type !== setResult?.type ||
|
||||
interactionResult?.mode !== setResult?.mode ||
|
||||
interactionResult?.value !== setResult?.value ||
|
||||
interactionResult?.dutyCycle !== setResult?.dutyCycle
|
||||
){
|
||||
matching = false
|
||||
}
|
||||
return matching
|
||||
}
|
||||
const load = useCallback(()=>{
|
||||
if(!loading){
|
||||
setLoading(true)
|
||||
const newInteractions: Interaction[] = [];
|
||||
const interactionSourceMap = new Map<string, string>();
|
||||
const sinkOptions: Component[] = [];
|
||||
const controls: Control[] = [];
|
||||
|
||||
const run = async () => {
|
||||
// Run all component API fetches in parallel
|
||||
await Promise.all(
|
||||
[...linkedComponents].map(async ([key, comp]) => {
|
||||
const device = componentDevices.get(key);
|
||||
|
||||
if (device) {
|
||||
const resp = await interactionsAPI.listInteractionsByComponent(
|
||||
device,
|
||||
comp.location(),
|
||||
undefined,
|
||||
as
|
||||
);
|
||||
|
||||
resp.forEach(interaction => interactionSourceMap.set(interaction.key(), key));
|
||||
newInteractions.push(...resp);
|
||||
}
|
||||
|
||||
// Collect controller components
|
||||
if (extension(comp.type()).isController) {
|
||||
sinkOptions.push(comp);
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
run().then(() => {
|
||||
newInteractions.forEach(interaction => {
|
||||
if (interaction.isControl()) {
|
||||
if (!interaction.settings.sink) return //if there is no sink it cant be a control so move on to the next
|
||||
const compKey = interactionSourceMap.get(interaction.key());
|
||||
if (!compKey) return //we have to have a valid component key
|
||||
const component = linkedComponents.get(compKey);
|
||||
const deviceID = componentDevices.get(compKey);
|
||||
if (!deviceID) return //we have to know which device it is for
|
||||
const controlDevice = findDevice(deviceID)
|
||||
if (!controlDevice) return //failed to get the device from the array
|
||||
const sinkComponent = findSinkComponent(sinkOptions, interaction.settings.sink, deviceID)
|
||||
if (!sinkComponent) return //failed to get the correct sink component
|
||||
|
||||
let similarControlIndex = controls.findIndex(control =>
|
||||
matchConditions(interaction, control) &&
|
||||
matchResult(interaction, control) &&
|
||||
sameComponentID(interaction.settings.sink, control.sinkComponent.location()) && //if the sink for the interaction is the same address as the existing control
|
||||
deviceID === control.device.id() //if the device id for source component is the same as the device id in the control
|
||||
);
|
||||
|
||||
if (component && interaction.settings.result) {
|
||||
if (similarControlIndex === -1) {
|
||||
controls.push({
|
||||
conditions: interaction.settings.conditions,
|
||||
result: interaction.settings.result,
|
||||
sourceComponents: [component],
|
||||
sinkComponent: sinkComponent,
|
||||
device: controlDevice
|
||||
});
|
||||
} else {
|
||||
controls[similarControlIndex].sourceComponents.push(component);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
setControls(controls);
|
||||
}).catch(err => {
|
||||
console.error("Interaction fetch error:", err)
|
||||
}).finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
},[linkedComponents, interactionsAPI, componentDevices, as])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<NewObjectInteraction
|
||||
open={openNewInteraction}
|
||||
onClose={(refresh) => {
|
||||
setOpenNewInteraction(false)
|
||||
if(refresh){
|
||||
load()
|
||||
}
|
||||
}}
|
||||
linkedComponents={linkedComponents}
|
||||
componentDevices={componentDevices}
|
||||
device={selectedDevice}/>
|
||||
{loading ?
|
||||
<LinearProgress />
|
||||
:
|
||||
<Controls
|
||||
controls={controls}
|
||||
devices={devices}
|
||||
permissions={permissions}
|
||||
addNew={(device) => {
|
||||
console.log("new control")
|
||||
setSelectedDevice(device)
|
||||
setOpenNewInteraction(true)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
130
src/bin/binSummary/components/binDetails.tsx
Normal file
130
src/bin/binSummary/components/binDetails.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { Box, Tab, Tabs } from "@mui/material";
|
||||
import React from "react";
|
||||
import { useState } from "react";
|
||||
import BinTableView from "../../binTableView";
|
||||
import { Bin, Component, Device } from "models";
|
||||
import Alerts from "objects/objectInteractions/Alerts";
|
||||
import BinAlerts from "./binAlerts";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import BinControls from "./binControls";
|
||||
import BinAnalysisGraphs from "./binAnalysisGraphs";
|
||||
import { GrainCable } from "models/GrainCable";
|
||||
import { Plenum } from "models/Plenum";
|
||||
import { Controller } from "models/Controller";
|
||||
import { useMobile } from "hooks";
|
||||
import Bin3dVisualizer from "bin/bin3dVisualizer";
|
||||
import BinPlayer from "bin/BinPlayer";
|
||||
|
||||
interface Props {
|
||||
bin: Bin
|
||||
updateBinCallback?: (bin: Bin) => void
|
||||
linkedComponents: Map<string, Component>; //the component key to the component object
|
||||
componentDevices: Map<string, number>;
|
||||
devices: Device[];
|
||||
permissions: pond.Permission[]
|
||||
cables: GrainCable[]
|
||||
plenums: Plenum[]
|
||||
showBinTab?: boolean
|
||||
fans?: Controller[]
|
||||
heaters?: Controller[]
|
||||
componentMap: Map<string, Component>
|
||||
binPrefs?: Map<string, pond.BinComponentPreferences>
|
||||
setBin?: React.Dispatch<React.SetStateAction<Bin>>;
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: any;
|
||||
value: any;
|
||||
}
|
||||
|
||||
function TabPanelMine(props: TabPanelProps) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<Box
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
aria-labelledby={`simple-tab-${index}`}
|
||||
sx={{
|
||||
display: value === index ? "flex" : "none",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
{...other}>
|
||||
{value === index && children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BinDetails (props: Props) {
|
||||
const {bin, updateBinCallback, linkedComponents, componentDevices, devices, permissions, cables, plenums, showBinTab, fans, heaters, componentMap, binPrefs, setBin} = props
|
||||
const isMobile = useMobile()
|
||||
const [currentTab, setCurrentTab] = useState((isMobile || showBinTab) ? "bin" : "table")
|
||||
|
||||
//this is where we will have the tabs and tab panels for the table view, alerts, controls, and analysis
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(_, value) => setCurrentTab(value)}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
variant={isMobile ? "scrollable" : "fullWidth"}
|
||||
scrollButtons="auto"
|
||||
allowScrollButtonsMobile
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
'& .MuiTabs-scroller': {
|
||||
overflowX: 'auto !important',
|
||||
}
|
||||
}}>
|
||||
{isMobile && <Tab label={"Bin"} value={"bin"} />}
|
||||
<Tab label={"Table"} value={"table"} />
|
||||
<Tab label={"Alerts"} value={"alerts"} />
|
||||
<Tab label={"Controls"} value={"controls"} />
|
||||
<Tab label={"Analysis"} value={"analysis"} />
|
||||
</Tabs>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
{(isMobile || showBinTab) &&
|
||||
<TabPanelMine value={currentTab} index={"bin"}>
|
||||
<Bin3dVisualizer
|
||||
bin={bin}
|
||||
permissions={permissions}
|
||||
devices={devices}
|
||||
componentDevices={componentDevices}
|
||||
componentMap={componentMap}
|
||||
binPrefs={binPrefs}
|
||||
updateBinCallback={updateBinCallback}
|
||||
/>
|
||||
{setBin &&
|
||||
<Box padding={1}>
|
||||
<BinPlayer bin={bin} componentDevices={componentDevices} setBin={setBin}/>
|
||||
</Box>
|
||||
}
|
||||
</TabPanelMine>
|
||||
}
|
||||
<TabPanelMine value={currentTab} index={"table"}>
|
||||
<BinTableView bin={bin} updateBinCallback={updateBinCallback}/>
|
||||
{setBin && isMobile &&
|
||||
<Box padding={1}>
|
||||
<BinPlayer bin={bin} componentDevices={componentDevices} setBin={setBin}/>
|
||||
</Box>
|
||||
}
|
||||
</TabPanelMine>
|
||||
<TabPanelMine value={currentTab} index={"alerts"}>
|
||||
<BinAlerts componentDevices={componentDevices} linkedComponents={linkedComponents} permissions={permissions}/>
|
||||
</TabPanelMine>
|
||||
<TabPanelMine value={currentTab} index={"controls"}>
|
||||
<BinControls componentDevices={componentDevices} devices={devices} linkedComponents={linkedComponents} permissions={permissions}/>
|
||||
</TabPanelMine>
|
||||
<TabPanelMine value={currentTab} index={"analysis"}>
|
||||
<BinAnalysisGraphs bin={bin} cables={cables} plenums={plenums} componentDevices={componentDevices}/>
|
||||
</TabPanelMine>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
406
src/bin/binSummary/components/nodeControls.tsx
Normal file
406
src/bin/binSummary/components/nodeControls.tsx
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
import { AppBar, Box, Button, Card, CardActionArea, Checkbox, DialogActions, DialogContent, DialogTitle, FormControlLabel, Grid2, IconButton, Typography } from "@mui/material";
|
||||
import { green } from "@mui/material/colors";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { CableData } from "bin/3dView/Data/BuildCableData";
|
||||
import { NodeData } from "bin/3dView/Data/BuildNodeData";
|
||||
import ResponsiveDialog from "common/ResponsiveDialog";
|
||||
import HumidityIcon from "component/HumidityIcon";
|
||||
import TemperatureIcon from "component/TemperatureIcon";
|
||||
import { useComponentAPI, useMobile, useSnackbar } from "hooks";
|
||||
import InteractionsOverview from "interactions/InteractionsOverview";
|
||||
import { Bin, Component, Device, Interaction } from "models";
|
||||
import moment from "moment";
|
||||
import AddIcon from "@mui/icons-material/AddCircle";
|
||||
import GraphIcon from "products/CommonIcons/graphIcon";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { useBinAPI, useGlobalState, useInteractionsAPI } from "providers";
|
||||
import React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { canWrite } from "pbHelpers/Permission";
|
||||
import InteractionSettings from "interactions/InteractionSettings";
|
||||
|
||||
const useStyles = makeStyles(() => {
|
||||
return ({
|
||||
dialog: {
|
||||
maxWidth: 350
|
||||
},
|
||||
appBar: {
|
||||
position: "static",
|
||||
borderRadius: 30
|
||||
},
|
||||
readingCard: {
|
||||
padding: 10
|
||||
},
|
||||
greenButton: {
|
||||
color: green["600"],
|
||||
padding: 0
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
node: NodeData
|
||||
cable: CableData
|
||||
device: Device
|
||||
bin: Bin
|
||||
/**
|
||||
* the possible options for controllers when creating new interactions, these controllers must be on the same device as the cable for interactions to work
|
||||
*/
|
||||
filteredComponents?: Component[]
|
||||
componentMap: Map<string, Component>
|
||||
permissions: pond.Permission[]
|
||||
/**
|
||||
* callback function for updating the cable with a new top node or excluded node
|
||||
* @returns void
|
||||
*/
|
||||
updateComponentCallback?: () => void
|
||||
}
|
||||
|
||||
export default function NodeControls(props: Props){
|
||||
const {open, onClose, node, cable, device, bin, filteredComponents, permissions, updateComponentCallback, componentMap} = props
|
||||
const classes = useStyles()
|
||||
const [isTopNode, setIsTopNode] = useState(false)
|
||||
const [isExcluded, setIsExcluded] = useState(false)
|
||||
const [cableComponent, setCableComponent] = useState<Component>(Component.create())
|
||||
const [cableInteractions, setCableInteractions] = useState<Interaction[]>([])
|
||||
const isMobile = useMobile()
|
||||
const navigate = useNavigate()
|
||||
const [{as, user}] = useGlobalState()
|
||||
const interactionAPI = useInteractionsAPI()
|
||||
const binAPI = useBinAPI()
|
||||
const componentAPI = useComponentAPI()
|
||||
const {openSnack} = useSnackbar()
|
||||
const [newInteraction, setNewInteraction] = useState(false);
|
||||
|
||||
//will need to list the interactions for the cable
|
||||
useEffect(() => {
|
||||
let component = componentMap.get(cable.grainCable.key) ?? Component.create()
|
||||
setCableComponent(component)
|
||||
interactionAPI.listInteractionsByComponent(device.id(), cableComponent.location(), undefined, as).then(resp => {
|
||||
setCableInteractions(resp);
|
||||
});
|
||||
},[cable, device, interactionAPI])
|
||||
|
||||
//determine if the node is excluded/the top node
|
||||
useEffect(()=>{
|
||||
if(node.topNode !== undefined){
|
||||
setIsTopNode(node.topNode)
|
||||
}
|
||||
if(cable.grainCable.excludedNodes.includes(node.nodeIndex)){
|
||||
setIsExcluded(true)
|
||||
}else(
|
||||
setIsExcluded(false)
|
||||
)
|
||||
},[node, cable])
|
||||
|
||||
//navigation function to go to the component page
|
||||
const goToComponent = () => {
|
||||
navigate("/devices/" + device.id() + "/components/" + cable.grainCable.key);
|
||||
}
|
||||
|
||||
const close = ()=>{
|
||||
onClose()
|
||||
}
|
||||
|
||||
const setEMC = () => {
|
||||
let settings = cableComponent.settings;
|
||||
//make sure the mutation is not there first
|
||||
if(!settings.defaultMutations.includes(pond.Mutator.MUTATOR_EMC)){
|
||||
settings.defaultMutations.push(pond.Mutator.MUTATOR_EMC)
|
||||
}
|
||||
settings.grainType = bin.grain();
|
||||
settings.customGrain = bin.customGrain();
|
||||
componentAPI
|
||||
.update(device.id(), settings, [bin.key()], ["bin"], as)
|
||||
.then(resp => {
|
||||
openSnack("EMC set on cable " + cableComponent.name());
|
||||
})
|
||||
.catch(err => {});
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
//should update the component and then update the bin prefs if successful
|
||||
componentAPI.update(device.id(), cableComponent.settings).then(resp => {
|
||||
let pref = pond.BinComponentPreferences.create({
|
||||
type: pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE,
|
||||
node: cableComponent.settings.grainFilledTo
|
||||
});
|
||||
//update the bins preferences to have the new top node for the cable
|
||||
binAPI
|
||||
.updateComponentPreferences(bin.key(), cableComponent.key(), pref, as)
|
||||
.then(resp => {
|
||||
openSnack("Changes will be reflected once the bins status updates")
|
||||
if(updateComponentCallback){
|
||||
updateComponentCallback()
|
||||
}
|
||||
})
|
||||
.catch(err => {});
|
||||
}).catch(err => {
|
||||
|
||||
})
|
||||
close()
|
||||
}
|
||||
|
||||
const updateTopNode = (checked: boolean) => {
|
||||
if (isTopNode && !checked) {
|
||||
cableComponent.settings.grainFilledTo = 0;
|
||||
cableInteractions.forEach(interaction => {
|
||||
interaction.settings.subtype = 0;
|
||||
interaction.settings.nodeOne = 0;
|
||||
interaction.settings.nodeTwo = 0;
|
||||
});
|
||||
} else if (checked) {
|
||||
cableComponent.settings.grainFilledTo = node.nodeIndex+1;
|
||||
cableInteractions.forEach(interaction => {
|
||||
interaction.settings.subtype = Interaction.upToSubtype(node.nodeIndex+1);
|
||||
interaction.settings.nodeOne = node.nodeIndex+1;
|
||||
});
|
||||
}
|
||||
setIsTopNode(checked);
|
||||
}
|
||||
|
||||
const updateNodeExclusion = (checked: boolean) => {
|
||||
if (checked) {
|
||||
cableComponent.settings.excludedNodes.push(node.nodeIndex)
|
||||
} else {
|
||||
cableComponent.settings.excludedNodes.splice(cableComponent.settings.excludedNodes.indexOf(node.nodeIndex), 1)
|
||||
}
|
||||
setIsExcluded(checked)
|
||||
}
|
||||
|
||||
const displayTemp = () => {
|
||||
let isF = user.settings.temperatureUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
|
||||
let tempFinal = node.celcius ?? 0;
|
||||
if (isF) {
|
||||
tempFinal = (tempFinal * 1.8 + 32);
|
||||
}
|
||||
return tempFinal.toFixed(2) + (isF ? " F" : "C");
|
||||
};
|
||||
|
||||
const latestReading = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Typography style={{ fontSize: 20, fontWeight: 650 }}>Latest Reading</Typography>
|
||||
<Typography style={{ fontSize: 10 }}>{moment(cable.grainCable.lastRead).fromNow()}</Typography>
|
||||
<Grid2 container direction="row" spacing={2} style={{ padding: 10 }}>
|
||||
<Grid2 size={6}>
|
||||
<Card className={classes.readingCard}>
|
||||
<Box display="flex" justifyContent="center" alignItems="center">
|
||||
<TemperatureIcon />
|
||||
<Typography style={{ fontWeight: 650 }}>
|
||||
{displayTemp()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography style={{ textAlign: "center", color: "grey", fontWeight: 650 }}>
|
||||
Temperature
|
||||
</Typography>
|
||||
</Card>
|
||||
</Grid2>
|
||||
<Grid2 size={6}>
|
||||
<Card className={classes.readingCard}>
|
||||
<Box display="flex" justifyContent="center" alignItems="center">
|
||||
<HumidityIcon />
|
||||
<Typography style={{ fontWeight: 650 }}>
|
||||
{node.humidity} %
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography style={{ textAlign: "center", color: "grey", fontWeight: 650 }}>
|
||||
Humidity
|
||||
</Typography>
|
||||
</Card>
|
||||
</Grid2>
|
||||
<Grid2 size={6}>
|
||||
{/* only show the emc if the bin grain type and the cable grain type match */}
|
||||
{node.moisture ? (
|
||||
<Card className={classes.readingCard}>
|
||||
<Box display="flex" justifyContent="center" alignItems="center">
|
||||
<HumidityIcon />
|
||||
<Typography style={{ fontWeight: 650 }}>
|
||||
{node.moisture} %
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography style={{ textAlign: "center", color: "grey", fontWeight: 650 }}>
|
||||
Grain EMC
|
||||
</Typography>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardActionArea
|
||||
className={classes.readingCard}
|
||||
onClick={() => {
|
||||
setEMC();
|
||||
}}>
|
||||
<Box display="flex" justifyContent="center" alignItems="center">
|
||||
<HumidityIcon />
|
||||
</Box>
|
||||
<Typography style={{ textAlign: "center", color: "grey", fontWeight: 650 }}>
|
||||
Set EMC
|
||||
</Typography>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
)}
|
||||
</Grid2>
|
||||
<Grid2 size={6}>
|
||||
<Card>
|
||||
<CardActionArea className={classes.readingCard} onClick={() => goToComponent()}>
|
||||
<Box display="flex" justifyContent="center" alignItems="center">
|
||||
<GraphIcon />
|
||||
</Box>
|
||||
<Typography style={{ textAlign: "center", color: "grey", fontWeight: 650 }}>
|
||||
See Graphs
|
||||
</Typography>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const nodeControl = () => {
|
||||
return (
|
||||
<Box style={{ marginTop: 10 }}>
|
||||
<Typography style={{ fontSize: 20, fontWeight: 650 }}>Node Control</Typography>
|
||||
<FormControlLabel
|
||||
style={{ marginTop: 10 }}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={isExcluded}
|
||||
value={isExcluded}
|
||||
onChange={e => {
|
||||
updateNodeExclusion(e.target.checked);
|
||||
}}
|
||||
name="excludedNode"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<React.Fragment>
|
||||
<Typography style={{ fontSize: 15, fontWeight: 650 }}>
|
||||
Exclude Node
|
||||
</Typography>
|
||||
<Typography style={{ fontSize: 15 }}>
|
||||
If checked this will set the node to be disabled and will not be used for any calculations done for the bin
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
style={{ marginTop: 10 }}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={isTopNode}
|
||||
value={isTopNode}
|
||||
disabled={isExcluded}
|
||||
onChange={e => {
|
||||
updateTopNode(e.target.checked);
|
||||
}}
|
||||
name="topNode"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<React.Fragment>
|
||||
<Typography style={{ fontSize: 15, fontWeight: 650 }}>
|
||||
Set as top node in grain
|
||||
</Typography>
|
||||
<Typography style={{ fontSize: 15 }}>
|
||||
If checked this will set interactions to ignore all nodes above it. Interactions can
|
||||
still be adjusted manually to use all nodes
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const interactionDisplay = () => {
|
||||
return (
|
||||
<Box marginTop={2}>
|
||||
<Typography style={{ fontSize: 20, fontWeight: 650 }}>Interactions</Typography>
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<InteractionsOverview
|
||||
component={cableComponent}
|
||||
components={filteredComponents ?? []}
|
||||
device={device}
|
||||
interactions={cableInteractions}
|
||||
permissions={permissions}
|
||||
refreshCallback={() => {
|
||||
interactionAPI
|
||||
.listInteractionsByComponent(device.id(), cableComponent.location(), undefined, as)
|
||||
.then(resp => {
|
||||
setCableInteractions(resp);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="center">
|
||||
<IconButton
|
||||
color="primary"
|
||||
disabled={!canWrite(permissions)}
|
||||
aria-label="Add Interaction"
|
||||
onClick={() => {
|
||||
setNewInteraction(true);
|
||||
}}
|
||||
className={classes.greenButton}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ResponsiveDialog open={open} onClose={close} fullWidth={isMobile}>
|
||||
<DialogTitle style={{ padding: 10 }}>
|
||||
<Box display="flex" justifyContent="center" marginBottom={2}>
|
||||
<Typography style={{ fontSize: 30, fontWeight: 650 }}>{cable.grainCable.name}</Typography>
|
||||
</Box>
|
||||
<AppBar
|
||||
className={classes.appBar}
|
||||
color="secondary">
|
||||
<Box>
|
||||
<Typography align="center" variant="h5">
|
||||
Node {node.nodeIndex+1}
|
||||
</Typography>
|
||||
</Box>
|
||||
</AppBar>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{latestReading()}
|
||||
{nodeControl()}
|
||||
{interactionDisplay()}
|
||||
{/*
|
||||
*/}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={close} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} color="primary">
|
||||
Submit
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</ResponsiveDialog>
|
||||
<InteractionSettings
|
||||
isDialogOpen={newInteraction}
|
||||
initialComponent={cableComponent}
|
||||
mode="add"
|
||||
device={device}
|
||||
components={filteredComponents ?? []}
|
||||
closeDialogCallback={() => {
|
||||
setNewInteraction(false);
|
||||
}}
|
||||
refreshCallback={() => {
|
||||
interactionAPI.listInteractionsByComponent(device.id(), cableComponent.location(), undefined, as).then(resp => {
|
||||
setCableInteractions(resp);
|
||||
});
|
||||
}}
|
||||
canEdit={canWrite(permissions)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
466
src/bin/binTableView.tsx
Normal file
466
src/bin/binTableView.tsx
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
import { Edit, GppGood, Info, Save } from "@mui/icons-material";
|
||||
import { Box, Button, darken, DialogActions, IconButton, Table, TableBody, TableCell, TableHead, TableRow, TextField, Theme, Tooltip, Typography, useTheme } from "@mui/material";
|
||||
import { green, grey, orange, red, yellow } from "@mui/material/colors";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import ResponsiveDialog from "common/ResponsiveDialog";
|
||||
import HumidityIcon from "component/HumidityIcon";
|
||||
import TemperatureIcon from "component/TemperatureIcon";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { Bin } from "models";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { useGlobalState } from "providers";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { avg, celsiusToFahrenheit } from "utils";
|
||||
import BinTableExpanded from "./BinTableExpanded";
|
||||
import { useMobile } from "hooks";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => ({
|
||||
tableStyle: {
|
||||
borderCollapse: "separate",
|
||||
borderSpacing: 0, // removes the default gap that "separate" introduces
|
||||
},
|
||||
headerCellStyle: {
|
||||
fontWeight: 650,
|
||||
backgroundColor: darken(theme.palette.background.paper, 0.3),
|
||||
textAlign: "center",
|
||||
padding: 10
|
||||
},
|
||||
mobileHeaderCellStyle: {
|
||||
fontWeight: 650,
|
||||
backgroundColor: darken(theme.palette.background.paper, 0.3),
|
||||
textAlign: "center",
|
||||
padding: 2
|
||||
},
|
||||
targetCellStyle: {
|
||||
textAlign: "center",
|
||||
backgroundColor: theme.palette.background.paper
|
||||
},
|
||||
mobileTargetCellStyle: {
|
||||
textAlign: "center",
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
padding: 2
|
||||
},
|
||||
defaultCellStyle: {
|
||||
padding: 2,
|
||||
fontSize: 15,
|
||||
textAlign: "center",
|
||||
backgroundColor: darken(theme.palette.background.paper, 0.1),
|
||||
borderTop: "1px solid " + darken(theme.palette.background.paper, 0.4),
|
||||
borderBottom: "none"
|
||||
},
|
||||
colouredCellStyle: {
|
||||
padding: 2,
|
||||
borderTop: "1px solid " + darken(theme.palette.background.paper, 0.4),
|
||||
// fontWeight: 650,
|
||||
fontSize: 15,
|
||||
textAlign: "center",
|
||||
borderBottom: "none"
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
interface Props {
|
||||
bin: Bin
|
||||
updateBinCallback?: (bin: Bin) => void
|
||||
}
|
||||
|
||||
interface BinLevel {
|
||||
grainTemps: number[]
|
||||
airTemps: number[]
|
||||
grainMoistures: number[]
|
||||
airMoistures: number[]
|
||||
}
|
||||
|
||||
|
||||
export default function BinTableView(props: Props) {
|
||||
const classes = useStyles()
|
||||
const {bin, updateBinCallback} = props
|
||||
const [{user}] = useGlobalState()
|
||||
const theme = useTheme()
|
||||
const [enterTemp, setEnterTemp] = useState(false)
|
||||
const [enterMoisture, setEnterMoisture] = useState(false)
|
||||
const [tempTarget, setTempTarget] = useState(0)
|
||||
const [tempEntry, setTempEntry] = useState("")
|
||||
const [moistureEntry, setMoistureEntry] = useState("")
|
||||
const [moistureTarget, setMoistureTarget] = useState(0)
|
||||
const [openExpandedCables, setOpenExpandedCables] = useState(false)
|
||||
const isMobile = useMobile()
|
||||
const inBounds = green[600]
|
||||
const caution = yellow[800]
|
||||
const warning = red[700]
|
||||
const headerRow1Ref = useRef<HTMLTableRowElement>(null)
|
||||
const [firstRowHeight, setFirstRowHeight] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (headerRow1Ref.current) {
|
||||
setFirstRowHeight(headerRow1Ref.current.getBoundingClientRect().height)
|
||||
}
|
||||
}, [enterTemp, enterMoisture]) // re-measure when the row height might change due to TextField appearing
|
||||
|
||||
useEffect(()=>{
|
||||
let val = bin.targetTemp()
|
||||
if(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){
|
||||
val = bin.targetTemp() * (9/5) + 32
|
||||
}
|
||||
setTempTarget(val)
|
||||
setTempEntry(val.toFixed(2))
|
||||
setMoistureTarget(bin.targetMoisture())
|
||||
setMoistureEntry(bin.targetMoisture().toFixed(2))
|
||||
},[bin])
|
||||
|
||||
const binLevels = useMemo(() => {
|
||||
const cables = bin.status.grainCables
|
||||
let levels: BinLevel[] = []
|
||||
//note for future self, the first item in the measurement arrays is the lowest node on the cable
|
||||
//the zones we are creating here will match with the node number because most cables will either have the same number of nodes or only have a difference of 1 or 2
|
||||
//example: cable 1 has 5 nodes cable 2 has 7 nodes, the first five nodes of the cables will match up and average together for each zone
|
||||
//and then zones 6 and 7 will be made using only cable 2 because cable 1 doesn't have them
|
||||
cables.forEach(cable => {
|
||||
cable.celcius.forEach((c, index) => {
|
||||
//remember index will be 0 based but the top node starts at 1 for the lowest cable
|
||||
let inGrain = index < cable.topNode
|
||||
if (levels[index]){
|
||||
if(cable.excludedNodes.includes(index+1)) return
|
||||
if(inGrain){
|
||||
levels[index].grainTemps.push(c)
|
||||
//need to check that the cable not only has the moisture mutation on it, but also that it reads humidity
|
||||
//cables that dont read humidity return an array of 0 values so if the average of all of the values is 0 then it is a temp only cable
|
||||
if(cable.moisture.length > 0 && avg(cable.moisture) > 0){
|
||||
//based on all the other checks and the fact that the indexes between the array should match this should never be 0 but put a guard here just in case
|
||||
//also note that we are only doing moisture and not humidity, it seems like users dont care about humidity
|
||||
levels[index].grainMoistures.push(cable.moisture[index] ?? 0)
|
||||
}
|
||||
}else{
|
||||
levels[index].airTemps.push(c)
|
||||
//need to check that the cable not only has the moisture mutation on it, but also that it reads humidity
|
||||
//cables that dont read humidity return an array of 0 values so if the average of all of the values is 0 then it is a temp only cable
|
||||
if(cable.moisture.length > 0 && avg(cable.moisture) > 0){
|
||||
//based on all the other checks and the fact that the indexes between the array should match this should never be 0 but put a guard here just in case
|
||||
levels[index].airMoistures.push(cable.moisture[index] ?? 0)
|
||||
}
|
||||
}
|
||||
}else{
|
||||
let newlevel: BinLevel = {
|
||||
grainTemps: [],
|
||||
grainMoistures: [],
|
||||
airTemps: [],
|
||||
airMoistures: []
|
||||
}
|
||||
if(!cable.excludedNodes.includes(index)){
|
||||
if(inGrain){
|
||||
newlevel.grainTemps.push(c)
|
||||
if(cable.moisture.length > 0 && avg(cable.moisture) > 0){
|
||||
newlevel.grainMoistures.push(cable.moisture[index] ?? 0)
|
||||
}
|
||||
}else{
|
||||
newlevel.airTemps.push(c)
|
||||
if(cable.moisture.length > 0 && avg(cable.moisture) > 0){
|
||||
newlevel.airMoistures.push(cable.moisture[index] ?? 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
levels.push(newlevel)
|
||||
}
|
||||
|
||||
})
|
||||
})
|
||||
//flip the levels so that the highest nodes are on the top
|
||||
levels.reverse()
|
||||
return levels
|
||||
},[bin])
|
||||
|
||||
const levelDisplay = (level: BinLevel, lastRow?: boolean) => {
|
||||
let tempDisplay = "--"
|
||||
let moistureDisplay = "--"
|
||||
let lvlTemp
|
||||
let lvlMoisture
|
||||
let tempColour: string = darken(theme.palette.background.paper, 0.1)
|
||||
let moistureColour: string = darken(theme.palette.background.paper, 0.1)
|
||||
|
||||
//determine the temp
|
||||
if(level.grainTemps.length > 0){
|
||||
lvlTemp = avg(level.grainTemps)
|
||||
//if the average temp is 5 degrees above the bins threshold
|
||||
if(lvlTemp > (bin.targetTemp() + 10)){
|
||||
tempColour = warning
|
||||
}else if(lvlTemp > bin.targetTemp() + 5){
|
||||
//if the average temp is greater than 5 degrees over
|
||||
tempColour = caution
|
||||
}else {
|
||||
tempColour = inBounds
|
||||
}
|
||||
}else if (level.airTemps.length > 0){
|
||||
lvlTemp = avg(level.airTemps)
|
||||
}
|
||||
if (lvlTemp){
|
||||
if(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){
|
||||
lvlTemp = celsiusToFahrenheit(lvlTemp)
|
||||
}
|
||||
tempDisplay = lvlTemp.toFixed(2)
|
||||
}
|
||||
|
||||
//determine the moisture to show
|
||||
if(level.grainMoistures.length > 0){
|
||||
lvlMoisture = avg(level.grainMoistures)
|
||||
if(lvlMoisture > (bin.targetMoisture() + 1.5)){
|
||||
moistureColour = warning
|
||||
}else if(lvlMoisture > bin.targetMoisture() + 0.5){
|
||||
moistureColour = caution
|
||||
}else {
|
||||
moistureColour = inBounds
|
||||
}
|
||||
}else if (level.airMoistures.length > 0){
|
||||
lvlMoisture = avg(level.airMoistures)
|
||||
}
|
||||
if(lvlMoisture){
|
||||
moistureDisplay = lvlMoisture.toFixed(2)
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TableCell className={classes.colouredCellStyle} padding="none" sx={{backgroundColor: tempColour}}>{tempDisplay}</TableCell>
|
||||
<TableCell className={classes.colouredCellStyle} padding="none" sx={{backgroundColor: moistureColour, borderBottomRightRadius: lastRow ? 8 : 0}}>{moistureDisplay}</TableCell>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
let temp = tempTarget
|
||||
if(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){
|
||||
//convert to c
|
||||
temp = (temp - 32) * (5 / 9);
|
||||
|
||||
}
|
||||
let clone = cloneDeep(bin)
|
||||
if(clone.settings.inventory && updateBinCallback){
|
||||
clone.settings.inventory.targetTemperature = temp
|
||||
clone.settings.inventory.targetMoisture = moistureTarget
|
||||
updateBinCallback(clone)
|
||||
}
|
||||
}
|
||||
|
||||
const tempThreshDisplay = (hideUnit?: boolean) => {
|
||||
if(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){
|
||||
return tempTarget.toFixed(2) + (hideUnit ? "" : " °F")
|
||||
}
|
||||
return tempTarget.toFixed(2) + (hideUnit ? "" : " °C")
|
||||
}
|
||||
|
||||
const LegendSwatch = ({ color }: { color: string }) => (
|
||||
<Box sx={{ width: 40, height: 20, backgroundColor: color, borderRadius: 1, flexShrink: 0 }} />
|
||||
)
|
||||
|
||||
|
||||
const updateTempEntry = () => {
|
||||
return (
|
||||
<TextField
|
||||
value={tempEntry}
|
||||
onChange={e => {
|
||||
let val = parseFloat(e.target.value)
|
||||
if(!isNaN(val)){
|
||||
setTempTarget(val)
|
||||
}
|
||||
setTempEntry(e.target.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const updateMoistureEntry = () => {
|
||||
return (
|
||||
<TextField
|
||||
value={moistureEntry}
|
||||
onChange={e => {
|
||||
let val = parseFloat(e.target.value)
|
||||
if(!isNaN(val)){
|
||||
setMoistureTarget(val)
|
||||
}
|
||||
setMoistureEntry(e.target.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const expandedTableDialog = () => {
|
||||
return (
|
||||
<ResponsiveDialog open={openExpandedCables} onClose={()=>{setOpenExpandedCables(false)}}>
|
||||
<BinTableExpanded
|
||||
cables={bin.status.grainCables}
|
||||
targetMoisture={bin.targetMoisture()}
|
||||
targetTemp={bin.targetTemp()}
|
||||
inBounds={inBounds}
|
||||
caution={caution}
|
||||
warning={warning}
|
||||
/>
|
||||
<DialogActions>
|
||||
<Button onClick={()=> {setOpenExpandedCables(false)}}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</ResponsiveDialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Box padding={2} sx={{ display: "flex", flexDirection: "column", height: isMobile ? "88%" : "100%" }}>
|
||||
{expandedTableDialog()}
|
||||
{/* Level Table */}
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Typography fontWeight={650}>Grain Table</Typography>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ background: theme.palette.background.default}}
|
||||
onClick={() => {
|
||||
setOpenExpandedCables(true)
|
||||
}}>
|
||||
Show All Cables
|
||||
</Button>
|
||||
<Tooltip title="This table shows the bins levels using nodes that are in the grain averaged together. Data comes from the bins status.
|
||||
Use the button to see all of the cables individually.">
|
||||
<Info />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* this box is to make the whole table scrollable */}
|
||||
<Box sx={{ overflowY: "auto", flex: 1, minHeight: 0 }}>
|
||||
<Table className={classes.tableStyle}>
|
||||
<TableHead>
|
||||
<TableRow ref={headerRow1Ref}>
|
||||
{/* "Targets" cell — hide on mobile when either field is being edited */}
|
||||
{!(isMobile && (enterTemp || enterMoisture)) && (
|
||||
<TableCell className={isMobile ? classes.mobileTargetCellStyle : classes.targetCellStyle} sx={{top: 0, position: "sticky", zIndex: 4, borderBottom: "none" }}>
|
||||
<Box justifyContent={"center"} sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<GppGood />
|
||||
{!isMobile &&
|
||||
<Typography fontSize={16} fontWeight={650}>
|
||||
Targets
|
||||
</Typography>
|
||||
}
|
||||
</Box>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Temperature cell */}
|
||||
{!(isMobile && enterMoisture) && (
|
||||
<TableCell
|
||||
className={isMobile ? classes.mobileTargetCellStyle : classes.targetCellStyle}
|
||||
colSpan={isMobile && enterTemp ? 3 : 1}
|
||||
sx={{ top: 0, position: "sticky", zIndex: 4, borderBottom: "none" }}
|
||||
>
|
||||
<Box justifyContent={"center"} sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<TemperatureIcon heightWidth={isMobile ? 25 : 30} />
|
||||
{enterTemp ? updateTempEntry() : (
|
||||
<Typography fontSize={isMobile ? 12 : 16}>
|
||||
{tempThreshDisplay(isMobile)}
|
||||
</Typography>
|
||||
)}
|
||||
{enterTemp ? (
|
||||
<IconButton onClick={() => { setEnterTemp(false); update(); }}>
|
||||
<Save />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton onClick={() => setEnterTemp(true)}>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
)}
|
||||
{/* Moisture cell — hide on mobile when temperature is being edited */}
|
||||
{!(isMobile && enterTemp) && (
|
||||
<TableCell
|
||||
className={isMobile ? classes.mobileTargetCellStyle : classes.targetCellStyle}
|
||||
colSpan={isMobile && enterMoisture ? 3 : 1}
|
||||
sx={{ top: 0, position: "sticky", zIndex: 4, borderBottom: "none" }}
|
||||
>
|
||||
<Box justifyContent={"center"} sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<HumidityIcon height={isMobile ? 25 : 30} width={20} />
|
||||
{enterMoisture ? updateMoistureEntry() : (
|
||||
<Typography fontSize={isMobile ? 12 : 16} sx={{ marginLeft: "10px" }}>
|
||||
{bin.targetMoisture()}
|
||||
</Typography>
|
||||
)}
|
||||
{enterMoisture ? (
|
||||
<IconButton onClick={() => { setEnterMoisture(false); update(); }}>
|
||||
<Save />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton onClick={() => setEnterMoisture(true)}>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
className={isMobile ? classes.mobileHeaderCellStyle : classes.headerCellStyle}
|
||||
sx={{
|
||||
top: firstRowHeight,
|
||||
position: "sticky",
|
||||
zIndex: 3,
|
||||
borderBottom: "none",
|
||||
borderTopLeftRadius: 8,
|
||||
boxShadow: `-12px -12px 0px 4px ${theme.palette.background.paper}`
|
||||
}}>
|
||||
Level
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={isMobile ? classes.mobileHeaderCellStyle : classes.headerCellStyle}
|
||||
sx={{
|
||||
top: firstRowHeight,
|
||||
position: "sticky",
|
||||
zIndex: 3,
|
||||
borderBottom: "none"
|
||||
}}>
|
||||
{isMobile ? "T" : "Temperature"}{(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? " (°F)" : " (°C)")}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={isMobile ? classes.mobileHeaderCellStyle : classes.headerCellStyle}
|
||||
sx={{
|
||||
top: firstRowHeight,
|
||||
position: "sticky",
|
||||
zIndex: 3,
|
||||
borderBottom: "none",
|
||||
borderTopRightRadius: 8,
|
||||
// fills the transparent corner area with the target row's background colour
|
||||
boxShadow: `16px -16px 8px 12px ${theme.palette.background.paper}`
|
||||
}}>
|
||||
EMC (%)
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{binLevels.map((level, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell sx={{borderBottomLeftRadius: (i === binLevels.length-1 ? 8 : 0)}} padding="none" className={classes.defaultCellStyle}>{binLevels.length - i}</TableCell>
|
||||
{levelDisplay(level, i === binLevels.length-1)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
{/* Table Legend */}
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" marginTop={2} sx={{ flexShrink: 0 }}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<LegendSwatch color={inBounds} />
|
||||
<Typography sx={{fontWeight: 600, fontSize: 10}}>On target</Typography>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<LegendSwatch color={caution} />
|
||||
<Typography sx={{fontWeight: 600, fontSize: 10}}>{user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "~10 °F" : "+5 °C"} (0.5%) above</Typography>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<LegendSwatch color={warning} />
|
||||
<Typography sx={{fontWeight: 600, fontSize: 10}}>{user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "~20 °F" : "+10 °C"} (1.5%) above</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { GrainCable } from "models/GrainCable";
|
|||
import { Plenum } from "models/Plenum";
|
||||
import { UnitMeasurement } from "models/UnitMeasurement";
|
||||
import moment from "moment";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { quack } from "protobuf-ts/quack";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { avg } from "utils";
|
||||
|
|
|
|||
292
src/bin/graphs/BinSensorGraph.tsx
Normal file
292
src/bin/graphs/BinSensorGraph.tsx
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import { Avatar, Box, Card, CircularProgress, IconButton, Paper, Typography, useTheme } from "@mui/material";
|
||||
import moment, { Moment } from "moment";
|
||||
import { Component } from "models";
|
||||
import { useComponentAPI, useGlobalState } from "providers";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { UnitMeasurement } from "models/UnitMeasurement";
|
||||
import { quack } from "protobuf-ts/quack";
|
||||
import {
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
TooltipProps,
|
||||
XAxis,
|
||||
YAxis
|
||||
} from "recharts";
|
||||
import { avg, roundTo } from "utils";
|
||||
import { MoreVert } from "@mui/icons-material";
|
||||
|
||||
interface Props {
|
||||
device: number;
|
||||
icon?: string;
|
||||
title?: string;
|
||||
tag?: string;
|
||||
onMenuClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
component: Component;
|
||||
startDate: Moment;
|
||||
endDate: Moment;
|
||||
customHeight?: string | number;
|
||||
/**
|
||||
* this will cause all of the readings for a cable to be used in the average,
|
||||
* by default it will filter any excluded nodes and only use nodes below the top node
|
||||
*/
|
||||
allNodes?: boolean
|
||||
}
|
||||
|
||||
/** Grain-cable nodes to include when averaging (matches GrainCable.filter indexing). */
|
||||
function valuesForAveraging(
|
||||
nodeValues: number[],
|
||||
component: Component,
|
||||
allNodes?: boolean
|
||||
): number[] {
|
||||
if (allNodes) {
|
||||
return nodeValues.filter(v => !isNaN(v));
|
||||
}
|
||||
|
||||
const grainFilledTo = component.settings.grainFilledTo ?? 0;
|
||||
const excludedNodes = component.settings.excludedNodes ?? [];
|
||||
const included: number[] = [];
|
||||
|
||||
nodeValues.forEach((v, index) => {
|
||||
if (isNaN(v)) {
|
||||
return;
|
||||
}
|
||||
if (excludedNodes.includes(index)) {
|
||||
return;
|
||||
}
|
||||
if (grainFilledTo > 0 && index >= grainFilledTo) {
|
||||
return;
|
||||
}
|
||||
included.push(v);
|
||||
});
|
||||
|
||||
return included;
|
||||
}
|
||||
|
||||
interface MeasurementMeta {
|
||||
value: number;
|
||||
unit: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ChartPoint {
|
||||
timestamp: number;
|
||||
_meta: Record<string, MeasurementMeta>;
|
||||
[seriesKey: string]: number | Record<string, MeasurementMeta> | undefined;
|
||||
}
|
||||
|
||||
interface SeriesInfo {
|
||||
key: string;
|
||||
colour: string;
|
||||
unit: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function normalizeValue(value: number, min: number, max: number): number {
|
||||
if (max === min) {
|
||||
return 50;
|
||||
}
|
||||
return ((value - min) / (max - min)) * 100;
|
||||
}
|
||||
|
||||
function buildChartData(
|
||||
measurements: UnitMeasurement[],
|
||||
component: Component,
|
||||
allNodes?: boolean
|
||||
): {
|
||||
data: ChartPoint[];
|
||||
series: SeriesInfo[];
|
||||
} {
|
||||
const rawSeries = measurements
|
||||
.filter(um => um.timestamps.length > 0 && um.values.length > 0)
|
||||
.map(um => {
|
||||
const points: { timestamp: number; value: number }[] = [];
|
||||
um.values.forEach((valArray, i) => {
|
||||
if (valArray.error || !um.timestamps[i]) {
|
||||
return;
|
||||
}
|
||||
const nodeValues = valuesForAveraging(valArray.values, component, allNodes);
|
||||
if (nodeValues.length === 0) {
|
||||
return;
|
||||
}
|
||||
points.push({
|
||||
timestamp: moment(um.timestamps[i]).valueOf(),
|
||||
value: avg(nodeValues)
|
||||
});
|
||||
});
|
||||
const actualValues = points.map(p => p.value);
|
||||
const min = actualValues.length > 0 ? Math.min(...actualValues) : 0;
|
||||
const max = actualValues.length > 0 ? Math.max(...actualValues) : 100;
|
||||
return { um, points, min, max, key: um.label };
|
||||
})
|
||||
.filter(s => s.points.length > 0);
|
||||
|
||||
const timestampMap = new Map<number, ChartPoint>();
|
||||
rawSeries.forEach(({ um, points, min, max, key }) => {
|
||||
points.forEach(({ timestamp, value }) => {
|
||||
let point = timestampMap.get(timestamp);
|
||||
if (!point) {
|
||||
point = { timestamp, _meta: {} };
|
||||
timestampMap.set(timestamp, point);
|
||||
}
|
||||
point[key] = normalizeValue(value, min, max);
|
||||
point._meta[key] = { value, unit: um.unit, label: um.label };
|
||||
});
|
||||
});
|
||||
|
||||
const data = Array.from(timestampMap.values()).sort((a, b) => a.timestamp - b.timestamp);
|
||||
const series = rawSeries.map(({ um, key }) => ({
|
||||
key,
|
||||
colour: um.colour,
|
||||
unit: um.unit,
|
||||
label: um.label
|
||||
}));
|
||||
|
||||
return { data, series };
|
||||
}
|
||||
|
||||
function SensorGraphTooltip(props: TooltipProps<number, string>) {
|
||||
const { active, label, payload } = props;
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper variant="outlined" style={{ opacity: 0.9 }}>
|
||||
<Box padding={1}>
|
||||
{payload.map(p => {
|
||||
const dataKey = String(p.dataKey);
|
||||
const meta = (p.payload as ChartPoint)._meta?.[dataKey];
|
||||
return (
|
||||
<Typography color="textPrimary" variant="subtitle1" key={dataKey}>
|
||||
{meta?.label ?? dataKey}
|
||||
{": "}
|
||||
<Box component="span">
|
||||
{meta ? `${roundTo(meta.value, 2)} ${meta.unit}` : p.value}
|
||||
</Box>
|
||||
</Typography>
|
||||
);
|
||||
})}
|
||||
<Typography color="textSecondary" variant="caption">
|
||||
{moment(label).format("lll")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BinSensorGraph(props: Props) {
|
||||
const { device, icon, title, tag, component, startDate, endDate, customHeight = 350, onMenuClick, allNodes } = props;
|
||||
const [measurements, setMeasurements] = useState<UnitMeasurement[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const componentAPI = useComponentAPI();
|
||||
const [{ user }] = useGlobalState();
|
||||
const theme = useTheme();
|
||||
const now = moment();
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
componentAPI
|
||||
.sampleUnitMeasurements(device, component.key(), startDate, endDate, 1000, undefined, undefined, undefined, undefined, true)
|
||||
.then(res => {
|
||||
setMeasurements(res.data.measurements.map(um => UnitMeasurement.any(um, user)));
|
||||
})
|
||||
.catch(() => {
|
||||
setMeasurements([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [device, component, startDate, endDate, componentAPI, user]);
|
||||
|
||||
const { data, series } = useMemo(
|
||||
() => buildChartData(measurements, component, allNodes),
|
||||
[measurements, component, allNodes]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card raised sx={{ height: "100%", padding: 1.5 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" marginBottom={1}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{icon &&
|
||||
<Avatar
|
||||
variant="square"
|
||||
src={icon}
|
||||
alt={(title ?? "sensor") + " icon"}
|
||||
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
|
||||
/>
|
||||
}
|
||||
<Typography fontWeight={500}>{title}</Typography>
|
||||
{tag && <Typography variant="caption" color="textSecondary">- {tag}</Typography>}
|
||||
</Box>
|
||||
{onMenuClick &&
|
||||
<IconButton size="small" onClick={onMenuClick}>
|
||||
<MoreVert fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
</Box>
|
||||
<Box height={customHeight} padding={1}>
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" height="100%">
|
||||
<CircularProgress size={80} thickness={1.5} />
|
||||
</Box>
|
||||
) : data.length === 0 || series.length === 0 ? (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" height="100%">
|
||||
<Typography color="textSecondary">No measurement data</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data}>
|
||||
<XAxis
|
||||
allowDataOverflow
|
||||
dataKey="timestamp"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
name="Time"
|
||||
tickFormatter={timestamp => {
|
||||
const t = moment(timestamp);
|
||||
return now.isSame(t, "day") ? t.format("LT") : t.format("MMM DD");
|
||||
}}
|
||||
scale="time"
|
||||
type="number"
|
||||
tick={{ fill: theme.palette.text.primary }}
|
||||
stroke={theme.palette.divider}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
{/* <YAxis
|
||||
domain={[0, 100]}
|
||||
tick={false}
|
||||
stroke={theme.palette.divider}
|
||||
label={{
|
||||
value: "Readings",
|
||||
position: "insideLeft",
|
||||
angle: -90,
|
||||
fill: theme.palette.text.primary
|
||||
}}
|
||||
/> */}
|
||||
<Tooltip
|
||||
animationEasing="ease-out"
|
||||
cursor={{ fill: theme.palette.text.primary, opacity: "0.15" }}
|
||||
content={SensorGraphTooltip}
|
||||
/>
|
||||
<Legend verticalAlign="bottom" />
|
||||
{series.map(s => (
|
||||
<Line
|
||||
key={s.key}
|
||||
type="monotone"
|
||||
dataKey={s.key}
|
||||
name={s.label}
|
||||
stroke={s.colour}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
175
src/grain/ChangeGrainDialog.tsx
Normal file
175
src/grain/ChangeGrainDialog.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { Box, Button, DialogActions, DialogContent, DialogTitle, Grid2, Switch, TextField } from "@mui/material";
|
||||
import ResponsiveDialog from "common/ResponsiveDialog";
|
||||
import SearchSelect, { Option } from "common/SearchSelect";
|
||||
import { Bin } from "models";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import GrainDescriber, { GrainOptions, ToGrainOption } from "grain/GrainDescriber";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import React from "react";
|
||||
import CustomGrainSelector from "./CustomGrainSelector";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { useBinAPI, useSnackbar } from "providers";
|
||||
|
||||
interface Props {
|
||||
bin: Bin
|
||||
open: boolean
|
||||
closeDialog: () => void
|
||||
permissions: pond.Permission[]
|
||||
/**
|
||||
* this function can be passed in if you want to just return a new bin with the changes and handle the actual update in the parent,
|
||||
* it will stop the component from doing the update itself
|
||||
* @param bin the bin with the new changes
|
||||
* @returns
|
||||
*/
|
||||
updateBin?: (bin: Bin) => void
|
||||
/**
|
||||
* this function can be passed in as a callback function if the component handles the update
|
||||
* @param updated whether the bin api was used to update the bin
|
||||
* @returns
|
||||
*/
|
||||
updateCallback?: (bin: Bin) => void
|
||||
}
|
||||
|
||||
export default function ChangeGrainDialog(props: Props){
|
||||
const { bin, open, permissions, closeDialog, updateBin, updateCallback } = props
|
||||
const canEdit = useMemo(()=>{
|
||||
return permissions ? permissions.includes(pond.Permission.PERMISSION_WRITE) : false;
|
||||
},[permissions])
|
||||
|
||||
const [isCustom, setIsCustom] = useState(false)
|
||||
const grainOptions = GrainOptions();
|
||||
const [grainOption, setGrainOption] = useState<Option>();
|
||||
const [newGrainType, setNewGrainType] = useState<pond.Grain>(0);
|
||||
const [newGrainSettings, setNewGrainSettings] = useState<pond.GrainSettings | undefined>()
|
||||
const binAPI = useBinAPI()
|
||||
const {openSnack} = useSnackbar()
|
||||
const [grainSubtype, setGrainSubtype] = useState("")
|
||||
|
||||
useEffect(()=>{
|
||||
setGrainOption(ToGrainOption(bin.grain()))
|
||||
setIsCustom(bin.grain() === pond.Grain.GRAIN_CUSTOM)
|
||||
setGrainSubtype(bin.subtype())
|
||||
setNewGrainType(bin.grain())
|
||||
},[bin])
|
||||
|
||||
/**
|
||||
* this function is where the split happens,
|
||||
* if update bin is passed in it will just clone the bin, change the settings and pass the updated clone to the parent so that it can handle the api call
|
||||
* otherwise the component will update the bin itself through the bin api and upon completion pass that updated bin to the parent if successfull
|
||||
* in the event both functions are passed in the parent update will be given priority and the internal update will not occur
|
||||
*/
|
||||
const updateBinSettings = () => {
|
||||
let clone = cloneDeep(bin)
|
||||
let inventory = clone.settings.inventory ?? pond.BinInventory.create()
|
||||
inventory.grainType = newGrainType
|
||||
inventory.customGrain = newGrainSettings
|
||||
inventory.grainSubtype = grainSubtype
|
||||
if(updateBin){
|
||||
//simply pass the clone up to the parent and it can deal with it
|
||||
updateBin(clone)
|
||||
}else if (updateCallback) {
|
||||
//use the actual bin api to update the bin inside the component and upon completion return the updated bin
|
||||
binAPI.updateBin(bin.key(), clone.settings).then(resp => {
|
||||
openSnack("Updated the grain type")
|
||||
updateCallback(clone)
|
||||
}).catch(err => {
|
||||
openSnack("There was a problem changing the grain type")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//closes the dialog
|
||||
const close = () => {
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={close}>
|
||||
<DialogTitle>Change Grain Type</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid2 container justifyContent="space-between" alignItems="center">
|
||||
<Grid2>
|
||||
<Grid2 container alignItems="center">
|
||||
<Grid2 >Grain</Grid2>
|
||||
<Grid2 >
|
||||
<Switch
|
||||
color="default"
|
||||
value={isCustom}
|
||||
checked={isCustom}
|
||||
onChange={(_, checked) => {
|
||||
setIsCustom(checked)
|
||||
if(checked){
|
||||
setNewGrainType(pond.Grain.GRAIN_CUSTOM)
|
||||
}else{
|
||||
setNewGrainType(bin.grain())
|
||||
setNewGrainSettings(undefined)
|
||||
}
|
||||
}}
|
||||
name="storage"
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 >Custom</Grid2>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
{!isCustom && (
|
||||
<Box>
|
||||
<SearchSelect
|
||||
label="Type"
|
||||
selected={grainOption}
|
||||
changeSelection={option => {
|
||||
let newGrainType = option
|
||||
? pond.Grain[option.value as keyof typeof pond.Grain]
|
||||
: pond.Grain.GRAIN_INVALID;
|
||||
setGrainOption(ToGrainOption(newGrainType));
|
||||
setNewGrainType(newGrainType);
|
||||
//setBushPerTonne(GrainDescriber(newGrainType).bushelsPerTonne.toFixed(2));
|
||||
}}
|
||||
group
|
||||
disabled={!canEdit}
|
||||
options={grainOptions}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{isCustom ? (
|
||||
<React.Fragment>
|
||||
<CustomGrainSelector initialGrain={bin.customGrain()} onGrainSettingsChange={settings => {setNewGrainSettings(settings)}}/>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<TextField
|
||||
sx={{marginTop: 2}}
|
||||
label="Grain Variant"
|
||||
value={grainSubtype}
|
||||
type="text"
|
||||
onChange={event => {
|
||||
setGrainSubtype(event.target.value);
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
disabled={!grainOption}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
close();
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
// disabled={grainErrorCheck()}
|
||||
onClick={() => {
|
||||
updateBinSettings();
|
||||
close()
|
||||
}}>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,12 +14,11 @@ import WheatImg from "assets/grain/wheat.jpg";
|
|||
import { Option } from "common/SearchSelect";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { Equation } from "./GrainMoisture";
|
||||
|
||||
export interface GrainExtension {
|
||||
name: string;
|
||||
group: string;
|
||||
equation: Equation;
|
||||
equation: pond.MoistureEquation;
|
||||
a: number;
|
||||
b: number;
|
||||
c: number;
|
||||
|
|
@ -38,7 +37,7 @@ const defaultSetTemp = 30.0;
|
|||
const defaultGrain: GrainExtension = {
|
||||
name: "None",
|
||||
group: "",
|
||||
equation: Equation.none,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_NONE,
|
||||
a: 0,
|
||||
b: 0,
|
||||
c: 0,
|
||||
|
|
@ -58,7 +57,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Custom Type",
|
||||
group: "",
|
||||
equation: Equation.none,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_NONE,
|
||||
a: 0,
|
||||
b: 0,
|
||||
c: 0,
|
||||
|
|
@ -75,7 +74,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Barley",
|
||||
group: "Barley",
|
||||
equation: Equation.chungPfost,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST,
|
||||
a: 475.12,
|
||||
b: 0.14843,
|
||||
c: 71.996,
|
||||
|
|
@ -93,7 +92,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Buckwheat",
|
||||
group: "Buckwheat",
|
||||
equation: Equation.chungPfost,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST,
|
||||
a: 103540000,
|
||||
b: 0.1646,
|
||||
c: 15853000,
|
||||
|
|
@ -111,7 +110,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Canola",
|
||||
group: "Canola",
|
||||
equation: Equation.halsey,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HALSEY,
|
||||
a: 3.489,
|
||||
b: -0.010553,
|
||||
c: 1.86,
|
||||
|
|
@ -130,7 +129,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Rapeseed",
|
||||
group: "Canola",
|
||||
equation: Equation.halsey,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HALSEY,
|
||||
a: 3.0026,
|
||||
b: -0.0048967,
|
||||
c: 1.7607,
|
||||
|
|
@ -148,7 +147,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Corn (Henderson)",
|
||||
group: "Corn",
|
||||
equation: Equation.henderson,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON,
|
||||
a: 0.000066612,
|
||||
b: 1.9677,
|
||||
c: 42.143,
|
||||
|
|
@ -166,7 +165,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Corn (Chung-Pfost)",
|
||||
group: "Corn",
|
||||
equation: Equation.chungPfost,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST,
|
||||
a: 374.34,
|
||||
b: 0.18662,
|
||||
c: 31.696,
|
||||
|
|
@ -184,7 +183,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Corn (Oswin)",
|
||||
group: "Corn",
|
||||
equation: Equation.oswin,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_OSWIN,
|
||||
a: 15.303,
|
||||
b: -0.10164,
|
||||
c: 3.0358,
|
||||
|
|
@ -202,7 +201,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Maize White",
|
||||
group: "Corn",
|
||||
equation: Equation.henderson,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON,
|
||||
a: 0.000066612,
|
||||
b: 1.9677,
|
||||
c: 70.143,
|
||||
|
|
@ -220,7 +219,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Maize Yellow",
|
||||
group: "Corn",
|
||||
equation: Equation.henderson,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON,
|
||||
a: 0.000066612,
|
||||
b: 1.9677,
|
||||
c: 65.143,
|
||||
|
|
@ -238,7 +237,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Oats (Henderson)",
|
||||
group: "Oats",
|
||||
equation: Equation.henderson,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON,
|
||||
a: 0.000085511,
|
||||
b: 2.0087,
|
||||
c: 37.811,
|
||||
|
|
@ -256,7 +255,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Oats (Chung-Pfost)",
|
||||
group: "Oats",
|
||||
equation: Equation.chungPfost,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST,
|
||||
a: 442.85,
|
||||
b: 0.21228,
|
||||
c: 35.803,
|
||||
|
|
@ -274,7 +273,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Oats (Oswin)",
|
||||
group: "Oats",
|
||||
equation: Equation.oswin,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_OSWIN,
|
||||
a: 12.412,
|
||||
b: -0.060707,
|
||||
c: 2.9397,
|
||||
|
|
@ -292,7 +291,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Peanuts",
|
||||
group: "Peanuts",
|
||||
equation: Equation.oswin,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_OSWIN,
|
||||
a: 8.6588,
|
||||
b: -0.057904,
|
||||
c: 2.6204,
|
||||
|
|
@ -310,7 +309,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Long Grain Rice",
|
||||
group: "Rice",
|
||||
equation: Equation.henderson,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON,
|
||||
a: 0.000041276,
|
||||
b: 2.1191,
|
||||
c: 49.828,
|
||||
|
|
@ -328,7 +327,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Medium Grain Rice",
|
||||
group: "Rice",
|
||||
equation: Equation.henderson,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON,
|
||||
a: 0.000035502,
|
||||
b: 2.31,
|
||||
c: 27.396,
|
||||
|
|
@ -346,7 +345,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Short Grain Rice",
|
||||
group: "Rice",
|
||||
equation: Equation.henderson,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON,
|
||||
a: 0.000048524,
|
||||
b: 2.0794,
|
||||
c: 45.646,
|
||||
|
|
@ -364,7 +363,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Sorghum",
|
||||
group: "Sorghum",
|
||||
equation: Equation.chungPfost,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST,
|
||||
a: 797.33,
|
||||
b: 0.18159,
|
||||
c: 52.238,
|
||||
|
|
@ -382,7 +381,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Soybeans",
|
||||
group: "Soybeans",
|
||||
equation: Equation.chungPfost,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST,
|
||||
a: 228.2,
|
||||
b: 0.2072,
|
||||
c: 30,
|
||||
|
|
@ -400,7 +399,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Sunflower",
|
||||
group: "Sunflower",
|
||||
equation: Equation.henderson,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON,
|
||||
a: 0.00031,
|
||||
b: 1.7459,
|
||||
c: 66.603,
|
||||
|
|
@ -418,7 +417,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Durum Wheat",
|
||||
group: "Wheat",
|
||||
equation: Equation.oswin,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_OSWIN,
|
||||
a: 13.101,
|
||||
b: -0.052626,
|
||||
c: 2.9987,
|
||||
|
|
@ -436,7 +435,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Hard Red Wheat",
|
||||
group: "Wheat",
|
||||
equation: Equation.chungPfost,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST,
|
||||
a: 610.34,
|
||||
b: 0.15526,
|
||||
c: 93.213,
|
||||
|
|
@ -454,7 +453,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Wheat SAWOS",
|
||||
group: "Wheat",
|
||||
equation: Equation.chungPfost,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST,
|
||||
a: 610.34,
|
||||
b: 0.15526,
|
||||
c: 93.213,
|
||||
|
|
@ -472,7 +471,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Un-retted Flax",
|
||||
group: "Flax",
|
||||
equation: Equation.halsey,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HALSEY,
|
||||
a: 5.11,
|
||||
b: Math.pow(-8.46 * 10, -3),
|
||||
c: 2.26,
|
||||
|
|
@ -490,7 +489,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Dew-retted Flax",
|
||||
group: "Flax",
|
||||
equation: Equation.oswin,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_OSWIN,
|
||||
a: 6.5,
|
||||
b: Math.pow(-1.68 * 10, -2),
|
||||
c: 3.2,
|
||||
|
|
@ -508,7 +507,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Yellow Peas",
|
||||
group: "Peas",
|
||||
equation: Equation.oswin,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_OSWIN,
|
||||
a: 14.81,
|
||||
b: -0.109,
|
||||
c: 3.019,
|
||||
|
|
@ -526,7 +525,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Blaze Lentils",
|
||||
group: "Lentils",
|
||||
equation: Equation.halsey,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HALSEY,
|
||||
a: 5.39,
|
||||
b: -0.015,
|
||||
c: 2.273,
|
||||
|
|
@ -544,7 +543,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Redberry Lentils",
|
||||
group: "Lentils",
|
||||
equation: Equation.halsey,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HALSEY,
|
||||
a: 4.749,
|
||||
b: -0.0116,
|
||||
c: 2.066,
|
||||
|
|
@ -562,7 +561,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Robin Lentils",
|
||||
group: "Lentils",
|
||||
equation: Equation.halsey,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HALSEY,
|
||||
a: 5.176,
|
||||
b: -0.0065,
|
||||
c: 2.337,
|
||||
|
|
@ -580,7 +579,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Dry Beans Red",
|
||||
group: "Dry Beans",
|
||||
equation: Equation.halsey,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HALSEY,
|
||||
a: 4.2669,
|
||||
b: -0.013382,
|
||||
c: 1.6933,
|
||||
|
|
@ -596,7 +595,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Dry Beans Black",
|
||||
group: "Dry Beans",
|
||||
equation: Equation.halsey,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HALSEY,
|
||||
a: 5.2003,
|
||||
b: -0.022685,
|
||||
c: 1.9656,
|
||||
|
|
@ -612,7 +611,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Rye (Henderson)",
|
||||
group: "Rye",
|
||||
equation: Equation.henderson,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON,
|
||||
a: 0.00006343,
|
||||
b: 2.2060,
|
||||
c: 13.1810,
|
||||
|
|
@ -628,7 +627,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Rye (Chung-Pfost)",
|
||||
group: "Rye",
|
||||
equation: Equation.chungPfost,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST,
|
||||
a: 461.0230,
|
||||
b: 0.1840,
|
||||
c: 36.7410,
|
||||
|
|
@ -644,7 +643,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Rye (Halsey)",
|
||||
group: "Rye",
|
||||
equation: Equation.halsey,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_HALSEY,
|
||||
a: 4.2970,
|
||||
b: 0.380,
|
||||
c: 2.2710,
|
||||
|
|
@ -660,7 +659,7 @@ export const GrainExtensions: Map<pond.Grain, GrainExtension> = new Map([
|
|||
{
|
||||
name: "Rye (Oswin)",
|
||||
group: "Rye",
|
||||
equation: Equation.oswin,
|
||||
equation: pond.MoistureEquation.MOISTURE_EQUATION_OSWIN,
|
||||
a: 11.8870,
|
||||
b: 0.0210,
|
||||
c: 3.2620,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,6 @@
|
|||
import { pond } from "protobuf-ts/pond";
|
||||
import GrainDescriber from "./GrainDescriber";
|
||||
|
||||
export enum Equation {
|
||||
"none",
|
||||
"oswin",
|
||||
"halsey",
|
||||
"henderson",
|
||||
"chungPfost"
|
||||
}
|
||||
|
||||
const toERH = (humidity: number): number => {
|
||||
return humidity >= 100 ? 0.99999 : humidity / 100;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ interface Props {
|
|||
asDestination?: boolean;
|
||||
restrictMatching?: boolean;
|
||||
open: boolean;
|
||||
close: () => void;
|
||||
close: (confirmed?: boolean) => void;
|
||||
callback?: (newTransaction?: pond.Transaction) => void;
|
||||
allowAttachmentUploads?: boolean;
|
||||
}
|
||||
|
|
@ -82,7 +82,7 @@ export default function GrainTransaction(props: Props) {
|
|||
const [filePermission, setFilePermission] = useState(false);
|
||||
|
||||
const closeDialogs = (confirmed: boolean, newTransaction?: pond.Transaction) => {
|
||||
close();
|
||||
close(confirmed);
|
||||
setGrainChangeVal("0");
|
||||
setGrainEntry("0");
|
||||
setIsObject("");
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export class Ambient {
|
|||
public status: pond.ComponentStatus = pond.ComponentStatus.create();
|
||||
public temperature: number = -127;
|
||||
public humidity: number = 200;
|
||||
public lastReading: string = ""
|
||||
|
||||
public static create(comp: Component): Ambient {
|
||||
let my = new Ambient();
|
||||
|
|
@ -18,6 +19,9 @@ export class Ambient {
|
|||
|
||||
if (comp.status.measurement.length > 0) {
|
||||
comp.status.measurement.forEach(um => {
|
||||
if (um.timestamps[0]){
|
||||
my.lastReading = um.timestamps[0]
|
||||
}
|
||||
if (um.values[0]) {
|
||||
if (um.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) {
|
||||
my.temperature = or(um.values[0].values[0], -127);
|
||||
|
|
@ -39,6 +43,7 @@ export class Ambient {
|
|||
hum = humAndTemp["relativeHumidityTimes100"];
|
||||
}
|
||||
}
|
||||
my.lastReading = comp.status.lastMeasurement?.timestamp ?? ""
|
||||
my.temperature = or(temp, -1270) / 10;
|
||||
my.humidity = or(hum, 20000) / 100;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,34 @@ export class Bin {
|
|||
return this.settings.specs?.shape;
|
||||
}
|
||||
|
||||
public diameter(): number {
|
||||
return this.settings.specs?.diameterCm ?? 0;
|
||||
}
|
||||
|
||||
public height(): number {
|
||||
return this.settings.specs?.heightCm ?? 0;
|
||||
}
|
||||
|
||||
public sidewallHeight(): number {
|
||||
return this.settings.specs?.advancedDimensions?.sidewallHeight ?? 0;
|
||||
}
|
||||
|
||||
public roofHeight() : number {
|
||||
return this.settings.specs?.advancedDimensions?.topConeHeight ?? 0;
|
||||
}
|
||||
|
||||
public hopperHeight() : number {
|
||||
return this.settings.specs?.advancedDimensions?.hopperHeight ?? 0;
|
||||
}
|
||||
|
||||
public roofAngle() : number {
|
||||
return this.settings.specs?.advancedDimensions?.roofAngle ?? 0;
|
||||
}
|
||||
|
||||
public hopperAngle() : number {
|
||||
return this.settings.specs?.advancedDimensions?.hopperAngle ?? 0;
|
||||
}
|
||||
|
||||
public key(): string {
|
||||
return this.settings.key;
|
||||
}
|
||||
|
|
@ -134,6 +162,11 @@ export class Bin {
|
|||
return colour;
|
||||
}
|
||||
|
||||
/**
|
||||
* this function returns the number of bushels in the bin, if using the automatic lidar or libracart to control inventory it will use the bushels in status
|
||||
* otherwise will use the bushels in the inventory found in settings
|
||||
* @returns (number) the current bushels in the bin
|
||||
*/
|
||||
public bushels(): number {
|
||||
let control = this.settings.inventory?.inventoryControl;
|
||||
let bushels = this.settings.inventory?.grainBushels || 0
|
||||
|
|
@ -144,6 +177,14 @@ export class Bin {
|
|||
return bushels
|
||||
}
|
||||
|
||||
/**
|
||||
* this function returns the bushelCapacity in the bins specs as it is stored or 0 if the specs are undefined
|
||||
* @returns (number) the capacity of the bin in bushels
|
||||
*/
|
||||
public bushelCapacity(): number {
|
||||
return this.settings.specs?.bushelCapacity ?? 0
|
||||
}
|
||||
|
||||
public fillPercent(): number {
|
||||
let fill = 0;
|
||||
if (this.settings.inventory && this.settings.specs) {
|
||||
|
|
@ -162,7 +203,12 @@ export class Bin {
|
|||
public grainName(): string {
|
||||
if (this.grain() !== pond.Grain.GRAIN_INVALID) {
|
||||
if (this.grain() === pond.Grain.GRAIN_CUSTOM) {
|
||||
return this.customType();
|
||||
//this will prioritize the new style of custom grain types
|
||||
let cg = this.customGrain()
|
||||
if(cg !== undefined){
|
||||
return cg.name
|
||||
}
|
||||
return this.customType(); //this is still here for bins that are using the old custom grain where it was just a string
|
||||
} else {
|
||||
return GrainDescriber(this.grain()).name;
|
||||
}
|
||||
|
|
@ -171,6 +217,18 @@ export class Bin {
|
|||
}
|
||||
}
|
||||
|
||||
public grainGroup(): string {
|
||||
if (this.grain() !== pond.Grain.GRAIN_INVALID) {
|
||||
let cg = this.customGrain()
|
||||
if (this.grain() === pond.Grain.GRAIN_CUSTOM && cg) {
|
||||
return cg.group
|
||||
} else {
|
||||
return GrainDescriber(this.grain()).group;
|
||||
}
|
||||
}
|
||||
return "None";
|
||||
}
|
||||
|
||||
public binFillCap(): string {
|
||||
let fillCap = "";
|
||||
if (this.settings.specs && this.settings.inventory) {
|
||||
|
|
@ -236,6 +294,12 @@ export class Bin {
|
|||
return bpt;
|
||||
}
|
||||
|
||||
/**
|
||||
* this function returns a bins stored inventory in the unit of the passed in user,
|
||||
* it can return it in bushels, US tons, or metric Tonnes
|
||||
* @param user the user object
|
||||
* @returns (number) bins current stored inventory
|
||||
*/
|
||||
public grainInventory(user: User): number {
|
||||
let grain = this.bushels()
|
||||
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && this.bushelsPerTonne() > 1){
|
||||
|
|
@ -246,6 +310,22 @@ export class Bin {
|
|||
return Math.round(grain*100)/100
|
||||
}
|
||||
|
||||
/**
|
||||
* this function returns a bins capacity in the unit of the passed in user,
|
||||
* it can return it in bushels, US tons, or metric Tonnes
|
||||
* @param user the user object
|
||||
* @returns (number) bins max capacity
|
||||
*/
|
||||
public grainCapacity(user: User): number {
|
||||
let capacity = this.bushelCapacity()
|
||||
if(user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TONNE && this.bushelsPerTonne() > 1){
|
||||
capacity = capacity / this.bushelsPerTonne()
|
||||
}else if (user.grainUnit() === pond.GrainUnit.GRAIN_UNIT_TON && this.bushelsPerTonne() > 1){
|
||||
capacity = capacity / (this.bushelsPerTonne() * 0.907)
|
||||
}
|
||||
return Math.round(capacity*100)/100
|
||||
}
|
||||
|
||||
/**
|
||||
* gets the enum value for the inventory control in the bins inventory
|
||||
*/
|
||||
|
|
@ -256,4 +336,17 @@ export class Bin {
|
|||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
//changed this to just use the target temp rather than the upper limit as we are deprecating the limits
|
||||
public targetTemp(): number {
|
||||
return this.settings.inventory?.targetTemperature ?? 0
|
||||
}
|
||||
|
||||
public lowerTempThreshold(): number {
|
||||
return this.settings.lowTemp
|
||||
}
|
||||
|
||||
public targetMoisture(): number {
|
||||
return this.settings.inventory?.targetMoisture ?? 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,13 +9,30 @@ export class Controller {
|
|||
public settings: pond.ComponentSettings = pond.ComponentSettings.create();
|
||||
public status: pond.ComponentStatus = pond.ComponentStatus.create();
|
||||
public on = false;
|
||||
public mode: quack.OutputMode | undefined
|
||||
public lastReading: string = "";
|
||||
|
||||
public static create(comp: Component): Controller {
|
||||
let my = new Controller();
|
||||
my.settings = comp.settings;
|
||||
my.status = comp.status;
|
||||
my.mode = comp.settings.defaultOutputState
|
||||
|
||||
my.on = Boolean(my.status.lastMeasurement?.measurement?.booleanOutput?.value);
|
||||
//this is deprecated because it is our old measurement structure
|
||||
// my.on = Boolean(my.status.lastMeasurement?.measurement?.booleanOutput?.value);
|
||||
|
||||
if (comp.status.lastGoodMeasurement.length > 0) {
|
||||
comp.status.measurement.forEach(um => {
|
||||
if (um.timestamps[0]){
|
||||
my.lastReading = um.timestamps[0]
|
||||
}
|
||||
if (um.values[0]) {
|
||||
if (um.type === quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN) {
|
||||
my.on = Boolean(um.values[0].values[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return my;
|
||||
}
|
||||
|
|
@ -25,6 +42,7 @@ export class Controller {
|
|||
my.settings = comp.settings ? comp.settings : pond.ComponentSettings.create();
|
||||
my.status = comp.status ? comp.status : pond.ComponentStatus.create();
|
||||
|
||||
|
||||
my.on = Boolean(my.status.lastMeasurement?.measurement?.booleanOutput?.value);
|
||||
|
||||
return my;
|
||||
|
|
|
|||
|
|
@ -132,21 +132,43 @@ export class GrainCable {
|
|||
return describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour();
|
||||
}
|
||||
|
||||
public minTemp(unit = pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS) {
|
||||
public minTemp(unit = pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS, inGrain?: boolean) {
|
||||
if(inGrain){
|
||||
let grainMax = Math.min(...this.filteredNodes(true).temps)
|
||||
if (unit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT)
|
||||
return this.farenheit(grainMax)
|
||||
return grainMax
|
||||
}
|
||||
if (unit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT)
|
||||
return this.farenheit(Math.min(...this.temperatures));
|
||||
return Math.min(...this.temperatures);
|
||||
}
|
||||
|
||||
public minHumidity() {
|
||||
public minHumidity(inGrain?: boolean) {
|
||||
if(inGrain){
|
||||
return Math.max(...this.filteredNodes(true).humids)
|
||||
}
|
||||
return Math.min(...this.humidities);
|
||||
}
|
||||
|
||||
public minMoisture() {
|
||||
public minMoisture(inGrain?: boolean) {
|
||||
if(inGrain){
|
||||
return Math.min(...this.filteredNodes(true).moistures)
|
||||
}
|
||||
return Math.min(...this.grainMoistures);
|
||||
}
|
||||
|
||||
public aveTemp(unit = pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS) {
|
||||
/**
|
||||
* Averages the temperatures of the grain cable, if filtered is true it will filter the excluded nodes and nodes above the top node before averaging
|
||||
* @param unit the unit of the temperature
|
||||
* @param inGrain whether to filter the excluded nodes and nodes above the top node before averaging
|
||||
* @returns
|
||||
*/
|
||||
public aveTemp(unit = pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS, inGrain?: boolean) {
|
||||
if(inGrain){
|
||||
let filteredNodes = this.filteredNodes(true)
|
||||
return filteredNodes.temps.reduce((p: any, c: any) => p + c, 0) / filteredNodes.temps.length
|
||||
}
|
||||
if (unit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT)
|
||||
return this.farenheit(
|
||||
this.temperatures.reduce((p: any, c: any) => p + c, 0) / this.temperatures.length
|
||||
|
|
@ -154,21 +176,45 @@ export class GrainCable {
|
|||
return this.temperatures.reduce((p: any, c: any) => p + c, 0) / this.temperatures.length;
|
||||
}
|
||||
|
||||
public aveHumidity() {
|
||||
public aveHumidity(inGrain?: boolean) {
|
||||
if(inGrain){
|
||||
let filteredNodes = this.filteredNodes(true)
|
||||
return filteredNodes.humids.reduce((p: any, c: any) => p + c, 0) / filteredNodes.humids.length
|
||||
}
|
||||
return this.humidities.reduce((p: any, c: any) => p + c, 0) / this.humidities.length;
|
||||
}
|
||||
|
||||
public maxTemp(unit = pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS) {
|
||||
public aveMoisture(inGrain?: boolean) {
|
||||
if(inGrain){
|
||||
let filteredNodes = this.filteredNodes(true)
|
||||
return filteredNodes.moistures.reduce((p: any, c: any) => p + c, 0) / filteredNodes.moistures.length
|
||||
}
|
||||
return this.grainMoistures.reduce((p: any, c: any) => p + c, 0) / this.grainMoistures.length;
|
||||
}
|
||||
|
||||
public maxTemp(unit = pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS, inGrain?: boolean) {
|
||||
if(inGrain){
|
||||
let grainMax = Math.max(...this.filteredNodes(true).temps)
|
||||
if (unit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT)
|
||||
return this.farenheit(grainMax)
|
||||
return grainMax
|
||||
}
|
||||
if (unit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT)
|
||||
return this.farenheit(Math.max(...this.temperatures));
|
||||
return Math.max(...this.temperatures);
|
||||
}
|
||||
|
||||
public maxHumidity() {
|
||||
public maxHumidity(inGrain?: boolean) {
|
||||
if(inGrain){
|
||||
return Math.max(...this.filteredNodes(true).humids)
|
||||
}
|
||||
return Math.max(...this.humidities);
|
||||
}
|
||||
|
||||
public maxMoisture() {
|
||||
public maxMoisture(inGrain?: boolean) {
|
||||
if(inGrain){
|
||||
return Math.max(...this.filteredNodes(true).moistures)
|
||||
}
|
||||
return Math.min(...this.grainMoistures);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export class Headspace {
|
|||
public status: pond.ComponentStatus = pond.ComponentStatus.create();
|
||||
public temperature: number = -127;
|
||||
public humidity: number = 200;
|
||||
public lastReading: string = "";
|
||||
|
||||
public static create(comp: Component): Headspace {
|
||||
let my = new Headspace();
|
||||
|
|
@ -18,6 +19,9 @@ export class Headspace {
|
|||
|
||||
if (comp.status.measurement.length > 0) {
|
||||
comp.status.measurement.forEach(um => {
|
||||
if (um.timestamps[0]){
|
||||
my.lastReading = um.timestamps[0]
|
||||
}
|
||||
if (um.values[0]) {
|
||||
if (um.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) {
|
||||
my.temperature = um.values[0].values[0];
|
||||
|
|
@ -39,6 +43,7 @@ export class Headspace {
|
|||
hum = humAndTemp["relativeHumidityTimes100"];
|
||||
}
|
||||
}
|
||||
my.lastReading = comp.status.lastMeasurement?.timestamp ?? ""
|
||||
my.temperature = or(temp, -1270) / 10;
|
||||
my.humidity = or(hum, 20000) / 100;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export class Plenum {
|
|||
public status: pond.ComponentStatus = pond.ComponentStatus.create();
|
||||
public temperature: number = -127;
|
||||
public humidity: number = 200;
|
||||
public lastReading: string = ""
|
||||
|
||||
public static create(comp: Component): Plenum {
|
||||
let my = new Plenum();
|
||||
|
|
@ -18,6 +19,9 @@ export class Plenum {
|
|||
|
||||
if (comp.status.measurement.length > 0) {
|
||||
comp.status.measurement.forEach(um => {
|
||||
if (um.timestamps[0]) {
|
||||
my.lastReading = um.timestamps[0];
|
||||
}
|
||||
if (um.values[0]) {
|
||||
if (um.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) {
|
||||
my.temperature = or(um.values[0].values[0], -127);
|
||||
|
|
@ -41,6 +45,7 @@ export class Plenum {
|
|||
}
|
||||
my.temperature = or(temp, -1270) / 10;
|
||||
my.humidity = or(hum, 20000) / 100;
|
||||
my.lastReading = comp.status.lastMeasurement?.timestamp ?? ""
|
||||
}
|
||||
|
||||
return my;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ export class Pressure {
|
|||
public settings: pond.ComponentSettings = pond.ComponentSettings.create();
|
||||
public status: pond.ComponentStatus = pond.ComponentStatus.create();
|
||||
public pascals: number = 0;
|
||||
public airflow: number | undefined
|
||||
public fanId: number = 0;
|
||||
public lastReading: string = ""
|
||||
|
||||
public static create(comp: Component): Pressure {
|
||||
let my = new Pressure();
|
||||
|
|
@ -19,19 +21,23 @@ export class Pressure {
|
|||
//getting the value from the unitmeasurements in status instead of the old style measurements in the status
|
||||
if (comp.status.measurement.length > 0) {
|
||||
comp.status.measurement.forEach(um => {
|
||||
if (um.timestamps[0]){
|
||||
my.lastReading = um.timestamps[0]
|
||||
}
|
||||
if (um.values[0] && um.values[0].values.length > 0) {
|
||||
if (um.type === quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE) {
|
||||
my.pascals = um.values[0].values[0];
|
||||
}
|
||||
//TODO-CS: could expand this to have the fan cfm in the pressure model as well
|
||||
// if (um.type === quack.MeasurementType.MEASUREMENT_TYPE_CFM){
|
||||
// my.fanCFM = um.values[0].values[0]
|
||||
// }
|
||||
if (um.type === quack.MeasurementType.MEASUREMENT_TYPE_CFM){
|
||||
my.airflow = um.values[0].values[0]
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
//if no unit measurements in status use the old measurements in status
|
||||
let pre = comp.status?.lastMeasurement?.measurement?.pressure?.pascals;
|
||||
my.lastReading = comp.status.lastMeasurement?.timestamp ?? ""
|
||||
if (pre) {
|
||||
my.pascals = pre;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const Teams = lazy(() => import("pages/Teams"));
|
|||
const Users = lazy(() => import("pages/Users"));
|
||||
const TeamPage = lazy(() => import("pages/Team"));
|
||||
const BinPage = lazy(() => import("pages/Bin"));
|
||||
const BinPageV2 = lazy(()=> import("pages/BinV2"));
|
||||
const Bins = lazy(() => import("pages/Bins"));
|
||||
const Mines = lazy(() => import("pages/Mines"));
|
||||
const DeviceComponent = lazy(() => import("pages/DeviceComponent"));
|
||||
|
|
@ -139,10 +140,17 @@ export default function Router() {
|
|||
path="" // "/settings/basic"
|
||||
element={<Bins key={"Bins page"} />}
|
||||
/>
|
||||
{user.hasFeature("beta") ?
|
||||
<Route
|
||||
path="/:binID" // "/settings/basic"
|
||||
element={<BinPageV2 />}
|
||||
/>
|
||||
:
|
||||
<Route
|
||||
path="/:binID" // "/settings/basic"
|
||||
element={<BinPage />}
|
||||
/>
|
||||
}
|
||||
{/* <Route
|
||||
path="/:deviceID/components/:componentID" // "/settings/basic"
|
||||
element={<DeviceComponent />}
|
||||
|
|
|
|||
|
|
@ -179,7 +179,6 @@ export default function NewObjectInteraction(props: Props){
|
|||
setNewAlertComponentType(quack.ComponentType.COMPONENT_TYPE_INVALID);
|
||||
setConditions([]);
|
||||
setSelectedAlertComponents([]);
|
||||
setValidComponents([])
|
||||
setValStrings(["0", "0"]);
|
||||
setNumConditions(0)
|
||||
setNodeOptions([]);
|
||||
|
|
@ -190,7 +189,6 @@ export default function NewObjectInteraction(props: Props){
|
|||
setResultType(quack.InteractionResultType.INTERACTION_RESULT_TYPE_REPORT)
|
||||
setSinkMotor(false)
|
||||
setSelectedSink("")//the key of the component to be the sink
|
||||
setSinkOptions([])
|
||||
setResultMode(0)
|
||||
setResultValue("")
|
||||
setDutyCycleEnabled(false)
|
||||
|
|
@ -1018,6 +1016,7 @@ export default function NewObjectInteraction(props: Props){
|
|||
displayEmpty
|
||||
value={newAlertComponentType}
|
||||
onChange={e => {
|
||||
console.log("type change")
|
||||
setNewAlertComponentType(e.target.value as quack.ComponentType);
|
||||
setSelectedAlertComponents([]);
|
||||
setConditions(initialConditions(e.target.value as quack.ComponentType));
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ interface Props {
|
|||
permissions: pond.Permission[]
|
||||
}
|
||||
|
||||
/**
|
||||
* combines the alerts and the controls together to display them simultaneously, if you just want one or the other just use the specific component you want
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
export default function ObjectInteractions(props: Props) {
|
||||
const { linkedComponents, componentDevices, devices, objectKey, objectType, permissions } = props
|
||||
const [{as}] = useGlobalState()
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ import {
|
|||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Typography,
|
||||
TextField,
|
||||
RadioGroup,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
} from "@mui/material";
|
||||
import BinActions from "bin/BinActions";
|
||||
import BinHistory from "bin/BinHistory";
|
||||
|
|
|
|||
563
src/pages/BinV2.tsx
Normal file
563
src/pages/BinV2.tsx
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
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, useSnackbar } 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, CircularProgress, Drawer, Grid2 as Grid, IconButton, ListItemIcon, ListItemText, Menu, MenuItem, Tab, Tabs, 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";
|
||||
import { Close } from "@mui/icons-material";
|
||||
import React from "react";
|
||||
import BinHistory from "bin/BinHistory";
|
||||
import Chat from "chat/Chat";
|
||||
import TaskViewer from "tasks/TaskViewer";
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: any;
|
||||
value: any;
|
||||
}
|
||||
|
||||
function TabPanelMine(props: TabPanelProps) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
aria-labelledby={`simple-tab-${index}`}
|
||||
{...other}>
|
||||
{value === index && <React.Fragment>{children}</React.Fragment>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => {
|
||||
const themeType = theme.palette.mode;
|
||||
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
|
||||
},
|
||||
menuPaper: {
|
||||
borderRadius: "20px"
|
||||
},
|
||||
drawerPaperDesktop: {
|
||||
height: "100%",
|
||||
width: "30%"
|
||||
},
|
||||
drawerPaperMobile: {
|
||||
height: "50%",
|
||||
width: "100%"
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
export default function BinV2(){
|
||||
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 {openSnack} = useSnackbar()
|
||||
const [fans, setFans] = useState<Controller[]>([]);
|
||||
// const [compositionNameMap, setCompositionNameMap] = useState<Map<string, string>>(new Map());
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [noteTab, setNoteTab] = useState(0)
|
||||
|
||||
|
||||
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);
|
||||
|
||||
//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
|
||||
*/
|
||||
|
||||
const deviceMenu = () => {
|
||||
return (
|
||||
<Menu
|
||||
id="groupMenu"
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
keepMounted
|
||||
classes={{ paper: classes.menuPaper }}
|
||||
disableAutoFocusItem>
|
||||
{devices.map(dev => (
|
||||
<MenuItem key={dev.id()} onClick={() => goToDevice(dev)}>
|
||||
<ListItemIcon>
|
||||
<BindaptIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={dev.name()} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 margin={2} marginBottom={0}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
const handleChange = (_event: React.ChangeEvent<{}>, newValue: number) => {
|
||||
setNoteTab(newValue);
|
||||
};
|
||||
|
||||
|
||||
//DRAWERS
|
||||
const noteDrawer = () => {
|
||||
return (
|
||||
<Drawer
|
||||
open={noteDrawerOpen}
|
||||
anchor={isMobile ? "bottom" : "right"}
|
||||
classes={{ paper: isMobile ? classes.drawerPaperMobile : classes.drawerPaperDesktop }}
|
||||
onClose={() => setNoteDrawerOpen(false)}>
|
||||
<IconButton style={{ width: 50 }} onClick={() => setNoteDrawerOpen(false)}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
<Tabs
|
||||
value={noteTab}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
onChange={handleChange}
|
||||
aria-label="disabled tabs example"
|
||||
// centered={!(isMobile || displayMobile)}
|
||||
style={{
|
||||
marginLeft: "auto"
|
||||
//marginTop: isMobile || displayMobile || drawer ? "-3rem" : "0.25rem"
|
||||
}}>
|
||||
<Tab label="Notes" />
|
||||
<Tab label="History" />
|
||||
</Tabs>
|
||||
<TabPanelMine value={noteTab} index={0}>
|
||||
{/* <Box height={isMobile || displayMobile ? "80vh" : "90vh"} padding={2}> */}
|
||||
<Chat parent={binID} parentType={"bin"} type={pond.NoteType.NOTE_TYPE_BIN} />
|
||||
{/* </Box> */}
|
||||
</TabPanelMine>
|
||||
<TabPanelMine value={noteTab} index={1}>
|
||||
<BinHistory drawer binID={binID} />
|
||||
</TabPanelMine>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
const taskDrawer = () => {
|
||||
return (
|
||||
<Drawer
|
||||
style={{ zIndex: 1099 }}
|
||||
open={taskDrawerOpen}
|
||||
anchor={isMobile ? "bottom" : "right"}
|
||||
classes={{ paper: isMobile ? classes.drawerPaperMobile : classes.drawerPaperDesktop }}
|
||||
onClose={() => setTaskDrawerOpen(false)}>
|
||||
<Box>
|
||||
<IconButton style={{ width: 50 }} onClick={() => setTaskDrawerOpen(false)}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
<TaskViewer drawerView keys={[binID]} types={["bin"]} overlayButton />
|
||||
</Box>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
{deviceMenu()}
|
||||
{pageHeader()}
|
||||
<BinSummary
|
||||
bin={bin}
|
||||
loading={binLoading}
|
||||
devices={devices}
|
||||
fans={fans}
|
||||
heaters={heaters}
|
||||
cables={grainCables}
|
||||
plenums={plenums}
|
||||
permissions={permissions}
|
||||
componentDevices={componentDevices}
|
||||
componentMap={components}
|
||||
binPrefs={preferences}
|
||||
setPreferences={setPreferences}
|
||||
updateBinCallback={(bin) => {
|
||||
setBin(bin)
|
||||
binAPI.updateBin(bin.key(), bin.settings).then(resp => {
|
||||
openSnack("Bin has Been Updated")
|
||||
})
|
||||
}}
|
||||
setBin={setBin}
|
||||
/>
|
||||
{/* render drawers */}
|
||||
{noteDrawer()}
|
||||
{taskDrawer()}
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
|
|
@ -65,10 +65,20 @@ export function stringToComponentId(componentIDString: string): quack.ComponentI
|
|||
return componentID;
|
||||
}
|
||||
|
||||
const componentIDFragments = componentIDString.split("-", 3);
|
||||
//first split on a colon to get the mux line seperated
|
||||
const componentSplit = componentIDString.split(":", 2);
|
||||
if(componentSplit[1]){
|
||||
componentID.muxLine = Number(componentSplit[1])
|
||||
}
|
||||
|
||||
const componentIDFragments = componentIDString.split("-", 4);
|
||||
let type: string = quack.ComponentType[Number(componentIDFragments[0])];
|
||||
let addressType: string = quack.AddressType[Number(componentIDFragments[1])];
|
||||
let address: number = Number(componentIDFragments[2]);
|
||||
if(componentIDFragments[3]){
|
||||
componentID.expansionLine = Number(componentIDFragments[3])
|
||||
}
|
||||
|
||||
componentID.type = quack.ComponentType[type as keyof typeof quack.ComponentType];
|
||||
componentID.addressType = quack.AddressType[addressType as keyof typeof quack.AddressType];
|
||||
componentID.address = address;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ import {
|
|||
Weight,
|
||||
CapacitorCable,
|
||||
AnalogPressure
|
||||
Plenum,
|
||||
AmbientOnewire,
|
||||
AmbientI2C
|
||||
} from "pbHelpers/ComponentTypes";
|
||||
//import { multilineGrainCableData } from "pbHelpers/ComponentTypes/GrainCable";
|
||||
//import { multilineCapCableData } from "./ComponentTypes/CapacitorCable";
|
||||
|
|
@ -100,7 +103,11 @@ const COMPONENT_TYPE_MAP = new Map<quack.ComponentType, Function>([
|
|||
[quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE, dragerGasDongle],
|
||||
[quack.ComponentType.COMPONENT_TYPE_AIRFLOW, Airflow],
|
||||
[quack.ComponentType.COMPONENT_TYPE_TEMP_HUMIDITY, TempHumidity],
|
||||
[quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, AnalogPressure]
|
||||
[quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, AnalogPressure],
|
||||
[quack.ComponentType.COMPONENT_TYPE_PLENUM, Plenum],
|
||||
[quack.ComponentType.COMPONENT_TYPE_AMBIENT_ONEWIRE, AmbientOnewire],
|
||||
[quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C, AmbientI2C],
|
||||
|
||||
]);
|
||||
|
||||
export interface Subtype {
|
||||
|
|
|
|||
79
src/pbHelpers/ComponentTypes/AmbientI2C.ts
Normal file
79
src/pbHelpers/ComponentTypes/AmbientI2C.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import TemperatureHumidityDarkIcon from "assets/components/temperatureHumidityDark.png";
|
||||
import TemperatureHumidityLightIcon from "assets/components/temperatureHumidityLight.png";
|
||||
import { convertedUnitMeasurement } from "models/UnitMeasurement";
|
||||
import {
|
||||
AreaChartData,
|
||||
ComponentTypeExtension,
|
||||
GraphFilters,
|
||||
LineChartData,
|
||||
simpleAreaChartData,
|
||||
simpleLineChartData,
|
||||
simpleMeasurements,
|
||||
simpleSummaries,
|
||||
Summary,
|
||||
unitMeasurementSummaries
|
||||
} from "pbHelpers/ComponentType";
|
||||
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { quack } from "protobuf-ts/quack";
|
||||
|
||||
export function AmbientI2C(subtype: number = 0): ComponentTypeExtension {
|
||||
let temperature = describeMeasurement(
|
||||
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
|
||||
quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C,
|
||||
subtype
|
||||
);
|
||||
let humidity = describeMeasurement(
|
||||
quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
|
||||
quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C,
|
||||
subtype
|
||||
);
|
||||
return {
|
||||
type: quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C,
|
||||
subtypes: [],
|
||||
friendlyName: "Ambient I2C",
|
||||
description: "Measures temperature and humidity",
|
||||
isController: false,
|
||||
isSource: true,
|
||||
isCalibratable: true,
|
||||
addressTypes: [quack.AddressType.ADDRESS_TYPE_I2C],
|
||||
interactionResultTypes: [],
|
||||
states: [],
|
||||
measurements: simpleMeasurements(temperature, humidity),
|
||||
measurementSummary: async function(measurement: quack.Measurement): Promise<Array<Summary>> {
|
||||
return simpleSummaries(measurement, temperature, humidity);
|
||||
},
|
||||
unitMeasurementSummary: (
|
||||
measurements: convertedUnitMeasurement
|
||||
): Summary[] => {
|
||||
return unitMeasurementSummaries(
|
||||
measurements,
|
||||
quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C,
|
||||
subtype
|
||||
);
|
||||
},
|
||||
areaChartData: (
|
||||
measurement: pond.UnitMeasurementsForComponent,
|
||||
smoothingAverages?: number,
|
||||
filters?: GraphFilters
|
||||
): AreaChartData => {
|
||||
return simpleAreaChartData(measurement, smoothingAverages, filters);
|
||||
},
|
||||
lineChartData: (
|
||||
measurement: pond.UnitMeasurementsForComponent,
|
||||
smoothingAverages?: number,
|
||||
filters?: GraphFilters
|
||||
): LineChartData => {
|
||||
return simpleLineChartData(
|
||||
quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C,
|
||||
measurement,
|
||||
smoothingAverages,
|
||||
filters
|
||||
);
|
||||
},
|
||||
minMeasurementPeriodMs: 1000,
|
||||
icon: (theme?: "light" | "dark"): string | undefined => {
|
||||
return theme === "light" ? TemperatureHumidityDarkIcon : TemperatureHumidityLightIcon;
|
||||
}
|
||||
};
|
||||
}
|
||||
79
src/pbHelpers/ComponentTypes/AmbientOnewire.ts
Normal file
79
src/pbHelpers/ComponentTypes/AmbientOnewire.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import TemperatureHumidityDarkIcon from "assets/components/temperatureHumidityDark.png";
|
||||
import TemperatureHumidityLightIcon from "assets/components/temperatureHumidityLight.png";
|
||||
import { convertedUnitMeasurement } from "models/UnitMeasurement";
|
||||
import {
|
||||
AreaChartData,
|
||||
ComponentTypeExtension,
|
||||
GraphFilters,
|
||||
LineChartData,
|
||||
simpleAreaChartData,
|
||||
simpleLineChartData,
|
||||
simpleMeasurements,
|
||||
simpleSummaries,
|
||||
Summary,
|
||||
unitMeasurementSummaries
|
||||
} from "pbHelpers/ComponentType";
|
||||
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { quack } from "protobuf-ts/quack";
|
||||
|
||||
export function AmbientOnewire(subtype: number = 0): ComponentTypeExtension {
|
||||
let temperature = describeMeasurement(
|
||||
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
|
||||
quack.ComponentType.COMPONENT_TYPE_AMBIENT_ONEWIRE,
|
||||
subtype
|
||||
);
|
||||
let humidity = describeMeasurement(
|
||||
quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
|
||||
quack.ComponentType.COMPONENT_TYPE_AMBIENT_ONEWIRE,
|
||||
subtype
|
||||
);
|
||||
return {
|
||||
type: quack.ComponentType.COMPONENT_TYPE_AMBIENT_ONEWIRE,
|
||||
subtypes: [],
|
||||
friendlyName: "Onewire Ambient",
|
||||
description: "Measures temperature and humidity",
|
||||
isController: false,
|
||||
isSource: true,
|
||||
isCalibratable: true,
|
||||
addressTypes: [quack.AddressType.ADDRESS_TYPE_CONFIGURABLE_PIN_ARRAY],
|
||||
interactionResultTypes: [],
|
||||
states: [],
|
||||
measurements: simpleMeasurements(temperature, humidity),
|
||||
measurementSummary: async function(measurement: quack.Measurement): Promise<Array<Summary>> {
|
||||
return simpleSummaries(measurement, temperature, humidity);
|
||||
},
|
||||
unitMeasurementSummary: (
|
||||
measurements: convertedUnitMeasurement
|
||||
): Summary[] => {
|
||||
return unitMeasurementSummaries(
|
||||
measurements,
|
||||
quack.ComponentType.COMPONENT_TYPE_AMBIENT_ONEWIRE,
|
||||
subtype
|
||||
);
|
||||
},
|
||||
areaChartData: (
|
||||
measurement: pond.UnitMeasurementsForComponent,
|
||||
smoothingAverages?: number,
|
||||
filters?: GraphFilters
|
||||
): AreaChartData => {
|
||||
return simpleAreaChartData(measurement, smoothingAverages, filters);
|
||||
},
|
||||
lineChartData: (
|
||||
measurement: pond.UnitMeasurementsForComponent,
|
||||
smoothingAverages?: number,
|
||||
filters?: GraphFilters
|
||||
): LineChartData => {
|
||||
return simpleLineChartData(
|
||||
quack.ComponentType.COMPONENT_TYPE_AMBIENT_ONEWIRE,
|
||||
measurement,
|
||||
smoothingAverages,
|
||||
filters
|
||||
);
|
||||
},
|
||||
minMeasurementPeriodMs: 1000,
|
||||
icon: (theme?: "light" | "dark"): string | undefined => {
|
||||
return theme === "light" ? TemperatureHumidityDarkIcon : TemperatureHumidityLightIcon;
|
||||
}
|
||||
};
|
||||
}
|
||||
84
src/pbHelpers/ComponentTypes/Plenum.ts
Normal file
84
src/pbHelpers/ComponentTypes/Plenum.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import TemperatureHumidityDarkIcon from "assets/components/temperatureHumidityDark.png";
|
||||
import TemperatureHumidityLightIcon from "assets/components/temperatureHumidityLight.png";
|
||||
import { convertedUnitMeasurement } from "models/UnitMeasurement";
|
||||
import {
|
||||
AreaChartData,
|
||||
ComponentTypeExtension,
|
||||
GraphFilters,
|
||||
LineChartData,
|
||||
simpleAreaChartData,
|
||||
simpleLineChartData,
|
||||
simpleMeasurements,
|
||||
simpleSummaries,
|
||||
Summary,
|
||||
unitMeasurementSummaries
|
||||
} from "pbHelpers/ComponentType";
|
||||
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { quack } from "protobuf-ts/quack";
|
||||
|
||||
export function Plenum(subtype: number = 0): ComponentTypeExtension {
|
||||
let temperature = describeMeasurement(
|
||||
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
|
||||
quack.ComponentType.COMPONENT_TYPE_PLENUM,
|
||||
subtype
|
||||
);
|
||||
let humidity = describeMeasurement(
|
||||
quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
|
||||
quack.ComponentType.COMPONENT_TYPE_PLENUM,
|
||||
subtype
|
||||
);
|
||||
let pressure = describeMeasurement(
|
||||
quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE,
|
||||
quack.ComponentType.COMPONENT_TYPE_PLENUM,
|
||||
subtype
|
||||
);
|
||||
return {
|
||||
type: quack.ComponentType.COMPONENT_TYPE_PLENUM,
|
||||
subtypes: [],
|
||||
friendlyName: "Plenum",
|
||||
description: "Measures temperature, humidity and pressure",
|
||||
isController: false,
|
||||
isSource: true,
|
||||
isCalibratable: true,
|
||||
addressTypes: [quack.AddressType.ADDRESS_TYPE_CONFIGURABLE_PIN_ARRAY],
|
||||
interactionResultTypes: [],
|
||||
states: [],
|
||||
measurements: simpleMeasurements(temperature, humidity, pressure),
|
||||
measurementSummary: async function(measurement: quack.Measurement): Promise<Array<Summary>> {
|
||||
return simpleSummaries(measurement, temperature, humidity, pressure);
|
||||
},
|
||||
unitMeasurementSummary: (
|
||||
measurements: convertedUnitMeasurement
|
||||
): Summary[] => {
|
||||
return unitMeasurementSummaries(
|
||||
measurements,
|
||||
quack.ComponentType.COMPONENT_TYPE_PLENUM,
|
||||
subtype
|
||||
);
|
||||
},
|
||||
areaChartData: (
|
||||
measurement: pond.UnitMeasurementsForComponent,
|
||||
smoothingAverages?: number,
|
||||
filters?: GraphFilters
|
||||
): AreaChartData => {
|
||||
return simpleAreaChartData(measurement, smoothingAverages, filters);
|
||||
},
|
||||
lineChartData: (
|
||||
measurement: pond.UnitMeasurementsForComponent,
|
||||
smoothingAverages?: number,
|
||||
filters?: GraphFilters
|
||||
): LineChartData => {
|
||||
return simpleLineChartData(
|
||||
quack.ComponentType.COMPONENT_TYPE_PLENUM,
|
||||
measurement,
|
||||
smoothingAverages,
|
||||
filters
|
||||
);
|
||||
},
|
||||
minMeasurementPeriodMs: 1000,
|
||||
icon: (theme?: "light" | "dark"): string | undefined => {
|
||||
return theme === "light" ? TemperatureHumidityDarkIcon : TemperatureHumidityLightIcon;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -30,3 +30,6 @@ export * from "./VPD";
|
|||
export * from "./Weight";
|
||||
export * from "./CapacitorCable";
|
||||
export * from "./AnalogPressure";
|
||||
export * from "./Plenum";
|
||||
export * from "./AmbientOnewire";
|
||||
export * from "./AmbientI2C";
|
||||
|
|
|
|||
|
|
@ -65,7 +65,8 @@ const DefaultAvailability: DeviceAvailabilityMap = new Map<quack.AddressType, De
|
|||
[quack.ComponentType.COMPONENT_TYPE_AIRFLOW, [0x6c]],
|
||||
[quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE, [0x71]], // the address cables will use when they are plugged into the expander with V2 device only
|
||||
[quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE, [0x6d]],
|
||||
[quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, [0x6e]]
|
||||
[quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, [0x6e]],
|
||||
[quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C, [0x44]]
|
||||
])
|
||||
],
|
||||
[quack.AddressType.ADDRESS_TYPE_DAC, [0, 1]],
|
||||
|
|
|
|||
|
|
@ -253,8 +253,7 @@ export const BindaptV2MonitorAvailability: DeviceAvailabilityMap = new Map<
|
|||
[quack.ComponentType.COMPONENT_TYPE_CO2, [0x61]],
|
||||
[quack.ComponentType.COMPONENT_TYPE_SEN5X, [0x69]],
|
||||
[quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE, [0x71]],
|
||||
[quack.ComponentType.COMPONENT_TYPE_TEMP_HUMIDITY, [0x44]]
|
||||
|
||||
[quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C, [0x44]]
|
||||
])
|
||||
],
|
||||
[quack.AddressType.ADDRESS_TYPE_POWER, [0]],
|
||||
|
|
@ -288,7 +287,7 @@ export const BindaptV2AutomateAvailability: DeviceAvailabilityMap = new Map<
|
|||
[quack.ComponentType.COMPONENT_TYPE_CO2, [0x61]],
|
||||
[quack.ComponentType.COMPONENT_TYPE_SEN5X, [0x69]],
|
||||
[quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE, [0x71]],
|
||||
[quack.ComponentType.COMPONENT_TYPE_TEMP_HUMIDITY, [0x44]]
|
||||
[quack.ComponentType.COMPONENT_TYPE_AMBIENT_I2C, [0x44]]
|
||||
])
|
||||
],
|
||||
[quack.AddressType.ADDRESS_TYPE_POWER, [0]],
|
||||
|
|
|
|||
|
|
@ -48,6 +48,15 @@ function makePaletteColor(name: keyof typeof Colours) {
|
|||
|
||||
export const getTheme = (mode: 'light' | 'dark') =>
|
||||
createTheme({
|
||||
components: {
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'none', // disables MUI's elevation overlay in dark mode
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...baseTheme,
|
||||
palette: {
|
||||
...baseTheme.palette,
|
||||
|
|
@ -57,8 +66,8 @@ export const getTheme = (mode: 'light' | 'dark') =>
|
|||
...(mode === 'dark'
|
||||
? {
|
||||
background: {
|
||||
default: '#121212',
|
||||
paper: '#1e1e1e',
|
||||
default: '#070F17', // was #0F1923 — push it darker
|
||||
paper: '#0D1820', // was #1A2530 — slightly darker, less grey/blue
|
||||
},
|
||||
}
|
||||
: {
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ export default function ObjectUsers(props: Props) {
|
|||
userAPI
|
||||
.listObjectUsers(scope)
|
||||
.then((response: any) => {
|
||||
console.log(response)
|
||||
let rUsers: User[] = [];
|
||||
or(response.data, { users: [] }).users.forEach((user: any) => {
|
||||
rUsers.push(User.any(user));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue