changed the orbital camera to allow for an offset without messing with the rotation, added a moisture heatmap, updated the bin3dvisualizer to look cleaner, re-implemented a new version of the dialog box that pops up when clicking a node
This commit is contained in:
parent
619f9b7bf4
commit
f54d4b5375
15 changed files with 1349 additions and 214 deletions
|
|
@ -33,6 +33,11 @@ interface Props {
|
||||||
* larger when initialRadius is large.
|
* larger when initialRadius is large.
|
||||||
*/
|
*/
|
||||||
maxRadius?: number;
|
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.
|
* Called when the camera is reset to its initial position.
|
||||||
* OrbitCameraControls manages the reset internally — use this to
|
* OrbitCameraControls manages the reset internally — use this to
|
||||||
|
|
@ -48,6 +53,10 @@ export default function OrbitCameraControls(props: Props) {
|
||||||
clampVerticalRotation,
|
clampVerticalRotation,
|
||||||
minPhi = 0.01,
|
minPhi = 0.01,
|
||||||
maxPhi = Math.PI - 0.01,
|
maxPhi = Math.PI - 0.01,
|
||||||
|
offset,
|
||||||
|
viewOffset,
|
||||||
|
initialRadius,
|
||||||
|
maxRadius,
|
||||||
onReset,
|
onReset,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
|
@ -55,9 +64,9 @@ export default function OrbitCameraControls(props: Props) {
|
||||||
|
|
||||||
const mouse = useRef(new Vector2());
|
const mouse = useRef(new Vector2());
|
||||||
const targetRef = useRef({
|
const targetRef = useRef({
|
||||||
x: target?.x ?? 0,
|
x: (target?.x ?? 0) + (offset?.x ?? 0),
|
||||||
y: target?.y ?? 0,
|
y: (target?.y ?? 0) + (offset?.y ?? 0),
|
||||||
z: target?.z ?? 0,
|
z: (target?.z ?? 0) + (offset?.z ?? 0),
|
||||||
});
|
});
|
||||||
|
|
||||||
const isDragging = useRef(false);
|
const isDragging = useRef(false);
|
||||||
|
|
@ -67,7 +76,7 @@ export default function OrbitCameraControls(props: Props) {
|
||||||
|
|
||||||
// Spherical coords
|
// Spherical coords
|
||||||
const spherical = useRef({
|
const spherical = useRef({
|
||||||
radius: props.initialRadius ?? 10,
|
radius: initialRadius ?? 10,
|
||||||
theta: 0, // horizontal angle
|
theta: 0, // horizontal angle
|
||||||
phi: Math.PI / 2, // vertical angle
|
phi: Math.PI / 2, // vertical angle
|
||||||
});
|
});
|
||||||
|
|
@ -78,11 +87,11 @@ export default function OrbitCameraControls(props: Props) {
|
||||||
|
|
||||||
const updateCamera = () => {
|
const updateCamera = () => {
|
||||||
const { radius, theta, phi } = spherical.current;
|
const { radius, theta, phi } = spherical.current;
|
||||||
|
|
||||||
const x = radius * Math.sin(phi) * Math.sin(theta);
|
const x = radius * Math.sin(phi) * Math.sin(theta);
|
||||||
const y = radius * Math.cos(phi);
|
const y = radius * Math.cos(phi);
|
||||||
const z = radius * Math.sin(phi) * Math.cos(theta);
|
const z = radius * Math.sin(phi) * Math.cos(theta);
|
||||||
|
|
||||||
camera.position.set(
|
camera.position.set(
|
||||||
targetRef.current.x + x,
|
targetRef.current.x + x,
|
||||||
targetRef.current.y + y,
|
targetRef.current.y + y,
|
||||||
|
|
@ -93,19 +102,23 @@ export default function OrbitCameraControls(props: Props) {
|
||||||
targetRef.current.y,
|
targetRef.current.y,
|
||||||
targetRef.current.z
|
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
|
// Hand the reset function to the caller so they can trigger it
|
||||||
// (e.g. from a button in CameraOverlay) without needing an external ref.
|
// (e.g. from a button in CameraOverlay) without needing an external ref.
|
||||||
if (onReset) {
|
if (onReset) {
|
||||||
onReset(() => {
|
onReset(() => {
|
||||||
spherical.current.radius = props.initialRadius ?? 10;
|
spherical.current.radius = initialRadius ?? 10;
|
||||||
spherical.current.theta = 0;
|
spherical.current.theta = 0;
|
||||||
spherical.current.phi = Math.PI / 2;
|
spherical.current.phi = Math.PI / 2;
|
||||||
targetRef.current = {
|
targetRef.current = {
|
||||||
x: target?.x ?? 0,
|
x: (target?.x ?? 0) + (offset?.x ?? 0),
|
||||||
y: target?.y ?? 0,
|
y: (target?.y ?? 0) + (offset?.y ?? 0),
|
||||||
z: target?.z ?? 0,
|
z: (target?.z ?? 0) + (offset?.z ?? 0),
|
||||||
};
|
};
|
||||||
updateCamera();
|
updateCamera();
|
||||||
});
|
});
|
||||||
|
|
@ -200,7 +213,7 @@ export default function OrbitCameraControls(props: Props) {
|
||||||
spherical.current.radius += e.deltaY * 0.01;
|
spherical.current.radius += e.deltaY * 0.01;
|
||||||
spherical.current.radius = Math.max(
|
spherical.current.radius = Math.max(
|
||||||
3,
|
3,
|
||||||
Math.min(props.maxRadius ?? 30, spherical.current.radius)
|
Math.min(maxRadius ?? 30, spherical.current.radius)
|
||||||
);
|
);
|
||||||
|
|
||||||
updateCamera();
|
updateCamera();
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ export default function BaseMesh(props: BaseMeshProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={position} rotation={rotation}>
|
<group position={position} rotation={rotation} renderOrder={renderOrder}>
|
||||||
{/* Main surface */}
|
{/* Main surface */}
|
||||||
<mesh geometry={geometry} onClick={onClick} renderOrder={renderOrder}>
|
<mesh geometry={geometry} onClick={onClick} renderOrder={renderOrder}>
|
||||||
{buildMaterial()}
|
{buildMaterial()}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Bin } from "models";
|
import { Bin } from "models";
|
||||||
|
import { GrainCable } from "models/GrainCable";
|
||||||
import { pond } from "protobuf-ts/pond";
|
import { pond } from "protobuf-ts/pond";
|
||||||
import { Vector3 } from "three";
|
import { Vector3 } from "three";
|
||||||
import { avg } from "utils";
|
import { avg } from "utils";
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,18 @@ import GrainCableFill from "../Systems/Inventory/GrainCableFill";
|
||||||
import TempHeatMap from "../Systems/Heatmap/TempHeatMap";
|
import TempHeatMap from "../Systems/Heatmap/TempHeatMap";
|
||||||
import NodePointCloud from "../Systems/Heatmap/NodePointCloud";
|
import NodePointCloud from "../Systems/Heatmap/NodePointCloud";
|
||||||
import { Box } from "@mui/material";
|
import { Box } from "@mui/material";
|
||||||
|
import MoistureHeatMap from "../Systems/Heatmap/MoistureHeatmap";
|
||||||
|
// import { GrainCable } from "models/GrainCable";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bin: Bin
|
bin: Bin
|
||||||
scale: number
|
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[]
|
||||||
fillPercent?: number
|
fillPercent?: number
|
||||||
nodeClick?: (node: NodeData, cable: CableData) => void
|
nodeClick?: (node: NodeData, cable: CableData) => void
|
||||||
showGrain?: boolean
|
showGrain?: boolean
|
||||||
|
|
@ -31,10 +39,11 @@ interface Props {
|
||||||
showResetButton?: boolean
|
showResetButton?: boolean
|
||||||
showTemp?: boolean
|
showTemp?: boolean
|
||||||
showMoisture?: boolean
|
showMoisture?: boolean
|
||||||
|
xOffset?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Bin3dView(props: Props) {
|
export default function Bin3dView(props: Props) {
|
||||||
const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture } = props
|
const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture, xOffset } = props
|
||||||
|
|
||||||
// Compute the initial camera radius so the full bin fits in frame.
|
// Compute the initial camera radius so the full bin fits in frame.
|
||||||
// Uses the perspective camera FOV (default 75°) with a padding factor.
|
// Uses the perspective camera FOV (default 75°) with a padding factor.
|
||||||
|
|
@ -105,7 +114,14 @@ export default function Bin3dView(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Canvas>
|
<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]}>
|
<group scale={[1 / scale, 1 / scale, 1 / scale]}>
|
||||||
<BinShell
|
<BinShell
|
||||||
binBodyColour="#fff"
|
binBodyColour="#fff"
|
||||||
|
|
@ -131,11 +147,12 @@ export default function Bin3dView(props: Props) {
|
||||||
onNodeClick={(node, cable) => {
|
onNodeClick={(node, cable) => {
|
||||||
if (nodeClick) nodeClick(node, cable)
|
if (nodeClick) nodeClick(node, cable)
|
||||||
}}
|
}}
|
||||||
renderOrder={1}
|
renderOrder={5}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showHotspots && <NodePointCloud bin={bin} nodes={nodeData} />}
|
{showHotspots && <NodePointCloud bin={bin} nodes={nodeData} />}
|
||||||
|
|
||||||
|
{/* note that the heatmaps insternally use render order 1,2, and 3 for the three seperate coloured meshes */}
|
||||||
{showTempHeatmap && (
|
{showTempHeatmap && (
|
||||||
<TempHeatMap
|
<TempHeatMap
|
||||||
bin={bin}
|
bin={bin}
|
||||||
|
|
@ -143,6 +160,13 @@ export default function Bin3dView(props: Props) {
|
||||||
flatMaxY={flatMaxY}
|
flatMaxY={flatMaxY}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showMoistureHeatmap && (
|
||||||
|
<MoistureHeatMap
|
||||||
|
bin={bin}
|
||||||
|
nodes={nodeData}
|
||||||
|
flatMaxY={flatMaxY}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
|
|
@ -151,16 +175,7 @@ export default function Bin3dView(props: Props) {
|
||||||
<directionalLight intensity={0.2} color={"white"} position={[0, 0, -5]} />
|
<directionalLight intensity={0.2} color={"white"} position={[0, 0, -5]} />
|
||||||
<directionalLight intensity={0.2} color={"white"} position={[5, 0, 0]} />
|
<directionalLight intensity={0.2} color={"white"} position={[5, 0, 0]} />
|
||||||
<directionalLight intensity={0.2} color={"white"} position={[-5, 0, 0]} />
|
<directionalLight intensity={0.2} color={"white"} position={[-5, 0, 0]} />
|
||||||
<OrbitCameraControls
|
|
||||||
clampVerticalRotation
|
|
||||||
initialRadius={initialCameraRadius}
|
|
||||||
maxRadius={initialCameraRadius * 2}
|
|
||||||
onReset={fn => { resetCameraFn.current = fn; }}
|
|
||||||
/>
|
|
||||||
{/* <CameraOverlay
|
|
||||||
showResetButton={showResetButton}
|
|
||||||
onReset={() => resetCameraFn.current?.()}
|
|
||||||
/> */}
|
|
||||||
</Canvas>
|
</Canvas>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -146,10 +146,12 @@ export default function CableNode(props: Props) {
|
||||||
colour={nodeColour()}
|
colour={nodeColour()}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
renderOrder={renderOrder}
|
renderOrder={renderOrder}
|
||||||
|
depthTest={false}
|
||||||
|
depthWrite={false}
|
||||||
|
opacity={0.99}
|
||||||
/>
|
/>
|
||||||
{node.topNode && (
|
{node.topNode && (
|
||||||
<Ring geometry={topNodeGeo} position={topNodePosition} rotation={topNodeRotation} renderOrder={renderOrder}/>
|
<Ring geometry={topNodeGeo} position={topNodePosition} rotation={topNodeRotation} renderOrder={renderOrder} opacity={0.99} depthTest={false} depthWrite={false}/>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
{node.inGrain && showLabel &&
|
{node.inGrain && showLabel &&
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export default function GrainCable(props: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Cylinder geometry={geometry} position={calculateCenter()} opacity={0.5} renderOrder={renderOrder} depthWrite={false} depthTest={false}/>
|
<Cylinder geometry={geometry} position={calculateCenter()} opacity={0.99} renderOrder={renderOrder} depthWrite={false} depthTest={false}/>
|
||||||
{nodes.map((node, i) => (
|
{nodes.map((node, i) => (
|
||||||
<CableNode displayTemp={displayTemp} displayMoisture={displayMoisture} onNodeClick={onNodeClick} key={"node " + i} renderOrder={renderOrder} node={node} targetMoisture={bin.targetMoisture()} upperThreshold={bin.upperTempThreshold()}/>
|
<CableNode displayTemp={displayTemp} displayMoisture={displayMoisture} onNodeClick={onNodeClick} key={"node " + i} renderOrder={renderOrder} node={node} targetMoisture={bin.targetMoisture()} upperThreshold={bin.upperTempThreshold()}/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
446
src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx
Normal file
446
src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx
Normal file
|
|
@ -0,0 +1,446 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { Bin } from "models";
|
||||||
|
import { NodeData } from "../../Data/BuildNodeData";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bin: Bin;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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({ bin, nodes, flatMaxY }: Props) {
|
||||||
|
const binRadius = bin.diameter() / 2;
|
||||||
|
const sidewallHeight = bin.sidewallHeight();
|
||||||
|
const hopperHeight = bin.hopperHeight() ?? 0;
|
||||||
|
const targetMoisture = bin.targetMoisture();
|
||||||
|
|
||||||
|
const sidewallBaseY = -sidewallHeight / 2;
|
||||||
|
const hopperTipY = sidewallBaseY - hopperHeight;
|
||||||
|
|
||||||
|
const maxRadiusAtY = (y: number) => {
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// Outermost ring clamps to wallY — matches GrainCableFill
|
||||||
|
const raw = idwSurfaceY(x, z, surfaceAnchors, SURFACE_IDW_POWER);
|
||||||
|
return Math.max(sidewallBaseY, Math.min(sidewallHeight / 2, 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]);
|
||||||
|
|
||||||
|
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 rawBottomY = hopperHeight > 0 ? hopperTipY : sidewallBaseY;
|
||||||
|
const grainBottomY = hopperHeight > 0
|
||||||
|
? hopperTipY + (maxGrainY - hopperTipY) / HEIGHT_STEPS
|
||||||
|
: sidewallBaseY;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hopper tip
|
||||||
|
const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer;
|
||||||
|
if (hopperHeight > 0) {
|
||||||
|
const tipMoisture = idwMoisture(0, rawBottomY, 0, moistureAnchors, IDW_POWER);
|
||||||
|
const tipHeat = moistureToHeat(tipMoisture, targetMoisture);
|
||||||
|
const [tr, tg, tb] = heatToRGB(tipHeat);
|
||||||
|
|
||||||
|
positions[tipVertexIndex * 3] = 0;
|
||||||
|
positions[tipVertexIndex * 3 + 1] = rawBottomY;
|
||||||
|
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,
|
||||||
|
hopperTipY,
|
||||||
|
sidewallBaseY,
|
||||||
|
binRadius,
|
||||||
|
targetMoisture,
|
||||||
|
hopperHeight,
|
||||||
|
flatMaxY,
|
||||||
|
]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -154,19 +154,23 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) {
|
||||||
[nodes]
|
[nodes]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Surface anchors — top nodes only, Y offset by half spacing
|
// Surface anchors — top nodes only, Y offset by half spacing.
|
||||||
// Matches GrainCableFill's anchor computation exactly.
|
// Only used when flatMaxY is NOT provided (i.e. Automatic / Hybrid_Cable
|
||||||
const surfaceAnchors = useMemo<SurfaceAnchor[]>(
|
// 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.
|
||||||
nodes
|
const surfaceAnchors = useMemo<SurfaceAnchor[]>(() => {
|
||||||
.filter(n => n.topNode && n.inGrain && !n.excluded)
|
if (flatMaxY !== undefined) {
|
||||||
.map(n => ({
|
// Non-cable inventory: force flat surface — ignore cable top nodes
|
||||||
x: n.position.x,
|
return [];
|
||||||
z: n.position.z,
|
}
|
||||||
y: n.position.y + n.nodeSpacing * 0.5,
|
return nodes
|
||||||
})),
|
.filter(n => n.topNode && n.inGrain && !n.excluded)
|
||||||
[nodes]
|
.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.
|
// Wall clamp Y — average of surface anchor heights.
|
||||||
// Prevents the surface from piling up at the bin wall where there are no cables.
|
// Prevents the surface from piling up at the bin wall where there are no cables.
|
||||||
|
|
@ -440,3 +444,4 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) {
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,67 @@
|
||||||
import { Box, Card, Grid2 } from "@mui/material";
|
import { Box, Card, Grid2, LinearProgress, Typography } from "@mui/material";
|
||||||
import { useMobile } from "hooks";
|
import { useMobile } from "hooks";
|
||||||
import { Bin } from "models";
|
import { Bin, Component, Device } from "models";
|
||||||
import Bin3dVisualizer from "./components/bin3dVisualizer";
|
import Bin3dVisualizer from "./components/bin3dVisualizer";
|
||||||
import BinTableView from "./components/binTableView";
|
import BinTableView from "./components/binTableView";
|
||||||
|
// 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 { useEffect, useState } from "react";
|
||||||
|
import { Controller } from "models/Controller";
|
||||||
|
import { GrainCable } from "models/GrainCable";
|
||||||
|
import { pond } from "protobuf-ts/pond";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bin: Bin
|
bin: Bin
|
||||||
|
devices: Device[]
|
||||||
|
cables?: GrainCable[]
|
||||||
|
fans?: Controller[]
|
||||||
|
heaters?: Controller[]
|
||||||
|
permissions: pond.Permission[]
|
||||||
|
componentDevices: Map<string, number>
|
||||||
|
componentMap: Map<string, Component>
|
||||||
}
|
}
|
||||||
export default function BinSummary(props: Props){
|
export default function BinSummary(props: Props){
|
||||||
const {bin} = props
|
const {bin, devices, fans, heaters, permissions, componentDevices, componentMap} = props
|
||||||
|
const [currentDevice, setCurrentDevice] = useState<Device | undefined>(undefined)
|
||||||
const isMobile = useMobile()
|
const isMobile = useMobile()
|
||||||
//need to decide if the vies shoulw both be displayed or toggled between
|
|
||||||
const bin3dView = () => {
|
|
||||||
return (
|
|
||||||
<Bin3dVisualizer bin={bin}/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableView = () => {
|
useEffect(() => {
|
||||||
return (
|
if(devices.length > 0){
|
||||||
<BinTableView bin={bin}/>
|
setCurrentDevice(devices[0])
|
||||||
)
|
}
|
||||||
}
|
},[devices])
|
||||||
|
|
||||||
|
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")
|
||||||
|
console.log(diff)
|
||||||
|
if(diff > 12){
|
||||||
|
status = "Inactive"
|
||||||
|
colour = red[700]
|
||||||
|
}else if (diff > 6){
|
||||||
|
status = "Missing"
|
||||||
|
colour = orange[300]
|
||||||
|
}
|
||||||
|
|
||||||
const inventoryOverView = () => {
|
|
||||||
return (
|
return (
|
||||||
<Box>inventory overview</Box>
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,20 +81,86 @@ export default function BinSummary(props: Props){
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box padding={2}>
|
<Box padding={2}>
|
||||||
|
<Card raised sx={{marginBottom: 2}}>
|
||||||
|
<Grid2 container justifyContent={"space-between"} sx={{padding:2}}>
|
||||||
|
<Grid2>
|
||||||
|
<Typography variant="caption" sx={{ color: grey[500] }}>
|
||||||
|
Grain Type
|
||||||
|
</Typography>
|
||||||
|
<Box display="flex" alignItems="center">
|
||||||
|
<Spa sx={{ color: "#4caf50", fontSize: 18 }} />
|
||||||
|
{/* <GrassIcon sx={{ color: "#4caf50", fontSize: 18 }} /> */}
|
||||||
|
<Typography variant="body1">{bin.grainName()}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid2>
|
||||||
|
<Grid2 sx={{ flexGrow: 1, maxWidth: 400 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: grey[500] }}>
|
||||||
|
Bin Fill
|
||||||
|
</Typography>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<Typography variant="body2" noWrap>
|
||||||
|
{bin.binFillCap()}
|
||||||
|
</Typography>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={bin.fillPercent()} // adjust to your data
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
"& .MuiLinearProgress-bar": {
|
||||||
|
backgroundColor: "#4caf50",
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Grid2>
|
||||||
|
<Grid2>
|
||||||
|
<Typography variant="caption" sx={{ color: grey[500] }}>
|
||||||
|
Last Updated
|
||||||
|
</Typography>
|
||||||
|
{/* this would be to show the last time the device checked in */}
|
||||||
|
{/* {currentDevice ?
|
||||||
|
<Box display="flex" alignItems="center" gap={2}>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<AccessTime sx={{ fontSize: 16, color: grey[400] }} />
|
||||||
|
<Typography variant="body2" sx={{ color: grey[300] }}>
|
||||||
|
{moment(currentDevice.status.lastActive).fromNow()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{activity(currentDevice.status.lastActive)}
|
||||||
|
</Box>
|
||||||
|
:
|
||||||
|
<Box display="flex" alignItems="center">
|
||||||
|
<Typography variant="body2" sx={{ color: "grey.300" }}>
|
||||||
|
No Connected Devices Found
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
} */}
|
||||||
|
{/* this shows the last time the bins status updated */}
|
||||||
|
<Box display="flex" alignItems="center" gap={2}>
|
||||||
|
<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>
|
||||||
|
</Grid2>
|
||||||
|
</Grid2>
|
||||||
|
</Card>
|
||||||
<Grid2 container spacing={2}>
|
<Grid2 container spacing={2}>
|
||||||
<Grid2 size={isMobile ? 12 : 8}>
|
<Grid2 size={isMobile ? 12 : 7}>
|
||||||
<Card raised>
|
<Card raised>
|
||||||
{bin3dView()}
|
<Bin3dVisualizer bin={bin} fans={fans} heaters={heaters} permissions={permissions} devices={devices} componentDevices={componentDevices} componentMap={componentMap}/>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid2>
|
</Grid2>
|
||||||
<Grid2 size={isMobile ? 12 : 4}>
|
<Grid2 size={isMobile ? 12 : 5}>
|
||||||
<Card raised sx={{height: "100%"}}>
|
<Card raised sx={{height: "100%"}}>
|
||||||
{tableView()}
|
<BinTableView bin={bin}/>
|
||||||
</Card>
|
|
||||||
</Grid2>
|
|
||||||
<Grid2 size={12}>
|
|
||||||
<Card>
|
|
||||||
{inventoryOverView()}
|
|
||||||
</Card>
|
</Card>
|
||||||
</Grid2>
|
</Grid2>
|
||||||
<Grid2 size={12}>
|
<Grid2 size={12}>
|
||||||
|
|
|
||||||
|
|
@ -1,130 +1,288 @@
|
||||||
import { Box, Checkbox, FormControlLabel, List, ListItem } from "@mui/material";
|
import { Box, Checkbox, FormControl, MenuItem, List, ListItem, InputLabel, Select, Typography, Stack } from "@mui/material";
|
||||||
import Bin3dView from "bin/3dView/Scene/Bin3dView";
|
import Bin3dView from "bin/3dView/Scene/Bin3dView";
|
||||||
import { Bin } from "models";
|
import { Bin, Component, Device } from "models";
|
||||||
import React from "react";
|
import { Controller } from "models/Controller";
|
||||||
|
import { pond, quack } from "protobuf-ts/pond";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import NodeControls from "./nodeControls";
|
||||||
|
import { NodeData } from "bin/3dView/Data/BuildNodeData";
|
||||||
|
import { CableData } from "bin/3dView/Data/BuildCableData";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bin: Bin
|
bin: Bin
|
||||||
|
// cables?: GrainCable[]
|
||||||
|
devices: Device[]
|
||||||
|
fans?: Controller[]
|
||||||
|
heaters?: Controller[]
|
||||||
|
permissions: pond.Permission[]
|
||||||
|
componentDevices: Map<string, number>
|
||||||
|
componentMap: Map<string, Component>
|
||||||
}
|
}
|
||||||
export default function bin3dVisualizer(props: Props){
|
export default function bin3dVisualizer(props: Props){
|
||||||
const {bin} = props
|
const {bin, fans, heaters, permissions, devices, componentDevices, componentMap} = props
|
||||||
const [showHeatmap, setShowHeatmap] = useState(true)
|
const [showHeatmap, setShowHeatmap] = useState(true)
|
||||||
const [showHotspots, setShowHotspots] = useState(false)
|
const [showHotspots, setShowHotspots] = useState(false)
|
||||||
const [showFill, setShowFill] = useState(false)
|
const [showFill, setShowFill] = useState(false)
|
||||||
const [showTemp, setShowTemp] = useState(false)
|
const [showTemp, setShowTemp] = useState(true)
|
||||||
const [showMoisture, setShowMoisture] = useState(false)
|
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 [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 getFill = () => {
|
useEffect(()=>{
|
||||||
//calculate the fill percent of the bin when using manual/lidar for inventory
|
let newMap: Map<number, Device> = new Map()
|
||||||
|
devices.forEach(d => {
|
||||||
|
newMap.set(d.id(), d)
|
||||||
|
})
|
||||||
|
setDevMap(newMap)
|
||||||
|
},[devices])
|
||||||
|
|
||||||
return 0
|
useEffect(()=>{
|
||||||
}
|
setBinMode(bin.settings.mode)
|
||||||
|
},[bin])
|
||||||
|
|
||||||
const controlsOverlay = () => {
|
const binModeControl = () => {
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<Box>
|
||||||
<List>
|
<Typography sx={{fontWeight: 650, fontSize: 20}}>{bin.name()}</Typography>
|
||||||
<ListItem>
|
<FormControl fullWidth>
|
||||||
<FormControlLabel
|
<Select
|
||||||
control={
|
value={binMode}
|
||||||
<Checkbox
|
sx={{
|
||||||
value={showHeatmap}
|
borderRadius: 1,
|
||||||
checked={showHeatmap}
|
bgcolor: 'rgba(255,255,255,0.08)',
|
||||||
onChange={(_, checked) => {
|
'& .MuiSelect-select': {
|
||||||
setShowHeatmap(checked);
|
py: 1.5,
|
||||||
}}
|
},
|
||||||
/>
|
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||||||
}
|
'&:hover .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||||||
label={"Toggle Heatmap"}
|
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||||||
/>
|
}}
|
||||||
</ListItem>
|
onChange={event => {
|
||||||
<ListItem>
|
console.log("change bin mode")
|
||||||
<FormControlLabel
|
setBinMode(event.target.value as pond.BinMode)
|
||||||
control={
|
}}
|
||||||
<Checkbox
|
>
|
||||||
value={showHotspots}
|
<MenuItem value={pond.BinMode.BIN_MODE_STORAGE}>Storage</MenuItem>
|
||||||
checked={showHotspots}
|
<MenuItem value={pond.BinMode.BIN_MODE_DRYING}>Drying</MenuItem>
|
||||||
onChange={(_, checked) => {
|
<MenuItem value={pond.BinMode.BIN_MODE_HYDRATING}>Hydrating</MenuItem>
|
||||||
setShowHotspots(checked);
|
<MenuItem value={pond.BinMode.BIN_MODE_COOLDOWN}>Cooldown</MenuItem>
|
||||||
}}
|
</Select>
|
||||||
/>
|
</FormControl>
|
||||||
}
|
</Box>
|
||||||
label={"Toggle Hotspots"}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
value={showFill}
|
|
||||||
checked={showFill}
|
|
||||||
onChange={(_, checked) => {
|
|
||||||
setShowFill(checked);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={"Toggle Hotspots"}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
value={showTemp}
|
|
||||||
checked={showTemp}
|
|
||||||
onChange={(_, checked) => {
|
|
||||||
setShowTemp(checked);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={"Toggle Temp"}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
value={showMoisture}
|
|
||||||
checked={showMoisture}
|
|
||||||
onChange={(_, checked) => {
|
|
||||||
setShowMoisture(checked);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={"Toggle Moisture"}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</List>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</React.Fragment>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const binViewer = () => {
|
const heatmapDisplay = () => {
|
||||||
return (
|
return (
|
||||||
<Bin3dView
|
<Box>
|
||||||
bin={bin}
|
<Typography sx={{fontWeight: 650, fontSize: 20}}>Heatmap Display</Typography>
|
||||||
scale={100}
|
<FormControl fullWidth>
|
||||||
showTempHeatmap={showHeatmap}
|
{/* <InputLabel>Display</InputLabel> */}
|
||||||
showTemp={showTemp}
|
<Select
|
||||||
showMoisture={showMoisture}
|
value={binDisplay}
|
||||||
showGrain={showFill}
|
sx={{
|
||||||
showHotspots={showHotspots}
|
borderRadius: 1,
|
||||||
/>
|
bgcolor: 'rgba(255,255,255,0.08)',
|
||||||
|
'& .MuiSelect-select': {
|
||||||
|
py: 1.5,
|
||||||
|
},
|
||||||
|
'& .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>
|
||||||
|
</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 = () => {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{fontWeight: 650, fontSize: 20}}>
|
||||||
|
Controllers
|
||||||
|
{fans && 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)',
|
||||||
|
borderRadius: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2">{fan.name()}</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{isOn ? "ON" : "OFF"}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: isOn ? 'success.main' : 'error.main'
|
||||||
|
}} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)})}
|
||||||
|
{heaters && 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)',
|
||||||
|
borderRadius: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2">{heater.name()}</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{isOn ? "ON" : "OFF"}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: isOn ? 'success.main' : 'error.main'
|
||||||
|
}} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)})}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
// loop through controllers displaying each one
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Box position={"relative"} height={750}>
|
{(selectedCable && selectedNode) &&
|
||||||
{binViewer()}
|
<NodeControls
|
||||||
<Box position={"absolute"} top={10} left={10}>
|
bin={bin}
|
||||||
{controlsOverlay()}
|
cable={selectedCable}
|
||||||
|
node={selectedNode}
|
||||||
|
open={openNodeControls}
|
||||||
|
permissions={permissions}
|
||||||
|
device={selectedCableDevice}
|
||||||
|
filteredComponents={filteredComponents}
|
||||||
|
componentMap={componentMap}
|
||||||
|
onClose={() => {
|
||||||
|
setOpenNodeControls(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<Box position={"relative"} height={600}>
|
||||||
|
<Bin3dView
|
||||||
|
bin={bin}
|
||||||
|
// cables={cables}
|
||||||
|
fillPercent={bin.fillPercent()/100}//expects a decimal between 0 and 1
|
||||||
|
nodeClick={(node, cable) => {
|
||||||
|
// console.log(node)
|
||||||
|
// console.log(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(fans){
|
||||||
|
fans.forEach((f) => {
|
||||||
|
if(componentDevices.get(f.key()) === d.id()){
|
||||||
|
filtered.push(f.asComponent())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if(heaters){
|
||||||
|
heaters.forEach((h) => {
|
||||||
|
if(componentDevices.get(h.key()) === d.id()){
|
||||||
|
filtered.push(h.asComponent())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
//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}
|
||||||
|
showMoisture={showMoisture}
|
||||||
|
showGrain={showFill}
|
||||||
|
showHotspots={showHotspots}
|
||||||
|
xOffset={-150}
|
||||||
|
/>
|
||||||
|
<Box position={"absolute"} top={20} left={30} height={"80%"} width={"30%"} padding={2}>
|
||||||
|
<Stack direction={"column"} justifyContent={"space-between"} sx={{height: "100%", width: "100%"}}>
|
||||||
|
{binModeControl()}
|
||||||
|
{heatmapDisplay()}
|
||||||
|
{controllerDisplay()}
|
||||||
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ export default function BinTableView(props: Props) {
|
||||||
|
|
||||||
//determine the temp
|
//determine the temp
|
||||||
if(level.grainTemps.length > 0){
|
if(level.grainTemps.length > 0){
|
||||||
|
console.log(level.grainTemps)
|
||||||
lvlTemp = avg(level.grainTemps)
|
lvlTemp = avg(level.grainTemps)
|
||||||
//if the average temp is 5 degrees above the bins threshold
|
//if the average temp is 5 degrees above the bins threshold
|
||||||
if(lvlTemp > (bin.upperTempThreshold() + 5)){
|
if(lvlTemp > (bin.upperTempThreshold() + 5)){
|
||||||
|
|
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -190,7 +190,12 @@ export class Bin {
|
||||||
public grainName(): string {
|
public grainName(): string {
|
||||||
if (this.grain() !== pond.Grain.GRAIN_INVALID) {
|
if (this.grain() !== pond.Grain.GRAIN_INVALID) {
|
||||||
if (this.grain() === pond.Grain.GRAIN_CUSTOM) {
|
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 {
|
} else {
|
||||||
return GrainDescriber(this.grain()).name;
|
return GrainDescriber(this.grain()).name;
|
||||||
}
|
}
|
||||||
|
|
@ -199,6 +204,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 {
|
public binFillCap(): string {
|
||||||
let fillCap = "";
|
let fillCap = "";
|
||||||
if (this.settings.specs && this.settings.inventory) {
|
if (this.settings.specs && this.settings.inventory) {
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,6 @@ export default function BinV2(){
|
||||||
const [binPresets, setBinPresets] = useState<DevicePreset[]>([]);
|
const [binPresets, setBinPresets] = useState<DevicePreset[]>([]);
|
||||||
const [missedReadings, setMissedReadings] = useState(0);
|
const [missedReadings, setMissedReadings] = useState(0);
|
||||||
|
|
||||||
const [currentSection, setCurrentSection] = useState<"binSum">("binSum")
|
|
||||||
|
|
||||||
//Drawer states
|
//Drawer states
|
||||||
const [noteDrawerOpen, setNoteDrawerOpen] = useState(false)
|
const [noteDrawerOpen, setNoteDrawerOpen] = useState(false)
|
||||||
const [taskDrawerOpen, setTaskDrawerOpen] = useState(false)
|
const [taskDrawerOpen, setTaskDrawerOpen] = useState(false)
|
||||||
|
|
@ -305,7 +303,7 @@ export default function BinV2(){
|
||||||
*/
|
*/
|
||||||
const pageHeader = () => {
|
const pageHeader = () => {
|
||||||
return (
|
return (
|
||||||
<Box marginTop={1} marginBottom={1}>
|
<Box margin={2} marginBottom={0}>
|
||||||
<Grid
|
<Grid
|
||||||
container
|
container
|
||||||
direction="row"
|
direction="row"
|
||||||
|
|
@ -413,45 +411,6 @@ export default function BinV2(){
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
//PAGE SECTIONS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Contains the main summary of your bin as it currently is
|
|
||||||
* has the 3D bin model, a switch to go to the table view
|
|
||||||
* the bin conditions if it is in a mode other than storage (the do nothing mode)
|
|
||||||
* a box underneath to display sensor data, for the plenums and such
|
|
||||||
* as well as the controls for attached controllers
|
|
||||||
*/
|
|
||||||
const binSummarySection = () => {
|
|
||||||
return (
|
|
||||||
<BinSummary bin={bin}/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* this sections is for showing attached bin components and assigning them to bin positions (see old pages bin components)
|
|
||||||
* as well as seeing the graph data for attached sensors
|
|
||||||
*/
|
|
||||||
const sensorsSection = () => {}
|
|
||||||
|
|
||||||
//Inventory
|
|
||||||
/**
|
|
||||||
* this section is for seeing information relating to the bins inventory for its fill level over time and transactions related to inventory of the bin
|
|
||||||
*/
|
|
||||||
const InventorySection = () => {}
|
|
||||||
|
|
||||||
//Analysis
|
|
||||||
/**
|
|
||||||
* this section is for the analytical charts (drying/hydrating, Moisture trending, Grain Water Content)
|
|
||||||
*/
|
|
||||||
const analysisSection = () => {}
|
|
||||||
|
|
||||||
//Interactions
|
|
||||||
/**
|
|
||||||
* this is the section for alerts and controls on the bin, if presets get re-implemented they can go here too
|
|
||||||
*/
|
|
||||||
const interactionsSection = () => {}
|
|
||||||
|
|
||||||
|
|
||||||
//DRAWERS
|
//DRAWERS
|
||||||
const noteDrawer = () => {
|
const noteDrawer = () => {
|
||||||
|
|
@ -473,7 +432,7 @@ export default function BinV2(){
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
{pageHeader()}
|
{pageHeader()}
|
||||||
{currentSection === "binSum" && binSummarySection()}
|
<BinSummary bin={bin} devices={devices} fans={fans} heaters={heaters} permissions={permissions} componentDevices={componentDevices} componentMap={components}/>
|
||||||
{/* render drawers */}
|
{/* render drawers */}
|
||||||
{noteDrawer()}
|
{noteDrawer()}
|
||||||
{taskDrawer()}
|
{taskDrawer()}
|
||||||
|
|
|
||||||
|
|
@ -65,10 +65,20 @@ export function stringToComponentId(componentIDString: string): quack.ComponentI
|
||||||
return componentID;
|
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 type: string = quack.ComponentType[Number(componentIDFragments[0])];
|
||||||
let addressType: string = quack.AddressType[Number(componentIDFragments[1])];
|
let addressType: string = quack.AddressType[Number(componentIDFragments[1])];
|
||||||
let address: number = Number(componentIDFragments[2]);
|
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.type = quack.ComponentType[type as keyof typeof quack.ComponentType];
|
||||||
componentID.addressType = quack.AddressType[addressType as keyof typeof quack.AddressType];
|
componentID.addressType = quack.AddressType[addressType as keyof typeof quack.AddressType];
|
||||||
componentID.address = address;
|
componentID.address = address;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue