From f54d4b53758b466242f19657ef2b970373b2edec Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 13 May 2026 14:32:52 -0600 Subject: [PATCH] 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 --- .../CameraControls/OrbitCameraControls.tsx | 35 +- src/3dModels/Shapes/BaseMesh.tsx | 2 +- src/bin/3dView/Data/BuildCableData.ts | 1 + src/bin/3dView/Scene/Bin3dView.tsx | 41 +- src/bin/3dView/Systems/Cables/CableNode.tsx | 6 +- src/bin/3dView/Systems/Cables/GrainCable.tsx | 2 +- .../Systems/Heatmap/MoistureHeatmap.tsx | 446 ++++++++++++++++++ .../3dView/Systems/Heatmap/TempHeatMap.tsx | 31 +- src/bin/binSummary/BinSummary.tsx | 152 +++++- .../binSummary/components/bin3dVisualizer.tsx | 364 ++++++++++---- .../binSummary/components/binTableView.tsx | 1 + .../binSummary/components/nodeControls.tsx | 406 ++++++++++++++++ src/models/Bin.ts | 19 +- src/pages/BinV2.tsx | 45 +- src/pbHelpers/Component.ts | 12 +- 15 files changed, 1349 insertions(+), 214 deletions(-) create mode 100644 src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx create mode 100644 src/bin/binSummary/components/nodeControls.tsx diff --git a/src/3dModels/CameraControls/OrbitCameraControls.tsx b/src/3dModels/CameraControls/OrbitCameraControls.tsx index d145caa..a64fc80 100644 --- a/src/3dModels/CameraControls/OrbitCameraControls.tsx +++ b/src/3dModels/CameraControls/OrbitCameraControls.tsx @@ -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 }); @@ -78,11 +87,11 @@ export default function OrbitCameraControls(props: Props) { const updateCamera = () => { const { radius, theta, phi } = spherical.current; - + const x = radius * Math.sin(phi) * Math.sin(theta); const y = radius * Math.cos(phi); const z = radius * Math.sin(phi) * Math.cos(theta); - + camera.position.set( targetRef.current.x + x, targetRef.current.y + y, @@ -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(); diff --git a/src/3dModels/Shapes/BaseMesh.tsx b/src/3dModels/Shapes/BaseMesh.tsx index 73ea434..e468c0c 100644 --- a/src/3dModels/Shapes/BaseMesh.tsx +++ b/src/3dModels/Shapes/BaseMesh.tsx @@ -79,7 +79,7 @@ export default function BaseMesh(props: BaseMeshProps) { }; return ( - + {/* Main surface */} {buildMaterial()} diff --git a/src/bin/3dView/Data/BuildCableData.ts b/src/bin/3dView/Data/BuildCableData.ts index f9493a0..3b2c673 100644 --- a/src/bin/3dView/Data/BuildCableData.ts +++ b/src/bin/3dView/Data/BuildCableData.ts @@ -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"; diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index cc2ae26..551e98e 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -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 ( - + + { resetCameraFn.current = fn; }} + viewOffset={xOffset} + /> { if (nodeClick) nodeClick(node, cable) }} - renderOrder={1} + renderOrder={5} /> {showHotspots && } + {/* note that the heatmaps insternally use render order 1,2, and 3 for the three seperate coloured meshes */} {showTempHeatmap && ( )} + {showMoistureHeatmap && ( + + )} @@ -151,16 +175,7 @@ export default function Bin3dView(props: Props) { - { resetCameraFn.current = fn; }} - /> - {/* resetCameraFn.current?.()} - /> */} + ) } diff --git a/src/bin/3dView/Systems/Cables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx index ecaaf18..08fa571 100644 --- a/src/bin/3dView/Systems/Cables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -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 && ( - + )} {node.inGrain && showLabel && diff --git a/src/bin/3dView/Systems/Cables/GrainCable.tsx b/src/bin/3dView/Systems/Cables/GrainCable.tsx index 7c3aa3b..62ca26f 100644 --- a/src/bin/3dView/Systems/Cables/GrainCable.tsx +++ b/src/bin/3dView/Systems/Cables/GrainCable.tsx @@ -41,7 +41,7 @@ export default function GrainCable(props: Props) { return ( - + {nodes.map((node, i) => ( ))} diff --git a/src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx b/src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx new file mode 100644 index 0000000..290afce --- /dev/null +++ b/src/bin/3dView/Systems/Heatmap/MoistureHeatmap.tsx @@ -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( + () => + 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(() => { + 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 ( + + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx index ee6e46c..8e70a79 100644 --- a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx +++ b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx @@ -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( - () => - 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] - ); + // 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(() => { + 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. @@ -440,3 +444,4 @@ export default function TempHeatMap({ bin, nodes, flatMaxY }: Props) { ); } + diff --git a/src/bin/binSummary/BinSummary.tsx b/src/bin/binSummary/BinSummary.tsx index 737f125..9621d8b 100644 --- a/src/bin/binSummary/BinSummary.tsx +++ b/src/bin/binSummary/BinSummary.tsx @@ -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 + componentMap: Map } export default function BinSummary(props: Props){ - const {bin} = props + const {bin, devices, fans, heaters, permissions, componentDevices, componentMap} = props + const [currentDevice, setCurrentDevice] = useState(undefined) const isMobile = useMobile() - //need to decide if the vies shoulw both be displayed or toggled between - const bin3dView = () => { - return ( - - ) - } - const tableView = () => { - return ( - - ) - } + 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 inventoryOverView = () => { return ( - inventory overview + + + + {status} + + ) } @@ -45,20 +81,86 @@ export default function BinSummary(props: Props){ return ( + + + + + Grain Type + + + + {/* */} + {bin.grainName()} + + + + + Bin Fill + + + + {bin.binFillCap()} + + + + + + + Last Updated + + {/* this would be to show the last time the device checked in */} + {/* {currentDevice ? + + + + + {moment(currentDevice.status.lastActive).fromNow()} + + + {activity(currentDevice.status.lastActive)} + + : + + + No Connected Devices Found + + + } */} + {/* this shows the last time the bins status updated */} + + + + + {moment(bin.status.timestamp).fromNow()} + + + {activity(bin.status.timestamp)} + + + + - + - {bin3dView()} + - + - {tableView()} - - - - - {inventoryOverView()} + diff --git a/src/bin/binSummary/components/bin3dVisualizer.tsx b/src/bin/binSummary/components/bin3dVisualizer.tsx index 5da0a3e..295374c 100644 --- a/src/bin/binSummary/components/bin3dVisualizer.tsx +++ b/src/bin/binSummary/components/bin3dVisualizer.tsx @@ -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 + componentMap: Map } 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("temp") + const [binMode, setBinMode] = useState(pond.BinMode.BIN_MODE_NONE) + const [selectedCable, setSelectedCable] = useState(undefined) + const [selectedNode, setSelectedNode] = useState(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([]) //components that are filtered to only include ones on the same device + const [openNodeControls, setOpenNodeControls] = useState(false) + const [devMap, setDevMap] = useState>(new Map()) - const getFill = () => { - //calculate the fill percent of the bin when using manual/lidar for inventory + useEffect(()=>{ + let newMap: Map = 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 ( - - - - { - setShowHeatmap(checked); - }} - /> - } - label={"Toggle Heatmap"} - /> - - - { - setShowHotspots(checked); - }} - /> - } - label={"Toggle Hotspots"} - /> - - - { - setShowFill(checked); - }} - /> - } - label={"Toggle Hotspots"} - /> - - - { - setShowTemp(checked); - }} - /> - } - label={"Toggle Temp"} - /> - - - - { - setShowMoisture(checked); - }} - /> - } - label={"Toggle Moisture"} - /> - - - - - - - + + {bin.name()} + + + + ) } - const binViewer = () => { + const heatmapDisplay = () => { return ( - + + Heatmap Display + + {/* Display */} + + + + ) + } + + 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 ( + + + Controllers + {fans && fans.map(fan => { + let isOn = controllerState(fan) + return ( + + {fan.name()} + + + {isOn ? "ON" : "OFF"} + + + + + )})} + {heaters && heaters.map(heater => { + let isOn = controllerState(heater) + return ( + + {heater.name()} + + + {isOn ? "ON" : "OFF"} + + + + + )})} + + + // loop through controllers displaying each one + ) } return ( - - {binViewer()} - - {controlsOverlay()} + {(selectedCable && selectedNode) && + { + setOpenNodeControls(false) + }} + /> + } + + { + // 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} + /> + + + {binModeControl()} + {heatmapDisplay()} + {controllerDisplay()} + diff --git a/src/bin/binSummary/components/binTableView.tsx b/src/bin/binSummary/components/binTableView.tsx index daa76ff..94721dd 100644 --- a/src/bin/binSummary/components/binTableView.tsx +++ b/src/bin/binSummary/components/binTableView.tsx @@ -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)){ diff --git a/src/bin/binSummary/components/nodeControls.tsx b/src/bin/binSummary/components/nodeControls.tsx new file mode 100644 index 0000000..2a8966a --- /dev/null +++ b/src/bin/binSummary/components/nodeControls.tsx @@ -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 + 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.create()) + const [cableInteractions, setCableInteractions] = useState([]) + 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 ( + + Latest Reading + {moment(cable.grainCable.lastRead).fromNow()} + + + + + + + {displayTemp()} + + + + Temperature + + + + + + + + + {node.humidity} % + + + + Humidity + + + + + {/* only show the emc if the bin grain type and the cable grain type match */} + {node.moisture ? ( + + + + + {node.moisture} % + + + + Grain EMC + + + ) : ( + + { + setEMC(); + }}> + + + + + Set EMC + + + + )} + + + + goToComponent()}> + + + + + See Graphs + + + + + + + ); + }; + + const nodeControl = () => { + return ( + + Node Control + { + updateNodeExclusion(e.target.checked); + }} + name="excludedNode" + /> + } + label={ + + + Exclude Node + + + If checked this will set the node to be disabled and will not be used for any calculations done for the bin + + + } + /> + { + updateTopNode(e.target.checked); + }} + name="topNode" + /> + } + label={ + + + Set as top node in grain + + + If checked this will set interactions to ignore all nodes above it. Interactions can + still be adjusted manually to use all nodes + + + } + /> + + ); + }; + + const interactionDisplay = () => { + return ( + + Interactions + + { + interactionAPI + .listInteractionsByComponent(device.id(), cableComponent.location(), undefined, as) + .then(resp => { + setCableInteractions(resp); + }); + }} + /> + + + { + setNewInteraction(true); + }} + className={classes.greenButton}> + + + + + ); + }; + + return ( + + + + + {cable.grainCable.name} + + + + + Node {node.nodeIndex+1} + + + + + + {latestReading()} + {nodeControl()} + {interactionDisplay()} + {/* + */} + + + + + + + { + setNewInteraction(false); + }} + refreshCallback={() => { + interactionAPI.listInteractionsByComponent(device.id(), cableComponent.location(), undefined, as).then(resp => { + setCableInteractions(resp); + }); + }} + canEdit={canWrite(permissions)} + /> + + ) +} \ No newline at end of file diff --git a/src/models/Bin.ts b/src/models/Bin.ts index 6aa2389..1041d86 100644 --- a/src/models/Bin.ts +++ b/src/models/Bin.ts @@ -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) { diff --git a/src/pages/BinV2.tsx b/src/pages/BinV2.tsx index 39691e6..98b0a3c 100644 --- a/src/pages/BinV2.tsx +++ b/src/pages/BinV2.tsx @@ -83,8 +83,6 @@ export default function BinV2(){ const [binPresets, setBinPresets] = useState([]); 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 ( - + { - return ( - - ) - } - - /** - * 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 ( {pageHeader()} - {currentSection === "binSum" && binSummarySection()} + {/* render drawers */} {noteDrawer()} {taskDrawer()} diff --git a/src/pbHelpers/Component.ts b/src/pbHelpers/Component.ts index e809649..b357813 100644 --- a/src/pbHelpers/Component.ts +++ b/src/pbHelpers/Component.ts @@ -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;