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:
csawatzky 2026-05-13 14:32:52 -06:00
parent 619f9b7bf4
commit f54d4b5375
15 changed files with 1349 additions and 214 deletions

View file

@ -33,6 +33,11 @@ interface Props {
* 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
@ -48,6 +53,10 @@ export default function OrbitCameraControls(props: Props) {
clampVerticalRotation,
minPhi = 0.01,
maxPhi = Math.PI - 0.01,
offset,
viewOffset,
initialRadius,
maxRadius,
onReset,
} = props;
@ -55,9 +64,9 @@ export default function OrbitCameraControls(props: Props) {
const mouse = useRef(new Vector2());
const targetRef = useRef({
x: target?.x ?? 0,
y: target?.y ?? 0,
z: target?.z ?? 0,
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);
@ -67,7 +76,7 @@ export default function OrbitCameraControls(props: Props) {
// Spherical coords
const spherical = useRef({
radius: props.initialRadius ?? 10,
radius: initialRadius ?? 10,
theta: 0, // horizontal angle
phi: Math.PI / 2, // vertical angle
});
@ -93,19 +102,23 @@ export default function OrbitCameraControls(props: Props) {
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 = props.initialRadius ?? 10;
spherical.current.radius = initialRadius ?? 10;
spherical.current.theta = 0;
spherical.current.phi = Math.PI / 2;
targetRef.current = {
x: target?.x ?? 0,
y: target?.y ?? 0,
z: target?.z ?? 0,
x: (target?.x ?? 0) + (offset?.x ?? 0),
y: (target?.y ?? 0) + (offset?.y ?? 0),
z: (target?.z ?? 0) + (offset?.z ?? 0),
};
updateCamera();
});
@ -200,7 +213,7 @@ export default function OrbitCameraControls(props: Props) {
spherical.current.radius += e.deltaY * 0.01;
spherical.current.radius = Math.max(
3,
Math.min(props.maxRadius ?? 30, spherical.current.radius)
Math.min(maxRadius ?? 30, spherical.current.radius)
);
updateCamera();

View file

@ -79,7 +79,7 @@ export default function BaseMesh(props: BaseMeshProps) {
};
return (
<group position={position} rotation={rotation}>
<group position={position} rotation={rotation} renderOrder={renderOrder}>
{/* Main surface */}
<mesh geometry={geometry} onClick={onClick} renderOrder={renderOrder}>
{buildMaterial()}

View file

@ -1,4 +1,5 @@
import { Bin } from "models";
import { GrainCable } from "models/GrainCable";
import { pond } from "protobuf-ts/pond";
import { Vector3 } from "three";
import { avg } from "utils";

View file

@ -15,10 +15,18 @@ 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 { GrainCable } from "models/GrainCable";
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[]
fillPercent?: number
nodeClick?: (node: NodeData, cable: CableData) => void
showGrain?: boolean
@ -31,10 +39,11 @@ interface Props {
showResetButton?: boolean
showTemp?: boolean
showMoisture?: boolean
xOffset?: number
}
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.
// Uses the perspective camera FOV (default 75°) with a padding factor.
@ -105,7 +114,14 @@ export default function Bin3dView(props: Props) {
}
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]}>
<BinShell
binBodyColour="#fff"
@ -131,11 +147,12 @@ export default function Bin3dView(props: Props) {
onNodeClick={(node, cable) => {
if (nodeClick) nodeClick(node, cable)
}}
renderOrder={1}
renderOrder={5}
/>
{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 && (
<TempHeatMap
bin={bin}
@ -143,6 +160,13 @@ export default function Bin3dView(props: Props) {
flatMaxY={flatMaxY}
/>
)}
{showMoistureHeatmap && (
<MoistureHeatMap
bin={bin}
nodes={nodeData}
flatMaxY={flatMaxY}
/>
)}
</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={[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>
)
}

View file

@ -146,10 +146,12 @@ export default function CableNode(props: Props) {
colour={nodeColour()}
onClick={handleClick}
renderOrder={renderOrder}
depthTest={false}
depthWrite={false}
opacity={0.99}
/>
{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>
{node.inGrain && showLabel &&

View file

@ -41,7 +41,7 @@ export default function GrainCable(props: Props) {
return (
<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) => (
<CableNode displayTemp={displayTemp} displayMoisture={displayMoisture} onNodeClick={onNodeClick} key={"node " + i} renderOrder={renderOrder} node={node} targetMoisture={bin.targetMoisture()} upperThreshold={bin.upperTempThreshold()}/>
))}

View 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>
);
}

View file

@ -154,19 +154,23 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) {
[nodes]
);
// Surface anchors — top nodes only, Y offset by half spacing
// Matches GrainCableFill's anchor computation exactly.
const surfaceAnchors = useMemo<SurfaceAnchor[]>(
() =>
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]
);
}));
}, [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.
@ -440,3 +444,4 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) {
</React.Fragment>
);
}

View file

