some tweaks to the 3d bin and nodes, finished up the table view

This commit is contained in:
csawatzky 2026-05-06 13:08:04 -06:00
parent 325d3ea7a0
commit 619f9b7bf4
9 changed files with 291 additions and 146 deletions

View file

@ -21,7 +21,7 @@ export interface NodeData {
}
export function BuildNodeData(cables: CableData[]): NodeData[] {
const nodeRadius = 20
const nodeRadius = 15
let nodeData: NodeData[] = []
cables.forEach((cable, cableIndex) => {

View file

@ -14,6 +14,7 @@ import { pond } from "protobuf-ts/pond";
import GrainCableFill from "../Systems/Inventory/GrainCableFill";
import TempHeatMap from "../Systems/Heatmap/TempHeatMap";
import NodePointCloud from "../Systems/Heatmap/NodePointCloud";
import { Box } from "@mui/material";
interface Props {
bin: Bin
@ -35,8 +36,6 @@ interface Props {
export default function Bin3dView(props: Props) {
const { bin, scale = 100, fillPercent, nodeClick, showTempHeatmap, showGrain, showHotspots, showResetButton, showMoistureHeatmap, showTemp, showMoisture } = props
const binCenter = useMemo(() => new Vector3(0, 0, 0), []);
// Compute the initial camera radius so the full bin fits in frame.
// Uses the perspective camera FOV (default 75°) with a padding factor.
// Both the diameter and total height are considered so neither is clipped.
@ -129,7 +128,6 @@ export default function Bin3dView(props: Props) {
cableData={cableData}
nodeData={nodeData}
bin={bin}
binCenter={binCenter}
onNodeClick={(node, cable) => {
if (nodeClick) nodeClick(node, cable)
}}

View file

@ -1,15 +1,13 @@
import GrainCable from "./GrainCable";
import { Vector3 } from "three";
import { Bin } from "models";
import React from "react";
import { CableData } from "../../Data/BuildCableData";
import { NodeData } from "../../Data/BuildNodeData";
interface Props {
cableData: CableData[]
nodeData: NodeData[]
bin: Bin
binCenter: Vector3,
cableData: CableData[],
nodeData: NodeData[],
bin: Bin,
renderOrder?: number,
displayTemp?: boolean,
displayMoisture?: boolean,
@ -17,7 +15,7 @@ interface Props {
}
export default function BinCables(props: Props){
const {bin, binCenter, cableData, nodeData, onNodeClick, renderOrder, displayTemp, displayMoisture} = props
const {bin, cableData, nodeData, onNodeClick, renderOrder, displayTemp, displayMoisture} = props
return (
<React.Fragment>
{cableData.map((cable, i) => {
@ -29,7 +27,6 @@ export default function BinCables(props: Props){
cable={cable}
nodes={cableNodes}
bin={bin}
binCenter={binCenter}
displayTemp={displayTemp}
displayMoisture={displayMoisture}
onNodeClick={(node) => {

View file

@ -1,21 +1,18 @@
import Sphere, { SphereGeometry } from "3dModels/Shapes/3D/Sphere"
import { useFrame, useThree } from "@react-three/fiber"
import { Billboard, Text } from "@react-three/drei"
import React, { useMemo, useState } from "react"
import { Text } from "@react-three/drei"
import React, { useMemo } from "react"
import { Euler, Group, Vector3 } from "three"
import { describeMeasurement } from "pbHelpers/MeasurementDescriber"
import { quack } from "protobuf-ts/quack"
import { green, grey, orange, red } from "@mui/material/colors";
import { useGlobalState } from "providers"
import { pond } from "protobuf-ts/pond"
import Ring, { RingGeometry } from "3dModels/Shapes/3D/Ring"
import { NodeData } from "../../Data/BuildNodeData"
import { TempToColour } from "bin/3dView/utils/tempToColour"
interface Props {
node: NodeData
binCenter: Vector3
lowerThreshold: number
upperThreshold: number,
targetMoisture: number,
renderOrder?: number,
displayTemp?: boolean,
displayMoisture?: boolean,
@ -23,33 +20,31 @@ interface Props {
}
export default function CableNode(props: Props) {
const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick, renderOrder, displayTemp, displayMoisture } = props
const { node, upperThreshold, targetMoisture, onNodeClick, renderOrder, displayTemp, displayMoisture } = props
const { camera } = useThree();
const [{ user }] = useGlobalState();
// const [showLabel, setShowLabel] = useState(false);
const labelGroupRef = React.useRef<Group>(null);
const ROW_HEIGHT = 35;
const PADDING = 20;
const ROW_HEIGHT = 45;
const PADDING = 15;
const LABEL_WIDTH = 220;
const flagRef = React.useRef<Group>(null);
//this can be used to control label visibility through the cameras zoom level
// useFrame(() => {
// const distance = camera.position.distanceTo(binCenter)
useFrame(() => {
if (!flagRef.current || !showLabel) return;
// const SHOW_DISTANCE = 15;
// const HIDE_DISTANCE = 16;
// Get camera's right direction in world space
const cameraRight = new Vector3();
camera.getWorldDirection(cameraRight); // forward
const up = new Vector3(0, 1, 0);
cameraRight.crossVectors(cameraRight, up).normalize(); // right = forward × up
// if (!showLabel && distance < SHOW_DISTANCE) {
// setShowLabel(true);
// } else if (showLabel && distance > HIDE_DISTANCE) {
// setShowLabel(false);
// }
const pos = node.position.clone();
pos.addScaledVector(cameraRight, LABEL_WIDTH / 2); // offset right in screen space
pos.addScaledVector(camera.position.clone().sub(node.position).normalize(), 1); // nudge toward camera
// const scale = distance * 0.07;
// if (labelGroupRef.current) {
// labelGroupRef.current.scale.set(scale, scale, 1);
// }
// });
flagRef.current.position.copy(pos);
flagRef.current.quaternion.copy(camera.quaternion);
});
const tempDisplay = useMemo(() => {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
@ -63,7 +58,7 @@ export default function CableNode(props: Props) {
if (node.excluded) {
if (!anyToggleOn) return [];
return [{ text: "Excluded", color: "red" }];
return [{ text: "Excluded", color: grey[700] }];
}
const r: { text: string; color: string }[] = [];
@ -72,7 +67,9 @@ export default function CableNode(props: Props) {
if (displayTemp && node.celcius !== 0) {
r.push({
text: tempDisplay,
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour()
// color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour()
//will be green when below threshold, orange when it goeas above but by less than 5 degrees, and red if more than 5 over
color: node.celcius > upperThreshold ? node.celcius > upperThreshold + 5 ? red[700] : orange[800]: green[800]
});
}
@ -81,21 +78,25 @@ export default function CableNode(props: Props) {
if (node.moisture && node.moisture > 0) {
r.push({
text: `${node.moisture.toFixed(2)}% EMC`,
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour()
});
} else if (node.humidity && node.humidity > 0) {
r.push({
text: `${node.humidity.toFixed(2)}%`,
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour()
// color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour()
color: node.moisture > targetMoisture ? node.moisture > targetMoisture + 0.5 ? red[700] : orange[800]: green[800]
});
}
//apparently we dont want to display humidity at all
// else if (node.humidity && node.humidity > 0) {
// r.push({
// text: `${node.humidity.toFixed(2)}%`,
// // color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour()
// color: "black"
// });
// }
}
const hasData = r.length > 0;
// Only show top node if there's actual data
if (node.topNode && hasData) {
r.unshift({ text: "Top Node", color: "yellow" });
r.unshift({ text: "Top Node", color: grey[700] });
}
return r;
@ -117,12 +118,8 @@ export default function CableNode(props: Props) {
const topNodeRotation = React.useMemo(() => new Euler(-Math.PI / 2, 0, 0), []);
const labelPosition = node.position.clone();
const labelHeight = rows.length * ROW_HEIGHT + PADDING;
const dir = camera.position.clone().sub(node.position).normalize();
labelPosition.add(dir.multiplyScalar(1));
const getRowY = (index: number) => {
const totalHeight = rows.length * ROW_HEIGHT;
return totalHeight / 2 - (index * ROW_HEIGHT) - ROW_HEIGHT / 2;
@ -130,9 +127,7 @@ export default function CableNode(props: Props) {
const nodeColour = () => {
if (node.excluded) return "grey";
if (!node.inGrain) return "white";
const c = TempToColour(node, lowerThreshold, upperThreshold, "node");
return c !== null ? c.getStyle() : "white";
return "white";
};
const handleClick = (e: any) => {
@ -144,7 +139,6 @@ export default function CableNode(props: Props) {
return (
<React.Fragment>
{(!node.inGrain || !showLabel) &&
<React.Fragment>
<Sphere
geometry={geometry}
@ -158,12 +152,10 @@ export default function CableNode(props: Props) {
<Ring geometry={topNodeGeo} position={topNodePosition} rotation={topNodeRotation} renderOrder={renderOrder}/>
)}
</React.Fragment>
}
{node.inGrain && showLabel &&
<Billboard position={labelPosition}>
<group ref={labelGroupRef} renderOrder={999} onClick={handleClick}>
<group ref={flagRef} renderOrder={999} onClick={handleClick}>
<mesh position={[0, 0, -1]}>
<planeGeometry args={[200, labelHeight]} />
<planeGeometry args={[LABEL_WIDTH, labelHeight]} />
<meshBasicMaterial
color="white"
transparent
@ -176,7 +168,8 @@ export default function CableNode(props: Props) {
<Text
key={i}
position={[0, getRowY(i), 0]}
fontSize={30}
fontSize={35}
fontWeight={650}
color={row.color}
anchorX="center"
anchorY="middle"
@ -185,8 +178,7 @@ export default function CableNode(props: Props) {
</Text>
))}
</group>
</Billboard>
}
}
</React.Fragment>
)
}

View file

@ -7,17 +7,16 @@ import { CableData } from "../../Data/BuildCableData"
import { NodeData } from "../../Data/BuildNodeData"
interface Props {
cable: CableData
nodes: NodeData[]
bin: Bin
binCenter: Vector3,
cable: CableData,
nodes: NodeData[],
bin: Bin,
renderOrder?: number,
displayTemp?: boolean,
displayMoisture?: boolean
displayMoisture?: boolean,
onNodeClick?: (node: NodeData) => void
}
export default function GrainCable(props: Props) {
const {cable, nodes, bin, binCenter, onNodeClick, renderOrder, displayTemp, displayMoisture} = props
const {cable, nodes, bin, onNodeClick, renderOrder, displayTemp, displayMoisture} = props
const calculateHeight = () => {
return cable.topPosition.distanceTo(cable.bottomPosition)
@ -44,7 +43,7 @@ export default function GrainCable(props: Props) {
<React.Fragment>
<Cylinder geometry={geometry} position={calculateCenter()} opacity={0.5} renderOrder={renderOrder} depthWrite={false} depthTest={false}/>
{nodes.map((node, i) => (
<CableNode displayTemp={displayTemp} displayMoisture={displayMoisture} onNodeClick={onNodeClick} key={"node " + i} renderOrder={renderOrder} node={node} binCenter={binCenter} lowerThreshold={bin.lowerTempThreshold()} upperThreshold={bin.upperTempThreshold()}/>
<CableNode displayTemp={displayTemp} displayMoisture={displayMoisture} onNodeClick={onNodeClick} key={"node " + i} renderOrder={renderOrder} node={node} targetMoisture={bin.targetMoisture()} upperThreshold={bin.upperTempThreshold()}/>
))}
</React.Fragment>
)

View file

@ -47,12 +47,12 @@ export default function BinSummary(props: Props){
<Box padding={2}>
<Grid2 container spacing={2}>
<Grid2 size={isMobile ? 12 : 8}>
<Card>
<Card raised>
{bin3dView()}
</Card>
</Grid2>
<Grid2 size={isMobile ? 12 : 4}>
<Card>
<Card raised sx={{height: "100%"}}>
{tableView()}
</Card>
</Grid2>

View file

@ -1,6 +1,7 @@
import { Box, Checkbox, Grid2, FormControlLabel } from "@mui/material";
import { Box, Checkbox, FormControlLabel, List, ListItem } from "@mui/material";
import Bin3dView from "bin/3dView/Scene/Bin3dView";
import { Bin } from "models";
import React from "react";
import { useState } from "react";
interface Props {
@ -20,11 +21,11 @@ export default function bin3dVisualizer(props: Props){
return 0
}
const controlsOverlay = () => {
return (
<Box>
<Grid2 container spacing={1}>
<Grid2 size={3}>
<React.Fragment>
<List>
<ListItem>
<FormControlLabel
control={
<Checkbox
@ -37,6 +38,8 @@ export default function bin3dVisualizer(props: Props){
}
label={"Toggle Heatmap"}
/>
</ListItem>
<ListItem>
<FormControlLabel
control={
<Checkbox
@ -49,6 +52,8 @@ export default function bin3dVisualizer(props: Props){
}
label={"Toggle Hotspots"}
/>
</ListItem>
<ListItem>
<FormControlLabel
control={
<Checkbox
@ -61,6 +66,8 @@ export default function bin3dVisualizer(props: Props){
}
label={"Toggle Hotspots"}
/>
</ListItem>
<ListItem>
<FormControlLabel
control={
<Checkbox
@ -73,6 +80,9 @@ export default function bin3dVisualizer(props: Props){
}
label={"Toggle Temp"}
/>
</ListItem>
<ListItem>
<FormControlLabel
control={
<Checkbox
@ -85,9 +95,18 @@ export default function bin3dVisualizer(props: Props){
}
label={"Toggle Moisture"}
/>
</Grid2>
<Grid2 size={9}>
<Box height={750}>
</ListItem>
</List>
</React.Fragment>
)
}
const binViewer = () => {
return (
<Bin3dView
bin={bin}
scale={100}
@ -97,9 +116,17 @@ export default function bin3dVisualizer(props: Props){
showGrain={showFill}
showHotspots={showHotspots}
/>
)
}
return (
<React.Fragment>
<Box position={"relative"} height={750}>
{binViewer()}
<Box position={"absolute"} top={10} left={10}>
{controlsOverlay()}
</Box>
</Grid2>
</Grid2>
</Box>
</React.Fragment>
)
}

View file

@ -1,37 +1,165 @@
import { Box } from "@mui/material";
import { Box, Table, TableBody, TableCell, TableHead, TableRow } from "@mui/material";
import { green, grey, orange, red } from "@mui/material/colors";
import { Bin } from "models";
import { pond } from "protobuf-ts/pond";
import { useGlobalState } from "providers";
import React from "react";
import { useMemo } from "react";
import { avg, celsiusToFahrenheit } from "utils";
interface Props {
bin: Bin
}
interface LevelAvg {
temp: number
moisture: number
inGrain: boolean
interface BinLevel {
grainTemps: number[]
airTemps: number[]
grainMoistures: number[]
airMoistures: number[]
}
export default function BinTableView(props: Props) {
const {bin} = props
const [{user}] = useGlobalState()
const cableLevelAverages = useMemo(() => {
const binLevels = useMemo(() => {
const cables = bin.status.grainCables
let levels: LevelAvg[] = []
console.log(cables)
let levels: BinLevel[] = []
//note for future self, the first item in the measurement arrays is the lowest node on the cable
//the zones we are creating here will match with the node number because most cables will either have the same number of nodes or only have a difference of 1 or 2
//example: cable 1 has 5 nodes cable 2 has 7 nodes, the first five nodes of the cables will match up and average together for each zone
//and then zones 6 and 7 will be made using only cable 2 because cable 1 doesn't have them
cables.forEach(cable => {
cable.celcius.forEach((c, index) => {
//remember index will be 0 based but the top node starts at 1 for the lowest cable
let inGrain = index < cable.topNode
if (levels[index]){
if(cable.excludedNodes.includes(index+1)) return
if(inGrain){
levels[index].grainTemps.push(c)
//need to check that the cable not only has the moisture mutation on it, but also that it reads humidity
//cables that dont read humidity return an array of 0 values so if the average of all of the values is 0 then it is a temp only cable
if(cable.moisture.length > 0 && avg(cable.moisture) > 0){
//based on all the other checks and the fact that the indexes between the array should match this should never be 0 but put a guard here just in case
//also note that we are only doing moisture and not humidity, it seems like users dont care about humidity
levels[index].grainMoistures.push(cable.moisture[index] ?? 0)
}
}else{
levels[index].airTemps.push(c)
//need to check that the cable not only has the moisture mutation on it, but also that it reads humidity
//cables that dont read humidity return an array of 0 values so if the average of all of the values is 0 then it is a temp only cable
if(cable.moisture.length > 0 && avg(cable.moisture) > 0){
//based on all the other checks and the fact that the indexes between the array should match this should never be 0 but put a guard here just in case
levels[index].airMoistures.push(cable.moisture[index] ?? 0)
}
}
}else{
let newlevel: BinLevel = {
grainTemps: [],
grainMoistures: [],
airTemps: [],
airMoistures: []
}
if(!cable.excludedNodes.includes(index)){
if(inGrain){
newlevel.grainTemps.push(c)
if(cable.moisture.length > 0 && avg(cable.moisture) > 0){
newlevel.grainMoistures.push(cable.moisture[index] ?? 0)
}
}else{
newlevel.airTemps.push(c)
if(cable.moisture.length > 0 && avg(cable.moisture) > 0){
newlevel.airMoistures.push(cable.moisture[index] ?? 0)
}
}
}
levels.push(newlevel)
}
})
})
//flip the levels so that the highest nodes are on the top
levels.reverse()
return levels
},[bin])
const levelDisplay = (level: BinLevel) => {
let tempDisplay = "--"
let moistureDisplay = "--"
let lvlTemp
let lvlMoisture
let tempColour: string = grey[600]
let moistureColour: string = grey[600]
//determine the temp
if(level.grainTemps.length > 0){
lvlTemp = avg(level.grainTemps)
//if the average temp is 5 degrees above the bins threshold
if(lvlTemp > (bin.upperTempThreshold() + 5)){
tempColour = red[700]
}else if(lvlTemp > bin.upperTempThreshold()){
//if the average temp is greater than 5 degrees over
tempColour = orange[600]
}else {
tempColour = green[300]
}
}else if (level.airTemps.length > 0){
lvlTemp = avg(level.airTemps)
}
if (lvlTemp){
if(user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){
lvlTemp = celsiusToFahrenheit(lvlTemp)
}
tempDisplay = lvlTemp.toFixed(2) + (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? " F" : " C")
}
//determine the moisture to show
if(level.grainMoistures.length > 0){
lvlMoisture = avg(level.grainMoistures)
if(lvlMoisture > (bin.targetMoisture() + 1.5)){
moistureColour = red[700]
}else if(lvlMoisture > bin.targetMoisture()){
//if the average temp is greater than 5 degrees over
moistureColour = orange[600]
}else {
moistureColour = green[300]
}
}else if (level.airMoistures.length > 0){
lvlMoisture = avg(level.airMoistures)
}
if(lvlMoisture){
moistureDisplay = lvlMoisture.toFixed(2) + "%"
}
return (
<Box>Table View</Box>
<React.Fragment>
<TableCell sx={{color: "black", fontWeight: 650, fontSize: 17, textAlign: "center", backgroundColor: tempColour}}>{tempDisplay}</TableCell>
<TableCell sx={{color: "black", fontWeight: 650, fontSize: 17, textAlign: "center", backgroundColor: moistureColour}}>{moistureDisplay}</TableCell>
</React.Fragment>
)
}
return (
<Box padding={2}>
<Table>
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 650, fontSize: 20, textAlign: "center"}}>Level</TableCell>
<TableCell sx={{ fontWeight: 650, fontSize: 20, textAlign: "center"}}>Temperature</TableCell>
<TableCell sx={{ fontWeight: 650, fontSize: 20, textAlign: "center"}}>Moisture</TableCell>
</TableRow>
</TableHead>
<TableBody>
{binLevels.map((level, i) => (
<TableRow>
<TableCell sx={{color: "gray", fontWeight: 650, fontSize: 17, textAlign: "center"}}>{binLevels.length - i}</TableCell>
{levelDisplay(level)}
</TableRow>
))}
</TableBody>
</Table>
</Box>
)
}

View file

@ -292,4 +292,8 @@ export class Bin {
public lowerTempThreshold(): number {
return this.settings.lowTemp
}
public targetMoisture(): number {
return this.settings.inventory?.targetMoisture ?? 0
}
}