@ -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 { Bin } from "models";
import { Bin, Component, Device } from "models";
import Bin3dVisualizer from "./components/bin3dVisualizer";
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 {
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){
const {bin} = props
const {bin, devices, fans, heaters, permissions, componentDevices, componentMap} = props
const [currentDevice, setCurrentDevice] = useState<Device | undefined>(undefined)
const isMobile = useMobile()
//need to decide if the vies shoulw both be displayed or toggled between
const bin3dView = () => {
return (
<Bin3dVisualizer bin={bin}/>
)
useEffect(() => {
if(devices.length > 0){
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 tableView = () => {
return (
<BinTableView bin={bin}/>
)
}
const inventoryOverView = () => {
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 (
<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 size={isMobile ? 12 : 8}>
<Grid2 size={isMobile ? 12 : 7}>
<Card raised>
{bin3dView()}
<Bin3dVisualizer bin={bin} fans={fans} heaters={heaters} permissions={permissions} devices={devices} componentDevices={componentDevices} componentMap={componentMap}/>
</Card>
</Grid2>
<Grid2 size={isMobile ? 12 : 4}>
<Grid2 size={isMobile ? 12 : 5}>
<Card raised sx={{height: "100%"}}>
{tableView()}
</Card>
</Grid2>
<Grid2 size={12}>
<Card>
{inventoryOverView()}
<BinTableView bin={bin}/>
</Card>
</Grid2>
<Grid2 size={12}>

View file

@ -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 { Bin } from "models";
import React from "react";
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 "./nodeControls";
import { NodeData } from "bin/3dView/Data/BuildNodeData";
import { CableData } from "bin/3dView/Data/BuildCableData";
interface Props {
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){
const {bin} = props
const {bin, fans, heaters, permissions, devices, componentDevices, componentMap} = props
const [showHeatmap, setShowHeatmap] = useState(true)
const [showHotspots, setShowHotspots] = useState(false)
const [showFill, setShowFill] = useState(false)
const [showTemp, setShowTemp] = 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 [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 = () => {
//calculate the fill percent of the bin when using manual/lidar for inventory
useEffect(()=>{
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 (
<React.Fragment>
<List>
<ListItem>
<FormControlLabel
control={
<Checkbox
value={showHeatmap}
checked={showHeatmap}
onChange={(_, checked) => {
setShowHeatmap(checked);
<Box>
<Typography sx={{fontWeight: 650, fontSize: 20}}>{bin.name()}</Typography>
<FormControl fullWidth>
<Select
value={binMode}
sx={{
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' },
}}
/>
}
label={"Toggle Heatmap"}
/>
</ListItem>
<ListItem>
<FormControlLabel
control={
<Checkbox
value={showHotspots}
checked={showHotspots}
onChange={(_, checked) => {
setShowHotspots(checked);
onChange={event => {
console.log("change bin mode")
setBinMode(event.target.value as pond.BinMode)
}}
/>
}
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>
>
<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 binViewer = () => {
const heatmapDisplay = () => {
return (
<Box>
<Typography sx={{fontWeight: 650, fontSize: 20}}>Heatmap Display</Typography>
<FormControl fullWidth>
{/* <InputLabel>Display</InputLabel> */}
<Select
value={binDisplay}
sx={{
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 (
<React.Fragment>
{(selectedCable && selectedNode) &&
<NodeControls
bin={bin}
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}
/>
)
}
return (
<React.Fragment>
<Box position={"relative"} height={750}>
{binViewer()}
<Box position={"absolute"} top={10} left={10}>
{controlsOverlay()}
<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>
</React.Fragment>

View file

@ -94,6 +94,7 @@ export default function BinTableView(props: Props) {
//determine the temp
if(level.grainTemps.length > 0){
console.log(level.grainTemps)
lvlTemp = avg(level.grainTemps)
//if the average temp is 5 degrees above the bins threshold
if(lvlTemp > (bin.upperTempThreshold() + 5)){

View 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>
)
}

View file

@ -190,7 +190,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;
}
@ -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 {
let fillCap = "";
if (this.settings.specs && this.settings.inventory) {

View file

@ -83,8 +83,6 @@ export default function BinV2(){
const [binPresets, setBinPresets] = useState<DevicePreset[]>([]);
const [missedReadings, setMissedReadings] = useState(0);
const [currentSection, setCurrentSection] = useState<"binSum">("binSum")
//Drawer states
const [noteDrawerOpen, setNoteDrawerOpen] = useState(false)
const [taskDrawerOpen, setTaskDrawerOpen] = useState(false)
@ -305,7 +303,7 @@ export default function BinV2(){
*/
const pageHeader = () => {
return (
<Box marginTop={1} marginBottom={1}>
<Box margin={2} marginBottom={0}>
<Grid
container
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
const noteDrawer = () => {
@ -473,7 +432,7 @@ export default function BinV2(){
return (
<PageContainer>
{pageHeader()}
{currentSection === "binSum" && binSummarySection()}
<BinSummary bin={bin} devices={devices} fans={fans} heaters={heaters} permissions={permissions} componentDevices={componentDevices} componentMap={components}/>
{/* render drawers */}
{noteDrawer()}
{taskDrawer()}

View file

@ -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;